Skip to content

Add native macOS Endpoint Security app - #42

Open
yiying-zhang wants to merge 35 commits into
mainfrom
agent/macos-endpoint-security
Open

Add native macOS Endpoint Security app#42
yiying-zhang wants to merge 35 commits into
mainfrom
agent/macos-endpoint-security

Conversation

@yiying-zhang

@yiying-zhang yiying-zhang commented Aug 15, 2026

Copy link
Copy Markdown
Contributor

What changed

  • add the native SwiftUI Gensee Crate macOS security console under macos/GenseeCrate
  • add the first-party Endpoint Security system extension for process, file, and authorization events
  • keep the existing OSS Rust workspace as the backend by embedding the repository-built gensee CLI
  • add versioned Endpoint Security JSONL parsing, process-tree attribution, ingestion, policy alerts, and store/dashboard correlation
  • add Endpoint Security policy keys and make the signed extension the default macOS system-event backend
  • add a Harnesses page for Codex, Claude Code, Antigravity, Cursor, GitHub Copilot, and Omnigent
  • detect which harnesses are installed, show unavailable harnesses in a muted disabled state, and let users enable or disable installed direct-hook integrations
  • validate enabled harnesses, surface Needs Repair for incomplete setup such as a mismatched event-store path, and provide a user-triggered repair action
  • route harness changes through the existing gensee setup commands; disabling removes only Gensee-owned hook entries and preserves unrelated user settings and hooks
  • show Omnigent as managed-launch protection through gensee run until a direct policy bridge is available
  • coalesce background dashboard refreshes and keep transient refresh failures nonmodal so the app does not repeatedly interrupt the user
  • rename Today's Highlight to Daily Highlight, retaining today's summary at the top and adding selectable 53-week activity heatmaps for agent turns, tool calls, alerts, and tokens
  • persist exact daily request activity and numeric per-turn token totals from compatible Claude Code and Codex JSONL usage metadata without storing transcript content
  • check in the host/system-extension entitlement plists, XcodeGen configuration, generated Xcode project, shared scheme, and brand assets
  • document the native console, harness controls, Daily Highlight, shared Rust backend, current Endpoint Security behavior, build/install requirements, Apple-managed entitlement approval, signing, safety, and rollback
  • ignore Xcode build products plus certificates, keys, provisioning profiles, notarization material, app bundles, and distribution artifacts

Why

The previous macOS path used /usr/bin/eslogger as a temporary compatibility sensor. Gensee now has Apple approval for com.apple.developer.endpoint-security.client, so the product can use a signed, first-party system extension with exact process identity and managed agent-tree attribution.

The native app intentionally calls the OSS Rust CLI rather than implementing a second backend in Swift. Policy, storage, event normalization, lineage, dashboard state, harness setup, and Daily Highlight aggregates therefore remain shared with the CLI and existing dashboard.

The harness inventory gives users one place to see all supported agent surfaces and explicitly choose which installed direct-hook integrations receive Gensee monitoring and policy enforcement. Unsupported-on-this-machine and managed-launch-only states remain visible without presenting a working toggle.

Developer and user impact

The source is public, but publishing an entitlement plist does not grant Endpoint Security access to another Apple Developer account. Contributors and distributors need their own Apple approval and matching Development or Developer ID signing/provisioning. No certificates, private keys, profiles, notarization credentials, built apps, DMGs, or archives are included.

The extension defaults to observe, which records evidence without denying operations. protect and strict authorization behavior is scoped to explicitly managed agent process trees; unrelated host processes remain outside the deny scope.

Token collection stores only numeric usage totals exposed by supported local transcript formats; it does not copy prompt or response content into the Gensee event store.

Validation

  • cargo fmt --all -- --check
  • cargo test --workspace — 497 tests passed
  • npm run docs:build
  • plutil -lint on both Info.plists and entitlement plists
  • jq empty on the default policy and policy schema
  • xcodegen generate --spec project.yml
  • unsigned Debug xcodebuild of the host, embedded Rust CLI, assets, and system extension — succeeded
  • staged-file audit found no certificates, keys, provisioning profiles, notarization credentials, build output, or user-specific paths

@yiying-zhang
yiying-zhang marked this pull request as ready for review August 15, 2026 18:01
@yiying-zhang

Copy link
Copy Markdown
Contributor Author

Code review — full branch + follow-up commits

Reviewed origin/main...HEAD (70 files, +7319/−296) plus the three follow-up commits. crate/gensee-crate-cli/src/tclone.rs is a clean mechanical clippy refactor (context struct + type alias, no behavior change) and is not covered below.

Verified locally: cargo test -p gensee-crate-store (27 passed) and cargo test -p gensee-crate-db (11 passed) at b1eef87. I did not build the Xcode targets.


1. Base branch (agent/macos-endpoint-security vs main)

High

macos/GenseeCrate/Host/EndpointSecuritySensor.swift:205 — cursor reset is clobbered; ingestion stalls permanently after an extension restart

cursor = response.1 unconditionally overwrites the recovery reset applyHealth just performed. After the extension is upgraded or relaunched its nextCursor restarts at 1 while the host still holds e.g. cursor = 100000. The fetch returns batch = [] and next = 100000 (the extension initializes next to the requested cursor), applyHealth correctly detects cursor >= next_cursor and resets to oldest_cursor - 1, then line 205 restores the stale value. Every subsequent poll repeats this: zero events are ever delivered and health.connected stays true, so nothing surfaces. Assign cursor before applyHealth, or skip the assignment when a reset occurred.

macos/GenseeCrate/Host/ConsoleModel.swift:498protect mode denies Gensee's own writes to ~/.gensee

homeURL.path is unconditionally added to protectedPaths, and the extension's authorizeMessage never exempts Gensee's own processes. In protect/strict, the agent-spawned gensee hook subprocess is a descendant of the managed root, so enforcing && session != nil holds (main.m:453); it opens ~/.gensee/gensee.db, hasProtectedPrefixLocked matches, and es_respond_auth_result returns DENY. Every hook invocation then fails, disabling the policy logging and PreToolUse enforcement the mode was enabled for. GenseeIsOwnProcess (main.m:120) exists but is only consulted in the recording path.

macos/GenseeCrate/Host/ConsoleModel.swift:509 — hook-registered sessions never end; stale root PIDs are re-pushed forever

hook_session_registration writes sessions with ended_at_ms: None and nothing ever closes them — even the Stop hook re-registers with ended_at_ms: None (main.rs:3928). The extension removes an exited root on NOTIFY_EXIT (main.m:519-523), but two seconds later refreshDashboardconfigureEndpointSensor re-pushes it. After PID reuse an unrelated process is attributed to the dead session, and in protect mode is denied access to ~/.ssh/~/.gensee. The set also grows without bound across days of use.

macos/GenseeCrate/EndpointSecurityExtension/main.m:651es_clear_cache runs roughly twice a second

updateConfiguration clears the cache on every accepted push, and configureEndpointSensor() sets configurationNeedsPush = true unconditionally (EndpointSecuritySensor.swift:102) with no comparison against the previous value. DashboardShell.swift:70 refreshes every 2s, so the 500ms poll loop pushes almost every time. With AUTH_OPEN/AUTH_EXEC/AUTH_CREATE subscribed system-wide, flushing the ES authorization cache at that rate forces every open on the machine back through the client for as long as the console is open. Only push when the configuration actually changed.

crate/gensee-crate-cli/src/watch.rs:588 — the new default backend starts no watcher but claims it is running

watch.system_events now defaults to endpoint-security, whose handler returns Ok(None) and prints gensee: using the signed Gensee Endpoint Security system extension without checking that the extension exists. Per the PR description, contributors cannot obtain the ES entitlement without their own Apple approval — so on every OSS build gensee watch now records zero system events where it previously ran eslogger, while printing a message that reassures the user coverage is present. Probe for the extension and fall back to eslogger, or warn when it is absent.

Medium

crate/gensee-crate-macos/src/event.rs:404 — event-loss detection is unreachable, and its alert is filtered out anyway

The extension tracks kernel_drops/ring_drops (main.m:558, 542) but GenseeSerializeMessage never emits dropped_events, and EndpointSecuritySensor.write(events:) forwards dictionaries verbatim — so serde(default) leaves it 0 on every real event and the > 0 branch is dead outside event.rs:595. Separately, if it were populated, the high-severity alert written at main.rs:3705 is excluded by the dashboard queries at store/lib.rs:341, 545, and 550. Kernel event loss under load is therefore never surfaced anywhere an operator looks.

crate/gensee-crate-cli/src/main.rs:3856ps -axo forked on every hook, ahead of the daemon fast path

hook_session_registration shells out to ps -axo pid=,ppid=,comm= and parses the whole process table on every hook invocation, and it runs before dispatch_via_daemon — adding a fork+exec to the blocking PreToolUse path that the warm daemon exists to keep fast (see the comment at main.rs:3849-3853). It also runs for PostToolUse/Stop where no registration is needed. On top of that, append_session (main.rs:3869, daemon.rs:104) appends a fresh JSONL row per event, so sessions.jsonl accumulates hundreds of duplicates per session. Register once (e.g. only on UserPromptSubmit) or cache the resolved root per session id.

crate/gensee-crate-cli/src/main.rs:968setup claude-code --disable leaves gateway routing in place

The disable branch calls only remove_hook_settings. The gateway env block written by apply_claude_code_gateway_settings (main.rs:2023) — ANTHROPIC_BASE_URL, ANTHROPIC_AUTH_TOKEN, apiKeyHelper — stays in the same settings.json. Flipping the Harnesses switch off therefore keeps every request routed through the Gensee gateway with the Gensee token while no hook runs to evaluate policy, which is the opposite of what the toggle's help text implies.

