Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
100 changes: 100 additions & 0 deletions .github/scripts/claude-lane-install-isolation.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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");

Expand Down Expand Up @@ -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);
});
42 changes: 41 additions & 1 deletion .github/workflows/pr-review-security.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
42 changes: 41 additions & 1 deletion .github/workflows/pr-review.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
Loading