Repository navigation
[BUG] 2.1.281: CLAUDE_CODE_SUBPROCESS_ENV_SCRUB sandbox denies $HOME/actions-runner, so every Bash call fails on a default self-hosted GitHub Actions runner #97730
Description
Activity
Reproduced your diagnosis at the bubblewrap level, and it is purely an argv-ordering problem: bwrap applies bind operations in argv order and the last mount over a path wins, so --ro-bind $HOME/actions-runner $HOME/actions-runner emitted after --bind <workspace> <workspace> remounts the workspace read-only, because the workspace is a child of the runner directory.
Minimal repro with the same bubblewrap version you reported (0.9.0), no Claude Code involved. A fake HOME with GitHub's default runner layout, then the four bind sets:
SB=/tmp/cc97730; H=$SB/root; RUNNER=$H/actions-runner; WS=$RUNNER/_work/myrepo/myrepo
mkdir -p "$WS" "$RUNNER/bin"; git -C "$WS" init -q; git -C "$WS" config user.email [email protected]
# A workspace bind only
bwrap --dev-bind / / --bind "$WS" "$WS" -- sh -c '<write probe>'
# B your order, without the stub
bwrap --dev-bind / / --bind "$WS" "$WS" --ro-bind "$RUNNER" "$RUNNER" -- sh -c '<write probe>'
# C your argv, incl. the /dev/null stub for the non-existent .git/config.lock
bwrap --dev-bind / / --bind "$WS" "$WS" --ro-bind "$RUNNER" "$RUNNER" \
--ro-bind /dev/null "$WS/.git/config.lock" -- /bin/true
# D identical bind set, ancestor deny emitted BEFORE the workspace bind
bwrap --dev-bind / / --ro-bind "$RUNNER" "$RUNNER" --bind "$WS" "$WS" \
--ro-bind /dev/null "$WS/.git/config.lock" -- sh -c '<write probe>'Output (run as root, Ubuntu 24.04, kernel 6.8, bubblewrap 0.9.0):
bwrap 0.9.0 | HOME=/tmp/cc97730/root RUNNER=$HOME/actions-runner WS=$RUNNER/_work/myrepo/myrepo
### A workspace bind only, no denyWrite ancestor
workspace : WRITABLE
runner/bin: WRITABLE
exit=0
### B reported order: --bind WS ... then --ro-bind $RUNNER (no stub yet)
sh: 2: cannot create /tmp/cc97730/root/actions-runner/_work/myrepo/myrepo/probe.txt: Read-only file system
workspace : read-only
sh: 3: cannot create /tmp/cc97730/root/actions-runner/bin/Runner.Listener: Read-only file system
runner/bin: read-only
exit=0
### C reported argv: same order + the /dev/null stub for the non-existent .git/config.lock
bwrap: Can't create file at /tmp/cc97730/root/actions-runner/_work/myrepo/myrepo/.git/config.lock: Read-only file system
exit=1
### D identical bind set, ancestor deny emitted BEFORE the workspace bind
workspace : WRITABLE
sh: 3: cannot create /tmp/cc97730/root/actions-runner/bin/Runner.Listener: Read-only file system
runner/bin: read-only
sh: 4: cannot create /tmp/cc97730/root/actions-runner/_work/myrepo/myrepo/.git/config.lock: Permission denied
config.lock: read-only (stub held)
exit=0
### E after D exits, on the HOST filesystem
-r--r--r-- 1 root root 0 Sep 28 10:18 /tmp/cc97730/root/actions-runner/_work/myrepo/myrepo/.git/config.lock
error: could not lock config file .git/config: File exists
git config exit=255
Two things beyond what the report already covers.
1. The bwrap abort is only the loudest symptom. Case B shows that with no stub needed at all, the workspace is already silently read-only and bwrap exits 0. So on any host where the deny list happens to contain only existing paths, there is no startup error: Bash calls start, and individual commands fail with Read-only file system mid-run. The Can't create file abort in case C only happens because a non-existent deny entry forces bwrap to create a mountpoint on a mount it has just made read-only.
2. The /dev/null stub is created on the host and survives the sandbox. Case E: after case D exits, .git/config.lock is still there as a real 0444 file, and plain git config outside the sandbox then fails with error: could not lock config file .git/config: File exists. Because the workspace is bind-mounted (not copied), the mountpoint bwrap creates lands on the real checkout. On a self-hosted runner that persists into later steps and later jobs on the same host. Worth checking whether the stub creation from the #79997 fix has a cleanup path for the file case, or whether it only happens to be harmless when the target is a directory.
Fix that matches your "Expected" exactly: case D. Emit deny entries that are ancestors of a write-allowed root before the write binds, i.e. sort the mount list so broader paths come first and the narrower --bind of the workspace re-exposes it. That single reordering gives the runner's own files read-only (runner/bin: read-only), the workspace writable (workspace : WRITABLE), and the config-injection stub still enforced (config.lock: read-only (stub held)) — no special-casing of runner layouts needed. As a separate hardening step, a non-existent deny path whose parent is read-only should be skipped rather than aborting the whole sandbox, the same way non-existent read-allow paths are already skipped.
On the "the run continues silently" part of your report: we keep a field catalog of exactly this shape, a hardening or instrumentation layer that switches off the capability it wraps while the run keeps reporting success (ours): https://github.com/aron-intframe/agent-guards
What's wrong
On a self-hosted GitHub Actions runner installed with GitHub's default layout (runner in
~/actions-runner, work folder~/actions-runner/_work), every Bash tool call fails before the command runs whenCLAUDE_CODE_SUBPROCESS_ENV_SCRUB=1:anthropics/claude-code-actionturns the scrub on wheneverallowed_non_write_usersis set, so review workflows on such runners lose all shell access (includinggit diff) and continue without it.Cause (from the captured bwrap argv)
The scrub denyWrite list includes
${HOME}/actions-runner(and${HOME}/runners). In the default self-hosted layout,GITHUB_WORKSPACEis inside that directory, so the sandbox emits--ro-bind /root/actions-runner /root/actions-runnerafter the workspace--bind. The whole workspace becomes read-only. The next deny entry for the non-existent<workspace>/.git/config.lockneeds a/dev/nullstub file, which bubblewrap cannot create on the read-only mount, so bwrap exits.Relevant argv excerpt:
Reproduction
kernel.apparmor_restrict_unprivileged_userns=0./root/actions-runner, default_workfolder.HOME=/root,GITHUB_WORKSPACE=<workspace>,CLAUDE_CODE_SUBPROCESS_ENV_SCRUB=1, runclaude -pand have the model call Bash withgit rev-parse HEAD. I drove it with a local mock Messages API, so no model call is needed to reproduce.Expected
When the workspace (or cwd) is inside a runner install path, the sandbox should write-protect the runner's own files (
bin,externals,.runner,.credentials,_diag, and so on) and leave the workspace writable, or it should fail with a clear message at startup. Today every Bash call fails, and the run continues silently.Related
Created with LLM: Opus 5.5 | medium | Harness: Claude Code