macos/GenseeCrate/Host/GenseeCLI.swift:93 — sequential stdout-then-stderr drain can deadlock

run reads stdout to EOF and only then reads stderr. A subcommand that writes more than the pipe buffer to stderr blocks in write(2) while the parent blocks reading stdout, which never reaches EOF. waitUntilExit() is never reached and the console's refresh loop hangs. Drain both pipes concurrently.

macos/GenseeCrate/Host/GenseeCLI.swift:62 — hardcoded developer home paths ship in the app

resolveExecutable() falls back to ~/Projects/Gensee-Prod/gensee-crate/target/{debug,release}/gensee. If the bundled binary is absent and no gensee is on the usual paths, the app executes whatever binary sits at that predictable, user-writable location, inside the host app's context with GENSEE_HOME pointed at the store. These lines also contradict the PR's validation claim that the staged-file audit found no user-specific paths.

macos/GenseeCrate/Host/EndpointSecuritySensor.swift:151 — ingester stderr is only drained at exit

The gensee ingest endpoint-security child's stderr pipe is read only inside terminationHandler, but the ingester prints one line per rejected event (main.rs:3701). A reject burst (schema mismatch, actor.pid == 0, a future schema_version) fills the pipe after ~64KB, the child blocks and stops consuming stdin, and ingestion halts silently — health.connected stays true and terminationHandler never runs. write(events:) at line 273 then blocks on the MainActor once the stdin pipe backs up, freezing the UI. Attach a readabilityHandler.

macos/GenseeCrate/EndpointSecurityExtension/main.m:518managedProcesses leaks the pre-exec key on every exec

NOTIFY_FORK inserts 900:5; NOTIFY_EXEC inserts the post-exec 900:6; NOTIFY_EXIT removes only 900:6 because keyForProcess:message->process yields the post-exec identity. One dictionary entry leaks per exec in a process that runs from boot to shutdown. updateConfiguration prunes by session id, but only for sessions that have left the host's managed-roots list — which, per the session-lifetime issue above, they never do.

Low

crate/gensee-crate-rules/src/policy.rs:239fail_closed_managed_only and max_auth_latency_ms are never read

Both are parsed into EndpointSecurityConfig and rendered on the Policy page (DashboardConfigPages.swift:78-79), but nothing consults them: the extension's scoping is hardcoded as enforcing && session != nil, and the latency budget is never sent to or enforced by the extension. Setting fail_closed_managed_only: false is a silent no-op on a security control. Neither key is in SETTABLE_POLICY_KEYS, so gensee policy set rejects them too.

crate/gensee-crate-macos/src/event.rs:197 — write-only open falls through to event_kind: "system"

For open(path, O_WRONLY) the guard self.is_read_open() || self.event_type != "open" is false on both sides, "open" matches no later arm, and it lands on _ => "system". The timeline (timeline.rs:393) and alert evidence (store/lib.rs:2053) then describe a write-intent open of a secret as an unclassified system event. This also disagrees with ingest_endpoint_security, which maps the same event to operation "read" (main.rs:3722). is_write_open at line 186 has no caller and was presumably meant to be used here.


2. feb0643 — Add harness health checks and repair

The Rust side is sound: all six write_claude_code_settings call sites were updated, --repair is parsed only by setup_claude_code (the only provider the UI passes it to), the Swift shellQuote safe-set and escape form match Rust's shell_quote exactly, absolutize_for_hook only absolutizes rather than canonicalizing, and merge_*_hook_event replaces rather than appends owned entries so Repair genuinely fixes stale commands. The per-provider event lists match both apply_*_hook_settings and the --disable removal lists.

macos/GenseeCrate/Host/HarnessConfigurationHealth.swift:62 (high) — exact backend-path match flags working hooks as unprotected

Partially addressed by 462a820 — see section 4. A user who installed gensee via Homebrew and ran gensee setup from the terminal has fully working hooks. Installing the app makes resolveExecutable() prefer the bundled binary, so expectedCommand names the bundle path while every installed command names the Homebrew path. All five harnesses flip to "Needs repair", protectedCount drops to 0, and the Observe/Enforce indicators go dark — which docs/macos-app.md defines as "stale configuration is not presented as protection". Pressing Repair rewrites to the bundle; the next terminal gensee setup flips it back. The GENSEE_HOME= prefix (what actually determines the event store) should be compared separately from the backend path, with a non-zero-protection status for a valid-but-different backend.

macos/GenseeCrate/Host/ConsoleModel.swift:244 (medium) — Repair pins harness hooks to the app bundle path

repairIntegration runs setup <provider> without --bin, so Rust uses env::current_exe() — the bundled …/Gensee Crate.app/Contents/Resources/bin/gensee. After Repair, ~/.claude/settings.json carries that path permanently. Deleting, moving, or restructuring the app then breaks the hook on every tool call. Because Repair is the one-click fix for the state above, this is the default path rather than an edge case. Consider --bin with a stable location, or a shim outside the bundle.

macos/GenseeCrate/Host/ConsoleModel.swift:240 (medium) — enable path omits --repair, landing straight in "Needs repair"

With "disableAllHooks": true, flipping the switch on runs setup without repair, so write_claude_code_settings leaves it true (main.rs:2015) and prints a warning to stderr that the UI discards. The row the user just enabled immediately shows "Needs repair" with Observe/Enforce off, requiring a second click. Either pass --repair on the explicit enable, or surface the CLI warning so the two-step is explained.

macos/GenseeCrate/Host/HarnessConfigurationHealth.swift:118 (low) — isGenseeCommand drops the space Rust's ownership test requires

Swift matches hasSuffix("hook \(provider)"); Rust's gensee_hook_command_owned_by requires ends_with(" hook {provider}"). A command like GENSEE_HOME=/x /usr/local/bin/mywrapper --hook cursor is owned by Swift but not by Rust, so the toggle renders ON, turning it off removes nothing, the CLI still reports success, and the toggle snaps back with no way to disable it. The two predicates are clearly meant to be identical and should share one definition.

macos/GenseeCrate/Host/HarnessConfigurationHealth.swift:55 (low) — malformed hooks container misreported as missing coverage

When root["hooks"] (or root["gensee-policy"]) exists but is not an object, the cast yields nil and every expected event is reported missing. The row promises "Repair will restore full coverage", but apply_claude_code_hook_settings rejects a non-object hooks field outright, so Repair fails with a raw CLI error. Distinguish "hooks container is malformed" from "coverage is incomplete", as the invalid-JSON branch already does.


3. b1eef87 — Add Daily Highlight activity heatmaps

Two things that were easy to get wrong and were handled correctly: adding created_at to requests made created_at ambiguous in the three alerts LEFT JOIN requests queries, and all three were qualified (store/lib.rs:335,341; sqlite.rs:1558,1577); and migrate_legacy_ownership's hardcoded requests_new rebuild omits the three new columns but runs before migrate_request_activity_fields in open(), so legacy agent_id upgrades are safe.

db/src/sqlite.rs:728 (high) — the first captured turn absorbs the entire prior session token total

complete_request_with_token_total derives the per-turn delta as cumulative − SUM(prior total_tokens), but migrate_request_activity_fields adds total_tokens INTEGER with no default, so every pre-upgrade request is NULL and SUM skips them. A session with 12 existing requests and a 500,000-token cumulative therefore stores 500,000 as the delta for the single next turn — the Daily Highlight reports 500k for one day and the rolling-year total is inflated by the same amount, directly contradicting docs/macos-app.md's "prior turns remain at zero". The same happens when ~/.gensee is deleted or GENSEE_HOME changes mid-session. The cumulative < earlier fallback returns the full cumulative rather than 0, erring in the same direction. Seeding a per-session baseline on first observation (or storing the last-seen cumulative) would avoid it.

macos/GenseeCrate/Host/DashboardOverviewPages.swift:176 (high) — historical day shows SQL totals beside empty detail panels

The heatmaps make every day of the past year selectable and drive date, but Sessions, Files Written/Read, Web Requests, Top tools, and Alert breakdown all derive from model.snapshot.agentEvents (store/lib.rs:369, LIMIT 200), snapshot.alerts (LIMIT 200), and snapshot.sessions (LIMIT 100), all ordered by timestamp DESC. Selecting a day from three months ago shows e.g. "Agent Turns 47, Tool Calls 120, Alerts 6" next to "Sessions 0", "Files Written 0", "Files Read 0", an empty Top-tools list, and an empty alert breakdown. Either scope the heatmap to days the snapshot can detail, or add a per-day CLI query for the detail panels.

crate/gensee-crate-store/src/lib.rs:1339 (medium) — the whole transcript is re-parsed on every Stop hook

transcript_total_tokens reads and JSON-parses the entire file (up to 64 MB) per Stop and builds a HashMap<String, i64> over every message id, so per-session cost is quadratic in turn count. A 30 MB transcript at turn 200 means 200 full re-parses on the blocking Stop path. Tracking a per-transcript byte offset, or reading backwards to the newest usage record, would make this incremental.

crate/gensee-crate-store/src/lib.rs:1339 (medium) — first code path to open an agent-supplied transcript_path, unvalidated

Every prior use of transcript_path treated it as opaque text (timeline.rs:227, 605; command_parse.rs:292). This commit passes it straight to fs::metadata and fs::File::open. A harness — or a prompt-injected agent that can influence the hook payload — can point it at any readable file; content is not persisted, but a numeric aggregate derived from an unintended file is stored as the turn's token total and Gensee is made to read arbitrary files on demand. Constrain reads to known transcript roots (~/.claude/projects, ~/.codex/sessions) or to a canonicalized path under the user's home, and reject escaping symlinks.

