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 sandboxed/containerized dispatch — run the guard alongside sandboxing, 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 |
| 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).
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:
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 sandboxed dispatch.
Sandboxed dispatch: credential posture¶
Sandboxing is the containment layer above the in-process guard. Its default is [sandbox] enabled = "auto": kew sandboxes automatically whenever a container runtime (Docker Desktop, OrbStack, Podman, …) is detected, so a fresh install with a runtime present gets containment by default. The dispatch banner is the consent surface — the first sandboxed run announces Sandbox: on (<runtime>), and when no runtime is found the run downgrades to the guard-only host path with a one-line notice explaining how to get containment. Set enabled = true to hard-require a runtime (fail closed), or enabled = false to opt out silently. The effective posture is recorded as a sandbox_resolved audit event on each dispatch.
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 |
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 runner that declares nothing (or that kew does not recognise) is refused, not launched with a neighbour's key — an image mapping alone does not make a runner containable.
- 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-tokenfor claude,openrouter.ai/keysfor 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.
See Configuration — sandbox for the full option reference.
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:
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:
- Set
[flight_recorder].expose = trueinkew.toml, or - Pass
--exposeon 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.