diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..c8475c5 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,43 @@ +name: ci + +on: + pull_request: + push: + branches: + - main + +permissions: + contents: read + +jobs: + ci: + name: ci + runs-on: macos-14 + steps: + - name: Checkout + uses: actions/checkout@v5 + + - name: Select Swift 6 toolchain + run: | + set -euo pipefail + for xcode in \ + /Applications/Xcode_16.2.app \ + /Applications/Xcode_16.1.app \ + /Applications/Xcode_16.0.app \ + /Applications/Xcode.app + do + if [ -d "$xcode" ]; then + sudo xcode-select -s "$xcode" + break + fi + done + swift --version + + - name: Build with warnings as errors + run: swift build -Xswiftc -warnings-as-errors + + - name: Test + run: swift test + + - name: Lint Swift formatting + run: xcrun swift-format lint --strict --recursive Sources Tests Package.swift diff --git a/LICENSING.md b/LICENSING.md new file mode 100644 index 0000000..fd5a67a --- /dev/null +++ b/LICENSING.md @@ -0,0 +1,20 @@ +# Licensing + +agentd is licensed under the Business Source License 1.1. + +The repository-level license is in `LICENSE`, and Swift source files carry: + +```text +SPDX-License-Identifier: BUSL-1.1 +``` + +Plain-language summary: + +- non-production copying, modification, redistribution, and derivative work use + is allowed under BUSL 1.1 +- production use requires a commercial license from EvalOps unless an additional + grant is published +- each version changes to GPL Version 2.0 or any later version on the earlier of + its stated Change Date or the fourth anniversary of first public distribution + +This summary is not a replacement for `LICENSE`; the license text controls. diff --git a/PRIVACY.md b/PRIVACY.md new file mode 100644 index 0000000..a44abaf --- /dev/null +++ b/PRIVACY.md @@ -0,0 +1,107 @@ +# Privacy + +agentd is a macOS menu-bar client that captures screen activity, performs OCR +locally, and submits scrubbed frame metadata to the EvalOps Chronicle pipeline. +This document describes the behavior in this repository as implemented today. + +## What agentd Captures + +For accepted frames, agentd may collect: + +- active display dimensions +- capture timestamp +- app bundle ID and app name +- focused window title +- focused document path or document URL when macOS Accessibility exposes it +- OCR text recognized by Apple Vision +- OCR confidence +- a 64-bit perceptual hash used for deduplication +- a raw-BGRA byte estimate for the frame size + +Raw screen pixels are used in memory for OCR, hashing, and filtering. Raw pixels +are not written to batch JSON and are not PNG-encoded for metadata. + +## What Stays On Device + +agentd performs these steps locally: + +- ScreenCaptureKit capture +- Vision OCR +- perceptual hashing and deduplication +- allowlist, denylist, pause-window, path, and secret checks +- local fallback persistence + +Local fallback batches are stored as `0o600` JSON files under: + +```text +~/.evalops/agentd/batches +``` + +The batch directory is swept on local persistence and after successful remote +submissions. Defaults are: + +- maximum local batch age: 7 days +- maximum local batch size budget: 512 MiB + +Oldest files are removed first when the byte budget is exceeded. + +## What Crosses The Wire + +When `localOnly` is `false`, agentd sends a Connect/proto JSON +`SubmitBatchRequest` to the configured Chronicle endpoint. Remote submission is +refused unless: + +- the endpoint is HTTPS, or +- the endpoint is loopback HTTP such as `localhost`, `127.x.x.x`, or `::1`, or +- the endpoint uses a supported local socket scheme + +Remote submission also requires configured client authentication. Bearer tokens +are resolved from Keychain. They are not stored in `config.json`. mTLS identities +are resolved from Keychain by label and attached through URLSession client +certificate authentication. + +## Drop And Scrub Behavior + +agentd drops a frame before batching when: + +- the active app bundle is denied +- an allowlist is present and the active app is not in it +- the document path matches a denied path prefix +- the window title matches a pause pattern +- the window title, document path, or full OCR text matches a secret pattern +- the perceptual hash is near a recent accepted frame + +Secret matches are fail-closed: agentd drops the whole frame rather than +redacting and shipping partial content. Secret scanning runs against the full OCR +text before any configured OCR text truncation. + +## Inspecting Or Wiping State + +Configuration: + +```text +~/.evalops/agentd/config.json +``` + +Local batches: + +```text +~/.evalops/agentd/batches +``` + +To wipe local batches: + +```sh +rm -f ~/.evalops/agentd/batches/*.json +``` + +To force local-only behavior, set `localOnly` to `true` in `config.json`. + +## Residual Risks + +OCR can miss secrets inside images, screenshots, unusual fonts, or partially +visible text. Window titles and document paths can still contain sensitive +metadata even when OCR is clean, which is why those fields are also checked by +the secret scrubber before batching. Local JSON batches contain OCR-derived +content and should be treated as sensitive until encrypted-at-rest support is +added. diff --git a/Package.swift b/Package.swift index 2135665..b943d40 100644 --- a/Package.swift +++ b/Package.swift @@ -1,29 +1,30 @@ // swift-tools-version: 6.0 +// SPDX-License-Identifier: BUSL-1.1 import PackageDescription let package = Package( - name: "agentd", - platforms: [.macOS(.v14)], - products: [ - .executable(name: "agentd", targets: ["agentd"]) - ], - targets: [ - .executableTarget( - name: "agentd", - path: "Sources/agentd", - linkerSettings: [ - .unsafeFlags([ - "-Xlinker", "-sectcreate", - "-Xlinker", "__TEXT", - "-Xlinker", "__info_plist", - "-Xlinker", "support/Info.plist" - ]) - ] - ), - .testTarget( - name: "agentdTests", - dependencies: ["agentd"], - path: "Tests/agentdTests" - ) - ] + name: "agentd", + platforms: [.macOS(.v14)], + products: [ + .executable(name: "agentd", targets: ["agentd"]) + ], + targets: [ + .executableTarget( + name: "agentd", + path: "Sources/agentd", + linkerSettings: [ + .unsafeFlags([ + "-Xlinker", "-sectcreate", + "-Xlinker", "__TEXT", + "-Xlinker", "__info_plist", + "-Xlinker", "support/Info.plist", + ]) + ] + ), + .testTarget( + name: "agentdTests", + dependencies: ["agentd"], + path: "Tests/agentdTests" + ), + ] ) diff --git a/README.md b/README.md index a232cfd..a8ffe68 100644 --- a/README.md +++ b/README.md @@ -9,21 +9,30 @@ This is the desktop component of the work tracked in ## What it does (v0) -- Captures the active display via `ScreenCaptureKit` at an adaptive 0.2–1 fps. +- Captures the active display via `ScreenCaptureKit` at an adaptive 0.2–1 fps; + input idle time drops cadence to `idleFps` and activity restores + `captureFps`. - Reads `(bundleId, windowTitle, documentPath)` per frame via the Accessibility API and `NSWorkspace`. - Runs Apple Vision OCR on-device. -- Drops near-duplicate frames via 64-bit pHash (Hamming ≤ 5). -- Fail-closed `SecretScrubber` against AWS / GCP / SSH / JWT / GitHub / - Anthropic / OpenAI / Slack / Stripe markers — match → frame dropped, never - partial-redacted. +- Drops near-duplicate frames via a 64-bit pHash ring buffer (Hamming ≤ 5). +- Fail-closed `SecretScrubber` against AWS / GCP / SSH / JWT / GitHub classic + and fine-grained tokens / Google API keys / npm / SendGrid / DigitalOcean / + Azure storage keys / Mailgun / Twilio / Discord / Anthropic / OpenAI / Slack / + Stripe markers — match → frame dropped, never partial-redacted. - Per-app allow/deny list and per-path deny list. - Window-title pause patterns (Zoom, FaceTime, 1Password…). +- Secret scanning covers OCR text, window titles, and document paths before a + frame is batched. +- OCR text is scrubbed at full length, then capped to `maxOcrTextChars` + (default 4096) with `ocrTextTruncated` set when the cap applies. - Batches every 30s or 24 frames, whichever first. - Local-only mode persists batches under `~/.evalops/agentd/batches/` as - `0o600` JSON; HTTP mode `POST`s a Connect/proto JSON `SubmitBatchRequest` - to `chronicle.v1.ChronicleService.SubmitBatch` and falls back to local on - failure. + `0o600` JSON and sweeps old or over-budget batches; HTTP mode `POST`s a + Connect/proto JSON `SubmitBatchRequest` to + `chronicle.v1.ChronicleService.SubmitBatch` and falls back to local on + failure. Remote HTTP is allowed only for loopback development; non-loopback + remote endpoints must use HTTPS and configured client auth. - Menu-bar UI: pause/resume (`⌃⌥⌘P`), flush now (`⌃⌥⌘F`), reveal batches dir, quit. @@ -39,6 +48,45 @@ First run will trigger the system Screen Recording and Accessibility prompts the first time the gated APIs are called. Grant both in System Settings → Privacy & Security and relaunch. +## Configuration + +agentd reads and writes `~/.evalops/agentd/config.json`. Important defaults: + +- `localOnly: true` +- `captureFps: 1.0` +- `idleFps: 0.2` +- `idleThresholdSeconds: 60` +- `batchIntervalSeconds: 30` +- `maxFramesPerBatch: 24` +- `maxOcrTextChars: 4096` +- `maxBatchAgeDays: 7` +- `maxBatchBytes: 536870912` +- `auth: { "mode": "none" }` + +Remote mode requires `localOnly: false`, an HTTPS or loopback endpoint, and an +auth mode. Bearer auth references a Keychain item: + +```json +{ + "auth": { + "mode": "bearer", + "keychainService": "dev.evalops.agentd", + "keychainAccount": "chronicle" + } +} +``` + +mTLS auth references a Keychain identity label: + +```json +{ + "auth": { + "mode": "mtls", + "identityLabel": "agentd Chronicle client" + } +} +``` + ## What's next - Consume generated `chronicle.v1` Swift types when the platform SDK publishes @@ -72,4 +120,5 @@ Tests/agentdTests/ # SecretScrubber + path policy ## License -Business Source License 1.1. See `LICENSE` for the current terms. +Business Source License 1.1. See `LICENSE` and `LICENSING.md` for the current +terms. diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..18bf787 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,57 @@ +# Security Policy + +## Reporting + +Please report suspected vulnerabilities through GitHub private vulnerability +reporting for this repository, or email security@evalops.dev with the subject +`agentd security report`. + +Include: + +- affected version or commit +- macOS version and hardware class, if relevant +- reproduction steps +- expected and observed behavior +- whether screen content, OCR text, local batches, credentials, or transport + authentication are involved + +Do not post exploitable details in public GitHub issues before EvalOps has had +a chance to triage and coordinate a fix. + +## Scope + +In scope: + +- screen capture, OCR, and window-context handling +- local batch persistence under `~/.evalops/agentd/batches` +- secret detection and fail-closed drop behavior +- endpoint transport security and client authentication +- macOS permissions and Keychain integration used by agentd + +Out of scope: + +- social engineering +- denial-of-service reports without a concrete security impact +- vulnerabilities in macOS, ScreenCaptureKit, Vision, or GitHub Actions unless + agentd uses them in a way that creates additional exposure + +## Severity Expectations + +EvalOps treats these as high severity by default: + +- screen pixels, OCR text, window titles, or document paths sent to an + unintended remote endpoint +- plaintext remote transport outside loopback development +- missing or bypassed client authentication for remote submission +- secret-bearing OCR text or window metadata shipped instead of dropped +- unbounded local retention of OCR-derived batch data + +Lower-severity issues include missing hardening, incomplete docs, or telemetry +gaps that do not expose captured content or credentials directly. + +## Handling + +EvalOps will acknowledge credible reports as quickly as possible, triage impact, +and coordinate fixes through private advisories when warranted. Public issues +may be opened after a fix is available or when the report is not security +sensitive. diff --git a/Sources/agentd/CaptureService.swift b/Sources/agentd/CaptureService.swift index 6a68110..bad79c1 100644 --- a/Sources/agentd/CaptureService.swift +++ b/Sources/agentd/CaptureService.swift @@ -1,94 +1,168 @@ -import Foundation -import ScreenCaptureKit -import CoreVideo -import CoreImage -import CoreGraphics +// SPDX-License-Identifier: BUSL-1.1 + import AppKit +import CoreGraphics +import CoreImage +import CoreVideo +import Foundation +@preconcurrency import ScreenCaptureKit struct CapturedFrame: Sendable { - let timestamp: Date - let cgImage: CGImage - let displayId: CGDirectDisplayID + let timestamp: Date + let cgImage: CGImage + let displayId: CGDirectDisplayID } actor CaptureService: NSObject { - private var stream: SCStream? - private var output: FrameOutput? - private let onFrame: @Sendable (CapturedFrame) async -> Void - private let ciContext = CIContext(options: [.useSoftwareRenderer: false]) + private var stream: SCStream? + private var output: FrameOutput? + private var streamConfiguration: SCStreamConfiguration? + private let onFrame: @Sendable (CapturedFrame) async -> Void + private let onFrameDropped: @Sendable () async -> Void + private let ciContext = CIContext(options: [.useSoftwareRenderer: false]) - init(onFrame: @escaping @Sendable (CapturedFrame) async -> Void) { - self.onFrame = onFrame - } + init( + onFrame: @escaping @Sendable (CapturedFrame) async -> Void, + onFrameDropped: @escaping @Sendable () async -> Void = {} + ) { + self.onFrame = onFrame + self.onFrameDropped = onFrameDropped + } - func start(targetFps: Double) async throws { - let content = try await SCShareableContent.excludingDesktopWindows( - true, onScreenWindowsOnly: true - ) - guard let display = content.displays.first else { - throw NSError(domain: "agentd", code: 1, userInfo: [NSLocalizedDescriptionKey: "no display"]) - } - - // Filter excludes our own menu-bar app from its own captures. - let myPID = ProcessInfo.processInfo.processIdentifier - let excluded = content.applications.filter { $0.processID == myPID } - let filter = SCContentFilter(display: display, excludingApplications: excluded, exceptingWindows: []) - - let cfg = SCStreamConfiguration() - cfg.width = Int(display.width) - cfg.height = Int(display.height) - cfg.minimumFrameInterval = CMTime(value: 1, timescale: CMTimeScale(max(1, Int32(targetFps)))) - cfg.queueDepth = 5 - cfg.showsCursor = true - cfg.pixelFormat = kCVPixelFormatType_32BGRA - - let stream = SCStream(filter: filter, configuration: cfg, delegate: nil) - let output = FrameOutput(ciContext: ciContext, displayId: display.displayID, onFrame: onFrame) - try stream.addStreamOutput(output, type: .screen, sampleHandlerQueue: .global(qos: .userInitiated)) - try await stream.startCapture() - - self.stream = stream - self.output = output - Log.capture.info("capture started fps=\(targetFps, privacy: .public) display=\(display.displayID, privacy: .public)") + func start(targetFps: Double) async throws { + let content = try await SCShareableContent.excludingDesktopWindows( + true, onScreenWindowsOnly: true + ) + guard let display = content.displays.first else { + throw NSError(domain: "agentd", code: 1, userInfo: [NSLocalizedDescriptionKey: "no display"]) } - func stop() async { - if let stream { - try? await stream.stopCapture() - } - stream = nil - output = nil - Log.capture.info("capture stopped") + // Filter excludes our own menu-bar app from its own captures. + let myPID = ProcessInfo.processInfo.processIdentifier + let excluded = content.applications.filter { $0.processID == myPID } + let filter = SCContentFilter( + display: display, excludingApplications: excluded, exceptingWindows: []) + + let cfg = SCStreamConfiguration() + cfg.width = Int(display.width) + cfg.height = Int(display.height) + cfg.minimumFrameInterval = CMTime(value: 1, timescale: CMTimeScale(max(1, Int32(targetFps)))) + cfg.queueDepth = 5 + cfg.showsCursor = true + cfg.pixelFormat = kCVPixelFormatType_32BGRA + + let stream = SCStream(filter: filter, configuration: cfg, delegate: nil) + let output = FrameOutput( + ciContext: ciContext, + displayId: display.displayID, + onFrame: onFrame, + onFrameDropped: onFrameDropped + ) + try stream.addStreamOutput( + output, type: .screen, sampleHandlerQueue: .global(qos: .userInitiated)) + try await stream.startCapture() + + self.stream = stream + self.output = output + self.streamConfiguration = cfg + Log.capture.info( + "capture started fps=\(targetFps, privacy: .public) display=\(display.displayID, privacy: .public)" + ) + } + + func stop() async { + if let stream { + try? await stream.stopCapture() } + stream = nil + output = nil + streamConfiguration = nil + Log.capture.info("capture stopped") + } - func updateFps(_ fps: Double) async { - guard let stream else { return } - let cfg = SCStreamConfiguration() - cfg.minimumFrameInterval = CMTime(value: 1, timescale: CMTimeScale(max(1, Int32(fps)))) - cfg.pixelFormat = kCVPixelFormatType_32BGRA - try? await stream.updateConfiguration(cfg) + func updateFps(_ fps: Double) async { + guard let stream, let cfg = streamConfiguration else { return } + cfg.minimumFrameInterval = CMTime(value: 1, timescale: CMTimeScale(max(1, Int32(fps)))) + do { + try await stream.updateConfiguration(cfg) + Log.capture.info("capture fps updated=\(fps, privacy: .public)") + } catch { + Log.capture.error( + "capture fps update failed: \(error.localizedDescription, privacy: .public)") } + } } private final class FrameOutput: NSObject, SCStreamOutput, @unchecked Sendable { - let ciContext: CIContext - let displayId: CGDirectDisplayID - let onFrame: @Sendable (CapturedFrame) async -> Void - - init(ciContext: CIContext, displayId: CGDirectDisplayID, - onFrame: @escaping @Sendable (CapturedFrame) async -> Void) { - self.ciContext = ciContext - self.displayId = displayId - self.onFrame = onFrame + let ciContext: CIContext + let displayId: CGDirectDisplayID + let dispatcher: BufferedFrameDispatcher + + init( + ciContext: CIContext, displayId: CGDirectDisplayID, + onFrame: @escaping @Sendable (CapturedFrame) async -> Void, + onFrameDropped: @escaping @Sendable () async -> Void + ) { + self.ciContext = ciContext + self.displayId = displayId + self.dispatcher = BufferedFrameDispatcher(onFrame: onFrame, onDropped: onFrameDropped) + } + + func stream( + _ stream: SCStream, didOutputSampleBuffer sampleBuffer: CMSampleBuffer, + of type: SCStreamOutputType + ) { + guard type == .screen, sampleBuffer.isValid, + let pixelBuffer = sampleBuffer.imageBuffer + else { return } + let ci = CIImage(cvPixelBuffer: pixelBuffer) + guard let cg = ciContext.createCGImage(ci, from: ci.extent) else { return } + let frame = CapturedFrame(timestamp: Date(), cgImage: cg, displayId: displayId) + dispatcher.yield(frame) + } +} + +final class BufferedFrameDispatcher: @unchecked Sendable { + private let continuation: AsyncStream.Continuation + private let task: Task + private let onDropped: @Sendable () async -> Void + + init( + bufferingNewest: Int = 2, + onFrame: @escaping @Sendable (CapturedFrame) async -> Void, + onDropped: @escaping @Sendable () async -> Void + ) { + var continuation: AsyncStream.Continuation! + let stream = AsyncStream(bufferingPolicy: .bufferingNewest(bufferingNewest)) { + cont in + continuation = cont + } + self.continuation = continuation + self.onDropped = onDropped + self.task = Task { + for await frame in stream { + await onFrame(frame) + } } + } - func stream(_ stream: SCStream, didOutputSampleBuffer sampleBuffer: CMSampleBuffer, of type: SCStreamOutputType) { - guard type == .screen, sampleBuffer.isValid, - let pixelBuffer = sampleBuffer.imageBuffer - else { return } - let ci = CIImage(cvPixelBuffer: pixelBuffer) - guard let cg = ciContext.createCGImage(ci, from: ci.extent) else { return } - let frame = CapturedFrame(timestamp: Date(), cgImage: cg, displayId: displayId) - Task { await onFrame(frame) } + func yield(_ frame: CapturedFrame) { + switch continuation.yield(frame) { + case .dropped: + Task { await onDropped() } + case .enqueued, .terminated: + break + @unknown default: + break } + } + + func finish() { + continuation.finish() + task.cancel() + } + + deinit { + finish() + } } diff --git a/Sources/agentd/Config.swift b/Sources/agentd/Config.swift index 4795f09..11a9605 100644 --- a/Sources/agentd/Config.swift +++ b/Sources/agentd/Config.swift @@ -1,198 +1,304 @@ +// SPDX-License-Identifier: BUSL-1.1 + import Foundation -struct AgentConfig: Codable, Sendable { - var deviceId: String - var organizationId: String - var workspaceId: String? - var userId: String? - var projectId: String? - var repository: String? - var endpoint: URL - var allowedBundleIds: [String] - var deniedBundleIds: [String] - var deniedPathPrefixes: [String] - var pauseWindowTitlePatterns: [String] - var captureFps: Double - var idleFps: Double - var batchIntervalSeconds: Double - var maxFramesPerBatch: Int - var localOnly: Bool - - enum CodingKeys: String, CodingKey { - case deviceId - case organizationId - case orgId - case workspaceId - case userId - case projectId - case repository - case endpoint - case allowedBundleIds - case deniedBundleIds - case deniedPathPrefixes - case pauseWindowTitlePatterns - case captureFps - case idleFps - case batchIntervalSeconds - case maxFramesPerBatch - case localOnly - } +enum AuthMode: Sendable, Codable, Equatable { + case none + case bearer(keychainService: String, keychainAccount: String) + case mtls(identityLabel: String) - init( - deviceId: String, - organizationId: String, - workspaceId: String? = nil, - userId: String? = nil, - projectId: String? = nil, - repository: String? = nil, - endpoint: URL, - allowedBundleIds: [String], - deniedBundleIds: [String], - deniedPathPrefixes: [String], - pauseWindowTitlePatterns: [String], - captureFps: Double, - idleFps: Double, - batchIntervalSeconds: Double, - maxFramesPerBatch: Int, - localOnly: Bool - ) { - self.deviceId = deviceId - self.organizationId = organizationId - self.workspaceId = workspaceId - self.userId = userId - self.projectId = projectId - self.repository = repository - self.endpoint = endpoint - self.allowedBundleIds = allowedBundleIds - self.deniedBundleIds = deniedBundleIds - self.deniedPathPrefixes = deniedPathPrefixes - self.pauseWindowTitlePatterns = pauseWindowTitlePatterns - self.captureFps = captureFps - self.idleFps = idleFps - self.batchIntervalSeconds = batchIntervalSeconds - self.maxFramesPerBatch = maxFramesPerBatch - self.localOnly = localOnly - } + enum CodingKeys: String, CodingKey { + case mode + case keychainService + case keychainAccount + case identityLabel + } - init(from decoder: Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - deviceId = try container.decode(String.self, forKey: .deviceId) - organizationId = try container.decodeIfPresent(String.self, forKey: .organizationId) - ?? container.decodeIfPresent(String.self, forKey: .orgId) - ?? "local" - workspaceId = try container.decodeIfPresent(String.self, forKey: .workspaceId) - userId = try container.decodeIfPresent(String.self, forKey: .userId) - projectId = try container.decodeIfPresent(String.self, forKey: .projectId) - repository = try container.decodeIfPresent(String.self, forKey: .repository) - endpoint = try container.decode(URL.self, forKey: .endpoint) - allowedBundleIds = try container.decodeIfPresent([String].self, forKey: .allowedBundleIds) ?? Self.defaultAllowedBundleIds - deniedBundleIds = try container.decodeIfPresent([String].self, forKey: .deniedBundleIds) ?? Self.defaultDeniedBundleIds - deniedPathPrefixes = try container.decodeIfPresent([String].self, forKey: .deniedPathPrefixes) ?? Self.defaultDeniedPathPrefixes - pauseWindowTitlePatterns = try container.decodeIfPresent([String].self, forKey: .pauseWindowTitlePatterns) ?? Self.defaultPauseWindowPatterns - captureFps = try container.decodeIfPresent(Double.self, forKey: .captureFps) ?? 1.0 - idleFps = try container.decodeIfPresent(Double.self, forKey: .idleFps) ?? 0.2 - batchIntervalSeconds = try container.decodeIfPresent(Double.self, forKey: .batchIntervalSeconds) ?? 30 - maxFramesPerBatch = try container.decodeIfPresent(Int.self, forKey: .maxFramesPerBatch) ?? 24 - localOnly = try container.decodeIfPresent(Bool.self, forKey: .localOnly) ?? true - } + enum Mode: String, Codable { + case none + case bearer + case mtls + } - func encode(to encoder: Encoder) throws { - var container = encoder.container(keyedBy: CodingKeys.self) - try container.encode(deviceId, forKey: .deviceId) - try container.encode(organizationId, forKey: .organizationId) - try container.encodeIfPresent(workspaceId, forKey: .workspaceId) - try container.encodeIfPresent(userId, forKey: .userId) - try container.encodeIfPresent(projectId, forKey: .projectId) - try container.encodeIfPresent(repository, forKey: .repository) - try container.encode(endpoint, forKey: .endpoint) - try container.encode(allowedBundleIds, forKey: .allowedBundleIds) - try container.encode(deniedBundleIds, forKey: .deniedBundleIds) - try container.encode(deniedPathPrefixes, forKey: .deniedPathPrefixes) - try container.encode(pauseWindowTitlePatterns, forKey: .pauseWindowTitlePatterns) - try container.encode(captureFps, forKey: .captureFps) - try container.encode(idleFps, forKey: .idleFps) - try container.encode(batchIntervalSeconds, forKey: .batchIntervalSeconds) - try container.encode(maxFramesPerBatch, forKey: .maxFramesPerBatch) - try container.encode(localOnly, forKey: .localOnly) + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + let mode = try container.decodeIfPresent(Mode.self, forKey: .mode) ?? .none + switch mode { + case .none: + self = .none + case .bearer: + self = .bearer( + keychainService: try container.decode(String.self, forKey: .keychainService), + keychainAccount: try container.decode(String.self, forKey: .keychainAccount) + ) + case .mtls: + self = .mtls(identityLabel: try container.decode(String.self, forKey: .identityLabel)) } + } - static let defaultAllowedBundleIds: [String] = [ - "com.apple.dt.Xcode", - "com.microsoft.VSCode", - "com.todesktop.230313mzl4w4u92", // Cursor - "com.googlecode.iterm2", - "com.apple.Terminal", - "dev.warp.Warp-Stable", - "company.thebrowser.Browser", // Arc - "com.google.Chrome", - "com.apple.Safari", - "org.mozilla.firefox", - "com.linear.LinearDesktop", - "com.tinyspeck.slackmacgap" - ] - - static let defaultDeniedBundleIds: [String] = [ - "com.agilebits.onepassword7", - "com.agilebits.onepassword-osx", - "com.bitwarden.desktop", - "com.lastpass.LastPass", - "com.dashlane.dashlanephonefinal", - "com.apple.keychainaccess" - ] - - static let defaultDeniedPathPrefixes: [String] = [ - ".ssh", ".aws", ".config/gcloud", ".gnupg", - ".kube", ".docker/config.json", - "Library/Keychains" - ] - - static let defaultPauseWindowPatterns: [String] = [ - "Zoom Meeting", "FaceTime", "Google Meet", - "1Password", "Bitwarden", "Keychain Access", - "Private", "Incognito" - ] - - static func fallback() -> AgentConfig { - AgentConfig( - deviceId: ProcessInfo.processInfo.globallyUniqueString, - organizationId: "local", - endpoint: URL(string: "http://127.0.0.1:8787/chronicle.v1.ChronicleService/SubmitBatch")!, - allowedBundleIds: defaultAllowedBundleIds, - deniedBundleIds: defaultDeniedBundleIds, - deniedPathPrefixes: defaultDeniedPathPrefixes, - pauseWindowTitlePatterns: defaultPauseWindowPatterns, - captureFps: 1.0, - idleFps: 0.2, - batchIntervalSeconds: 30, - maxFramesPerBatch: 24, - localOnly: true - ) + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + switch self { + case .none: + try container.encode(Mode.none, forKey: .mode) + case .bearer(let service, let account): + try container.encode(Mode.bearer, forKey: .mode) + try container.encode(service, forKey: .keychainService) + try container.encode(account, forKey: .keychainAccount) + case .mtls(let identityLabel): + try container.encode(Mode.mtls, forKey: .mode) + try container.encode(identityLabel, forKey: .identityLabel) } + } +} + +struct AgentConfig: Codable, Sendable { + var deviceId: String + var organizationId: String + var workspaceId: String? + var userId: String? + var projectId: String? + var repository: String? + var endpoint: URL + var allowedBundleIds: [String] + var deniedBundleIds: [String] + var deniedPathPrefixes: [String] + var pauseWindowTitlePatterns: [String] + var captureFps: Double + var idleFps: Double + var batchIntervalSeconds: Double + var maxFramesPerBatch: Int + var maxOcrTextChars: Int + var maxBatchBytes: Int64 + var maxBatchAgeDays: Double + var idleThresholdSeconds: Double + var idlePollSeconds: Double + var localOnly: Bool + var auth: AuthMode + + enum CodingKeys: String, CodingKey { + case deviceId + case organizationId + case orgId + case workspaceId + case userId + case projectId + case repository + case endpoint + case allowedBundleIds + case deniedBundleIds + case deniedPathPrefixes + case pauseWindowTitlePatterns + case captureFps + case idleFps + case batchIntervalSeconds + case maxFramesPerBatch + case maxOcrTextChars + case maxBatchBytes + case maxBatchAgeDays + case idleThresholdSeconds + case idlePollSeconds + case localOnly + case auth + } + + init( + deviceId: String, + organizationId: String, + workspaceId: String? = nil, + userId: String? = nil, + projectId: String? = nil, + repository: String? = nil, + endpoint: URL, + allowedBundleIds: [String], + deniedBundleIds: [String], + deniedPathPrefixes: [String], + pauseWindowTitlePatterns: [String], + captureFps: Double, + idleFps: Double, + batchIntervalSeconds: Double, + maxFramesPerBatch: Int, + maxOcrTextChars: Int = 4096, + maxBatchBytes: Int64 = 512 * 1024 * 1024, + maxBatchAgeDays: Double = 7, + idleThresholdSeconds: Double = 60, + idlePollSeconds: Double = 5, + localOnly: Bool, + auth: AuthMode = .none + ) { + self.deviceId = deviceId + self.organizationId = organizationId + self.workspaceId = workspaceId + self.userId = userId + self.projectId = projectId + self.repository = repository + self.endpoint = endpoint + self.allowedBundleIds = allowedBundleIds + self.deniedBundleIds = deniedBundleIds + self.deniedPathPrefixes = deniedPathPrefixes + self.pauseWindowTitlePatterns = pauseWindowTitlePatterns + self.captureFps = captureFps + self.idleFps = idleFps + self.batchIntervalSeconds = batchIntervalSeconds + self.maxFramesPerBatch = maxFramesPerBatch + self.maxOcrTextChars = maxOcrTextChars + self.maxBatchBytes = maxBatchBytes + self.maxBatchAgeDays = maxBatchAgeDays + self.idleThresholdSeconds = idleThresholdSeconds + self.idlePollSeconds = idlePollSeconds + self.localOnly = localOnly + self.auth = auth + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + deviceId = try container.decode(String.self, forKey: .deviceId) + organizationId = + try container.decodeIfPresent(String.self, forKey: .organizationId) + ?? container.decodeIfPresent(String.self, forKey: .orgId) + ?? "local" + workspaceId = try container.decodeIfPresent(String.self, forKey: .workspaceId) + userId = try container.decodeIfPresent(String.self, forKey: .userId) + projectId = try container.decodeIfPresent(String.self, forKey: .projectId) + repository = try container.decodeIfPresent(String.self, forKey: .repository) + endpoint = try container.decode(URL.self, forKey: .endpoint) + allowedBundleIds = + try container.decodeIfPresent([String].self, forKey: .allowedBundleIds) + ?? Self.defaultAllowedBundleIds + deniedBundleIds = + try container.decodeIfPresent([String].self, forKey: .deniedBundleIds) + ?? Self.defaultDeniedBundleIds + deniedPathPrefixes = + try container.decodeIfPresent([String].self, forKey: .deniedPathPrefixes) + ?? Self.defaultDeniedPathPrefixes + pauseWindowTitlePatterns = + try container.decodeIfPresent([String].self, forKey: .pauseWindowTitlePatterns) + ?? Self.defaultPauseWindowPatterns + captureFps = try container.decodeIfPresent(Double.self, forKey: .captureFps) ?? 1.0 + idleFps = try container.decodeIfPresent(Double.self, forKey: .idleFps) ?? 0.2 + batchIntervalSeconds = + try container.decodeIfPresent(Double.self, forKey: .batchIntervalSeconds) ?? 30 + maxFramesPerBatch = try container.decodeIfPresent(Int.self, forKey: .maxFramesPerBatch) ?? 24 + maxOcrTextChars = try container.decodeIfPresent(Int.self, forKey: .maxOcrTextChars) ?? 4096 + maxBatchBytes = + try container.decodeIfPresent(Int64.self, forKey: .maxBatchBytes) ?? 512 * 1024 * 1024 + maxBatchAgeDays = try container.decodeIfPresent(Double.self, forKey: .maxBatchAgeDays) ?? 7 + idleThresholdSeconds = + try container.decodeIfPresent(Double.self, forKey: .idleThresholdSeconds) ?? 60 + idlePollSeconds = try container.decodeIfPresent(Double.self, forKey: .idlePollSeconds) ?? 5 + localOnly = try container.decodeIfPresent(Bool.self, forKey: .localOnly) ?? true + auth = try container.decodeIfPresent(AuthMode.self, forKey: .auth) ?? .none + } + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(deviceId, forKey: .deviceId) + try container.encode(organizationId, forKey: .organizationId) + try container.encodeIfPresent(workspaceId, forKey: .workspaceId) + try container.encodeIfPresent(userId, forKey: .userId) + try container.encodeIfPresent(projectId, forKey: .projectId) + try container.encodeIfPresent(repository, forKey: .repository) + try container.encode(endpoint, forKey: .endpoint) + try container.encode(allowedBundleIds, forKey: .allowedBundleIds) + try container.encode(deniedBundleIds, forKey: .deniedBundleIds) + try container.encode(deniedPathPrefixes, forKey: .deniedPathPrefixes) + try container.encode(pauseWindowTitlePatterns, forKey: .pauseWindowTitlePatterns) + try container.encode(captureFps, forKey: .captureFps) + try container.encode(idleFps, forKey: .idleFps) + try container.encode(batchIntervalSeconds, forKey: .batchIntervalSeconds) + try container.encode(maxFramesPerBatch, forKey: .maxFramesPerBatch) + try container.encode(maxOcrTextChars, forKey: .maxOcrTextChars) + try container.encode(maxBatchBytes, forKey: .maxBatchBytes) + try container.encode(maxBatchAgeDays, forKey: .maxBatchAgeDays) + try container.encode(idleThresholdSeconds, forKey: .idleThresholdSeconds) + try container.encode(idlePollSeconds, forKey: .idlePollSeconds) + try container.encode(localOnly, forKey: .localOnly) + try container.encode(auth, forKey: .auth) + } + + static let defaultAllowedBundleIds: [String] = [ + "com.apple.dt.Xcode", + "com.microsoft.VSCode", + "com.todesktop.230313mzl4w4u92", // Cursor + "com.googlecode.iterm2", + "com.apple.Terminal", + "dev.warp.Warp-Stable", + "company.thebrowser.Browser", // Arc + "com.google.Chrome", + "com.apple.Safari", + "org.mozilla.firefox", + "com.linear.LinearDesktop", + "com.tinyspeck.slackmacgap", + ] + + static let defaultDeniedBundleIds: [String] = [ + "com.agilebits.onepassword7", + "com.agilebits.onepassword-osx", + "com.bitwarden.desktop", + "com.lastpass.LastPass", + "com.dashlane.dashlanephonefinal", + "com.apple.keychainaccess", + ] + + static let defaultDeniedPathPrefixes: [String] = [ + ".ssh", ".aws", ".config/gcloud", ".gnupg", + ".kube", ".docker/config.json", + "Library/Keychains", + ] + + static let defaultPauseWindowPatterns: [String] = [ + "Zoom Meeting", "FaceTime", "Google Meet", + "1Password", "Bitwarden", "Keychain Access", + "Private", "Incognito", + ] + + static func fallback() -> AgentConfig { + AgentConfig( + deviceId: ProcessInfo.processInfo.globallyUniqueString, + organizationId: "local", + endpoint: URL(string: "http://127.0.0.1:8787/chronicle.v1.ChronicleService/SubmitBatch")!, + allowedBundleIds: defaultAllowedBundleIds, + deniedBundleIds: defaultDeniedBundleIds, + deniedPathPrefixes: defaultDeniedPathPrefixes, + pauseWindowTitlePatterns: defaultPauseWindowPatterns, + captureFps: 1.0, + idleFps: 0.2, + batchIntervalSeconds: 30, + maxFramesPerBatch: 24, + maxOcrTextChars: 4096, + maxBatchBytes: 512 * 1024 * 1024, + maxBatchAgeDays: 7, + idleThresholdSeconds: 60, + idlePollSeconds: 5, + localOnly: true, + auth: .none + ) + } } enum ConfigStore { - static var path: URL { - let home = FileManager.default.homeDirectoryForCurrentUser - return home.appendingPathComponent(".evalops/agentd/config.json") - } + static var path: URL { + let home = FileManager.default.homeDirectoryForCurrentUser + return home.appendingPathComponent(".evalops/agentd/config.json") + } - static func load() -> AgentConfig { - guard let data = try? Data(contentsOf: path), - let cfg = try? JSONDecoder().decode(AgentConfig.self, from: data) - else { - let fallback = AgentConfig.fallback() - try? save(fallback) - return fallback - } - return cfg + static func load() -> AgentConfig { + guard let data = try? Data(contentsOf: path), + let cfg = try? JSONDecoder().decode(AgentConfig.self, from: data) + else { + let fallback = AgentConfig.fallback() + try? save(fallback) + return fallback } + return cfg + } - static func save(_ cfg: AgentConfig) throws { - let dir = path.deletingLastPathComponent() - try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) - let enc = JSONEncoder() - enc.outputFormatting = [.prettyPrinted, .sortedKeys] - try enc.encode(cfg).write(to: path, options: .atomic) - try? FileManager.default.setAttributes([.posixPermissions: 0o600], ofItemAtPath: path.path) - } + static func save(_ cfg: AgentConfig) throws { + let dir = path.deletingLastPathComponent() + try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + let enc = JSONEncoder() + enc.outputFormatting = [.prettyPrinted, .sortedKeys] + try enc.encode(cfg).write(to: path, options: .atomic) + try? FileManager.default.setAttributes([.posixPermissions: 0o600], ofItemAtPath: path.path) + } } diff --git a/Sources/agentd/Logging.swift b/Sources/agentd/Logging.swift index fc63947..6d90f53 100644 --- a/Sources/agentd/Logging.swift +++ b/Sources/agentd/Logging.swift @@ -1,12 +1,14 @@ +// SPDX-License-Identifier: BUSL-1.1 + import Foundation import OSLog enum Log { - static let subsystem = "dev.evalops.agentd" - static let app = Logger(subsystem: subsystem, category: "app") - static let capture = Logger(subsystem: subsystem, category: "capture") - static let ocr = Logger(subsystem: subsystem, category: "ocr") - static let scrub = Logger(subsystem: subsystem, category: "scrub") - static let policy = Logger(subsystem: subsystem, category: "policy") - static let submit = Logger(subsystem: subsystem, category: "submit") + static let subsystem = "dev.evalops.agentd" + static let app = Logger(subsystem: subsystem, category: "app") + static let capture = Logger(subsystem: subsystem, category: "capture") + static let ocr = Logger(subsystem: subsystem, category: "ocr") + static let scrub = Logger(subsystem: subsystem, category: "scrub") + static let policy = Logger(subsystem: subsystem, category: "policy") + static let submit = Logger(subsystem: subsystem, category: "submit") } diff --git a/Sources/agentd/MenuBarController.swift b/Sources/agentd/MenuBarController.swift index 377d499..1fd7ad4 100644 --- a/Sources/agentd/MenuBarController.swift +++ b/Sources/agentd/MenuBarController.swift @@ -1,92 +1,99 @@ +// SPDX-License-Identifier: BUSL-1.1 + import AppKit import Foundation @MainActor final class MenuBarController: NSObject { - private let statusItem: NSStatusItem - private let menu = NSMenu() - private var paused: Bool = false - private let onPauseToggle: @Sendable (Bool) -> Void - private let onFlushNow: @Sendable () -> Void - private let onOpenBatchesDir: @Sendable () -> Void - private let onQuit: @Sendable () -> Void - - init( - onPauseToggle: @escaping @Sendable (Bool) -> Void, - onFlushNow: @escaping @Sendable () -> Void, - onOpenBatchesDir: @escaping @Sendable () -> Void, - onQuit: @escaping @Sendable () -> Void - ) { - self.onPauseToggle = onPauseToggle - self.onFlushNow = onFlushNow - self.onOpenBatchesDir = onOpenBatchesDir - self.onQuit = onQuit - self.statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength) - super.init() - configure() + private let statusItem: NSStatusItem + private let menu = NSMenu() + private var paused: Bool = false + private let onPauseToggle: @Sendable (Bool) -> Void + private let onFlushNow: @Sendable () -> Void + private let onOpenBatchesDir: @Sendable () -> Void + private let onQuit: @Sendable () -> Void + + init( + onPauseToggle: @escaping @Sendable (Bool) -> Void, + onFlushNow: @escaping @Sendable () -> Void, + onOpenBatchesDir: @escaping @Sendable () -> Void, + onQuit: @escaping @Sendable () -> Void + ) { + self.onPauseToggle = onPauseToggle + self.onFlushNow = onFlushNow + self.onOpenBatchesDir = onOpenBatchesDir + self.onQuit = onQuit + self.statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength) + super.init() + configure() + } + + private func configure() { + if let button = statusItem.button { + button.image = NSImage( + systemSymbolName: "circle.fill", accessibilityDescription: "agentd recording") + button.image?.isTemplate = true + button.toolTip = "agentd — capturing" } - private func configure() { - if let button = statusItem.button { - button.image = NSImage(systemSymbolName: "circle.fill", accessibilityDescription: "agentd recording") - button.image?.isTemplate = true - button.toolTip = "agentd — capturing" - } + let pauseItem = NSMenuItem( + title: "Pause Capture", action: #selector(togglePause), keyEquivalent: "p") + pauseItem.keyEquivalentModifierMask = [.command, .option, .control] + pauseItem.target = self + menu.addItem(pauseItem) - let pauseItem = NSMenuItem(title: "Pause Capture", action: #selector(togglePause), keyEquivalent: "p") - pauseItem.keyEquivalentModifierMask = [.command, .option, .control] - pauseItem.target = self - menu.addItem(pauseItem) + let flushItem = NSMenuItem( + title: "Flush Batch Now", action: #selector(flush), keyEquivalent: "f") + flushItem.keyEquivalentModifierMask = [.command, .option, .control] + flushItem.target = self + menu.addItem(flushItem) - let flushItem = NSMenuItem(title: "Flush Batch Now", action: #selector(flush), keyEquivalent: "f") - flushItem.keyEquivalentModifierMask = [.command, .option, .control] - flushItem.target = self - menu.addItem(flushItem) + menu.addItem(.separator()) - menu.addItem(.separator()) + let revealItem = NSMenuItem( + title: "Reveal Batches in Finder", action: #selector(reveal), keyEquivalent: "") + revealItem.target = self + menu.addItem(revealItem) - let revealItem = NSMenuItem(title: "Reveal Batches in Finder", action: #selector(reveal), keyEquivalent: "") - revealItem.target = self - menu.addItem(revealItem) + menu.addItem(.separator()) - menu.addItem(.separator()) + let about = NSMenuItem( + title: "agentd \(Bundle.main.appVersion) — local-only", action: nil, keyEquivalent: "") + about.isEnabled = false + menu.addItem(about) - let about = NSMenuItem(title: "agentd \(Bundle.main.appVersion) — local-only", action: nil, keyEquivalent: "") - about.isEnabled = false - menu.addItem(about) + menu.addItem(.separator()) - menu.addItem(.separator()) + let quit = NSMenuItem(title: "Quit", action: #selector(quit), keyEquivalent: "q") + quit.target = self + menu.addItem(quit) - let quit = NSMenuItem(title: "Quit", action: #selector(quit), keyEquivalent: "q") - quit.target = self - menu.addItem(quit) + statusItem.menu = menu + } - statusItem.menu = menu + @objc private func togglePause() { + paused.toggle() + if let button = statusItem.button { + button.image = NSImage( + systemSymbolName: paused ? "pause.circle" : "circle.fill", + accessibilityDescription: paused ? "agentd paused" : "agentd recording" + ) + button.image?.isTemplate = true + button.toolTip = paused ? "agentd — paused" : "agentd — capturing" } - - @objc private func togglePause() { - paused.toggle() - if let button = statusItem.button { - button.image = NSImage( - systemSymbolName: paused ? "pause.circle" : "circle.fill", - accessibilityDescription: paused ? "agentd paused" : "agentd recording" - ) - button.image?.isTemplate = true - button.toolTip = paused ? "agentd — paused" : "agentd — capturing" - } - if let item = menu.items.first { - item.title = paused ? "Resume Capture" : "Pause Capture" - } - onPauseToggle(paused) + if let item = menu.items.first { + item.title = paused ? "Resume Capture" : "Pause Capture" } + onPauseToggle(paused) + } - @objc private func flush() { onFlushNow() } - @objc private func reveal() { onOpenBatchesDir() } - @objc private func quit() { onQuit() } + @objc private func flush() { onFlushNow() } + @objc private func reveal() { onOpenBatchesDir() } + @objc private func quit() { onQuit() } } -private extension Bundle { - var appVersion: String { - (infoDictionary?["CFBundleShortVersionString"] as? String) ?? "0.0.0" - } +extension Bundle { + fileprivate var appVersion: String { + (infoDictionary?["CFBundleShortVersionString"] as? String) ?? "0.0.0" + } } diff --git a/Sources/agentd/PerceptualHash.swift b/Sources/agentd/PerceptualHash.swift index 17c4166..3f3c53c 100644 --- a/Sources/agentd/PerceptualHash.swift +++ b/Sources/agentd/PerceptualHash.swift @@ -1,46 +1,54 @@ -import Foundation +// SPDX-License-Identifier: BUSL-1.1 + +import Accelerate import CoreGraphics import CoreImage -import Accelerate +import Foundation /// 64-bit perceptual hash via 8x8 mean-luma comparison (the cheap-and-correct one). /// Hamming distance ≤ 5 = "near-duplicate frame, drop it" — typical kills 90% of /// captured frames during steady scrolling or no-op time. struct PerceptualHash: Sendable { - let value: UInt64 + let value: UInt64 - init?(cgImage: CGImage) { - guard let small = PerceptualHash.downscale(cgImage, to: 8) else { return nil } - guard let lumas = PerceptualHash.lumaSamples(small) else { return nil } - let mean = lumas.reduce(0, +) / Double(lumas.count) - var bits: UInt64 = 0 - for (i, l) in lumas.enumerated() { - if l > mean { bits |= (1 << UInt64(i)) } - } - self.value = bits - } + init(value: UInt64) { + self.value = value + } - static func distance(_ a: PerceptualHash, _ b: PerceptualHash) -> Int { - (a.value ^ b.value).nonzeroBitCount + init?(cgImage: CGImage) { + guard let small = PerceptualHash.downscale(cgImage, to: 8) else { return nil } + guard let lumas = PerceptualHash.lumaSamples(small) else { return nil } + let mean = lumas.reduce(0, +) / Double(lumas.count) + var bits: UInt64 = 0 + for (i, l) in lumas.enumerated() { + if l > mean { bits |= (1 << UInt64(i)) } } + self.value = bits + } - private static func downscale(_ image: CGImage, to size: Int) -> CGImage? { - let cs = CGColorSpaceCreateDeviceGray() - guard let ctx = CGContext( - data: nil, width: size, height: size, bitsPerComponent: 8, - bytesPerRow: size, space: cs, - bitmapInfo: CGImageAlphaInfo.none.rawValue - ) else { return nil } - ctx.interpolationQuality = .low - ctx.draw(image, in: CGRect(x: 0, y: 0, width: size, height: size)) - return ctx.makeImage() - } + static func distance(_ a: PerceptualHash, _ b: PerceptualHash) -> Int { + (a.value ^ b.value).nonzeroBitCount + } - private static func lumaSamples(_ image: CGImage) -> [Double]? { - guard let data = image.dataProvider?.data, - let ptr = CFDataGetBytePtr(data) - else { return nil } - let count = image.width * image.height - return (0.. CGImage? { + let cs = CGColorSpaceCreateDeviceGray() + guard + let ctx = CGContext( + data: nil, width: size, height: size, bitsPerComponent: 8, + bytesPerRow: size, space: cs, + bitmapInfo: CGImageAlphaInfo.none.rawValue + ) + else { return nil } + ctx.interpolationQuality = .low + ctx.draw(image, in: CGRect(x: 0, y: 0, width: size, height: size)) + return ctx.makeImage() + } + + private static func lumaSamples(_ image: CGImage) -> [Double]? { + guard let data = image.dataProvider?.data, + let ptr = CFDataGetBytePtr(data) + else { return nil } + let count = image.width * image.height + return (0.. OCRResult +} struct ProcessedFrame: Sendable, Codable { - let frameHash: String - let perceptualHash: UInt64 - let capturedAt: Date - let bundleId: String - let appName: String - let windowTitle: String - let documentPath: String? - let ocrText: String - let ocrConfidence: Float - let widthPx: Int - let heightPx: Int - let bytesPng: Int - - enum CodingKeys: String, CodingKey { - case frameHash - case perceptualHash - case capturedAt - case bundleId - case appName - case windowTitle - case documentPath - case ocrText - case ocrConfidence - case widthPx - case heightPx - case bytesPng - } + let frameHash: String + let perceptualHash: UInt64 + let capturedAt: Date + let bundleId: String + let appName: String + let windowTitle: String + let documentPath: String? + let ocrText: String + let ocrTextTruncated: Bool + let ocrConfidence: Float + let widthPx: Int + let heightPx: Int + let bytesPng: Int - init( - frameHash: String, - perceptualHash: UInt64, - capturedAt: Date, - bundleId: String, - appName: String, - windowTitle: String, - documentPath: String?, - ocrText: String, - ocrConfidence: Float, - widthPx: Int, - heightPx: Int, - bytesPng: Int - ) { - self.frameHash = frameHash - self.perceptualHash = perceptualHash - self.capturedAt = capturedAt - self.bundleId = bundleId - self.appName = appName - self.windowTitle = windowTitle - self.documentPath = documentPath - self.ocrText = ocrText - self.ocrConfidence = ocrConfidence - self.widthPx = widthPx - self.heightPx = heightPx - self.bytesPng = bytesPng - } + enum CodingKeys: String, CodingKey { + case frameHash + case perceptualHash + case capturedAt + case bundleId + case appName + case windowTitle + case documentPath + case ocrText + case ocrTextTruncated + case ocrConfidence + case widthPx + case heightPx + case bytesPng + } - init(from decoder: Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - frameHash = try container.decode(String.self, forKey: .frameHash) - if let value = try? container.decode(UInt64.self, forKey: .perceptualHash) { - perceptualHash = value - } else { - let raw = try container.decode(String.self, forKey: .perceptualHash) - perceptualHash = UInt64(raw) ?? 0 - } - capturedAt = try container.decode(Date.self, forKey: .capturedAt) - bundleId = try container.decode(String.self, forKey: .bundleId) - appName = try container.decode(String.self, forKey: .appName) - windowTitle = try container.decode(String.self, forKey: .windowTitle) - documentPath = try container.decodeIfPresent(String.self, forKey: .documentPath) - ocrText = try container.decode(String.self, forKey: .ocrText) - ocrConfidence = try container.decode(Float.self, forKey: .ocrConfidence) - widthPx = try container.decode(Int.self, forKey: .widthPx) - heightPx = try container.decode(Int.self, forKey: .heightPx) - if let value = try? container.decode(Int.self, forKey: .bytesPng) { - bytesPng = value - } else { - let raw = try container.decode(String.self, forKey: .bytesPng) - bytesPng = Int(raw) ?? 0 - } - } + init( + frameHash: String, + perceptualHash: UInt64, + capturedAt: Date, + bundleId: String, + appName: String, + windowTitle: String, + documentPath: String?, + ocrText: String, + ocrTextTruncated: Bool = false, + ocrConfidence: Float, + widthPx: Int, + heightPx: Int, + bytesPng: Int + ) { + self.frameHash = frameHash + self.perceptualHash = perceptualHash + self.capturedAt = capturedAt + self.bundleId = bundleId + self.appName = appName + self.windowTitle = windowTitle + self.documentPath = documentPath + self.ocrText = ocrText + self.ocrTextTruncated = ocrTextTruncated + self.ocrConfidence = ocrConfidence + self.widthPx = widthPx + self.heightPx = heightPx + self.bytesPng = bytesPng + } - func encode(to encoder: Encoder) throws { - var container = encoder.container(keyedBy: CodingKeys.self) - try container.encode(frameHash, forKey: .frameHash) - try container.encode(String(perceptualHash), forKey: .perceptualHash) - try container.encode(capturedAt, forKey: .capturedAt) - try container.encode(bundleId, forKey: .bundleId) - try container.encode(appName, forKey: .appName) - try container.encode(windowTitle, forKey: .windowTitle) - try container.encodeIfPresent(documentPath, forKey: .documentPath) - try container.encode(ocrText, forKey: .ocrText) - try container.encode(ocrConfidence, forKey: .ocrConfidence) - try container.encode(widthPx, forKey: .widthPx) - try container.encode(heightPx, forKey: .heightPx) - try container.encode(String(bytesPng), forKey: .bytesPng) + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + frameHash = try container.decode(String.self, forKey: .frameHash) + if let value = try? container.decode(UInt64.self, forKey: .perceptualHash) { + perceptualHash = value + } else { + let raw = try container.decode(String.self, forKey: .perceptualHash) + perceptualHash = UInt64(raw) ?? 0 + } + capturedAt = try container.decode(Date.self, forKey: .capturedAt) + bundleId = try container.decode(String.self, forKey: .bundleId) + appName = try container.decode(String.self, forKey: .appName) + windowTitle = try container.decode(String.self, forKey: .windowTitle) + documentPath = try container.decodeIfPresent(String.self, forKey: .documentPath) + ocrText = try container.decode(String.self, forKey: .ocrText) + ocrTextTruncated = try container.decodeIfPresent(Bool.self, forKey: .ocrTextTruncated) ?? false + ocrConfidence = try container.decode(Float.self, forKey: .ocrConfidence) + widthPx = try container.decode(Int.self, forKey: .widthPx) + heightPx = try container.decode(Int.self, forKey: .heightPx) + if let value = try? container.decode(Int.self, forKey: .bytesPng) { + bytesPng = value + } else { + let raw = try container.decode(String.self, forKey: .bytesPng) + bytesPng = Int(raw) ?? 0 } + } + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(frameHash, forKey: .frameHash) + // Connect-protocol JSON serializes int64/uint64 as strings; keep this wire contract aligned with cmd/chronicle. + try container.encode(String(perceptualHash), forKey: .perceptualHash) + try container.encode(capturedAt, forKey: .capturedAt) + try container.encode(bundleId, forKey: .bundleId) + try container.encode(appName, forKey: .appName) + try container.encode(windowTitle, forKey: .windowTitle) + try container.encodeIfPresent(documentPath, forKey: .documentPath) + try container.encode(ocrText, forKey: .ocrText) + try container.encode(ocrTextTruncated, forKey: .ocrTextTruncated) + try container.encode(ocrConfidence, forKey: .ocrConfidence) + try container.encode(widthPx, forKey: .widthPx) + try container.encode(heightPx, forKey: .heightPx) + // Connect-protocol JSON serializes int64/uint64 as strings; keep this wire contract aligned with cmd/chronicle. + try container.encode(String(bytesPng), forKey: .bytesPng) + } } struct Batch: Sendable, Codable { - let batchId: String - let deviceId: String - let organizationId: String - let workspaceId: String? - let userId: String? - let projectId: String? - let repository: String? - let startedAt: Date - let endedAt: Date - let frames: [ProcessedFrame] - let droppedCounts: DropCounts + let batchId: String + let deviceId: String + let organizationId: String + let workspaceId: String? + let userId: String? + let projectId: String? + let repository: String? + let startedAt: Date + let endedAt: Date + let frames: [ProcessedFrame] + let droppedCounts: DropCounts } struct DropCounts: Sendable, Codable { - let secret: Int - let duplicate: Int - let deniedApp: Int - let deniedPath: Int + let secret: Int + let duplicate: Int + let deniedApp: Int + let deniedPath: Int + let droppedBackpressure: Int + + enum CodingKeys: String, CodingKey { + case secret + case duplicate + case deniedApp + case deniedPath + case droppedBackpressure + } + + init(secret: Int, duplicate: Int, deniedApp: Int, deniedPath: Int, droppedBackpressure: Int = 0) { + self.secret = secret + self.duplicate = duplicate + self.deniedApp = deniedApp + self.deniedPath = deniedPath + self.droppedBackpressure = droppedBackpressure + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + secret = try container.decode(Int.self, forKey: .secret) + duplicate = try container.decode(Int.self, forKey: .duplicate) + deniedApp = try container.decode(Int.self, forKey: .deniedApp) + deniedPath = try container.decode(Int.self, forKey: .deniedPath) + droppedBackpressure = try container.decodeIfPresent(Int.self, forKey: .droppedBackpressure) ?? 0 + } +} + +struct DedupWindow: Sendable { + private var hashes: [PerceptualHash] = [] + let capacity: Int + let threshold: Int + + init(capacity: Int = 16, threshold: Int = 5) { + self.capacity = capacity + self.threshold = threshold + } + + func containsDuplicate(of hash: PerceptualHash) -> Bool { + hashes.contains { PerceptualHash.distance($0, hash) <= threshold } + } + + mutating func remember(_ hash: PerceptualHash) { + hashes.append(hash) + if hashes.count > capacity { + hashes.removeFirst(hashes.count - capacity) + } + } } actor FramePipeline { - private var config: AgentConfig - private let ocr = VisionOCR() - private var lastHash: PerceptualHash? - private let phashThreshold = 5 - - private var pending: [ProcessedFrame] = [] - private var startedAt = Date() - private var droppedSecret = 0 - private var droppedDup = 0 - private var droppedDeniedApp = 0 - private var droppedDeniedPath = 0 - - private let onBatch: @Sendable (Batch) async -> Void - - init(config: AgentConfig, onBatch: @escaping @Sendable (Batch) async -> Void) { - self.config = config - self.onBatch = onBatch + private var config: AgentConfig + private let ocr: any OCRRecognizing + private var dedupWindow = DedupWindow(capacity: 16, threshold: 5) + + private var pending: [ProcessedFrame] = [] + private var startedAt = Date() + private var droppedSecret = 0 + private var droppedDup = 0 + private var droppedDeniedApp = 0 + private var droppedDeniedPath = 0 + private var droppedBackpressure = 0 + + private let onBatch: @Sendable (Batch) async -> Void + + init( + config: AgentConfig, + ocr: any OCRRecognizing = VisionOCR(), + onBatch: @escaping @Sendable (Batch) async -> Void + ) { + self.config = config + self.ocr = ocr + self.onBatch = onBatch + } + + func updateConfig(_ cfg: AgentConfig) { + self.config = cfg + } + + func recordBackpressureDrop() { + droppedBackpressure += 1 + } + + func consume(_ frame: CapturedFrame, context: WindowContext?) async { + guard let ctx = context else { return } + + if config.deniedBundleIds.contains(ctx.bundleId) { + droppedDeniedApp += 1 + Log.scrub.debug("denied bundle \(ctx.bundleId, privacy: .public)") + return + } + if !config.allowedBundleIds.isEmpty, + !config.allowedBundleIds.contains(ctx.bundleId) + { + droppedDeniedApp += 1 + return } - func updateConfig(_ cfg: AgentConfig) { - self.config = cfg + let pathPolicy = PathPolicy(deniedPrefixes: config.deniedPathPrefixes) + if let p = ctx.documentPath, pathPolicy.deny(p) { + droppedDeniedPath += 1 + Log.scrub.info("denied path bundle=\(ctx.bundleId, privacy: .public)") + return + } + if config.pauseWindowTitlePatterns.contains(where: { ctx.windowTitle.contains($0) }) { + droppedDeniedApp += 1 + return } - func consume(_ frame: CapturedFrame, context: WindowContext?) async { - guard let ctx = context else { return } - - if config.deniedBundleIds.contains(ctx.bundleId) { - droppedDeniedApp += 1 - Log.scrub.debug("denied bundle \(ctx.bundleId, privacy: .public)") - return - } - if !config.allowedBundleIds.isEmpty, - !config.allowedBundleIds.contains(ctx.bundleId) { - droppedDeniedApp += 1 - return - } - - let pathPolicy = PathPolicy(deniedPrefixes: config.deniedPathPrefixes) - if let p = ctx.documentPath, pathPolicy.deny(p) { - droppedDeniedPath += 1 - Log.scrub.info("denied path bundle=\(ctx.bundleId, privacy: .public)") - return - } - if config.pauseWindowTitlePatterns.contains(where: { ctx.windowTitle.contains($0) }) { - droppedDeniedApp += 1 - return - } - - guard let phash = PerceptualHash(cgImage: frame.cgImage) else { return } - if let last = lastHash, PerceptualHash.distance(last, phash) <= phashThreshold { - droppedDup += 1 - return - } - - let ocrResult: OCRResult - do { - ocrResult = try await ocr.recognize(cgImage: frame.cgImage) - } catch { - Log.ocr.error("ocr failed: \(error.localizedDescription, privacy: .public)") - return - } - - switch SecretScrubber.evaluate(ocrResult.text) { - case .dropped(let reason): - droppedSecret += 1 - Log.scrub.warning("frame dropped secret=\(reason, privacy: .public)") - return - case .clean: - break - } - - let pngBytes = encodePng(frame.cgImage) ?? 0 - let frameHash = sha256Hex("\(phash.value)|\(frame.timestamp.timeIntervalSince1970)|\(ctx.bundleId)|\(ctx.windowTitle)|\(ocrResult.text)") - - let processed = ProcessedFrame( - frameHash: frameHash, - perceptualHash: phash.value, - capturedAt: frame.timestamp, - bundleId: ctx.bundleId, - appName: ctx.appName, - windowTitle: ctx.windowTitle, - documentPath: ctx.documentPath, - ocrText: ocrResult.text, - ocrConfidence: ocrResult.confidence, - widthPx: frame.cgImage.width, - heightPx: frame.cgImage.height, - bytesPng: pngBytes - ) - - pending.append(processed) - lastHash = phash - - if pending.count >= config.maxFramesPerBatch { - await flush() - } + guard let phash = PerceptualHash(cgImage: frame.cgImage) else { return } + if dedupWindow.containsDuplicate(of: phash) { + droppedDup += 1 + return } - func flush() async { - guard !pending.isEmpty || hasDrops() else { return } - let batch = Batch( - batchId: UUID().uuidString, - deviceId: config.deviceId, - organizationId: config.organizationId, - workspaceId: config.workspaceId, - userId: config.userId, - projectId: config.projectId, - repository: config.repository, - startedAt: startedAt, - endedAt: Date(), - frames: pending, - droppedCounts: DropCounts( - secret: droppedSecret, - duplicate: droppedDup, - deniedApp: droppedDeniedApp, - deniedPath: droppedDeniedPath - ) - ) - pending.removeAll(keepingCapacity: true) - droppedSecret = 0; droppedDup = 0; droppedDeniedApp = 0; droppedDeniedPath = 0 - startedAt = Date() - await onBatch(batch) + let ocrResult: OCRResult + do { + ocrResult = try await ocr.recognize(cgImage: frame.cgImage) + } catch { + Log.ocr.error("ocr failed: \(error.localizedDescription, privacy: .public)") + return } - private func hasDrops() -> Bool { - droppedSecret + droppedDup + droppedDeniedApp + droppedDeniedPath > 0 + switch evaluateSecretSurfaces(context: ctx, ocrText: ocrResult.text) { + case .dropped(let reason): + droppedSecret += 1 + Log.scrub.warning("frame dropped secret=\(reason, privacy: .public)") + return + case .clean: + break } - private func encodePng(_ image: CGImage) -> Int? { - let data = NSMutableData() - guard let dst = CGImageDestinationCreateWithData(data, UTType.png.identifier as CFString, 1, nil) else { return nil } - CGImageDestinationAddImage(dst, image, nil) - guard CGImageDestinationFinalize(dst) else { return nil } - return data.length + let ocrText = truncated(ocrResult.text, maxChars: config.maxOcrTextChars) + // `bytesPng` is a cheap raw-BGRA size estimate; raw pixels stay on device and are not PNG-encoded. + let estimatedBytes = frame.cgImage.width * frame.cgImage.height * 4 + let frameHash = sha256Hex( + "\(phash.value)|\(frame.timestamp.timeIntervalSince1970)|\(ctx.bundleId)|\(ctx.windowTitle)|\(ocrResult.text)" + ) + + let processed = ProcessedFrame( + frameHash: frameHash, + perceptualHash: phash.value, + capturedAt: frame.timestamp, + bundleId: ctx.bundleId, + appName: ctx.appName, + windowTitle: ctx.windowTitle, + documentPath: ctx.documentPath, + ocrText: ocrText.value, + ocrTextTruncated: ocrText.truncated, + ocrConfidence: ocrResult.confidence, + widthPx: frame.cgImage.width, + heightPx: frame.cgImage.height, + bytesPng: estimatedBytes + ) + + pending.append(processed) + dedupWindow.remember(phash) + + if pending.count >= config.maxFramesPerBatch { + await flush() } + } + + func flush() async { + guard !pending.isEmpty || hasDrops() else { return } + let batch = Batch( + batchId: UUID().uuidString, + deviceId: config.deviceId, + organizationId: config.organizationId, + workspaceId: config.workspaceId, + userId: config.userId, + projectId: config.projectId, + repository: config.repository, + startedAt: startedAt, + endedAt: Date(), + frames: pending, + droppedCounts: DropCounts( + secret: droppedSecret, + duplicate: droppedDup, + deniedApp: droppedDeniedApp, + deniedPath: droppedDeniedPath, + droppedBackpressure: droppedBackpressure + ) + ) + pending.removeAll(keepingCapacity: true) + droppedSecret = 0 + droppedDup = 0 + droppedDeniedApp = 0 + droppedDeniedPath = 0 + droppedBackpressure = 0 + startedAt = Date() + await onBatch(batch) + } - private func sha256Hex(_ s: String) -> String { - let digest = SHA256.hash(data: Data(s.utf8)) - return digest.map { String(format: "%02x", $0) }.joined() + private func hasDrops() -> Bool { + droppedSecret + droppedDup + droppedDeniedApp + droppedDeniedPath + droppedBackpressure > 0 + } + + private func evaluateSecretSurfaces(context: WindowContext, ocrText: String) + -> SecretScrubber.Decision + { + let surfaces = [ + ("windowTitle", context.windowTitle), + ("documentPath", context.documentPath ?? ""), + ("ocrText", ocrText), + ] + for (name, text) in surfaces { + switch SecretScrubber.evaluate(text) { + case .clean: + continue + case .dropped(let reason): + return .dropped(reason: "\(name):\(reason)") + } + } + return .clean + } + + private func truncated(_ text: String, maxChars: Int) -> (value: String, truncated: Bool) { + guard maxChars >= 0, text.count > maxChars else { + return (text, false) } + return (String(text.prefix(maxChars)), true) + } + + private func sha256Hex(_ s: String) -> String { + let digest = SHA256.hash(data: Data(s.utf8)) + return digest.map { String(format: "%02x", $0) }.joined() + } } diff --git a/Sources/agentd/SecretScrubber.swift b/Sources/agentd/SecretScrubber.swift index 5d92127..57397d7 100644 --- a/Sources/agentd/SecretScrubber.swift +++ b/Sources/agentd/SecretScrubber.swift @@ -1,3 +1,5 @@ +// SPDX-License-Identifier: BUSL-1.1 + import Foundation /// Fail-closed scrubber. We never partial-redact and ship — a hit means the @@ -7,59 +9,67 @@ import Foundation /// and (b) it bridges trivially to Sendable, unlike the new typed Regex which is /// not Sendable in Swift 6. struct SecretScrubber: Sendable { - struct Pattern: @unchecked Sendable { - let name: String - let regex: NSRegularExpression - } + struct Pattern: @unchecked Sendable { + let name: String + let regex: NSRegularExpression + } - static let patterns: [Pattern] = { - let raw: [(String, String)] = [ - ("aws_access_key", #"\bAKIA[0-9A-Z]{16}\b"#), - ("aws_secret", #"(?i)aws(.{0,20})?(secret|access).{0,20}?["']?[A-Za-z0-9/+=]{40}["']?"#), - ("gcp_sa_key", #"-----BEGIN\s+PRIVATE\s+KEY-----"#), - ("ssh_private", #"-----BEGIN\s+(?:RSA|OPENSSH|EC|DSA)\s+PRIVATE\s+KEY-----"#), - ("jwt", #"\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\b"#), - ("github_token", #"\bgh[pousr]_[A-Za-z0-9]{30,}\b"#), - ("slack_bot", #"\bxox[abprs]-[A-Za-z0-9-]{10,}\b"#), - ("anthropic_key", #"\bsk-ant-[A-Za-z0-9-_]{20,}\b"#), - ("openai_key", #"\bsk-[A-Za-z0-9]{20,}\b"#), - ("stripe_live", #"\b(?:rk|sk)_live_[A-Za-z0-9]{20,}\b"#), - ("pem_certreq", #"-----BEGIN\s+CERTIFICATE\s+REQUEST-----"#), - ("password_field", #"(?i)\b(password|passwd|secret|api[_-]?key)\b\s*[:=]\s*\S{4,}"#) - ] - return raw.compactMap { (name, p) in - (try? NSRegularExpression(pattern: p)).map { Pattern(name: name, regex: $0) } - } - }() - - enum Decision: Sendable, Equatable { - case clean - case dropped(reason: String) + static let patterns: [Pattern] = { + let raw: [(String, String)] = [ + ("aws_access_key", #"\bAKIA[0-9A-Z]{16}\b"#), + ("aws_secret", #"(?i)aws(.{0,20})?(secret|access).{0,20}?["']?[A-Za-z0-9/+=]{40}["']?"#), + ("gcp_sa_key", #"-----BEGIN\s+PRIVATE\s+KEY-----"#), + ("ssh_private", #"-----BEGIN\s+(?:RSA|OPENSSH|EC|DSA)\s+PRIVATE\s+KEY-----"#), + ("jwt", #"\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\b"#), + ("github_token", #"\bgh[pousr]_[A-Za-z0-9]{30,}\b"#), + ("github_fine_grained_token", #"\bgithub_pat_[A-Za-z0-9_]{82}\b"#), + ("google_api_key", #"\bAIza[0-9A-Za-z\-_]{35}\b"#), + ("npm_token", #"\bnpm_[A-Za-z0-9]{36}\b"#), + ("sendgrid_key", #"\bSG\.[A-Za-z0-9_-]{22}\.[A-Za-z0-9_-]{43}\b"#), + ("digitalocean_pat", #"\bdop_v1_[a-f0-9]{64}\b"#), + ("azure_storage_key", #"AccountKey=[A-Za-z0-9+/=]{86,90}"#), + ("mailgun_key", #"\bkey-[0-9a-f]{32}\b"#), + ("twilio_api_key", #"\bSK[a-f0-9]{32}\b"#), + ("discord_bot_token", #"\b[A-Za-z0-9_-]{24}\.[A-Za-z0-9_-]{6}\.[A-Za-z0-9_-]{27,}\b"#), + ("slack_bot", #"\bxox[abprs]-[A-Za-z0-9-]{10,}\b"#), + ("anthropic_key", #"\bsk-ant-[A-Za-z0-9-_]{20,}\b"#), + ("openai_key", #"\bsk-(?:proj-|svcacct-|admin-|None-)?[A-Za-z0-9]{32,}\b"#), + ("stripe_live", #"\b(?:rk|sk)_live_[A-Za-z0-9]{20,}\b"#), + ("pem_certreq", #"-----BEGIN\s+CERTIFICATE\s+REQUEST-----"#), + ("password_field", #"(?i)\b(password|passwd|secret|api[_-]?key)\b\s*[:=]\s*\S{4,}"#), + ] + return raw.compactMap { (name, p) in + (try? NSRegularExpression(pattern: p)).map { Pattern(name: name, regex: $0) } } + }() + + enum Decision: Sendable, Equatable { + case clean + case dropped(reason: String) + } - /// O(n * patterns); short-circuits on first hit. - static func evaluate(_ text: String) -> Decision { - guard !text.isEmpty else { return .clean } - let range = NSRange(text.startIndex.. Decision { + guard !text.isEmpty else { return .clean } + let range = NSRange(text.startIndex.. Bool { - let normalized = path.hasPrefix("/") ? path : path - let home = FileManager.default.homeDirectoryForCurrentUser.path - return deniedPrefixes.contains { prefix in - normalized.hasPrefix(home + "/" + prefix) || - normalized.hasPrefix("~/" + prefix) || - normalized.contains("/" + prefix + "/") - } + func deny(_ path: String) -> Bool { + let normalized = path.hasPrefix("/") ? path : path + let home = FileManager.default.homeDirectoryForCurrentUser.path + return deniedPrefixes.contains { prefix in + normalized.hasPrefix(home + "/" + prefix) || normalized.hasPrefix("~/" + prefix) + || normalized.contains("/" + prefix + "/") } + } } diff --git a/Sources/agentd/Submitter.swift b/Sources/agentd/Submitter.swift index d2ae8c6..a4bec78 100644 --- a/Sources/agentd/Submitter.swift +++ b/Sources/agentd/Submitter.swift @@ -1,99 +1,385 @@ +// SPDX-License-Identifier: BUSL-1.1 + import Foundation +import Security /// HTTP/JSON poster against `chronicle.v1.ChronicleService.SubmitBatch`. /// Local-only mode writes batches to `~/.evalops/agentd/batches/` instead of POSTing. actor Submitter { - private let endpoint: URL - private let localOnly: Bool - private let session: URLSession - - init(endpoint: URL, localOnly: Bool, session: URLSession? = nil) { - self.endpoint = endpoint - self.localOnly = localOnly - if let session { - self.session = session - } else { - let cfg = URLSessionConfiguration.ephemeral - cfg.httpAdditionalHeaders = [ - "Content-Type": "application/json", - "Connect-Protocol-Version": "1" - ] - cfg.timeoutIntervalForRequest = 30 - self.session = URLSession(configuration: cfg) - } + private let endpoint: URL + private let localOnly: Bool + private let client: any HTTPClient + private let auth: ResolvedSubmitterAuth + private let batchDirectory: URL + private let maxBatchBytes: Int64 + private let maxBatchAgeDays: Double + + init( + endpoint: URL, + localOnly: Bool, + authMode: AuthMode = .none, + credentialProvider: any SubmitterCredentialProviding = KeychainCredentialProvider(), + session: URLSession? = nil, + client: (any HTTPClient)? = nil, + batchDirectory: URL? = nil, + maxBatchBytes: Int64 = 512 * 1024 * 1024, + maxBatchAgeDays: Double = 7 + ) throws { + guard EndpointPolicy.isAllowed(endpoint: endpoint, localOnly: localOnly) else { + throw SubmitterInitError.insecureRemoteEndpoint(endpoint.absoluteString) + } + guard localOnly || authMode != .none else { + throw SubmitterInitError.missingRemoteAuth } - @discardableResult - func submit(_ batch: Batch) async -> SubmitResult { - let data: Data - do { - data = try encodeSubmitBatchRequest(batch, localOnly: localOnly) - } catch { - Log.submit.error("batch encode failed id=\(batch.batchId, privacy: .public)") - return .failed - } + self.endpoint = endpoint + self.localOnly = localOnly + self.auth = try ResolvedSubmitterAuth(mode: authMode, credentialProvider: credentialProvider) + self.batchDirectory = + batchDirectory + ?? FileManager.default.homeDirectoryForCurrentUser + .appendingPathComponent(".evalops/agentd/batches") + self.maxBatchBytes = maxBatchBytes + self.maxBatchAgeDays = maxBatchAgeDays + + if let client { + self.client = client + } else if let session { + self.client = URLSessionHTTPClient(session: session) + } else if case .mtls(let identity) = auth { + let cfg = URLSessionConfiguration.ephemeral + cfg.httpAdditionalHeaders = [ + "Content-Type": "application/json", + "Connect-Protocol-Version": "1", + ] + cfg.timeoutIntervalForRequest = 30 + let delegate = MTLSURLSessionDelegate(identity: identity.secIdentity) + self.client = URLSessionHTTPClient( + session: URLSession( + configuration: cfg, + delegate: delegate, + delegateQueue: nil + )) + } else { + let cfg = URLSessionConfiguration.ephemeral + cfg.httpAdditionalHeaders = [ + "Content-Type": "application/json", + "Connect-Protocol-Version": "1", + ] + cfg.timeoutIntervalForRequest = 30 + self.client = URLSessionHTTPClient(session: URLSession(configuration: cfg)) + } + } - if localOnly { + @discardableResult + func submit(_ batch: Batch) async -> SubmitResult { + let data: Data + do { + data = try encodeSubmitBatchRequest(batch, localOnly: localOnly) + } catch { + Log.submit.error("batch encode failed id=\(batch.batchId, privacy: .public)") + return .failed + } + + if localOnly { + await persistLocal(batch.batchId, data: data) + return .persistedLocal + } + + let req = makeRequest(body: data) + do { + let (body, resp) = try await client.data(for: req) + if let http = resp as? HTTPURLResponse, !(200..<300).contains(http.statusCode) { + Log.submit.warning( + "submit status=\(http.statusCode, privacy: .public) batch=\(batch.batchId, privacy: .public) — falling back to local" + ) + await persistLocal(batch.batchId, data: data) + return .persistedLocal + } else { + let response: SubmitBatchResponse? + if body.isEmpty { + response = nil + } else { + do { + response = try JSONDecoder().decode(SubmitBatchResponse.self, from: body) + } catch { + Log.submit.warning( + "submit malformed response batch=\(batch.batchId, privacy: .public) — falling back to local" + ) await persistLocal(batch.batchId, data: data) return .persistedLocal + } } + Log.submit.info( + "submit ok batch=\(batch.batchId, privacy: .public) frames=\(batch.frames.count, privacy: .public)" + ) + await sweepLocalBatches() + return .submitted(response) + } + } catch { + Log.submit.warning( + "submit error \(error.localizedDescription, privacy: .public) — falling back to local") + await persistLocal(batch.batchId, data: data) + return .persistedLocal + } + } - var req = URLRequest(url: endpoint) - req.httpMethod = "POST" - req.setValue("application/json", forHTTPHeaderField: "Content-Type") - req.setValue("1", forHTTPHeaderField: "Connect-Protocol-Version") - req.httpBody = data - do { - let (body, resp) = try await session.data(for: req) - if let http = resp as? HTTPURLResponse, !(200..<300).contains(http.statusCode) { - Log.submit.warning("submit status=\(http.statusCode, privacy: .public) batch=\(batch.batchId, privacy: .public) — falling back to local") - await persistLocal(batch.batchId, data: data) - return .persistedLocal - } else { - let response = try? JSONDecoder().decode(SubmitBatchResponse.self, from: body) - Log.submit.info("submit ok batch=\(batch.batchId, privacy: .public) frames=\(batch.frames.count, privacy: .public)") - return .submitted(response) - } - } catch { - Log.submit.warning("submit error \(error.localizedDescription, privacy: .public) — falling back to local") - await persistLocal(batch.batchId, data: data) - return .persistedLocal + func makeRequest(body: Data) -> URLRequest { + var req = URLRequest(url: endpoint) + req.httpMethod = "POST" + req.setValue("application/json", forHTTPHeaderField: "Content-Type") + req.setValue("1", forHTTPHeaderField: "Connect-Protocol-Version") + if case .bearer(let token) = auth { + req.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") + } + req.httpBody = body + return req + } + + private func persistLocal(_ id: String, data: Data) async { + try? FileManager.default.createDirectory(at: batchDirectory, withIntermediateDirectories: true) + let url = batchDirectory.appendingPathComponent("\(id).json") + try? data.write(to: url, options: .atomic) + try? FileManager.default.setAttributes([.posixPermissions: 0o600], ofItemAtPath: url.path) + Log.submit.info("local persist \(url.path, privacy: .public)") + await sweepLocalBatches() + } + + func sweepLocalBatches() async { + let fm = FileManager.default + guard + let urls = try? fm.contentsOfDirectory( + at: batchDirectory, + includingPropertiesForKeys: [.contentModificationDateKey, .fileSizeKey, .isRegularFileKey], + options: [.skipsHiddenFiles] + ) + else { return } + + var files: [LocalBatchFile] = [] + let now = Date() + let cutoff = now.addingTimeInterval(-maxBatchAgeDays * 24 * 60 * 60) + var removedCount = 0 + var removedBytes: Int64 = 0 + + for url in urls where url.pathExtension == "json" { + guard + let values = try? url.resourceValues(forKeys: [ + .contentModificationDateKey, + .fileSizeKey, + .isRegularFileKey, + ]), + values.isRegularFile == true + else { continue } + + let modified = values.contentModificationDate ?? .distantPast + let size = Int64(values.fileSize ?? 0) + if modified < cutoff { + if (try? fm.removeItem(at: url)) != nil { + removedCount += 1 + removedBytes += size } + continue + } + files.append(LocalBatchFile(url: url, modified: modified, size: size)) } - private func persistLocal(_ id: String, data: Data) async { - let dir = FileManager.default.homeDirectoryForCurrentUser - .appendingPathComponent(".evalops/agentd/batches") - try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) - let url = dir.appendingPathComponent("\(id).json") - try? data.write(to: url, options: .atomic) - try? FileManager.default.setAttributes([.posixPermissions: 0o600], ofItemAtPath: url.path) - Log.submit.info("local persist \(url.path, privacy: .public)") + var totalBytes = files.reduce(Int64(0)) { $0 + $1.size } + for file in files.sorted(by: { $0.modified < $1.modified }) where totalBytes > maxBatchBytes { + if (try? fm.removeItem(at: file.url)) != nil { + totalBytes -= file.size + removedCount += 1 + removedBytes += file.size + } + } + + if removedCount > 0 { + Log.submit.notice( + "local batch sweep removed=\(removedCount, privacy: .public) bytes=\(removedBytes, privacy: .public)" + ) + } + } +} + +private struct LocalBatchFile { + let url: URL + let modified: Date + let size: Int64 +} + +protocol HTTPClient: Sendable { + func data(for request: URLRequest) async throws -> (Data, URLResponse) +} + +struct URLSessionHTTPClient: HTTPClient { + let session: URLSession + + func data(for request: URLRequest) async throws -> (Data, URLResponse) { + try await session.data(for: request) + } +} + +enum SubmitterInitError: Error, Equatable, LocalizedError { + case insecureRemoteEndpoint(String) + case missingRemoteAuth + case missingBearerToken(service: String, account: String) + case missingClientIdentity(label: String) + + var errorDescription: String? { + switch self { + case .insecureRemoteEndpoint(let endpoint): + return "remote agentd endpoint must use HTTPS or loopback HTTP: \(endpoint)" + case .missingRemoteAuth: + return "remote agentd endpoint requires bearer or mTLS auth" + case .missingBearerToken(let service, let account): + return "bearer token not found in Keychain service=\(service) account=\(account)" + case .missingClientIdentity(let label): + return "mTLS identity not found in Keychain label=\(label)" + } + } +} + +enum EndpointPolicy { + static func isAllowed(endpoint: URL, localOnly: Bool) -> Bool { + guard !localOnly else { return true } + guard let scheme = endpoint.scheme?.lowercased() else { return false } + if scheme == "https" || scheme == "unix" { + return true + } + guard scheme == "http", let host = endpoint.host?.lowercased() else { + return false + } + return host == "localhost" || host == "::1" || host.hasPrefix("127.") + } +} + +protocol SubmitterCredentialProviding: Sendable { + func bearerToken(service: String, account: String) throws -> String + func clientIdentity(label: String) throws -> ClientIdentity +} + +struct ClientIdentity: @unchecked Sendable { + let secIdentity: SecIdentity +} + +enum ResolvedSubmitterAuth: @unchecked Sendable, Equatable { + case none + case bearer(String) + case mtls(ClientIdentity) + + init(mode: AuthMode, credentialProvider: any SubmitterCredentialProviding) throws { + switch mode { + case .none: + self = .none + case .bearer(let service, let account): + let token = try credentialProvider.bearerToken(service: service, account: account) + guard !token.isEmpty else { + throw SubmitterInitError.missingBearerToken(service: service, account: account) + } + self = .bearer(token) + case .mtls(let label): + self = .mtls(try credentialProvider.clientIdentity(label: label)) + } + } + + static func == (lhs: ResolvedSubmitterAuth, rhs: ResolvedSubmitterAuth) -> Bool { + switch (lhs, rhs) { + case (.none, .none): + return true + case (.bearer(let left), .bearer(let right)): + return left == right + case (.mtls, .mtls): + return true + default: + return false + } + } +} + +struct KeychainCredentialProvider: SubmitterCredentialProviding { + func bearerToken(service: String, account: String) throws -> String { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: account, + kSecReturnData as String: true, + kSecMatchLimit as String: kSecMatchLimitOne, + ] + var item: CFTypeRef? + let status = SecItemCopyMatching(query as CFDictionary, &item) + guard status == errSecSuccess, + let data = item as? Data, + let token = String(data: data, encoding: .utf8) + else { + throw SubmitterInitError.missingBearerToken(service: service, account: account) + } + return token.trimmingCharacters(in: .whitespacesAndNewlines) + } + + func clientIdentity(label: String) throws -> ClientIdentity { + let query: [String: Any] = [ + kSecClass as String: kSecClassIdentity, + kSecAttrLabel as String: label, + kSecReturnRef as String: true, + kSecMatchLimit as String: kSecMatchLimitOne, + ] + var item: CFTypeRef? + let status = SecItemCopyMatching(query as CFDictionary, &item) + guard status == errSecSuccess, let identity = item else { + throw SubmitterInitError.missingClientIdentity(label: label) + } + return ClientIdentity(secIdentity: identity as! SecIdentity) + } +} + +private final class MTLSURLSessionDelegate: NSObject, URLSessionDelegate, @unchecked Sendable { + private let identity: SecIdentity + + init(identity: SecIdentity) { + self.identity = identity + } + + func urlSession( + _ session: URLSession, + didReceive challenge: URLAuthenticationChallenge + ) async -> (URLSession.AuthChallengeDisposition, URLCredential?) { + guard + challenge.protectionSpace.authenticationMethod == NSURLAuthenticationMethodClientCertificate + else { + return (.performDefaultHandling, nil) } + return ( + .useCredential, + URLCredential( + identity: identity, + certificates: nil, + persistence: .forSession + ) + ) + } } struct SubmitBatchRequest: Sendable, Codable { - let batch: Batch - let localOnly: Bool + let batch: Batch + let localOnly: Bool } struct SubmitBatchResponse: Sendable, Codable, Equatable { - let batchId: String? - let artifactId: String? - let acceptedFrameCount: Int? - let droppedFrameCount: Int? - let memoryIds: [String]? + let batchId: String? + let artifactId: String? + let acceptedFrameCount: Int? + let droppedFrameCount: Int? + let memoryIds: [String]? } enum SubmitResult: Sendable, Equatable { - case submitted(SubmitBatchResponse?) - case persistedLocal - case failed + case submitted(SubmitBatchResponse?) + case persistedLocal + case failed } func encodeSubmitBatchRequest(_ batch: Batch, localOnly: Bool) throws -> Data { - let enc = JSONEncoder() - enc.dateEncodingStrategy = .iso8601 - enc.outputFormatting = [.sortedKeys] - return try enc.encode(SubmitBatchRequest(batch: batch, localOnly: localOnly)) + let enc = JSONEncoder() + enc.dateEncodingStrategy = .iso8601 + enc.outputFormatting = [.sortedKeys] + return try enc.encode(SubmitBatchRequest(batch: batch, localOnly: localOnly)) } diff --git a/Sources/agentd/VisionOCR.swift b/Sources/agentd/VisionOCR.swift index cc1def7..a4186a2 100644 --- a/Sources/agentd/VisionOCR.swift +++ b/Sources/agentd/VisionOCR.swift @@ -1,50 +1,53 @@ -import Foundation +// SPDX-License-Identifier: BUSL-1.1 + import CoreGraphics +import Foundation import Vision struct OCRResult: Sendable { - let text: String - let confidence: Float - let language: String? + let text: String + let confidence: Float + let language: String? } -actor VisionOCR { - func recognize(cgImage: CGImage) async throws -> OCRResult { - try await withCheckedThrowingContinuation { (cont: CheckedContinuation) in - let request = VNRecognizeTextRequest { req, err in - if let err { - cont.resume(throwing: err) - return - } - let observations = (req.results as? [VNRecognizedTextObservation]) ?? [] - var lines: [String] = [] - var confSum: Float = 0 - var confN: Int = 0 - for obs in observations { - guard let candidate = obs.topCandidates(1).first else { continue } - lines.append(candidate.string) - confSum += candidate.confidence - confN += 1 - } - let conf = confN > 0 ? confSum / Float(confN) : 0 - let detectedLang = observations.first - .flatMap { $0.topCandidates(1).first } - .map { _ in "en" } // language detection requires Sequence APIs; fine for v0 - cont.resume(returning: OCRResult( - text: lines.joined(separator: "\n"), - confidence: conf, - language: detectedLang - )) - } - request.recognitionLevel = .accurate - request.usesLanguageCorrection = false - request.revision = VNRecognizeTextRequestRevision3 - let handler = VNImageRequestHandler(cgImage: cgImage, options: [:]) - do { - try handler.perform([request]) - } catch { - cont.resume(throwing: error) - } +actor VisionOCR: OCRRecognizing { + func recognize(cgImage: CGImage) async throws -> OCRResult { + try await withCheckedThrowingContinuation { (cont: CheckedContinuation) in + let request = VNRecognizeTextRequest { req, err in + if let err { + cont.resume(throwing: err) + return + } + let observations = (req.results as? [VNRecognizedTextObservation]) ?? [] + var lines: [String] = [] + var confSum: Float = 0 + var confN: Int = 0 + for obs in observations { + guard let candidate = obs.topCandidates(1).first else { continue } + lines.append(candidate.string) + confSum += candidate.confidence + confN += 1 } + let conf = confN > 0 ? confSum / Float(confN) : 0 + let detectedLang = observations.first + .flatMap { $0.topCandidates(1).first } + .map { _ in "en" } // language detection requires Sequence APIs; fine for v0 + cont.resume( + returning: OCRResult( + text: lines.joined(separator: "\n"), + confidence: conf, + language: detectedLang + )) + } + request.recognitionLevel = .accurate + request.usesLanguageCorrection = false + request.revision = VNRecognizeTextRequestRevision3 + let handler = VNImageRequestHandler(cgImage: cgImage, options: [:]) + do { + try handler.perform([request]) + } catch { + cont.resume(throwing: error) + } } + } } diff --git a/Sources/agentd/WindowContext.swift b/Sources/agentd/WindowContext.swift index 98c42b5..85cb36d 100644 --- a/Sources/agentd/WindowContext.swift +++ b/Sources/agentd/WindowContext.swift @@ -1,66 +1,68 @@ -import Foundation +// SPDX-License-Identifier: BUSL-1.1 + import AppKit import ApplicationServices +import Foundation struct WindowContext: Sendable, Codable { - let bundleId: String - let appName: String - let windowTitle: String - let documentPath: String? - let pid: pid_t - let timestamp: Date + let bundleId: String + let appName: String + let windowTitle: String + let documentPath: String? + let pid: pid_t + let timestamp: Date } enum WindowContextProbe { - @MainActor - static func current() -> WindowContext? { - guard let app = NSWorkspace.shared.frontmostApplication, - let bundleId = app.bundleIdentifier - else { return nil } + @MainActor + static func current() -> WindowContext? { + guard let app = NSWorkspace.shared.frontmostApplication, + let bundleId = app.bundleIdentifier + else { return nil } - let appElement = AXUIElementCreateApplication(app.processIdentifier) + let appElement = AXUIElementCreateApplication(app.processIdentifier) - var focused: AnyObject? - AXUIElementCopyAttributeValue(appElement, kAXFocusedWindowAttribute as CFString, &focused) + var focused: AnyObject? + AXUIElementCopyAttributeValue(appElement, kAXFocusedWindowAttribute as CFString, &focused) - var title = "" - var docPath: String? = nil + var title = "" + var docPath: String? = nil - if let win = focused { - // swift-format-ignore - let winRef = win as! AXUIElement // AX values are CF, force-cast is correct here - var rawTitle: AnyObject? - AXUIElementCopyAttributeValue(winRef, kAXTitleAttribute as CFString, &rawTitle) - if let t = rawTitle as? String { title = t } + if let win = focused { + // swift-format-ignore + let winRef = win as! AXUIElement // AX values are CF, force-cast is correct here + var rawTitle: AnyObject? + AXUIElementCopyAttributeValue(winRef, kAXTitleAttribute as CFString, &rawTitle) + if let t = rawTitle as? String { title = t } - var rawDoc: AnyObject? - AXUIElementCopyAttributeValue(winRef, kAXDocumentAttribute as CFString, &rawDoc) - if let d = rawDoc as? String { - if let url = URL(string: d), url.isFileURL { - docPath = url.path - } else { - docPath = d - } - } + var rawDoc: AnyObject? + AXUIElementCopyAttributeValue(winRef, kAXDocumentAttribute as CFString, &rawDoc) + if let d = rawDoc as? String { + if let url = URL(string: d), url.isFileURL { + docPath = url.path + } else { + docPath = d } - - return WindowContext( - bundleId: bundleId, - appName: app.localizedName ?? bundleId, - windowTitle: title, - documentPath: docPath, - pid: app.processIdentifier, - timestamp: Date() - ) + } } - @MainActor - static func axTrustedPrompt() -> Bool { - // The literal value of `kAXTrustedCheckOptionPrompt` is the documented - // string "AXTrustedCheckOptionPrompt". Using it directly sidesteps - // Swift 6's strict-concurrency complaint about referencing the C global. - let key = "AXTrustedCheckOptionPrompt" as CFString - let opts: NSDictionary = [key: true] - return AXIsProcessTrustedWithOptions(opts as CFDictionary) - } + return WindowContext( + bundleId: bundleId, + appName: app.localizedName ?? bundleId, + windowTitle: title, + documentPath: docPath, + pid: app.processIdentifier, + timestamp: Date() + ) + } + + @MainActor + static func axTrustedPrompt() -> Bool { + // The literal value of `kAXTrustedCheckOptionPrompt` is the documented + // string "AXTrustedCheckOptionPrompt". Using it directly sidesteps + // Swift 6's strict-concurrency complaint about referencing the C global. + let key = "AXTrustedCheckOptionPrompt" as CFString + let opts: NSDictionary = [key: true] + return AXIsProcessTrustedWithOptions(opts as CFDictionary) + } } diff --git a/Sources/agentd/main.swift b/Sources/agentd/main.swift index dddd9b2..21c4fd5 100644 --- a/Sources/agentd/main.swift +++ b/Sources/agentd/main.swift @@ -1,92 +1,143 @@ +// SPDX-License-Identifier: BUSL-1.1 + import AppKit import Foundation @MainActor final class AppController { - private let config: AgentConfig - private let pipeline: FramePipeline - private let submitter: Submitter - private var capture: CaptureService! - private var menuBar: MenuBarController! - private var paused = false - private var flushTimer: Timer? - - init() { - let cfg = ConfigStore.load() - self.config = cfg - - let submitter = Submitter(endpoint: cfg.endpoint, localOnly: cfg.localOnly) - self.submitter = submitter - - self.pipeline = FramePipeline(config: cfg) { batch in - await submitter.submit(batch) - } + private let config: AgentConfig + private let pipeline: FramePipeline + private let submitter: Submitter + private var capture: CaptureService! + private var menuBar: MenuBarController! + private var paused = false + private var flushTimer: Timer? + private var idleTimer: Timer? + private var idleMode = false + + init() { + let cfg = ConfigStore.load() + self.config = cfg + + let submitter: Submitter + do { + submitter = try Submitter( + endpoint: cfg.endpoint, + localOnly: cfg.localOnly, + authMode: cfg.auth, + maxBatchBytes: cfg.maxBatchBytes, + maxBatchAgeDays: cfg.maxBatchAgeDays + ) + } catch { + Log.submit.fault( + "submitter config rejected: \(error.localizedDescription, privacy: .public); forcing local-only" + ) + submitter = try! Submitter( + endpoint: cfg.endpoint, + localOnly: true, + authMode: .none, + maxBatchBytes: cfg.maxBatchBytes, + maxBatchAgeDays: cfg.maxBatchAgeDays + ) + } + self.submitter = submitter + + self.pipeline = FramePipeline(config: cfg) { batch in + await submitter.submit(batch) } + } - func boot() async { - if !WindowContextProbe.axTrustedPrompt() { - Log.app.warning("Accessibility not granted yet — window-context will be empty until granted") - } - - let pipeline = pipeline - capture = CaptureService { [pipeline] frame in - let ctx = await MainActor.run { WindowContextProbe.current() } - await pipeline.consume(frame, context: ctx) - } - - menuBar = MenuBarController( - onPauseToggle: { [weak self] paused in - Task { @MainActor in await self?.applyPause(paused) } - }, - onFlushNow: { [weak self] in - Task { await self?.pipeline.flush() } - _ = self - }, - onOpenBatchesDir: { - let dir = FileManager.default.homeDirectoryForCurrentUser - .appendingPathComponent(".evalops/agentd/batches") - try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) - NSWorkspace.shared.activateFileViewerSelecting([dir]) - }, - onQuit: { - Task { @MainActor in NSApp.terminate(nil) } - } - ) - - do { - try await capture.start(targetFps: config.captureFps) - } catch { - Log.app.error("capture start failed: \(error.localizedDescription, privacy: .public)") - } - - let interval = config.batchIntervalSeconds - flushTimer = Timer.scheduledTimer(withTimeInterval: interval, repeats: true) { _ in - Task { await pipeline.flush() } - } - - Log.app.info("agentd booted device=\(self.config.deviceId, privacy: .public) localOnly=\(self.config.localOnly, privacy: .public)") + func boot() async { + if !WindowContextProbe.axTrustedPrompt() { + Log.app.warning("Accessibility not granted yet — window-context will be empty until granted") } - private func applyPause(_ paused: Bool) async { - guard paused != self.paused else { return } - self.paused = paused - if paused { - await capture.stop() - } else { - try? await capture.start(targetFps: config.captureFps) - } + let pipeline = pipeline + capture = CaptureService { [pipeline] frame in + let ctx = await MainActor.run { WindowContextProbe.current() } + await pipeline.consume(frame, context: ctx) + } onFrameDropped: { [pipeline] in + await pipeline.recordBackpressureDrop() } + + menuBar = MenuBarController( + onPauseToggle: { [weak self] paused in + Task { @MainActor in await self?.applyPause(paused) } + }, + onFlushNow: { [weak self] in + Task { await self?.pipeline.flush() } + _ = self + }, + onOpenBatchesDir: { + let dir = FileManager.default.homeDirectoryForCurrentUser + .appendingPathComponent(".evalops/agentd/batches") + try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + NSWorkspace.shared.activateFileViewerSelecting([dir]) + }, + onQuit: { + Task { @MainActor in NSApp.terminate(nil) } + } + ) + + do { + try await capture.start(targetFps: config.captureFps) + } catch { + Log.app.error("capture start failed: \(error.localizedDescription, privacy: .public)") + } + + let interval = config.batchIntervalSeconds + flushTimer = Timer.scheduledTimer(withTimeInterval: interval, repeats: true) { _ in + Task { await pipeline.flush() } + } + idleTimer = Timer.scheduledTimer( + withTimeInterval: max(1, config.idlePollSeconds), repeats: true + ) { [weak self] _ in + Task { @MainActor in await self?.pollIdleState() } + } + + Log.app.info( + "agentd booted device=\(self.config.deviceId, privacy: .public) localOnly=\(self.config.localOnly, privacy: .public)" + ) + } + + private func applyPause(_ paused: Bool) async { + guard paused != self.paused else { return } + self.paused = paused + if paused { + await capture.stop() + } else { + try? await capture.start(targetFps: config.captureFps) + idleMode = false + } + } + + private func pollIdleState() async { + guard !paused else { return } + let idleSeconds = CGEventSource.secondsSinceLastEventType( + .combinedSessionState, + eventType: CGEventType(rawValue: ~0)! + ) + let shouldIdle = idleSeconds >= config.idleThresholdSeconds + guard shouldIdle != idleMode else { return } + + idleMode = shouldIdle + let fps = shouldIdle ? config.idleFps : config.captureFps + await capture.updateFps(fps) + Log.capture.info( + "adaptive fps mode=\(shouldIdle ? "idle" : "active", privacy: .public) fps=\(fps, privacy: .public)" + ) + } } @MainActor final class AppDelegate: NSObject, NSApplicationDelegate { - var controller: AppController? + var controller: AppController? - func applicationDidFinishLaunching(_ notification: Notification) { - let c = AppController() - controller = c - Task { await c.boot() } - } + func applicationDidFinishLaunching(_ notification: Notification) { + let c = AppController() + controller = c + Task { await c.boot() } + } } // Status-bar-only app — no Dock icon, no main window. diff --git a/Tests/agentdTests/PipelineTests.swift b/Tests/agentdTests/PipelineTests.swift new file mode 100644 index 0000000..2874509 --- /dev/null +++ b/Tests/agentdTests/PipelineTests.swift @@ -0,0 +1,337 @@ +// SPDX-License-Identifier: BUSL-1.1 + +import CoreGraphics +import XCTest + +@testable import agentd + +final class PipelineTests: XCTestCase { + func testWindowTitleSecretDropsFrame() async throws { + let recorder = BatchRecorder() + let pipeline = FramePipeline(config: Self.config(), ocr: StubOCR(text: "clean")) { batch in + await recorder.append(batch) + } + + await pipeline.consume( + Self.frame(bits: 0xAAAA_AAAA_AAAA_AAAA), + context: Self.context(windowTitle: "prod \("AKIA" + String(repeating: "A", count: 16))") + ) + await pipeline.flush() + + let batches = await recorder.snapshot() + let batch = try XCTUnwrap(batches.first) + XCTAssertEqual(batch.frames.count, 0) + XCTAssertEqual(batch.droppedCounts.secret, 1) + } + + func testDocumentPathSecretDropsFrame() async throws { + let recorder = BatchRecorder() + let pipeline = FramePipeline(config: Self.config(), ocr: StubOCR(text: "clean")) { batch in + await recorder.append(batch) + } + + await pipeline.consume( + Self.frame(bits: 0xAAAA_AAAA_AAAA_AAAA), + context: Self.context(documentPath: "/tmp/report?\(SecretScrubberTests.jwtFixture())") + ) + await pipeline.flush() + + let batches = await recorder.snapshot() + let batch = try XCTUnwrap(batches.first) + XCTAssertEqual(batch.frames.count, 0) + XCTAssertEqual(batch.droppedCounts.secret, 1) + } + + func testDeniedPathPrecedenceStillUsesPathPolicy() async throws { + let recorder = BatchRecorder() + let pipeline = FramePipeline(config: Self.config(), ocr: StubOCR(text: "clean")) { batch in + await recorder.append(batch) + } + let home = FileManager.default.homeDirectoryForCurrentUser.path + + await pipeline.consume( + Self.frame(bits: 0xAAAA_AAAA_AAAA_AAAA), + context: Self.context(documentPath: "\(home)/.ssh/id_ed25519") + ) + await pipeline.flush() + + let batches = await recorder.snapshot() + let batch = try XCTUnwrap(batches.first) + XCTAssertEqual(batch.frames.count, 0) + XCTAssertEqual(batch.droppedCounts.deniedPath, 1) + XCTAssertEqual(batch.droppedCounts.secret, 0) + } + + func testOcrTextIsCappedAfterFullTextSecretScan() async throws { + let cleanRecorder = BatchRecorder() + let cleanPipeline = FramePipeline( + config: Self.config(maxOcrTextChars: 64), + ocr: StubOCR(text: String(repeating: "a", count: 128)) + ) { batch in + await cleanRecorder.append(batch) + } + + await cleanPipeline.consume(Self.frame(bits: 0xAAAA_AAAA_AAAA_AAAA), context: Self.context()) + await cleanPipeline.flush() + + let cleanBatches = await cleanRecorder.snapshot() + let cleanBatch = try XCTUnwrap(cleanBatches.first) + let frame = try XCTUnwrap(cleanBatch.frames.first) + XCTAssertEqual(frame.ocrText.count, 64) + XCTAssertTrue(frame.ocrTextTruncated) + + let secretRecorder = BatchRecorder() + let secretText = + String(repeating: "a", count: 128) + " " + ("AKIA" + String(repeating: "B", count: 16)) + let secretPipeline = FramePipeline( + config: Self.config(maxOcrTextChars: 64), + ocr: StubOCR(text: secretText) + ) { batch in + await secretRecorder.append(batch) + } + + await secretPipeline.consume(Self.frame(bits: 0x5555_5555_5555_5555), context: Self.context()) + await secretPipeline.flush() + + let secretBatches = await secretRecorder.snapshot() + let secretBatch = try XCTUnwrap(secretBatches.first) + XCTAssertEqual(secretBatch.frames.count, 0) + XCTAssertEqual(secretBatch.droppedCounts.secret, 1) + } + + func testMaxFramesPerBatchFlushesAutomatically() async throws { + let recorder = BatchRecorder() + let pipeline = FramePipeline( + config: Self.config(maxFramesPerBatch: 2), ocr: StubOCR(text: "clean") + ) { batch in + await recorder.append(batch) + } + + await pipeline.consume(Self.frame(bits: 0xAAAA_AAAA_AAAA_AAAA), context: Self.context()) + await pipeline.consume(Self.frame(bits: 0x5555_5555_5555_5555), context: Self.context()) + + let batches = await recorder.snapshot() + let batch = try XCTUnwrap(batches.first) + XCTAssertEqual(batch.frames.count, 2) + XCTAssertEqual(batch.frames.first?.bytesPng, 8 * 8 * 4) + } + + func testManualFlushEmitsPendingFrame() async throws { + let recorder = BatchRecorder() + let pipeline = FramePipeline(config: Self.config(), ocr: StubOCR(text: "clean")) { batch in + await recorder.append(batch) + } + + await pipeline.consume(Self.frame(bits: 0xAAAA_AAAA_AAAA_AAAA), context: Self.context()) + await pipeline.flush() + + let batches = await recorder.snapshot() + let batch = try XCTUnwrap(batches.first) + XCTAssertEqual(batch.frames.count, 1) + } + + func testFlushEmitsDropOnlyBatch() async throws { + let recorder = BatchRecorder() + let pipeline = FramePipeline(config: Self.config(), ocr: StubOCR(text: "clean")) { batch in + await recorder.append(batch) + } + + await pipeline.consume( + Self.frame(bits: 0xAAAA_AAAA_AAAA_AAAA), + context: Self.context(bundleId: "com.agilebits.onepassword7") + ) + await pipeline.flush() + + let batches = await recorder.snapshot() + let batch = try XCTUnwrap(batches.first) + XCTAssertEqual(batch.frames.count, 0) + XCTAssertEqual(batch.droppedCounts.deniedApp, 1) + } + + func testPauseWindowTitleBeatsAllowedBundle() async throws { + let recorder = BatchRecorder() + let pipeline = FramePipeline(config: Self.config(), ocr: StubOCR(text: "clean")) { batch in + await recorder.append(batch) + } + + await pipeline.consume( + Self.frame(bits: 0xAAAA_AAAA_AAAA_AAAA), + context: Self.context(windowTitle: "Zoom Meeting") + ) + await pipeline.flush() + + let batches = await recorder.snapshot() + let batch = try XCTUnwrap(batches.first) + XCTAssertEqual(batch.frames.count, 0) + XCTAssertEqual(batch.droppedCounts.deniedApp, 1) + } + + func testDeniedBundleBeatsAllowlist() async throws { + var cfg = Self.config() + cfg.allowedBundleIds = ["com.agilebits.onepassword7"] + let recorder = BatchRecorder() + let pipeline = FramePipeline(config: cfg, ocr: StubOCR(text: "clean")) { batch in + await recorder.append(batch) + } + + await pipeline.consume( + Self.frame(bits: 0xAAAA_AAAA_AAAA_AAAA), + context: Self.context(bundleId: "com.agilebits.onepassword7") + ) + await pipeline.flush() + + let batches = await recorder.snapshot() + let batch = try XCTUnwrap(batches.first) + XCTAssertEqual(batch.frames.count, 0) + XCTAssertEqual(batch.droppedCounts.deniedApp, 1) + } + + func testPerceptualHashDistanceForIdenticalSmallNoiseAndUnrelatedImages() throws { + let base = try XCTUnwrap(PerceptualHash(cgImage: Self.image(bits: 0xAAAA_AAAA_AAAA_AAAA))) + let identical = try XCTUnwrap(PerceptualHash(cgImage: Self.image(bits: 0xAAAA_AAAA_AAAA_AAAA))) + let smallNoise = try XCTUnwrap(PerceptualHash(cgImage: Self.image(bits: 0xAAAA_AAAA_AAAA_AAAB))) + let unrelated = try XCTUnwrap(PerceptualHash(cgImage: Self.image(bits: 0x5555_5555_5555_5555))) + + XCTAssertEqual(PerceptualHash.distance(base, identical), 0) + XCTAssertLessThanOrEqual(PerceptualHash.distance(base, smallNoise), 5) + XCTAssertGreaterThan(PerceptualHash.distance(base, unrelated), 5) + } + + func testDedupWindowDropsABAInsideWindowAndEvictsOldest() { + var window = DedupWindow(capacity: 16, threshold: 0) + let a = PerceptualHash(value: 0x1) + let b = PerceptualHash(value: 0x2) + + XCTAssertFalse(window.containsDuplicate(of: a)) + window.remember(a) + XCTAssertFalse(window.containsDuplicate(of: b)) + window.remember(b) + XCTAssertTrue(window.containsDuplicate(of: a)) + + for value in UInt64(3)...UInt64(17) { + window.remember(PerceptualHash(value: value)) + } + XCTAssertFalse(window.containsDuplicate(of: a)) + XCTAssertTrue(window.containsDuplicate(of: b)) + } + + func testBufferedFrameDispatcherBoundsBackpressure() async throws { + let counts = DispatchCounts() + let dispatcher = BufferedFrameDispatcher(bufferingNewest: 2) { _ in + try? await Task.sleep(nanoseconds: 50_000_000) + await counts.recordProcessed() + } onDropped: { + await counts.recordDropped() + } + + for value in 0..<100 { + dispatcher.yield(Self.frame(bits: UInt64(value + 1))) + } + + try await Task.sleep(nanoseconds: 300_000_000) + dispatcher.finish() + let processed = await counts.processed + let dropped = await counts.dropped + XCTAssertLessThan(processed, 100) + XCTAssertGreaterThan(dropped, 0) + } + + static func config(maxFramesPerBatch: Int = 24, maxOcrTextChars: Int = 4096) -> AgentConfig { + AgentConfig( + deviceId: "device_1", + organizationId: "org_1", + endpoint: URL(string: "http://127.0.0.1:8787/submit")!, + allowedBundleIds: ["com.test.App"], + deniedBundleIds: AgentConfig.defaultDeniedBundleIds, + deniedPathPrefixes: AgentConfig.defaultDeniedPathPrefixes, + pauseWindowTitlePatterns: AgentConfig.defaultPauseWindowPatterns, + captureFps: 1, + idleFps: 0.2, + batchIntervalSeconds: 30, + maxFramesPerBatch: maxFramesPerBatch, + maxOcrTextChars: maxOcrTextChars, + localOnly: true + ) + } + + static func context( + bundleId: String = "com.test.App", + windowTitle: String = "clean", + documentPath: String? = nil + ) -> WindowContext { + WindowContext( + bundleId: bundleId, + appName: "TestApp", + windowTitle: windowTitle, + documentPath: documentPath, + pid: 123, + timestamp: Date() + ) + } + + static func frame(bits: UInt64) -> CapturedFrame { + CapturedFrame(timestamp: Date(), cgImage: image(bits: bits), displayId: 1) + } + + static func image(bits: UInt64) -> CGImage { + let size = 8 + let colorSpace = CGColorSpaceCreateDeviceRGB() + let context = CGContext( + data: nil, + width: size, + height: size, + bitsPerComponent: 8, + bytesPerRow: size * 4, + space: colorSpace, + bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue + )! + for y in 0..> index) & 1) == 1 + context.setFillColor(white ? CGColor(gray: 1, alpha: 1) : CGColor(gray: 0, alpha: 1)) + context.fill(CGRect(x: x, y: y, width: 1, height: 1)) + } + } + return context.makeImage()! + } +} + +actor BatchRecorder { + private(set) var batches: [Batch] = [] + + func append(_ batch: Batch) { + batches.append(batch) + } + + func snapshot() -> [Batch] { + batches + } +} + +struct StubOCR: OCRRecognizing { + let text: String + let confidence: Float + + init(text: String, confidence: Float = 0.9) { + self.text = text + self.confidence = confidence + } + + func recognize(cgImage: CGImage) async throws -> OCRResult { + OCRResult(text: text, confidence: confidence, language: "en") + } +} + +actor DispatchCounts { + private(set) var processed = 0 + private(set) var dropped = 0 + + func recordProcessed() { + processed += 1 + } + + func recordDropped() { + dropped += 1 + } +} diff --git a/Tests/agentdTests/SecretScrubberTests.swift b/Tests/agentdTests/SecretScrubberTests.swift index f226d4f..1abdcc4 100644 --- a/Tests/agentdTests/SecretScrubberTests.swift +++ b/Tests/agentdTests/SecretScrubberTests.swift @@ -1,49 +1,86 @@ +// SPDX-License-Identifier: BUSL-1.1 + import XCTest + @testable import agentd final class SecretScrubberTests: XCTestCase { - func testCleanText() { - XCTAssertEqual(SecretScrubber.evaluate("hello world, no secrets here"), .clean) - } + func testCleanText() { + XCTAssertEqual(SecretScrubber.evaluate("hello world, no secrets here"), .clean) + } - func testAwsAccessKeyDropped() { - if case .dropped(let reason) = SecretScrubber.evaluate("the key is AKIAIOSFODNN7EXAMPLE in env") { - XCTAssertEqual(reason, "aws_access_key") - } else { - XCTFail("expected drop") - } - } + func testAwsAccessKeyDropped() { + assertDrops( + "the key is \("AKIA" + String(repeating: "A", count: 16)) in env", reason: "aws_access_key") + } - func testGithubTokenDropped() { - if case .dropped(let reason) = SecretScrubber.evaluate("export GH=ghp_abcdef0123456789ABCDEF0123456789abcd") { - XCTAssertEqual(reason, "github_token") - } else { - XCTFail("expected drop") - } - } + func testGithubTokenDropped() { + let token = "ghp_" + String(repeating: "A", count: 36) + assertDrops("export GH=\(token)", reason: "github_token") + } - func testJwtDropped() { - let jwt = "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJhYmMifQ.signaturepartlonger" - if case .dropped(let reason) = SecretScrubber.evaluate("Authorization: Bearer \(jwt)") { - XCTAssertEqual(reason, "jwt") - } else { - XCTFail("expected drop") - } - } + func testJwtDropped() { + assertDrops("Authorization: Bearer \(Self.jwtFixture())", reason: "jwt") + } - func testPrivateKeyMarkerDropped() { - if case .dropped(let reason) = SecretScrubber.evaluate("-----BEGIN OPENSSH PRIVATE KEY-----\nMIIE...") { - XCTAssertEqual(reason, "ssh_private") - } else { - XCTFail("expected drop") - } + func testPrivateKeyMarkerDropped() { + assertDrops("-----BEGIN OPENSSH PRIVATE KEY-----\nMIIE...", reason: "ssh_private") + } + + func testExpandedProviderPatternsDropped() { + let fixtures: [(String, String)] = [ + ( + "github_fine_grained_token", + ["github", "pat"].joined(separator: "_") + "_" + String(repeating: "A", count: 82) + ), + ("google_api_key", "AIza" + String(repeating: "A", count: 35)), + ("npm_token", "npm_" + String(repeating: "A", count: 36)), + ( + "sendgrid_key", + "SG." + String(repeating: "A", count: 22) + "." + String(repeating: "B", count: 43) + ), + ("digitalocean_pat", "dop_v1_" + String(repeating: "a", count: 64)), + ("azure_storage_key", "AccountKey=" + String(repeating: "A", count: 88)), + ("mailgun_key", "key-" + String(repeating: "a", count: 32)), + ("twilio_api_key", "SK" + String(repeating: "a", count: 32)), + ( + "discord_bot_token", + String(repeating: "A", count: 24) + "." + String(repeating: "B", count: 6) + "." + + String(repeating: "C", count: 27) + ), + ("openai_key", "sk-proj-" + String(repeating: "A", count: 32)), + ] + + for (reason, token) in fixtures { + assertDrops("credential=\(token)", reason: reason) } + } + + func testOpenAIKeyPatternAvoidsShortSkNoise() { + XCTAssertEqual(SecretScrubber.evaluate("not a provider key: sk-demo"), .clean) + } + + func testPathPolicyDeniesSshAndAws() { + let p = PathPolicy(deniedPrefixes: AgentConfig.defaultDeniedPathPrefixes) + let home = FileManager.default.homeDirectoryForCurrentUser.path + XCTAssertTrue(p.deny("\(home)/.ssh/id_ed25519")) + XCTAssertTrue(p.deny("\(home)/.aws/credentials")) + XCTAssertFalse(p.deny("\(home)/Documents/notes.md")) + } + + static func jwtFixture() -> String { + "eyJ" + String(repeating: "a", count: 12) + + ".eyJ" + String(repeating: "b", count: 12) + + "." + String(repeating: "c", count: 16) + } - func testPathPolicyDeniesSshAndAws() { - let p = PathPolicy(deniedPrefixes: AgentConfig.defaultDeniedPathPrefixes) - let home = FileManager.default.homeDirectoryForCurrentUser.path - XCTAssertTrue(p.deny("\(home)/.ssh/id_ed25519")) - XCTAssertTrue(p.deny("\(home)/.aws/credentials")) - XCTAssertFalse(p.deny("\(home)/Documents/notes.md")) + private func assertDrops( + _ text: String, reason: String, file: StaticString = #filePath, line: UInt = #line + ) { + if case .dropped(let actual) = SecretScrubber.evaluate(text) { + XCTAssertEqual(actual, reason, file: file, line: line) + } else { + XCTFail("expected drop for \(reason)", file: file, line: line) } + } } diff --git a/Tests/agentdTests/SubmitterTests.swift b/Tests/agentdTests/SubmitterTests.swift index a63e42a..85bcf39 100644 --- a/Tests/agentdTests/SubmitterTests.swift +++ b/Tests/agentdTests/SubmitterTests.swift @@ -1,74 +1,313 @@ -import XCTest +// SPDX-License-Identifier: BUSL-1.1 + import Foundation +import XCTest + @testable import agentd final class SubmitterTests: XCTestCase { - func testSubmitBatchEncodingMatchesChronicleProtoJSONShape() throws { - let batch = Batch( - batchId: "batch_fixture", - deviceId: "device_1", - organizationId: "org_1", - workspaceId: "workspace_1", - userId: "user_1", - projectId: "project_1", - repository: "evalops/platform", - startedAt: Date(timeIntervalSince1970: 1), - endedAt: Date(timeIntervalSince1970: 2), - frames: [ - ProcessedFrame( - frameHash: String(repeating: "a", count: 64), - perceptualHash: 42, - capturedAt: Date(timeIntervalSince1970: 1), - bundleId: "com.microsoft.VSCode", - appName: "Code", - windowTitle: "chronicle.proto", - documentPath: "/Users/alice/src/platform/proto/chronicle/v1/chronicle.proto", - ocrText: "ChronicleService SubmitBatch", - ocrConfidence: 0.93, - widthPx: 1512, - heightPx: 982, - bytesPng: 120_000 - ) - ], - droppedCounts: DropCounts(secret: 0, duplicate: 1, deniedApp: 0, deniedPath: 0) + func testSubmitBatchEncodingMatchesChronicleProtoJSONShape() throws { + let batch = Batch( + batchId: "batch_fixture", + deviceId: "device_1", + organizationId: "org_1", + workspaceId: "workspace_1", + userId: "user_1", + projectId: "project_1", + repository: "evalops/platform", + startedAt: Date(timeIntervalSince1970: 1), + endedAt: Date(timeIntervalSince1970: 2), + frames: [ + ProcessedFrame( + frameHash: String(repeating: "a", count: 64), + perceptualHash: 42, + capturedAt: Date(timeIntervalSince1970: 1), + bundleId: "com.microsoft.VSCode", + appName: "Code", + windowTitle: "chronicle.proto", + documentPath: "/Users/alice/src/platform/proto/chronicle/v1/chronicle.proto", + ocrText: "ChronicleService SubmitBatch", + ocrConfidence: 0.93, + widthPx: 1512, + heightPx: 982, + bytesPng: 120_000 ) + ], + droppedCounts: DropCounts(secret: 0, duplicate: 1, deniedApp: 0, deniedPath: 0) + ) + + let data = try encodeSubmitBatchRequest(batch, localOnly: true) + let root = try XCTUnwrap(JSONSerialization.jsonObject(with: data) as? [String: Any]) + XCTAssertEqual(root["localOnly"] as? Bool, true) + let encodedBatch = try XCTUnwrap(root["batch"] as? [String: Any]) + XCTAssertEqual(encodedBatch["batchId"] as? String, "batch_fixture") + XCTAssertEqual(encodedBatch["organizationId"] as? String, "org_1") + XCTAssertEqual(encodedBatch["projectId"] as? String, "project_1") + XCTAssertNil(encodedBatch["orgId"]) + + let frames = try XCTUnwrap(encodedBatch["frames"] as? [[String: Any]]) + XCTAssertEqual(frames.first?["perceptualHash"] as? String, "42") + XCTAssertEqual(frames.first?["bytesPng"] as? String, "120000") + XCTAssertEqual(frames.first?["ocrTextTruncated"] as? Bool, false) + XCTAssertEqual(frames.first?["bundleId"] as? String, "com.microsoft.VSCode") + XCTAssertEqual(frames.first?["frameHash"] as? String, String(repeating: "a", count: 64)) + + let droppedCounts = try XCTUnwrap(encodedBatch["droppedCounts"] as? [String: Any]) + XCTAssertEqual(droppedCounts["duplicate"] as? Int, 1) + } + + func testAgentConfigDecodesLegacyOrgIdAndEncodesOrganizationId() throws { + let legacy = """ + { + "deviceId": "device_1", + "orgId": "org_legacy", + "endpoint": "http://127.0.0.1:8787/chronicle.v1.ChronicleService/SubmitBatch", + "localOnly": true + } + """.data(using: .utf8)! + + let cfg = try JSONDecoder().decode(AgentConfig.self, from: legacy) + XCTAssertEqual(cfg.organizationId, "org_legacy") + XCTAssertEqual(cfg.allowedBundleIds, AgentConfig.defaultAllowedBundleIds) + XCTAssertEqual(cfg.maxOcrTextChars, 4096) + XCTAssertEqual(cfg.maxBatchBytes, 512 * 1024 * 1024) + + let encoded = try JSONEncoder().encode(cfg) + let root = try XCTUnwrap(JSONSerialization.jsonObject(with: encoded) as? [String: Any]) + XCTAssertEqual(root["organizationId"] as? String, "org_legacy") + XCTAssertNil(root["orgId"]) + } - let data = try encodeSubmitBatchRequest(batch, localOnly: true) - let root = try XCTUnwrap(JSONSerialization.jsonObject(with: data) as? [String: Any]) - XCTAssertEqual(root["localOnly"] as? Bool, true) - let encodedBatch = try XCTUnwrap(root["batch"] as? [String: Any]) - XCTAssertEqual(encodedBatch["batchId"] as? String, "batch_fixture") - XCTAssertEqual(encodedBatch["organizationId"] as? String, "org_1") - XCTAssertEqual(encodedBatch["projectId"] as? String, "project_1") - XCTAssertNil(encodedBatch["orgId"]) - - let frames = try XCTUnwrap(encodedBatch["frames"] as? [[String: Any]]) - XCTAssertEqual(frames.first?["perceptualHash"] as? String, "42") - XCTAssertEqual(frames.first?["bytesPng"] as? String, "120000") - XCTAssertEqual(frames.first?["bundleId"] as? String, "com.microsoft.VSCode") - XCTAssertEqual(frames.first?["frameHash"] as? String, String(repeating: "a", count: 64)) - - let droppedCounts = try XCTUnwrap(encodedBatch["droppedCounts"] as? [String: Any]) - XCTAssertEqual(droppedCounts["duplicate"] as? Int, 1) + func testAgentConfigPrefersOrganizationIdOverLegacyOrgId() throws { + let data = """ + { + "deviceId": "device_1", + "organizationId": "org_new", + "orgId": "org_legacy", + "endpoint": "http://127.0.0.1:8787/chronicle.v1.ChronicleService/SubmitBatch", + "localOnly": true + } + """.data(using: .utf8)! + + let cfg = try JSONDecoder().decode(AgentConfig.self, from: data) + XCTAssertEqual(cfg.organizationId, "org_new") + } + + func testEndpointPolicyRejectsPlainHttpRemoteAndAllowsHttpsAndLoopback() throws { + let provider = StubCredentialProvider(token: "token") + XCTAssertNoThrow( + try Submitter( + endpoint: URL(string: "https://chronicle.example.com/submit")!, + localOnly: false, + authMode: .bearer(keychainService: "svc", keychainAccount: "acct"), + credentialProvider: provider, + client: StubHTTPClient.success() + )) + XCTAssertNoThrow( + try Submitter( + endpoint: URL(string: "http://127.0.0.1:8787/submit")!, + localOnly: false, + authMode: .bearer(keychainService: "svc", keychainAccount: "acct"), + credentialProvider: provider, + client: StubHTTPClient.success() + )) + XCTAssertThrowsError( + try Submitter( + endpoint: URL(string: "http://chronicle.example.com/submit")!, + localOnly: false, + authMode: .bearer(keychainService: "svc", keychainAccount: "acct"), + credentialProvider: provider, + client: StubHTTPClient.success() + ) + ) { error in + XCTAssertEqual( + error as? SubmitterInitError, .insecureRemoteEndpoint("http://chronicle.example.com/submit") + ) } + } - func testAgentConfigDecodesLegacyOrgIdAndEncodesOrganizationId() throws { - let legacy = """ - { - "deviceId": "device_1", - "orgId": "org_legacy", - "endpoint": "http://127.0.0.1:8787/chronicle.v1.ChronicleService/SubmitBatch", - "localOnly": true - } - """.data(using: .utf8)! - - let cfg = try JSONDecoder().decode(AgentConfig.self, from: legacy) - XCTAssertEqual(cfg.organizationId, "org_legacy") - XCTAssertEqual(cfg.allowedBundleIds, AgentConfig.defaultAllowedBundleIds) - - let encoded = try JSONEncoder().encode(cfg) - let root = try XCTUnwrap(JSONSerialization.jsonObject(with: encoded) as? [String: Any]) - XCTAssertEqual(root["organizationId"] as? String, "org_legacy") - XCTAssertNil(root["orgId"]) + func testRemoteSubmitterRequiresAuth() { + XCTAssertThrowsError( + try Submitter( + endpoint: URL(string: "https://chronicle.example.com/submit")!, + localOnly: false, + authMode: .none, + credentialProvider: StubCredentialProvider(token: ""), + client: StubHTTPClient.success() + ) + ) { error in + XCTAssertEqual(error as? SubmitterInitError, .missingRemoteAuth) } + } + + func testBearerModeAddsAuthorizationHeader() async throws { + let submitter = try Submitter( + endpoint: URL(string: "https://chronicle.example.com/submit")!, + localOnly: false, + authMode: .bearer(keychainService: "svc", keychainAccount: "acct"), + credentialProvider: StubCredentialProvider(token: "secret-token"), + client: StubHTTPClient.success() + ) + + let request = await submitter.makeRequest(body: Data("{}".utf8)) + XCTAssertEqual(request.value(forHTTPHeaderField: "Authorization"), "Bearer secret-token") + XCTAssertEqual(request.value(forHTTPHeaderField: "Connect-Protocol-Version"), "1") + } + + func testSubmitterPersistsLocalOnServerAndTransportFailures() async throws { + let cases: [(String, StubHTTPClient)] = [ + ("client_error", .status(400, body: #"{"batchId":"nope"}"#)), + ("server_error", .status(500, body: #"{"batchId":"nope"}"#)), + ("malformed_body", .status(200, body: #"{"#)), + ("timeout", .failure(URLError(.timedOut))), + ] + + for (id, client) in cases { + let dir = try makeTemporaryDirectory() + let submitter = try Submitter( + endpoint: URL(string: "https://chronicle.example.com/submit")!, + localOnly: false, + authMode: .bearer(keychainService: "svc", keychainAccount: "acct"), + credentialProvider: StubCredentialProvider(token: "token"), + client: client, + batchDirectory: dir + ) + + let result = await submitter.submit(Self.batch(id: id)) + XCTAssertEqual(result, .persistedLocal, id) + XCTAssertTrue( + FileManager.default.fileExists(atPath: dir.appendingPathComponent("\(id).json").path), id) + } + } + + func testLocalBatchSweepRemovesOldFiles() async throws { + let dir = try makeTemporaryDirectory() + try writeBatchFile( + dir.appendingPathComponent("old.json"), bytes: 10, + modified: Date(timeIntervalSinceNow: -8 * 24 * 60 * 60)) + try writeBatchFile(dir.appendingPathComponent("new.json"), bytes: 10, modified: Date()) + let submitter = try Submitter( + endpoint: URL(string: "http://127.0.0.1:8787/submit")!, + localOnly: true, + batchDirectory: dir, + maxBatchAgeDays: 7 + ) + + await submitter.sweepLocalBatches() + XCTAssertFalse( + FileManager.default.fileExists(atPath: dir.appendingPathComponent("old.json").path)) + XCTAssertTrue( + FileManager.default.fileExists(atPath: dir.appendingPathComponent("new.json").path)) + } + + func testLocalBatchSweepRemovesOldestFilesOverByteBudget() async throws { + let dir = try makeTemporaryDirectory() + try writeBatchFile( + dir.appendingPathComponent("oldest.json"), bytes: 80, modified: Date(timeIntervalSince1970: 1) + ) + try writeBatchFile( + dir.appendingPathComponent("middle.json"), bytes: 80, modified: Date(timeIntervalSince1970: 2) + ) + try writeBatchFile( + dir.appendingPathComponent("newest.json"), bytes: 80, modified: Date(timeIntervalSince1970: 3) + ) + let submitter = try Submitter( + endpoint: URL(string: "http://127.0.0.1:8787/submit")!, + localOnly: true, + batchDirectory: dir, + maxBatchBytes: 120, + maxBatchAgeDays: 365 * 100 + ) + + await submitter.sweepLocalBatches() + XCTAssertFalse( + FileManager.default.fileExists(atPath: dir.appendingPathComponent("oldest.json").path)) + XCTAssertFalse( + FileManager.default.fileExists(atPath: dir.appendingPathComponent("middle.json").path)) + XCTAssertTrue( + FileManager.default.fileExists(atPath: dir.appendingPathComponent("newest.json").path)) + } + + static func batch(id: String = "batch_fixture") -> Batch { + Batch( + batchId: id, + deviceId: "device_1", + organizationId: "org_1", + workspaceId: nil, + userId: nil, + projectId: nil, + repository: nil, + startedAt: Date(timeIntervalSince1970: 1), + endedAt: Date(timeIntervalSince1970: 2), + frames: [ + ProcessedFrame( + frameHash: String(repeating: "a", count: 64), + perceptualHash: 42, + capturedAt: Date(timeIntervalSince1970: 1), + bundleId: "com.microsoft.VSCode", + appName: "Code", + windowTitle: "chronicle.proto", + documentPath: nil, + ocrText: "ChronicleService SubmitBatch", + ocrConfidence: 0.93, + widthPx: 10, + heightPx: 10, + bytesPng: 400 + ) + ], + droppedCounts: DropCounts(secret: 0, duplicate: 0, deniedApp: 0, deniedPath: 0) + ) + } + + private func makeTemporaryDirectory() throws -> URL { + let dir = FileManager.default.temporaryDirectory + .appendingPathComponent("agentd-tests-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + return dir + } + + private func writeBatchFile(_ url: URL, bytes: Int, modified: Date) throws { + try Data(repeating: 0x41, count: bytes).write(to: url) + try FileManager.default.setAttributes([.modificationDate: modified], ofItemAtPath: url.path) + } +} + +struct StubCredentialProvider: SubmitterCredentialProviding { + let token: String + + func bearerToken(service: String, account: String) throws -> String { + token + } + + func clientIdentity(label: String) throws -> ClientIdentity { + throw SubmitterInitError.missingClientIdentity(label: label) + } +} + +struct StubHTTPClient: HTTPClient { + let handler: @Sendable (URLRequest) async throws -> (Data, URLResponse) + + func data(for request: URLRequest) async throws -> (Data, URLResponse) { + try await handler(request) + } + + static func success() -> StubHTTPClient { + status(200, body: #"{"batchId":"ok"}"#) + } + + static func status(_ statusCode: Int, body: String) -> StubHTTPClient { + StubHTTPClient { request in + let response = HTTPURLResponse( + url: request.url!, + statusCode: statusCode, + httpVersion: "HTTP/1.1", + headerFields: nil + )! + return (Data(body.utf8), response) + } + } + + static func failure(_ error: Error) -> StubHTTPClient { + StubHTTPClient { _ in throw error } + } }