Repository navigation
Make /tmp writable inside the sandbox - #1324
Conversation
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
left a comment
There was a problem hiding this comment.
#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.
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
left a comment
There was a problem hiding this comment.
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.
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>
#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.
…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>
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>
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>
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>
…#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>
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/tmphas needed a workaround of its own: aTMPDIRoverride (#1199), a zshTMPPREFIXoverride (#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./tmpis 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/tmpbut 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/tmpentries the sandbox uid owns:/tmpis 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 analways()step and filing atend-outageissue 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
TMPPREFIXoverride and its reservedsandbox_envkey, and the repo-wide test banning bare/tmppaths in shipped instructions.run-tend's temp-files section is down to the one fact a session still needs —$TMPDIRis/home/tend-sandbox/tmp.TMPDIRandAGENT_TMP_DIRthemselves stay: the supervisor reads the step summary from that directory, and the shipped skills name$TMPDIRthroughout. So does theCLAUDE_CODE_TMPDIRoverride, for a reason the comment now states — with filesystem policy on, SRT overwritesTMPDIRin the child environment with its own/tmp/claudedefault 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/tmprather 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
🤖 Generated with Claude Code