From eeb468424e74eba77a5fe5209097918f10dc3781 Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Sun, 8 Nov 2026 01:00:39 -0500 Subject: [PATCH 1/2] fix(pr-review): install sandbox-runtime's seccomp helper for the lanes Claude Code 2.1.304 and later pass bubblewrap --disable-userns when no apply-seccomp helper is found. That flag writes /proc/sys/user/max_user_namespaces, which Docker mounts read-only on the melodic-review workers, so every Bash call in a review lane fails. The isolation step in both lanes now downloads the pinned @anthropic-ai/sandbox-runtime 0.0.81 tarball from the npm registry, checks its SHA-256, and installs only the helper, root-owned, under /usr/local/lib/node_modules, a root sandbox-runtime searches. The worker image has no npm, so this needs no Node toolchain. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_015XWwmbdW8moDiQ5Rtgt7d5 --- .../claude-lane-install-isolation.test.cjs | 95 +++++++++++++++++++ .github/workflows/pr-review-security.yml | 41 +++++++- .github/workflows/pr-review.yml | 41 +++++++- 3 files changed, 175 insertions(+), 2 deletions(-) diff --git a/.github/scripts/claude-lane-install-isolation.test.cjs b/.github/scripts/claude-lane-install-isolation.test.cjs index 02bab1fb..0e4724f8 100644 --- a/.github/scripts/claude-lane-install-isolation.test.cjs +++ b/.github/scripts/claude-lane-install-isolation.test.cjs @@ -5,7 +5,10 @@ // lane without the isolation the other has. const assert = require("node:assert/strict"); +const { spawnSync } = require("node:child_process"); +const crypto = require("node:crypto"); const fs = require("node:fs"); +const os = require("node:os"); const path = require("node:path"); const test = require("node:test"); @@ -76,3 +79,95 @@ test("every apt-get call ends before its step limit", () => { ); } }); + +// Without the helper, bubblewrap gets --disable-userns and dies on a +// read-only /proc/sys. sandbox-runtime looks for it only under these static +// global npm roots and `npm root -g` (src/sandbox/generate-seccomp-filter.ts, +// buildGlobalNpmPaths, v0.0.81), so a helper anywhere else is never used. +const helperRoots = [ + "/usr/lib/node_modules", + "/usr/local/lib/node_modules", + "/opt/homebrew/lib/node_modules", +]; +const helperSuffix = + "@anthropic-ai/sandbox-runtime/vendor/seccomp/x64/apply-seccomp"; + +const runHelperInstall = (tarballSha256) => { + const run = installStep("pr-review.yml").run; + const fragment = run.slice(run.indexOf('case "$RUNNER_ARCH" in')); + assert.ok(fragment.startsWith("case"), "the step installs the helper"); + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "seccomp-helper-")); + const root = path.join(dir, "root"); + const stubs = path.join(dir, "stubs"); + const packageDir = path.join( + dir, + "pkg", + "package", + "vendor", + "seccomp", + "x64", + ); + fs.mkdirSync(packageDir, { recursive: true }); + fs.mkdirSync(stubs); + fs.writeFileSync(path.join(packageDir, "apply-seccomp"), "helper-bytes\n"); + const tarball = path.join(dir, "package.tgz"); + spawnSync("tar", ["-czf", tarball, "-C", path.join(dir, "pkg"), "package"]); + const sha256 = + tarballSha256 ?? + crypto.createHash("sha256").update(fs.readFileSync(tarball)).digest("hex"); + fs.writeFileSync( + path.join(stubs, "curl"), + '#!/bin/bash\nwhile [ $# -gt 1 ]; do [ "$1" = --output ] && out="$2"; shift; done\necho "$1" >> "$STUB_LOG"\ncp "$FAKE_TARBALL" "$out"\n', + { mode: 0o755 }, + ); + fs.writeFileSync( + path.join(stubs, "sudo"), + '#!/bin/bash\necho "sudo $*" >> "$STUB_LOG"\nfor arg; do src="$dest"; dest="$arg"; done\nmkdir -p "$(dirname "$FAKE_ROOT$dest")"\ncp "$src" "$FAKE_ROOT$dest"\n', + { mode: 0o755 }, + ); + const log = path.join(dir, "stub.log"); + const result = spawnSync("bash", ["-eo", "pipefail", "-c", fragment], { + encoding: "utf8", + env: { + ...process.env, + PATH: `${stubs}:${process.env.PATH}`, + RUNNER_ARCH: "X64", + RUNNER_TEMP: dir, + SANDBOX_RUNTIME_VERSION: "0.0.81", + SANDBOX_RUNTIME_SHA256: sha256, + FAKE_TARBALL: tarball, + FAKE_ROOT: root, + STUB_LOG: log, + }, + }); + const stubLog = fs.existsSync(log) ? fs.readFileSync(log, "utf8") : ""; + return { result, root, stubLog, dir }; +}; + +test("the helper lands root-owned where sandbox-runtime looks for it", (t) => { + const { result, root, stubLog, dir } = runHelperInstall(); + t.after(() => fs.rmSync(dir, { recursive: true, force: true })); + assert.equal(result.status, 0, result.stderr); + const installed = helperRoots + .map((base) => path.join(base, helperSuffix)) + .filter((target) => fs.existsSync(path.join(root, target))); + assert.equal(installed.length, 1, "one helper under a searched root"); + assert.equal( + fs.readFileSync(path.join(root, installed[0]), "utf8"), + "helper-bytes\n", + ); + assert.match(stubLog, /^sudo install -D -o root -g root -m 0755 -- /mu); + assert.match( + stubLog, + /^https:\/\/registry\.npmjs\.org\/@anthropic-ai\/sandbox-runtime\/-\/sandbox-runtime-0\.0\.81\.tgz$/mu, + ); +}); + +test("a tarball that does not match the pinned SHA-256 installs nothing", (t) => { + const { result, root, stubLog, dir } = runHelperInstall("0".repeat(64)); + t.after(() => fs.rmSync(dir, { recursive: true, force: true })); + assert.notEqual(result.status, 0); + assert.match(result.stdout, /::error::.*does not match its pinned SHA-256/u); + assert.equal(fs.existsSync(root), false, "nothing installed"); + assert.doesNotMatch(stubLog, /^sudo /mu); +}); diff --git a/.github/workflows/pr-review-security.yml b/.github/workflows/pr-review-security.yml index 431063be..d4e1b9f5 100644 --- a/.github/workflows/pr-review-security.yml +++ b/.github/workflows/pr-review-security.yml @@ -664,12 +664,23 @@ jobs: # Linux's, and a runner without apt-get must already have bwrap. The # AppArmor sysctl is lifted only on GitHub-hosted runners, which are # discarded after the job, so no reused host keeps the change. + # The step also installs sandbox-runtime's apply-seccomp helper. Without + # it, Claude Code passes bubblewrap --disable-userns, which writes + # /proc/sys/user/max_user_namespaces and dies where Docker mounts + # /proc/sys read-only, so every Bash call fails. Claude Code looks for + # the helper under the global npm roots, /usr/local/lib/node_modules + # among them (sandbox-runtime src/sandbox/generate-seccomp-filter.ts, + # buildGlobalNpmPaths). The worker image has no npm, so the helper is + # taken from the package tarball after a SHA-256 check. - name: Install subprocess isolation id: isolate if: steps.scope.outputs.review != 'false' && runner.os == 'Linux' - timeout-minutes: 1 + timeout-minutes: 2 env: RUNNER_HOSTING: ${{ runner.environment }} + SANDBOX_RUNTIME_VERSION: 0.0.81 + # SHA-256 of that version's npm tarball; change both together. + SANDBOX_RUNTIME_SHA256: 0e0b8249062047f7b592c7995f8ec2f251f3bbebdc0402db1ee23af09983953f run: | if command -v apt-get >/dev/null && ! { command -v bwrap && command -v socat; } >/dev/null; then @@ -699,6 +710,34 @@ jobs: echo "::error::bubblewrap or socat is not installed; Claude Code cannot scrub its subprocess environment" exit 1 fi + case "$RUNNER_ARCH" in + X64) arch=x64 ;; + ARM64) arch=arm64 ;; + *) + echo "::error::sandbox-runtime ships no apply-seccomp helper for runner architecture $RUNNER_ARCH" + exit 1 + ;; + esac + work="$(mktemp -d "$RUNNER_TEMP/sandbox-runtime.XXXXXX")" + tarball="$work/package.tgz" + member="package/vendor/seccomp/$arch/apply-seccomp" + helper="/usr/local/lib/node_modules/@anthropic-ai/sandbox-runtime/vendor/seccomp/$arch/apply-seccomp" + if ! curl -q --fail --silent --show-error --location \ + --proto '=https' --proto-redir '=https' \ + --connect-timeout 10 --max-time 15 --retry 2 --retry-all-errors \ + --output "$tarball" \ + "https://registry.npmjs.org/@anthropic-ai/sandbox-runtime/-/sandbox-runtime-$SANDBOX_RUNTIME_VERSION.tgz"; then + echo "::error::the sandbox-runtime $SANDBOX_RUNTIME_VERSION tarball did not download from the npm registry" + exit 1 + fi + if ! printf '%s %s\n' "$SANDBOX_RUNTIME_SHA256" "$tarball" | sha256sum -c --quiet -; then + echo "::error::the sandbox-runtime $SANDBOX_RUNTIME_VERSION tarball does not match its pinned SHA-256" + exit 1 + fi + tar -xzf "$tarball" -C "$work" -- "$member" + sudo install -D -o root -g root -m 0755 -- "$work/$member" "$helper" + rm -rf -- "${work:?}" + echo "installed apply-seccomp from sandbox-runtime $SANDBOX_RUNTIME_VERSION at $helper" # Installed bubblewrap can still fail to create a namespace (unprivileged # user namespaces off), and then every Bash call the model makes fails diff --git a/.github/workflows/pr-review.yml b/.github/workflows/pr-review.yml index 706e84d1..83b5b5bc 100644 --- a/.github/workflows/pr-review.yml +++ b/.github/workflows/pr-review.yml @@ -804,12 +804,23 @@ jobs: # Linux's, and a runner without apt-get must already have bwrap. The # AppArmor sysctl is lifted only on GitHub-hosted runners, which are # discarded after the job, so no reused host keeps the change. + # The step also installs sandbox-runtime's apply-seccomp helper. Without + # it, Claude Code passes bubblewrap --disable-userns, which writes + # /proc/sys/user/max_user_namespaces and dies where Docker mounts + # /proc/sys read-only, so every Bash call fails. Claude Code looks for + # the helper under the global npm roots, /usr/local/lib/node_modules + # among them (sandbox-runtime src/sandbox/generate-seccomp-filter.ts, + # buildGlobalNpmPaths). The worker image has no npm, so the helper is + # taken from the package tarball after a SHA-256 check. - name: Install subprocess isolation id: isolate if: steps.scope.outputs.review != 'false' && runner.os == 'Linux' - timeout-minutes: 1 + timeout-minutes: 2 env: RUNNER_HOSTING: ${{ runner.environment }} + SANDBOX_RUNTIME_VERSION: 0.0.81 + # SHA-256 of that version's npm tarball; change both together. + SANDBOX_RUNTIME_SHA256: 0e0b8249062047f7b592c7995f8ec2f251f3bbebdc0402db1ee23af09983953f run: | if command -v apt-get >/dev/null && ! { command -v bwrap && command -v socat; } >/dev/null; then @@ -839,6 +850,34 @@ jobs: echo "::error::bubblewrap or socat is not installed; Claude Code cannot scrub its subprocess environment" exit 1 fi + case "$RUNNER_ARCH" in + X64) arch=x64 ;; + ARM64) arch=arm64 ;; + *) + echo "::error::sandbox-runtime ships no apply-seccomp helper for runner architecture $RUNNER_ARCH" + exit 1 + ;; + esac + work="$(mktemp -d "$RUNNER_TEMP/sandbox-runtime.XXXXXX")" + tarball="$work/package.tgz" + member="package/vendor/seccomp/$arch/apply-seccomp" + helper="/usr/local/lib/node_modules/@anthropic-ai/sandbox-runtime/vendor/seccomp/$arch/apply-seccomp" + if ! curl -q --fail --silent --show-error --location \ + --proto '=https' --proto-redir '=https' \ + --connect-timeout 10 --max-time 15 --retry 2 --retry-all-errors \ + --output "$tarball" \ + "https://registry.npmjs.org/@anthropic-ai/sandbox-runtime/-/sandbox-runtime-$SANDBOX_RUNTIME_VERSION.tgz"; then + echo "::error::the sandbox-runtime $SANDBOX_RUNTIME_VERSION tarball did not download from the npm registry" + exit 1 + fi + if ! printf '%s %s\n' "$SANDBOX_RUNTIME_SHA256" "$tarball" | sha256sum -c --quiet -; then + echo "::error::the sandbox-runtime $SANDBOX_RUNTIME_VERSION tarball does not match its pinned SHA-256" + exit 1 + fi + tar -xzf "$tarball" -C "$work" -- "$member" + sudo install -D -o root -g root -m 0755 -- "$work/$member" "$helper" + rm -rf -- "${work:?}" + echo "installed apply-seccomp from sandbox-runtime $SANDBOX_RUNTIME_VERSION at $helper" # Installed bubblewrap can still fail to create a namespace (unprivileged # user namespaces off), and then every Bash call the model makes fails From d8087e5ceba23a2a7b2d940f33f3714d6c1bed67 Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Sun, 8 Nov 2026 01:07:07 -0500 Subject: [PATCH 2/2] fix(pr-review): make the seccomp helper install best-effort A failed download, a hash mismatch, an unsupported architecture or a failed extract now installs nothing, warns with the cause, and lets the isolation step continue. Without the helper Claude Code behaves as it did before, so a registry outage no longer stops every review lane. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_015XWwmbdW8moDiQ5Rtgt7d5 --- .../claude-lane-install-isolation.test.cjs | 11 ++++-- .github/workflows/pr-review-security.yml | 39 ++++++++++--------- .github/workflows/pr-review.yml | 39 ++++++++++--------- 3 files changed, 48 insertions(+), 41 deletions(-) diff --git a/.github/scripts/claude-lane-install-isolation.test.cjs b/.github/scripts/claude-lane-install-isolation.test.cjs index 0e4724f8..7efeed83 100644 --- a/.github/scripts/claude-lane-install-isolation.test.cjs +++ b/.github/scripts/claude-lane-install-isolation.test.cjs @@ -163,11 +163,16 @@ test("the helper lands root-owned where sandbox-runtime looks for it", (t) => { ); }); -test("a tarball that does not match the pinned SHA-256 installs nothing", (t) => { +// A missing helper only restores the behavior without it, so a bad tarball +// warns and lets the lane run instead of stopping it. +test("a tarball that does not match the pinned SHA-256 installs nothing and warns", (t) => { const { result, root, stubLog, dir } = runHelperInstall("0".repeat(64)); t.after(() => fs.rmSync(dir, { recursive: true, force: true })); - assert.notEqual(result.status, 0); - assert.match(result.stdout, /::error::.*does not match its pinned SHA-256/u); + assert.equal(result.status, 0, result.stderr); + assert.match( + result.stdout, + /^::warning::.*does not match its pinned SHA-256; the seccomp helper is not installed/mu, + ); assert.equal(fs.existsSync(root), false, "nothing installed"); assert.doesNotMatch(stubLog, /^sudo /mu); }); diff --git a/.github/workflows/pr-review-security.yml b/.github/workflows/pr-review-security.yml index d4e1b9f5..73a4bb63 100644 --- a/.github/workflows/pr-review-security.yml +++ b/.github/workflows/pr-review-security.yml @@ -671,7 +671,9 @@ jobs: # the helper under the global npm roots, /usr/local/lib/node_modules # among them (sandbox-runtime src/sandbox/generate-seccomp-filter.ts, # buildGlobalNpmPaths). The worker image has no npm, so the helper is - # taken from the package tarball after a SHA-256 check. + # taken from the package tarball after a SHA-256 check. That install is + # best-effort: on any failure nothing is installed, a warning names the + # cause, and Claude Code runs as it would without the helper. - name: Install subprocess isolation id: isolate if: steps.scope.outputs.review != 'false' && runner.os == 'Linux' @@ -713,31 +715,30 @@ jobs: case "$RUNNER_ARCH" in X64) arch=x64 ;; ARM64) arch=arm64 ;; - *) - echo "::error::sandbox-runtime ships no apply-seccomp helper for runner architecture $RUNNER_ARCH" - exit 1 - ;; + *) arch="" ;; esac + url="https://registry.npmjs.org/@anthropic-ai/sandbox-runtime/-/sandbox-runtime-$SANDBOX_RUNTIME_VERSION.tgz" + helper="/usr/local/lib/node_modules/@anthropic-ai/sandbox-runtime/vendor/seccomp/$arch/apply-seccomp" + member="package/vendor/seccomp/$arch/apply-seccomp" work="$(mktemp -d "$RUNNER_TEMP/sandbox-runtime.XXXXXX")" tarball="$work/package.tgz" - member="package/vendor/seccomp/$arch/apply-seccomp" - helper="/usr/local/lib/node_modules/@anthropic-ai/sandbox-runtime/vendor/seccomp/$arch/apply-seccomp" - if ! curl -q --fail --silent --show-error --location \ + skip="the seccomp helper is not installed, so Claude Code runs without it" + if [ -z "$arch" ]; then + echo "::warning::sandbox-runtime ships no apply-seccomp helper for runner architecture $RUNNER_ARCH; $skip" + elif ! curl -q --fail --silent --show-error --location \ --proto '=https' --proto-redir '=https' \ --connect-timeout 10 --max-time 15 --retry 2 --retry-all-errors \ - --output "$tarball" \ - "https://registry.npmjs.org/@anthropic-ai/sandbox-runtime/-/sandbox-runtime-$SANDBOX_RUNTIME_VERSION.tgz"; then - echo "::error::the sandbox-runtime $SANDBOX_RUNTIME_VERSION tarball did not download from the npm registry" - exit 1 - fi - if ! printf '%s %s\n' "$SANDBOX_RUNTIME_SHA256" "$tarball" | sha256sum -c --quiet -; then - echo "::error::the sandbox-runtime $SANDBOX_RUNTIME_VERSION tarball does not match its pinned SHA-256" - exit 1 + --output "$tarball" "$url"; then + echo "::warning::the sandbox-runtime $SANDBOX_RUNTIME_VERSION tarball did not download from the npm registry; $skip" + elif ! printf '%s %s\n' "$SANDBOX_RUNTIME_SHA256" "$tarball" | sha256sum -c --quiet -; then + echo "::warning::the sandbox-runtime $SANDBOX_RUNTIME_VERSION tarball does not match its pinned SHA-256; $skip" + elif ! tar -xzf "$tarball" -C "$work" -- "$member" || + ! sudo install -D -o root -g root -m 0755 -- "$work/$member" "$helper"; then + echo "::warning::apply-seccomp from sandbox-runtime $SANDBOX_RUNTIME_VERSION did not extract or install; $skip" + else + echo "installed apply-seccomp from sandbox-runtime $SANDBOX_RUNTIME_VERSION at $helper" fi - tar -xzf "$tarball" -C "$work" -- "$member" - sudo install -D -o root -g root -m 0755 -- "$work/$member" "$helper" rm -rf -- "${work:?}" - echo "installed apply-seccomp from sandbox-runtime $SANDBOX_RUNTIME_VERSION at $helper" # Installed bubblewrap can still fail to create a namespace (unprivileged # user namespaces off), and then every Bash call the model makes fails diff --git a/.github/workflows/pr-review.yml b/.github/workflows/pr-review.yml index 83b5b5bc..077218a2 100644 --- a/.github/workflows/pr-review.yml +++ b/.github/workflows/pr-review.yml @@ -811,7 +811,9 @@ jobs: # the helper under the global npm roots, /usr/local/lib/node_modules # among them (sandbox-runtime src/sandbox/generate-seccomp-filter.ts, # buildGlobalNpmPaths). The worker image has no npm, so the helper is - # taken from the package tarball after a SHA-256 check. + # taken from the package tarball after a SHA-256 check. That install is + # best-effort: on any failure nothing is installed, a warning names the + # cause, and Claude Code runs as it would without the helper. - name: Install subprocess isolation id: isolate if: steps.scope.outputs.review != 'false' && runner.os == 'Linux' @@ -853,31 +855,30 @@ jobs: case "$RUNNER_ARCH" in X64) arch=x64 ;; ARM64) arch=arm64 ;; - *) - echo "::error::sandbox-runtime ships no apply-seccomp helper for runner architecture $RUNNER_ARCH" - exit 1 - ;; + *) arch="" ;; esac + url="https://registry.npmjs.org/@anthropic-ai/sandbox-runtime/-/sandbox-runtime-$SANDBOX_RUNTIME_VERSION.tgz" + helper="/usr/local/lib/node_modules/@anthropic-ai/sandbox-runtime/vendor/seccomp/$arch/apply-seccomp" + member="package/vendor/seccomp/$arch/apply-seccomp" work="$(mktemp -d "$RUNNER_TEMP/sandbox-runtime.XXXXXX")" tarball="$work/package.tgz" - member="package/vendor/seccomp/$arch/apply-seccomp" - helper="/usr/local/lib/node_modules/@anthropic-ai/sandbox-runtime/vendor/seccomp/$arch/apply-seccomp" - if ! curl -q --fail --silent --show-error --location \ + skip="the seccomp helper is not installed, so Claude Code runs without it" + if [ -z "$arch" ]; then + echo "::warning::sandbox-runtime ships no apply-seccomp helper for runner architecture $RUNNER_ARCH; $skip" + elif ! curl -q --fail --silent --show-error --location \ --proto '=https' --proto-redir '=https' \ --connect-timeout 10 --max-time 15 --retry 2 --retry-all-errors \ - --output "$tarball" \ - "https://registry.npmjs.org/@anthropic-ai/sandbox-runtime/-/sandbox-runtime-$SANDBOX_RUNTIME_VERSION.tgz"; then - echo "::error::the sandbox-runtime $SANDBOX_RUNTIME_VERSION tarball did not download from the npm registry" - exit 1 - fi - if ! printf '%s %s\n' "$SANDBOX_RUNTIME_SHA256" "$tarball" | sha256sum -c --quiet -; then - echo "::error::the sandbox-runtime $SANDBOX_RUNTIME_VERSION tarball does not match its pinned SHA-256" - exit 1 + --output "$tarball" "$url"; then + echo "::warning::the sandbox-runtime $SANDBOX_RUNTIME_VERSION tarball did not download from the npm registry; $skip" + elif ! printf '%s %s\n' "$SANDBOX_RUNTIME_SHA256" "$tarball" | sha256sum -c --quiet -; then + echo "::warning::the sandbox-runtime $SANDBOX_RUNTIME_VERSION tarball does not match its pinned SHA-256; $skip" + elif ! tar -xzf "$tarball" -C "$work" -- "$member" || + ! sudo install -D -o root -g root -m 0755 -- "$work/$member" "$helper"; then + echo "::warning::apply-seccomp from sandbox-runtime $SANDBOX_RUNTIME_VERSION did not extract or install; $skip" + else + echo "installed apply-seccomp from sandbox-runtime $SANDBOX_RUNTIME_VERSION at $helper" fi - tar -xzf "$tarball" -C "$work" -- "$member" - sudo install -D -o root -g root -m 0755 -- "$work/$member" "$helper" rm -rf -- "${work:?}" - echo "installed apply-seccomp from sandbox-runtime $SANDBOX_RUNTIME_VERSION at $helper" # Installed bubblewrap can still fail to create a namespace (unprivileged # user namespaces off), and then every Bash call the model makes fails