implemented security case study · 7 September 2026
Make the agent workspace disposable
Tend keeps GitHub Actions’ workspace runner-owned and gives the agent a
disposable checkout under /tmp. Setup and the complete Claude or
Codex turn share one sandbox lifetime. The invariant is:
runner authority never executes or follows an unchecked agent-controlled
path.
Vendor permission modes can add defense in depth; Tend relies on the outer
process boundary.
Before: workspace ownership crossed phases
Before the fix, Tend separated the agent’s UID and credentials from the runner
but not its durable filesystem state. setup_sandbox.py chowned
GITHUB_WORKSPACE to the agent, and each harness later chowned it
back. GitHub then ran ordinary workflow steps and registered action POST
handlers against that same tree. Restoring local action directories repaired
one known consumer while Git, PATH entries, language startup files, action
manifests, and setup-generated shims remained open paths into runner work.
flowchart LR R0[Runner checkout] --> R1[Runner setup] R1 --> A1[Same tree handed to agent] A1 --> R2[Same tree returned to runner] R2 --> P1[Runner tail and action POSTs] classDef danger fill:var(--danger-tint),stroke:var(--danger),color:var(--danger-ink) class A1,R2,P1 danger
Four contracts define the boundary
- Content enters once. A clean agent checkout receives the target commit and non-secret startup state, with no link back to runner Git metadata.
- One supervisor owns the lifetime. Setup and the agent run in the same outer SRT process boundary, then every descendant is reaped.
- Authority enters through named proxies. Real credentials stay runner-side; the sandbox receives only the protocol it needs.
- Data leaves through bounded collectors. Live output is inert, fixed files are precreated, and dynamic trees are validated without following agent-selected paths.
Full architecture, lifecycle, migration, and adversarial proof
Two principals, protected and disposable resources, explicit channels
| Object | Owner | Agent access | Runner use after launch |
|---|---|---|---|
Wᵣ — runner workspace |
Runner principal | Denied | Workflow steps and action POSTs; trusted POSTs may mutate it |
Wₐ — agent checkout |
Sandbox UID | Read/write | Never execute; delete before the trusted tail |
Hₐ — agent home |
Sandbox UID | Read/write | Validated data collection only |
Tₐ — runtime scratch |
Sandbox UID | Read/write | Never read by runner; discarded with VM |
E — export sinks |
Runner-opened streams and a runner-owned directory containing precreated agent-writable files | Write bytes to inherited descriptors or file contents, not directory entries | Bounded, no-follow reads after reap |
P — credential proxies |
Runner principal | One authenticated protocol endpoint | Stopped by the supervisor or fixed teardown on every exit |
There are two execution principals: the runner control plane and the
untrusted agent process tree. Wᵣ, workflow command files,
action cache, proxy state, and long-lived credentials are protected runner
resources; Wₐ, Hₐ, and Tₐ are
disposable agent resources. Tₐ accounts explicitly for SRT’s
built-in writable /tmp/claude and home-relative debug/npm
paths; the launcher fixes the sandbox UID and HOME=Hₐ, and no
trusted consumer uses those paths. B1 owns trusted initial code/config
provisioning, B2 owns contained execution, B3 owns credential grants and
network brokerage, and B4 owns results exported to trusted runner consumers.
These are responsibilities, not four physically disjoint pipes: dependencies
and model responses travel through B3, and subscription authority is
deliberately staged in Hₐ. Persistent auto-memory remains
untrusted data for a future agent. Any other host service or Unix socket is
a new B3 gateway; none is admitted initially.
The job has an irreversible handoff, not a hand-back
stateDiagram-v2 [*] --> S0 state "S0 · Gate passed" as S0 state "S1 · Wᵣ prepared" as S1 state "S2 · Wₐ imported" as S2 state "S3 · sandbox_setup inside SRT" as S3 state "S4 · Agent inside same SRT" as S4 state "S5 · Finalizer running" as S5 state "S6 · Quiescent; outputs validated or skipped" as S6 state "S7 · Fixed cleanup, trusted tail + POST" as S7 state "Fail closed" as F S0 --> S1: checkout base/default + setup S1 --> S2: import exact target into a new checkout S2 --> S3: trusted launcher starts supervisor + SRT S3 --> S4: setup succeeds S4 --> S5: exit, failure, cancel, or timeout S5 --> S6: process tree reaped S6 --> S7: bounded data accepted, or collection skipped S7 --> [*]: runner VM discarded S0 --> F: gate or checkout failure S1 --> F: import failure S2 --> F: preflight rejects before resource allocation S2 --> S5: initialization or launch fails after allocation S3 --> S5: setup failure or cancellation S5 --> F: cannot prove process-tree termination S6 --> F: export validation failure F --> S7: report failure without agent-tree reads
| State | Invariant that becomes true | What cannot happen next |
|---|---|---|
| S1 |
Local actions are loaded from Wᵣ; their POST paths remain
stable.
|
No PR/head checkout replaces Wᵣ. |
| S2 |
Wₐ is an independent Git directory at the exact target
commit; startup-sensitive files are pinned from base; persisted
checkout credentials are absent.
|
No filesystem link from Wₐ/.git reaches
Wᵣ/.git.
|
| S3 |
A runner-owned absolute launcher has entered one outer SRT policy; cwd
and agent-visible GITHUB_WORKSPACE are Wₐ.
|
Setup cannot select or replace its enforcement executable, policy, or bootstrap PATH. |
| S4 | Setup and agent share the same SRT namespace, filesystem grants, supervisor, and explicit environment. |
No process in that lifetime can reach Wᵣ, runner home,
command files, host sockets, or direct network.
|
| S5 | The supervisor waits for SRT and terminates every remaining process owned by the sandbox UID after setup/agent success, failure, cancellation, or timeout. | No normal or error return bypasses cleanup. |
| S6 | Quiescence is proved before bounded collection. Invalid exports are skipped and fail the action; failed quiescence skips every agent-tree read. | No writer can race collection, and no agent-selected path is executed or unchecked-dereferenced with runner authority. |
| S7 |
Fixed runner steps stop the remaining proxy, delete
Wₐ and its runtime, and restore temporary host policy.
The runner resumes on Wᵣ, which the agent never accessed.
|
POST cleanup cannot observe the disposable agent tree. |
Put policy at four bottlenecks
| Bottleneck | Single responsibility | Enforced fact |
|---|---|---|
| B1 · Content ingress | Materialize all non-secret agent-readable startup state: target code, Tend prompt/plugin assets, project instructions, and harness config. | Independent Git metadata; exact event-specific commit and branch/upstream topology; no credential; sensitive project startup files pinned from the trusted base; no runner path referenced after launch. |
| B2 · Launch and lifetime |
A trusted supervisor starts sandbox_setup and one harness
inside a single outer SRT process boundary, then reaps it on every
exit.
|
Runner-owned absolute runtime, bwrap, socat,
seccomp-helper, and policy paths; fixed sandbox UID/HOME; sanitized
bootstrap PATH; explicit payload environment; every dependency warning
is fatal; no host sockets; no direct network; one process lifetime.
|
| B3 · Authority ingress | Translate dummy PAT/API credentials into authenticated requests. |
PAT and API keys never enter the untrusted domain. Codex subscription
mode is the one explicit exception: an access-only token enters
Hₐ; its refresh token remains runner-only. Trusted tail
steps may separately receive runner credentials.
|
| B4 · Data egress | Convert agent bytes into runner-owned data without treating paths or workflow syntax as authority. | Live stdout/stderr are escaped as inert log data through inherited descriptors. Fixed outputs use precreated nonreplaceable entries. Dynamic logs and memory use descriptor-relative traversal that validates every ancestor and inode, with type, link-count, file-count, and total-byte bounds. |
These four are exhaustive by direction and kind: static bytes enter at B1; an untrusted process lifetime begins and ends at B2; privileged effects enter at B3; bytes leave at B4. Every proposed exception must name which one it extends. A cache copied into the sandbox is B1, a service socket is B3, and a session-log directory is B4. “Security-related” code elsewhere is suspect unless it implements one of these contracts.
GitHub’s POST chain is not a fifth security mechanism. It is simply a
consumer that remains on the runner side of B1. With
Wᵣ unchanged, Tend can delete the generated “restore local
setup actions” step rather than expanding it into a larger list of things to
repair.
One outer sandbox is the enforcement seam for both harnesses
| Harness | Use | Required posture | Why this is still not enough alone |
|---|---|---|---|
| Codex |
SRT wraps the supervisor payload containing setup and the complete
codex exec process.
|
Remove Tend’s legacy --sandbox surface; do not claim
Codex permission profiles as the boundary because their documented
scope excludes MCP processes and client-service traffic. Inner
execution may be unrestricted relative to the outer boundary.
|
One outer process boundary covers integrations and pre-agent setup. Codex’s own permissions can remain a UX safeguard only if they do not change the tested contract. |
| Claude Code |
The same SRT launcher wraps setup and the complete
claude -p process.
|
Do not rely on Claude’s Bash-only built-in sandbox or its
allowUnsandboxedCommands setting as the outer enforcement
switch. No command exclusion may escape the SRT process.
|
The shared adapter gives both harnesses one filesystem/network/process contract while leaving model-specific invocation inside the boundary. |
This leans on an existing generic sandbox wrapper and keeps Tend out of the bubblewrap/AppArmor implementation. Tend owns the smaller cross-harness contract: a trusted absolute launcher, the SRT policy, the disposable checkout, the supervised lifetime, and the ingress/egress schemas. The selected outer boundary makes Codex’s internal Linux enforcement details irrelevant.
Setup becomes a clean ownership seam
| Config surface | Runs where | May produce | May not assume |
|---|---|---|---|
setup: |
Runner, with Wᵣ at base/default |
System toolchains, caches, and runner-only environment; local actions and their POSTs are supported. Services remain runner-only. |
Files under Wᵣ or runner home, or host sockets/services,
will be visible to the agent.
|
sandbox_setup: |
Same vendor sandbox and cwd as the agent |
Dependencies, generated files, and tools under
Wₐ/Hₐ.
|
Sudo, runner home, Wᵣ, or real credentials. |
sandbox_path/sandbox_env |
Validated once at B2 | Explicit agent PATH and non-secret environment additions. |
References into Wᵣ, runner home, or GitHub command-file
paths.
|
The current public-consumer audit found two local
setup: composites (PRQL and Worktrunk), which remain supported,
and two workspace-warming setup commands (uv sync in Leaf and
numbagg), which move to sandbox_setup:. Worktrunk’s one
deliberate copy from runner home should be replaced in that
repository—install the two tools inside Hₐ or
Wₐ—rather than adding a general runner-to-agent artifact
bridge. Worktrunk’s multi-user Nix daemon is a separate authority gateway:
its socket hands work to nixbld processes outside Tend’s
supervised tree. The initial cutover denies it and migrates that consumer to
a daemonless/single-user installation inside the agent boundary. If that
proves impossible, a constrained Nix broker is a new design decision, not a
PATH fix.
The proposal cut the work into three slices
-
Prove one shared outer adapter. On
ubuntu-24.04, invoke SRT’s library API from a runner-owned absolute path with runner-owned policy,bwrap,socat, and seccomp-helper paths plus a sanitized bootstrap PATH. Runsandbox_setupand each complete harness under one supervisor. Verify that writable home/project config, PATH, agent arguments, and CodexEXTRA_ARGScannot weaken the launcher; every SRT dependency warning is fatal; absent or ineffective seccomp stops before setup. -
Introduce the lifecycle and schemas. A tested Python step
creates a non-hardlinked, no-alternates clone in
RUNNER_TEMP, recreates the exact event-specific commit and branch/upstream semantics, strips credentials, and pins startup-sensitive files from base. It sets subprocess cwd and agent-visibleGITHUB_WORKSPACEtoWₐ; GitHub’s runner context remainsWᵣ. The supervisor owns proxies, cancellation, quiescence proof, and B4 collectors. -
Change generation and consumers, then delete repairs.
Review and mention resolve targets without replacing
Wᵣ. Remove the second workspace checkout,gh pr checkout,clean: false, local-action restore generation, recursive workspace chown/hand-back, legacy Codex sandbox flags, and PATH translation that admits workspace or runner-home paths. Move Leaf/numbagg warming intosandbox_setup; replace Worktrunk’s runner-home copies and multi-user Nix dependency.
Acceptance is end-to-end and adversarial
| Test | Attack or failure | Required observation |
|---|---|---|
| POST sentinel |
A local setup composite registers a POST and the agent replaces its
manifest, nested action, git, and every workspace PATH
shim.
|
The original POST runs from Wᵣ; every sentinel in
Wᵣ is unchanged.
|
| Git separation and usability |
The agent rewrites config, refs, remotes, and objects under
Wₐ/.git, then commits, switches branches, creates a
remote, and performs push -u; it separately attempts to
create a hook and switches across a commit changing SRT-protected
files.
|
Intended Git operations work in Wₐ; hook creation remains
denied by SRT and is not initially supported. Protected tracked files
still switch correctly or identify an upstream SRT limitation.
Wᵣ/.git remains independent. The policy enables
disposable .git/config through
allowGitConfig.
|
| Import topology | Review merge refs, same-repo and fork PR heads, conflicting-PR fallback, open mention PRs, and closed mentions are each imported. | Commit identity and push/upstream behavior match today’s workflow without a linked worktree, alternates, or local-clone hardlinks. |
| Export confusion | The agent replaces every result/log/memory path and every ancestor with symlinks, FIFOs, devices, oversized files, hard links, and atomic renames. | The descriptor-relative collector rejects each shape without following, hanging, or exceeding file-count/total-byte caps; live output cannot emit GitHub workflow commands. |
| Process race | Setup or agent forks background writers and the job is cancelled during initialization, setup, launch, execution, or export. |
Any failure after resource allocation enters the one finalizer.
Verified quiescence gates agent-tree collection; failed proof skips
collection, while trusted tail and registered POSTs may proceed safely
because
Wᵣ was never exposed.
|
| Boundary override |
The agent plants bwrap/srt on PATH and edits
home/project config, policy paths, CLI args, and Codex
EXTRA_ARGS.
|
The runner-owned launcher and policy remain selected; no legacy
--sandbox setting or fallback changes the outer boundary.
|
| Sandbox degradation |
SRT, bwrap, socat, the seccomp helper, its
AppArmor permission, or the policy is missing, malformed, or merely
warned about.
|
The run fails before sandbox_setup; a live negative
Unix-socket probe confirms seccomp rather than trusting dependency
discovery or normal startup.
|
| Authority denial | The agent tries direct network, runner-owned proxies, arbitrary localhost/Unix sockets, the Nix daemon, and runner process state. | Only the specified credential-proxy protocol works. PAT/API secrets remain outside; subscription mode exposes only the intended access token and never its refresh token. |
| Real consumer matrix | All eight known public adopter configs run representative workflows under both harnesses, including Playwright startup and a real Worktrunk Nix operation under the migrated design. | Tools remain reachable, local action POSTs succeed, pushes/comments work through the proxy, and no per-consumer exception enters Tend. |
The adapter probe and hosted integration tests subsequently exercised current Claude, Codex, and SRT behavior on GitHub-hosted Ubuntu 24.04, including the nested UID, TLS-intercepting proxy, headless command lines, Unix-socket denial, runner-checkout isolation, and complete teardown.
Rejected shapes
| Shape | Reason to reject |
|---|---|
| Shared checkout plus more denies/restores | Every new runner consumer creates another persistence path to discover and repair. The security property is a list, not an invariant. |
| Private same-path bind/COW mount | It can preserve absolute-path compatibility, but Tend would own mount construction, propagation, teardown, and overlay edge cases. Use it only if the clean setup contract proves impossible in real adopters. |
| No post-agent runner work | Tend cannot suppress GitHub’s registered action POST lifecycle, and failure reporting, artifact upload, notification state, and memory persistence are legitimate data-plane exits. Isolating their inputs is smaller than reimplementing them. |
Vendor sandbox alone on Wᵣ |
Both vendors intentionally persist authorized workspace writes. That confines the write while it happens; it does not constrain a later runner process that consumes the modified bytes. |
Astra review
Approved and landed: no P0/P1/P2 design or implementation findings remain. Astra completed two full adversarial passes plus a final verification. The first pass found ten contract gaps; the second found SRT-default assumptions and one partially initialized-launch transition. The proposal above incorporates them. The final pass reviewed the implementation, tests, and hosted probe evidence.
- P1 · Whole-process enforcement. Codex profiles exclude integrations; B2 now uses one outer SRT lifetime for setup and either harness.
- P1 · Immutable bootstrap. B2 now fixes runner-owned absolute runtime/policy paths, separates bootstrap from payload environment, removes legacy override surfaces, and tests hostile PATH/config/arguments.
- P1 · Host services. Runner services are no longer implicitly shared; the Worktrunk Nix daemon is identified as a separate forbidden gateway pending a daemonless migration.
- P1 · Failure transitions. Setup is explicit, and every exit after launch routes through one supervisor that stops proxies and proves quiescence.
- P1 · Credential exception. The universal secret claim now distinguishes PAT/API proxying from Codex’s intentionally staged access-only subscription token and runner-only refresh token.
- P2 · Dynamic exports. B4 now separates inert live streams, fixed precreated entries, and bounded descriptor-relative tree collection.
-
P2 · Execution context. The contract now sets both cwd
and agent-visible
GITHUB_WORKSPACEtoWₐand defines event-specific Git topology. - P2 · Git usability. The policy explicitly permits disposable Git metadata and tests commits, remotes, switching, and upstream pushes.
- P2 · Ontology. The model now separates principals, resources, and ingress/egress channels; the invariant forbids privileged execution or unchecked dereference rather than all reads.
- P2 · Effective SRT policy. Implicit writable scratch, degraded seccomp startup, Unix-socket behavior, and mandatory Git-hook denial are explicit contract and probe inputs.
- P2 · Partial launch cleanup. Resource allocation begins a cleanup obligation even when sandbox initialization fails before setup; only pre-allocation rejection may bypass the finalizer.
- P3 · Migration diagnostics. Direct errors are limited to explicit forbidden paths; implicit dependencies are documented and exercised in the consumer matrix.
Primary evidence and source links
Primary evidence
- The merged Tend implementation — the disposable workspace, shared SRT lifecycle, bounded exports, fixed teardown, tests, and review record.
-
OpenAI Codex permission profiles
— exact filesystem rules, documented exclusions for MCP/client-service
integrations, legacy
--sandboxoverride behavior, and the warning that persistent writes can be executed later outside the sandbox. - OpenAI Codex sandboxing — Linux bubblewrap behavior and Ubuntu 24.04 AppArmor setup.
-
OpenAI Codex CLI commands
— the separate
codex sandboxcommand is documented as Landlock + seccomp on Linux, which is why the native implementation detail is non-contractual here. - Anthropic Claude Code sandboxing — Bash-only built-in scope, SRT as a generic wrapper, fail-closed controls, and Linux bubblewrap behavior.
- Anthropic sandbox-runtime source and contract — library API, Linux bwrap/network-proxy architecture, mandatory Git denies, seccomp-helper warning behavior, and Unix-socket limitations.
-
GitHub
actions/checkoutcontract — persisted authentication and post-job cleanup. - GitHub runner action resolution and the local-composite POST reproducer — local action definitions are resolved again from the workspace filesystem.
- Pre-change Tend local-action restore, workspace handoff, and review checkout sequence.