Container entrypoint contract¶
Status: normative. This page is the canonical text; kew/src/kew/sandbox.py,
kew/src/kew/dispatch_sandbox.py and docker/agent-entrypoint.sh point here
rather than restating the contract.
When [sandbox] enabled = true, kew does not run a coding agent as a host
subprocess — it runs a container image and talks to it across a narrow,
fixed interface. This page specifies that interface.
The organising invariant:
Per-runner variation lives inside the image. kew's host-side contract never changes.
kew builds the same docker run argv, hands over the prompt the same way, and
reads the same two channels back, whether the image wraps claude, opencode,
or something that does not exist yet. The five elements below are what an image
must satisfy to be containable by kew — there is no per-image special-casing
on the host side, and there will not be one.
Why this matters: the sandbox path grew up claude-only, and the coupling was
live-proven expensive — a contained dispatch routed to a non-claude runner
handed an OpenRouter model string to the claude CLI inside the kew-agent image
and 404'd in 1.3s (#779). The fix is a contract, not a second code path.
What is built today¶
This page is normative for image implementers now: elements 1, 2 and 5 are
fully enforced by the shipped host code, and an image that violates them fails
today. The remaining host-side work is the containment epic
(docs/superpowers/plans/2026-07-26-containment-epic.md), and until it lands
the host half is still claude-shaped in one specific place:
| Gap | Today | Closed by |
|---|---|---|
| Image selection | [sandbox] image is a single string (config.py), so every contained dispatch gets the same image regardless of runner — routing a non-claude node into a sandbox hands it the claude image, which is the #1104 dead combo above. |
N2 ([sandbox] images.<runner> map, fail-closed on an unmapped runner) |
| ~~Extra args (element 3)~~ | Closed by N3. run_container_and_stream now fills the slot from runner.container_args() — the resolved runner's own flags, not a claude block. |
— |
| ~~Stdout parsing (element 4)~~ | Closed by N4. The sandbox tail loop and the terminal telemetry replay both go through runner.parse_line() — the same parser the host path uses — so a non-claude image's tokens and native cost reach the run row, and the row records the runner that actually ran. |
— |
Until N2 lands, do not route a non-claude node through the sandbox — the epic deliberately runs every one of its own nodes on the claude route for exactly this reason.
The five elements¶
1. Mounted checkout¶
kew clones the target repository into a temporary standalone checkout on the host and bind-mounts that directory and nothing else:
- The mount point inside the container is
/work, and it is also the working directory. Both are fixed. - It is the container's only bind mount. Host
~/.claude,dispatch.db, GitHub credentials, and the rest of the host filesystem are outside the mount and invisible. This mount boundary is the primary containment — the in-process security guard is best-effort layer 2 (see Security, Threat model). - The image MUST NOT assume any other host path exists.
- Everything the agent produces that kew will act on — commits, the prompt
file, the reviewer sidecar — lives under
/work.
The container never pushes and never opens a PR: it holds no GitHub token by
design. After a clean exit the host brokers git push and gh pr create.
2. Prompt file → stdin¶
The prompt is never passed in argv (no length limit, not visible in ps)
and is never piped from the host process.
- Before launch, the host writes the prompt to
<checkout_dir>/.kew-prompt.txt—PROMPT_FILENAMEinkew.sandbox. - Because the checkout is mounted at
/work, the file appears inside the container at/work/.kew-prompt.txt—PROMPT_MOUNT_PATH. - The entrypoint MUST read that file and feed it to the agent binary on stdin.
- The host deletes the file on every exit path — success, failure, or kill (#725) — so it can never land in a PR branch.
The entrypoint MUST honour KEW_PROMPT_FILE as an override, defaulting to
/work/.kew-prompt.txt, and MUST fail loudly (non-zero exit, message on
stderr) when the file is missing or empty rather than starting the agent with
no prompt. That failure mode is exactly #695: the container started, the agent
exited on "Input must be provided either through stdin or as a prompt
argument", and the dispatch burned for nothing.
3. "$@" — the extra-args slot¶
Everything after the image name in the docker run argv becomes the container
command, which the entrypoint receives as "$@":
docker run [hardening flags] -v <checkout>:/work -w /work \
-e ANTHROPIC_API_KEY -e CLAUDE_CODE_OAUTH_TOKEN \
<image> <extra args ...>
- The entrypoint MUST forward
"$@"to the agent binary unmodified, and MUST NOT hardcode flags that the host is responsible for. - This is the slot the routed model travels through. Routing that only reaches the run row and not the container is the #761 bug class.
- This slot is where per-runner difference shows up on the host side. The host
fills it from the resolved runner's
container_args()—build_command()minus the binary and minus the prompt handoff. Forclaude-codethat is--dangerously-skip-permissions,--verbose --output-format stream-json, and--model <routed model>; for another runner it is that runner's own flags. An image therefore receives args shaped like the agent it ships, and the image-side obligation (forward"$@"unmodified, hardcode nothing) is the same either way. - A runner deliberately omits host-only argv from this slot: a session id minted for a host-side resume, or a sidecar path under the host log dir, means nothing inside the container. The prompt is omitted for the same reason it never rides in argv at all — element 2 already delivers it.
4. One JSON event per line on stdout¶
The agent's telemetry channel is stdout, one JSON object per line — newline-delimited JSON, no pretty-printing, no multi-line objects, no interleaved prose on stdout. kew streams it live: each line is written to the run log and parsed for telemetry (tokens, cost, reported model, session id).
- The image MUST emit newline-delimited JSON on stdout for the whole run.
- Human-readable diagnostics belong on stderr.
- A line kew cannot parse MUST NOT be fatal to the image — kew tolerates unparseable lines (they still reach the log), but telemetry derived from them is lost. An image that emits prose on stdout is cost-blind, not broken, which is the worse failure: it looks like it worked.
For the reference image this is claude --verbose --output-format stream-json.
An image wrapping a different agent is responsible for producing the same
shape of channel: the "one JSON object per line" framing is the fixed part.
The event vocabulary inside those lines is the resolved runner's own. kew
feeds every line to that runner's parse_line() — the same parser the host
(non-sandbox) path uses — so an image speaking opencode's vocabulary is metered
as accurately as one speaking claude's. Concretely, telemetry is derived from
the parsed StreamEvents: usage events accumulate tokens, step events sum
the runner's native per-step cost, metadata["model"] names the model, and a
terminal result event that carries a RunTotals
(kew/src/kew/runners/protocol.py) overwrites the accumulation with the run's
authoritative totals. A runner that reports no end-of-run summary simply omits
it and its per-step sums stand.
An image whose vocabulary matches no kew runner still runs and still logs —
it is cost-blind, not broken (the #761 failure class). The fix for such an image
is a runner implementing parse_line() for its shape, not a change here.
5. Reviewer sidecar path¶
A reviewer dispatch runs through the same primitive
(run_container_and_stream) but must return a verdict, not a branch. It
does so through a sidecar file inside the mount:
- Host-relative name:
.kew-review-verdict.json—SIDECAR_FILENAME. - The path the reviewer is told to write, always absolute:
/work/.kew-review-verdict.json—SIDECAR_MOUNT_PATH.
The absolute form is load-bearing (#1026): the agent's working directory is arbitrary relative to the mount root on any monorepo or nested-package target repo, so a bare relative name writes the verdict where kew never looks and yields a flaky false-DEGRADE. kew anchors to the mount root it controls, and keeps a subdirectory-recovery fallback as defence in depth.
- The image MUST allow the agent to create files at the mount root; it MUST NOT intercept, relocate, or post-process the sidecar.
- kew copies the sidecar onto the host and digests its own stored copy, not the in-sandbox original — the reviewed code could delete or rewrite the original (F10). Nothing in the image can influence that digest after collection.
- A missing or unparseable sidecar is fail-closed: the node is held for a human, never auto-approved.
Host-side hardening kew applies¶
build_run_argv() in kew/src/kew/sandbox.py is the host half of the contract. An
image implementer does not choose these — they are the environment the image
must survive:
| Flag | Effect on the image |
|---|---|
--read-only |
The root filesystem is read-only. Nothing may be written outside the mount and the tmpfs mounts below. |
--tmpfs /tmp |
Ephemeral scratch, mounted noexec. |
--tmpfs /home/agent:exec,uid=1001,gid=1001,mode=0700 |
A writable, ephemeral HOME, pinned to the image's agent uid, and deliberately exec (#762) so tooling the agent installs is runnable there instead of littering the bind-mounted checkout. |
--cap-drop ALL, --security-opt no-new-privileges |
No capabilities, no privilege escalation. |
--pids-limit |
Fork-bomb bound. |
--rm, --name kew-<slug> |
One container per work item, removed on exit. |
--network |
allowlist (default) joins the pre-created kew-allowlist network and fails closed if it is absent; open is an explicit full-egress opt-out and warns when a credential is injected. |
Credentials are forwarded by name only — -e <NAME>, never -e
NAME=value — with values inherited from the host process env; an unset name
stays unset inside. CLI login state can never reach a container, because
~/.claude is deliberately not mounted, so an env credential is the only auth
path in (#721). No GitHub token is ever passed.
Which names are forwarded is keyed by the resolved runner (#1146). Each
runner declares its own required_credentials, and build_run_argv injects
exactly that runner's set and nothing else, so a contained run only ever sees
its own provider's credentials:
| Runner | Forwarded names |
|---|---|
claude-code |
-e ANTHROPIC_API_KEY -e CLAUDE_CODE_OAUTH_TOKEN |
opencode, pi, hermes |
-e OPENROUTER_API_KEY |
lemonade |
(none — declares no in-container credential, so it is refused rather than contained) |
A claude container therefore never carries OPENROUTER_API_KEY, and an
opencode container never carries CLAUDE_CODE_OAUTH_TOKEN. This is a
security surface, not a convenience list: an opencode image that could read
the claude token could spend the operator's Anthropic subscription. Widening a
runner's declaration widens what a prompt-injected agent in that container can
exfiltrate — and it must never widen how credentials travel (still by name,
still no mounted credential file, still no host path).
Consequences an image MUST be built for:
HOMEis fresh every run: no~/.gitconfig, no cached agent config, no credential store. Anything the image needs atHOMEmust be created by the entrypoint, not baked into the image layer./workis a host bind mount owned by a different uid than the container user, so git refuses to operate on it until told otherwise.- Any state written outside
/work,/tmp, and$HOMEwill fail withEROFS.
Reference implementation: the kew-agent image¶
docker/agent.Dockerfile + docker/agent-entrypoint.sh are the reference
implementation of this contract, and the image every claude route currently
runs on. A future opencode (or other) image is built against the same five
elements.
Image layout (docker/agent.Dockerfile):
debian:bookworm-slim, pinned by digest for reproducibility.- Non-root user
agent, uid 1001 — the uid the tmpfsHOMEmount is pinned to. The Dockerfile andAGENT_HOME/_AGENT_UIDinkew/src/kew/sandbox.pymust be kept in sync; a mismatch makes/home/agentunwritable and breaks the agent at shell-init. /workcreated and chowned toagentbeforeUSER agent, so the image is usable even when/workis not a bind mount.- Preinstalled tooling so agents do not re-download it into the bind-mounted
checkout every dispatch (#762):
git,curl,ca-certificates,procps(ps),jq(the security-guard hook parses its payload with it and fails closed without it), Node.js 22 +npm,uvin/usr/local/bin, and the Claude Code CLI. ENTRYPOINT ["/usr/local/bin/kew-agent-entrypoint"],WORKDIR /work, and no baked credentials — ever.
Entrypoint (docker/agent-entrypoint.sh), in order:
set -euo pipefail.- Resolve
PROMPT_FILE="${KEW_PROMPT_FILE:-/work/.kew-prompt.txt}"; exit64with a stderr message if it is missing or empty. - Git bootstrap (#742) — required by the read-only/tmpfs constraints
above, not optional polish:
Without
git config --global --add safe.directory /work git config --global user.name "kew-agent" git config --global user.email "agent@kew.local"safe.directory, git rejects the bind mount as dubious ownership; without an identity,git commitdies on Author identity unknown. Either way the agent does the work and it never lands — the kew-721 incident. The identity is container-local; the host broker owns push and PR. exec claude --print "$@" <"$PROMPT_FILE"— the extra-args slot and the stdin handoff in one line.
Verification. kew sandbox smoke exercises launch, mount, and the prompt
handoff without invoking the agent, by overriding the image ENTRYPOINT and
cat-ing /work/.kew-prompt.txt back out. An image that passes smoke has
elements 1–3 wired correctly.
Implementer checklist¶
To be containable by kew, an image MUST:
- [ ] Do all of its work under
/work, and assume no other host path exists. - [ ] Default its prompt file to
/work/.kew-prompt.txt, honourKEW_PROMPT_FILEas an override, and feed that file to the agent on stdin — never in argv. - [ ] Exit non-zero with a stderr message when the prompt file is missing or empty, rather than launching a promptless agent.
- [ ] Forward
"$@"to the agent binary unmodified, and hardcode no flag the host owns (model, permission mode, output format). - [ ] Emit one JSON object per line on stdout for the whole run, in the
vocabulary its runner's
parse_line()reads, and keep human-readable diagnostics on stderr. - [ ] Let the agent write
/work/.kew-review-verdict.jsonat the mount root, untouched, for reviewer runs. - [ ] Run as a non-root user whose uid matches the
AGENT_HOMEtmpfs pin (1001today), and survive--read-only+--cap-drop ALL+no-new-privileges+--pids-limit. - [ ] Bootstrap git in the entrypoint (
safe.directory /work+ a container-local identity), becauseHOMEis a fresh tmpfs every run. - [ ] Authenticate from environment variables only, declared on the runner
(
required_credentials) so kew can forward them by name; assume no mounted credential file and no CLI login state. Only the resolved runner's declared names arrive — an image must not expect another runner's credentials to be present. - [ ] Bake no credentials into any layer, and expect no GitHub token.
- [ ] Pass
kew sandbox smoke.
And an image MUST NOT:
- Require a writable root filesystem, extra bind mounts, or added capabilities.
- Push, open PRs, or otherwise talk to GitHub — the host brokers all of it.
- Print non-JSON prose on stdout (it silently blinds cost accounting).
- Relocate or rewrite the reviewer sidecar.