Security posture
Senkani is a trust boundary. v0.3.0 defaults are all secure: prompt-injection guard on, SSRF hardening on, secret redaction on, schema migrations versioned + crash-safe. Opt-outs are explicit env vars, not hidden flags.
What "trust boundary" means
The agent on one side; your filesystem, network, and secrets on the other. Everything that crosses the boundary is scanned, redacted, validated, or rejected. The boundary is not a feature; it's the product.
Prompt-injection guard
SENKANI_INJECTION_GUARD=on by default. Scans every MCP tool response for instruction-override patterns ("ignore previous instructions", "you are now…"), tool-call injection (fake function-call syntax inside returned text), context-manipulation (zero-width chars, homoglyph substitutions), and exfiltration (base64-looking blobs that decode to URLs or credentials). Anti-evasion normalization: lowercase, zero-width-char strip, Cyrillic→Latin homoglyph fold, NFKC normalization. Single linear pass.
SSRF hardening
senkani_web resolves the target host via getaddrinfo before fetch and blocks any address in private/link-local/CGNAT/multicast ranges (including IPv4-mapped IPv6, octal/hex IPv4, IPv4-compatible IPv6). Redirects are re-validated via WKNavigationDelegate.decidePolicyFor — a 3xx Location header to 10.x, 169.254.169.254, or ::ffff:… is cancelled. A WKContentRuleList blocks subresource requests (img/script/xhr) to the same ranges, so a hostile HTML page embedding <img src="http://169.254.169.254/…"> cannot reach cloud metadata through WebKit's auto-rendering. file:// scheme is rejected entirely.
Secret redaction
14 regex families + entropy-based fallback. Short-circuits with firstMatch so no-match inputs don't pay the full regex cost (1 MB benign input: ~25 ms). Every tool output runs through before reaching the model. The counter command_redactions on event_counters increments on each redaction.
Measured against a 55-fixture adversarial corpus (recalibrated 2026-05-01 after closing the Twilio AC, GCS-V4 signed-URL, and 66-char pure-hex gaps): 1.000 precision, 0.957 recall, zero false positives. The 2 remaining misses are documented sub-threshold gaps in short_token (Slack xoxb- body <10 chars, Stripe sk_live_/sk_test_ <24 chars). The 66-char pure-hex case was closed by adding a length-band short-circuit: pure-hex tokens of length ≥ 40 that are not a known digest size (32, 40, 64, 128 hex) flag HIGH_ENTROPY directly — pure hex peaks at log₂(16) = 4.0 bits/char and would otherwise sit just below the 4.5 entropy floor. The harness asserts per-family precision and recall floors so a regression fails the test deterministically; a future change that closes a documented gap fails with "documented gap appears closed" so the public number stays calibrated. The friendly fixture suite still measures 100% — both numbers are reported as a pair.
Schema migrations — versioned + crash-safe
Session DB uses PRAGMA user_version + a schema_migrations audit log. Cross-process coordination via flock sidecar. On failed migration, a kill-switch lockfile is written and subsequent boots refuse to run migrations until the operator inspects the DB. This trades convenience for safety deliberately.
Retention
RetentionScheduler prunes token_events (90 d default), sandboxed_results (24 h default), validation_results (24 h default) on an hourly tick. Tune via ~/.senkani/config.json → "retention": {…}.
Socket authentication — opt-in
SENKANI_SOCKET_AUTH=on generates a 32-byte random token at ~/.senkani/.token (mode 0600), rotated on every server start. Every connection to mcp.sock/hook.sock/pane.sock must send a length-prefixed handshake frame matching the token. Raises the bar from ambient same-UID socket access to must-read-token-file. Default off in v0.3.0 for backward compatibility; opt in via the env var.
Observability
Every security-defense site increments an event_counters row: injection detections, SSRF blocks, socket handshake rejections, schema migrations, retention prunes, command redactions, and policy-snapshot hashing failures (security.policy.hash_failed, security.policy.learned_rules_hash_failed — bumped when policy_snapshots refuses a write because the encoded config or the active learned-rules file could not be hashed cleanly; pre-fix this collapsed silently to policy_hash = "" and dropped one of two distinct broken configs through ON CONFLICT DO NOTHING). Surfaced via senkani stats --security with Gelman-style count/total (pct%) rate annotation, or the senkani_session action:"stats" MCP action.
Vendored grammar integrity
25 tree-sitter grammars vendor as third-party C code that runs in-process during every senkani index and every MCP outline/deps/repo call. Each grammar entry in Sources/Indexer/GrammarManifest.swift declares a SHA-256 contentHash covering parser.c (concatenated with scanner.c when one is present). tools/verify-grammar-hashes.sh recomputes and diffs each hash; it runs as a pre-flight gate inside tools/test-safe.sh, so a swapped parser.c fails CI before any test can mask the change. tools/generate-sbom.sh emits a CycloneDX 1.5 JSON SBOM (25 grammars + 4 ML models + 22 Swift packages = 51 components, byte-deterministic, honors SOURCE_DATE_EPOCH) for downstream consumers auditing what shipped.
Data portability
senkani export streams sessions + commands + token_events as JSONL via a read-only SQLite connection — doesn't block the live MCP server. --redact collapses user paths. GDPR-adjacent by design, not by regulation.
Tamper-evident audit chain (Phase T.5)
Every row in 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) — carries prev_hash + entry_hash + chain_anchor_id. entry_hash = SHA-256(prev_hash || canonical_row_bytes) — a single-byte tamper at row N invalidates row N's hash AND every subsequent row's hash, so the chain fails closed at the first mutation point. senkani doctor --verify-chain walks each chain and reports OK or names the first broken (table, rowid). The policy_snapshots coverage (added 2026-05-04 via migration v17) matters because that table IS the audit baseline counterfactual replay reports cite — without it, a write-capable attacker could rewrite the configuration baseline and replay would silently lie. The integrity line surfaces in senkani doctor as chain integrity: OK since <ISO-date> / N repairs. Recovery via senkani doctor --repair-chain --table <T> --from-rowid <N> opens a fresh chain segment with typed-string double-confirm, tty enforcement, and prior-tip linkage in the new anchor's operator_note; the repair surface (widened 2026-05-04, chainrepairer-supportedtables-widen) covers every integer-keyed chain participant. Existing rows at upgrade time are anchor-from-now — the chain is a forward integrity guarantee starting at migration time, not retroactive history.
EgressProxy live listener (Phase T.1a + T.1a.2 + T.1b + T.1c + T.1b-followup)
EgressRuleEngine matches hosts against an exact / prefix / suffix / single-* glob rule set with deny-wins precedence and default-deny-on-miss; EgressHostNormalizer canonicalizes case, default ports (:80 / :443), trailing dots, and trailing slashes so a rule for example.com matches Example.COM:80. exactly the same way. EgressDecisionStore chains every decision into the egress_decisions audit table (migration v19) so senkani doctor --verify-chain proves no allow/deny was redacted; tampering with a single decision row breaks the chain at exactly that row. HTTPRequestLine parses the absolute-URL HTTP_PROXY form and the CONNECT form (origin-form is rejected on purpose so the proxy can't accidentally accept misrouted requests); TLSClientHelloSNI extracts the server_name extension from a ClientHello so the tunnel can validate SNI vs CONNECT host.
EgressListener binds a POSIX TCP socket to 127.0.0.1:<port> (port 0 = kernel-assigned, persisted to ~/.senkani/egress.port via atomic rename) and accepts via a DispatchSourceRead loop. EgressConnectionHandler reads the request line, evaluates the host through EgressRuleEngine, and either pipes to the upstream (plain HTTP rewritten to origin form, CONNECT tunnels with peeked TLS ClientHello SNI validated against the CONNECT host) or replies 403 Forbidden. SNI mismatch writes a deny row with sentinel rule_id="sni_mismatch" and tears down without piping a single byte upstream; an unparseable ClientHello writes rule_id="sni_unparseable"; a resolver / connect failure writes rule_id="upstream_unreachable". senkani egress start|stop daemonize / signal via ~/.senkani/egress.pid (SIGTERM-driven shutdown unlinks both the port and pid files); senkani egress status [--recent N] reports listener state + decision count; senkani doctor includes "Egress proxy: running on :PORT (decisions: N)" / "down".
T.1b layers in a PaneMode taxonomy (research, write, redteam, general — typed enum, parsed from an internal X-Senkani-Pane-Mode proxy header stripped before forward), per-mode allowlists loaded from ~/.senkani/egress-policy.json (with fallback to the legacy flat egress-rules.json for T.1a operators), and a Gemma judge fallback on static-rule miss (JudgeAdapter protocol with GemmaJudgeAdapter enforcing a 300 ms hard timeout via DispatchSemaphore that defaults to .deny on expiry — Schneier P0). The judge's rationale lands in egress_decisions.judge_rationale (migration v23) with a per-anchor chain shape switch: the v19 fresh-install anchor is renamed to fresh-install-pre-v23 and a migration-v23 anchor opens at MAX(id) so legacy rows keep verifying under the v19 canonical shape while post-v23 rows include judge_rationale + pane_mode in the canonical hash. .redteam panes deny on every static-miss without invoking the judge — verified by inference-call counter, not by timing (Vitalik posture: no model layer can be asked to allow exfiltration from a redteam pane). The judge prompt is a stable code constant in GemmaJudgeAdapter.buildPrompt — bumping it requires a code change + new test fixtures, never a hot-swap on disk (Karpathy P0).
T.1c hardens the defense with a 20-scenario adversarial corpus that runs as part of every swift test: DNS rebinding (×4 — link-local cloud-metadata, RFC1918 class 10, class 192, loopback), redirect chain to private IP (×4 — RFC1918 class 172, literal localhost, all-zeros, bracketed IPv6 loopback), SSRF (×4 — request-line vs Host:-header smuggling, origin-form-rejected-at-parse, bracketed-IPv6 doesn't match unbracketed allow rule, decimal-IP encoding treated as opaque hostname), CONNECT-vs-SNI mismatch (×2 — SNI declares blocked host tears down with sni_mismatch; blocked CONNECT host short-circuits before SNI peek), allowlist boundary (×4 — label-boundary suffix anchor rejects notexample.com, non-default port preserved, trailing-dot + mixed-case canonicalizes through the normalizer, deny-wins over permissive suffix allow), and judge-prompt-injection (×2 — static-engine default-deny short-circuits before judge dispatch when allowlist is tight; judge dispatch records judge-deny rule_id + rationale when wired). Each scenario asserts deny + correct rule_id + chained audit row + no upstream connection opened. senkani doctor --check-egress ships a 5-scenario smoke subset operators (and CI) can run on demand — pure rule-engine + normalizer assertions, ~11 ms wall-clock, exits non-zero on any miss. The corpus surfaced one real normalization gap during the round (EXAMPLE.com.:80 was retaining a residual trailing dot post-port-strip) which was patched in-round with a second post-port-strip dot pass — the corpus is doing its job. The MITM CA install path lands in T.1d.
T.1b-followup ships the write side of the chain. PaneModeStore backs HookRouter.paneModeResolver with an operator-editable JSON file at ~/.senkani/pane-modes.json — NSLock-guarded snapshot, atomic-rename persist, silent .general default on missing-or-malformed file so an attacker who can write the file cannot elevate a pane out of .general through it. PaneLaunchEnv threads the resolved mode into every pane subprocess env as SENKANI_PANE_MODE=<mode>; both PaneContainerView and OllamaLauncherPane resolve via HookRouter.paneModeResolver(pane.id.uuidString), so the same call path covers Terminal panes and Ollama-launcher panes. Senkani-aware HTTP clients read the env var and add the matching X-Senkani-Pane-Mode header on outbound requests; non-senkani-aware clients (raw curl without an explicit -H) still hit the daemon's deny-on-miss default of .general — the env var carries operator-attested information, the rule engine carries the decision (defense-in-depth, not a security boundary). The redteam end-to-end chain is proven by an integration test that spawns /bin/bash -c with SENKANI_PANE_MODE=redteam, splices $SENKANI_PANE_MODE into the request header via printf, sends over nc 127.0.0.1 <listener.port>, and asserts: (1) HTTP 403 Forbidden response; (2) audit row records pane_mode == .redteam + decision .deny; (3) MockJudgeAdapter.callCount stays at 0. The Vitalik invariant holds end-to-end across subprocess env → header → daemon parse → policy engine → audit row.
Egress body/header/path DENY matchers are best-effort (Phase T.1d-3)
The egress allowlist (host match) plus deny-on-miss default is the real enforcement boundary. The body-substring (bodyContains), header, and path DENY matchers added in T.1d are best-effort defense-in-depth, NOT enforcement. Treat them as a tripwire that catches careless exfiltration shapes, not as a guarantee that any banned payload will be denied. Specifically:
bodyContainsis evadable by case change, embedded whitespace / CRLF, percent/base64 encoding, or splitting the banned token across the body excerpt boundary — and the matcher silently fails open when evasion succeeds (the request proceeds under the host allow rule with no operator-visible warning).- The ≤4 KB body excerpt cap (Phase T.1d-4 capture policy) means a deny-substring that lives past the first 4 KB of the request body never matches — again, a silent fail-open. Sizing a deny rule on a token that may appear deep in a large request gives you no protection.
- AND-across-dimensions is over-specifying. A rule that requires
bodyContainsAND a specificheaderAND a specificpathall match simultaneously catches fewer requests than an operator may intuit — every added dimension tightens the AND. Recommendation: prefer single-dimension deny rules. If you need to deny on three different shapes, write three deny rules, not one rule with three conditions.
If a payload class genuinely must not leave the host, encode that as a host-level allowlist tightening (deny the destination) or a pane-mode restriction (.redteam denies on every static-miss without invoking the judge). Body/header/path DENY rules sit on top of those primary controls; they do not substitute for them.
Credential gateway injection (Phase T.4b)
MCPToolConfig.credentialGateway declares which vaultKeys a tool needs at which scope, plus a per-tool dryRun opt-in for FAKE_KEY placeholders and an injection target (env by default, args for tools whose runtime doesn't propagate env). HookRouter consults CredentialGateway AFTER the confirmation gate, so a ConfirmationGate.deny short-circuits before any vault read — operator's contract is "the gate decides first, then we inject". On .deny, the gateway surfaces a structured deny reason that names BOTH the missing key AND the scope, with a senkani vault add --key <name> --scope <scope> hint, so the operator never has to guess which entry is missing. The audit row written to token_events records feature: "credential_gateway", the keynames, the scope, and the dry-run flag — the credential value itself is never serialized. Round T.4b ships the API surface against InMemoryKeychainStore; the macOS Keychain conformance, senkani vault CLI, and adversarial corpus eval (zero credentials in any logged row) land in T.4c.
Runtime telemetry — privacy filter (Phase V.18a-4)
The local OTLP receiver (loopback 127.0.0.1, Phase V.18a-3) runs every incoming span and log through OTLPPrivacyFilter between decode and persist. The default metadata mode drops HTTP/RPC request and response bodies, headers, cookies, RPC metadata, db.statement.parameters, process.env.*, user.email/user.name, and enduser.id/enduser.email attribute key prefixes at receive — they never reach the SQLite store. Non-sensitive attribute values are still routed through SecretDetector so an accidentally-embedded API key in url.full or db.statement is redacted before persist. Opt-in widening per source via HandManifest.runtime_telemetry.capture: redacted_bodies captures the sensitive keys but routes every value through SecretDetector; full passes through verbatim and requires the operator to populate validated_fields with a per-key reason — HandManifestLinter warns when full mode ships without it. See spec/architecture.md "Runtime telemetry privacy filter — SecretDetector coverage gap" for the accepted-risk surface (regex-based, attribute keys not scanned, composite AnyValue types skipped, no per-key env-var allowlist).
Confirmation gate (Phase T.6a)
Every write/exec-tagged tool call walks ConfirmationGate and writes a chained row in the confirmations table. MCPToolCatalog tags each tool with read/write/exec/network; the gate fires on .write or .exec, short-circuits read-only tools with no row, and consults a pluggable PolicyResolver for the decision. The default resolver returns auto so today's Edit/Write/Bash flow is unblocked, but every approval is auditable through the T.5 chain — Schneier's "every gate decision is detectable" contract. Rows record decision (approve/deny/auto) and decided_by (operator/policy/auto) so post-hoc audits can tell operator-confirmed calls apart from policy-skipped ones. HookRouter's PreToolUse path consults the gate; a deny response returns a structured permissionDecisionReason ("Confirmation denied for '<tool>': <reason>") to the caller. The NotificationSink protocol (with NullNotificationSink + MockNotificationSink shipping today and a fan-out helper that swallows throws) is the pluggable surface real adapters (StdoutSink, MacOSLocalSink, PushoverSink) plug into in T.6b/T.6c.
Pushover delivery path (Phase T.6c)
The push-notification egress path is locked down end to end. PushoverSink can only ever target the compile-time constant api.pushover.net and consults an EgressRuleEngine before every send (one exact allow rule, everything else deny-on-miss); PushoverMessage's only initializer derives a secret-scrubbed, newline-collapsed, 120-char-capped template from the event — a raw body cannot be smuggled onto the wire by construction. The Keychain-reading transport (KeychainPushoverTransport, T.6c child B) resolves the operator-seeded credential from the credential vault's Keychain seam at send time — the slot senkani doctor --seed-pushover-key (child A) writes, key senkani.pushover, scope default, format <app-token>:<user-key> — and form-POSTs through an injectable HTTP client. Defense-in-depth: the transport re-pins the host (a request naming any other host is refused before the vault is read), no error case has a payload shape that can carry the credential, and the secret's only sink is the outgoing form fields. Failure is observable, never blocking: a missing seed degrades to a delivery_failed(unconfigured) telemetry row without touching HTTP, a network failure or non-2xx swallows into one delivery_failed(transport_error) row, and the synchronous send bridge carries a 30-second fuse so a wedged network cannot stall the agent. Seeding the real token, the egress-policy allow rule, and the live device-push proof remain the operator's leg of T.6c.