ADR-0018: Dispatch Security & Isolation
Context and Problem Statement
Agent dispatch (SPEC-0003) executes an AI agent that runs arbitrary tool calls — shell, file writes, test runs, network fetches — against a checkout of the user's repository, on the user's Mac, and then pushes a branch and opens a pull request on the user's behalf. That is a large amount of authority handed to a nondeterministic model driven by a brief the human authored from code the model itself may have written, executed by a third-party harness (OpenHands, ADR-0004) against a bring-your-own model endpoint (ADR-0004). Getting the isolation and credential handling wrong means a sandbox escape onto the host, a leaked push token in a transcript or commit, a poisoned brief redirecting the agent, or a runaway dispatch burning money and CPU.
ADR-0003 (pluggable container runtime) and ADR-0007 (forge auth + scoped credentials) established that dispatch is sandboxed and that agents get a branch-scoped credential. Neither pinned down the concrete threat model, the credential-injection mechanism, the cleanup guarantee, or which residual risks we knowingly accept for M4. M4 ("Execution — Closing The Loop") makes dispatch real (Docker-only runtime, OpenHands-only harness, single-step workflow live, the real end-to-end PR human-gated), so those decisions can no longer be deferred. This ADR records them as the security contract the M4 code enforces and its tests assert.
Decision Drivers
- Least authority in the sandbox. The container should hold the narrowest credential and the fewest mounts that let the agent do the work — never the user's primary forge token, never the host home, never the Docker socket.
- The wide-privilege token never enters the container. Push and PR creation are authority that belongs to the app, not the agent.
- No credential ever reaches a place it can be exfiltrated — not a CLI argv (
ps/ process listing), not a transcript, not a commit, not a PR body. - Guaranteed, idempotent cleanup. Every dispatch — success, failure, cancel, timeout, crash — reclaims its container, worktree, and credential.
- Injection is contained, not "prevented." The brief and repo are untrusted input; the container is the blast boundary if injection lands anyway.
- Auditable in one place. Security-critical checks (branch/SHA validation, sanitization, credential exit) live in single, testable seams, not scattered.
- Honest residuals. Where a mitigation is bounded rather than absolute (GitHub branch-scope, model-endpoint trust, perfect cleanup), say so and bound it.
Considered Options
- Option A — Ambient authority. Run the agent with the user's real forge token in the container so it can push its own branch and open its own PR. Simplest wiring.
- Option B — App-mediated authority with a scoped credential (chosen). The
container never holds the primary token; it receives at most a short-TTL,
repo-scoped
ScopedCredential(environment-only), and the app performs the push and PR creation with the primary token held privately in theGitHubForgeactor. Validation, sanitization, and cleanup are dedicated seams. - Option C — Custom sandbox (seccomp/AppArmor-equivalent, syscall filtering) on top of the container. Strongest isolation, most work, and largely redundant with the container's own kernel isolation for M4's threat level.
Decision Outcome
Chosen option: Option B — app-mediated authority, environment-only scoped credentials, dedicated validation/sanitization/cleanup seams, and a documented set of accepted residuals. The container is treated as the blast boundary; the app retains every piece of wide authority; the credential the sandbox may hold is narrow-by-construction and revoked immediately. Custom syscall filtering (Option C) is not adopted for M4 — Docker's kernel isolation is the accepted baseline (ADR-0003) — and is left as an Open Question. Ambient authority (Option A) is rejected outright: it puts the primary token one transcript line away from exfiltration.
This is the security contract M4 implements and unit-tests with fakes; the real end-to-end run against a live daemon, a live model, and a scratch repo is human-gated (see SPEC-0003 design, Gated-live plan).
Threat model
Each row names the threat, the mitigation, and where in the code it is enforced and asserted — so the model is not aspirational but wired to files a reviewer can open.
| # | Threat | Mitigation (enforced / asserted) |
|---|---|---|
| 1 | Sandbox escape → host access | Docker kernel isolation is the baseline (ADR-0003; no custom sandbox for M4). Minimal mounts: only the per-dispatch worktree bind-mounted RW plus an /outputs dir; no /etc, no host home, no Docker socket. --network none unless AgentGuardrails.allowNetworkAccess (default off). Resource caps (--memory/--cpus/--pids-limit) from DispatchLimits. Enforced in DockerRuntime.createSandbox flag construction (Sources/StetKit/Runtime/DockerRuntime.swift); asserted in DockerRuntimeTests (no socket mount, network flag by guardrail, caps from limits). |
| 2 | Token leakage → transcript / commit / PR / process list | ScopedCredential is injected environment-only via asEnvironment() under the fixed key STET_GIT_CREDENTIAL — never a CLI argv, so it cannot surface in ps or an argv scan (Sources/StetKit/Dispatch/ScopedCredential.swift, DispatchSandbox.run). Every TranscriptEvent passes a TokenSanitizer redacting ghp_/github_pat_/gh[ousr]_/sk-/xox[baprs]- shapes and URL-embedded userinfo before it reaches DispatchProgress (Sources/StetKit/Dispatch/DispatchState.swift). Asserted in DispatchServiceTests by scanning every SandboxProcess.command the sandbox saw for the credential value, and in DispatchSandboxTests end-to-end through the real DockerSandbox. |
| 3 | The primary forge token enters the container | The app (GitHubForge actor, Sources/StetKit/Forge/GitHubForge.swift) holds the primary read-write token privately and performs push + PR creation host-side. GitHubForge.pushBranch deliberately passes the scoped credential to GitService, never the actor's private token. The container is never handed the primary token in any code path. Asserted in GitHubForgeDispatchTests (push carries the scoped value, not the primary token) and DispatchServiceTests. |
| 4 | Prompt / command injection from the brief or repo | Brief items render as data into instruction templates — fixed-vocabulary {{…}} text substitution, no eval, no shell interpolation (WorkflowRunner.render). Repo file contents are never inlined into the brief; only Anchor coordinates (path + line range) travel — the agent reads content in-sandbox. Sandbox commands are array argv (SandboxProcess.command: [String]), never string-concatenated; the OpenHands task instruction is staged to a file via a discrete-argv write, so a hostile brief can never become a CLI flag (OpenHandsHarness.run). If injection still lands, the container (row 1) is the blast boundary. |
| 5 | Wrong repo / wrong base / malicious branch name | Preflight validates the Brief.Target repo + base against the forge before any sandbox exists (no side effects on the preflight failure path). The agent never chooses the repo — it edits the mounted worktree; the app pushes to the validated repo/base. BranchValidator (Sources/StetKit/Dispatch/BranchValidator.swift) rejects traversal (.., //, @{, backslash, whitespace, control chars), denylisted names (main/master/develop/trunk/HEAD), base-branch collision, and malformed SHAs → .invalidBranch / .invalidCommitSHA before any push. Existing-PR path resolves the real head ref via Forge.resolveHead in preflight and updates the PR, never re-creates it. Asserted in BranchValidatorTests and DispatchServiceTests. |
| 6 | Resource exhaustion / runaway dispatch | --memory/--cpus/--pids-limit from DispatchLimits. A wall-clock timeout (default 30 min) races the workflow in DispatchService via withThrowingTaskGroup → .timedOut → guaranteed teardown. AgentGuardrails.costCeilingUSD is checked against accumulated .usage events (best-effort where the endpoint reports usage — residual below). Asserted in DispatchServiceTests (timeout path runs cleanup). |
| 7 | Credential-revocation failure / orphaned sandbox | ScopedCredential.revoke() is idempotent + error-swallowing (safe inside defer; a second 404 is treated as already-revoked). Short TTL is the primary defense, not revocation. Teardown runs on every exit and cannot shadow the original error. A launch-time DispatchService.sweepOrphans docker rm -fs stale stet-dispatch-* containers (best-effort). |
| 8 | Concurrent-dispatch collision | Every dispatch is a separate DispatchService instance with a unique stet-dispatch-<uuid> container name, worktree path, branch, and freshly-minted credential. DispatchModel enforces a concurrency ceiling (default 2) and refuses a second dispatch against an in-flight PR target or the same inbox row. Asserted in DispatchServiceTests (two instances → distinct branches + independently minted credentials). |
| 9 | Supply chain (harness image) | The dispatch image is Stet-maintained — docker/dispatch/Dockerfile bakes git + Python 3.12 + the version-pinned OpenHands CLI, published to ghcr.io/joestump/stet-openhands by .github/workflows/dispatch-image.yml — and referenced ONLY by immutable @sha256 digest (DispatchModel.defaultImage, Sources/Stet/Dispatch/DispatchModel.swift) so a mutable tag cannot swap the harness underneath us. Digest bumps are reviewed PRs — see "Dispatch-image pin + refresh" below. |
The invariant (promoted to law)
The app, holding the real read-write forge token inside the
GitHubForgeactor, performs the branch push and the PR creation. The sandbox only ever holds a short-TTL, repo-scopedScopedCredential, injected environment-only, and never the app's primary token. The wide-privilege token never enters the container.
This single invariant collapses the most dangerous rows of the threat model (2, 3, 5)
into one auditable boundary and is asserted directly in DispatchServiceTests.
Guaranteed-cleanup pattern
Once provisioning succeeds, teardown + revoke are the responsibility of a single,
always-run cleanup path so every subsequent exit — success, throw, cancel,
timeout — reclaims the container, the worktree, and the credential exactly once.
Because a Swift defer cannot await, DispatchService runs the post-provision
phases with their outcome captured in a Result, always awaits
DispatchSandbox.teardown() (which cascades container reap → worktree remove →
credential revoke, capturing the first error but continuing past it), removes the
sibling /outputs scratch dir, and only then propagates the captured outcome. A
teardown failure is logged and never shadows the original error. Idempotent
revoke() + short TTL + launch-time sweepOrphans cover the crash-mid-teardown edge.
Credential-injection mechanism
ScopedCredentialProvider.mint(repository:branch:) returns a ScopedCredential
whose value is in-memory only. Its sole sanctioned exit is
asEnvironment() -> [String: String] under the fixed key STET_GIT_CREDENTIAL,
which DispatchSandbox.run merges into the process environment (the caller's own env
wins on a collision) so it rides docker exec -e KEY=VALUE, never a bare argv
token. In M4's default path the sandbox does not even receive a push credential —
the app pushes host-side — so STET_GIT_CREDENTIAL is injected only if/when a
workflow needs in-container fetch, and even then it is repo-scoped + short-TTL. Per
backend: GitHub fine-grained PAT / installation token — DELETE on revoke, 404
treated as success; Gitea scoped token — DELETE, 404 no-ops; the M4-gated
fallback provider (a short-TTL wrapper derived from the app token) has a
revoke() that is a logged no-op.
Dispatch-image pin + refresh
(Resolves the former "Image pin refresh + cache/pull overhead" Open Question, 2026-07-11.)
The dispatch image is Stet-maintained: docker/dispatch/Dockerfile bakes git +
Python 3.12 + the version-pinned OpenHands CLI into ghcr.io/joestump/stet-openhands
(the upstream OpenHands image is the app/server, not a headless CLI environment —
live bring-up ground truth, issue #36). .github/workflows/dispatch-image.yml builds
it for linux/arm64 (+ amd64) and pushes it whenever the image sources change. The
tags it pushes (latest, sha-<commit>) exist for humans browsing the registry;
Stet never dispatches a tag.
The pin. DispatchModel.defaultImage holds the full @sha256: digest reference,
and DockerRuntime.createSandbox runs exactly the reference it is handed — so a
moved tag, upstream or in our own registry, can never swap the harness underneath a
dispatch.
The refresh. Bumping the pin is an ordinary reviewed dependency change (the ADR-0017 posture — dependency changes land via the PR ceremony):
- Change the image source in a PR (e.g.
OPENHANDS_VERSIONin the Dockerfile); merging triggers the publish workflow. - The workflow's job summary prints the new digest-pinned reference verbatim.
- A follow-up one-line PR swaps
DispatchModel.defaultImageto that reference. Reviewing that diff is the supply-chain gate, and git history becomes the audit trail of every harness image the app has ever dispatched.
Deliberately, no automation moves the pin — a bot-bumped digest nobody read would reintroduce exactly the silent-swap risk pinning exists to close.
Build-time inputs (accepted residual). The dispatch-time pin guarantees the app
runs exactly the bytes that were reviewed and adopted — but the build consumes
inputs of its own. The Dockerfile's FROM is pinned by digest (bumped via the same
reviewed-PR ceremony), while apt package versions remain unpinned: a rebuild may
resolve newer Debian packages with no diff to review. Accepted for M4, because what
dispatches run is always the adopted digest, never an unreviewed rebuild.
Pull auth. The Dockerfile's org.opencontainers.image.source label ties the ghcr
package to the repo, so while the repo is private the package is too — the host's
Docker daemon must be logged in to ghcr.io with a read:packages-capable token
before the first pull (e.g. gh auth token | docker login ghcr.io -u <user> --password-stdin). An unauthenticated pull fails loudly at provisioning as
denied/unauthorized (not "manifest unknown"). Making the package public after
first publish would remove this prerequisite; until that call is made, docker login ghcr.io is a documented live-dispatch prerequisite alongside STET_DISPATCH_LIVE.
Cache/pull overhead (accepted). The first dispatch after a bump pays a one-time
docker pull of the new image; the daemon caches by digest, so every subsequent
dispatch starts from cache. No preheating for M4 — the pull happens inside the
provisioning phase, visible in the theater, and that first-run latency is accepted.
Consequences
- Good, because the most catastrophic failure (primary token exfiltrated from a transcript, commit, or process list) is designed out: the token is never in the container and never in argv, and transcripts are sanitized.
- Good, because the security-critical logic is concentrated in named, fake-testable
seams (
BranchValidator,TokenSanitizer,ScopedCredential.asEnvironment,DispatchSandbox.teardown) rather than scattered across the orchestrator. - Good, because cleanup is guaranteed on every exit path and cannot be shadowed by a teardown error, with idempotent revoke + orphan sweep as backstops.
- Bad, because app-mediated push is more moving parts than ambient authority (a scoped mint, a host-side push seam, a resolveHead round-trip for existing PRs).
- Bad, because we accept bounded, not absolute, mitigations for GitHub branch-scope, model-endpoint trust, and perfect cleanup (residuals below).
- Neutral, because Docker kernel isolation — not a custom syscall sandbox — is the M4 baseline; hardening beyond it is deferred (Open Questions).
Confirmation
- No code path places the primary forge token inside a container;
GitHubForge.pushpasses only the scoped credential (asserted byGitHubForgeDispatchTests). - Scanning every
SandboxProcess.commanda dispatch issued never finds the credential value (asserted byDispatchServiceTests/DispatchSandboxTests). BranchValidatorrejects../evil,main, base-branch collision, and malformed SHAs before any push (asserted byBranchValidatorTests).createSandboxmounts no Docker socket and defaults--network none; caps come fromDispatchLimits(asserted byDockerRuntimeTests).- Teardown + revoke run exactly once on happy path, harness-throw, cancel, and timeout
(asserted by
DispatchServiceTests). - Every
DispatchErrordescription is credential-free by construction (payloads are pre-sanitized strings, never live errors or tokens).
Accepted residual risks
These are knowingly accepted for M4, each bounded by a compensating control and tracked for a future revisit.
- GitHub cannot scope a token to a single branch. Branch-narrowness is bounded by
(a) short TTL, (b) immediate
revoke()after push, and (c) the app — not the sandbox — doing the push, so the sandbox holds no push credential in the default path. Accepted; the confirmed scope + TTL are to be recorded from the gated-live mint/revoke run. - Model-endpoint trust. The user-configured
OPENAI_BASE_URLsees the brief and the code the agent sends it. Sanitizing that traffic is out of scope — the endpoint (self-hosted LiteLLM) is trusted by configuration. Accepted. - Perfect cleanup is not guaranteed. A wedged Docker daemon or a host crash
mid-teardown can strand a container or a not-yet-expired credential. Bounded by
idempotent
revoke(), short TTL, and launch-timesweepOrphans; residual leakage is possible and accepted. - Cost accounting is best-effort. The
costCeilingUSDguardrail depends on the endpoint surfacing token usage in a shape the harness can parse; if it does not, the guardrail degrades to best-effort and the meter shows no data. Accepted. - Docker kernel isolation is the only sandbox boundary. No custom syscall filtering for M4. Accepted as the ADR-0003 baseline; see Open Questions.
Open Questions
- OpenHands CLI/JSONL stability. The real headless entrypoint, its
events.jsonl, andresult.jsonschema are the primary gated-live unknown; version drift can change the format after pinning. The harness adapter isolates parsing/normalization so onlyOpenHandsHarnesschanges if the schema moves. - Network posture at the model endpoint. If the LiteLLM endpoint is off-host and
unproxyable,
--network nonemust relax; host-proxy vs. a single narrow egress is a decision to record at gated-live time (widening the escape surface either way). - In-container runtime assumption. Running OpenHands headless inside our single container (not docker-in-docker) assumes the CLI supports a local runtime; if it insists on spawning its own runtime container, the single-boundary teardown model needs revisiting.
- Per-agent resource limits.
DispatchLimitsare hardcoded defaults, not per-agent and not part ofAgentGuardrails; if per-agent resource control is later required, a new guardrails field plusDockerRuntimeflag construction and its tests must change. - Hardening beyond kernel isolation. Whether a future milestone adds syscall filtering / a stricter profile on top of the container, or moves to the Apple Containerization backend (ADR-0003) whose per-container VM changes the escape surface.
More Information
Governs SPEC-0003 (agent dispatch). Builds on ADR-0003 (the sandbox this hardens),
ADR-0004 (the harness that runs inside it), ADR-0007 (the forge token this keeps out
of the container, and the branch-scoped-credential promise this bounds), and ADR-0016
(the agent identity a scoped credential attaches to). The concrete threat-model
enforcement, the state machine it protects, and the human-gated end-to-end plan are
detailed in the agent-dispatch design.
The M4 implementation design that this ADR was extracted from is
docs/design/m4-locked-design.md.