db/src/sqlite.rs:1919 (low) — idx_requests_created_at is never created on fresh databases

The index is created only inside migrate_request_activity_fields, which returns early when requests does not yet exist, and schema.sql does not declare it (contrast db/schema.sql:247-307, which lists every other index). New and upgraded databases end up with different index sets for the same schema. Today's query wraps created_at in date(...) so it cannot use the index anyway, which is why this is low — but it belongs in schema.sql.

macos/GenseeCrate/Host/DashboardOverviewPages.swift:371 (low) — duplicate dates would trap the app

Dictionary(uniqueKeysWithValues:) traps on a duplicate key, and activity is decoded from a separate gensee process whose version the app does not control (resolveExecutable() may pick any gensee on the machine). Dictionary(activity.map { … }, uniquingKeysWith: +) removes the crash path at no cost.

macos/GenseeCrate/Host/DashboardOverviewPages.swift:213 (low) — zero-token banner is wrong for historical days

"Token capture starts with new completed turns" is gated only on tokenCount == 0, so it appears on days that predate capture entirely and on harnesses with no compatible usage metadata, implying the user should wait when no data can ever arrive. Distinguish "no data for this date" from "capture not yet started" using whether the day has any requests.

Coverage note: neither migrate_request_activity_fields on a legacy database nor the complete_request_with_token_total delta math has a test. A test that completes a request in a session whose earlier rows have total_tokens IS NULL would catch the NULL-baseline bug above.


4. 462a820 — Fix macOS harness repair path checks

The parser is correct. I differential-tested shellWords against shellQuote for every shape Rust's shell_quote can emit — plain paths, embedded spaces, embedded single quotes (the '\'' form), backslashes, and non-ASCII — and all five round-trip to exactly four words with the home recovered intact. The /tmp/private/tmp alias step is load-bearing rather than redundant with resolvingSymlinksInPath(), because the latter is a no-op on paths that do not exist on disk. The prefix matching correctly appends / so /varlog is not caught by the /var rule, and testStaleEventStoreOrBackendNeedsRepair still guards the negative case.

On the section 2 finding — partially addressed. This fixes different spellings of the same file. It does not change the case described there: /opt/homebrew/bin/gensee resolves to its Cellar target, the bundled binary resolves to itself, and those stay unequal — so a working CLI-installed setup still flips to "Needs repair" with protectedCount at 0 the moment the app is installed. That is now arguably a defensible signal (they really are different binaries, possibly different versions) rather than a false alarm, but it still reports "unprotected" for hooks that work, and the repair-loop concern stands because the --bin pinning issue is untouched.

macos/GenseeCrate/Host/HarnessConfigurationHealth.swift:70 (low) — unparseable owned command reported as a wrong event store

hookCommandsAreEquivalent returns false whenever parsedHookCommand cannot produce the exact four-word shape. env GENSEE_HOME=… /usr/local/bin/gensee hook codex, or GENSEE_HOME="/Users/a b/.gensee" … (double quotes are not treated as quoting), are accepted by both isGenseeCommand and the Rust ownership test but yield five words. The row then reads "Hooks point to a different event store or Gensee backend" — which is false, the store is correct — and Repair silently replaces the user's wrapper. The parser's doc comment states the shape limitation; the user-facing message does not.

macos/GenseeCrate/Host/HarnessConfigurationHealth.swift:190 (low) — empty path normalizes to the process working directory

GENSEE_HOME= /usr/local/bin/gensee hook codex parses cleanly into four words with home == "", and URL(fileURLWithPath: "").standardizedFileURL.path resolves to the process CWD (verified in a Swift REPL). The comparison result then depends on where the app was launched from rather than on the config. It happens to mismatch today, so the row is still flagged — but by accident. Reject an empty home in parsedHookCommand instead.

Coverage note: the new test covers /tmp vs /private/tmp only. There is no test for a backend-only difference (the existing stale test varies both home and backend), and none for the parse-failure path — which is where both findings above live.


🤖 Generated with Claude Code

@yiying-zhang

Copy link
Copy Markdown
Contributor Author

Follow-up review — 2321f6e and 9651bc6

Checked out 9651bc6 and ran: cargo test -p gensee-crate-cli (383 passed), -p gensee-crate-store (27), -p gensee-crate-db (12), and xcodebuild test -scheme GenseeCrateHarnessTests (13 passed). All green.


2321f6e — Live Feed connection badge

Clean and self-contained. The Label is replaced with an explicit HStack + capsule that fixedSizes so it cannot compress in the header, keeps text alongside color (satisfying .impeccable.md principle 1), and adds an accessibility label. No findings.

One pre-existing semantic it inherits rather than introduces: the badge reads "Connected"/"Disconnected" from model.backendAvailable, which only means a gensee executable was found on disk. model.endpointSensor.health.connected is the actual live-connection signal, so a stalled ingest currently still renders as "Connected".


9651bc6 — Confirmed fixed

Verified against the earlier findings:

  • Cursor reset orderingcursor = response.1 now precedes applyHealth, so the rewind survives (but see the new finding below).
  • Event-loss reportingendpoint_security_event_gap is no longer filtered from dashboard_state and the extension now emits dropped_events as a delta against reportedDrops. Both halves were needed; either alone would have left the path dead.
  • Session lifecycleend_session on managed-root exit. Worth noting the extension change was load-bearing here: without attribution.root_pid now being populated from rootPIDForSessionLocked:, the Rust-side root_pid == actor.pid check would have been permanently false and the fix inert.
  • managedProcesses exec leak — pre-exec key now removed when it differs from the target key.
  • Self-lockout (partial)authorizeMessage now consults GenseeIsOwnProcess, the embedded CLI is codesigned as ai.gensee.crate.cli, and moving the own-process filter out of main() into recordMessage means EXIT bookkeeping now runs for own processes too. Note this only covers the bundled CLI; a Homebrew or cargo gensee still is not recognized as own.
  • es_clear_cache stormupdateConfiguration now compares serialized configurations and returns early when unchanged.
  • Ingester stderrreadabilityHandler with a 64 KB rolling buffer; write(events:) moved off the MainActor.
  • GenseeCLI.run deadlock — stdout and stderr drained concurrently via detached tasks before waitUntilExit.
  • Developer paths — removed from resolveExecutable().
  • Gateway on --disablegenseeGatewayManagedKeys records what Gensee wrote so disable can remove exactly those keys.
  • Enable path — now passes --repair for claude-code and --bin off the app bundle.
  • isGenseeCommand — leading space added, now identical to Rust's gensee_hook_command_owned_by.
  • Health check over-broadening — home and backend are compared separately; a valid alternate backend is now a note rather than an issue, so a working Homebrew install stays "Protected" with Observe/Enforce lit. This fully resolves the original concern.
  • Unparseable / malformed configs — distinct messages with canRepair: false and a "Manual fix needed" status instead of a misleading "different event store" claim.
  • Empty paths — rejected in both parsedHookCommand and absolutize_for_hook.
  • Daily Highlight detail panels — new gensee dashboard-day query wired via .task(id:), so historical days no longer show real headline numbers beside empty panels.
  • Heatmap crash pathDictionary(uniquingKeysWith: +).
  • Token banner — gated on requests > 0 with corrected copy.
  • Write-only open — now maps to file_mutation; is_write_open has a caller.
  • idx_requests_created_at — moved into schema.sql so fresh databases get it.
  • Token baselinesession_token_usage prevents the first captured turn from absorbing the whole prior session total, and a counter reset now yields 0 rather than the full cumulative.

9651bc6 — New findings

High — all three are interactions between fixes in this commit

macos/GenseeCrate/Host/GenseeCLI.swift:75protect mode denies exec of the new stable hook binary

stableHookExecutableURL() installs the hook at ~/.gensee/bin/gensee, which is inside the store directory configureEndpointSensor unconditionally adds to protectedPaths (ConsoleModel.swift:527, var protectedPaths = [homeURL.path]). When the agent execs the hook, GenseeAuthorizationPath returns the AUTH_EXEC target /Users/x/.gensee/bin/gensee, hasProtectedPrefixLocked matches the /Users/x/.gensee/ prefix, and the request is denied. The new own-process exemption does not help: isOwnProcessLocked: inspects message->process, which for AUTH_EXEC is the calling agent, not the target. Every hook invocation fails to launch, so hook-layer enforcement silently stops exactly when the user raises endpoint_security.mode to protect. Exempt the store's own bin/ from protectedPaths, or check the exec target against GenseeIsOwnProcess as well.

macos/GenseeCrate/EndpointSecurityExtension/main.m:481fail_closed_managed_only: false extends the deny scope to the whole machine

BOOL inScope = !self.failClosedManagedOnly || session != nil; means a false value makes authorizeMessage evaluate protectedPaths for every process on the Mac. Since configureEndpointSensor seeds that list unconditionally with ~/.gensee, ~/.ssh, ~/.aws, ~/.kube, and ~/.config/gcloud, protect mode plus this key denies ssh reading its own key, git over SSH, aws, kubectl, and Finder. The key is now in SETTABLE_POLICY_KEYS, so gensee policy set endpoint_security.fail_closed_managed_only false is a supported operation, and its schema description ("Never fail closed for unrelated host processes") reads as a mild relaxation rather than a machine-wide enforcement switch. This also contradicts the PR description's guarantee that "unrelated host processes remain outside the deny scope". Relatedly, max_auth_latency_ms is now settable with no range check despite the schema's minimum: 1, maximum: 100, so a value of 1 makes denies fail open under any lock contention.

macos/GenseeCrate/Host/EndpointSecuritySensor.swift:224 — cursor advances before the write, dropping batches on error

Moving cursor = response.1 ahead of applyHealth fixes the restart stall, but it now also runs before pushConfiguration and write(events:). If pushConfiguration throws (the sensor rejecting a configuration, e.g. max_auth_latency_ms == 0 at main.m:688) or write throws (ingestInput nil after the ingester died), control leaves the do block before the batch is handed to gensee ingest — and the next poll filters eventCursor <= cursor, so up to 500 events are lost with no gap record. dropped_events does not cover them because the extension already emitted them. Track a pending cursor and commit it only after a successful write, while still letting applyHealth's rewind win.

Medium

db/src/sqlite.rs:766total_tokens is now write-once, so a repeated Stop loses tokens

The update changed from COALESCE(?3, total_tokens) to COALESCE(total_tokens, ?3). If a UserPromptSubmit is missed, the next Stop reuses the prior request via latest_request_for_session and completes it a second time. The first completion stored a non-NULL value (0 for a session's first turn, since that call establishes the baseline) and advanced session_token_usage.last_cumulative_tokens. The second call computes a correct delta but COALESCE keeps the existing value and discards it — and because the baseline already moved past those tokens, no later turn can claim them either. total_tokens = COALESCE(total_tokens, 0) + ?3 accumulates correctly. The new test uses three distinct requests so it does not cover this.

macos/GenseeCrate/Host/ConsoleModel.swift:261 — Repair pins Homebrew hooks to a versioned Cellar path

repairIntegration reuses integration.configuredBackendPath, which inspect sets from normalizedPath($0.backend) (HarnessConfigurationHealth.swift:124) — and normalizedPath calls resolvingSymlinksInPath(). So a hook naming /opt/homebrew/bin/gensee is rewritten to /opt/homebrew/Cellar/gensee/0.2.1/bin/gensee, which the next brew upgrade deletes. Keep the unresolved path from the parsed command for --bin; use the normalized form only for comparison.

macos/GenseeCrate/Host/GenseeCLI.swift:68 — the stable hook copy is never refreshed

stableHookExecutableURL() stages the bundled CLI only during enable/repair, and preferredHookExecutableURL() prefers the existing copy unconditionally thereafter. After an app update, expectedCommand names the stale copy, the installed commands match it, and every harness reports Healthy while all agent hooks keep executing the previous backend against a store written by the newer app. Compare the copy's version or content hash during refreshIntegrations and re-stage on mismatch.

crate/gensee-crate-store/src/lib.rs:68 — the transcript token cache is never evicted

transcript_tokens: Arc<Mutex<HashMap<PathBuf, TranscriptTokenState>>> grows without bound, and each entry retains a claude_messages map holding one entry per assistant message id so the running total can be recomputed. In the long-lived daemon this accumulates every message id of every transcript ever seen, including sessions that have long ended — tens of megabytes after a few hundred sessions. Before this change the map was per-call and dropped immediately. The new end_session path is a natural eviction hook.

crate/gensee-crate-store/src/lib.rs:1188 — the incremental read does not help the non-daemon path

The offset cache lives on the EventStore instance, but the in-process hook fallback builds a fresh EventStore::default_local() per invocation (main.rs:3958). For any user who has not started the warm daemon, cache.entry(path).or_default() always yields offset == 0 and the whole transcript is re-parsed on every Stop — the exact quadratic behavior the rewrite was meant to remove, unchanged. Persisting the offset and running totals in the store (alongside session_token_usage, say) would make it survive process boundaries.

Low

macos/GenseeCrate/Host/DashboardOverviewPages.swift:177 — every detail metric falls back to 0/[] when selectedDetail is nil, while requests/toolCalls/alertCount still fall back to selectedActivity. A failed dashboard-day call (transient CLI failure, or a resolved gensee predating the subcommand) leaves refreshDailyDetail setting only dashboardRefreshIssue, so the page shows real headline numbers beside "Sessions 0", "Files Read 0", and "No alerts for this date" — the original inconsistency, back on the error path and during every load. Distinguish loading/unavailable from a real zero.

crate/gensee-crate-store/src/lib.rs:210 — the new duplicate guard calls list_sessions() on every append_session, reading, decrypting, and parsing the entire sessions.jsonl before appending one line. end_session does it twice (once to find the record, once via append_session), ingest_endpoint_security calls it per managed-root exit, and tclone_fork calls append_session per clone. The guard only needs records for one session_id.

macos/GenseeCrate/EndpointSecurityExtension/main.m:501 — the comment says the fail-open "is still recorded with a reason so the console can surface the overrun", but recordMessage returns early on actorSession == nil, which is precisely the case reachable once fail_closed_managed_only is false. A reversed enforcement decision on an unmanaged process is never recorded anywhere.

crate/gensee-crate-store/src/lib.rs:1510allowed_transcript_path hardcodes $HOME/.claude/projects and $HOME/.codex/sessions. With CLAUDE_CONFIG_DIR or CODEX_HOME set, every canonicalized transcript path falls outside the allowed root, transcript_total_tokens returns None, and total_tokens stays NULL forever — with the UI banner blaming the harness rather than the path. Honor the same environment variables the harnesses use.


🤖 Generated with Claude Code

@yiying-zhang

Copy link
Copy Markdown
Contributor Author

Review — 75ae847, 2f24181, f9fb825

Continues from the previous follow-up, which covered 2321f6e and 9651bc6. 16 new findings across three commits.

Not covered here: 48229d0 (Prepare notarized macOS app releases) and the config-audit backend commits 2ea6f3446343fb (~9,000 lines) are still unreviewed.

Verification run: cargo test across cli/store/db/rules/macos (383 + 30 + 14 + 36 + 6 passed) and xcodebuild test -scheme GenseeCrateHarnessTests (17 passed).


75ae847 — Address Endpoint Security follow-up review

10 of the 12 prior findings are fixed, several with real depth. fail_closed_managed_only is neutralized at five layers (removed from extension state so session != nil is unconditional, removed from SETTABLE_POLICY_KEYS and the Policy page, const: true in the schema, rejected at Policy::from_json, rejected over XPC) with docs and two tests. max_auth_latency_ms is bounded 1–100 at schema, policy-load, XPC (with a floor non-integer check), and host-clamp. Transcript parse state moved into a transcript_token_state table keyed by (session, path) and deleted on end_session — one change that fixes both the unbounded cache and the non-daemon path, with a test proving the offset survives an EventStore restart. total_tokens now accumulates. append_session uses an indexed get_session with a new sessions.root_pid column and migration.

Medium

macos/GenseeCrate/Host/GenseeCLI.swift:75protect mode denies exec of the new stable hook binary

stableHookExecutableURL() installs the hook at ~/.gensee/bin/gensee, inside the store directory configureEndpointSensor unconditionally adds to protectedPaths (ConsoleModel.swift:527). When the agent execs the hook, GenseeAuthorizationPath returns the AUTH_EXEC target /Users/x/.gensee/bin/gensee and hasProtectedPrefixLocked matches. The ownExecTarget check added in this commit covers exactly this — but only for a binary GenseeIsOwnProcess recognizes; see the related finding below for the case it does not.

macos/GenseeCrate/EndpointSecurityExtension/main.m:480 — a non-bundled gensee is still denied writes to its own store

ownExecTarget fixes the exec half, but GenseeIsOwnProcess only recognizes binaries signed by team 3KWVB4M63F. A developer or Homebrew user has executableURL outside an app bundle, so stableHookExecutableURL() returns it unchanged and hooks are wired to --bin /opt/homebrew/bin/gensee. In protect mode the exec is allowed (not under a protected path), but the hook's subsequent open of ~/.gensee/gensee.db is denied: isOwnProcessLocked: returns NO and hasProtectedPrefixLocked matches. Hook-layer enforcement stops for exactly the users who chose their own backend. Recognizing the configured --bin path would close the remaining half.

macos/GenseeCrate/Host/EndpointSecuritySensor.swift:229 — the first poll after launch ingests its batch twice

bootID starts empty and GenseeBootID() never returns an empty string, so applyHealth always rewinds on the first poll. The new if !didRewind guard makes that rewind stick after write(events:) has already handed the batch to gensee ingest, so the next poll refetches and re-ingests the same events: duplicate system_events rows and duplicate agent_descendant_exec / unexpected_interpreter_chain / policy alerts, inflating alert counts and the Daily Highlight. This predates the commit — 9651bc6 had the same net effect by assigning cursor before applyHealth — but the explicit guard is the natural place to fix it. Skip the write when a rewind happened, or apply the rewind before fetching.

crate/gensee-crate-store/src/lib.rs:1130 — the transcript read moved inside the write transaction

Persisting parse state required a &SqliteStore, so the transcript read now runs inside with_sqlite_transaction. On a resumed session with a 30 MB transcript and no transcript_token_state row yet, the SQLite write lock is held across the whole read, blocking every concurrent gensee process including the console's 2s dashboard-state. Separately, the call is now .transpose()? rather than .ok().flatten(), so an I/O error during the read rolls back the transaction and discards the Stop hook event itself, not just its token count. Read the file before opening the transaction, and keep token-accounting failures non-fatal for evidence recording.

Low

  • macos/GenseeCrate/Host/GenseeCLI.swift:71preferredHookExecutableURL() byte-compares two multi-megabyte Mach-O files via contentsEqual, and refreshIntegrations() calls it once per provider definition — six full comparisons on the MainActor per refresh, all returning the same answer. Hoist to one call and short-circuit on size and mtime.
  • macos/GenseeCrate/Host/ConsoleModel.swift:146refreshDailyDetail never checks cancellation and cli.run uses Task.detached, which does not inherit it. Two quick heatmap clicks can leave dailyDetailLoadState at .loaded(olderDay) while the selected day has no detail and no loading state, stranding the card on "Preparing daily details…" indefinitely.
  • macos/GenseeCrate/Host/HarnessConfigurationHealth.swift:137 — the guard uses the normalized set (backendPaths.count == 1) but the value comes from configuredBackendPaths.sorted().first. When two events name the same binary through different spellings, the lexicographically first wins — and Cellar sorts before bin, reintroducing the versioned path this commit's own comment says it exists to prevent.

2f24181 — Improve macOS security console

Two things I checked by measuring rather than assuming, both clean. The alerts query gained four correlated subqueries for trigger-event resolution plus one for latest feedback, and dashboard_state runs every 2s — but on a synthetic 200k-alert / 60k-agent-event database the new query runs in 46 ms versus 64 ms for the old one; SQLite defers the correlated subqueries until after the sort and limit, so they execute for the 200 output rows only. And json_extract(alerts.evidence, …) would abort the whole query on malformed JSON, but the schema carries CHECK (evidence IS NULL OR json_valid(evidence)). Also verified --event-key, --session, and --tool-use-id are accepted by feedback_record via parse_named_flags, the three verdict values match its validation, idx_human_feedback_event covers the feedback subquery, and column qualification is complete (necessary once human_feedback.created_at joins alongside alerts.created_at).

Medium

macos/GenseeCrate/Host/DashboardConfigPages.swift:9 — a Settings edit wipes an unparseable JSON buffer

document silently returns [:] whenever editorText is empty or invalid, and updatePolicyValue rebuilds editorText from it — setDottedValue happily creates intermediate dictionaries from nothing. A typo in the Advanced tab followed by touching any Settings control replaces the whole policy buffer with e.g. {"egress":{"require_proxy":true}}. The same happens at launch when refreshPolicy throws, leaving policyDocument at its initial "". I confirmed the damage stops there: gensee policy validate rejects such a document with missing field schema_version, so Save & Validate fails and ~/.gensee/policy.json is never overwritten — but the user sees a schema error rather than being told their JSON does not parse. Disable the Settings controls, or surface the parse error, when document is empty.

macos/GenseeCrate/Host/DashboardConfigPages.swift:381 — failed validation writes the raw string into typed fields

Every failure branch of validateAndUpdate calls onChange(raw), so a rejected value is still written into the policy JSON as a String where the schema and the Rust type require a number. Typing abc into "Max read bytes", or 500 into "Authorization latency budget" (maximum 100), produces "max_read_bytes": "abc" in the buffer, flips dirty, and enables Save & Validate; the only backstop is a serde error from gensee policy validate rather than the inline message the user already saw. The same fires on every intermediate keystroke while clearing a non-nullable numeric field. Keep the last valid value and let validationMessage alone represent the invalid state.

macos/GenseeCrate/Host/DashboardConfigPages.swift:449 — decimal fields render values their own validator rejects

editableText formats decimals with .formatted(.number.precision(.fractionLength(1...3))), which applies locale grouping, but validateAndUpdate parses with Double(_:), which is locale-independent. Verified in a Swift REPL: (1234.5).formatted(.number.precision(.fractionLength(1...3))) yields "1,234.5" and Double("1,234.5") is nil. A user who sets max_file_accessed_rate_per_min to 1200 sees a field its own validator will not accept, and the first keystroke writes a raw string per the finding above. The shipped defaults (120.0, 30.0) stay below the grouping threshold, which is why this is invisible out of the box; comma-decimal locales hit it immediately at any magnitude. Format with a fixed non-grouping style, or parse with the same FormatStyle used to render.

Low

  • macos/GenseeCrate/Host/DashboardActivityPages.swift:185TransactionsPage is now unreachable: the .transactions destination, sidebar entry, and case were all removed, but the ~40-line view remains and Swift emits no warning for an unreferenced internal type. FeedbackPage and DashboardAlertRow were deleted properly in the same commit. Transaction data is still reachable through the Live Feed's "Transactional environment" category, so the feature is not lost — either wire the page back up or delete it.
  • macos/GenseeCrate/Host/ConsoleModel.swift:63unreadAlertCount and markAllAlertsRead both operate on snapshot.alerts, capped at LIMIT 200, so the sidebar badge silently changed from a true total (summary.alertsCount) to unread-among-the-200-most-recent and can never exceed 200. Self-consistent, but it cannot represent a real backlog.
  • macos/GenseeCrate/Host/DashboardOverviewPages.swift:414 — the heatmap hover readout swaps a plain Text for a padded capsule in the same header VStack, so the header grows on hover and the grid below shifts, moving the hovered cell out from under the cursor and producing flicker. Reserve the height, or give the idle text matching padding.

f9fb825 — Add native macOS config audit console

Rather than reading the decode models against the Rust structs, I exercised them end to end: built the CLI, generated real reports for both targets, and decoded them with swiftc-compiled ConfigAuditModels.swift. Both codex (5 findings, 29 sources) and vscode (2 reports, 12 findings, 57 sources, 23 extensions) decode cleanly, including the not_detected report and the extensions/custom_agents inventory the unit test's hand-written JSON does not cover; the decodeIfPresent ?? [] treatment lines up with the Rust skip_serializing_if attributes, and AuditReport's own fields carry none so the required Swift fields there are safe. acceptingExitCodes: [0, 2] is exactly right — audit_exit_code returns 2 for incomplete audits and 1 only under --fail-on, which the UI never passes — and the JSON is emitted via println! before std::process::exit, so Rust's LineWriter flushes it and exit 2 does not truncate the report over a pipe. Summary counts agree with what the tabs render, since the bundle summary already aggregates over included reports only.

Medium

macos/GenseeCrate/Host/DashboardConfigAuditPage.swift:281 — duplicate ForEach ids drop rows in the Sources tab

sourcesView enumerates the sources but identifies rows by \.element.id, which is "kind:path" — discarding the uniqueness the enumeration already provides. Running gensee audit vscode --workspace $HOME --json on a real machine produced vscode_hook:~/.claude/settings.json twice among its 58 sources, so SwiftUI hits a repeated identifier, logs "ID … occurs multiple times within the collection, this will give undefined results", and renders only one — silently hiding a hook source row with its own applied flag and errors list from a security audit view. I checked every other ForEach identifier against the same live reports (skills, extensions, MCP servers, findings, evidence, manual checks) and only this one collides. id: \.offset fixes it.

Low

  • macos/GenseeCrate/Host/DashboardConfigAuditPage.swift:11defaultWorkspace falls back to the user's home directory, so the prominent "Run Audit" button audits $HOME as if it were a project. Measured at ~1s, so not a performance problem — the auditor reads bounded config surfaces rather than walking the tree — but the workspace-scoped half of the report is meaningless: $HOME yields 58 sources versus 57 for an empty temp directory. The CLI defaults to the current directory; the GUI has no equivalent.
  • macos/GenseeCrate/Host/DashboardConfigAuditPage.swift:16 — the freshness guard compares only requestedTarget, so editing the workspace field leaves the previous workspace's findings, inventory, and sources on screen while the control shows a different path. The only hint is a 10pt secondary-colored path at the far right of the summary row. Include the audited workspace in the check, as TodayHighlightPage does for dailyDetail.date.

🤖 Generated with Claude Code

@yiying-zhang

Copy link
Copy Markdown
Contributor Author

Review — 095dd8d, 3d4aba9

All 16 findings from the previous comment are addressed in 3d4aba9. Verified: cargo test (391 + 30 passed), xcodebuild test -scheme GenseeCrateHarnessTests (20 passed, up from 17).

Four items need attention — two high, one medium, one low. Two of the four are consequences of fixes in these commits rather than pre-existing issues.


High — macos/GenseeCrate/Host/ConsoleModel.swift:694 — agent-writable config feeds the Endpoint Security authorization bypass list

The own_executables allowlist added in 3d4aba9 closes the Homebrew self-lockout, but its input is attacker-reachable.

ownExecutables is built from integrations.compactMap(\.configuredBackendPath), and configuredBackendPath is parsed out of the harness's own configuration file. ~/.claude/settings.json is not in protectedPaths — that list is only ~/.gensee, ~/.ssh, ~/.aws, ~/.kube, ~/.config/gcloud plus policy additions — so a managed agent can write it.

The chain:

  1. The agent writes a hook command of the form GENSEE_HOME=/Users/x/.gensee /tmp/evil hook claude-code. isGenseeCommand accepts it (contains GENSEE_HOME=, ends with hook claude-code).
  2. inspect parses it into configuredBackendPaths. Because /tmp/evil is executable, no missingBackend issue fires — the row surfaces only an informational note, "Hooks use another valid Gensee installation at /tmp/evil."
  3. configureEndpointSensor pushes /tmp/evil into own_executables.
  4. isOwnProcessLocked: (main.m:460) matches it by path string alone, with no signature or hash check, so authorizeMessage skips every protected-path and blocked-executable rule for that process.

A file write becomes a full protect-mode bypass. The same path-only trust applies to ~/.gensee/bin/gensee, which any unmanaged process on the machine can overwrite (only managed trees are enforced). GenseeIsOwnProcess's team/signing-ID check is the strong form; the allowlist should verify the code signature or a content hash rather than the path.


High — crate/gensee-crate-store/src/lib.rs:540dashboard-state payload grew ~58× on a 2-second refresh loop

095dd8d added an unbounded-text requests block (500 rows of original_user_prompt and final_response) and tool_response on the 200 agentEvents rows. Measured on a seeded store with 20k requests and 60k agent events at realistic sizes (2 KB prompts, 9 KB responses, 8 KB tool stdout):

gensee dashboard-state  ->  7,600,439 bytes in 1.07 s

  requests      rows=500  5,776,500 bytes  (76%)   <- new
  agentEvents   rows=200  1,834,600 bytes  (24%)   <- 1,694,000 of this is tool_response, new
  ...
  payload before this commit ~= 129,939 bytes      -> 58x increase

DashboardShell.swift:70 calls refreshDashboard every 2 seconds, so the app now spawns a subprocess, reads 7.6 MB through a pipe, and JSON-decodes it into SecuritySnapshot on the MainActor — roughly 1.07 s of backend work per 2 s window. The requests query also has no index supporting ORDER BY COALESCE(completed_at, created_at, request_id) DESC, so it full-scans and sorts the table on every refresh.

Project the text columns down, or keep them out of the periodic payload and fetch on demand the way dashboard-day already does.


Medium — crate/gensee-crate-store/src/lib.rs:2973tool_response is stored with no size cap

tool_response_json serializes tool_response_stdout / stderr at whatever size the harness supplied. Nothing bounds it: AgentHookEvent.tool_response_stdout (crate/gensee-crate-core/src/hooks.rs:12) is not truncated at parse time either. This contrasts with tool_input on the same row, which is truncated to MAX_STORED_TOOL_INPUT_BYTES (16 KB) with replacement metadata at lib.rs:2929.

A PostToolUse for a Bash call emitting several megabytes of stdout is stored in full — and as of 095dd8d that column ships in every 2-second dashboard payload (1.69 MB across 200 events in the measurement above). Apply the same truncation-with-metadata treatment tool_input already gets.


Low — macos/GenseeCrate/Host/ConsoleModel.swift:73 — unread-alert baseline goes stale if the store is reset

readAlertBaselineCount and readThroughAlertID persist in UserDefaults keyed only by homeURL.path, so they survive a deleted or recreated event store whose alert_id sequence restarts at 1.

After marking all read with 5,000 alerts, a user who deletes ~/.gensee/gensee.db (the Settings page offers Reveal data store, and EventStore::new recreates it) or restores an older copy gets min(readAlertBaselineCount, alertsCount) cancelling the entire count, so the badge stays at 0 until 5,000 fresh alerts accumulate. isAlertRead also returns true for every new alert, since the new autoincrement ids all fall below the 5,000 watermark — nothing renders as unread. Alerts are append-only in normal operation, so this needs an explicit store reset. Resetting the baseline whenever summary.alertsCount drops below it is sufficient.


Checked and clear

Recording these so they are not re-investigated:

  • TimelineDerivation.toolCalls phantom rows — the call site filters agentEvents by requestID only, but UserPromptSubmit / Stop never become agent_events rows (they match their own arms before is_agent_event), and file_intent rows share a tool_use_id with their PreToolUse, so the real Pre always wins the pairing regardless of arrival order. No phantom "Unknown" timeline entries.
  • AlertSeverityBreakdown — cumulative slice fractions are correct and unknown severities bucket into info.
  • Cursor rewind convergence — after the first rewind bootID matches and cursor = oldest-1 < nextCursor, including when the ring is empty, so the discard-and-refetch cannot loop.

🤖 Generated with Claude Code

@yiying-zhang

Copy link
Copy Markdown
Contributor Author

Review — b570f73

All four findings from the previous comment are addressed. Verified: cargo test -p gensee-crate-store (34 passed), xcodebuild test -scheme GenseeCrateHarnessTests (24 passed, up from 20).

Three new items, all consequences of the lineage-noise filtering added here.


Medium — crate/gensee-crate-store/src/lib.rs:624 — the relations prefilter can starve the Lineage view to zero

The relations query keeps LIMIT 5000 in SQL and then applies dashboard_relation_is_visible and .take(200) in Rust. The visible set is therefore whatever survives filtering within the newest 5000 rows, not the newest 200 visible relations.

Reproduced on a seeded store:

300 clean /repo relations present            -> dashboard-state returns 200 relations
+ 5,199 newer /usr/lib <-> /usr/lib relations -> dashboard-state returns 0 relations

The clean lineage is still in the store; it has simply been pushed out of the prefilter window. This is precisely the workload the new filter exists for — Endpoint Security generates large volumes of /usr/lib, /System, and .claude/projects edges, so the newest 5000 rows can easily be all-noise on a real machine, and the Lineage graph goes empty with no indication why.

Push lineage_path_is_harness_runtime_noise / lineage_path_is_system_dependency into the SQL predicate, or page until 200 visible rows are collected.


Medium — crate/gensee-crate-store/src/lib.rs:570 — the artifact_facts scan is now unbounded on every refresh

The artifact query dropped its LIMIT 80 so the Rust-side visibility filter can see every row, but nothing replaces the bound. The whole artifact_facts table is joined against system_events and materialized into JSON on each two-second refresh.

Measured on the same store:

artifact_facts empty      -> dashboard-state 0.40 s
artifact_facts 50,000 rows -> dashboard-state 0.92 s   (2.3x, result still capped at 80 artifacts / 31 KB)

The cost is linear in table size, paid every two seconds by DashboardShell.swift:70, and artifact_facts grows monotonically with agent activity. summary.artifacts_count now also requires the full scan.

The correctness motive is sound — filtering after a SQL LIMIT 80 would under-fill the list — but the same fix as above applies. The predicates are all path prefixes plus a json_extract(..., '$.file.mode') check, so they are expressible in SQL and the LIMIT can be restored.


Medium — macos/GenseeCrate/Host/ConsoleModel.swift:719 — removing own_executables reopens the non-bundled CLI lockout

Dropping the own_executables allowlist correctly closes the path-based authorization bypass, and tightening GenseeIsOwnProcess to strict team-ID + signing-ID (dropping both the is_es_client exemption and the /Gensee Crate.app/Contents/ path-substring check) is the right hardening. But protectedPaths still starts unconditionally with homeURL.path.

So a gensee that is not signed by team 3KWVB4M63F is again denied access to its own store in protect mode: a developer or Homebrew user has cli.executableURL outside an app bundle, stableHookExecutableURL() returns it unchanged, and the hooks run an unsigned binary. When that process opens ~/.gensee/gensee.db to record the event and evaluate policy, isOwnProcessLocked: returns NO and hasProtectedPrefixLocked matches the ~/.gensee/ prefix — the open is denied and hook-layer enforcement stops for that population.

The security fix and the functional fix need to land together. Excluding the store's own database path from protectedPaths, or verifying a content hash of the configured --bin instead of trusting its path, would close both.


Verified fixed

Recording the measurements so they are not re-derived:

  • Payloadfinal_response dropped, prompts truncated to 1024 chars, tool_response replaced by json_extract(..., '$.duration_ms'), hookEvents / workspaceEffects removed. Re-measured on the same seeded store as before: 7,600,439 -> 625,801 bytes (12x) and 1.07 s -> 0.40 s steady state.
  • Indexidx_requests_dashboard_activity is created without error and is actually used: EXPLAIN QUERY PLAN reports SCAN requests USING INDEX idx_requests_dashboard_activity, with no temporary B-tree sort.
  • tool_response size capMAX_STORED_TOOL_RESPONSE_BYTES added alongside the existing tool_input bound.
  • Unread-alert baselinereconcileReadAlertState plus AlertReadState.storeWasReset resets the baseline whenever alertCount < readAlertBaselineCount.

🤖 Generated with Claude Code

@yiying-zhang

Copy link
Copy Markdown
Contributor Author

Review — 582c8aa, 3d4163f

All three findings from the previous comment are addressed in 3d4163f. Verified: cargo test -p gensee-crate-store (35 passed).

Four new items — two high, one medium, one low.


High — crate/gensee-crate-store/src/lib.rs:822transactionEvents now ships with zero consumers

582c8aa removed TransactionEvent, the transactionEvents field on SecuritySnapshot, and its CodingKeys entry, so the macOS console no longer decodes it. The Tauri dashboard does not read it from dashboard-state either — it fetches transactions from a separate endpoint (dashboards/src/api/client.ts:103, api.transactionEvents(1_000, 0)).

But dashboard_state still emits the LIMIT 1000 block. Measured on a store seeded with 3,000 transaction events at realistic sizes (1.2 KB summaries, 4 KB metadata):

dashboard-state total          6,277,799 bytes
  transactionEvents  1000 rows 5,628,000 bytes   (89% of payload, zero consumers)

That undoes most of the payload reduction b570f73 achieved — the same store without transaction history returns 625,801 bytes. Drop the block from dashboard_state, the way hookEvents and workspaceEffects already were.


High — macos/GenseeCrate/EndpointSecurityExtension/main.m:227 — content trust hashes the path, not the running image

Replacing the path allowlist with content hashing is the right primitive, but the verification is bound to the file rather than to the executing image, and there are two independent ways through it.

1. TOCTOU. GenseeExecutableSHA256 re-reads the file at process->executable->path when the authorization arrives, not the image the process loaded. A process that executed a malicious binary is granted trust if the file is restored to trusted content before the extension hashes it. ~/.gensee/bin/gensee is user-writable, and with fail_closed_managed_only fixed at true, unmanaged processes are never denied writes to it.

2. Cache defeat, no race required. contentTrustByExecutable is keyed on dev:ino:size:mtime. Overwriting the trusted binary in place preserves dev and ino; size and mtime are attacker-controlled through truncate and utimes. The key still matches, so isOwnProcessLocked: returns the cached YES without rehashing at all.

es_process_t exposes cdhash (ESTypes.h:545), which the kernel computes over the pages actually executed. Note its guarantee is scoped to hardened-runtime, non-debugged, running processes — so an unsigned cargo build CLI, which is exactly the population this trust path exists for, still needs a file read. For that case, open by the inode Endpoint Security already reports rather than by path, and drop the size/mtime cache.


Medium — macos/GenseeCrate/EndpointSecurityExtension/main.m:495 — full-file SHA-256 runs inside the Endpoint Security authorization callback

isOwnProcessLocked: is called from authorizeMessage while holding @synchronized (self). On a cache miss it performs [NSData dataWithContentsOfFile:options:NSDataReadingMappedIfSafe] plus a full CC_SHA256 over a multi-megabyte Mach-O before the message can be answered, holding the lock that recordMessage and updateConfiguration also contend for.

max_auth_latency_ms does not bound this: it is evaluated after the decision completes and only downgrades a deny to an allow. If the binary sits on a network mount or stalled removable media, the mapped read blocks indefinitely, the client misses its message deadline, and macOS answers that by killing the ES client.

recordMessage also calls [self isOwnProcessLocked:message->event.exec.target] on every managed NOTIFY_EXEC purely to warm the cache, putting the same work on the notify path.

The lastPathComponent check usefully limits hashing to files named gensee, but the first touch still pays it inside the callback. Hash off the callback thread, or use cdhash and avoid file I/O entirely.


Low — crate/gensee-crate-store/src/lib.rs:2887 — the SQL /usr/local/bin glob is broader than the Rust predicate

dashboard_path_visibility_sql emits GLOB '/usr/local/bin*' with no separating slash, while lineage_path_is_system_dependency matches only the exact string /usr/local/bin. Every other entry in the pair is symmetric (= '/bin' alongside GLOB '/bin/*', and so on).

Verified against SQLite:

/usr/local/bin           SQL: hidden   Rust: hidden
/usr/local/bin/gensee    SQL: hidden   Rust: VISIBLE
/usr/local/binaries/tool SQL: hidden   Rust: VISIBLE
/usr/local/bin-old/x     SQL: hidden   Rust: VISIBLE

Those artifacts and relations stay materialized at ingest and are simply never shown, so a user with a project under /usr/local/binaries loses its lineage with no indication. Because the SQL side is the stricter one, debug_assert!(relations.iter().all(dashboard_relation_is_visible)) still passes and the divergence is silent even in debug builds.

Make the pair symmetric: = '/usr/local/bin' plus GLOB '/usr/local/bin/*' in SQL, and add the matching starts_with("/usr/local/bin/") in Rust.


Verified fixed

  • Lineage filtering — both predicates pushed into SQL via dashboard_path_visibility_sql / dashboard_artifact_visibility_sql, with LIMIT 80 and LIMIT 200 restored and debug_assert! cross-checks against the Rust versions. This resolves both the starved-Lineage case (previously 300 clean relations returning 0) and the unbounded artifact_facts scan.
  • CLI trust — content hashing replaces the path allowlist, which is the right primitive; see the two findings above for the binding.
  • Live Feed removalLiveFeedPage, LiveFeedConnectionBadge, TransactionEvent, the model field, its CodingKeys entry, and the nav case were all removed together, with no dangling references. This also resolves the earlier TransactionsPage dead-code finding.

🤖 Generated with Claude Code

@yiying-zhang

Copy link
Copy Markdown
Contributor Author

Review — 3763088, 9e9dabb

All four findings from the previous comment are addressed. Verified: cargo test across store / macos / cli (391 + 40 + 11 passed).

Three new items — two medium, one low.


Medium — macos/GenseeCrate/Host/ConsoleModel.swift:719 — unprotecting GENSEE_HOME is broader than its justification

Removing the content-hash trust and unprotecting the store instead is a reasonable posture change, and the docs state it clearly. But the rationale — "hook binaries must be able to read and update their own encrypted event store" — covers gensee.db and the JSONL files. It does not cover two other things now sitting in the same unprotected directory.

policy::user_policy_path() (policy.rs:528) resolves the active policy to $GENSEE_HOME/policy.json, and Policy::global() is a per-process OnceLock, so every gensee hook invocation is a fresh process that re-reads that file. With ~/.gensee no longer in protectedPaths, a managed agent in protect mode can write it directly — previously denied — and the next hook invocation enforces the agent's own rules. ~/.gensee/bin/gensee, the backend the harness actually executes, is now agent-writable for the same reason.

Hooks only ever read the policy and the backend binary. Keeping $GENSEE_HOME unprotected for the store files while re-adding policy.json and bin/ as protected prefixes preserves the documented rationale without leaving the enforcement mechanism itself writable by the tree it governs.


Medium — crate/gensee-crate-macos/src/event.rs:490 — the bookkeeping filter suppresses findings in agent-creatable directories

endpoint_security_event_is_bookkeeping matches these as unanchored substrings, at any position in the path:

/target/    /.build/    /deriveddata/    /test-results/    /testresults/    /node_modules/.cache/

Those are directory names an agent can create anywhere inside the workspace it controls. An agent that stages secrets at /repo/target/exfil.env, or writes /repo/src/test-results/id_rsa, produces no finding at all:

  • endpoint_security_event_is_bookkeeping short-circuits logical_operation to None (event.rs:280), suppressing both the Policy::global().evaluate_observation findings and the hook_bypass_file_mutation alert
  • main.rs:3834 separately drops the ingestor findings for the same event

Raw telemetry is still persisted, so this is an alerting blind spot rather than an evidence gap — but alerts are what an operator sees.

The code comment states the intent correctly ("dedicated state/build locations rather than filename extensions alone"). Anchoring these to a known build root beneath the session's cwd, instead of an unanchored contains, is what would deliver it.


Low — crate/gensee-crate-macos/src/event.rs:539 — the root-exit revocation is dead against the real sensor

The new "exit" branch revokes session attribution only when node.depth == Some(0). The extension serializes attribution as exactly session_id, root_pid, confidence, matched_by (main.m:572-577) — there is no depth field.

So in EndpointSecurityIngestor::ingest, any event carrying a session_id takes the first branch and stores depth: event.attribution.depth, which is always None. The ensure_actor path that computes Some(0) is reached only for events without extension attribution. if node.depth == Some(0) therefore never fires in production, and neither the descendant revocation nor self.active_roots.remove(...) runs.

This is the same shape as the earlier root_pid gap, which was fixed by having the extension emit the field. The new test passes because it seeds active_roots through EndpointSecurityIngestor::new and feeds unattributed events, exercising only the ensure_actor path.

Filed as low rather than medium: the active_session_id gate added in the same commit (main.rs:3823) requires a recent tool call before attribution is retained, so the practical outcome is already covered. This is a dead fallback with a test that implies otherwise, not a live exposure. Emitting depth from the extension, or keying the revocation on root_pid == actor.pid which is already populated, would make it real.


Verified fixed

  • Content-hash trust — removed wholesale rather than patched, which resolves the TOCTOU, the dev:ino:size:mtime cache defeat, and the in-callback SHA-256 together: isOwnProcessLocked: is back to signature-only GenseeIsOwnProcess, and the CommonCrypto dependency, the digest cache, and the host-side hashing are all gone.
  • transactionEvents — dropped from dashboard_state, with dashboard_snapshot_omits_transaction_events asserting the key is absent.
  • /usr/local/bin — now symmetric on both sides (= '/usr/local/bin' plus GLOB '/usr/local/bin/*' in SQL, starts_with("/usr/local/bin/") in Rust), and dashboard_usr_local_bin_filter_matches_rust_predicate cross-checks the Rust predicate against the SQL over exactly the four paths from the previous report — the differential check the debug_assert could not provide.

🤖 Generated with Claude Code

@yiying-zhang

Copy link
Copy Markdown
Contributor Author

Review — 336996b, 65f1659, ed813d1, 97ccf81

Continues from the previous comment. Both findings there are addressed in 336996b. Verified: cargo test across store / macos / cli (391 + 40 + 13 passed), xcodebuild test -scheme GenseeCrateHarnessTests (34 passed, up from 24).

Eight new items across the four commits.


336996b — harden Endpoint Security filtering and totals

High — crate/gensee-crate-cli/src/main.rs:3956 — OS evidence is dropped outside a 60-second in-flight tool window

store.append_system_event became conditional:

if active_session_id.is_some() && !bookkeeping {
    store.append_system_event(&event.into_system_event()?)?;
}

The previous code always persisted the event and merely stripped attribution when idle. Now events are discarded outright, and active_session_id depends on active_tool_call, whose SQL (store/lib.rs:409-415) requires candidate.ts >= observed_at - 60000 — the PreToolUse must be within the last minute, not merely uncompleted.

So a Bash tool call running a five-minute build or test suite stops matching after 60 seconds, and every Endpoint Security event it generates from minute two onward is silently dropped: no system_events row, no artifact, no lineage. All activity between tool calls, and anything a spawned background process does after its tool returns, is discarded the same way.

This also removes the mitigation that made the bookkeeping filter tolerable. In the previous round that suppression was an alerting gap because raw telemetry was retained regardless; it is now an evidence gap.

The stated motive is dashboard noise, which is a display concern. Keep persisting the events and filter them in dashboard_state, or gate on session liveness rather than a 60-second tool window.

Medium — crate/gensee-crate-store/src/lib.rs:813artifacts_count now reports the page size, not the total

Moving LIMIT 80 into the visible_artifacts CTE means visible_artifact_count = artifacts.len() counts the page rather than the visible set. Before this commit the query returned all candidates, Rust filtered them, counted the survivors for the summary, and took 80 for the list — the total was correct and only the list was capped.

Any store with more than 80 visible artifacts — essentially any real one — now reports exactly 80. The commit's own test at lib.rs:5222 seeds 500 clean /repo/artifact-N facts plus 500 noise facts and asserts artifacts_count == 80, so the regression is locked in by a passing assertion.

Compute the total with a separate SELECT COUNT(*) FROM artifact_candidates WHERE {artifact_visibility} alongside the limited page — the way the visible_alerts CTE already does for alert counts.

Low — crate/gensee-crate-macos/src/event.rs:383 — the build-process check matches on executable basename alone

The rewrite is a large improvement: suppression now requires a known build process, a workspace root, and a path at a fixed top-level build directory beneath it, and does_not_hide_agent_files_in_build_named_directories covers all three evasions from the previous report plus a nested-directory case.

The residual is that executable_name comes from rsplit('/') on the reported path, with no signature check, no absolute-path requirement, and no verification the binary is a real toolchain component. An agent that copies any executable to <workspace>/npm and uses it to write <workspace>/target/exfil.env gets its writes suppressed — and per the finding above, suppressed events now leave no record at all. Requiring the executable to resolve under a known toolchain prefix (/usr/bin, /opt/homebrew, a rustup or Xcode path) would close it without weakening the legitimate cases.


65f1659 — guided macOS first-run setup

prepareLocalRuntime creating ~/.gensee at 0700 and policy.json at 0600 is good hardening, and the verification baseline/reconcile logic is consistent (both sides are epoch-ms).

Medium — macos/GenseeCrate/Host/ConsoleModel.swift:510 — the Codex review script is written into an unprotected directory

3763088 unprotected $GENSEE_HOME so hook binaries could write the store, which also left ~/.gensee/bin/ writable by a managed agent tree in protect mode. openCodexHookReview now creates ~/.gensee/bin/review-codex-hooks.command, chmods it 0700, and hands it to NSWorkspace.open.

Between setAttributes and NSWorkspace.open an agent can replace the file, and the user's click then executes agent-authored code in their own login session — outside any managed process tree, so no Endpoint Security policy applies. 97ccf81 raises the stakes considerably: the script now kills processes, runs osascript, and truncates files, so winning that race yields much more than it did when this was first flagged.

The script content itself is fine — shellSingleQuote escapes correctly and codexExecutableURL() searches only conventional install locations. The problem is the location. Write single-use launcher scripts to a private temporary directory the agent cannot reach, rather than to the directory that holds the trusted hook backend.


ed813d1 — Fix Codex hook review launcher

The reordering is a real improvement: app-bundle candidates now precede ~/.local/bin and ~/.cargo/bin, so a system-installed Codex wins over user-writable paths, which also narrows the planted-binary surface. Extracting CodexExecutableResolver with ordering and probe tests is the right shape.

Medium — macos/GenseeCrate/Host/ConsoleModel.swift:866 — the Codex probe blocks the MainActor with undrained pipes

openCodexHookReview is a @MainActor method. It calls codexExecutableURL(), which probes up to eight candidates in sequence, each via process.run() plus waitUntilExit(). Any candidate that hangs — a wrapper that reads stdin, a binary on a stalled network mount, an installer shim that prompts — blocks the entire UI with no timeout and no cancellation.

Independently, standardOutput and standardError are set to Pipe() and never read, so a candidate that writes more than the pipe buffer (some wrappers print full help on an unrecognized flag) blocks in write(2) while the app blocks in waitUntilExit() — the same deadlock shape already fixed twice in GenseeCLI.run in this PR.

Run the probe off the main actor, bound it with a timeout, and either drain the pipes concurrently or redirect to FileHandle.nullDevice.


97ccf81 — Close Codex hook review after approval

Extracting the script into CodexHookReviewScript.render with tests is right, and the Swift-side escaping is correct — \\[hooks\\.state\\." produces the intended awk regex, and ${CODEX_HOME:-...} is not swallowed by Swift interpolation. Approval detection degrades gracefully if Codex changes its config format.

Medium — macos/GenseeCrate/Host/HarnessActivationGuidance.swift:128 — the mktemp-then-delete marker allows symlink truncation

approval_marker=$(/usr/bin/mktemp -t gensee-codex-hook-review)
/bin/rm -f "$approval_marker"

This discards exactly the guarantee mktemp exists to provide, so it can use file existence as a boolean. The watcher subshell later runs : > "$approval_marker".

Any process running as the user — including a managed agent, which this product's threat model assumes can write files — can watch $TMPDIR for the create/unlink pair and plant a symlink at that path. The redirection follows it and truncates the target to zero bytes. The window is small, but this is the textbook temp-file race.

Keep the file mktemp created and signal approval by writing a sentinel into it (testing with [[ -s ... ]]), rather than deleting and recreating it.

Low — macos/GenseeCrate/Host/HarnessActivationGuidance.swift:113CODEX_HOME is honored for the config but not for the hooks path

The script derives config_path as ${CODEX_HOME:-$HOME/.codex}/config.toml, but Swift passes hooksURL as a hardcoded ~/.codex/hooks.json. With CODEX_HOME=/Volumes/work/codex the two disagree: hooks_are_trusted greps a hooks file Codex is not using, and its awk matches config state sections against that same wrong path, so the trusted count never reaches the expected count. The watcher never fires and the window never auto-closes, despite the notice promising it will.

Resolve the hooks path from CODEX_HOME in Swift — the way allowed_transcript_path already does for transcripts — or derive it inside the script from the same ${CODEX_HOME:-$HOME/.codex} root.

Low — macos/GenseeCrate/Host/HarnessActivationGuidance.swift:133 — auto-close hardcodes Terminal.app

close_review_window sends tell application "Terminal", but NSWorkspace.shared.open(scriptURL) hands the .command file to whatever handler the user has registered. With iTerm2, Ghostty, or WezTerm, review_tty belongs to that terminal; the AppleScript then launches Terminal.app (per tell semantics), iterates its empty window list, finds no matching tty, and returns — leaving the real review window open and an unwanted Terminal.app running.

Detect the frontmost terminal's bundle identifier, or print an explicit "you can close this window" line as the fallback when the close attempt finds no match.


Verified fixed

  • Bookkeeping filter — now requires a known build process and a workspace root and a top-level build directory beneath it. does_not_hide_agent_files_in_build_named_directories covers /repo/target/exfil.env, /repo/src/test-results/id_rsa, and /other/target/exfil.env from the previous report, plus a nested build-process case.
  • Root-exit revocation — gained || node.root_pid == Some(event.actor.pid), and sensor_attributed_root_exit_revokes_helpers_without_depth constructs extension-shaped events carrying session_id and root_pid but no depth — exactly the production path the earlier test did not exercise.

🤖 Generated with Claude Code

@yiying-zhang

Copy link
Copy Markdown
Contributor Author

Review — c5c6099

All eight findings from the previous comment are addressed. Verified: cargo test (40 + 13 passed), xcodebuild test -scheme GenseeCrateHarnessTests (38 passed, up from 34).

One new item, introduced by the launcher rework.


High — macos/GenseeCrate/Host/Info.plist — the Apple Events send lacks both its usage string and its entitlement

openCodexHookReview now builds tell application "Terminal" … do script … and runs it through NSAppleScript.executeAndReturnError. That replaces NSWorkspace.open, which needed no special permission, with an Apple event — and two independent gates block it in the shipping configuration:

  1. Info.plist. macOS 10.14+ requires NSAppleEventsUsageDescription before the Automation consent prompt can be shown. Host/Info.plist declares only CFBundle*, LSApplicationCategoryType, LSMinimumSystemVersion, NSHumanReadableCopyright, and NSPrincipalClass.
  2. Hardened runtime. project.yml:16 sets ENABLE_HARDENED_RUNTIME: YES, and a hardened-runtime process cannot send Apple events at all without com.apple.security.automation.apple-events. GenseeCrate.entitlements contains only com.apple.developer.system-extension.install.

So executeAndReturnError fails with errAEEventNotPermitted, the guard falls through to errorMessage = "Could not open Codex hook review: …", and the Codex hook-review flow that 65f1659, ed813d1, and 97ccf81 exist to deliver never runs for any user of a signed build.

This is easy to miss in development: the test target sets CODE_SIGNING_ALLOWED: NO, so a locally built unsigned app is not subject to the hardened-runtime gate and the feature appears to work.

Add both the Info.plist string and the entitlement, and treat a denial as a recoverable state that points the user at System Settings → Privacy & Security → Automation rather than surfacing a raw AppleScript error.


Verified fixed

  • Evidence retention — reverted to unconditional append_system_event with attribution stripped when idle. The new comment states the principle directly: "Dashboard noise filtering is intentionally kept separate from evidence retention."
  • artifacts_count — a separate COUNT(*) over the unlimited candidate set. The test now asserts 102 against 100 seeded clean facts plus 2 incidental ones, with the page still capped at 80 — so it demonstrates total and page are decoupled instead of re-encoding the cap.
  • Script location — solved by removing the file entirely. The script is base64-encoded and piped into zsh via do script, so there is nothing on disk for an agent to swap. That closes the TOCTOU rather than narrowing it.
  • mktemp marker — the file mktemp created is retained, approval is signalled with a printf 'approved' sentinel tested via [[ -s ]], and a cleanup trap covers EXIT HUP INT TERM.
  • Probe — moved to Task.detached with a 1s timeout and terminate → SIGKILL escalation, FileHandle.nullDevice on stdin/stdout/stderr so there are no pipes to drain, plus a runningCommand re-entrancy guard.
  • CODEX_HOME — both config_path and hooks_path now derive from a single codex_home variable.
  • Terminal.app — resolved from both directions: the launcher explicitly drives Terminal so review_tty always matches, and the AppleScript returns closed/not-found with a printed fallback when it does not.
  • Build-tool trustendpoint_security_executable_is_trusted_build_tool requires a system prefix or a rustup toolchain path, covered by a /repo/npm planted-binary test.

🤖 Generated with Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants