Skip to content

[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

@richkuo

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 when CLAUDE_CODE_SUBPROCESS_ENV_SCRUB=1:

bwrap: Can't create file at /root/actions-runner/_work/<repo>/<repo>/.git/config.lock: Read-only file system

anthropics/claude-code-action turns the scrub on whenever allowed_non_write_users is set, so review workflows on such runners lose all shell access (including git 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_WORKSPACE is inside that directory, so the sandbox emits --ro-bind /root/actions-runner /root/actions-runner after the workspace --bind. The whole workspace becomes read-only. The next deny entry for the non-existent <workspace>/.git/config.lock needs a /dev/null stub file, which bubblewrap cannot create on the read-only mount, so bwrap exits.

Relevant argv excerpt:

--bind /root/actions-runner/_work/<repo>/<repo> /root/actions-runner/_work/<repo>/<repo>
...
--ro-bind /root/actions-runner /root/actions-runner
--ro-bind /dev/null /root/actions-runner/_work/<repo>/<repo>/.git/config.lock

Reproduction

  • Claude Code 2.1.281 (native), Ubuntu 24.04, kernel 6.8, bubblewrap 0.9.0, running as root, kernel.apparmor_restrict_unprivileged_userns=0.
  • Runner installed per GitHub's instructions at /root/actions-runner, default _work folder.
  • From the workspace, with HOME=/root, GITHUB_WORKSPACE=<workspace>, CLAUDE_CODE_SUBPROCESS_ENV_SCRUB=1, run claude -p and have the model call Bash with git 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

Activity

aron-intframe commented on Sep 28, 2026

@aron-intframe

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

added 2 commits that reference this issue on Oct 2, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions