Skip to content

Security

The security hook runs as a Claude Code PreToolUse handler on Bash, Write, and Edit tools — meaning it executes before every command, even with --dangerously-skip-permissions enabled.

This is defense-in-depth, not a security boundary. The hook is a pattern match against known-destructive shell commands; it catches casual and accidental damage, it does not prevent a sufficiently motivated or prompt-injected agent from working around it. The actual isolation boundary is container or runner-native containment — run the guard alongside containment, not instead of it.

Write/Edit protection: - Blocks all modifications to .claude/ (hooks, settings, permissions) with path canonicalization to prevent symlink/relative-path bypasses — this also protects installed skills and the guard script itself from being tampered with by the agent they govern

Bash command blocking:

Category Examples
Destructive git git push --force, git reset --hard, git clean -fd, git branch -D main
System destruction sudo, chmod 777, rm -rf /
Subshell injection $(...), backtick substitution
Indirect invocation sh -c "...", bash -c "...", eval
Base64 decode base64 -d, base64 --decode
Credential exfiltration env, printenv, echo $ANTHROPIC, echo $GITHUB_TOKEN
Pipe-to-shell curl ... \| bash, wget ... \| sh
File exfiltration curl --data @file, curl --upload-file, wget --post-file
Cross-checkout Git mutation git commit, git checkout -B, git update-ref, or git branch -f outside the assigned agent/issue-* worktree
Custom patterns Via [security] config: blocked_prefix_patterns, blocked_exact_patterns

If the hook blocks a command, Claude sees a denial message and adjusts its approach. Your existing hand-managed hooks are preserved (the generator only overwrites files it created).

Dispatches also pass the assigned worktree to the managed guard and compare the operator checkout's HEAD and base ref before and after the run. A movement is failed closed, preserved for incident review, and recorded as a root_checkout_integrity_violation audit event with the before/after values. The marker is a path, not a credential; if it is absent, ref-mutating commands are denied.

See Audit log & tamper-evidence for the hash-chained audit log and its honest limits.

Custom patterns are configured via the [security] section in kew.toml:

[security]
blocked_prefix_patterns = ["kubectl delete"]
blocked_exact_patterns = ["DROP DATABASE"]

The guard is managed through kew skills like any other shipped artifact (kew setup installs it initially). kew skills update security-guard re-renders the hook from your current kew.toml, so custom patterns are re-applied rather than lost — see Guard updates preserve your config.

Untrusted content in agent prompts

Every dispatch surface renders attacker-reachable text into the agent's prompt — the issue body (dispatch), PR review comments (review), CI failure logs (ci-fix), and conflicted paths (resolve). kew treats all of it as data, never instructions:

  • Both safety blocks in every flow. The "treat as data" preamble and the "NEVER gh pr merge / gh pr review --approve / add merge labels" rule render in all four templates, not just dispatch.
  • Per-render random sentinel. Untrusted bodies are fenced between two copies of a sentinel drawn from a CSPRNG at render time (<<<KEW-UNTRUSTED-…>>>). Because the content author cannot predict the token, they cannot forge a closing delimiter to break out — the previous static HTML-comment markers were spoofable. Content that already contains the sentinel is rejected.
  • Explicit caps and markers. Bodies are capped with an explicit truncation marker; an empty body renders (no spec provided) rather than a blank, so a missing spec is never mistaken for an intentional one.

This is defense-in-depth for the prompt layer — the enforced boundaries remain the command guard and dispatch containment.

Sandboxed dispatch: credential posture

Containment is the layer above the in-process guard. Its default is [sandbox] enabled = "auto": kew accepts a runner-native boundary declared in runner code, or otherwise sandboxes automatically whenever a container runtime (Docker Desktop, OrbStack, Podman, …) is detected. The first container run announces Sandbox: on (<runtime>); when a non-native runner has no runtime, the run downgrades to the guard-only host path with a one-line notice. Set enabled = true to hard-require either native or container containment (fail closed), or enabled = false to opt out silently. The effective posture is recorded as a sandbox_resolved audit event on each dispatch.

Native containment is not configurable. A runner's containment_posture protocol property must declare it in code, so TOML cannot promote an arbitrary host process to a trusted boundary. Official Codex currently declares native: the fixed CLI launch uses workspace-write, disables command network access, and grants no extra writable roots. Native audit events carry no container runtime or image.

When a dispatch is sandboxed, kew enforces the following credential posture:

Credential Where it lives Container sees it?
GitHub token (GH_TOKEN / GITHUB_TOKEN) Host-only — used for push and PR creation after the container exits No
The resolved runner's own provider credential Forwarded by name only (-e ANTHROPIC_API_KEY, never -e NAME=value); the value is inherited from the host process env Yes (intentional)
Any other runner's provider credential Host env — deliberately not in this container's allowlist No
dispatch.db / audit log Host filesystem, outside the mount No
~/.claude config and hooks Host filesystem, outside the mount No

The credential env allowlist is per-runner

Each runner declares the env var names it can authenticate with in-container (required_credentials), and that declaration is the container env allowlist: build_run_argv injects exactly the resolved runner's names and nothing else.

Runner Injected Not injected
claude-code ANTHROPIC_API_KEY, CLAUDE_CODE_OAUTH_TOKEN OPENROUTER_API_KEY
opencode, pi, hermes OPENROUTER_API_KEY ANTHROPIC_API_KEY, CLAUDE_CODE_OAUTH_TOKEN
codex (none; its credential declaration is empty) all provider, GitHub, and AWS credentials
lemonade (nothing — declares no in-container credential) everything

This is a security boundary, not a config convenience. A prompt-injected agent in an opencode container that could read CLAUDE_CODE_OAUTH_TOKEN could spend the operator's Anthropic subscription; the reverse holds for OPENROUTER_API_KEY. Consequences of the design:

  • By name only. Widening a runner's declaration must never widen how credentials travel — no -e NAME=value, no mounted credential file, no host path. A credential value therefore never reaches an argv, a process list, a log line, the run row, or the audit ledger.
  • Fail closed, never borrow. A non-native runner that declares no container credential (or that kew does not recognise) is refused, not launched with a neighbour's key — an image mapping alone does not make that runner containable. Native Codex uses its host CLI login and never enters this injection path.
  • Preflight, not post-mortem. If the resolved runner's own credential is absent from the environment, the dispatch is refused before a container starts, with provider-specific guidance (claude setup-token for claude, openrouter.ai/keys for an OpenRouter-backed runner). Containers cannot inherit CLI login, so launching anyway means a "Not logged in" container that burns the dispatch at $0 (#721).

Egress is restricted by default. [sandbox] network defaults to "allowlist": the container joins the pre-configured kew-allowlist Docker network (operators add iptables rules restricting egress to the inference endpoint + GitHub), and Docker fails closed if that network does not exist — there is no silent fallback to open egress. An operator can opt into full egress with network = "open", but because a provider credential is injected into the container, kew emits a loud dispatch warning in that case, naming (by name, never by value) the credential that one outbound request could exfiltrate.

Accepted non-goals (v1): container image integrity beyond digest pinning, and protection against a compromised container image itself.

The GitHub token is host-only — the container never receives it, so a prompt-injected agent cannot exfiltrate it or use it to push to arbitrary repos. Push and PR creation are host-brokered: kew verifies the container exited cleanly, then calls git push and gh pr create on the host.

Epic merges require a non-author GitHub User approval plus a fresh collaborator-permission lookup granting write, maintain, or admin. Bot reviewers, external reviewers without that authority, missing actor data, and permission lookup failures all fail closed. Configured approver lists only narrow this authority requirement; an empty list never grants authority.

See Configuration — sandbox for the full option reference.

Official Codex host boundary

