0039: Tell the child's sandbox what the host already decided
Status: accepted (2026-07-25) · Scope: product tier (core/workflow spawn adapters, core/adapters profiles, workflow proof)
Context
Section titled “Context”Harnery decides where a workflow child may write. WorkspaceAllocationRequest
carries writable_roots, the local Git provider validates containment against
them, and the workspace lifecycle refuses anything outside them
(ADR 0034).
It then never tells the child.
SpawnRequest carries a prompt, a model, an effort, a timeout, a turn ceiling,
a working directory, a run id, and a billing mode. It carries no filesystem
policy of any kind, so no adapter can express one. The vendor CLI applies a
sandbox of its own, chosen by the adapter’s hardcoded flags, and the two
policies never meet.
The consequence is concrete. A child working in a provider-owned Git worktree can edit files and cannot commit:
fatal: Unable to create '.../.git/worktrees/wt/index.lock': Read-only file systemThe obvious reading is that the Git administrative directory sits outside the
worktree, so relocating it should help. It does not. The same child in a full
clone, with .git directly inside its working directory, fails identically:
fatal: Unable to create '.git/index.lock': Read-only file systemThe vendor excludes the repository’s administrative directory from its writable set by policy, not by path. No repository topology fixes this, because topology is not what is being enforced.
Naming the directory does fix it. The same run, with that one path declared as a writable root, commits successfully. Harnery already knows the path: the provider resolves it. The missing piece is a way to say so.
Corrected. This ADR originally said the projection gave the blanket
policyMapping: unsupported claim its first real content. It does not:
policyMapping is about ALLOW/DENY/ASK translation, which is a different fact.
ADR 0041 gives projection
its own capability claim and leaves policyMapping alone.
Decision
Section titled “Decision”Add an optional filesystem policy to a spawn request, and a declared, per-adapter projection that renders it into vendor-native form or refuses.
The request
Section titled “The request”SpawnRequest gains an optional filesystemPolicy:
mode:read-onlyorworkspace-write.writableRoots: explicit absolute paths the child may write.
Absent means today’s behaviour, byte for byte. Shared-checkout runs, which are a first-class mode, are unchanged and unaffected.
The writable set is declared, not derived from the working directory. The reproduction is the argument: the path that needed writing was not inside the working directory in one topology, and was excluded regardless in the other.
The declaration
Section titled “The declaration”Each AdapterProfile declares what it can represent, per mode, and may declare
that it cannot represent something at all. A null entry means “this adapter
does not distinguish that”, which is a fact about the adapter, not a defect to
paper over.
As shipped:
| Adapter | read-only |
workspace-write |
Writable roots |
|---|---|---|---|
codex |
--sandbox read-only |
--sandbox workspace-write |
sandbox_workspace_write.writable_roots |
claude-code |
not representable | not representable | not representable |
cursor |
not representable | not representable | not representable |
Only one of three adapters can carry this today. That asymmetry is the point of declaring it rather than assuming it.
full-access is deliberately absent from the mode set. Harnery does not project
a no-sandbox mode into a vendor CLI, even where the vendor offers one.
Failing closed
Section titled “Failing closed”A projection an adapter cannot represent is an error before launch, never a silent downgrade. There is no fallback to a laxer mode at any layer.
This is the load-bearing rule. Passing a policy that the adapter quietly drops would leave an operator believing a child was constrained when it was not, which is worse than refusing, and worse than never offering the feature. Harnery already refuses a Cursor effort request it cannot express; this applies the same rule to filesystem policy.
Granting the Git directory
Section titled “Granting the Git directory”Write access to a repository’s administrative directory is the power to rewrite history. It is granted only when the run’s contract expects the child to commit, never as a convenience default.
Two guarantees back that grant:
- Containment. The engine validates every requested writable root against the root the workspace provider already validated, once, before the first child launches. A path outside it fails the run. A policy with writable roots and no isolated workspace also fails, because there is no validated root to check against. Containment lives at the engine seam rather than in the renderer, which never sees the binding.
- Auditability. The applied projection is recorded in run proof as
sandbox_projection, so a completed run states exactly what its children were permitted to write. Absent means no projection was applied.
Superseded in part. This ADR said a caller passes the administrative directory explicitly. The containment rule above makes that impossible: in a linked worktree the directory lies outside the workspace root, so containment refuses it. ADR 0040 replaces the caller-supplied path with a named grant resolved from the binding, and records the measurement showing that a commit needs the shared half of the directory rather than the worktree’s private one.
Alternatives
Section titled “Alternatives”- Build a full-clone workspace provider. Rejected on evidence. The reproduction shows a full clone fails identically, because the exclusion is by policy rather than by path. This was the previously recorded remedy and would not have worked.
- Provider-owned commit finalization, where the host commits on the child’s behalf. Rejected as a default. It moves commit authority away from the agent that made the change, and adds a second actor to every run, to route around a restriction that one declared path lifts. It remains available if a future adapter can neither commit nor accept a projection.
- Derive the writable set from the working directory. Rejected. The directory that needs writing is routinely outside it.
- Project only a mode, no paths. Rejected on evidence. The correct mode was
already being passed and was insufficient;
workspace-writestill blocked the Git directory. - Fall back to the vendor default when an adapter cannot represent a projection. Rejected. That converts an unenforced policy into an apparent guarantee.
- Have Harnery supply its own sandbox instead of the vendor’s. Rejected. Harnery drives official vendor CLIs under the user’s own stored login; substituting our own confinement would mean working around the adapter rather than through it.
- Grant the Git directory by default whenever a workspace is provider-owned. Rejected. Most children do not need to commit, and the grant is the power to rewrite history.
Verification
Section titled “Verification”- Unit tests cover rendering per adapter, refusal when a mode or a writable set is undeclarable, refusal of a relative or non-string path, and argv remaining unchanged when no policy is supplied.
- The reproduction was closed end to end against the real vendor CLI: a child spawned through the production adapter into a provider-owned worktree, given the Git administrative directory as a writable root, committed successfully. That check spends vendor tokens, so it is a recorded live verification rather than an automated test; the argv it depends on is covered by unit tests.
- Containment tests cover a path inside the root, the root itself, a trailing
slash, a path outside, a sibling sharing a prefix (
/srv/wsXis not inside/srv/ws), one bad path refusing a whole set, and an empty set. - Proof tests cover a recorded projection and its absence.
- Negative tests assert that a refusal never falls back to a laxer mode, and that an adapter which declares a projection unrepresentable throws instead of dropping it.
- The capability claim for this behaviour is
filesystemPolicyProjection, notpolicyMapping, and it reaches anattestedbasis only from a live observation that an adapter’s declared sandbox is actually enforced (ADR 0041), never from the declaration alone.