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

  1. Content enters once. A clean agent checkout receives the target commit and non-secret startup state, with no link back to runner Git metadata.
  2. One supervisor owns the lifetime. Setup and the agent run in the same outer SRT process boundary, then every descendant is reaped.
  3. Authority enters through named proxies. Real credentials stay runner-side; the sandbox receives only the protocol it needs.
  4. 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

The objects are named by owner and lifecycle, not by where they happen to sit.
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

  1. 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. Run sandbox_setup and each complete harness under one supervisor. Verify that writable home/project config, PATH, agent arguments, and Codex EXTRA_ARGS cannot weaken the launcher; every SRT dependency warning is fatal; absent or ineffective seccomp stops before setup.
  2. 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-visible GITHUB_WORKSPACE to Wₐ; GitHub’s runner context remains Wᵣ. The supervisor owns proxies, cancellation, quiescence proof, and B4 collectors.
  3. 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 into sandbox_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.

  1. P1 · Whole-process enforcement. Codex profiles exclude integrations; B2 now uses one outer SRT lifetime for setup and either harness.
  2. 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.
  3. 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.
  4. P1 · Failure transitions. Setup is explicit, and every exit after launch routes through one supervisor that stops proxies and proves quiescence.
  5. 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.
  6. P2 · Dynamic exports. B4 now separates inert live streams, fixed precreated entries, and bounded descriptor-relative tree collection.
  7. P2 · Execution context. The contract now sets both cwd and agent-visible GITHUB_WORKSPACE to Wₐ and defines event-specific Git topology.
  8. P2 · Git usability. The policy explicitly permits disposable Git metadata and tests commits, remotes, switching, and upstream pushes.
  9. P2 · Ontology. The model now separates principals, resources, and ingress/egress channels; the invariant forbids privileged execution or unchecked dereference rather than all reads.
  10. P2 · Effective SRT policy. Implicit writable scratch, degraded seccomp startup, Unix-socket behavior, and mandatory Git-hook denial are explicit contract and probe inputs.
  11. 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.
  12. 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