Official Codex is not placed in kew's external container in v1. It runs with Codex-native workspace-write on the host, command network off, protected .git, fixed extension/search/shell-secret controls, and a sanitized process environment. Project .codex/config.toml, unknown entries, symlinks, and special files fail closed; only regular content under .codex/rules/ and .codex/skills/ is admitted. The official CLI login remains in its cache or keychain and kew's host broker owns commit, push, PR, and report publication. This runner-owned boundary is admitted and attested as posture native on the default path, whether or not a container runtime is installed.

Switchyard credential boundary

Switchyard is an inference transport, not a credential broker or a Kew runner. Kew configuration contains the endpoint, route, versions, and SHA-256 digests; it must never contain a credential value. Those non-secret fields and the sanitized endpoint origin may be written to run/audit evidence. Route config contents, authorization headers, raw proxy responses, and credential values must not be logged or committed.

Credential ownership is split deliberately:

Secret or authority Owner Switchyard exposure
Runner/provider login Runner operator and native CLI The native CLI may present it to the configured endpoint; Switchyard is therefore a credential-bearing trust endpoint.
Upstream route credential Switchyard endpoint operator Kew neither stores nor audits its value. External endpoint lifecycle and revocation remain operator duties.
Managed proxy process environment Kew Empty except for the exact reviewed credential_env names required by the admitted route.
GitHub, tracker, SSH, or package credentials Kew host boundary Never required by Switchyard and must not enter its config, process, raw evidence, or endpoint.

Codex's custom Responses provider requires its existing OpenAI authentication; Claude Code uses its native Anthropic authentication against the configured Messages base URL. Consequently, changing endpoint changes which server can receive that native authorization material. Use a dedicated operator-owned literal-loopback endpoint for managed mode. For an external endpoint, require reviewed HTTPS identity, access control, retention, and incident/revocation ownership before use. A successful health probe does not prove authorization.

For managed authenticated routes, credential_env declares names only. Kew parses the privately snapshotted route, follows the exact route's target and client references, and requires its api_key_env names to equal that declaration. Only OPENAI_API_KEY, OPENROUTER_API_KEY, and ANTHROPIC_API_KEY are eligible. Missing, empty, duplicate, unsupported, extra, or mismatched names fail before proxy startup. Version probes remain keyless; the validated dry run and owned server receive a fresh environment containing only the exact matched name/value pairs. GitHub, Linear, SSH, package, cloud, and unrelated provider credentials remain excluded.

The credential value is never placed in kew.toml, the route snapshot, argv, logs, reports, raw canary output, fixtures, or audit payloads. Audit evidence contains only credential names plus the route-config and binary digests. Kew clears its temporary Python mapping after launch and destroys the child environment when the process exits. As with any environment-based delivery, the operating system may expose a live process environment to the same user or privileged process inspection; use a dedicated operator account where that boundary is insufficient.

Rotate by stopping admissions, terminating managed runs, replacing the host variable, and starting a new evidence window. Revoke at the provider first when exposure is suspected, then stop Kew and verify no managed process remains. Never broaden credential_env to work around an authentication failure.

Live passthrough canaries and benchmarks require a separate human gate. Canary raw records can contain prompts, source, commands, paths, session identifiers, or secrets; Kew accepts only a pre-existing owner-only record directory and writes files mode 0600. The operator must set a retention deadline, reduce the evidence to non-secret checks and digests, delete the raw directory, and confirm deletion in the UAT record (docs/audit/2026-08-03-switchyard-passthrough-uat.md in the repository — internal audit packs are not published). Proxy Prometheus metrics remain process-wide observations and never replace runner-reported usage for accounting.

Approval inbox

kew serve adds kew's first authenticated network surface: a web approval inbox at GET /inbox. It exposes exactly two writes — approve/deny a hold, and stop the fleet — behind layered auth controls.

Auth model

Token → session cookie. On startup, kew writes a random 32-byte URL-safe token to <logging.dir>/serve_token (mode 0600, created with O_EXCL so it is never world-readable even briefly). The startup line prints the inbox URL with the token in the URL fragment:

