Hook relay
The senkani-hook binary is a zero-dependency Mach-O that Claude Code (and other MCP-capable agents) runs before every Read/Bash/Grep/Write/Edit tool call. It decides: allow, allow-with-rewrite, or deny.
What it intercepts
Registered during senkani init with matcher Read|Bash|Grep|Write|Edit. Claude Code fires PreToolUse hook → the hook binary runs (< 5 ms active, < 1 ms passthrough) → the hook returns a decision.
The three outcomes
- Allow + pass through. The tool runs as requested. Common for
Write,Edit, and mutatingBash. - Allow + rewrite. The tool still runs, but output is post-processed (secret redaction, ANSI strip, filter rules). Common for
ReadandBash. - Deny with a reason. The tool does not run. The reason string goes to the model — often with a cached answer inline. Common for re-reads of unchanged files,
ls/pwd, redundant build re-runs.
Why a separate binary
Claude Code's hook protocol spawns a fresh process per tool call. Swift apps with full MLX linkage take tens of milliseconds to start; that's too slow to sit in front of every tool call. So the hook is a minimal binary (Sources/Hook), zero MLX dependency, IPC-connects to the long-running MCP server via a Unix-domain socket.
The shared HookRelay library
Sources/HookRelay/ is imported by both senkani-hook (the standalone binary) and the main app's --hook mode (useful during testing). Same decision logic, same socket wire format.
SENKANI_PANE_ID gating
MCP tools are only active in Senkani-managed terminals — the app injects SENKANI_PANE_ID=<id> when it spawns a pane's shell. Non-Senkani terminals never see senkani tools even if the MCP server is running globally. This prevents pollution and keeps the trust boundary well-defined.
Socket-auth handshake
With SENKANI_SOCKET_AUTH=on, every hook connection sends a length-prefixed handshake frame matching the token at ~/.senkani/.token (mode 0600, rotated on every server start). Ambient same-UID attackers can no longer talk to the sockets; they'd need to read the token file too.
Confirmation gate (T.6a)
PreToolUse on a write/exec-tagged tool now consults ConfirmationGate.evaluate(toolName:) before dispatching. The gate looks the tool up in MCPToolCatalog (Edit/Write are .write; Bash and senkani_exec are .exec; everything read-only short-circuits with no row), runs an injectable PolicyResolver, and writes a chained row in the confirmations table. Round 1's default resolver returns auto — production Edit/Write/Bash flow stays unblocked, but every approval lands in the T.5 audit chain. A deny outcome short-circuits the call with a structured permissionDecisionReason ("Confirmation denied for '<tool>': <reason>") so the agent caller knows what was denied. Real notification adapters (StdoutSink, MacOSLocalSink, PushoverSink) plug into the NotificationSink protocol in T.6b/T.6c — round 1 ships the protocol scaffolding plus null/mock implementations and a fan-out helper that swallows throws so a bad adapter cannot block other sinks.
Notification sinks (T.6b)
Two real adapters now plug into NotificationSink. StdoutSink writes one canonical JSON line per NotifyEvent — sorted-key, scalar-only payload plus an ISO-8601 ts, e.g. {"kind":"notify_failure","tool":"Bash","reason":"…","ts":"…"} — through an injectable writer (defaults to FileHandle.standardOutput); an NSLock serialises writes so concurrent fan-outs never interleave partial lines. MacOSLocalSink hands a banner over to a LocalNotifierBridge: production wires UNUserNotificationCenter in the App, while CLI / MCP / CI default to NullLocalNotifierBridge and tests use SpyLocalNotifierBridge — Core stays AppKit-free. Banner copy is "Senkani — done / failed / schedule" with the tool or schedule id as subtitle and the human summary/reason as body. NotificationRouter reads ~/.senkani/notifications.json ({"sinks": {"stdout": {"events": ["notify_failure"]}}}) to pick which sink fires for which event variant; sinks listed in make but absent from the file default to subscribe-all (under-notification hides failures, so the safe default is on). The Pushover adapter and a Settings → Notifications matrix UI are still on T.6c.
Soft-flag fragmentation detector (U.4a)
HookRouter.handle(...) now records every event into a process-wide FragmentationDetector before the routing switch. The detector keeps a per-session_id sliding window and emits soft flags for three patterns: tool_burst (≥3 same-tool calls inside 10 s), fragment_stitch (overlapping prompt fragments inside 30 s, ≥12-char overlap), and cross_pane (same tool in two panes inside one session). Flags persist into Migration v12's chained trust_audits table, where operator False alarm / Real labels are NEW append-only rows referencing the flag's rowid (re-labelling is detectable, never destructive). The detector is non-blocking by construction — neither the detector nor its trustFlagSink can return a deny response, and the flag classification (which feeds the same-event PreToolUse deny decision) runs synchronously while the flag persistence is enqueued off the synchronous response path (a detached Task), so a slow trust_audits write never adds latency to HookRouter.handle(). senkani doctor surfaces the rolling 30-day counter (trust flags — soft flags last 30d: N | confirmed FP: M | confirmed TP: K); the Trust Flags sidebar tool is the operator-facing label UI. Promotion-to-blocking is U.4b, gated on a 30-day operator-labelled FP rate.
Denial → DiffViewerPane annotation pipe (V.12b)
HookRouter denials that block real work now emit a HookAnnotation on HookAnnotationFeed.shared. Two call sites qualify: the budget gate (checkHookBudgetGate returning .block(reason)) and a ConfirmationGate .deny outcome on a write/exec-tagged tool. Read / Bash / Grep advisory denials — the token-saving redirects to mcp__senkani__read, mcp__senkani__exec, mcp__senkani__search — do not emit; those are routing nudges, not policy violations, and would flood the diff sidebar with badges that aren't really blockers.
The DiffViewerPane subscribes to the feed on appear; admitted records whose filePath matches the active diff's leftPath or rightPath are converted to DiffAnnotation pinned to the first hunk and added to the V.12a sidebar in real time. Severity is always must-fix from HookRouter — the gate-level denials are by definition blocking.
Severity rate cap. The feed counts admitted must-fix annotations per 60-second window (default threshold 5); past the threshold, further must-fix records return .suppressed and never reach subscribers. Suppression is non-blocking: HookRouter's deny response is byte-identical whether the annotation was admitted or suppressed, so a noisy denial loop cannot accidentally turn a deny into an allow. Each closed window with at least one suppression writes one row to annotation_rate_cap_log (Migration v13) — window_start, window_end, severity, suppressed_count, threshold. Not chain-hashed; the source denials are already chained via T.5.
Response deadline & fail-closed posture
The relay reads the server's decision under a deadline. Never-deny hooks keep a 5 ms imperceptible budget, but deny-capable hooks (PreToolUse) get a larger denyCapableTimeoutMs — default 250 ms, override via SENKANI_HOOK_DENY_DEADLINE_MS (20× under the 5 s vault ceiling) — because a slow HookRouter.handle() that misses 5 ms used to make the relay passthrough (emit {} = approve), silently discarding any deny/block the server computed past the deadline. That fail-OPEN was a trust/confirmation/budget/pack gate bypass on a loaded machine. On a deny-capable read-timeout the relay now fails CLOSED as permissionDecision:"ask" (default on via SENKANI_HOOK_FAILCLOSED; =off restores the historical fail-open passthrough for rebuild-free rollback) — it escalates to the human gate rather than fabricating a block or silently approving. A connect_timeout (daemon-down) and a nil/unparseable hook name stay fail-OPEN by design. Every deadline-driven passthrough appends <iso8601>\t<reason>\t<hook_event_name> to ~/.senkani/hook-relay-drops.log (atomic O_APPEND, zero-dep) so a bypass is observable. The activation/posture env flags (SENKANI_HOOK_FAILCLOSED, SENKANI_HOOK, SENKANI_INTERCEPT) are parsed case-insensitively and whitespace-trimmed — OFF, Off, and " off " all read as off (an explicit off is the only fail-open value; every other value, including a typo, stays on the safe fail-closed side). A deny-capable read-timeout that passes through because fail-open was explicitly in effect records the distinct reason read_timeout_failopen_forced (vs the never-deny read_timeout and the fail-closed read_timeout_failclosed_ask), so an auto-downgraded run leaves a queryable trace — senkani doctor counts it toward the gate-bypass indicator.
Headless caveat. A fail-closed ask needs an operator to answer it; with nobody present it is an effective BLOCK that can wedge a no-operator loop. senkani autorun handles its own case — an unattended run (no stdin TTY, the same condition that gates the supervise-first refusal) auto-exports SENKANI_HOOK_FAILCLOSED=off into the environment its children (and, in a later leg, the claude agent and its relay subprocess) inherit, unless you set the variable explicitly. For a non-interactive claude -p run outside autorun, set SENKANI_HOOK_FAILCLOSED=off yourself (the relay's permissionDecisionReason on an ask already names the variable) so the fail-closed ask cannot wedge the run.