Skip to content

Make /tmp writable inside the sandbox - #1324

Merged
max-sixty merged 3 commits into
mainfrom
sandbox-setup-from-actions
Sep 19, 2026
Merged

max-sixty merged 3 commits into
mainfrom
sandbox-setup-from-actions

Conversation

@max-sixty

@max-sixty max-sixty commented Sep 19, 2026 •

Copy link
Copy Markdown
Owner

SRT read-only-binds the whole root and rw-binds only what tend lists in allowWrite, which never included /tmp. Nothing protects on that — it was simply never listed — so every tool that hard-codes a path under /tmp has needed a workaround of its own: a TMPDIR override (#1199), a zsh TMPPREFIX override (#1211), skill text warning sessions off it (#1244), and now NuGet's build mutex (#1312), which no environment variable moves and which a consumer is working around per-repo. This fixes the layout instead, so the family ends.

/tmp is writable inside the sandbox now. The two runner-owned containers — the per-run SRT runtime and the disposable event checkout — move to /var/tmp, which is 1777 like /tmp but outside the write policy, so from inside they stay exactly as unwritable as before. The Claude auto-memory working copy moves with them, since the dispose step runs before the save that reads it. After the reap, that step also removes the top-level /tmp entries the sandbox uid owns: /tmp is sticky, so ownership is the whole test, and every runner-owned entry beside them stays. Reading a directory the whole runner shares races it, so an entry that disappears between the listing and the stat is treated as the gone thing it is rather than failing an always() step and filing a tend-outage issue over someone else's temp file. Ownership separates the two at the top level alone, which the security model now says.

Deleted with the constraint that produced them: the TMPPREFIX override and its reserved sandbox_env key, and the repo-wide test banning bare /tmp paths in shipped instructions. run-tend's temp-files section is down to the one fact a session still needs — $TMPDIR is /home/tend-sandbox/tmp. TMPDIR and AGENT_TMP_DIR themselves stay: the supervisor reads the step summary from that directory, and the shipped skills name $TMPDIR throughout. So does the CLAUDE_CODE_TMPDIR override, for a reason the comment now states — with filesystem policy on, SRT overwrites TMPDIR in the child environment with its own /tmp/claude default unless handed a path.

The check that matters is test-sandbox, which drives the real boundary on a hosted Ubuntu runner. Its probe for an unwritable path now aims at /var/tmp rather than at the 755 container directories, where a non-root uid would be refused on file permissions alone and the assertion would hold whether or not the boundary was still there. A runner-owned file planted before the lifecycle covers the other half: the sandbox must fail to unlink it — the sticky bit is all that protects runner state in the shared directory while the agent runs — and a new step then runs the real dispose against the real filesystem, where sandbox-written scratch goes and that file stays.

Closes #1312

This was written by Claude Code on behalf of max-sixty

🤖 Generated with Claude Code

SRT read-only-binds the whole root and rw-binds only what tend lists in
allowWrite, which never included /tmp. Every tool that hard-codes a path
under it therefore needed a workaround of its own: a TMPDIR override, a zsh
TMPPREFIX override, skill text warning sessions off it, and most recently
NuGet's build mutex, which no environment variable moves.

/tmp is writable now. Tend's two runner-owned containers — the per-run SRT
runtime and the disposable event checkout — move to /var/tmp, which is 1777
like /tmp but outside the write policy, so they stay as unwritable as they
were; the Claude auto-memory working copy moves with them, since the dispose
step runs before the save that reads it. After the reap that step also
removes the top-level /tmp entries the sandbox uid owns, so nothing
sandbox-written is left where a later runner step or a setup: action's POST
step would read it.

The TMPPREFIX override goes with the constraint that produced it, along with
its reserved sandbox_env key and the repo-wide ban on bare /tmp paths in
shipped instructions.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

@tend-agent tend-agent left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

#1313 is open against the same two tend.example.yaml files and states the opposite of this change: "/tmp itself is read-only there and no config key makes it writable", followed by the .NET EROFS walkthrough and an unshare --mount recipe for working around it. That PR's premise is #1312, which this one closes at the source. If both land, every consumer's example config ships a false claim about the sandbox write set plus a workaround for a failure that no longer happens — and the unshare recipe is the more expensive half, since a consumer who copies it wraps every build command in a mount namespace for nothing. The AF_UNIX/-m:1 MSBuild half of #1313 is independent of the write policy and still holds, so the two PRs aren't wholly redundant; it's the /tmp paragraphs that this supersedes.

The other point is on the new verify_srt probes, inline below.

Comment thread proxy/test-setup-sandbox.sh Outdated
Comment thread proxy/test-setup-sandbox.sh
The security model now says the sandbox can add entries to /tmp but cannot
unlink or rename a runner-owned one beside them. That sticky bit is all that
protects runner state in the shared directory while the agent runs, and
nothing exercised it: the dispose test plants its runner-owned file after the
sandbox is already gone, so it covers the sweep's ownership filter instead.

The file is planted before the lifecycle now, the sandbox tries to unlink it
from inside, and dispose still has to leave it. A lost fixture fails the
probe the same way a lost sticky bit does, since rm -f returns 0 on a path
that is not there.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

@tend-agent tend-agent left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Two things on the dispose sweep, which is what the widened /tmp rests on.

A /tmp entry vanishing mid-sweep fails the job. scratch_entries has to lstat every top-level entry in a shared world-writable directory, not just the sandbox's own, and FileNotFoundError lands in main's (OSError, subprocess.CalledProcessError, ValueError) handler. Any process still live on the runner that removes a temp file between iterdir() and lstat() — a background service a setup: step left up, a browser clearing its /tmp/.org.chromium.* — turns the always() dispose step red after the agent has already posted its work, and Report failure then files a tend-outage issue naming someone else's temp file. Suggestion inline; happy to push it instead if you'd rather.

The sweep is top-level only, so the docs/security-model.md guarantee is narrower than it reads. "the agent's scratch is gone before a later step or a setup: action's POST step could read it" holds for entries directly in /tmp. A runner-owned directory there that the sandbox can write — one a setup: step created group- or world-writable — keeps whatever the sandbox put inside it, since the directory entry itself is runner-owned and stays. Either walk the tree for sandbox-owned files, or scope the sentence to /tmp's own entries so the threat model doesn't promise more than ownership-at-the-top-level delivers.

Comment thread shared/steps/dispose_sandbox_resources.py Outdated
The sweep lstats every top-level entry of a directory the whole runner
shares, so a temp file another process removes between the listing and the
stat raised FileNotFoundError and failed the step. The dispose step runs
under always(), after the agent has already posted its work, and a failure
there files a tend-outage issue — over someone else's temp file. An entry
that is already gone is the outcome the sweep wants, so skip it.

The security model now also says where ownership stops separating the
sandbox's scratch from the runner's: at /tmp's top level. What the agent
wrote inside a directory the runner or a setup: action already owned there
survives the sweep, and walking those trees to find it would be a larger
mechanism than the one this protects.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@max-sixty
max-sixty merged commit 31f5524 into main Sep 19, 2026
12 checks passed
@max-sixty
max-sixty deleted the sandbox-setup-from-actions branch September 19, 2026 20:13
tend-agent added a commit that referenced this pull request Sep 19, 2026
#1324 added /tmp to the sandbox's allowWrite set, so the write-set
paragraph and the unshare mount-namespace recipe now describe a
constraint that no longer exists. The AF_UNIX paragraph is independent
of the write policy and still holds, so it stays.
max-sixty pushed a commit that referenced this pull request Sep 20, 2026
…ple.yaml (#1313)

Adds one paragraph to the two mirrored `tend.example.yaml` files
recording that the sandbox refuses `AF_UNIX` sockets, and what that
costs a build tool. Creating one returns `EPERM`; `socketpair` still
works, so a process and its own children are unaffected and only
cross-process Unix-socket IPC is lost. MSBuild loses its workers to this
and says nothing useful — a multi-project `dotnet build` prints `Build
FAILED.` with `0 Error(s)` and no diagnostic, which `-m:1` turns back
into real errors. A consumer hitting that has no way to reach the
explanation from the symptom, which is what the paragraph is for.

This is a durable property rather than a knob the config can relax:
[`probe_boundary()`](https://github.com/max-sixty/tend/blob/31f5524104d1859efa1b058d9845f27954b27b72/shared/steps/agent_lifecycle.py#L14-L21)
fails the run if an `AF_UNIX` socket *can* be created, so it is the
liveness check for seccomp. Documenting it is therefore the whole fix.

**Scope narrowed since this was opened.** The PR also documented a
read-only `/tmp` and carried an `unshare --map-root-user --mount` recipe
for working around it. #1324 has since merged, adding `/tmp` to the
sandbox's `allowWrite` set, so both of those paragraphs described a
constraint that no longer exists — shipping them would have put a false
claim about the write set plus a costly workaround into every consumer's
example config. They are removed; the `AF_UNIX` half is independent of
the write policy and is all that remains.

<details><summary>What the two removed paragraphs said</summary>

The first stated that `/tmp` is read-only inside the sandbox with no
config key to change it, and walked through the .NET SDK's first-run
`Mutex` failing with `EROFS`. The second gave a private-mount-namespace
recipe (`mount -t tmpfs tmpfs /tmp`, with the checkout bind-mounted
across) to give a single command a writable `/tmp`.

Both were accurate when written and are not now. #1324 removed the
underlying constraint — the motivation it gives is that a read-only
`/tmp` "buys a per-tool workaround every time one surfaces", of which
the .NET case (#1312) was the latest.

</details>

---------

Co-authored-by: tend-agent <270458913+tend-agent@users.noreply.github.com>
Co-authored-by: tend-agent <tend-agent@users.noreply.github.com>
@max-sixty max-sixty mentioned this pull request Sep 21, 2026
max-sixty added a commit that referenced this pull request Sep 21, 2026
Version bump, lockfile, and the `## 0.2.12` section the release workflow
publishes verbatim as the GitHub Release notes.

17 commits since 0.2.11. The headline is the sandbox layout: the agent
runs in the job's own checkout and home through a copy-on-write view, so
what a consumer's `setup:` steps prepare reaches the agent without being
rebuilt in `sandbox_setup:`, and the job environment crosses into the
sandbox in a `0600` file rather than on `sudo`'s command line
([#1333](#1333),
[#1334](#1334)). Alongside it:
`/tmp` is writable inside the sandbox as scratch private to the run
([#1324](#1324)), CI sessions
ignore the bot's claude.ai skills, plugins and connectors
([#1328](#1328)), the SRT launch
retries a transient bridge-socket failure
([#1327](#1327)), and a cancelled
gating check polls as unverified rather than green
([#1323](#1323)).

Each bullet was written from the commit's diff rather than its subject.
No real agent session has run through the new view yet (CI's
`test-sandbox` lifecycle runs stub `claude`/`codex` binaries), so the
release's live check after tagging is the first one that does.

> _This was written by Claude Code on behalf of max-sixty_

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
max-sixty pushed a commit to max-sixty/leaf that referenced this pull request Sep 21, 2026
Regenerates the nine `tend-*` workflows against tend 0.2.13 (from
0.2.11). The committed diff is only the pinned action ref and the `uv`
setup version (`0.12.13` → `0.12.17`); everything below rides in the
action and the plugin it loads.

## What changes for this repo

**A cancelled gating check no longer reads as green.**
`poll_pr_checks.py` now treats only `SUCCESS`, `NEUTRAL`, and `SKIPPED`
as green and exits 2 — unverified — on anything else
([max-sixty/tend#1323](max-sixty/tend#1323)).
Cancelled runs are routine here: main serializes the complete suite, so
a newer commit replaces the one waiting for its turn, and `running-tend`
already carries a rule for reading that shape. Under 0.2.11 a session
polling its own push could report such a check as verified; it now has
to say it could not verify.

**CI sessions ignore the bot's claude.ai account.** Skills, plugins, and
MCP connectors enabled on the `leaf-agent` account no longer load into
sessions that push and post as it
([max-sixty/tend#1328](max-sixty/tend#1328)).
Sessions run with this repo's skills and tend's own, and nothing else.

**`/tmp` is writable inside the sandbox**, as scratch private to the run
([max-sixty/tend#1324](max-sixty/tend#1324)). A
tool that hard-codes a `/tmp` path no longer needs an override;
`$TMPDIR` stays the documented place to write.

**The sandbox launch retries a transient bridge-socket failure**, up to
three attempts
([max-sixty/tend#1327](max-sixty/tend#1327)).
SRT gives socat 600 ms to bind, and a slow runner previously lost the
whole job before the agent started.

Three plugin-side fixes also land: `review-runs` can now cancel a run
parked in GitHub's `waiting` state that holds its concurrency group
without ever concluding
([#1320](max-sixty/tend#1320)); `ground-claims`
reads a past run's code at that run's commit rather than at the current
default branch ([#1319](max-sixty/tend#1319));
and `run-tend` corrects its list of instruction paths restored to the
base version on a PR
([#1311](max-sixty/tend#1311)).

## No configuration change needed

0.2.12 moved the agent into the job's own checkout through a
copy-on-write view, which would have changed where dependency setup
belongs. 0.2.13 reverts it
([#1339](max-sixty/tend#1339)): `setup:` and
`sandbox_setup:` behave as they did at 0.2.11, so `.config/tend.yaml` is
unchanged here. This repo pins no `model:`, so the harness default
applies as before.

Full comparison:
[0.2.11...0.2.13](max-sixty/tend@0.2.11...0.2.13).

Co-authored-by: leaf-agent <318509791+leaf-agent@users.noreply.github.com>
max-sixty pushed a commit to max-sixty/worktrunk that referenced this pull request Sep 21, 2026
Regenerates the `tend-*` workflows with tend 0.2.13 (from 0.2.11).
Mechanically the diff is the action pin `max-sixty/tend/claude@0.2.11` →
`@0.2.13` in the eight workflows that run the agent, the
`astral-sh/setup-uv` pin `0.12.13` → `0.12.17` in the two that install
uv directly (`tend-mention`, `tend-notifications`), and a regenerated
header comment in all nine.

The upgrade that matters for this repo is in CI polling.
`poll_pr_checks.py` previously treated any gating check that settled
without a red conclusion as green — so a required check belonging to a
**cancelled** or **stale** run, or a `COMPLETED` check carrying no
conclusion at all, let the bot report a PR as green when nothing had
actually verified it. 0.2.13 splits those out as `unverified` and gives
`poll` a distinct exit code for them (max-sixty/tend#1323). Practically:
fewer "checks went green" claims on PRs whose required jobs were
cancelled, at the cost of some polls ending unverified rather than
green.

The rest, in rough order of how visible it is here:

- **Sandbox reliability** — the sandbox runtime now retries past a
transient bridge-socket failure at launch (max-sixty/tend#1327). That
failure is one of the shapes that produces a `tend-outage` issue, so it
should show up as fewer of those.
- **Sandbox environment** — root `/tmp` is writable inside the sandbox
(max-sixty/tend#1324); the `run-tend` skill's note that it was read-only
is gone. This repo's `running-tend` skill still restates that note, so
it goes stale when this merges — I'll follow up separately rather than
mix a skill edit into the generated-workflow diff.
- **Instruction-path pinning** — the `run-tend` skill's description of
which paths get restored from the PR base, and on which events, was
wrong in 0.2.11; `.agents/` is now named alongside `.claude/`, and the
event list is corrected (max-sixty/tend#1311). The restore behavior
itself is unchanged.
- **Auth hygiene** — the bot's claude.ai account is kept out of CI
sessions (max-sixty/tend#1328).
- **Harness pins** — claude-code 2.1.278 (max-sixty/tend#1330), uv
0.12.17 (max-sixty/tend#1331).

`.config/tend.yaml` pins no model, so the harness-default check found
nothing to adjust.

Full comparison:
max-sixty/tend@0.2.11...0.2.13

<details><summary>Copy-on-write sandbox view: landed and reverted within
this range</summary>

max-sixty/tend#1333 and max-sixty/tend#1334 moved the agent into the
job's own checkout and home through a copy-on-write view;
max-sixty/tend#1339 reverted both before 0.2.13. Nothing from that work
is in this diff — noted so a later reader doesn't chase it out of the
commit list.

</details>

Co-authored-by: worktrunk-bot <254187624+worktrunk-bot@users.noreply.github.com>
max-sixty pushed a commit to max-sixty/worktrunk that referenced this pull request Sep 21, 2026
…#4217)

`main` now pins `max-sixty/tend/claude@0.2.13` (#4215, merged as
`c5e17139a`), and two sandbox facts this overlay asserts stopped being
true of the sandbox sessions actually run in.

**Root `/tmp` is writable.**
[max-sixty/tend#1324](max-sixty/tend#1324)
("Make /tmp writable inside the sandbox") merged 2026-09-19, after
0.2.11 and before 0.2.12. In the `0.2.11...0.2.13` diff the sandbox's
own SRT test flips its probe: `touch /tmp/tend-unscoped` must fail at
0.2.11, while at 0.2.13 `touch /tmp/tend-sandbox-scratch` must succeed
and `/var/tmp` is the refused one. Tend dropped the matching paragraph
from its bundled `run-tend` skill in the same range, along with the
`TMPPREFIX` override whose comment said it existed only because zsh's
here-document default "sits in the read-only root /tmp".

**The agent workspace container moved to `/var/tmp`.**
`prepare_agent_workspace.py` at 0.2.13 calls
`mkdtemp(prefix="tend-agent-workspace-", dir="/var/tmp")`, so the
slugified session-log directory is `-var-tmp-…`, not `-tmp-…`.

Neither edit changes what a session should do. `${TMPDIR:-/tmp}` is
still where scratch files belong, and the log-path section's conclusion
— there is no literal to match on, so use `find "$DEST" -name '*.jsonl'`
— is if anything better supported by a container that moves between
releases. What goes is the justification attached to each, which is the
part that could go stale and did.

The old text was accurate right up to the merge: a write probe in this
run, still on 0.2.11, is refused with `Read-only file system`. It is
wrong from the next session on, which is why this is a deletion rather
than a rewrite — the overlay is better off not restating a boundary tend
owns and moves.

<details><summary>Where this was found</summary>

The `tend-review` session on #4215 ([run
35570800500](https://github.com/max-sixty/worktrunk/actions/runs/35570800500))
noticed the overlay would go stale on merge and deliberately left it
alone, on the grounds that `.claude/**` is restored from the base branch
during a review session and a separate PR is the cleaner home. This is
that PR, opened from the `tend-review-runs` sweep that can edit
`.claude/skills/` from a `$TMPDIR` worktree.

</details>

Co-authored-by: worktrunk-bot <254187624+worktrunk-bot@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

dotnet is on the sandbox PATH but cannot build: the SDK's first-run Mutex needs a writable /tmp

2 participants