Skip to content

Commit c34c129

Browse files
authored
Merge pull request #47 from A3S-Lab/codex/fix-release-macos-intel-20260723
fix(release): restore managed sandbox hosts
2 parents 2610ac7 + 972561b commit c34c129

8 files changed

Lines changed: 415 additions & 29 deletions

File tree

Lines changed: 177 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,177 @@
1+
import assert from "node:assert/strict";
2+
import {
3+
mkdtempSync,
4+
mkdirSync,
5+
readFileSync,
6+
rmSync,
7+
writeFileSync,
8+
} from "node:fs";
9+
import { tmpdir } from "node:os";
10+
import { dirname, join, resolve } from "node:path";
11+
import { pathToFileURL } from "node:url";
12+
13+
const relativeRuntime = join(
14+
"node_modules",
15+
"@anthropic-ai",
16+
"sandbox-runtime",
17+
"dist",
18+
"sandbox",
19+
"linux-sandbox-utils.js",
20+
);
21+
22+
const denyOrderUpstream = ` const denyPaths = [
23+
...(writeConfig.denyWithinAllow || []),
24+
...(await linuxGetMandatoryDenyPaths(ripgrepConfig, mandatoryDenySearchDepth, allowGitConfig, abortSignal)),
25+
];`;
26+
27+
const denyOrderPatched = ` const denyPaths = [
28+
// Mandatory child paths must be mounted before caller-supplied parent
29+
// denies. Otherwise a read-only parent prevents bwrap from creating
30+
// a mount point for a non-existent mandatory child.
31+
...(await linuxGetMandatoryDenyPaths(ripgrepConfig, mandatoryDenySearchDepth, allowGitConfig, abortSignal)),
32+
...(writeConfig.denyWithinAllow || []),
33+
];`;
34+
35+
const seccompReadUpstream = ` const fsArgs = await generateFilesystemArgs(readConfig, writeConfig, maskedFileBinds, maskedFileStoreDir, ripgrepConfig, mandatoryDenySearchDepth, allowGitConfig, abortSignal);`;
36+
37+
const seccompReadPatched = ` // The outer sandbox can hide the user home before its inner seccomp
38+
// helper starts. Re-expose only the helper selected by this verified
39+
// runtime so Unix-socket filtering remains active inside that boundary.
40+
const seccompReadPath = !allowAllUnixSockets
41+
? seccompConfig?.argv0
42+
? seccompConfig.applyPath
43+
: getApplySeccompBinaryPath(seccompConfig?.applyPath)
44+
: undefined;
45+
const effectiveReadConfig = readConfig && seccompReadPath
46+
? {
47+
...readConfig,
48+
allowWithinDeny: [...(readConfig.allowWithinDeny || []), seccompReadPath],
49+
}
50+
: readConfig;
51+
const fsArgs = await generateFilesystemArgs(effectiveReadConfig, writeConfig, maskedFileBinds, maskedFileStoreDir, ripgrepConfig, mandatoryDenySearchDepth, allowGitConfig, abortSignal);`;
52+
53+
const missingAncestorUpstream = ` const firstNonExistent = findFirstNonExistentComponent(normalizedPath);
54+
// Fix 2: If firstNonExistent is an intermediate component (not the
55+
// leaf deny path itself), mount a read-only empty directory instead
56+
// of /dev/null. This prevents the component from appearing as a file
57+
// which breaks tools that expect to traverse it as a directory.`;
58+
59+
const missingAncestorPatched = ` const firstNonExistent = findFirstNonExistentComponent(normalizedPath);
60+
// Multiple child and parent denies can converge on the same first
61+
// missing component. The first read-only mount already protects the
62+
// entire subtree; emitting another can conflict on file-vs-directory
63+
// destination type and make bwrap refuse to start.
64+
if (seenDenyWriteMounts.has(firstNonExistent)) {
65+
continue;
66+
}
67+
seenDenyWriteMounts.add(firstNonExistent);
68+
// Fix 2: If firstNonExistent is an intermediate component (not the
69+
// leaf deny path itself), mount a read-only empty directory instead
70+
// of /dev/null. This prevents the component from appearing as a file
71+
// which breaks tools that expect to traverse it as a directory.`;
72+
73+
const mountSetUpstream = ` const seenDenyWrite = new Set();
74+
for (const pathPattern of denyPaths) {`;
75+
76+
const mountSetPatched = ` const seenDenyWrite = new Set();
77+
const seenDenyWriteMounts = new Set();
78+
for (const pathPattern of denyPaths) {`;
79+
80+
const replacements = [
81+
{
82+
name: "nested deny mount order",
83+
upstream: denyOrderUpstream,
84+
patched: denyOrderPatched,
85+
},
86+
{
87+
name: "seccomp helper read access",
88+
upstream: seccompReadUpstream,
89+
patched: seccompReadPatched,
90+
},
91+
{
92+
name: "missing ancestor mount deduplication",
93+
upstream: missingAncestorUpstream,
94+
patched: missingAncestorPatched,
95+
},
96+
{
97+
name: "missing ancestor mount tracking",
98+
upstream: mountSetUpstream,
99+
patched: mountSetPatched,
100+
},
101+
];
102+
103+
function occurrenceCount(source, needle) {
104+
return source.split(needle).length - 1;
105+
}
106+
107+
export function patchManagedSrtLinux(installRoot) {
108+
const runtime = join(resolve(installRoot), relativeRuntime);
109+
let source = readFileSync(runtime, "utf8");
110+
let changed = false;
111+
112+
for (const replacement of replacements) {
113+
const upstreamCount = occurrenceCount(source, replacement.upstream);
114+
const patchedCount = occurrenceCount(source, replacement.patched);
115+
if (upstreamCount === 0 && patchedCount === 1) {
116+
continue;
117+
}
118+
if (upstreamCount !== 1 || patchedCount !== 0) {
119+
throw new Error(
120+
`managed SRT Linux compatibility patch expected one ${replacement.name} ` +
121+
`upstream block in ${runtime}; found upstream=${upstreamCount}, ` +
122+
`patched=${patchedCount}`,
123+
);
124+
}
125+
source = source.replace(replacement.upstream, replacement.patched);
126+
changed = true;
127+
}
128+
129+
if (changed) {
130+
writeFileSync(runtime, source, "utf8");
131+
return "patched";
132+
}
133+
return "already-patched";
134+
}
135+
136+
function selfTest() {
137+
const root = mkdtempSync(join(tmpdir(), "a3s-managed-srt-patch-"));
138+
const runtime = join(root, relativeRuntime);
139+
try {
140+
mkdirSync(dirname(runtime), { recursive: true });
141+
const fixture = replacements
142+
.map((replacement) => replacement.upstream)
143+
.join("\n");
144+
writeFileSync(runtime, `prefix\n${fixture}\nsuffix\n`, "utf8");
145+
assert.equal(patchManagedSrtLinux(root), "patched");
146+
const patched = readFileSync(runtime, "utf8");
147+
for (const replacement of replacements) {
148+
assert.equal(occurrenceCount(patched, replacement.upstream), 0);
149+
assert.equal(occurrenceCount(patched, replacement.patched), 1);
150+
}
151+
assert.equal(patchManagedSrtLinux(root), "already-patched");
152+
153+
writeFileSync(runtime, "unexpected upstream source\n", "utf8");
154+
assert.throws(
155+
() => patchManagedSrtLinux(root),
156+
/expected one .* upstream block/,
157+
);
158+
} finally {
159+
rmSync(root, { recursive: true, force: true });
160+
}
161+
}
162+
163+
const invokedDirectly =
164+
process.argv[1] &&
165+
pathToFileURL(resolve(process.argv[1])).href === import.meta.url;
166+
167+
if (invokedDirectly) {
168+
if (process.argv[2] === "--self-test" && process.argv.length === 3) {
169+
selfTest();
170+
} else if (process.argv[2] && process.argv.length === 3) {
171+
process.stdout.write(`${patchManagedSrtLinux(process.argv[2])}\n`);
172+
} else {
173+
throw new Error(
174+
"usage: node patch-managed-srt-linux.mjs <install-root> | --self-test",
175+
);
176+
}
177+
}

