|
| 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 | +} |
0 commit comments