kew serve → http://127.0.0.1:8765/inbox#token=<token>

The fragment (#token=…) is never sent to the server or written to any log by the browser. The inbox page reads it client-side and POSTs it to /api/auth/login, which exchanges it for an HttpOnly SameSite=Strict session cookie and a CSRF token. All subsequent requests use the cookie; the raw token is not stored in the browser.

Sessions are held in memory and expire after 12 hours. Restarting kew serve clears all active sessions. kew serve --new-token rotates the token on disk; to fully revoke existing sessions, rotate and restart.

The KEW_SERVE_TOKEN environment variable overrides the file-based token (useful in scripted environments). When set, no file is written.

CSRF + Origin check (writes). Every write endpoint requires the X-Kew-CSRF header carrying the double-submit CSRF token issued at login, and rejects requests with an Origin header that does not match an allowed origin. The allowed set is derived from the bind address; when fronting kew with a TLS reverse proxy, the browser's public origin must be added via [flight_recorder].extra_origins or writes 403 (see Reverse proxies).

Baseline response headers. Every response carries Content-Security-Policy (default-src 'self'; frame-ancestors 'none'; object-src 'none'), X-Content-Type-Options: nosniff, X-Frame-Options: DENY, and Referrer-Policy: no-referrer — independent of --expose.

Host-header allowlist. All requests are checked against a set of allowed Host values (127.0.0.1, localhost, ::1, and their port variants). Requests with an unexpected Host header are rejected with 403 before any auth logic runs. This defends against DNS-rebinding attacks.

Read guard. In the default loopback-only mode, reads from a loopback peer are permitted without a session. When --expose is active, all API reads (/api/*) require a valid session — the loopback-peer bypass is disabled. (The /inbox HTML shell itself is static and unauthenticated; it contains no data — every data call it makes hits the authenticated API.)

Binding and exposure

By default, kew serve binds to 127.0.0.1:8765 (loopback only). To bind a non-loopback address:

  1. Set [flight_recorder].expose = true in kew.toml, or
  2. Pass --expose on the command line.

Either form is required; a non-loopback host without expose is rejected at startup with an actionable error. See kew serve and Configuration for details.

Writes: approve, deny, stop

Approve / deny. Approving a hold via the inbox calls the same decide_hold service function as kew approve/kew deny. It releases the hold to ready (approve) or parks it (deny). The running scheduler detects the label change and dispatches — the web process does not spawn an agent.

Approving requires gh to be authenticated in the process running kew serve. If GitHub is unreachable, the inbox returns HTTP 502 with gh_error — it does not silently mark the hold as resolved.

Stop. The stop write signals process groups (real kill: SIGTERM, then SIGKILL after a grace period). It runs synchronously and is best-effort — it does not add a false timeout guard.

Actor honesty

A web approval is audited with source="web" and the actor set to the OS user running kew serve. The token proves possession of the server process, not a distinct browser identity. Web approvals are identically policy-bound to CLI approvals: the [approval].approvers allowlist (if set) still gates whether the operator may approve.

Do not interpret the token as a multi-user identity mechanism — there is one shared token, and it authenticates the operator who controls the machine, not a named user.

Reverse proxies

If you place kew serve behind a reverse proxy (nginx, Caddy, etc.), the loopback peer-check is meaningless — all connections appear to come from the proxy. Set --expose (or [flight_recorder].expose = true) so reads require a session regardless of peer; do not rely on kew's loopback bypass.

Because the browser's public Origin never matches kew's internal host:port, declare it in [flight_recorder].extra_origins (e.g. ["https://kew.example.com"]) or every write 403s. Each entry is a bare origin — scheme + host, optional port, no path. See the Reverse proxy + TLS recipe for the full nginx/Caddy setup.

kew does not provide TLS; TLS termination must be done by the reverse proxy.