diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 62279e6..69ce763 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,14 +1,15 @@ name: ci -# Work happens on a long-lived feature branch (feature/embedded-swift-mcp); -# CI runs on PRs to main, on pushes to main (the merge), and on release tags. -# The package targets macOS and its only dependency is the MCP Swift SDK, so -# every job runs on macOS runners. +# Work happens on long-lived feature branches (feature/**), which may stack on +# each other rather than always targeting main. CI runs on every PR, on pushes +# to main (the merge) and to feature branches (so stacked branches are +# validated even when their PR base is another feature branch), and on release +# tags. The package targets macOS and its only dependency is the MCP Swift SDK, +# so every job runs on macOS runners. on: pull_request: - branches: [main] push: - branches: [main] + branches: [main, 'feature/**'] tags: ['v*'] permissions: @@ -40,9 +41,15 @@ jobs: - name: Build run: swift build -v - # Unit suite: offline. Args/platform/pins/cache/sha/bundle-extraction. - # The conformance tests skip themselves unless STACKQL_MCP_INTEGRATION - # is set, so they are inert here. + # Build the CloudLens demo app executable explicitly so a GUI-target + # break is attributed clearly (swift build above already covers it). + - name: Build CloudLens demo app + run: swift build -v --product CloudLens + + # Unit suite: offline. Covers the package (args/platform/pins/cache/sha/ + # bundle-extraction) and CloudLensCore (pulse parsing, finding diff, + # agent prompt/response handling). The conformance tests skip themselves + # unless STACKQL_MCP_INTEGRATION is set, so they are inert here. - name: Unit tests run: swift test -v diff --git a/.st b/.st new file mode 100644 index 0000000..22eb3e6 --- /dev/null +++ b/.st @@ -0,0 +1 @@ +PENDING diff --git a/CLAUDE.md b/CLAUDE.md index a87d5db..01251e3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -68,8 +68,9 @@ on a Mac and will not open four consoles. ## Build and test -- Swift 5.10+/Xcode current; SwiftPM for the package, Xcode project for the - app; deps: modelcontextprotocol/swift-sdk only +- Swift 6.1+/Xcode 16.3+ (the MCP swift-sdk is a tools-version 6.1 package, + so 6.1 is the real floor), macOS 13+; SwiftPM for the package, Xcode + project for the app; deps: modelcontextprotocol/swift-sdk only - XCTest: locate/extract/cache unit tests + spawn/handshake/tools-list integration against the github fixture; CI on macos-latest runners - Release engineering doc: signing + notarising the demo app WITH the diff --git a/Package.swift b/Package.swift index 2319505..62c84e7 100644 --- a/Package.swift +++ b/Package.swift @@ -17,7 +17,11 @@ let package = Package( .macOS(.v13) ], products: [ - .library(name: "StackQLMCP", targets: ["StackQLMCP"]) + .library(name: "StackQLMCP", targets: ["StackQLMCP"]), + // CloudLens: the menu bar cloud sentinel demo app. Built as a SwiftPM + // executable so CI can compile it; the signed/notarised .app is + // assembled in the packaging step documented in docs/. + .executable(name: "CloudLens", targets: ["CloudLens"]), ], dependencies: [ .package( @@ -35,6 +39,23 @@ let package = Package( .testTarget( name: "StackQLMCPTests", dependencies: ["StackQLMCP"] - ) + ), + // CloudLensCore holds the testable app logic (pulses, finding diff, + // the Anthropic agent client, Keychain access) with no SwiftUI, so it + // can be unit-tested on CI without a GUI. + .target( + name: "CloudLensCore", + dependencies: ["StackQLMCP"] + ), + // CloudLens is the thin SwiftUI MenuBarExtra shell: @main App, menu + // bar icon state, popover, notifications. + .executableTarget( + name: "CloudLens", + dependencies: ["CloudLensCore", "StackQLMCP"] + ), + .testTarget( + name: "CloudLensCoreTests", + dependencies: ["CloudLensCore"] + ), ] ) diff --git a/Sources/CloudLens/CloudLensApp.swift b/Sources/CloudLens/CloudLensApp.swift new file mode 100644 index 0000000..6f06918 --- /dev/null +++ b/Sources/CloudLens/CloudLensApp.swift @@ -0,0 +1,49 @@ +import SwiftUI +import AppKit +import CloudLensCore + +/// CloudLens: a menu bar cloud sentinel. The menu bar icon reflects overall +/// state (calm / attention / unknown); the popover shows the three pulses; new +/// attention findings fire native notifications that include the SQL behind +/// them. +/// +/// Built as a SwiftPM executable so CI can compile it. The signed, notarised +/// .app that bundles the stackql binary is assembled in the packaging step +/// documented in docs/bundling-and-notarisation.md. +@main +struct CloudLensApp: App { + @NSApplicationDelegateAdaptor(AppDelegate.self) private var delegate + + var body: some Scene { + MenuBarExtra { + PopoverView(vm: delegate.viewModel) + } label: { + // A dedicated observing view so the menu bar glyph updates when the + // view model's @Published state changes. + MenuBarLabel(vm: delegate.viewModel) + } + .menuBarExtraStyle(.window) + } +} + +/// Owns the view model and the unattended schedule. Using an app delegate keeps +/// the sentinel running on its timer whether or not the popover is open, and +/// gives a clean place to hang the launch sequence. +@MainActor +final class AppDelegate: NSObject, NSApplicationDelegate { + let viewModel = SentinelViewModel() + + /// How often the pulse suite runs unattended. + private static let refreshInterval: TimeInterval = 15 * 60 + private var timer: Timer? + + func applicationDidFinishLaunching(_ notification: Notification) { + Notifications.shared.requestAuthorization() + Task { await viewModel.refresh() } + timer = Timer.scheduledTimer( + withTimeInterval: Self.refreshInterval, repeats: true + ) { [viewModel] _ in + Task { @MainActor in await viewModel.refresh() } + } + } +} diff --git a/Sources/CloudLens/MenuBarLabel.swift b/Sources/CloudLens/MenuBarLabel.swift new file mode 100644 index 0000000..773c2ae --- /dev/null +++ b/Sources/CloudLens/MenuBarLabel.swift @@ -0,0 +1,11 @@ +import SwiftUI + +/// The menu bar glyph. Split into its own observing view so SwiftUI re-renders +/// the icon when the view model's @Published state changes. +struct MenuBarLabel: View { + @ObservedObject var vm: SentinelViewModel + + var body: some View { + Image(systemName: vm.stateSymbol) + } +} diff --git a/Sources/CloudLens/Notifications.swift b/Sources/CloudLens/Notifications.swift new file mode 100644 index 0000000..e2ef6e3 --- /dev/null +++ b/Sources/CloudLens/Notifications.swift @@ -0,0 +1,50 @@ +import Foundation +import CloudLensCore +import UserNotifications +import os + +/// Wraps UNUserNotificationCenter to post a native notification per new +/// finding. Per the design, the notification body includes the SQL behind the +/// finding so it is auditable from the notification itself. +@MainActor +final class Notifications { + static let shared = Notifications() + private let log = Logger(subsystem: "io.stackql.cloudlens", category: "notifications") + + /// Ask once for permission. Safe to call on every launch. + func requestAuthorization() { + UNUserNotificationCenter.current().requestAuthorization( + options: [.alert, .sound] + ) { [log] granted, error in + if let error { + log.error("notification auth error: \(error.localizedDescription)") + } else { + log.debug("notification auth granted=\(granted)") + } + } + } + + /// Post one notification per new finding. Only attention-level findings + /// notify; info-level changes update the popover silently. + func post(_ findings: [Finding]) { + let center = UNUserNotificationCenter.current() + for finding in findings where finding.severity == .attention { + let content = UNMutableNotificationContent() + content.title = finding.title + // Body carries the human detail plus the SQL behind the finding. + content.body = "\(finding.detail)\n\nSQL:\n\(finding.sql)" + content.sound = .default + + let request = UNNotificationRequest( + identifier: finding.id, // stable id de-dupes repeat notifications + content: content, + trigger: nil + ) + center.add(request) { [log] error in + if let error { + log.error("post notification failed: \(error.localizedDescription)") + } + } + } + } +} diff --git a/Sources/CloudLens/PopoverView.swift b/Sources/CloudLens/PopoverView.swift new file mode 100644 index 0000000..cd82de2 --- /dev/null +++ b/Sources/CloudLens/PopoverView.swift @@ -0,0 +1,107 @@ +import SwiftUI +import CloudLensCore + +/// The popover content: overall state header, the three pulses grouped, and a +/// refresh control. Each finding shows its title, detail, and the SQL behind +/// it (the same SQL that rides along in the notification). +struct PopoverView: View { + @ObservedObject var vm: SentinelViewModel + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + header + Divider() + if vm.findings.isEmpty { + Text(vm.isRunning ? "Checking..." : "No findings yet.") + .foregroundStyle(.secondary) + } else { + ScrollView { + VStack(alignment: .leading, spacing: 10) { + ForEach(PulseKind.allCases, id: \.self) { kind in + pulseSection(kind) + } + } + } + .frame(maxHeight: 320) + } + Divider() + footer + } + .padding(14) + .frame(width: 360) + } + + private var header: some View { + HStack(spacing: 8) { + Image(systemName: vm.stateSymbol) + .foregroundStyle(color(for: vm.state)) + Text(vm.stateLabel).font(.headline) + Spacer() + if vm.isRunning { ProgressView().controlSize(.small) } + } + } + + private func pulseSection(_ kind: PulseKind) -> some View { + let items = vm.findings.filter { $0.kind == kind } + return Group { + if !items.isEmpty { + VStack(alignment: .leading, spacing: 6) { + Text(title(for: kind)) + .font(.caption).bold() + .foregroundStyle(.secondary) + ForEach(items) { finding in + findingRow(finding) + } + } + } + } + } + + private func findingRow(_ finding: Finding) -> some View { + VStack(alignment: .leading, spacing: 2) { + HStack(alignment: .top, spacing: 6) { + Image(systemName: finding.severity == .attention + ? "exclamationmark.triangle.fill" : "info.circle") + .foregroundStyle(finding.severity == .attention ? .orange : .secondary) + .font(.caption) + VStack(alignment: .leading, spacing: 2) { + Text(finding.title).font(.callout) + Text(finding.detail).font(.caption).foregroundStyle(.secondary) + Text(finding.sql) + .font(.system(.caption2, design: .monospaced)) + .foregroundStyle(.tertiary) + .textSelection(.enabled) + } + } + } + } + + private var footer: some View { + HStack { + if let lastRun = vm.lastRun { + Text("Last checked \(lastRun.formatted(date: .omitted, time: .shortened))") + .font(.caption).foregroundStyle(.secondary) + } + Spacer() + Button("Refresh") { Task { await vm.refresh() } } + .disabled(vm.isRunning) + Button("Quit") { NSApplication.shared.terminate(nil) } + } + } + + private func title(for kind: PulseKind) -> String { + switch kind { + case .spend: return "Spend pulse" + case .exposure: return "Exposure pulse" + case .posture: return "Org posture (github)" + } + } + + private func color(for state: SentinelState) -> Color { + switch state { + case .calm: return .green + case .attention: return .orange + case .unknown: return .secondary + } + } +} diff --git a/Sources/CloudLens/SentinelViewModel.swift b/Sources/CloudLens/SentinelViewModel.swift new file mode 100644 index 0000000..62502f3 --- /dev/null +++ b/Sources/CloudLens/SentinelViewModel.swift @@ -0,0 +1,65 @@ +import Foundation +import Combine +import CloudLensCore +import StackQLMCP + +/// SwiftUI-facing view model. It owns a `SentinelModel` (the framework-free +/// core) and republishes its state as `@Published` properties so the menu bar +/// and popover update. Kept in the app target so the core stays UI-agnostic. +@MainActor +final class SentinelViewModel: ObservableObject { + @Published private(set) var state: SentinelState = .unknown + @Published private(set) var findings: [Finding] = [] + @Published private(set) var lastRun: Date? + @Published private(set) var isRunning = false + + private let model: SentinelModel + + init() { + // Demo configuration: the github org-posture pulse in null_auth mode + // (zero cloud creds) plus the cloud pulses, which degrade gracefully to + // "not configured" until AWS credentials are added. + let pulses: [any Pulse] = [ + PosturePulse(org: "stackql"), + SpendPulse(), + ExposurePulse(), + ] + var options = Options() + options.mode = .readOnly + options.auth = ["github": ["type": "null_auth"]] + + self.model = SentinelModel( + pulses: pulses, + serverOptions: options, + onNewFindings: { fresh in + Notifications.shared.post(fresh) + } + ) + } + + var stateSymbol: String { + switch state { + case .calm: return "checkmark.seal" + case .attention: return "exclamationmark.triangle.fill" + case .unknown: return "questionmark.circle" + } + } + + var stateLabel: String { + switch state { + case .calm: return "All calm" + case .attention: return "Needs attention" + case .unknown: return "Not checked yet" + } + } + + /// Run the pulse suite and copy the model's state across for the views. + func refresh() async { + isRunning = true + await model.runOnce() + state = model.state + findings = model.orderedFindings + lastRun = model.lastRun + isRunning = model.isRunning + } +} diff --git a/Sources/CloudLensCore/AnthropicAgent.swift b/Sources/CloudLensCore/AnthropicAgent.swift new file mode 100644 index 0000000..d35d966 --- /dev/null +++ b/Sources/CloudLensCore/AnthropicAgent.swift @@ -0,0 +1,107 @@ +import Foundation + +/// A thin Anthropic Messages API client over URLSession. CloudLens uses it to +/// turn the raw pulse findings into a short, human-readable summary for the +/// popover. There is no official Anthropic Swift SDK, so this calls the REST +/// endpoint directly. The API key is read from the Keychain, never stored in +/// the app bundle. +public struct AnthropicAgent: Sendable { + public static let endpoint = URL(string: "https://api.anthropic.com/v1/messages")! + public static let apiVersion = "2023-06-01" + /// Default to the latest, most capable Claude model. + public static let defaultModel = "claude-opus-4-8" + + let session: URLSession + let model: String + + public init(session: URLSession = .shared, model: String = AnthropicAgent.defaultModel) { + self.session = session + self.model = model + } + + public enum AgentError: Error, CustomStringConvertible { + case noAPIKey + case http(status: Int, body: String) + case malformedResponse + + public var description: String { + switch self { + case .noAPIKey: + return "Anthropic API key not set - add it in CloudLens settings." + case .http(let status, let body): + return "Anthropic API error \(status): \(body)" + case .malformedResponse: + return "Anthropic API returned an unexpected response shape." + } + } + } + + /// Summarise the findings into one short paragraph for the popover header. + /// `apiKey` is supplied by the caller (read from the Keychain) so this type + /// stays free of storage concerns. + public func summarise(_ findings: [Finding], apiKey: String) async throws -> String { + guard !apiKey.isEmpty else { throw AgentError.noAPIKey } + + let prompt = Self.buildPrompt(findings) + let body: [String: Any] = [ + "model": model, + "max_tokens": 300, + "messages": [ + ["role": "user", "content": prompt] + ], + ] + + var request = URLRequest(url: Self.endpoint) + request.httpMethod = "POST" + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.setValue(apiKey, forHTTPHeaderField: "x-api-key") + request.setValue(Self.apiVersion, forHTTPHeaderField: "anthropic-version") + request.httpBody = try JSONSerialization.data(withJSONObject: body) + + let (data, response) = try await session.data(for: request) + guard let http = response as? HTTPURLResponse else { + throw AgentError.malformedResponse + } + guard (200...299).contains(http.statusCode) else { + throw AgentError.http( + status: http.statusCode, body: String(decoding: data, as: UTF8.self)) + } + return try Self.extractText(from: data) + } + + /// Pull the first text block out of a Messages API response: + /// `{ "content": [ { "type": "text", "text": "..." }, ... ] }`. + static func extractText(from data: Data) throws -> String { + guard + let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let content = obj["content"] as? [[String: Any]] + else { + throw AgentError.malformedResponse + } + let text = content + .filter { ($0["type"] as? String) == "text" } + .compactMap { $0["text"] as? String } + .joined(separator: "\n") + guard !text.isEmpty else { throw AgentError.malformedResponse } + return text + } + + static func buildPrompt(_ findings: [Finding]) -> String { + if findings.isEmpty { + return "Our cloud posture check found nothing notable. In one short, " + + "matter-of-fact sentence, reassure an engineering lead that all is calm." + } + let lines = findings.map { f in + "- [\(f.kind.rawValue)/\(f.severity == .attention ? "attention" : "info")] " + + "\(f.title): \(f.detail)" + }.joined(separator: "\n") + return """ + You are a cloud sentinel for an engineering lead. Summarise these findings \ + in two or three short, matter-of-fact sentences. Lead with anything that \ + needs attention. No hyperbole. + + Findings: + \(lines) + """ + } +} diff --git a/Sources/CloudLensCore/ExposurePulse.swift b/Sources/CloudLensCore/ExposurePulse.swift new file mode 100644 index 0000000..a9cbe38 --- /dev/null +++ b/Sources/CloudLensCore/ExposurePulse.swift @@ -0,0 +1,86 @@ +import Foundation +import StackQLMCP + +/// The exposure pulse: newly public S3 buckets and wide-open security groups. +/// Needs AWS credentials; degrades to a neutral "not configured" error when +/// absent. +public struct ExposurePulse: Pulse { + public let kind: PulseKind = .exposure + + public init() {} + + public func run(_ server: StackQLServer) async -> PulseResult { + let bucketSQL = """ + SELECT bucket_name, acl_public \ + FROM aws.s3.bucket_acls \ + WHERE acl_public = true + """ + let sgSQL = """ + SELECT group_id, cidr_ip, from_port, to_port \ + FROM aws.ec2.security_group_rules \ + WHERE cidr_ip = '0.0.0.0/0' + """ + do { + let buckets = try await server.call("run_select_query", stringArgs: ["query": bucketSQL]) + if buckets.isError { + return PulseResult(kind: kind, findings: [], + error: PulseErrors.classify(buckets.text, provider: "AWS")) + } + let sgs = try await server.call("run_select_query", stringArgs: ["query": sgSQL]) + if sgs.isError { + return PulseResult(kind: kind, findings: [], + error: PulseErrors.classify(sgs.text, provider: "AWS")) + } + var out = bucketFindings(RowParser.rows(from: buckets.text), sql: bucketSQL) + out += sgFindings(RowParser.rows(from: sgs.text), sql: sgSQL) + return PulseResult(kind: kind, findings: out) + } catch { + return PulseResult(kind: kind, findings: [], error: "\(error)") + } + } + + func bucketFindings(_ rows: [[String: Any]], sql: String) -> [Finding] { + rows.compactMap { row in + guard let name = row.string("bucket_name") else { return nil } + return Finding( + kind: kind, + severity: .attention, + title: "Public S3 bucket: \(name)", + detail: "Bucket ACL grants public access.", + sql: sql, + key: "public-bucket:\(name)" + ) + } + } + + func sgFindings(_ rows: [[String: Any]], sql: String) -> [Finding] { + rows.compactMap { row in + guard let gid = row.string("group_id") else { return nil } + let from = row.int("from_port").map(String.init) ?? "?" + let to = row.int("to_port").map(String.init) ?? "?" + return Finding( + kind: kind, + severity: .attention, + title: "Open security group: \(gid) (\(from)-\(to))", + detail: "Security group allows 0.0.0.0/0 ingress on \(from)-\(to).", + sql: sql, + key: "open-sg:\(gid):\(from)-\(to)" + ) + } + } +} + +/// Shared classification of tool errors into user-facing pulse errors. +public enum PulseErrors { + /// Turn a raw server error string into a concise message, recognising the + /// missing-credentials case so the UI can show "not configured" instead + /// of an alarming stack trace. + public static func classify(_ raw: String, provider: String) -> String { + let lower = raw.lowercased() + if lower.contains("auth") || lower.contains("credential") + || lower.contains("token") || lower.contains("unauthorized") { + return "\(provider) not configured - add credentials in Settings." + } + return raw + } +} diff --git a/Sources/CloudLensCore/Finding.swift b/Sources/CloudLensCore/Finding.swift new file mode 100644 index 0000000..ea1b59e --- /dev/null +++ b/Sources/CloudLensCore/Finding.swift @@ -0,0 +1,73 @@ +import Foundation + +/// How loud a finding is. Drives the menu bar icon state and whether a +/// notification fires. +public enum Severity: Int, Sendable, Comparable, Codable { + case info = 0 + case attention = 1 + + public static func < (lhs: Severity, rhs: Severity) -> Bool { + lhs.rawValue < rhs.rawValue + } +} + +/// Which pulse produced a finding. +public enum PulseKind: String, Sendable, Codable, CaseIterable { + case spend + case exposure + case posture // the github org-posture demo pulse (null_auth) +} + +/// One observation from a pulse: a human title, a detail line, the SQL that +/// produced it (surfaced in notifications so the finding is auditable), and a +/// stable identity for day-over-day diffing. +public struct Finding: Sendable, Codable, Identifiable, Equatable { + public let kind: PulseKind + public let severity: Severity + public let title: String + public let detail: String + /// The StackQL behind the finding. Shown in the notification body. + public let sql: String + /// Stable identity used to diff "new since yesterday". Two findings with + /// the same key are the same underlying thing even if their detail (for + /// example a dollar amount) changed. + public let key: String + + public var id: String { "\(kind.rawValue):\(key)" } + + public init( + kind: PulseKind, + severity: Severity, + title: String, + detail: String, + sql: String, + key: String + ) { + self.kind = kind + self.severity = severity + self.title = title + self.detail = detail + self.sql = sql + self.key = key + } +} + +/// The result of running one pulse. +public struct PulseResult: Sendable, Codable, Equatable { + public let kind: PulseKind + public let findings: [Finding] + /// Non-nil when the pulse could not run (missing creds, server error). + /// A failed pulse is surfaced as a neutral state, not a false "all calm". + public let error: String? + + public init(kind: PulseKind, findings: [Finding], error: String? = nil) { + self.kind = kind + self.findings = findings + self.error = error + } + + /// The loudest severity among this pulse's findings, or nil if none. + public var topSeverity: Severity? { + findings.map(\.severity).max() + } +} diff --git a/Sources/CloudLensCore/FindingDiff.swift b/Sources/CloudLensCore/FindingDiff.swift new file mode 100644 index 0000000..73ec395 --- /dev/null +++ b/Sources/CloudLensCore/FindingDiff.swift @@ -0,0 +1,51 @@ +import Foundation + +/// The overall state the menu bar icon reflects. +public enum SentinelState: String, Sendable { + /// Nothing notable, or only informational findings. + case calm + /// At least one attention-level finding is present. + case attention + /// No pulse has run yet, or every pulse errored - we genuinely do not + /// know, which must not look like "calm". + case unknown +} + +/// Pure functions over pulse results: deriving the icon state and computing +/// what is new since the previous run. Kept free of UI and IO so it is fully +/// unit-testable. +public enum FindingDiff { + /// Derive the menu bar state from the latest pulse results. attention wins + /// over calm; if there are no findings and at least one pulse succeeded it + /// is calm; if every pulse errored (or there are none) it is unknown. + public static func state(for results: [PulseResult]) -> SentinelState { + if results.isEmpty { return .unknown } + let anySucceeded = results.contains { $0.error == nil } + if !anySucceeded { return .unknown } + let loudest = results.compactMap(\.topSeverity).max() + switch loudest { + case .attention: return .attention + case .info, nil: return .calm + } + } + + /// Findings present in `current` whose id was not present in `previous`. + /// This is the "new since yesterday" set that drives notifications, so an + /// unchanged finding does not re-notify every run. + public static func newFindings( + current: [Finding], + previous: [Finding] + ) -> [Finding] { + let seen = Set(previous.map(\.id)) + return current.filter { !seen.contains($0.id) } + } + + /// Flatten pulse results to their findings, attention first then by title, + /// for stable display in the popover. + public static func ordered(_ results: [PulseResult]) -> [Finding] { + results.flatMap(\.findings).sorted { a, b in + if a.severity != b.severity { return a.severity > b.severity } + return a.title < b.title + } + } +} diff --git a/Sources/CloudLensCore/Keychain.swift b/Sources/CloudLensCore/Keychain.swift new file mode 100644 index 0000000..c0feb9c --- /dev/null +++ b/Sources/CloudLensCore/Keychain.swift @@ -0,0 +1,81 @@ +import Foundation +import Security + +/// A minimal Keychain-backed secret store for the Anthropic API key and +/// cloud provider credentials. Items are generic passwords scoped to the +/// app's service name, so they survive app updates and never touch disk in +/// plaintext. +public struct Keychain: Sendable { + public let service: String + + public init(service: String = "io.stackql.cloudlens") { + self.service = service + } + + public enum KeychainError: Error, CustomStringConvertible { + case unexpectedStatus(OSStatus) + + public var description: String { + switch self { + case .unexpectedStatus(let s): + return "keychain error: \(SecCopyErrorMessageString(s, nil) as String? ?? "\(s)")" + } + } + } + + /// Store or replace a secret for `account`. An empty value deletes it. + public func set(_ value: String, for account: String) throws { + if value.isEmpty { + try remove(account) + return + } + let data = Data(value.utf8) + var query = baseQuery(account: account) + let status = SecItemCopyMatching(query as CFDictionary, nil) + if status == errSecSuccess { + let attrs: [String: Any] = [kSecValueData as String: data] + let upd = SecItemUpdate(query as CFDictionary, attrs as CFDictionary) + guard upd == errSecSuccess else { throw KeychainError.unexpectedStatus(upd) } + } else if status == errSecItemNotFound { + query[kSecValueData as String] = data + let add = SecItemAdd(query as CFDictionary, nil) + guard add == errSecSuccess else { throw KeychainError.unexpectedStatus(add) } + } else { + throw KeychainError.unexpectedStatus(status) + } + } + + /// Fetch a secret, or nil if not set. + public func get(_ account: String) throws -> String? { + var query = baseQuery(account: account) + query[kSecReturnData as String] = true + query[kSecMatchLimit as String] = kSecMatchLimitOne + var item: CFTypeRef? + let status = SecItemCopyMatching(query as CFDictionary, &item) + if status == errSecItemNotFound { return nil } + guard status == errSecSuccess else { throw KeychainError.unexpectedStatus(status) } + guard let data = item as? Data else { return nil } + return String(decoding: data, as: UTF8.self) + } + + /// Delete a secret. Missing item is not an error. + public func remove(_ account: String) throws { + let status = SecItemDelete(baseQuery(account: account) as CFDictionary) + guard status == errSecSuccess || status == errSecItemNotFound else { + throw KeychainError.unexpectedStatus(status) + } + } + + private func baseQuery(account: String) -> [String: Any] { + [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: account, + ] + } +} + +/// Well-known Keychain account names CloudLens uses. +public enum SecretKey { + public static let anthropicAPIKey = "anthropic-api-key" +} diff --git a/Sources/CloudLensCore/PosturePulse.swift b/Sources/CloudLensCore/PosturePulse.swift new file mode 100644 index 0000000..0077286 --- /dev/null +++ b/Sources/CloudLensCore/PosturePulse.swift @@ -0,0 +1,69 @@ +import Foundation +import StackQLMCP + +/// The github org-posture pulse: the demo pulse that runs with zero cloud +/// credentials (github in null_auth mode). It surfaces public-facing posture +/// signals for an org's repositories, standing in for the cloud exposure +/// pulse when no cloud creds are configured. This is what CI exercises. +public struct PosturePulse: Pulse { + public let kind: PulseKind = .posture + public let org: String + + public init(org: String) { + self.org = org + } + + public func run(_ server: StackQLServer) async -> PulseResult { + // Ensure the github provider is available, then count public repos in + // the org. A high public count is informational posture context, not + // an alarm, so these are info-severity findings. + let sql = """ + SELECT name, visibility, archived \ + FROM github.repos.repos \ + WHERE org = '\(org)' + """ + do { + _ = try await server.call("pull_provider", stringArgs: ["provider": "github"]) + let result = try await server.call("run_select_query", stringArgs: ["query": sql]) + if result.isError { + return PulseResult(kind: kind, findings: [], error: result.text) + } + let rows = RowParser.rows(from: result.text) + return PulseResult(kind: kind, findings: findings(from: rows, sql: sql)) + } catch { + return PulseResult(kind: kind, findings: [], error: "\(error)") + } + } + + func findings(from rows: [[String: Any]], sql: String) -> [Finding] { + let publicRepos = rows.filter { ($0.string("visibility") ?? "") == "public" } + let total = rows.count + var out: [Finding] = [] + + out.append(Finding( + kind: kind, + severity: .info, + title: "\(publicRepos.count) public repos in \(org)", + detail: "\(total) repos scanned, \(publicRepos.count) public.", + sql: sql, + key: "public-repo-count" + )) + + // An archived-but-still-public repo is worth a glance: stale code left + // exposed. Flag as attention so the demo shows a non-calm state. + let archivedPublic = publicRepos.filter { ($0.int("archived") ?? 0) == 1 + || ($0.string("archived") ?? "") == "true" } + for repo in archivedPublic { + let name = repo.string("name") ?? "(unknown)" + out.append(Finding( + kind: kind, + severity: .attention, + title: "Archived repo still public: \(name)", + detail: "Archived repositories left public expose stale code.", + sql: sql, + key: "archived-public:\(name)" + )) + } + return out + } +} diff --git a/Sources/CloudLensCore/Pulse.swift b/Sources/CloudLensCore/Pulse.swift new file mode 100644 index 0000000..1624096 --- /dev/null +++ b/Sources/CloudLensCore/Pulse.swift @@ -0,0 +1,61 @@ +import Foundation +import StackQLMCP + +/// A pulse runs one read_only check suite against the embedded StackQL server +/// and turns the rows into findings. Pulses are the unit the runner schedules. +public protocol Pulse: Sendable { + var kind: PulseKind { get } + /// Run the pulse against a connected server. Implementations should run + /// only SELECT/metadata tools so they are safe under read_only. + func run(_ server: StackQLServer) async -> PulseResult +} + +/// Parse a StackQL tool text result into rows. StackQL returns query results +/// as a JSON array of objects; tolerate a leading/trailing log line by +/// extracting the outermost JSON array. +public enum RowParser { + public static func rows(from text: String) -> [[String: Any]] { + guard let json = outermostJSONArray(in: text), + let data = json.data(using: .utf8), + let arr = try? JSONSerialization.jsonObject(with: data) as? [[String: Any]] + else { + return [] + } + return arr + } + + /// Extract the substring from the first '[' to the last ']' inclusive, so + /// surrounding diagnostics do not break parsing. Returns nil if absent. + static func outermostJSONArray(in text: String) -> String? { + guard let start = text.firstIndex(of: "["), + let end = text.lastIndex(of: "]"), + start < end + else { + return nil + } + return String(text[start...end]) + } +} + +/// Helpers for reading typed values out of a parsed row. +extension Dictionary where Key == String, Value == Any { + func string(_ key: String) -> String? { + if let s = self[key] as? String { return s } + if let n = self[key] as? NSNumber { return n.stringValue } + return nil + } + + func double(_ key: String) -> Double? { + if let d = self[key] as? Double { return d } + if let n = self[key] as? NSNumber { return n.doubleValue } + if let s = self[key] as? String { return Double(s) } + return nil + } + + func int(_ key: String) -> Int? { + if let i = self[key] as? Int { return i } + if let n = self[key] as? NSNumber { return n.intValue } + if let s = self[key] as? String { return Int(s) } + return nil + } +} diff --git a/Sources/CloudLensCore/SentinelModel.swift b/Sources/CloudLensCore/SentinelModel.swift new file mode 100644 index 0000000..4999ffc --- /dev/null +++ b/Sources/CloudLensCore/SentinelModel.swift @@ -0,0 +1,86 @@ +import Foundation +import StackQLMCP + +/// The observable model behind CloudLens: it owns the embedded server, runs the +/// pulse suite on demand or on a schedule, derives the menu bar state, and +/// computes what is new since the previous run (which drives notifications). +/// +/// It is a MainActor type so SwiftUI can bind to its published-style state +/// directly; the heavy work (spawning the server, running SQL) happens off the +/// main actor inside the awaited calls. +@MainActor +public final class SentinelModel { + public private(set) var results: [PulseResult] = [] + public private(set) var state: SentinelState = .unknown + public private(set) var lastRun: Date? + public private(set) var isRunning = false + + /// Findings from the previous completed run, used to diff "new since last". + private var previousFindings: [Finding] = [] + + private let pulses: [any Pulse] + private let serverOptions: Options + /// Called with the findings that are new since the previous run, after each + /// run. The app wires this to native notifications. + private let onNewFindings: ([Finding]) -> Void + /// Injectable clock so the model is testable without wall-clock time. + private let now: () -> Date + + public init( + pulses: [any Pulse], + serverOptions: Options = Options(), + now: @escaping () -> Date = Date.init, + onNewFindings: @escaping ([Finding]) -> Void = { _ in } + ) { + self.pulses = pulses + self.serverOptions = serverOptions + self.now = now + self.onNewFindings = onNewFindings + } + + /// The findings to show in the popover, attention first. + public var orderedFindings: [Finding] { + FindingDiff.ordered(results) + } + + /// Run the full pulse suite once: spawn the server, run every pulse, update + /// state, diff against the previous run, and fire the new-findings hook. + /// Re-entrancy is guarded so overlapping ticks don't double-run. + public func runOnce() async { + guard !isRunning else { return } + isRunning = true + defer { isRunning = false } + + let server: StackQLServer + do { + server = try await StackQLServer.start(serverOptions) + } catch { + // Could not even start the server: surface as an errored run for + // every pulse so the UI shows unknown, not a false calm. + results = pulses.map { + PulseResult(kind: $0.kind, findings: [], error: "\(error)") + } + state = FindingDiff.state(for: results) + lastRun = now() + return + } + defer { Task { await server.stop() } } + + var collected: [PulseResult] = [] + for pulse in pulses { + collected.append(await pulse.run(server)) + } + + let current = collected.flatMap(\.findings) + let fresh = FindingDiff.newFindings(current: current, previous: previousFindings) + + results = collected + state = FindingDiff.state(for: collected) + lastRun = now() + previousFindings = current + + if !fresh.isEmpty { + onNewFindings(fresh) + } + } +} diff --git a/Sources/CloudLensCore/SpendPulse.swift b/Sources/CloudLensCore/SpendPulse.swift new file mode 100644 index 0000000..539cfb7 --- /dev/null +++ b/Sources/CloudLensCore/SpendPulse.swift @@ -0,0 +1,66 @@ +import Foundation +import StackQLMCP + +/// The spend pulse: top cost movers from AWS Cost Explorer. Needs AWS +/// credentials; when they are absent the pulse reports a neutral +/// "not configured" error rather than a false all-calm. +public struct SpendPulse: Pulse { + public let kind: PulseKind = .spend + /// Daily spend over this threshold (USD) for a service is an attention + /// finding. + public let alertThresholdUSD: Double + + public init(alertThresholdUSD: Double = 100) { + self.alertThresholdUSD = alertThresholdUSD + } + + public func run(_ server: StackQLServer) async -> PulseResult { + // Top services by unblended cost over the trailing day. Exact + // table/columns follow the aws.cost_explorer provider surface. + let sql = """ + SELECT service, amount \ + FROM aws.cost_explorer.cost_and_usage \ + ORDER BY amount DESC + """ + do { + let result = try await server.call("run_select_query", stringArgs: ["query": sql]) + if result.isError { + return PulseResult(kind: kind, findings: [], + error: PulseErrors.classify(result.text, provider: "AWS")) + } + let rows = RowParser.rows(from: result.text) + return PulseResult(kind: kind, findings: findings(from: rows, sql: sql)) + } catch { + return PulseResult(kind: kind, findings: [], error: "\(error)") + } + } + + func findings(from rows: [[String: Any]], sql: String) -> [Finding] { + var out: [Finding] = [] + // Top mover is always shown as context (info); anything over the + // threshold is attention. + for row in rows.prefix(5) { + guard let service = row.string("service"), + let amount = row.double("amount") else { continue } + let isHot = amount >= alertThresholdUSD + out.append(Finding( + kind: kind, + severity: isHot ? .attention : .info, + title: isHot + ? "High spend: \(service) $\(amount.rounded2())/day" + : "Top spend: \(service) $\(amount.rounded2())/day", + detail: "Daily unblended cost for \(service).", + sql: sql, + key: "spend:\(service)" + )) + } + return out + } +} + +extension Double { + /// Two-decimal string for money display. + func rounded2() -> String { + String(format: "%.2f", self) + } +} diff --git a/Sources/StackQLMCP/StackQLServer+Tools.swift b/Sources/StackQLMCP/StackQLServer+Tools.swift index 0cee7f8..7c05d63 100644 --- a/Sources/StackQLMCP/StackQLServer+Tools.swift +++ b/Sources/StackQLMCP/StackQLServer+Tools.swift @@ -28,6 +28,28 @@ extension StackQLServer { return ToolResult(text: Self.joinText(content), isError: isError ?? false) } + /// Convenience overload for the common all-string-arguments case, so + /// callers can pass runtime `String` values without wrapping each in + /// `Value` (a `[String: String]` literal of runtime strings does not + /// implicitly convert to `[String: Value]`). Example: + /// `try await server.call("run_select_query", stringArgs: ["query": sql])`. + @discardableResult + public func call(_ name: String, stringArgs: [String: String]) async throws -> ToolResult { + let args = arguments(from: stringArgs) + return try await call(name, args) + } + + /// Map a string dictionary to MCP `Value` arguments. Uses the + /// ExpressibleByStringLiteral initializer, which is part of Value's public + /// API, rather than assuming a specific enum case name. + static func valueArgs(_ stringArgs: [String: String]) -> [String: Value] { + stringArgs.mapValues { Value(stringLiteral: $0) } + } + + private func arguments(from stringArgs: [String: String]) -> [String: Value] { + Self.valueArgs(stringArgs) + } + /// Concatenate the text parts of tool content, one per line. static func joinText(_ content: [Tool.Content]) -> String { var lines: [String] = [] diff --git a/Tests/CloudLensCoreTests/AnthropicAgentTests.swift b/Tests/CloudLensCoreTests/AnthropicAgentTests.swift new file mode 100644 index 0000000..f205674 --- /dev/null +++ b/Tests/CloudLensCoreTests/AnthropicAgentTests.swift @@ -0,0 +1,49 @@ +import XCTest +@testable import CloudLensCore + +final class AnthropicAgentTests: XCTestCase { + func testExtractTextJoinsTextBlocks() throws { + let json = """ + {"content":[{"type":"text","text":"line one"}, + {"type":"tool_use","name":"x"}, + {"type":"text","text":"line two"}]} + """ + let text = try AnthropicAgent.extractText(from: Data(json.utf8)) + XCTAssertEqual(text, "line one\nline two") + } + + func testExtractTextThrowsOnNoText() { + let json = #"{"content":[{"type":"tool_use","name":"x"}]}"# + XCTAssertThrowsError(try AnthropicAgent.extractText(from: Data(json.utf8))) + } + + func testExtractTextThrowsOnMalformed() { + XCTAssertThrowsError(try AnthropicAgent.extractText(from: Data("not json".utf8))) + } + + func testBuildPromptListsFindings() { + let findings = [ + Finding(kind: .exposure, severity: .attention, title: "Public bucket", + detail: "ACL public", sql: "SELECT 1", key: "b1") + ] + let prompt = AnthropicAgent.buildPrompt(findings) + XCTAssertTrue(prompt.contains("Public bucket")) + XCTAssertTrue(prompt.contains("attention")) + } + + func testBuildPromptHandlesEmpty() { + let prompt = AnthropicAgent.buildPrompt([]) + XCTAssertTrue(prompt.lowercased().contains("calm")) + } + + func testSummariseThrowsWithoutKey() async { + do { + _ = try await AnthropicAgent().summarise([], apiKey: "") + XCTFail("expected noAPIKey") + } catch let e as AnthropicAgent.AgentError { + guard case .noAPIKey = e else { return XCTFail("wrong error \(e)") } + } catch { + XCTFail("wrong error type \(error)") + } + } +} diff --git a/Tests/CloudLensCoreTests/FindingDiffTests.swift b/Tests/CloudLensCoreTests/FindingDiffTests.swift new file mode 100644 index 0000000..8cffd3f --- /dev/null +++ b/Tests/CloudLensCoreTests/FindingDiffTests.swift @@ -0,0 +1,68 @@ +import XCTest +@testable import CloudLensCore + +final class FindingDiffTests: XCTestCase { + private func finding( + _ kind: PulseKind, _ sev: Severity, key: String, title: String = "t" + ) -> Finding { + Finding(kind: kind, severity: sev, title: title, detail: "d", sql: "SELECT 1", key: key) + } + + func testStateUnknownWhenNoResults() { + XCTAssertEqual(FindingDiff.state(for: []), .unknown) + } + + func testStateUnknownWhenAllErrored() { + let r = [ + PulseResult(kind: .spend, findings: [], error: "no creds"), + PulseResult(kind: .exposure, findings: [], error: "no creds"), + ] + XCTAssertEqual(FindingDiff.state(for: r), .unknown) + } + + func testStateCalmWhenSucceededWithNoAttention() { + let r = [ + PulseResult(kind: .posture, findings: [finding(.posture, .info, key: "a")]), + PulseResult(kind: .spend, findings: [], error: "no creds"), + ] + XCTAssertEqual(FindingDiff.state(for: r), .calm) + } + + func testStateAttentionWins() { + let r = [ + PulseResult(kind: .posture, findings: [ + finding(.posture, .info, key: "a"), + finding(.posture, .attention, key: "b"), + ]), + ] + XCTAssertEqual(FindingDiff.state(for: r), .attention) + } + + func testNewFindingsAreThoseNotInPrevious() { + let previous = [finding(.exposure, .attention, key: "bucket1")] + let current = [ + finding(.exposure, .attention, key: "bucket1"), // unchanged + finding(.exposure, .attention, key: "bucket2"), // new + ] + let fresh = FindingDiff.newFindings(current: current, previous: previous) + XCTAssertEqual(fresh.map(\.key), ["bucket2"]) + } + + func testNewFindingsDistinguishesByKindAndKey() { + // Same key under different pulses are different findings (id includes kind). + let previous = [finding(.spend, .info, key: "x")] + let current = [finding(.exposure, .info, key: "x")] + let fresh = FindingDiff.newFindings(current: current, previous: previous) + XCTAssertEqual(fresh.count, 1) + } + + func testOrderedPutsAttentionFirstThenByTitle() { + let r = [PulseResult(kind: .posture, findings: [ + finding(.posture, .info, key: "a", title: "zebra"), + finding(.posture, .attention, key: "b", title: "yak"), + finding(.posture, .info, key: "c", title: "ant"), + ])] + let ordered = FindingDiff.ordered(r) + XCTAssertEqual(ordered.map(\.title), ["yak", "ant", "zebra"]) + } +} diff --git a/Tests/CloudLensCoreTests/PulseParsingTests.swift b/Tests/CloudLensCoreTests/PulseParsingTests.swift new file mode 100644 index 0000000..8768cf0 --- /dev/null +++ b/Tests/CloudLensCoreTests/PulseParsingTests.swift @@ -0,0 +1,74 @@ +import XCTest +@testable import CloudLensCore + +final class PulseParsingTests: XCTestCase { + func testRowParserExtractsJSONArray() { + let text = "some log line\n[{\"a\":1,\"b\":\"x\"},{\"a\":2,\"b\":\"y\"}]\ntrailing" + let rows = RowParser.rows(from: text) + XCTAssertEqual(rows.count, 2) + XCTAssertEqual(rows[0].int("a"), 1) + XCTAssertEqual(rows[1].string("b"), "y") + } + + func testRowParserReturnsEmptyOnNoArray() { + XCTAssertTrue(RowParser.rows(from: "no json here").isEmpty) + XCTAssertTrue(RowParser.rows(from: "").isEmpty) + } + + func testRowAccessorsCoerceTypes() { + let row: [String: Any] = ["n": "42", "d": "1.5", "s": 7] + XCTAssertEqual(row.int("n"), 42) + XCTAssertEqual(row.double("d"), 1.5) + XCTAssertEqual(row.string("s"), "7") + XCTAssertNil(row.int("missing")) + } + + func testPostureFindingsFlagArchivedPublicRepo() { + let pulse = PosturePulse(org: "acme") + let rows: [[String: Any]] = [ + ["name": "live", "visibility": "public", "archived": 0], + ["name": "old", "visibility": "public", "archived": 1], + ["name": "secret", "visibility": "private", "archived": 0], + ] + let findings = pulse.findings(from: rows, sql: "SELECT ...") + // One summary (info) + one archived-public (attention). + XCTAssertTrue(findings.contains { $0.severity == .attention && $0.title.contains("old") }) + let summary = findings.first { $0.key == "public-repo-count" } + XCTAssertEqual(summary?.severity, .info) + XCTAssertTrue(summary?.title.contains("2 public repos") ?? false) + } + + func testSpendFindingsThresholdSeverity() { + let pulse = SpendPulse(alertThresholdUSD: 100) + let rows: [[String: Any]] = [ + ["service": "EC2", "amount": 250.0], + ["service": "S3", "amount": 12.0], + ] + let findings = pulse.findings(from: rows, sql: "SELECT ...") + let ec2 = findings.first { $0.key == "spend:EC2" } + let s3 = findings.first { $0.key == "spend:S3" } + XCTAssertEqual(ec2?.severity, .attention) + XCTAssertEqual(s3?.severity, .info) + } + + func testExposureFindingsAreAttention() { + let pulse = ExposurePulse() + let buckets: [[String: Any]] = [["bucket_name": "public-bucket"]] + let sgs: [[String: Any]] = [ + ["group_id": "sg-123", "from_port": 0, "to_port": 65535] + ] + let b = pulse.bucketFindings(buckets, sql: "SELECT ...") + let s = pulse.sgFindings(sgs, sql: "SELECT ...") + XCTAssertEqual(b.first?.severity, .attention) + XCTAssertTrue(b.first?.title.contains("public-bucket") ?? false) + XCTAssertEqual(s.first?.severity, .attention) + XCTAssertTrue(s.first?.title.contains("sg-123") ?? false) + } + + func testPulseErrorsClassifyMissingCredentials() { + let auth = PulseErrors.classify("error: missing AWS credentials", provider: "AWS") + XCTAssertTrue(auth.contains("not configured")) + let other = PulseErrors.classify("syntax error near SELECT", provider: "AWS") + XCTAssertEqual(other, "syntax error near SELECT") + } +}