Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 14 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ and what they've **left behind**:
| 📡 **Monitoring health** | A separate coverage strip checks the deployed bridge, local event socket, Claude wiring, and Codex hook trust, then waits for a real event from each configured agent before reporting delivery as verified. Its state also colors the collapsed notch and menu-bar bird; guided setup installs or repairs integrations. |
| 🔔 **Alerts even when nothing prompts** | Danger fires an OS notification — including calls auto-approved by allow rules or relaxed permission modes. Notification actions jump straight to the exact detection, session, or usage view instead of leaving you to hunt for context. |
| 📊 **Explainable security score** | A rolling 0–100 posture score in the notch and menu bar: −25 per danger, −5 per caution over the last hour. Open the strip for the formula and retained recent detections; dismissing an alert card does not erase its history. |
| 🔎 **Local Insights** | A zero-setup, offline view of caution/danger trends on this Mac: 24-hour, 7-day, and 30-day timelines plus findings grouped by code, agent, tool, and session (menu bar → **Insights…**). Perch records what it observed, not whether a request ran. |
| 🐦 **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. |
Expand Down Expand Up @@ -239,14 +240,20 @@ 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
Deduplicated 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).

Menu bar → **Insights…** reads that same database locally for 24-hour, 7-day,
and 30-day timelines plus finding, agent, tool, and session aggregates. It
creates no second database, sends no telemetry, and does not claim that an
observed request was approved, denied, executed, or completed. The detailed
**Recent Detections…** view remains an in-memory past-hour feed.

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.
Expand All @@ -266,12 +273,13 @@ 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. Accepted
service — nothing Perch observes ever leaves your machine. Recorded
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:
30 days; Insights reads that store in-process and 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 doesn't persist what it inspects.** Risk scoring is pure
Expand Down
12 changes: 12 additions & 0 deletions Sources/Perch/App/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ struct AppActions {
var openRecentDetections: () -> Void
var toggleNotch: () -> Void
var openDebugWindow: () -> Void
var openInsights: () -> Void
var openUsageHistory: () -> Void
var openWorktrees: () -> Void
var quit: () -> Void
Expand Down Expand Up @@ -45,6 +46,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
private var worktreeWindow: WorktreeWindowController?
private var setupWindow: SetupWindowController?
private var recentDetectionsWindow: RecentDetectionsWindowController?
private var insightsWindow: InsightsWindowController?
private var cancellables = Set<AnyCancellable>()
/// True while the notch is showing attention we raised via onAttention.
/// Lets the session-publish observer clear notification-driven
Expand Down Expand Up @@ -138,6 +140,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
worktrees: worktreeModel,
openWorktrees: { [weak self] in self?.openWorktreeWindow() },
openUsageHistory: { [weak self] in self?.openUsageHistoryWindow() },
openInsights: { [weak self] in self?.openInsightsWindow() },
openSetup: { [weak self] in self?.openSetupWindow() },
openRecentDetections: { [weak self] in self?.openRecentDetectionsWindow() })
notchController.show()
Expand Down Expand Up @@ -297,11 +300,20 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
}
self.debugWindow?.show()
},
openInsights: { [weak self] in self?.openInsightsWindow() },
openUsageHistory: { [weak self] in self?.openUsageHistoryWindow() },
openWorktrees: { [weak self] in self?.openWorktreeWindow() },
quit: { NSApp.terminate(nil) })
}

private func openInsightsWindow() {
if insightsWindow == nil {
insightsWindow = InsightsWindowController(
model: InsightsModel(store: detectionStore))
}
insightsWindow?.show()
}

private func openWorktreeWindow() {
if worktreeWindow == nil {
worktreeWindow = WorktreeWindowController(model: worktreeModel)
Expand Down
238 changes: 235 additions & 3 deletions Sources/Perch/App/Selftest.swift
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,8 @@ enum Selftest {
detectionStoreTransactionAndDedupe(t)
detectionStoreRetentionAndPostureRestore(t)
detectionStoreFailureAndPermissions(t)
detectionInsightsAggregatesAndBounds(t)
detectionInsightsCalendarBuckets(t)
sessionStorePersistsOnlyAcceptedMetadata(t)
monitoringSnapshotSeparatesCoverageFromPosture(t)
monitoringHealthSeparatesConfigurationFromVerification(t)
Expand Down Expand Up @@ -2382,6 +2384,16 @@ private extension Selftest {
String(DetectionStore.schemaVersion), "schemaVersion")
t.expectEqual(sqliteColumnNames(databaseURL, "SELECT * FROM detection_export_v1 LIMIT 0"),
DetectionStore.exportColumns, "exactExportColumns")
t.expectEqual(
sqliteRows(databaseURL, "PRAGMA table_info(detection_events)")?
.compactMap { $0[safe: 1] ?? nil },
DetectionStore.eventColumns,
"exactEventColumns")
t.expectEqual(
sqliteRows(databaseURL, "PRAGMA table_info(detection_findings)")?
.compactMap { $0[safe: 1] ?? nil },
DetectionStore.findingColumns,
"exactFindingColumns")
store.close()

let reopened = detectionStore(at: databaseURL)
Expand Down Expand Up @@ -2585,6 +2597,223 @@ private extension Selftest {
t.expectTrue(line.contains("30-day retention"), "doctorShowsRetention")
}

@MainActor
static func detectionInsightsAggregatesAndBounds(_ t: Checker) {
t.suite("DetectionInsights.aggregatesAndBounds")
let root = detectionTempRoot()
defer { try? FileManager.default.removeItem(at: root) }
let databaseURL = root.appendingPathComponent("perch/detections.sqlite3")
let store = detectionStore(at: databaseURL)

var calendar = Calendar(identifier: .gregorian)
calendar.locale = Locale(identifier: "en_US_POSIX")
calendar.timeZone = TimeZone(secondsFromGMT: 0)!
let now = calendar.date(from: DateComponents(
year: 2026, month: 7, day: 23, hour: 12))!
guard (try? store.startSynchronously(now: now)) != nil else {
t.expectTrue(false, "open")
return
}

let lowerBound = now.addingTimeInterval(-24 * 60 * 60)
let records = [
detectionRecord(
id: "lower-bound",
at: lowerBound,
toolUseID: "lower-bound",
risk: .caution,
agent: .claude,
sessionID: "shared-session",
toolName: "Bash",
findings: [
DetectionFindingRecord(code: "destructive-delete", level: .caution),
]),
detectionRecord(
id: "multi-finding",
at: now.addingTimeInterval(-2 * 60 * 60),
toolUseID: "multi-finding",
agent: .claude,
sessionID: "shared-session",
toolName: "Bash",
findings: [
DetectionFindingRecord(code: "destructive-delete", level: .danger),
DetectionFindingRecord(code: "privilege-escalation", level: .danger),
]),
detectionRecord(
id: "same-raw-session-other-agent",
at: now.addingTimeInterval(-60 * 60),
toolUseID: "other-agent",
agent: .codex,
sessionID: "shared-session",
toolName: "shell",
findings: [
DetectionFindingRecord(code: "privilege-escalation", level: .danger),
]),
detectionRecord(
id: "upper-bound",
at: now,
toolUseID: "upper-bound",
risk: .caution,
agent: .claude,
sessionID: "edge-session",
toolName: "Write",
findings: [
DetectionFindingRecord(code: "memory-pollution", level: .caution),
]),
detectionRecord(
id: "older-calendar-day",
at: now.addingTimeInterval(-3 * 24 * 60 * 60),
toolUseID: "older",
risk: .caution,
agent: .codex,
sessionID: "older-session",
toolName: "WebFetch",
findings: [
DetectionFindingRecord(code: "raw-ip", level: .caution),
]),
detectionRecord(
id: "before-lower-bound",
at: lowerBound.addingTimeInterval(-1),
toolUseID: "before",
agent: .claude,
sessionID: "outside-session",
toolName: "Bash"),
detectionRecord(
id: "future",
at: now.addingTimeInterval(1),
toolUseID: "future",
agent: .claude,
sessionID: "outside-session",
toolName: "Bash"),
]
for record in records {
t.expectEqual(try? store.insertSynchronously(record), true,
"insert.\(record.eventID)")
}

guard let day = try? store.loadInsightsSynchronously(
range: .hours24,
now: now,
calendar: calendar) else {
t.expectTrue(false, "load24H")
store.close()
return
}
t.expectEqual(day.timeline.count, 24, "twentyFourHourlyBuckets")
t.expectEqual(day.startAt, lowerBound, "rollingLowerBound")
t.expectEqual(day.endAt, now, "inclusiveUpperBound")
t.expectEqual(day.cautionCount, 2, "cautionEventsCountOnce")
t.expectEqual(day.dangerCount, 2, "dangerEventsCountOnce")
t.expectEqual(day.detectionCount, 4, "multiFindingEventNotDuplicated")
t.expectEqual(day.timeline.reduce(0) { $0 + $1.totalCount }, 4,
"timelineMatchesDetectionTotal")
t.expectEqual(day.timeline.first?.cautionCount, 1,
"lowerBoundFallsInFirstBucket")
t.expectEqual(day.timeline.last?.cautionCount, 1,
"upperBoundFallsInFinalBucket")
t.expectEqual(day.findings, [
DetectionFindingAggregate(
code: "privilege-escalation", level: .danger, count: 2),
DetectionFindingAggregate(
code: "destructive-delete", level: .danger, count: 1),
DetectionFindingAggregate(
code: "destructive-delete", level: .caution, count: 1),
DetectionFindingAggregate(
code: "memory-pollution", level: .caution, count: 1),
], "findingCodeAndLevelAreSeparate")
t.expectEqual(day.agents.map { "\($0.agent.rawValue):\($0.detectionCount)" },
["claude:3", "codex:1"], "agentTotals")
t.expectEqual(day.tools.map { "\($0.toolName):\($0.detectionCount)" },
["Bash:2", "shell:1", "Write:1"], "toolTotals")
t.expectEqual(day.sessions.count, 3, "agentScopesRawSessionID")
t.expectEqual(
day.sessions.first {
$0.agent == .claude && $0.sessionID == "shared-session"
}?.detectionCount,
2,
"sessionClustersDetections")
t.expectFalse(day.sessions.contains { $0.sessionID == "outside-session" },
"outOfRangeSessionsExcluded")

let week = try? store.loadInsightsSynchronously(
range: .days7,
now: now,
calendar: calendar)
t.expectEqual(week?.timeline.count, 7, "sevenCalendarBuckets")
t.expectEqual(week?.detectionCount, 6, "weekIncludesEarlierEvents")
t.expectEqual(week?.timeline.reduce(0) { $0 + $1.totalCount }, 6,
"weekTimelineMatchesTotal")

let month = try? store.loadInsightsSynchronously(
range: .days30,
now: now,
calendar: calendar)
t.expectEqual(month?.timeline.count, 30, "thirtyCalendarBuckets")
t.expectEqual(month?.detectionCount, 6, "monthExcludesFutureEvent")
store.close()
}

@MainActor
static func detectionInsightsCalendarBuckets(_ t: Checker) {
t.suite("DetectionInsights.calendarBuckets")
guard let losAngeles = TimeZone(identifier: "America/Los_Angeles"),
let utc = TimeZone(identifier: "UTC") else {
t.expectTrue(false, "timeZonesAvailable")
return
}

var laCalendar = Calendar(identifier: .gregorian)
laCalendar.locale = Locale(identifier: "en_US_POSIX")
laCalendar.timeZone = losAngeles

let springNow = laCalendar.date(from: DateComponents(
year: 2024, month: 3, day: 11, hour: 12))!
let spring = InsightsRange.days7.bucketDefinitions(
now: springNow,
calendar: laCalendar)
let springTransition = spring.first { $0.label == "Mar 10" }
t.expectEqual(spring.count, 7, "springHasSevenCalendarDays")
t.expectEqual(springTransition.map { $0.end.timeIntervalSince($0.start) } ?? -1,
23 * 60 * 60,
accuracy: 0.1,
"springForwardDayIs23Hours")

let fallNow = laCalendar.date(from: DateComponents(
year: 2024, month: 11, day: 3, hour: 12, minute: 30))!
let fallHours = InsightsRange.hours24.bucketDefinitions(
now: fallNow,
calendar: laCalendar)
let repeatedOneAM = fallHours.map(\.label).filter {
$0.hasPrefix("1 AM ")
}
t.expectEqual(fallHours.count, 24, "fallHasTwentyFourElapsedHours")
t.expectEqual(repeatedOneAM.count, 2, "repeatedHourLabelsIncludeZone")
t.expectEqual(Set(repeatedOneAM).count, 2, "repeatedHourLabelsAreDistinct")

let fallDays = InsightsRange.days7.bucketDefinitions(
now: laCalendar.date(from: DateComponents(
year: 2024, month: 11, day: 4, hour: 12))!,
calendar: laCalendar)
let fallTransition = fallDays.first { $0.label == "Nov 3" }
t.expectEqual(fallTransition.map { $0.end.timeIntervalSince($0.start) } ?? -1,
25 * 60 * 60,
accuracy: 0.1,
"fallBackDayIs25Hours")

var utcCalendar = laCalendar
utcCalendar.timeZone = utc
let laSnapshot = DetectionInsightsSnapshot.aggregate(
rows: [], range: .days7, now: springNow, calendar: laCalendar)
let utcSnapshot = DetectionInsightsSnapshot.aggregate(
rows: [], range: .days7, now: springNow, calendar: utcCalendar)
t.expectEqual(laSnapshot.timeZoneIdentifier, losAngeles.identifier,
"snapshotCapturesLocalZone")
t.expectEqual(utcSnapshot.timeZoneIdentifier, utc.identifier,
"refreshCapturesChangedZone")
t.expectTrue(laSnapshot.startAt != utcSnapshot.startAt,
"changedZoneRecomputesCalendarBounds")
}

@MainActor
static func sessionStorePersistsOnlyAcceptedMetadata(_ t: Checker) {
t.suite("SessionStore.persistsOnlyAcceptedMetadata")
Expand Down Expand Up @@ -2715,6 +2944,9 @@ private extension Selftest {
at: Date,
toolUseID: String?,
risk: RiskLevel = .danger,
agent: AgentKind = .claude,
sessionID: String = "session-1",
toolName: String = "Bash",
findings: [DetectionFindingRecord] = [
DetectionFindingRecord(code: "destructive-delete", level: .danger),
]
Expand All @@ -2726,10 +2958,10 @@ private extension Selftest {
endpointUser: "test-user",
endpointHost: "test-host",
producerVersion: "v-test",
agent: .claude,
sessionID: "session-1",
agent: agent,
sessionID: sessionID,
toolUseID: toolUseID,
toolName: "Bash",
toolName: toolName,
riskLevel: risk,
findings: findings)
}
Expand Down
2 changes: 1 addition & 1 deletion Sources/Perch/App/ShowcaseRenderer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -216,7 +216,7 @@ enum ShowcaseRenderer {
riskFeed: riskFeed, posture: posture, health: health,
usageHistory: usageHistory, integrity: integrity,
worktrees: worktrees, openWorktrees: {},
openUsageHistory: {}, openSetup: {},
openUsageHistory: {}, openInsights: {}, openSetup: {},
openRecentDetections: {}, renderStatic: true)
.frame(width: state.expandedSize.width, height: state.expandedSize.height)
// ImageRenderer otherwise lets the shell negotiate up to the
Expand Down
Loading