Skip to main content
Reference · Information

senkani doctor

Live

Diagnose and optionally repair hook registration, binary paths, grammar versions, config.

Syntax

senkani doctor [--fix] [--verify-chain] [--repair-chain --table <T> --from-rowid <N> [--note "…"] [--force]] [--install-validation-browser] [--check-egress] [--check-sandbox] [--install-egress-ca] [--uninstall-egress-ca] [--seed-pushover-key] [--no-rebuild-stale-bundle]

Behavior

Checks: MCP server entry, hook registration, binary path resolution, grammar staleness (non-blocking advisory — PASS for recent, SKIP for stale >30 d, never FAIL), per-RAM-tier ML output quality, the runtime SLO pack, the release-commitment SLOs (Phase V.14), audit-chain integrity (Phase T.5), FileProvider / iCloud-Drive eviction risk on the project tree, the OpenAI-compatible endpoint telemetry (Phase V.13e-2), and (when a walk-bundle is present under tools/soak/runner/) bundle staleness vs merge-target HEAD.

Audit chain (16): walks fifteen chained tables — token_events, validation_results, sandboxed_results, commands, pane_refresh_state, confirmations, trust_audits, policy_snapshots, egress_decisions, pack_audits, eval_results, surrogate_writes, workstream_handoffs, openai_request_log, and thread_handoff_event (introduced across migrations v4 / v5 / v6 / v11 / v12 / v17 / v19 / v20 / v24 / v27 / v40 / v41 / v43, listed migration-chronologically so future additions append rather than re-sort) — recomputing SHA-256(prev_hash || canonical_row_bytes) per row and comparing against the stored entry_hash. Reports chain integrity: OK across … since <ISO-date> / N repairs on green, or chain integrity (table): BROKEN at row N — expected …, got … on tamper. --verify-chain runs only this check (scriptable exit code: 0 = OK / NoChain, non-zero = BROKEN). annotation_rate_cap_log is intentionally NOT a chain participant — the table is a derived flood marker (per AnnotationRateCapStore's design note) and tampering is detectable by re-deriving from hook_events / token_events / commands.

Chain repair: --repair-chain --table <token_events|validation_results|commands|policy_snapshots|pane_refresh_state|confirmations|trust_audits> --from-rowid <N> opens a fresh chain segment with two typed-string confirms (REPAIR, then the table name). Refuses non-tty invocations unless --force is passed. Refuses a second repair against an existing repair anchor without --force. --note "…" records a free-form note alongside the prior chain's tip hash on the new anchor's operator_note. sandboxed_results (TEXT primary keys) needs an --from-created-at flag and is repaired by hand pending operator demand.

ML tier quality (5b): reads ~/.senkani/ml-tier-eval.json and surfaces a per-tier rating for installed Gemma 4 tiers — excellent (≥80% pass), acceptable (≥60%), degraded (<60%, fails check with an "upgrade if RAM allows" hint), or skipped if the report is missing or the tier isn't installed. The harness is Sources/Bench/MLTierEvalTasks.swift (10 rationale + 10 vision tasks).

SLO pack (14): one line per published SLO — cache.hit (<1 ms), pipeline.miss (<20 ms), hook.passthrough (<1 ms), hook.active (<3 ms). Verdict is green (p99 within threshold and ≤ 1% over budget), warn (p99 between 80% and 100% of threshold), burn (p99 over threshold OR > 1% of samples over), or unknown (fewer than 30 samples in the 24-hour rolling window). Samples persist to ~/.senkani/slo-samples.json; recording is opt-in via SENKANI_SLO_SAMPLES=1 so the measurement does not become the cost. The CI perf gate (tools/perf-gate.sh / SLOPerfGateTests) synthesises a representative workload for each SLO and fails the build if any p99 crosses its ceiling. See spec/slos.md.

Egress proxy (18, Phase T.1a + T.1a.2): reports the EgressProxy daemon state. Reads ~/.senkani/egress.port — when present and parseable as a port number, the line reads Egress proxy: running on :PORT (decisions: N) (PASS); otherwise Egress proxy: down (decisions: N) (SKIP). N is the row count of the chained egress_decisions audit table (migration v19), so an operator can always see whether ANY decisions have been emitted regardless of listener state. T.1a shipped the deterministic rule + decision + chain core; T.1a.2 wires the live POSIX TCP listener (EgressListener + EgressConnectionHandler + EgressUpstreamConnector) that senkani egress start spawns — the listener binds 127.0.0.1:<port>, peeks the TLS ClientHello on CONNECT to validate SNI vs the CONNECT-line host, and writes the port file via atomic rename; senkani egress stop reads ~/.senkani/egress.pid and sends SIGTERM, which unlinks both the port and pid files on clean shutdown. SNI mismatch writes a deny row with sentinel rule_id="sni_mismatch" without piping a single byte upstream.

FileProvider eviction (19): walks the project root, .build/checkouts/, and the operator-tracked source roots (Sources/, Tests/, docs/) flagging three iCloud-Drive eviction signals: the project root sitting under a FileProvider-managed path (probed via ~/Library/Mobile Documents/ ancestry, the com.apple.metadata:com_apple_clouddocs xattr, and URL.resourceValues(forKeys: [.isUbiquitousItemKey])); files carrying the SF_DATALESS APFS st_flag (sentinel for "content is evicted to a cloud provider; metadata is on disk"); and * 2 Finder-shadow siblings (sync-conflict resolution, e.g. mlx-swift-lmmlx-swift-lm 2). On green, prints FileProvider: no iCloud-Drive eviction symptoms (root, .build/checkouts/, source tree clean). On any positive signal, prints up to three discrete fail lines naming the offending paths and pointing at CONTRIBUTING.md's ## macOS / iCloud Drive section for the disable-Desktop-&-Documents-sync remediation.

Runtime telemetry receiver (21, Phase V.18a-3): reads ~/.senkani/runtime-telemetry-receiver.json (port + cumulative drops + per-source rate cap, snapshotted by RuntimeTelemetryReceiver.start() / stop()) and prints Runtime telemetry receiver — :PORT | drops: N | rate cap: 1000 spans/s/source | loopback boundary: performative (local-user trust) on green; not yet bound appears in place of the port when the receiver has never started. The line states the trust boundary directly — the loopback bind is performative, not protective; any local process on the same user account can connect to or compete with the receiver. See spec/architecture.md "Runtime telemetry receiver — trust boundary (Phase V.18a-3)" for the full discussion and the defense-in-depth measures (loopback-literal bind, accept-time peer check, 413 body cap, 415 content-type allowlist, per-source rate cap with drop counter).

OpenAI endpoint (22, Phase V.13e-2): one informational line for the OpenAI-compatible inference endpoint (senkani serve --openai): OpenAI endpoint — bind: 127.0.0.1 | port: 8470 | keys: N | requests (24h): M | 429-rate: X.X%. Bind + port come from the persisted OpenAIEndpointConfig (~/.senkani/openai-endpoint.json, loopback defaults when never configured); keys is the count of provisioned key records read straight off ~/.senkani/openai-keys.json (count only — the raw key, its hash, and its label never reach the doctor surface); and requests (24h) + 429-rate read the persisted openai_request_log query API (Phase V.13e-1), so the two telemetry fields are correct cross-process and survive a serve restart (they reflect durable rows, not in-memory state). Always PASS (non-blocking, like the runtime-telemetry-receiver line) — a zero-request endpoint is a normal state, not a failure, and never trips the doctor exit code. The producer-side listener wiring landed in Phase V.13e (OpenAIServedRequestSink records every served chat / embeddings / streamed request at the serve endpoint's completion points, co-located with the in-memory audit-chain append), so requests (24h) now reflects real served traffic — not a permanent 0. 429-rate is likewise live (Phase V.13e): the rate-limited (429) and auth-refused (401 / 403) responses — which short-circuit at the auth gate ahead of the per-surface producer sites — are recorded to the persisted openai_request_log by OpenAIServedRequestSink.recordRefusal at the single gate-decision point (one metadata-only row per refused request, with the path-derived surface and the matched key's label for a 403/429 — never for a 401, and never the raw key). The in-memory chain is deliberately left untouched for refusals (they never appended to it), so the v13e-5 burst-integrity semantics are unaffected. 429-rate therefore now reflects real rejected traffic rather than a structural 0.0%.

Bundle staleness (20): compares the wrapped onboarding/uninstall-pass .app bundle (tools/soak/runner/_onboarding-pass-SenkaniApp.app preferred, tools/soak/runner/SenkaniApp.app fallback) binary mtime against the merge-target HEAD commit time (git log -1 main --format=%ct; merge target resolved from spec/autonomous-manifest.yaml's close.merge_target with upstream-tracking and main fallbacks). Silent skip when no walk bundle is present (the niche walk-runner case). On .fresh, prints Bundle staleness: <bundle> is up-to-date with main HEAD (PASS). On .stale, prints three discrete fail lines (header, bundle/HEAD timestamps + commit subject, recommended action) AND — unless --no-rebuild-stale-bundle is passed — invokes BundleRebuilder.rebuild(...) in-process: swift build --product SenkaniApp, copies .build/arm64-apple-macosx/debug/SenkaniApp into the bundle's Contents/MacOS/SenkaniApp, ad-hoc re-codesigns (codesign --force --sign -), and re-registers with LaunchServices (lsregister -f). The same logic is exposed as senkani walk rebuild-bundle <bundle> so the walk-runner .command scripts call one canonical surface.

Install validation browser (U.2a-1): --install-validation-browser is a focused motion that prints the operator-runnable npx playwright install chromium command. The doctor will NOT auto-download third-party binaries — the operator stays in the loop. Idempotent: when the Chromium cache (~/Library/Caches/ms-playwright) is detected for the first time, a single validation.browser.install chained row lands in token_events (T.5 prev_hash / entry_hash / chain_anchor_id populated). Subsequent invocations short-circuit on the existing audit row and write nothing. Cache-absent invocations print the install command, exit 0, and write nothing. The Chromium install primes U.2a-2's senkani validate --browser dispatch path; the runtime contract (axes vocabulary, planner over DiffSelector, subprocess runner, schema migration v22) ships in U.2a-1.

Check egress (T.1c): --check-egress walks the EgressProxy 5-scenario adversarial smoke subset — one representative from each of the 6 categories the full 20-scenario corpus covers (DNS rebinding, SSRF, allowlist boundary, deny-wins, judge-prompt-injection). Pure rule-engine + normalizer assertions: no live listener, no SQLite writes, no judge inference. Each scenario constructs a representative rule set, evaluates a single attacker-controlled host through EgressRuleEngine, and asserts the expected decision + rule_id. Per-scenario [ok] / [fail] lines print the host literal, the engine's verdict, and the rule_id. Exits non-zero on any miss (suitable for CI scripts). Wall-clock ~11 ms locally; acceptance budget < 2 s. The full 20-scenario corpus runs as part of every swift test in Tests/SenkaniTests/EgressProxyAdversarialTests.swift; --check-egress is the fast-feedback operator surface.

Check sandbox (T.3b): --check-sandbox reports the senkani_exec execution-sandbox posture, DERIVED from the live ExecRoutingDecision routing source of truth (not a hardcoded string). It prints the headline exec sandbox: deny-by-default / fail-CLOSED plus the concrete facts: user-supplied scripts are REFUSED (reason=user_supplied_deny_by_default, no host /bin/sh fallback); tool-internal callers use the host path (Foundation Process /bin/sh, reserved for explicitly-trusted in-process callers); the positive wasm sandbox path is deferred/unavailable (a trusted wasm opt-in fails CLOSED on reason=sandbox_runtime_unavailable, never falls back to host). The surface re-probes ExecRoutingDecision.route(...) for each caller kind, so it can never overclaim the posture — the headline PASS is emitted ONLY when the router genuinely denies user-supplied callers. Read-only: no SQLite writes, no process spawn, no mutation. Exits 0 when the fail-CLOSED invariant holds, non-zero only if a user-supplied caller ever routed to the host shell (a breach). No third-party wasm shell is vendored, so user-supplied scripts have no sandboxed surface to run on — the safe action is to refuse (ratified operator decision 2026-06-05; the positive wasm-shell path is deferred indefinitely).

Egress CA trust (T.1d-6): --install-egress-ca and --uninstall-egress-ca are dry-run scaffolding for the HTTPS-body MITM root CA (Phase T.1d). --install-egress-ca generates the local egress root CA PEM and PRINTS the operator-runnable security add-trusted-cert command behind a typed-string confirm; --uninstall-egress-ca PRINTS the matching security remove-trusted-cert command. The doctor NEVER runs security, never invokes sudo, and never touches the System Keychain — actually trusting (or removing) the CA is the operator's manual step (item T.1d-7). The loop and tests only assert the printed command + confirm path.

Seed Pushover credential (T.6c): --seed-pushover-key is the prompt-twice seed motion for the PushoverSink credential. It prompts for the credential TWICE (entry + confirm — terminal echo disabled on a tty, prompts to stderr, so the secret never appears on screen, in argv, or in shell history), hard-aborts on a missing entry, missing confirm, or mismatch BEFORE any write, then writes the secret through the credential vault's Keychain seam under the canonical key senkani.pushover (scope default — the slot the Keychain-reading Pushover transport resolves at send time) and records ONE non-secret audit row to token_events (feature pushover.seed, payload pushover.seed key=senkani.pushover scope=default — the key NAME and scope only, never the value, enforced at the formatter's parameter shape). Entries are whitespace-trimmed before the compare so a pasted trailing newline cannot force a false mismatch. Every aborted outcome exits non-zero (scriptable). Note: until the operator-gated real-Keychain store swap lands, CredentialVault.shared is an in-memory store — the mechanism is live, but seeding the REAL token (and proving a live device push) is the operator's leg of the parent T.6c item.

Release commitments (15, Phase V.14): reads ~/.senkani/slo-history.jsonl and renders the latest captured row plus a per-SLO median-of-5 baseline regression check. Five numbers — cold.start (<250 ms p95, p95 of senkani --version across N=20), idle.memory (<75 MB, RSS of senkani-mcp after a 10 s settle), install.size (per-binary stripped budgets — senkani <50 MB, senkani-mcp <70 MB, senkani-hook <5 MB, senkani-mig-helper <15 MB; each shipped binary is stripped into a temp copy and measured, losslessly because dSYMs exist, then checked against its own budget rather than one summed total), classifier.p95 (<2 ms p95, slot pending U.1 TierScorer), openai.cold.start (<1500 ms p95, senkani serve --openai spawn → listener-ready across N=10, Phase V.13e-3). Verdict is ok, regression (≥10% over baseline), over-budget (over published threshold), or n/a (no history yet, slot null, or daemon not running). Improvements never fail the gate. Capture is tools/measure-slos.sh; rows carry git_sha + version for trend correlation. See spec/slos.md "Release commitments (Phase V.14)".

Example

$ senkani doctor --fix

Flags

Flag
Description
--fix
Attempt repair for each failing check.
--verify-chain
Run only the audit-chain integrity check (Phase T.5). Exit 0 on OK / NoChain, non-zero on tamper. Scriptable.
--repair-chain
Open a fresh audit-chain segment after a verified tamper. Requires --table and --from-rowid. Double-confirms (typed strings) unless --force.
--table
String
For --repair-chain: which chain to repair. One of token_events, validation_results, commands, policy_snapshots, pane_refresh_state, confirmations, trust_audits.
--from-rowid
Int
For --repair-chain: first rowid to re-anchor under the new repair anchor.
--note
String
For --repair-chain: free-form note recorded on the new repair anchor's operator_note alongside the prior tip hash.
--force
For --repair-chain: skip typed-string confirms (required for non-tty invocations and to override the "repair anchor already exists" guard).
--install-validation-browser
Print the operator-runnable npx playwright install chromium command (U.2a-1). Does NOT auto-download. Idempotent: writes a single validation.browser.install chained audit row to token_events on first cache detection.
--check-egress
Walk the EgressProxy 5-scenario adversarial smoke subset (T.1c). Reports per-scenario pass/fail with rule_id; exits non-zero on any miss. Engine-level — does not spin the live listener.
--check-sandbox
Report the senkani_exec execution-sandbox posture derived from the live ExecRoutingDecision (T.3b): deny-by-default / fail-CLOSED — user-supplied scripts REFUSED (no host /bin/sh fallback), tool-internal callers use the host path, the positive wasm path deferred/unavailable. Read-only status; exit 0 when the fail-CLOSED invariant holds, non-zero on a breach.
--install-egress-ca
Dry-run scaffolding (T.1d-6): generate the local egress MITM root CA PEM and PRINT the operator-runnable security add-trusted-cert command behind a typed-string confirm. NEVER runs security/sudo or touches the System Keychain — the real trust install is the operator's manual step (T.1d-7).
--uninstall-egress-ca
Dry-run scaffolding (T.1d-6): PRINT the operator-runnable security remove-trusted-cert command for the egress MITM root CA behind a typed-string confirm. NEVER runs security/sudo or touches the System Keychain — the real removal is the operator's manual step (T.1d-7).
--seed-pushover-key
Seed the Pushover credential (T.6c): prompts twice (entry + confirm, input hidden on a tty), writes through the credential vault's Keychain seam under key senkani.pushover, and records ONE non-secret audit row (key NAME only — never the value). Mismatch or missing confirm aborts with NO write and a non-zero exit. Seeding the REAL token is the operator's leg; this flag is the mechanism.
--no-rebuild-stale-bundle
When check #20 detects a stale walk bundle, only warn — skip the auto-rebuild. Default behavior is rebuild.
Source: Sources/CLI/DoctorCommand.swift