.github/workflows/ci.yml

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,13 +9,92 @@ jobs:
99
- uses: actions/checkout@v4
1010
- uses: dtolnay/rust-toolchain@stable
1111
with: { components: "rustfmt, clippy" }
12+
- uses: actions/setup-node@v4
13+
with:
14+
node-version: 22.17.1
1215
- name: Use published A3S crates
1316
run: bash .github/scripts/use-published-a3s-crates.sh
17+
- name: Test managed sandbox compatibility patch
18+
run: node .github/scripts/patch-managed-srt-linux.mjs --self-test
1419
- run: cargo fmt --all -- --check
1520
- run: cargo clippy --all-targets -- -D warnings
1621
- run: cargo test --all-targets
1722
- run: cargo build --release
1823

24+
release-macos-sandbox:
25+
name: Verify release macOS sandbox host
26+
runs-on: macos-15-intel
27+
steps:
28+
- name: Exercise the native Seatbelt entrypoint
29+
run: |
30+
set -euo pipefail
31+
test -x /usr/bin/sandbox-exec
32+
/usr/bin/sandbox-exec \
33+
-p '(version 1) (allow default)' \
34+
/usr/bin/true
35+
36+
managed-srt-linux:
37+
name: Exercise release managed sandbox on Linux
38+
runs-on: ubuntu-22.04
39+
steps:
40+
- uses: actions/checkout@v4
41+
- uses: dtolnay/rust-toolchain@stable
42+
- uses: actions/setup-node@v4
43+
with:
44+
node-version: 22.17.1
45+
cache: npm
46+
cache-dependency-path: support/managed-srt/package-lock.json
47+
- name: Use published A3S crates
48+
run: bash .github/scripts/use-published-a3s-crates.sh
49+
- name: Prepare the exact managed sandbox release tree
50+
working-directory: support/managed-srt
51+
env:
52+
npm_config_registry: https://registry.npmjs.org/
53+
npm_config_replace_registry_host: never
54+
run: |
55+
set -euo pipefail
56+
npm ci \
57+
--ignore-scripts \
58+
--omit=dev \
59+
--no-audit \
60+
--no-fund \
61+
--engine-strict=true \
62+
--registry=https://registry.npmjs.org/ \
63+
--replace-registry-host=never
64+
node ../../.github/scripts/patch-managed-srt-linux.mjs .
65+
node -e '
66+
const fs = require("node:fs");
67+
for (const path of [
68+
"node_modules/.bin",
69+
"node_modules/.package-lock.json",
70+
"node_modules/@pondwader/socks5-server/.github",
71+
]) {
72+
fs.rmSync(path, { recursive: true, force: true });
73+
}
74+
'
75+
node ../../.github/scripts/verify-managed-srt-tree.mjs \
76+
. ../managed-srt.tree-sha256
77+
- name: Provision native sandbox prerequisites
78+
run: |
79+
set -euo pipefail
80+
sudo apt-get update
81+
sudo apt-get install --no-install-recommends --yes \
82+
bubblewrap \
83+
ripgrep \
84+
socat
85+
- name: Prove the complete packaged sandbox policy
86+
run: |
87+
set -euo pipefail
88+
cargo test --locked --lib \
89+
components::managed_srt::tests::real_packaged_payload_enforces_complete_local_command_policy \
90+
-- --ignored --exact --nocapture
91+
- name: Prove managed sandbox first-use installation
92+
run: |
93+
set -euo pipefail
94+
cargo test --locked --lib \
95+
components::managed_srt::tests::real_managed_first_use_installs_once_and_runs_without_host_fallback \
96+
-- --ignored --exact --nocapture
97+
1998
# Validate the TUI compiles for Windows + macOS so all three desktop
2099
# platforms can run `a3s code` (Linux is covered by `check` above).
21100
cross:

