diff --git a/.github/scripts/claude-lane-install-isolation.test.cjs b/.github/scripts/claude-lane-install-isolation.test.cjs index 02bab1fb..7efeed83 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,100 @@ 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, + ); +}); + +// 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.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 431063be..73a4bb63 100644 --- a/.github/workflows/pr-review-security.yml +++ b/.github/workflows/pr-review-security.yml @@ -664,12 +664,25 @@ 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. 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' - 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 +712,33 @@ 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 ;; + *) 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" + 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" "$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 + rm -rf -- "${work:?}" # 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..077218a2 100644 --- a/.github/workflows/pr-review.yml +++ b/.github/workflows/pr-review.yml @@ -804,12 +804,25 @@ 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. 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' - 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 +852,33 @@ 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 ;; + *) 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" + 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" "$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 + rm -rf -- "${work:?}" # Installed bubblewrap can still fail to create a namespace (unprivileged # user namespaces off), and then every Bash call the model makes fails