diff --git a/.gitignore b/.gitignore index 403b5a0..4bc23f4 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,7 @@ dist/ .swiftpm/ PLAN.md MEMORY.md +plans/ .claude/ .fuzz-progress fuzz-crashes/ diff --git a/Package.swift b/Package.swift index 24575b1..3d1b6f2 100644 --- a/Package.swift +++ b/Package.swift @@ -25,7 +25,8 @@ let package = Package( .executableTarget( name: "Perch", dependencies: ["PerchCore"], - swiftSettings: swiftSettings + swiftSettings: swiftSettings, + linkerSettings: [.linkedLibrary("sqlite3")] ), // Harness tooling β€” offline oracles for bug-bashing PerchCore. Not // shipped in the app bundle (Makefile copies only Perch/perch-bridge). diff --git a/README.md b/README.md index 55722f5..a326e12 100644 --- a/README.md +++ b/README.md @@ -61,7 +61,7 @@ and what they've **left behind**: | 🐦 **Every session at a glance** | Live list of all Claude Code and Codex sessions β€” running / waiting / idle, last message, context gauge, red badge on any session that just ran something dangerous. | | 🎫 **Token usage** | Today / 7-day / 30-day totals in the notch, rate-limit gauges with reset countdowns, and a full per-day / per-model / per-project dashboard (menu bar β†’ **Token Usage…**). | | 🌳 **Worktree housekeeping** | A read-only cross-project audit of the git worktrees agent sessions leave behind β€” classified `reclaimable` (clean, merged, stale), `review` (dirty or ahead of the default branch), `active` (a live session or recently touched), or `orphaned` β€” with disk sizes and a *Copy cleanup commands* button (menu bar β†’ **Worktrees…**). Perch scores and reports; it never deletes. | -| πŸͺΆ **Zero footprint** | No dependencies, no telemetry, and an auditable all-Swift codebase. If Perch dies, your agents don't even notice. | +| πŸͺΆ **Zero footprint** | No third-party dependencies or telemetry, and an auditable all-Swift codebase using macOS's system SQLite for minimal local detection metadata. If Perch dies, your agents don't even notice. |
🧭 Footholds Β· 🌳 Worktrees Β· πŸ“Š token dashboard screenshots @@ -228,7 +228,8 @@ Option 2 β€” it's two commands. Claude Code / Codex ──hooks──▢ perch-bridge ──unix socket──▢ Perch.app (your terminal) (fire & forget, β”œβ”€ risk scoring keeps all decisions ~10 ms, exits) β”œβ”€ notch card + notification - └─ sessions / tokens / score + β”œβ”€ sessions / tokens / score + └─ minimal local SQLite record ``` Hooks invoke the bundled `perch-bridge`, which forwards each event over a @@ -238,6 +239,14 @@ danger raises an OS notification and a notch card. In parallel, Perch tails transcript/rollout files and validates liveness against `~/.claude/sessions` pid files, so sessions started before Perch launched are covered too. +Accepted caution/danger detections also write compact metadata to +`~/Library/Application Support/Perch/detections.sqlite3` after the hook reply +and live-feed deduplication. The database retains 30 days and restores only the +past hour's posture after restart. It never stores commands, tool payloads, +paths, prompts, finding prose, decisions, or outcomes. The versioned, +read-only consumer contract is documented in +[Detection storage](docs/detection-storage.md). + One caveat: Claude's rate-limit gauges are fed by the statusline payload, which only terminal `claude` sessions render β€” the Claude desktop app never invokes it. Detection, sessions, and token totals work everywhere. @@ -257,17 +266,23 @@ be audited, not trusted**: fire-and-forget ~10 ms, and if Perch is wedged the hook gives up on its own after 5 s β€” the agent always proceeds. - **100% local detection, zero telemetry.** No analytics, no cloud detection - service β€” nothing Perch observes ever leaves your machine. The one network + service β€” nothing Perch observes ever leaves your machine. Accepted + caution/danger detections retain only minimal metadata in local SQLite for + 30 days; there is no uploader. The one network call in the codebase is the optional update check: an unauthenticated GET to the GitHub releases API, on by default, toggleable from the menu bar (**Check Automatically**), and zero network when off. Verify it yourself: `grep -rn "URLSession\|NWConnection" Sources/` matches only [`UpdateChecker.swift`](Sources/Perch/Model/UpdateChecker.swift). -- **The detector can't leak what it inspects.** Risk scoring is pure string - matching in-process; flagged commands are shown to you and written to your - local log, never sent anywhere. -- **No dependencies.** AppKit/SwiftUI/Foundation only. The supply-chain - surface is this repo β€” read it top to bottom. +- **The detector doesn't persist what it inspects.** Risk scoring is pure + string matching in-process. Commands and tool payloads may appear in the + live card, but SQLite stores only event/endpoint/tool identifiers, Perch + version, risk level, and stable finding codes. No commands, paths, prompts, + content, decisions, or outcomes are stored. See + [Detection storage](docs/detection-storage.md). +- **No third-party dependencies.** AppKit/SwiftUI/Foundation plus the SQLite + library shipped with macOS. The supply-chain surface is this repo and the + operating system β€” read the app top to bottom. - **Config writes are surgical and reversible.** Installing hooks parse-merges your `~/.claude/settings.json` / `~/.codex/hooks.json` (your keys and hooks preserved), writes a timestamped backup, and replaces diff --git a/Sources/Perch/App/AppDelegate.swift b/Sources/Perch/App/AppDelegate.swift index e00b6ac..5b442da 100644 --- a/Sources/Perch/App/AppDelegate.swift +++ b/Sources/Perch/App/AppDelegate.swift @@ -22,6 +22,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate { let securityPosture = SecurityPosture() let monitoringHealth = MonitoringHealth() let notificationPreferences = NotificationPreferences() + private let detectionStore = DetectionStore() private var socketServer: UnixSocketServer? private var livenessMonitor: LivenessMonitor? @@ -69,6 +70,13 @@ final class AppDelegate: NSObject, NSApplicationDelegate { sessionStore.riskFeed = riskFeed sessionStore.usageStore = usageStore sessionStore.securityPosture = securityPosture + sessionStore.detectionStore = detectionStore + detectionStore.start { [weak self] result in + guard case .success(let restored) = result else { return } + Task { @MainActor in + self?.securityPosture.hydrate(restored) + } + } let notifier = Notifier(sessions: sessionStore, preferences: notificationPreferences) self.notifier = notifier @@ -213,6 +221,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate { func applicationWillTerminate(_ notification: Notification) { monitoringHealth.persistVerification() socketServer?.stop() + detectionStore.close() PerchLog.info("Perch terminated") } diff --git a/Sources/Perch/App/Selftest.swift b/Sources/Perch/App/Selftest.swift index 6bebd15..e08493b 100644 --- a/Sources/Perch/App/Selftest.swift +++ b/Sources/Perch/App/Selftest.swift @@ -1,5 +1,6 @@ import Foundation import PerchCore +import SQLite3 /// In-binary selftest, run via `Perch --selftest`. Ports the old XCTest /// suites (CoreTests/StoreTests) so they keep running on machines whose @@ -48,6 +49,12 @@ enum Selftest { riskFeedDedupesSameCall(t) riskFeedDismissAndFocusClamp(t) riskFeedRetainsRecentDetections(t) + detectionIdentityHostNameIsNonempty(t) + detectionStoreSchemaAndReopen(t) + detectionStoreTransactionAndDedupe(t) + detectionStoreRetentionAndPostureRestore(t) + detectionStoreFailureAndPermissions(t) + sessionStorePersistsOnlyAcceptedMetadata(t) monitoringSnapshotSeparatesCoverageFromPosture(t) monitoringHealthSeparatesConfigurationFromVerification(t) notificationCoalescerSuppressesOnlyOverlap(t) @@ -715,6 +722,12 @@ private extension Selftest { t.expectEqual(posture.dangerCount, 1, "dangerCounted") t.expectEqual(posture.cautionCount, 1, "cautionCounted") + posture.hydrate([ + DetectionPostureEvent(level: .caution, observedAt: now.addingTimeInterval(-20)), + ], now: now) + t.expectEqual(posture.score, 65, "hydratedEventCounts") + t.expectEqual(posture.cautionCount, 2, "hydratedCautionCounted") + // Events age out of the 1h window and the score recovers. posture.recompute(now: now.addingTimeInterval(SecurityPosture.window + 60)) t.expectEqual(posture.score, 100, "recoversAfterWindow") @@ -2340,3 +2353,479 @@ private func codexTrustEventNamesAndConfigScan(_ t: Checker) { t.expectEqual(CodexHookTrust.trustRecordCount(configToml: "trusted_hash = \"sha256:x\""), 0, "hashOutsideSectionIgnored") } + +// MARK: - DetectionStore + +private extension Selftest { + @MainActor + static func detectionIdentityHostNameIsNonempty(_ t: Checker) { + t.suite("DetectionIdentity.localHostName") + t.expectTrue(!DetectionIdentity.current.endpointHost.isEmpty, "hostNameIsNonempty") + } + + @MainActor + static func detectionStoreSchemaAndReopen(_ t: Checker) { + t.suite("DetectionStore.schemaAndReopen") + let root = detectionTempRoot() + defer { try? FileManager.default.removeItem(at: root) } + let databaseURL = root.appendingPathComponent("perch/detections.sqlite3") + let now = Date(timeIntervalSince1970: 1_900_000_000) + + let store = detectionStore(at: databaseURL) + let initial = try? store.startSynchronously(now: now) + t.expectEqual(initial?.count, 0, "freshStoreHasNoPosture") + t.expectEqual(DetectionStore.inspect(databaseURL: databaseURL), .ready, + "freshContractIsInspectable") + t.expectEqual(sqliteScalar(databaseURL, "PRAGMA application_id"), + String(DetectionStore.applicationID), "applicationID") + t.expectEqual(sqliteScalar(databaseURL, "PRAGMA user_version"), + String(DetectionStore.schemaVersion), "schemaVersion") + t.expectEqual(sqliteColumnNames(databaseURL, "SELECT * FROM detection_export_v1 LIMIT 0"), + DetectionStore.exportColumns, "exactExportColumns") + store.close() + + let reopened = detectionStore(at: databaseURL) + t.expectEqual((try? reopened.startSynchronously(now: now))?.count, 0, + "reopenSucceeds") + t.expectEqual(DetectionStore.inspect(databaseURL: databaseURL), .ready, + "reopenedContractIsReady") + reopened.close() + + // A conflicting schema makes migration fail atomically. The store + // preserves the database for diagnosis rather than replacing it. + let conflictURL = root.appendingPathComponent("conflict/detections.sqlite3") + t.expectTrue(sqliteExecute( + conflictURL, + "CREATE TABLE detection_events (existing_column TEXT)"), + "createMigrationConflict") + let conflictStore = detectionStore(at: conflictURL) + t.expectNil(try? conflictStore.startSynchronously(now: now), + "conflictingMigrationFails") + conflictStore.close() + t.expectEqual(sqliteScalar(conflictURL, "PRAGMA user_version"), "0", + "failedMigrationLeavesVersionZero") + t.expectEqual(sqliteScalar(conflictURL, "PRAGMA application_id"), "0", + "failedMigrationRollsBackApplicationID") + t.expectEqual( + sqliteRows(conflictURL, "PRAGMA table_info(detection_events)")? + .compactMap { $0[safe: 1] ?? nil }, + ["existing_column"], + "failedMigrationPreservesOriginalTable") + } + + @MainActor + static func detectionStoreTransactionAndDedupe(_ t: Checker) { + t.suite("DetectionStore.transactionAndDedupe") + let root = detectionTempRoot() + defer { try? FileManager.default.removeItem(at: root) } + let databaseURL = root.appendingPathComponent("perch/detections.sqlite3") + let now = Date(timeIntervalSince1970: 1_900_000_000) + let store = detectionStore(at: databaseURL) + guard (try? store.startSynchronously(now: now)) != nil else { + t.expectTrue(false, "open") + return + } + + let first = detectionRecord( + id: "event-a", + at: now, + toolUseID: "tool-1", + findings: [ + DetectionFindingRecord(code: "destructive-delete", level: .danger), + DetectionFindingRecord(code: "privilege-escalation", level: .danger), + ]) + t.expectEqual(try? store.insertSynchronously(first), true, "eventInserted") + + let rows = sqliteRows(databaseURL, """ + SELECT producer, event_id, risk_level, finding_code, finding_level + FROM detection_export_v1 + ORDER BY observed_at_ms, event_id, finding_code + """) + t.expectEqual(rows?.count, 2, "oneExportRowPerFinding") + t.expectEqual(rows?.first?[safe: 0] ?? nil, "perch", "producerConstant") + t.expectEqual(rows?.first?[safe: 1] ?? nil, "event-a", "eventIDExported") + t.expectEqual(rows?.first?[safe: 2] ?? nil, "danger", "riskLevelExported") + t.expectEqual(rows?.compactMap { $0[safe: 3] ?? nil }, + ["destructive-delete", "privilege-escalation"], + "findingCodesExportedInStableOrder") + + // A child-row failure rolls back the parent and every prior child. + let invalid = detectionRecord( + id: "event-invalid", + at: now.addingTimeInterval(1), + toolUseID: "tool-invalid", + findings: [ + DetectionFindingRecord(code: "duplicate", level: .danger), + DetectionFindingRecord(code: "duplicate", level: .danger), + ]) + t.expectNil(try? store.insertSynchronously(invalid), "childFailureReported") + t.expectEqual(sqliteScalar( + databaseURL, + "SELECT count(*) FROM detection_events WHERE event_id='event-invalid'"), + "0", "childFailureRollsBackParent") + + let duplicateTool = detectionRecord( + id: "event-b", + at: now.addingTimeInterval(2), + toolUseID: "tool-1") + t.expectEqual(try? store.insertSynchronously(duplicateTool), false, + "toolUseConflictIsSuccessfulNoOp") + t.expectEqual(sqliteScalar(databaseURL, "SELECT count(*) FROM detection_events"), + "1", "dedupeDoesNotAddEvent") + + let eventColumns = sqliteRows(databaseURL, "PRAGMA table_info(detection_events)")? + .compactMap { $0[safe: 1] ?? nil } + let findingColumns = sqliteRows(databaseURL, "PRAGMA table_info(detection_findings)")? + .compactMap { $0[safe: 1] ?? nil } + let storedColumns = Set((eventColumns ?? []) + (findingColumns ?? [])) + let forbidden = Set([ + "tool_input", "tool_output", "command", "cwd", "path", "repo", + "url", "prompt", "response", "patch", "content", "input_hash", + "finding_message", "summary", "outcome", + ]) + t.expectTrue(storedColumns.isDisjoint(with: forbidden), + "schemaContainsNoRawOrDerivedContent") + store.close() + } + + @MainActor + static func detectionStoreRetentionAndPostureRestore(_ t: Checker) { + t.suite("DetectionStore.retentionAndPostureRestore") + let root = detectionTempRoot() + defer { try? FileManager.default.removeItem(at: root) } + let databaseURL = root.appendingPathComponent("perch/detections.sqlite3") + let now = Date(timeIntervalSince1970: 1_900_000_000) + let store = detectionStore(at: databaseURL) + guard (try? store.startSynchronously(now: now)) != nil else { + t.expectTrue(false, "open") + return + } + + let records = [ + detectionRecord(id: "expired", at: now.addingTimeInterval(-31 * 86_400), + toolUseID: "old"), + detectionRecord(id: "retained", at: now.addingTimeInterval(-29 * 86_400), + toolUseID: "retained"), + detectionRecord(id: "recent-a", at: now.addingTimeInterval(-30 * 60), + toolUseID: "recent-a", risk: .caution, + findings: [ + DetectionFindingRecord(code: "agent-memory-write", + level: .caution), + ]), + detectionRecord(id: "recent-b", at: now.addingTimeInterval(-10 * 60), + toolUseID: "recent-b"), + ] + for record in records { + t.expectEqual(try? store.insertSynchronously(record), true, + "insert.\(record.eventID)") + } + t.expectEqual(try? store.pruneSynchronously(now: now), 1, "oneExpiredEventPruned") + t.expectEqual(sqliteScalar(databaseURL, "SELECT count(*) FROM detection_findings"), + "3", "pruneCascadesToFindings") + t.expectEqual(sqliteRows(databaseURL, """ + SELECT event_id FROM detection_export_v1 + ORDER BY observed_at_ms, event_id, finding_code + """)?.compactMap { $0.first ?? nil }, + ["retained", "recent-a", "recent-b"], + "exportSupportsStableCursorOrder") + store.close() + + let reopened = detectionStore(at: databaseURL) + let restored = try? reopened.startSynchronously(now: now) + t.expectEqual(restored?.map(\.level), [.caution, .danger], + "onlyPastHourRiskLevelsRestored") + t.expectTrue(restored?.allSatisfy { + now.timeIntervalSince($0.observedAt) <= SecurityPosture.window + } == true, "restoredEventsAreBoundedToPostureWindow") + reopened.close() + } + + @MainActor + static func detectionStoreFailureAndPermissions(_ t: Checker) { + t.suite("DetectionStore.failureAndPermissions") + let root = detectionTempRoot() + defer { try? FileManager.default.removeItem(at: root) } + let databaseURL = root.appendingPathComponent("perch/detections.sqlite3") + let store = detectionStore(at: databaseURL) + t.expectTrue((try? store.startSynchronously()) != nil, "open") + + let directoryMode = posixMode(databaseURL.deletingLastPathComponent()) + let databaseMode = posixMode(databaseURL) + let walURL = URL(fileURLWithPath: databaseURL.path + "-wal") + let shmURL = URL(fileURLWithPath: databaseURL.path + "-shm") + t.expectEqual(directoryMode, 0o700, "directoryMode0700") + t.expectEqual(databaseMode, 0o600, "databaseMode0600") + t.expectTrue(FileManager.default.fileExists(atPath: walURL.path), "walExists") + t.expectTrue(FileManager.default.fileExists(atPath: shmURL.path), "shmExists") + t.expectEqual(posixMode(walURL), 0o600, "walMode0600") + t.expectEqual(posixMode(shmURL), 0o600, "shmMode0600") + store.close() + + let missingURL = root.appendingPathComponent("missing/detections.sqlite3") + t.expectEqual(DetectionStore.inspect(databaseURL: missingURL), .notCreated, + "missingStoreReported") + + let corruptURL = root.appendingPathComponent("corrupt/detections.sqlite3") + try? FileManager.default.createDirectory( + at: corruptURL.deletingLastPathComponent(), + withIntermediateDirectories: true) + let corruptBytes = Data("not a sqlite database".utf8) + try? corruptBytes.write(to: corruptURL) + let corruptStore = detectionStore(at: corruptURL) + t.expectNil(try? corruptStore.startSynchronously(), "corruptStoreRejected") + corruptStore.close() + t.expectEqual(DetectionStore.inspect(databaseURL: corruptURL), .unavailable, + "corruptStoreReportedWithoutReplacement") + t.expectEqual(try? Data(contentsOf: corruptURL), corruptBytes, + "corruptDatabasePreserved") + + let line = DetectionStore.diagnosticLine(databaseURL: databaseURL) + t.expectTrue(line.contains("schema 1"), "doctorShowsSchema") + t.expectTrue(line.contains("contract v1"), "doctorShowsContract") + t.expectTrue(line.contains("30-day retention"), "doctorShowsRetention") + } + + @MainActor + static func sessionStorePersistsOnlyAcceptedMetadata(_ t: Checker) { + t.suite("SessionStore.persistsOnlyAcceptedMetadata") + let root = detectionTempRoot() + defer { try? FileManager.default.removeItem(at: root) } + let databaseURL = root.appendingPathComponent("perch/detections.sqlite3") + let durable = detectionStore(at: databaseURL) + guard (try? durable.startSynchronously()) != nil else { + t.expectTrue(false, "open") + return + } + + let store = SessionStore() + let feed = RiskFeed() + let posture = SecurityPosture() + store.riskFeed = feed + store.securityPosture = posture + store.detectionStore = durable + let recorder = ReplyRecorder() + let secret = "must-never-enter-detection-sqlite" + let dangerousInput: JSONValue = .object([ + "command": .string("sudo rm -rf /tmp/\(secret)"), + ]) + + store.handleEnvelope(hookEnvelope(event: "PreToolUse", extra: [ + "tool_name": .string("Bash"), + "tool_input": dangerousInput, + "tool_use_id": .string("tool-accepted"), + "cwd": .string("/tmp/\(secret)"), + ])) { recorder.record($0) } + t.expectEqual(recorder.count, 1, "hookReplyIsImmediate") + t.expectNil(recorder.replies.first?.stdout, "hookReplyRemainsObserveOnly") + durable.waitUntilIdle() + + let rows = sqliteRows(databaseURL, """ + SELECT endpoint_user, endpoint_host, producer_version, agent, + session_id, tool_use_id, tool_name, risk_level, finding_code + FROM detection_export_v1 + """) + t.expectEqual( + sqliteScalar(databaseURL, "SELECT count(*) FROM detection_events"), + "1", "acceptedDetectionPersisted") + t.expectTrue(rows?.isEmpty == false, "acceptedFindingsExported") + t.expectEqual(rows?.first?[safe: 0] ?? nil, "test-user", "endpointUserStored") + t.expectEqual(rows?.first?[safe: 1] ?? nil, "test-host", "endpointHostStored") + t.expectEqual(rows?.first?[safe: 2] ?? nil, "v-test", "producerVersionStored") + t.expectEqual(rows?.first?[safe: 3] ?? nil, "claude", "agentStored") + t.expectEqual(rows?.first?[safe: 4] ?? nil, "s-1", "sessionStored") + t.expectEqual(rows?.first?[safe: 5] ?? nil, "tool-accepted", "toolUseStored") + t.expectEqual(rows?.first?[safe: 6] ?? nil, "Bash", "toolNameStored") + t.expectEqual(rows?.first?[safe: 7] ?? nil, "danger", "riskStored") + + // The second hook for the same real call is rejected at RiskFeed's + // boundary, so it cannot create another durable row. + store.handleEnvelope(hookEnvelope(event: "PermissionRequest", extra: [ + "tool_name": .string("Bash"), + "tool_input": dangerousInput, + "tool_use_id": .string("tool-accepted"), + ])) { recorder.record($0) } + durable.waitUntilIdle() + t.expectEqual(sqliteScalar(databaseURL, "SELECT count(*) FROM detection_events"), + "1", "feedDedupeIsPersistenceBoundary") + + store.handleEnvelope(hookEnvelope(event: "PreToolUse", extra: [ + "tool_name": .string("Read"), + "tool_input": .object(["file_path": .string("/tmp/\(secret)")]), + "tool_use_id": .string("tool-safe"), + ])) { recorder.record($0) } + durable.waitUntilIdle() + t.expectEqual(sqliteScalar(databaseURL, "SELECT count(*) FROM detection_events"), + "1", "safeCallNotPersisted") + + let secretBytes = Data(secret.utf8) + let sqliteFiles = [ + databaseURL, + URL(fileURLWithPath: databaseURL.path + "-wal"), + URL(fileURLWithPath: databaseURL.path + "-shm"), + ] + t.expectTrue(sqliteFiles.allSatisfy { url in + guard let data = try? Data(contentsOf: url) else { return true } + return data.range(of: secretBytes) == nil + }, "rawInputAndCwdAbsentFromDatabaseFiles") + durable.close() + + // A disabled durable store never suppresses live behavior. + let corruptURL = root.appendingPathComponent("disabled/detections.sqlite3") + try? FileManager.default.createDirectory( + at: corruptURL.deletingLastPathComponent(), + withIntermediateDirectories: true) + try? Data("broken".utf8).write(to: corruptURL) + let disabled = detectionStore(at: corruptURL) + t.expectNil(try? disabled.startSynchronously(), "disabledStoreSetup") + let liveStore = SessionStore() + let liveFeed = RiskFeed() + let livePosture = SecurityPosture() + liveStore.riskFeed = liveFeed + liveStore.securityPosture = livePosture + liveStore.detectionStore = disabled + let liveReplies = ReplyRecorder() + liveStore.handleEnvelope(hookEnvelope(event: "PreToolUse", extra: [ + "tool_name": .string("Bash"), + "tool_input": .object(["command": .string("sudo shutdown -h now")]), + ])) { liveReplies.record($0) } + disabled.waitUntilIdle() + t.expectEqual(liveReplies.count, 1, "storageFailureStillReplies") + t.expectEqual(liveFeed.count, 1, "storageFailureStillShowsCard") + t.expectEqual(livePosture.dangerCount, 1, "storageFailureStillUpdatesPosture") + disabled.close() + } + + static func detectionTempRoot() -> URL { + FileManager.default.temporaryDirectory + .appendingPathComponent("perch-detection-selftest-\(UUID().uuidString)", + isDirectory: true) + } + + static func detectionStore(at url: URL) -> DetectionStore { + DetectionStore( + databaseURL: url, + identity: DetectionIdentity( + endpointUser: "test-user", + endpointHost: "test-host", + producerVersion: "v-test")) + } + + static func detectionRecord( + id: String, + at: Date, + toolUseID: String?, + risk: RiskLevel = .danger, + findings: [DetectionFindingRecord] = [ + DetectionFindingRecord(code: "destructive-delete", level: .danger), + ] + ) -> DetectionRecord { + DetectionRecord( + recordSchemaVersion: DetectionStore.recordSchemaVersion, + eventID: id, + observedAtMs: Int64((at.timeIntervalSince1970 * 1000).rounded()), + endpointUser: "test-user", + endpointHost: "test-host", + producerVersion: "v-test", + agent: .claude, + sessionID: "session-1", + toolUseID: toolUseID, + toolName: "Bash", + riskLevel: risk, + findings: findings) + } + + static func sqliteExecute(_ url: URL, _ sql: String) -> Bool { + try? FileManager.default.createDirectory( + at: url.deletingLastPathComponent(), + withIntermediateDirectories: true) + var database: OpaquePointer? + let openCode = url.path.withCString { + sqlite3_open_v2( + $0, + &database, + SQLITE_OPEN_READWRITE | SQLITE_OPEN_CREATE | SQLITE_OPEN_FULLMUTEX, + nil) + } + guard openCode == SQLITE_OK, let database else { + if let database { sqlite3_close_v2(database) } + return false + } + defer { sqlite3_close_v2(database) } + return sql.withCString { + sqlite3_exec(database, $0, nil, nil, nil) + } == SQLITE_OK + } + + static func sqliteRows(_ url: URL, _ sql: String) -> [[String?]]? { + var database: OpaquePointer? + let openCode = url.path.withCString { + sqlite3_open_v2($0, &database, SQLITE_OPEN_READONLY | SQLITE_OPEN_FULLMUTEX, nil) + } + guard openCode == SQLITE_OK, let database else { + if let database { sqlite3_close_v2(database) } + return nil + } + defer { sqlite3_close_v2(database) } + sqlite3_busy_timeout(database, 250) + + var statement: OpaquePointer? + let prepareCode = sql.withCString { + sqlite3_prepare_v2(database, $0, -1, &statement, nil) + } + guard prepareCode == SQLITE_OK, let statement else { return nil } + defer { sqlite3_finalize(statement) } + + var rows: [[String?]] = [] + while true { + let code = sqlite3_step(statement) + if code == SQLITE_DONE { return rows } + guard code == SQLITE_ROW else { return nil } + rows.append((0.. String? { + sqliteRows(url, sql)?.first?.first ?? nil + } + + static func sqliteColumnNames(_ url: URL, _ sql: String) -> [String]? { + var database: OpaquePointer? + let openCode = url.path.withCString { + sqlite3_open_v2($0, &database, SQLITE_OPEN_READONLY | SQLITE_OPEN_FULLMUTEX, nil) + } + guard openCode == SQLITE_OK, let database else { + if let database { sqlite3_close_v2(database) } + return nil + } + defer { sqlite3_close_v2(database) } + var statement: OpaquePointer? + let prepareCode = sql.withCString { + sqlite3_prepare_v2(database, $0, -1, &statement, nil) + } + guard prepareCode == SQLITE_OK, let statement else { return nil } + defer { sqlite3_finalize(statement) } + return (0.. Int? { + let attributes = try? FileManager.default.attributesOfItem(atPath: url.path) + return (attributes?[.posixPermissions] as? NSNumber).map { + $0.intValue & 0o777 + } + } +} + +private extension Array { + subscript(safe index: Index) -> Element? { + indices.contains(index) ? self[index] : nil + } +} diff --git a/Sources/Perch/Install/Doctor.swift b/Sources/Perch/Install/Doctor.swift index 73271da..4f6d844 100644 --- a/Sources/Perch/Install/Doctor.swift +++ b/Sources/Perch/Install/Doctor.swift @@ -34,6 +34,7 @@ enum Doctor { + "Token usage totals come from transcripts and work for all session types.") lines.append(CodexHookTrust.doctorLine() + " If Codex hooks are installed but tool calls never surface, missing trust is why.") + lines.append(DetectionStore.diagnosticLine()) lines.append("Log: \(PerchPaths.logFile.path)") return DoctorReport(state: aggregateState(for: checks), checks: checks, text: lines.joined(separator: "\n")) diff --git a/Sources/Perch/Model/DetectionStore.swift b/Sources/Perch/Model/DetectionStore.swift new file mode 100644 index 0000000..f77fa87 --- /dev/null +++ b/Sources/Perch/Model/DetectionStore.swift @@ -0,0 +1,745 @@ +import Darwin +import Foundation +import PerchCore +import SQLite3 + +struct DetectionPostureEvent: Equatable, Sendable { + let level: RiskLevel + let observedAt: Date +} + +struct DetectionFindingRecord: Equatable, Sendable { + let code: String + let level: RiskLevel +} + +struct DetectionRecord: Equatable, Sendable { + let recordSchemaVersion: Int + let eventID: String + let observedAtMs: Int64 + let endpointUser: String + let endpointHost: String + let producerVersion: String + let agent: AgentKind + let sessionID: String + let toolUseID: String? + let toolName: String + let riskLevel: RiskLevel + let findings: [DetectionFindingRecord] +} + +struct DetectionIdentity: Equatable, Sendable { + let endpointUser: String + let endpointHost: String + let producerVersion: String + + static var current: DetectionIdentity { + DetectionIdentity( + endpointUser: NSUserName(), + endpointHost: localHostName, + producerVersion: AppVersion.string) + } + + private static var localHostName: String { + let capacity = Int(MAXHOSTNAMELEN) + var buffer = [CChar](repeating: 0, count: capacity + 1) + guard gethostname(&buffer, capacity) == 0 else { return "unknown" } + return String(cString: buffer) + } +} + +enum DetectionStoreInspection: Equatable { + case notCreated + case ready + case unavailable +} + +private enum DetectionStoreError: Error, CustomStringConvertible, Sendable { + case disabled + case filesystem(String) + case invalidRecord + case sqlite(operation: String, code: Int32) + + var description: String { + switch self { + case .disabled: + return "disabled for this run" + case .filesystem(let operation): + return "\(operation) failed" + case .invalidRecord: + return "record validation failed" + case .sqlite(let operation, let code): + return "\(operation) failed (SQLite \(code))" + } + } + + var disablesStore: Bool { + guard case .sqlite(_, let code) = self else { return false } + switch code & 0xff { + case SQLITE_CORRUPT, SQLITE_IOERR, SQLITE_NOTADB: + return true + default: + return false + } + } +} + +/// App-only durable store for the minimum facts behind accepted risk-feed +/// entries. The serial queue owns the connection and every prepared statement; +/// the bridge never links or opens this database. +final class DetectionStore { + static let applicationID: Int32 = 0x50455243 // "PERC" + static let schemaVersion = 1 + static let recordSchemaVersion = 1 + static let retentionDays = 30 + static let retentionInterval: TimeInterval = 30 * 24 * 60 * 60 + static let exportColumns = [ + "record_schema_version", + "event_id", + "observed_at_ms", + "endpoint_user", + "endpoint_host", + "producer", + "producer_version", + "agent", + "session_id", + "tool_use_id", + "tool_name", + "risk_level", + "finding_code", + "finding_level", + ] + + let databaseURL: URL + + private let identity: DetectionIdentity + private let queue = DispatchQueue(label: "app.perch.detection-store") + private var database: OpaquePointer? + private var insertEventStatement: OpaquePointer? + private var insertFindingStatement: OpaquePointer? + private var recentPostureStatement: OpaquePointer? + private var pruneStatement: OpaquePointer? + private var isDisabled = false + private var lastPruneAt: Date? + + init(databaseURL: URL = PerchPaths.detectionDatabaseFile, + identity: DetectionIdentity = .current) { + self.databaseURL = databaseURL + self.identity = identity + } + + /// Opens, migrates, prunes, and returns only the posture events from the + /// previous hour. Work runs off the main actor; completion returns on main. + func start(completion: @escaping (Result<[DetectionPostureEvent], Error>) -> Void) { + queue.async { [self] in + let result = Result { try openAndRestoreOnQueue(now: Date()) } + if case .failure(let error) = result { + PerchLog.error("Detection store open failed: \(error)", category: "detection-store") + } + DispatchQueue.main.async { + completion(result) + } + } + } + + /// Copies only the approved metadata before crossing onto the store queue. + /// In particular, the queued closure never captures tool input, cwd, paths, + /// finding prose, or any other live RiskFeed content. + func enqueue(_ entry: RiskFeed.Entry) { + let record = DetectionRecord( + recordSchemaVersion: Self.recordSchemaVersion, + eventID: entry.id.uuidString.lowercased(), + observedAtMs: Self.milliseconds(entry.receivedAt), + endpointUser: identity.endpointUser, + endpointHost: identity.endpointHost, + producerVersion: identity.producerVersion, + agent: entry.key.agent, + sessionID: entry.key.id, + toolUseID: entry.toolUseId, + toolName: entry.toolName, + riskLevel: entry.risk.level, + findings: entry.risk.findings.map { + DetectionFindingRecord(code: $0.code, level: $0.level) + }) + enqueue(record) + } + + func close() { + queue.sync { + closeOnQueue() + } + } + + // MARK: - Selftest seams + + @discardableResult + func startSynchronously(now: Date = Date()) throws -> [DetectionPostureEvent] { + try queue.sync { + try openAndRestoreOnQueue(now: now) + } + } + + func insertSynchronously(_ record: DetectionRecord) throws -> Bool { + try queue.sync { + guard database != nil, !isDisabled else { throw DetectionStoreError.disabled } + return try insertOnQueue(record) + } + } + + @discardableResult + func pruneSynchronously(now: Date) throws -> Int { + try queue.sync { + guard database != nil, !isDisabled else { throw DetectionStoreError.disabled } + return try pruneOnQueue(now: now) + } + } + + func waitUntilIdle() { + queue.sync {} + } + + // MARK: - Diagnostics + + static func inspect(databaseURL: URL = PerchPaths.detectionDatabaseFile) + -> DetectionStoreInspection { + guard FileManager.default.fileExists(atPath: databaseURL.path) else { + return .notCreated + } + + var db: OpaquePointer? + let openCode = databaseURL.path.withCString { + sqlite3_open_v2($0, &db, SQLITE_OPEN_READONLY | SQLITE_OPEN_FULLMUTEX, nil) + } + guard openCode == SQLITE_OK, let db else { + if let db { sqlite3_close_v2(db) } + return .unavailable + } + defer { sqlite3_close_v2(db) } + sqlite3_busy_timeout(db, 250) + + guard scalarInt("PRAGMA application_id", database: db) == Int64(applicationID), + scalarInt("PRAGMA user_version", database: db) == Int64(schemaVersion), + viewColumns(database: db) == exportColumns else { + return .unavailable + } + return .ready + } + + static func diagnosticLine(databaseURL: URL = PerchPaths.detectionDatabaseFile) -> String { + let suffix = "schema \(schemaVersion), contract v\(recordSchemaVersion), " + + "\(retentionDays)-day retention β€” \(databaseURL.path)" + switch inspect(databaseURL: databaseURL) { + case .notCreated: + return "Detection store: NOT CREATED β€” \(suffix)" + case .ready: + return "Detection store: OK β€” \(suffix)" + case .unavailable: + return "Detection store: UNAVAILABLE β€” \(suffix)" + } + } + + // MARK: - Queue-owned lifecycle + + private func openAndRestoreOnQueue(now: Date) throws -> [DetectionPostureEvent] { + if database != nil { + return try recentPostureOnQueue(now: now) + } + guard !isDisabled else { throw DetectionStoreError.disabled } + + do { + try prepareDirectory() + try openDatabase() + try secureDatabaseFiles() + try configureDatabase() + try migrateIfNeeded() + try prepareStatements() + try secureDatabaseFiles() + + do { + _ = try pruneOnQueue(now: now) + } catch { + handleOperationError(error, operation: "prune") + } + + let recent: [DetectionPostureEvent] + do { + recent = try recentPostureOnQueue(now: now) + } catch { + handleOperationError(error, operation: "posture restore") + recent = [] + } + lastPruneAt = now + return recent + } catch { + closeOnQueue() + isDisabled = true + throw error + } + } + + private func prepareDirectory() throws { + let directory = databaseURL.deletingLastPathComponent() + do { + try FileManager.default.createDirectory( + at: directory, + withIntermediateDirectories: true, + attributes: [.posixPermissions: 0o700]) + try FileManager.default.setAttributes( + [.posixPermissions: 0o700], + ofItemAtPath: directory.path) + } catch { + throw DetectionStoreError.filesystem("directory preparation") + } + } + + private func openDatabase() throws { + var opened: OpaquePointer? + let flags = SQLITE_OPEN_READWRITE | SQLITE_OPEN_CREATE | SQLITE_OPEN_FULLMUTEX + let code = databaseURL.path.withCString { + sqlite3_open_v2($0, &opened, flags, nil) + } + guard code == SQLITE_OK, let opened else { + if let opened { sqlite3_close_v2(opened) } + throw DetectionStoreError.sqlite(operation: "database open", code: code) + } + database = opened + sqlite3_extended_result_codes(opened, 1) + guard sqlite3_busy_timeout(opened, 250) == SQLITE_OK else { + throw sqliteError("busy timeout") + } + } + + private func configureDatabase() throws { + guard let database else { throw DetectionStoreError.disabled } + let mode = try scalarText("PRAGMA journal_mode=WAL", database: database) + guard mode.lowercased() == "wal" else { + throw DetectionStoreError.sqlite( + operation: "WAL configuration", + code: sqlite3_errcode(database)) + } + try execute("PRAGMA synchronous=FULL") + try execute("PRAGMA foreign_keys=ON") + } + + private func migrateIfNeeded() throws { + guard let database else { throw DetectionStoreError.disabled } + let currentApplicationID = try scalarInt("PRAGMA application_id") + let currentVersion = try scalarInt("PRAGMA user_version") + + guard currentApplicationID == 0 + || currentApplicationID == Int64(Self.applicationID) else { + throw DetectionStoreError.sqlite( + operation: "application identity validation", + code: SQLITE_MISMATCH) + } + + switch currentVersion { + case 0: + try execute("BEGIN IMMEDIATE") + do { + try execute("PRAGMA application_id=\(Self.applicationID)") + try execute(Self.schemaV1) + try execute("PRAGMA user_version=\(Self.schemaVersion)") + try execute("COMMIT") + } catch { + try? execute("ROLLBACK") + throw error + } + case Int64(Self.schemaVersion): + guard currentApplicationID == Int64(Self.applicationID) else { + throw DetectionStoreError.sqlite( + operation: "application identity validation", + code: SQLITE_MISMATCH) + } + default: + throw DetectionStoreError.sqlite( + operation: "schema version validation", + code: SQLITE_MISMATCH) + } + + guard try scalarInt("PRAGMA application_id") == Int64(Self.applicationID), + try scalarInt("PRAGMA user_version") == Int64(Self.schemaVersion) else { + throw DetectionStoreError.sqlite( + operation: "migration validation", + code: sqlite3_errcode(database)) + } + } + + private func prepareStatements() throws { + insertEventStatement = try prepare(""" + INSERT INTO detection_events ( + record_schema_version, event_id, observed_at_ms, + endpoint_user, endpoint_host, producer_version, + agent, session_id, tool_use_id, tool_name, risk_level + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """) + insertFindingStatement = try prepare(""" + INSERT INTO detection_findings ( + event_id, finding_code, finding_level + ) VALUES (?, ?, ?) + """) + recentPostureStatement = try prepare(""" + SELECT risk_level, observed_at_ms + FROM detection_events + WHERE observed_at_ms >= ? + ORDER BY observed_at_ms, event_id + """) + pruneStatement = try prepare(""" + DELETE FROM detection_events + WHERE observed_at_ms < ? + """) + } + + private func closeOnQueue() { + for statement in [ + insertEventStatement, + insertFindingStatement, + recentPostureStatement, + pruneStatement, + ] { + if let statement { sqlite3_finalize(statement) } + } + insertEventStatement = nil + insertFindingStatement = nil + recentPostureStatement = nil + pruneStatement = nil + if let database { + sqlite3_close_v2(database) + self.database = nil + } + } + + // MARK: - Queue-owned operations + + private func enqueue(_ record: DetectionRecord) { + queue.async { [self] in + guard database != nil, !isDisabled else { return } + do { + _ = try insertOnQueue(record) + let now = Date() + if lastPruneAt.map({ now.timeIntervalSince($0) >= 24 * 60 * 60 }) ?? true { + _ = try pruneOnQueue(now: now) + lastPruneAt = now + } + } catch { + handleOperationError(error, operation: "insert") + } + } + } + + private func insertOnQueue(_ record: DetectionRecord) throws -> Bool { + guard record.recordSchemaVersion == Self.recordSchemaVersion, + record.riskLevel != .safe, + !record.findings.isEmpty, + record.findings.allSatisfy({ $0.level != .safe }), + record.findings.map(\.level).max() == record.riskLevel else { + throw DetectionStoreError.invalidRecord + } + guard let eventStatement = insertEventStatement, + let findingStatement = insertFindingStatement else { + throw DetectionStoreError.disabled + } + + try execute("BEGIN IMMEDIATE") + do { + reset(eventStatement) + try bind(Int64(record.recordSchemaVersion), at: 1, in: eventStatement) + try bind(record.eventID, at: 2, in: eventStatement) + try bind(record.observedAtMs, at: 3, in: eventStatement) + try bind(record.endpointUser, at: 4, in: eventStatement) + try bind(record.endpointHost, at: 5, in: eventStatement) + try bind(record.producerVersion, at: 6, in: eventStatement) + try bind(record.agent.rawValue, at: 7, in: eventStatement) + try bind(record.sessionID, at: 8, in: eventStatement) + try bind(record.toolUseID, at: 9, in: eventStatement) + try bind(record.toolName, at: 10, in: eventStatement) + try bind(record.riskLevel.label, at: 11, in: eventStatement) + + let eventCode = sqlite3_step(eventStatement) + if Self.isUniquenessConflict(eventCode) { + reset(eventStatement) + try execute("ROLLBACK") + return false + } + guard eventCode == SQLITE_DONE else { + throw sqliteError("event insert", code: eventCode) + } + reset(eventStatement) + + for finding in record.findings { + reset(findingStatement) + try bind(record.eventID, at: 1, in: findingStatement) + try bind(finding.code, at: 2, in: findingStatement) + try bind(finding.level.label, at: 3, in: findingStatement) + let findingCode = sqlite3_step(findingStatement) + guard findingCode == SQLITE_DONE else { + throw sqliteError("finding insert", code: findingCode) + } + reset(findingStatement) + } + try secureDatabaseFiles() + try execute("COMMIT") + return true + } catch { + reset(eventStatement) + reset(findingStatement) + try? execute("ROLLBACK") + throw error + } + } + + private func recentPostureOnQueue(now: Date) throws -> [DetectionPostureEvent] { + guard let statement = recentPostureStatement else { + throw DetectionStoreError.disabled + } + reset(statement) + defer { reset(statement) } + let cutoff = Self.milliseconds(now.addingTimeInterval(-SecurityPosture.window)) + try bind(cutoff, at: 1, in: statement) + + var result: [DetectionPostureEvent] = [] + while true { + let code = sqlite3_step(statement) + if code == SQLITE_DONE { return result } + guard code == SQLITE_ROW else { + throw sqliteError("posture query", code: code) + } + let label = Self.textColumn(statement, at: 0) + let level: RiskLevel + switch label { + case "caution": level = .caution + case "danger": level = .danger + default: + throw DetectionStoreError.sqlite( + operation: "posture decoding", + code: SQLITE_MISMATCH) + } + let observedAtMs = sqlite3_column_int64(statement, 1) + result.append(DetectionPostureEvent( + level: level, + observedAt: Date(timeIntervalSince1970: Double(observedAtMs) / 1000))) + } + } + + private func pruneOnQueue(now: Date) throws -> Int { + guard let database, let statement = pruneStatement else { + throw DetectionStoreError.disabled + } + reset(statement) + defer { reset(statement) } + let cutoff = Self.milliseconds(now.addingTimeInterval(-Self.retentionInterval)) + try bind(cutoff, at: 1, in: statement) + let code = sqlite3_step(statement) + guard code == SQLITE_DONE else { + throw sqliteError("retention prune", code: code) + } + try secureDatabaseFiles() + return Int(sqlite3_changes(database)) + } + + private func handleOperationError(_ error: Error, operation: String) { + PerchLog.error("Detection store \(operation) failed: \(error)", + category: "detection-store") + if let storeError = error as? DetectionStoreError, storeError.disablesStore { + closeOnQueue() + isDisabled = true + } + } + + // MARK: - SQLite helpers + + private func prepare(_ sql: String) throws -> OpaquePointer { + guard let database else { throw DetectionStoreError.disabled } + var statement: OpaquePointer? + let code = sql.withCString { + sqlite3_prepare_v2(database, $0, -1, &statement, nil) + } + guard code == SQLITE_OK, let statement else { + throw sqliteError("statement preparation", code: code) + } + return statement + } + + private func execute(_ sql: String) throws { + guard let database else { throw DetectionStoreError.disabled } + let code = sql.withCString { + sqlite3_exec(database, $0, nil, nil, nil) + } + guard code == SQLITE_OK else { + throw sqliteError("statement execution", code: code) + } + } + + private func scalarInt(_ sql: String) throws -> Int64 { + let statement = try prepare(sql) + defer { sqlite3_finalize(statement) } + let code = sqlite3_step(statement) + guard code == SQLITE_ROW else { + throw sqliteError("metadata query", code: code) + } + return sqlite3_column_int64(statement, 0) + } + + private func scalarText(_ sql: String, database: OpaquePointer) throws -> String { + var statement: OpaquePointer? + let prepareCode = sql.withCString { + sqlite3_prepare_v2(database, $0, -1, &statement, nil) + } + guard prepareCode == SQLITE_OK, let statement else { + throw sqliteError("metadata query", code: prepareCode) + } + defer { sqlite3_finalize(statement) } + let stepCode = sqlite3_step(statement) + guard stepCode == SQLITE_ROW else { + throw sqliteError("metadata query", code: stepCode) + } + return Self.textColumn(statement, at: 0) + } + + private func bind(_ value: String?, at index: Int32, in statement: OpaquePointer) + throws { + let code: Int32 + if let value { + code = value.withCString { + sqlite3_bind_text(statement, index, $0, -1, Self.sqliteTransient) + } + } else { + code = sqlite3_bind_null(statement, index) + } + guard code == SQLITE_OK else { + throw sqliteError("value binding", code: code) + } + } + + private func bind(_ value: Int64, at index: Int32, in statement: OpaquePointer) + throws { + let code = sqlite3_bind_int64(statement, index, value) + guard code == SQLITE_OK else { + throw sqliteError("value binding", code: code) + } + } + + private func reset(_ statement: OpaquePointer) { + sqlite3_reset(statement) + sqlite3_clear_bindings(statement) + } + + private func secureDatabaseFiles() throws { + let manager = FileManager.default + for url in [ + databaseURL, + URL(fileURLWithPath: databaseURL.path + "-wal"), + URL(fileURLWithPath: databaseURL.path + "-shm"), + ] where manager.fileExists(atPath: url.path) { + do { + try manager.setAttributes( + [.posixPermissions: 0o600], + ofItemAtPath: url.path) + } catch { + throw DetectionStoreError.filesystem("file permission update") + } + } + } + + private func sqliteError(_ operation: String, code: Int32? = nil) + -> DetectionStoreError { + DetectionStoreError.sqlite( + operation: operation, + code: code ?? database.map { sqlite3_extended_errcode($0) } ?? SQLITE_ERROR) + } + + private static func milliseconds(_ date: Date) -> Int64 { + Int64((date.timeIntervalSince1970 * 1000).rounded()) + } + + private static func isUniquenessConflict(_ code: Int32) -> Bool { + let primaryKey = SQLITE_CONSTRAINT | Int32(6 << 8) + let unique = SQLITE_CONSTRAINT | Int32(8 << 8) + return code == primaryKey || code == unique + } + + private static let sqliteTransient = unsafeBitCast( + -1, + to: sqlite3_destructor_type.self) + + private static func textColumn(_ statement: OpaquePointer, at index: Int32) -> String { + guard let bytes = sqlite3_column_text(statement, index) else { return "" } + return String(cString: UnsafeRawPointer(bytes).assumingMemoryBound(to: CChar.self)) + } + + private static func scalarInt(_ sql: String, database: OpaquePointer) -> Int64? { + var statement: OpaquePointer? + let prepareCode = sql.withCString { + sqlite3_prepare_v2(database, $0, -1, &statement, nil) + } + guard prepareCode == SQLITE_OK, let statement else { return nil } + defer { sqlite3_finalize(statement) } + guard sqlite3_step(statement) == SQLITE_ROW else { return nil } + return sqlite3_column_int64(statement, 0) + } + + private static func viewColumns(database: OpaquePointer) -> [String]? { + var statement: OpaquePointer? + let sql = "SELECT * FROM detection_export_v1 LIMIT 0" + let prepareCode = sql.withCString { + sqlite3_prepare_v2(database, $0, -1, &statement, nil) + } + guard prepareCode == SQLITE_OK, let statement else { return nil } + defer { sqlite3_finalize(statement) } + return (0.. Bool { - addEntry(key: key, toolName: toolName, toolInput: toolInput, + addEntry(key: key, toolUseId: toolUseId, toolName: toolName, toolInput: toolInput, cwd: cwd, risk: risk, receivedAt: receivedAt) != nil } /// Entry-returning form used by notification routing so a banner can open /// the exact retained detection rather than merely the newest session row. @discardableResult - func addEntry(key: SessionKey, toolName: String, toolInput: JSONValue, + func addEntry(key: SessionKey, toolUseId: String? = nil, + toolName: String, toolInput: JSONValue, cwd: String?, risk: RiskAssessment, receivedAt: Date = Date()) -> Entry? { guard !risk.isEmpty else { return nil } @@ -71,8 +74,9 @@ final class RiskFeed: ObservableObject { }) { return nil } - let entry = Entry(id: UUID(), key: key, toolName: toolName, toolInput: toolInput, - cwd: cwd, receivedAt: receivedAt, risk: risk) + let entry = Entry(id: UUID(), key: key, toolUseId: toolUseId, + toolName: toolName, toolInput: toolInput, cwd: cwd, + receivedAt: receivedAt, risk: risk) entries.append(entry) recent.append(entry) if entries.count == 1 { focusedIndex = 0 } diff --git a/Sources/Perch/Model/SecurityPosture.swift b/Sources/Perch/Model/SecurityPosture.swift index b39c5e8..8826230 100644 --- a/Sources/Perch/Model/SecurityPosture.swift +++ b/Sources/Perch/Model/SecurityPosture.swift @@ -8,7 +8,7 @@ import PerchCore /// Purely informational, like everything else in Perch. @MainActor final class SecurityPosture: ObservableObject { - static let window: TimeInterval = 3600 + nonisolated static let window: TimeInterval = 3600 static let dangerPenalty = 25 static let cautionPenalty = 5 @@ -54,6 +54,15 @@ final class SecurityPosture: ObservableObject { recompute(now: date) } + /// Restores only the rolling counters after launch. Historical rows never + /// become cards, notifications, or attention events. + func hydrate(_ restored: [DetectionPostureEvent], now: Date = Date()) { + events.append(contentsOf: restored.map { + Event(level: $0.level, at: $0.observedAt) + }) + recompute(now: now) + } + func recompute(now: Date = Date()) { events.removeAll { now.timeIntervalSince($0.at) > Self.window } let dangers = events.filter { $0.level == .danger }.count diff --git a/Sources/Perch/Model/SessionStore.swift b/Sources/Perch/Model/SessionStore.swift index 15919cb..887a62a 100644 --- a/Sources/Perch/Model/SessionStore.swift +++ b/Sources/Perch/Model/SessionStore.swift @@ -14,6 +14,7 @@ final class SessionStore: ObservableObject { var riskFeed: RiskFeed? var usageStore: UsageStore? var securityPosture: SecurityPosture? + var detectionStore: DetectionStore? /// (session, reason) β€” notch auto-expand + notifications. var onAttention: ((Session, String) -> Void)? var onTaskComplete: ((Session, String?) -> Void)? @@ -134,7 +135,8 @@ final class SessionStore: ObservableObject { // and, for danger, an OS notification. reply(.empty) surfaceRisk(risk, agent: agent, sid: sid, toolName: toolName, - toolInput: payload.toolInput, cwd: payload.cwd) + toolUseId: payload.toolUseId, toolInput: payload.toolInput, + cwd: payload.cwd) case .postToolUse: upsert(agent: agent, id: sid) { s in @@ -179,7 +181,8 @@ final class SessionStore: ObservableObject { // zero added latency; Perch just surfaces what is being asked. reply(.empty) surfaceRisk(risk, agent: agent, sid: sid, toolName: toolName, - toolInput: payload.toolInput, cwd: payload.cwd) + toolUseId: payload.toolUseId, toolInput: payload.toolInput, + cwd: payload.cwd) if let session = find(agent: agent, id: sid) { let reason = risk.isEmpty ? "Permission requested: \(toolName)" @@ -258,7 +261,8 @@ final class SessionStore: ObservableObject { /// notch card, and escalate danger to an OS notification. Detection never /// touches the agent β€” it only makes noise on this side of the glass. private func surfaceRisk(_ risk: RiskAssessment, agent: AgentKind, sid: String, - toolName: String, toolInput: JSONValue?, cwd: String?) { + toolName: String, toolUseId: String?, + toolInput: JSONValue?, cwd: String?) { guard !risk.isEmpty else { return } PerchLog.warn("Risk \(risk.level.label) on \(toolName) (\(agent.rawValue)): \(risk.findings.map(\.code).joined(separator: ","))", category: "detect") @@ -269,17 +273,25 @@ final class SessionStore: ObservableObject { let resolvedInput = toolInput ?? .null let resolvedCwd = cwd ?? find(agent: agent, id: sid)?.cwd let entry: RiskFeed.Entry + let acceptedByFeed: Bool if let riskFeed { - guard let added = riskFeed.addEntry(key: key, toolName: toolName, + guard let added = riskFeed.addEntry(key: key, toolUseId: toolUseId, + toolName: toolName, toolInput: resolvedInput, cwd: resolvedCwd, risk: risk) else { return } entry = added + acceptedByFeed = true } else { - entry = RiskFeed.Entry(id: UUID(), key: key, toolName: toolName, + entry = RiskFeed.Entry(id: UUID(), key: key, toolUseId: toolUseId, + toolName: toolName, toolInput: resolvedInput, cwd: resolvedCwd, receivedAt: Date(), risk: risk) + acceptedByFeed = false } securityPosture?.record(risk.level) + if acceptedByFeed { + detectionStore?.enqueue(entry) + } if risk.level == .danger, let session = find(agent: agent, id: sid) { onRiskDetected?(session, entry) } diff --git a/Sources/PerchCore/BridgeProtocol.swift b/Sources/PerchCore/BridgeProtocol.swift index 85ea801..5d8df00 100644 --- a/Sources/PerchCore/BridgeProtocol.swift +++ b/Sources/PerchCore/BridgeProtocol.swift @@ -10,6 +10,9 @@ public enum PerchPaths { public static var socketPath: String { appSupportDir.appendingPathComponent("perch.sock").path } public static var configFile: URL { appSupportDir.appendingPathComponent("config.json") } public static var logFile: URL { appSupportDir.appendingPathComponent("perch.log") } + public static var detectionDatabaseFile: URL { + appSupportDir.appendingPathComponent("detections.sqlite3") + } /// Stable location the hook commands point at (survives app moves). public static var bridgeInstallPath: URL { appSupportDir.appendingPathComponent("perch-bridge") } diff --git a/docs/detection-storage.md b/docs/detection-storage.md new file mode 100644 index 0000000..0f2c194 --- /dev/null +++ b/docs/detection-storage.md @@ -0,0 +1,86 @@ +# Detection storage + +Perch keeps a compact local SQLite record of accepted `caution` and `danger` +detections. This preserves the rolling posture across app restarts and provides +a stable source contract for a future Crowsnest adapter. It is not a detailed +history database or a tamper-evident compliance log. + +## Location and retention + +The database is: + +```text +~/Library/Application Support/Perch/detections.sqlite3 +``` + +The containing directory is mode `0700`; the database and its SQLite WAL/SHM +files are mode `0600`. Perch uses the SQLite library shipped with macOS, with +WAL journaling, full synchronous durability, foreign keys, and a bounded busy +timeout. + +Rows are retained for 30 days and pruned without a foreground `VACUUM`. +Perch restores only risk levels from the previous hour to rebuild the posture +score. It does not replay old cards or notifications and does not expose a +local history browser. + +## What is stored + +Each event stores only: + +- contract version, stable event ID, and UTC observation time; +- endpoint user and host; +- Perch version; +- agent, session ID, optional tool-use ID, and tool name; +- overall risk level; +- stable finding codes and finding levels. + +Perch does **not** store commands, tool inputs or outputs, finding messages, +summaries, paths, working directories, repository names, URLs, prompts, +responses, patches, file contents, content hashes, approval decisions, or +execution outcomes. + +A row means only that Perch observed a tool request and emitted the listed +findings. It does not claim that the request was approved, denied, executed, or +completed. + +## Consumer contract + +`detection_export_v1` is the supported read contract: + +| Column | Meaning | +| --- | --- | +| `record_schema_version` | Export record contract, currently `1` | +| `event_id` | Idempotent Perch event ID | +| `observed_at_ms` | UTC Unix time in milliseconds | +| `endpoint_user`, `endpoint_host` | Origin endpoint identity | +| `producer`, `producer_version` | `perch` and the assessing Perch version | +| `agent`, `session_id`, `tool_use_id` | Agent/tool-call correlation | +| `tool_name`, `risk_level` | Tool dimension and overall classification | +| `finding_code`, `finding_level` | Stable finding identity and classification | + +The view emits one row per finding. Consumers should read it in stable order: + +```sql +SELECT * +FROM detection_export_v1 +ORDER BY observed_at_ms, event_id, finding_code; +``` + +Internal tables may evolve while this view remains stable. A breaking contract +will use a new versioned view rather than changing version 1 in place. + +## Future Crowsnest ingestion + +[Crowsnest](https://github.com/theMobiusStrip/crowsnest) integration is not part +of Perch. A future Crowsnest adapter can open the database read-only, poll +`detection_export_v1`, keep its checkpoint in Crowsnest, re-read a small +overlap, and deduplicate by `event_id` plus +`perch::`. +Because the database is mode `0600`, that adapter must run as the same local +account or receive access through an explicit read-only mount. + +Crowsnest owns its durable copy and retention. Once a row is ingested, Perch's +30-day TTL does not affect that copy. The TTL is the maximum recovery window +for rows not yet ingested: an adapter outage longer than 30 days can lose +unread rows. Perch deliberately has no acknowledgement state, delivery queue, +retry state, or pre-TTL archive.