.github/workflows/release.yml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -501,6 +501,7 @@ jobs:
501501
--engine-strict=true \
502502
--registry=https://registry.npmjs.org/ \
503503
--replace-registry-host=never
504+
node ../../.github/scripts/patch-managed-srt-linux.mjs .
504505
node -e '
505506
const fs = require("node:fs");
506507
for (const path of [
@@ -538,7 +539,7 @@ jobs:
538539
matrix:
539540
include:
540541
- { platform: linux, os: ubuntu-22.04 }
541-
- { platform: macos, os: macos-15 }
542+
- { platform: macos, os: macos-15-intel }
542543
runs-on: ${{ matrix.os }}
543544
steps:
544545
- uses: actions/checkout@v4

CHANGELOG.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1414
- Resolved user-scoped configuration and component paths through native Windows
1515
profile variables when `HOME` is absent, so commands such as
1616
`a3s install webview` work in PowerShell and clean Windows CI environments.
17+
- Pinned managed sandbox release verification to the x64 macOS 15 runner that
18+
still provides Seatbelt, with a pull-request guard against runner drift.
19+
- Ordered mandatory Linux sandbox child mounts before A3S's stricter parent
20+
denies and collapsed mounts that converge on the same missing ancestor,
21+
preventing bubblewrap startup failures in both bundled and first-use managed
22+
sandbox installations. The runtime's immutable seccomp helper also remains
23+
readable when the surrounding user home is hidden, without weakening either
24+
policy boundary.
1725
- Made local Claude Code, Codex, Kimi Code, and WorkBuddy account discovery
1826
fall back to the native Windows user profile when `HOME` is unset. WorkBuddy
1927
now locates its bundled CodeBuddy CLI through standard Windows installation

0 commit comments

Comments
 (0)