@opendatalabs/personal-server-ts-server 1.12.0 → 1.12.1-canary.0bee0a7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/app.d.ts +16 -1
- package/dist/app.d.ts.map +1 -1
- package/dist/app.js +38 -0
- package/dist/app.js.map +1 -1
- package/dist/bootstrap.d.ts +12 -2
- package/dist/bootstrap.d.ts.map +1 -1
- package/dist/bootstrap.js +36 -0
- package/dist/bootstrap.js.map +1 -1
- package/dist/query/mcp-ask-port.d.ts +33 -0
- package/dist/query/mcp-ask-port.d.ts.map +1 -0
- package/dist/query/mcp-ask-port.js +61 -0
- package/dist/query/mcp-ask-port.js.map +1 -0
- package/dist/query/node-sandbox.d.ts +96 -0
- package/dist/query/node-sandbox.d.ts.map +1 -0
- package/dist/query/node-sandbox.js +756 -0
- package/dist/query/node-sandbox.js.map +1 -0
- package/dist/query/pkg-runtime.d.ts +109 -0
- package/dist/query/pkg-runtime.d.ts.map +1 -0
- package/dist/query/pkg-runtime.js +196 -0
- package/dist/query/pkg-runtime.js.map +1 -0
- package/dist/query/query-service.d.ts +281 -0
- package/dist/query/query-service.d.ts.map +1 -0
- package/dist/query/query-service.js +462 -0
- package/dist/query/query-service.js.map +1 -0
- package/dist/query/read-paths.d.ts +35 -0
- package/dist/query/read-paths.d.ts.map +1 -0
- package/dist/query/read-paths.js +106 -0
- package/dist/query/read-paths.js.map +1 -0
- package/dist/query/runner-entry.d.ts +68 -0
- package/dist/query/runner-entry.d.ts.map +1 -0
- package/dist/query/runner-entry.js +182 -0
- package/dist/query/runner-entry.js.map +1 -0
- package/dist/query/runner.js +7190 -0
- package/dist/query/sandbox-tool-host.d.ts +106 -0
- package/dist/query/sandbox-tool-host.d.ts.map +1 -0
- package/dist/query/sandbox-tool-host.js +320 -0
- package/dist/query/sandbox-tool-host.js.map +1 -0
- package/dist/query/search-bridge.d.ts +31 -0
- package/dist/query/search-bridge.d.ts.map +1 -0
- package/dist/query/search-bridge.js +66 -0
- package/dist/query/search-bridge.js.map +1 -0
- package/dist/routes/mcp.d.ts +7 -1
- package/dist/routes/mcp.d.ts.map +1 -1
- package/dist/routes/mcp.js +6 -0
- package/dist/routes/mcp.js.map +1 -1
- package/dist/routes/query.d.ts +72 -0
- package/dist/routes/query.d.ts.map +1 -0
- package/dist/routes/query.js +241 -0
- package/dist/routes/query.js.map +1 -0
- package/dist/storage/question-store.d.ts.map +1 -1
- package/dist/storage/question-store.js +43 -9
- package/dist/storage/question-store.js.map +1 -1
- package/dist/ui/ps-lite-debug.js +13723 -7697
- package/package.json +7 -5
|
@@ -0,0 +1,756 @@
|
|
|
1
|
+
import { execFileSync, spawn } from "node:child_process";
|
|
2
|
+
import { existsSync, mkdtempSync, realpathSync, rmSync, writeFileSync, } from "node:fs";
|
|
3
|
+
import { createRequire } from "node:module";
|
|
4
|
+
import { homedir } from "node:os";
|
|
5
|
+
import { dirname, join } from "node:path";
|
|
6
|
+
import { resolveRootPath } from "../config/paths.js";
|
|
7
|
+
import { isPackagedBinary, materializeFromSnapshot, packagedBinDir, resolveSandboxNodePath, } from "./pkg-runtime.js";
|
|
8
|
+
/**
|
|
9
|
+
* Node implementation of the query-layer {@link Sandbox}.
|
|
10
|
+
*
|
|
11
|
+
* Two layers, per design §19.7:
|
|
12
|
+
*
|
|
13
|
+
* - **OS enforcement** comes from `@anthropic-ai/sandbox-runtime` (ASRT):
|
|
14
|
+
* Seatbelt on macOS, bubblewrap+seccomp on Linux, a restricted user on
|
|
15
|
+
* Windows. ASRT is a *command wrapper* — `wrapWithSandboxArgv` hands back
|
|
16
|
+
* an argv and an env for us to spawn — not a process supervisor.
|
|
17
|
+
* - **Resource limits are entirely ours.** ASRT has no CPU, memory, or
|
|
18
|
+
* process-count surface at all (verified against 0.0.74's shipped config
|
|
19
|
+
* schema: 16 top-level keys, none resource-related, and no
|
|
20
|
+
* `rlimit`/`setrlimit`/`cgroup` string anywhere in its JS). Because ASRT
|
|
21
|
+
* never spawns the process it could not impose them even in principle. So
|
|
22
|
+
* we impose them at spawn, and report honestly which ones the platform
|
|
23
|
+
* actually enforces.
|
|
24
|
+
*
|
|
25
|
+
* ## The read-policy trap
|
|
26
|
+
*
|
|
27
|
+
* ASRT's read model is **allow-by-default, deny-then-allow**: `allowRead`
|
|
28
|
+
* *re-allows* within regions denied by `denyRead`, and on its own it does
|
|
29
|
+
* nothing. A configuration of `allowRead: [grantedPaths]` with no `denyRead`
|
|
30
|
+
* leaves the entire filesystem readable — measured: such a config read
|
|
31
|
+
* `/etc/passwd` in full. `readPaths` in {@link SandboxSpec} is written as an
|
|
32
|
+
* allowlist, so this implementation converts it into deny-broad +
|
|
33
|
+
* allow-narrow rather than passing it through.
|
|
34
|
+
*/
|
|
35
|
+
const SANDBOX_RUNTIME_VERSION = "0.0.74";
|
|
36
|
+
/**
|
|
37
|
+
* Process-wide serialisation of sandboxed runs.
|
|
38
|
+
*
|
|
39
|
+
* ASRT exports `SandboxManager` as a module-level **singleton**, so its
|
|
40
|
+
* filesystem and network policy is process-global. Two overlapping
|
|
41
|
+
* `initialize()` calls therefore clobber each other, and this is not
|
|
42
|
+
* theoretical: with a per-instance lock, two concurrent runs under different
|
|
43
|
+
* grants left one of them unable to read even its own script, silently
|
|
44
|
+
* producing an empty result. A per-instance queue cannot fix that because
|
|
45
|
+
* the contended resource is not per-instance — the lock has to live where
|
|
46
|
+
* the singleton does.
|
|
47
|
+
*
|
|
48
|
+
* The cost is that query throughput is one script at a time per process.
|
|
49
|
+
* That is the correct trade until ASRT exposes a per-run manager: the
|
|
50
|
+
* alternative is a run executing under another consumer's read policy.
|
|
51
|
+
*/
|
|
52
|
+
let globalRunQueue = Promise.resolve();
|
|
53
|
+
/**
|
|
54
|
+
* Regions denied wholesale before the granted paths are re-allowed.
|
|
55
|
+
*
|
|
56
|
+
* We deny the data-bearing regions rather than `/`, because the sandboxed
|
|
57
|
+
* process still has to read the Node binary and the system libraries it
|
|
58
|
+
* links against. `/usr`, `/System` and `/bin` therefore stay readable; they
|
|
59
|
+
* hold no user data. Everything that could hold user data, credentials or
|
|
60
|
+
* another request's scratch is denied here.
|
|
61
|
+
*/
|
|
62
|
+
function broadDenyRead() {
|
|
63
|
+
const denies = [
|
|
64
|
+
homedir(),
|
|
65
|
+
"/etc",
|
|
66
|
+
"/private/etc",
|
|
67
|
+
// Temp roots hold other processes' scratch, including other requests'.
|
|
68
|
+
// Our own run directory is re-allowed by realpath in the allow list.
|
|
69
|
+
// Note: /var itself is NOT denied — `/var/select/sh` is how macOS
|
|
70
|
+
// resolves /bin/sh, and denying it stops the sandbox launching at all.
|
|
71
|
+
"/tmp",
|
|
72
|
+
"/private/tmp",
|
|
73
|
+
"/var/tmp",
|
|
74
|
+
"/private/var/tmp",
|
|
75
|
+
"/var/folders",
|
|
76
|
+
"/private/var/folders",
|
|
77
|
+
"/Volumes",
|
|
78
|
+
"/mnt",
|
|
79
|
+
"/media",
|
|
80
|
+
"/home",
|
|
81
|
+
"/root",
|
|
82
|
+
"/proc",
|
|
83
|
+
"/sys",
|
|
84
|
+
];
|
|
85
|
+
return [...new Set(denies)];
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* macOS reaches most of these through symlinks (`/var` -> `/private/var`,
|
|
89
|
+
* `/tmp` -> `/private/tmp`), and the kernel policy matches on the *resolved*
|
|
90
|
+
* path. An allow entry that is not resolved therefore fails to re-open a
|
|
91
|
+
* region a resolved deny entry closed — which manifests as the sandbox being
|
|
92
|
+
* unable to read its own script. Resolve every allow entry.
|
|
93
|
+
*/
|
|
94
|
+
function realOrSelf(p) {
|
|
95
|
+
try {
|
|
96
|
+
return realpathSync(p);
|
|
97
|
+
}
|
|
98
|
+
catch {
|
|
99
|
+
return p;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* ASRT's own default write paths, which it grants unconditionally and which
|
|
104
|
+
* this sandbox has to take back.
|
|
105
|
+
*
|
|
106
|
+
* `SandboxManager` builds the write policy as
|
|
107
|
+
* `allowOnly: [...getDefaultWritePaths(), ...userAllowWrite]` — the defaults
|
|
108
|
+
* are unioned in regardless of what the caller asked for, and ASRT's source
|
|
109
|
+
* carries its own warning that they "are intentionally broad for
|
|
110
|
+
* compatibility but may allow access to files from other processes". Nothing
|
|
111
|
+
* in this file's config requested them. Measured before this deny list
|
|
112
|
+
* existed, with the suite's ordinary spec:
|
|
113
|
+
*
|
|
114
|
+
* - **macOS**: a sandboxed script wrote `~/.npm/_logs/PROBE-ESCAPE.txt`,
|
|
115
|
+
* `~/.claude/debug/PROBE-ESCAPE.txt` and `/private/tmp/claude/PROBE-ESCAPE.txt`,
|
|
116
|
+
* and all three appeared on the host holding the script's bytes. Reading
|
|
117
|
+
* them back was denied (`EPERM`) — a write-only escape.
|
|
118
|
+
* - **Linux**: the same writes landed on the host *and* the directories' real
|
|
119
|
+
* contents were readable, a host-planted canary included. A read-write
|
|
120
|
+
* escape.
|
|
121
|
+
*
|
|
122
|
+
* ASRT maps `filesystem.denyWrite` to `denyWithinAllow`, which is applied
|
|
123
|
+
* *within* `allowOnly` — so naming these here is the one supported way to
|
|
124
|
+
* subtract from a set we never added to. It closes the write half on both
|
|
125
|
+
* platforms; see {@link buildEnforcement} for the read half, which 0.0.74
|
|
126
|
+
* cannot close.
|
|
127
|
+
*
|
|
128
|
+
* **Derived from ASRT's own list, not a copy of it.** At 0.0.74 that list is
|
|
129
|
+
* the four paths above plus six character devices, and this takes the
|
|
130
|
+
* complement of {@link isCharacterDevice} — so it denies exactly what ASRT
|
|
131
|
+
* grants, minus what a process needs to produce output. A hardcoded copy
|
|
132
|
+
* would go stale silently: a future release adding a seventh default write
|
|
133
|
+
* path would widen the writable set while a deny list naming only today's
|
|
134
|
+
* four stayed green. Deriving makes a new path denied by default, so the
|
|
135
|
+
* failure mode of a dependency bump is a loud broken run rather than a quiet
|
|
136
|
+
* escape. That is the direction this file errs in everywhere else.
|
|
137
|
+
*
|
|
138
|
+
* Both spellings of each path are emitted. On macOS `/tmp` is a symlink to
|
|
139
|
+
* `/private/tmp`, and the two spellings were measured to behave *differently*
|
|
140
|
+
* under ASRT's allow rules — a write to `/tmp/claude/X` was refused while the
|
|
141
|
+
* same host file reached through `/private/tmp/claude/X` succeeded. A deny
|
|
142
|
+
* list that names only one spelling therefore leaves the other open.
|
|
143
|
+
*
|
|
144
|
+
* Exported for the drift guard in `hostile-scripts.test.ts`, which drives
|
|
145
|
+
* ASRT's real list through a sandboxed run.
|
|
146
|
+
*/
|
|
147
|
+
export function asrtDefaultWritePathDenials(defaultWritePaths) {
|
|
148
|
+
const literal = defaultWritePaths.filter((p) => !isCharacterDevice(p));
|
|
149
|
+
return [...new Set([...literal, ...literal.map(realOrSelf)])];
|
|
150
|
+
}
|
|
151
|
+
/**
|
|
152
|
+
* The entries of `getDefaultWritePaths()` that must stay writable: the
|
|
153
|
+
* character devices a process needs in order to produce any output at all
|
|
154
|
+
* (`/dev/stdout`, `/dev/stderr`, `/dev/null`, `/dev/tty`, and macOS's
|
|
155
|
+
* `/dev/dtracehelper` and `/dev/autofs_nowait`). They hold no user data and
|
|
156
|
+
* carry nothing off the host. Everything else in that list is denied.
|
|
157
|
+
*
|
|
158
|
+
* Matched by prefix rather than by an enumerated set, deliberately: the point
|
|
159
|
+
* of deriving the deny list is that a path ASRT adds in a future release is
|
|
160
|
+
* denied by default. Enumerating the safe ones and denying the rest keeps that
|
|
161
|
+
* true; enumerating the *unsafe* ones would not.
|
|
162
|
+
*/
|
|
163
|
+
function isCharacterDevice(p) {
|
|
164
|
+
return p === "/dev" || p.startsWith("/dev/");
|
|
165
|
+
}
|
|
166
|
+
/**
|
|
167
|
+
* The one host binary the Linux sandbox must be able to exec *inside* its own
|
|
168
|
+
* namespace.
|
|
169
|
+
*
|
|
170
|
+
* ASRT's Linux path is two-stage: `bwrap` builds the namespace, then execs
|
|
171
|
+
* ASRT's own `apply-seccomp` helper inside it to install the seccomp filter
|
|
172
|
+
* and become PID 1 of a nested PID namespace. ASRT resolves that helper on
|
|
173
|
+
* the **host** — a plain `existsSync` over its vendor directory — and then
|
|
174
|
+
* uses the same absolute path inside the namespace.
|
|
175
|
+
*
|
|
176
|
+
* The trap is that `denyRead` on Linux is not a kernel policy the way
|
|
177
|
+
* Seatbelt's is: it is `--tmpfs <dir>`. A denied directory is *replaced*, so
|
|
178
|
+
* nothing beneath it exists in the namespace at all. {@link broadDenyRead}
|
|
179
|
+
* denies `/home` and the user's home directory, and a checkout under either
|
|
180
|
+
* — `/home/runner/work/...` on a GitHub runner, `~/src/...` on a Linux
|
|
181
|
+
* workstation — puts
|
|
182
|
+
* `node_modules/@anthropic-ai/sandbox-runtime/vendor/seccomp/<arch>/apply-seccomp`
|
|
183
|
+
* inside that tmpfs. The helper then fails to exec with ENOENT and *every*
|
|
184
|
+
* run dies before the script starts, control cases included.
|
|
185
|
+
*
|
|
186
|
+
* Measured on ubuntu-24.04 / x86_64: `ls` of the helper inside
|
|
187
|
+
* `bwrap --ro-bind / / --tmpfs /home` reports "No such file or directory",
|
|
188
|
+
* and a single `--ro-bind <helper> <helper>` restores it. That is what
|
|
189
|
+
* putting the helper in `allowRead` emits.
|
|
190
|
+
*
|
|
191
|
+
* macOS never exercises this: Seatbelt applies a kernel policy over the real
|
|
192
|
+
* filesystem — nothing is unmounted — and needs no helper binary at all.
|
|
193
|
+
*
|
|
194
|
+
* Re-allowing this one file is deliberately *not* a widening of the
|
|
195
|
+
* grant-derived read allowlist. It is a statically linked binary shipped
|
|
196
|
+
* inside ASRT, holding no user data, in a namespace where `/usr`, `/bin` and
|
|
197
|
+
* `/lib` are already readable through bwrap's `--ro-bind / /`. No path that
|
|
198
|
+
* could carry user data becomes readable, so the property
|
|
199
|
+
* `hostile-scripts.test.ts` asserts — a script reads its granted scope paths
|
|
200
|
+
* and the run scratch dir, nothing else — is unchanged.
|
|
201
|
+
*
|
|
202
|
+
* The path is also handed back to ASRT as `seccomp.applyPath`, which it uses
|
|
203
|
+
* verbatim ahead of its own lookup. That makes "the path we re-allow" and
|
|
204
|
+
* "the path it execs" the same string by construction rather than by two
|
|
205
|
+
* lookups happening to agree.
|
|
206
|
+
*/
|
|
207
|
+
function linuxSeccompHelperPath(materializeDir) {
|
|
208
|
+
if (process.platform !== "linux")
|
|
209
|
+
return undefined;
|
|
210
|
+
// Mirrors ASRT's own `getVendorArchitecture()`; it ships no other builds.
|
|
211
|
+
const arch = process.arch === "x64" ? "x64" : process.arch === "arm64" ? "arm64" : null;
|
|
212
|
+
if (arch === null)
|
|
213
|
+
return undefined;
|
|
214
|
+
try {
|
|
215
|
+
const resolveFrom = createRequire(import.meta.url);
|
|
216
|
+
const packageRoot = dirname(resolveFrom.resolve("@anthropic-ai/sandbox-runtime/package.json"));
|
|
217
|
+
const helper = join(packageRoot, "vendor", "seccomp", arch, "apply-seccomp");
|
|
218
|
+
if (!existsSync(helper))
|
|
219
|
+
return undefined;
|
|
220
|
+
// Under `pkg` the vendored helper resolves onto the virtual snapshot
|
|
221
|
+
// filesystem. This process can read it, but bwrap cannot bind a snapshot
|
|
222
|
+
// path and the kernel cannot exec one — and both are exactly what happens
|
|
223
|
+
// to this string. Copy it out to `<root>/bin/` first; off `pkg` the path
|
|
224
|
+
// is already real and this returns it untouched.
|
|
225
|
+
return materializeDir === undefined
|
|
226
|
+
? helper
|
|
227
|
+
: materializeFromSnapshot(helper, materializeDir, {
|
|
228
|
+
version: `${SANDBOX_RUNTIME_VERSION}_${arch}`,
|
|
229
|
+
mode: 0o755,
|
|
230
|
+
});
|
|
231
|
+
}
|
|
232
|
+
catch {
|
|
233
|
+
// Nothing to re-allow. ASRT falls back to its own lookup and, if that
|
|
234
|
+
// also fails, reports seccomp as unavailable through `checkDependencies`.
|
|
235
|
+
return undefined;
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
/**
|
|
239
|
+
* Build the child's environment from scratch rather than inheriting.
|
|
240
|
+
*
|
|
241
|
+
* `wrapWithSandboxArgv` returns an env derived from `process.env`, so
|
|
242
|
+
* spreading it hands the host's entire environment — API keys, tokens,
|
|
243
|
+
* `INFERENCE_API_KEY` — to model-authored code. No filesystem policy covers
|
|
244
|
+
* that channel: the values are already in memory. Measured: a canary set in
|
|
245
|
+
* the parent was readable inside the sandbox when the returned env was
|
|
246
|
+
* spread.
|
|
247
|
+
*
|
|
248
|
+
* So we take only the variables ASRT *introduced or changed* (its proxy and
|
|
249
|
+
* sandbox plumbing, which the wrapper needs to work) and drop every variable
|
|
250
|
+
* inherited unchanged from the host.
|
|
251
|
+
*/
|
|
252
|
+
function minimalEnv(sandboxEnv, scratch) {
|
|
253
|
+
const out = {};
|
|
254
|
+
for (const [k, v] of Object.entries(sandboxEnv)) {
|
|
255
|
+
if (v === undefined)
|
|
256
|
+
continue;
|
|
257
|
+
// Only what the sandbox wrapper itself added or rewrote.
|
|
258
|
+
if (process.env[k] !== v)
|
|
259
|
+
out[k] = v;
|
|
260
|
+
}
|
|
261
|
+
// Applied *after* the loop, not before it. These four name where the run
|
|
262
|
+
// lives, and the run's own scratch dir is the answer to all of them, so a
|
|
263
|
+
// value the wrapper introduced must not overwrite them. ASRT does introduce
|
|
264
|
+
// one: `TMPDIR=/tmp/claude`. Note this is necessary but *not* sufficient —
|
|
265
|
+
// see {@link scratchTmpdirPreamble} for the half that actually wins.
|
|
266
|
+
out.PATH = "/usr/bin:/bin:/usr/sbin:/sbin";
|
|
267
|
+
out.HOME = scratch;
|
|
268
|
+
out.TMPDIR = scratch;
|
|
269
|
+
out.LANG = "C";
|
|
270
|
+
return out;
|
|
271
|
+
}
|
|
272
|
+
/** Wall-clock grace beyond the CPU limit before we escalate to SIGKILL. */
|
|
273
|
+
const KILL_GRACE_MS = 2_000;
|
|
274
|
+
/** How often the RSS watchdog samples. Memory is bounded by sampling on
|
|
275
|
+
* every platform, not by a kernel limit — see {@link buildEnforcement}. */
|
|
276
|
+
const RSS_POLL_MS = 50;
|
|
277
|
+
function shQuote(s) {
|
|
278
|
+
return `'${s.replaceAll("'", `'\\''`)}'`;
|
|
279
|
+
}
|
|
280
|
+
/**
|
|
281
|
+
* Pin `TMPDIR` (and `HOME`) to the run's own scratch dir, from inside the
|
|
282
|
+
* sandboxed shell, where it is the last word.
|
|
283
|
+
*
|
|
284
|
+
* ASRT's `generateProxyEnvVars` emits `TMPDIR=/tmp/claude` — one of the
|
|
285
|
+
* default write paths {@link asrtDefaultWritePathDenials} takes back — so
|
|
286
|
+
* that "temp-file writers land in a path the FS sandbox allows". It does not
|
|
287
|
+
* hand that back only in the env object: `wrapWithSandboxArgv` builds the
|
|
288
|
+
* argv as `env TMPDIR=… … /usr/bin/sandbox-exec -p <profile> /bin/sh -c <cmd>`
|
|
289
|
+
* (`bwrap --setenv` on Linux). The assignment is therefore applied by `env`
|
|
290
|
+
* at exec time, *after* the environment {@link minimalEnv} composed — which
|
|
291
|
+
* is why ordering `minimalEnv` correctly is necessary but cannot win on its
|
|
292
|
+
* own. Measured: with `TMPDIR: scratch` set after that loop, a run still
|
|
293
|
+
* reported `os.tmpdir() === "/tmp/claude"`.
|
|
294
|
+
*
|
|
295
|
+
* The inner `/bin/sh -c` runs after every one of those layers, so an
|
|
296
|
+
* assignment here is the one that the script observes.
|
|
297
|
+
*
|
|
298
|
+
* Why it matters rather than being tidiness: `/tmp/claude` is a shared host
|
|
299
|
+
* directory. Before this, `os.tmpdir()` inside a run pointed at it, so every
|
|
300
|
+
* library that writes a temp file was aimed out of the sandbox by default —
|
|
301
|
+
* the escape's most likely route, reached without a script ever naming a path.
|
|
302
|
+
* And ASRT's own stated intent was not even being met: measured on macOS
|
|
303
|
+
* before this change, `mkdtempSync(os.tmpdir())` failed with `EPERM`, so the
|
|
304
|
+
* "writable temp dir" was already broken while the path stayed live enough to
|
|
305
|
+
* escape through. Redirecting to the scratch dir serves that intent properly
|
|
306
|
+
* — it is the one location `allowWrite` grants, it is per-run, and it is swept
|
|
307
|
+
* afterwards — and is what makes denying `/tmp/claude` safe rather than merely
|
|
308
|
+
* strict.
|
|
309
|
+
*
|
|
310
|
+
* Nothing in ASRT needs `/tmp/claude` for itself: it is a command wrapper, its
|
|
311
|
+
* proxy runs on the host outside this policy, and `TMPDIR` is the only use of
|
|
312
|
+
* that path anywhere in its shipped JS.
|
|
313
|
+
*/
|
|
314
|
+
function scratchTmpdirPreamble(scratch) {
|
|
315
|
+
const q = shQuote(scratch);
|
|
316
|
+
return `TMPDIR=${q}; HOME=${q}; export TMPDIR HOME`;
|
|
317
|
+
}
|
|
318
|
+
/**
|
|
319
|
+
* Build the `ulimit` preamble. These are POSIX shell built-ins that call
|
|
320
|
+
* `setrlimit(2)` before `exec`, so they apply to the Node process and every
|
|
321
|
+
* child it spawns.
|
|
322
|
+
*
|
|
323
|
+
* The shell is not the same shell on both platforms, and that matters more
|
|
324
|
+
* than it looks: macOS `/bin/sh` is bash, Ubuntu's is **dash**, and their
|
|
325
|
+
* `ulimit` built-ins do not accept the same options. A limit spelled for one
|
|
326
|
+
* is silently absent on the other, because every line here is guarded with
|
|
327
|
+
* `|| true` so that a kernel or shell rejecting one limit cannot abort the
|
|
328
|
+
* run. Silence is the price of that guard, so each limit below is written to
|
|
329
|
+
* work on both, and each claim is measured.
|
|
330
|
+
*
|
|
331
|
+
* Platform reality, measured on macOS 26.5 / arm64 and on ubuntu-24.04:
|
|
332
|
+
* - `ulimit -t` (RLIMIT_CPU) works on both, with the soft/hard split below.
|
|
333
|
+
* - RLIMIT_NPROC is `-u` in bash and `-p` in dash, so both are attempted.
|
|
334
|
+
* - `ulimit -v` (RLIMIT_AS) is emitted on **neither**, and this is the
|
|
335
|
+
* deliberate part.
|
|
336
|
+
*
|
|
337
|
+
* RLIMIT_AS bounds *virtual address space*, which is not the quantity
|
|
338
|
+
* `memoryMb` is about. V8 reserves a large virtual range up front regardless
|
|
339
|
+
* of how small its heap is, so `ulimit -v` set to a realistic memory budget
|
|
340
|
+
* does not cap a runaway allocation — it stops Node from starting at all.
|
|
341
|
+
* Measured on ubuntu-24.04: `ulimit -v 262144` (the suite's 256MB budget)
|
|
342
|
+
* followed by `node -e 'console.log(42)'` never reaches the script. macOS
|
|
343
|
+
* rejects the limit outright ("cannot modify limit: Invalid argument") and a
|
|
344
|
+
* 4GB allocation then succeeds, so it never enforced anything there either.
|
|
345
|
+
*
|
|
346
|
+
* An unusably coarse limit is not containment, and emitting it under
|
|
347
|
+
* `|| true` made it look like one. Memory is bounded by the RSS watchdog in
|
|
348
|
+
* `runOne`, on every platform, and {@link buildEnforcement} says so.
|
|
349
|
+
*/
|
|
350
|
+
function ulimitPreamble(spec) {
|
|
351
|
+
const cpuSeconds = Math.max(1, Math.ceil(spec.cpuMs / 1000));
|
|
352
|
+
const parts = [
|
|
353
|
+
// RLIMIT_CPU, deliberately as a *split* soft/hard pair: `ulimit -t N`
|
|
354
|
+
// sets both, then `-S -t` lowers the soft one. With soft == hard the
|
|
355
|
+
// kernel raises SIGXCPU and escalates to SIGKILL in the same instant, and
|
|
356
|
+
// the run surfaces as a bare exit 137 that `runOne` cannot tell from any
|
|
357
|
+
// other kill — measured on ubuntu-24.04, where the budget fired within
|
|
358
|
+
// 21ms of `cpuMs` and was still classified `error` rather than `cpu`.
|
|
359
|
+
// (macOS delivered SIGXCPU cleanly, which is why this went unseen.) One
|
|
360
|
+
// second of headroom lets SIGXCPU land first — exit 152, which `runOne`
|
|
361
|
+
// does recognise — while the hard limit remains the backstop for a
|
|
362
|
+
// process that tries to ignore it.
|
|
363
|
+
`ulimit -t ${cpuSeconds + 1} 2>/dev/null || true`,
|
|
364
|
+
`ulimit -S -t ${cpuSeconds} 2>/dev/null || true`,
|
|
365
|
+
`ulimit -c 0 2>/dev/null || true`,
|
|
366
|
+
];
|
|
367
|
+
if (spec.maxProcesses !== undefined) {
|
|
368
|
+
// RLIMIT_NPROC. `-u` is the bash spelling; dash's ulimit has no `-u` at
|
|
369
|
+
// all and spells this limit `-p`. Emitting only `-u` under `|| true` left
|
|
370
|
+
// the process cap silently absent on every Linux run while
|
|
371
|
+
// `enforcement.processCount` went on claiming it — measured: a
|
|
372
|
+
// 400-process fork bomb under `maxProcesses: 24` produced
|
|
373
|
+
// "ulimit: Illegal option -u" and ran until the *memory* watchdog stopped
|
|
374
|
+
// it. `-p` is pipe size in bash, so it is only ever reached on a shell
|
|
375
|
+
// where `-u` failed.
|
|
376
|
+
parts.push(`ulimit -u ${spec.maxProcesses} 2>/dev/null || ulimit -p ${spec.maxProcesses} 2>/dev/null || true`);
|
|
377
|
+
}
|
|
378
|
+
return parts.join("; ");
|
|
379
|
+
}
|
|
380
|
+
/**
|
|
381
|
+
* What this platform actually enforces. `false` is a fact to surface, not to
|
|
382
|
+
* hide — plan §4.3, "reduced capability must be visible".
|
|
383
|
+
*/
|
|
384
|
+
function buildEnforcement(sandboxAvailable) {
|
|
385
|
+
const notes = [];
|
|
386
|
+
if (!sandboxAvailable) {
|
|
387
|
+
notes.push("OS sandbox unavailable on this platform/installation; filesystem and network are NOT confined");
|
|
388
|
+
}
|
|
389
|
+
if (sandboxAvailable && process.platform === "linux") {
|
|
390
|
+
// Not a reduced capability, but a materially different shape of denial,
|
|
391
|
+
// and a consumer that assumes the macOS shape will misread it. Measured
|
|
392
|
+
// on ubuntu-24.04 (both architectures) — see design §19.7.1.
|
|
393
|
+
notes.push("Linux confines by namespace, not by kernel permission check: a read-denied directory is replaced with an empty private tmpfs rather than returning EACCES, so operations against it succeed against nothing instead of failing. Measured: the home directory lists only sandbox scaffolding and none of the host's contents; a write outside the scratch dir appears to succeed into that tmpfs and is discarded, with no file ever created on the host; /proc is a fresh namespace-private procfs holding two PIDs and the run's own already-scrubbed environ; and a listening socket binds inside an unshared network namespace that nothing outside can reach. Confinement holds in every one of those cases — but a script observing its own syscalls sees success where macOS returns an error.");
|
|
394
|
+
notes.push("KNOWN GAP, Linux: the contents of ASRT's default write paths — ~/.npm/_logs, ~/.claude/debug, /tmp/claude, /private/tmp/claude — are readable inside the sandbox. ASRT unconditionally unions getDefaultWritePaths() into the write allow-list, which on Linux means a bind mount that survives our denyRead of the home directory. Writing to them is now denied (see the all-platforms note), but denyWrite maps to denyWithinAllow, which makes the bind read-only rather than absent: the path stays mounted in order to carry the write allowance it is being denied. So the read half is NOT closable through the 0.0.74 config, and remains open. Measured: a host-planted file in ~/.npm/_logs was read back in full from inside a run. Closing it needs an ASRT release that stops unioning these paths in, or a config surface for opting out of them.");
|
|
395
|
+
}
|
|
396
|
+
if (sandboxAvailable) {
|
|
397
|
+
// Both platforms. Reported unconditionally: the write escape is closed
|
|
398
|
+
// but the fact that it took an explicit deny to close it — and that the
|
|
399
|
+
// paths are still mounted — is a property of this dependency a consumer
|
|
400
|
+
// should be told about, not a detail to drop once it is handled.
|
|
401
|
+
notes.push("ASRT unions its own getDefaultWritePaths() — ~/.npm/_logs, ~/.claude/debug, /tmp/claude, /private/tmp/claude — into the write allow-list regardless of our allowWrite, and its own source warns they 'may allow access to files from other processes'. Left alone this is a real escape: measured, a sandboxed script's writes to all four landed on the real host filesystem, on macOS (write only; reads denied EPERM) and on Linux (read and write). We take them back via filesystem.denyWrite, which ASRT applies as denyWithinAllow — verified to refuse the same writes with EPERM on macOS and EROFS on Linux. The scratch dir is the only writable location. TMPDIR is pinned to the scratch dir for the same reason: ASRT otherwise sets it to /tmp/claude, which would send every os.tmpdir() write at a denied path.");
|
|
402
|
+
}
|
|
403
|
+
// The memory caveat is the same on both platforms, and used to be reported
|
|
404
|
+
// only on macOS because Linux was assumed to have a kernel limit behind it.
|
|
405
|
+
// It does not: see `ulimitPreamble`. Claiming a limit we do not impose is
|
|
406
|
+
// exactly the kind of thing `SandboxEnforcement` exists to prevent.
|
|
407
|
+
notes.push("Total memory is bounded by an out-of-process RSS watchdog sampling every " +
|
|
408
|
+
RSS_POLL_MS +
|
|
409
|
+
"ms, on every platform, so a burst allocation between samples can briefly exceed memoryMb. No kernel limit backs it: macOS rejects RLIMIT_AS outright and bounds neither Buffer nor external memory through RLIMIT_DATA or --max-old-space-size (measured: 4GB allocated under all three), and on Linux RLIMIT_AS bounds virtual address space rather than resident memory — set to a realistic memoryMb it stops Node starting at all rather than capping it.");
|
|
410
|
+
return {
|
|
411
|
+
filesystemRead: sandboxAvailable,
|
|
412
|
+
filesystemWrite: sandboxAvailable,
|
|
413
|
+
network: sandboxAvailable,
|
|
414
|
+
cpu: true,
|
|
415
|
+
memory: true,
|
|
416
|
+
processCount: true,
|
|
417
|
+
wallClock: true,
|
|
418
|
+
notes,
|
|
419
|
+
};
|
|
420
|
+
}
|
|
421
|
+
export function createNodeSandbox(options) {
|
|
422
|
+
let manager;
|
|
423
|
+
/**
|
|
424
|
+
* ASRT's unconditional write grants, read from ASRT rather than copied.
|
|
425
|
+
* Captured alongside the manager because both come from the same dynamic
|
|
426
|
+
* import — the module is loaded lazily so an unsupported platform never
|
|
427
|
+
* pays for it.
|
|
428
|
+
*/
|
|
429
|
+
let defaultWritePaths;
|
|
430
|
+
/**
|
|
431
|
+
* The data root, resolved only inside a `pkg` binary. Off `pkg` it stays
|
|
432
|
+
* `undefined`, so nothing is materialised and no path is consulted that was
|
|
433
|
+
* not consulted before.
|
|
434
|
+
*/
|
|
435
|
+
const packagedRoot = options.storageRoot ??
|
|
436
|
+
(isPackagedBinary()
|
|
437
|
+
? resolveRootPath(process.env.PERSONAL_SERVER_ROOT_PATH)
|
|
438
|
+
: undefined);
|
|
439
|
+
/**
|
|
440
|
+
* The interpreter the OS layer execs, resolved once. Off `pkg` this is
|
|
441
|
+
* `process.execPath` exactly as before; inside one it is a real Node the
|
|
442
|
+
* embedder supplied, or a refusal to run at all.
|
|
443
|
+
*/
|
|
444
|
+
let interpreter;
|
|
445
|
+
const resolveInterpreter = () => {
|
|
446
|
+
interpreter ??= resolveSandboxNodePath({
|
|
447
|
+
...(options.nodePath === undefined ? {} : { nodePath: options.nodePath }),
|
|
448
|
+
...(packagedRoot === undefined ? {} : { storageRoot: packagedRoot }),
|
|
449
|
+
});
|
|
450
|
+
return interpreter;
|
|
451
|
+
};
|
|
452
|
+
async function loadManager() {
|
|
453
|
+
if (manager)
|
|
454
|
+
return manager;
|
|
455
|
+
const mod = await import("@anthropic-ai/sandbox-runtime");
|
|
456
|
+
manager = mod.SandboxManager;
|
|
457
|
+
defaultWritePaths = mod.getDefaultWritePaths();
|
|
458
|
+
return manager;
|
|
459
|
+
}
|
|
460
|
+
async function ensureInitialized(spec) {
|
|
461
|
+
const sm = await loadManager();
|
|
462
|
+
if (!sm.isSupportedPlatform())
|
|
463
|
+
return false;
|
|
464
|
+
// See {@link linuxSeccompHelperPath}: on Linux `denyRead` unmounts, so
|
|
465
|
+
// the sandbox's own helper has to be bound back in or nothing runs.
|
|
466
|
+
// Deliberately *not* passed through `realOrSelf` — Linux matches the
|
|
467
|
+
// literal path against the deny prefixes and binds that same string, and
|
|
468
|
+
// it is the literal path ASRT execs.
|
|
469
|
+
const seccompHelper = linuxSeccompHelperPath(packagedRoot === undefined ? undefined : packagedBinDir(packagedRoot));
|
|
470
|
+
// Same shape as the seccomp helper: a host-side executable the sandbox
|
|
471
|
+
// has to be able to read, named as one path rather than by opening a
|
|
472
|
+
// region. Only present when the interpreter is *not* the one already
|
|
473
|
+
// running — a `<root>/bin/node` sits under the home directory that
|
|
474
|
+
// `broadDenyRead` closes, so without this the sandbox cannot exec it.
|
|
475
|
+
// Off `pkg` the resolved path is `process.execPath` and this is empty,
|
|
476
|
+
// leaving the emitted policy exactly as it was.
|
|
477
|
+
const resolved = resolveInterpreter();
|
|
478
|
+
const interpreterAllow = resolved.ok && resolved.bind ? [realOrSelf(resolved.path)] : [];
|
|
479
|
+
await sm.initialize({
|
|
480
|
+
filesystem: {
|
|
481
|
+
denyRead: broadDenyRead(),
|
|
482
|
+
// Re-allow exactly the granted files plus the scratch dir, resolved
|
|
483
|
+
// so they match the kernel's view of the same paths.
|
|
484
|
+
allowRead: [
|
|
485
|
+
...[...spec.readPaths, spec.writePath].map(realOrSelf),
|
|
486
|
+
...(seccompHelper === undefined ? [] : [seccompHelper]),
|
|
487
|
+
...interpreterAllow,
|
|
488
|
+
],
|
|
489
|
+
allowWrite: [realOrSelf(spec.writePath)],
|
|
490
|
+
// Takes back the write paths ASRT grants unconditionally — see
|
|
491
|
+
// {@link asrtDefaultWritePathDenials}. The scratch dir stays the only
|
|
492
|
+
// writable location, which is what `allowWrite` was always meant to
|
|
493
|
+
// say. `loadManager()` has run by here, so the list is populated.
|
|
494
|
+
denyWrite: asrtDefaultWritePathDenials(defaultWritePaths ?? []),
|
|
495
|
+
},
|
|
496
|
+
...(seccompHelper === undefined
|
|
497
|
+
? {}
|
|
498
|
+
: { seccomp: { applyPath: seccompHelper } }),
|
|
499
|
+
network: {
|
|
500
|
+
// Zero egress. Not an allowlist with nothing in it by accident —
|
|
501
|
+
// `denyNetwork: true` is non-negotiable in the spec type.
|
|
502
|
+
allowedDomains: [],
|
|
503
|
+
deniedDomains: [],
|
|
504
|
+
strictAllowlist: true,
|
|
505
|
+
allowUnixSockets: [],
|
|
506
|
+
allowAllUnixSockets: false,
|
|
507
|
+
allowLocalBinding: false,
|
|
508
|
+
},
|
|
509
|
+
});
|
|
510
|
+
return true;
|
|
511
|
+
}
|
|
512
|
+
return {
|
|
513
|
+
async capabilities() {
|
|
514
|
+
const sm = await loadManager();
|
|
515
|
+
if (!sm.isSupportedPlatform()) {
|
|
516
|
+
return {
|
|
517
|
+
available: false,
|
|
518
|
+
reason: `@anthropic-ai/sandbox-runtime@${SANDBOX_RUNTIME_VERSION} does not support ${process.platform}`,
|
|
519
|
+
};
|
|
520
|
+
}
|
|
521
|
+
// Reported here as well as refused at `run`, so a caller that asks
|
|
522
|
+
// before running learns the sandbox is unusable rather than discovering
|
|
523
|
+
// it one question later.
|
|
524
|
+
const nodePath = resolveInterpreter();
|
|
525
|
+
if (!nodePath.ok) {
|
|
526
|
+
return { available: false, reason: nodePath.reason };
|
|
527
|
+
}
|
|
528
|
+
const deps = sm.checkDependencies();
|
|
529
|
+
if (deps && "ok" in deps && deps.ok === false) {
|
|
530
|
+
return {
|
|
531
|
+
available: false,
|
|
532
|
+
reason: `sandbox dependencies missing: ${JSON.stringify(deps)}`,
|
|
533
|
+
};
|
|
534
|
+
}
|
|
535
|
+
return { available: true, enforcement: buildEnforcement(true) };
|
|
536
|
+
},
|
|
537
|
+
run(script, spec) {
|
|
538
|
+
// Serialised process-wide — see `globalRunQueue`. Chaining here makes
|
|
539
|
+
// the race impossible rather than relying on every caller to remember.
|
|
540
|
+
const result = globalRunQueue.then(async () => {
|
|
541
|
+
try {
|
|
542
|
+
return await runOne(script, spec);
|
|
543
|
+
}
|
|
544
|
+
finally {
|
|
545
|
+
// ASRT's documented lifecycle is initialize -> run -> reset(), and
|
|
546
|
+
// the singleton keeps session state (on Windows, actual filesystem
|
|
547
|
+
// ACEs) between runs. Without this teardown a *second* run under a
|
|
548
|
+
// different policy silently produced no output at all — it could
|
|
549
|
+
// not read even its own script. Always reset, including after a
|
|
550
|
+
// failure, or the next consumer's run is the one that breaks.
|
|
551
|
+
try {
|
|
552
|
+
const sm = await loadManager();
|
|
553
|
+
sm.cleanupAfterCommand();
|
|
554
|
+
await sm.reset();
|
|
555
|
+
}
|
|
556
|
+
catch {
|
|
557
|
+
// Teardown failure must not mask the run's own result; the next
|
|
558
|
+
// run re-initializes from scratch regardless.
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
});
|
|
562
|
+
globalRunQueue = result.catch(() => undefined);
|
|
563
|
+
return result;
|
|
564
|
+
},
|
|
565
|
+
};
|
|
566
|
+
async function runOne(script, spec) {
|
|
567
|
+
{
|
|
568
|
+
const started = Date.now();
|
|
569
|
+
const scratch = mkdtempSync(join(spec.writePath, "run-"));
|
|
570
|
+
const scriptPath = join(scratch, "script.js");
|
|
571
|
+
writeFileSync(scriptPath, script, { mode: 0o400 });
|
|
572
|
+
// Ahead of `ensureInitialized`, because there is no point establishing a
|
|
573
|
+
// policy for a program that has nothing to run it.
|
|
574
|
+
const nodePath = resolveInterpreter();
|
|
575
|
+
if (!nodePath.ok) {
|
|
576
|
+
rmSync(scratch, { recursive: true, force: true });
|
|
577
|
+
return {
|
|
578
|
+
stdout: "",
|
|
579
|
+
stderr: `refusing to run: no Node interpreter for the sandbox — ${nodePath.reason}`,
|
|
580
|
+
exitCode: -1,
|
|
581
|
+
timedOut: false,
|
|
582
|
+
truncated: false,
|
|
583
|
+
durationMs: Date.now() - started,
|
|
584
|
+
termination: "sandboxUnavailable",
|
|
585
|
+
enforcement: buildEnforcement(false),
|
|
586
|
+
violations: [],
|
|
587
|
+
};
|
|
588
|
+
}
|
|
589
|
+
let sandboxAvailable = false;
|
|
590
|
+
try {
|
|
591
|
+
sandboxAvailable = await ensureInitialized(spec);
|
|
592
|
+
}
|
|
593
|
+
catch (err) {
|
|
594
|
+
rmSync(scratch, { recursive: true, force: true });
|
|
595
|
+
return {
|
|
596
|
+
stdout: "",
|
|
597
|
+
stderr: `sandbox initialization failed: ${err.message}`,
|
|
598
|
+
exitCode: -1,
|
|
599
|
+
timedOut: false,
|
|
600
|
+
truncated: false,
|
|
601
|
+
durationMs: Date.now() - started,
|
|
602
|
+
termination: "sandboxUnavailable",
|
|
603
|
+
enforcement: buildEnforcement(false),
|
|
604
|
+
violations: [],
|
|
605
|
+
};
|
|
606
|
+
}
|
|
607
|
+
if (!sandboxAvailable) {
|
|
608
|
+
rmSync(scratch, { recursive: true, force: true });
|
|
609
|
+
return {
|
|
610
|
+
stdout: "",
|
|
611
|
+
stderr: "refusing to run: no OS sandbox on this platform, and running model-authored code unconfined is not an option",
|
|
612
|
+
exitCode: -1,
|
|
613
|
+
timedOut: false,
|
|
614
|
+
truncated: false,
|
|
615
|
+
durationMs: Date.now() - started,
|
|
616
|
+
termination: "sandboxUnavailable",
|
|
617
|
+
enforcement: buildEnforcement(false),
|
|
618
|
+
violations: [],
|
|
619
|
+
};
|
|
620
|
+
}
|
|
621
|
+
const inner = `${scratchTmpdirPreamble(scratch)}; ${ulimitPreamble(spec)}; exec ${shQuote(nodePath.path)} ${shQuote(scriptPath)}`;
|
|
622
|
+
const sm = await loadManager();
|
|
623
|
+
const commandId = `query-${started}-${Math.random().toString(36).slice(2)}`;
|
|
624
|
+
const { argv, env } = await sm.wrapWithSandboxArgv(`/bin/sh -c ${shQuote(inner)}`, undefined, undefined, undefined, scratch, { commandId });
|
|
625
|
+
const child = spawn(argv[0], argv.slice(1), {
|
|
626
|
+
env: minimalEnv(env, scratch),
|
|
627
|
+
cwd: scratch,
|
|
628
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
629
|
+
});
|
|
630
|
+
let stdout = "";
|
|
631
|
+
let stderr = "";
|
|
632
|
+
let bytes = 0;
|
|
633
|
+
let truncated = false;
|
|
634
|
+
let termination;
|
|
635
|
+
const finish = (reason) => {
|
|
636
|
+
if (termination === undefined)
|
|
637
|
+
termination = reason;
|
|
638
|
+
child.kill("SIGKILL");
|
|
639
|
+
};
|
|
640
|
+
const collect = (chunk, into) => {
|
|
641
|
+
bytes += chunk.length;
|
|
642
|
+
if (bytes > spec.maxOutputBytes) {
|
|
643
|
+
truncated = true;
|
|
644
|
+
finish("outputCap");
|
|
645
|
+
return;
|
|
646
|
+
}
|
|
647
|
+
if (into === "out")
|
|
648
|
+
stdout += chunk.toString("utf8");
|
|
649
|
+
else
|
|
650
|
+
stderr += chunk.toString("utf8");
|
|
651
|
+
};
|
|
652
|
+
child.stdout.on("data", (c) => collect(c, "out"));
|
|
653
|
+
child.stderr.on("data", (c) => collect(c, "err"));
|
|
654
|
+
const wallTimer = setTimeout(() => {
|
|
655
|
+
finish("wallClock");
|
|
656
|
+
}, spec.wallClockMs);
|
|
657
|
+
// Memory watchdog. This is the *only* mechanism that bounds total
|
|
658
|
+
// process memory, on every platform (see buildEnforcement notes), so it
|
|
659
|
+
// samples the whole process group, not just the direct child.
|
|
660
|
+
let watchdogFailed;
|
|
661
|
+
const rssTimer = setInterval(() => {
|
|
662
|
+
if (child.pid === undefined)
|
|
663
|
+
return;
|
|
664
|
+
try {
|
|
665
|
+
const out = execFileSync("ps", ["-Ao", "pid=,ppid=,rss="], {
|
|
666
|
+
encoding: "utf8",
|
|
667
|
+
});
|
|
668
|
+
const rows = out
|
|
669
|
+
.trim()
|
|
670
|
+
.split("\n")
|
|
671
|
+
.map((l) => l.trim().split(/\s+/).map(Number));
|
|
672
|
+
// Sum RSS over the child and everything descended from it.
|
|
673
|
+
const byParent = new Map();
|
|
674
|
+
const rssOf = new Map();
|
|
675
|
+
for (const [pid, ppid, rss] of rows) {
|
|
676
|
+
if (pid === undefined || ppid === undefined || rss === undefined)
|
|
677
|
+
continue;
|
|
678
|
+
rssOf.set(pid, rss);
|
|
679
|
+
byParent.set(ppid, [...(byParent.get(ppid) ?? []), pid]);
|
|
680
|
+
}
|
|
681
|
+
let total = 0;
|
|
682
|
+
const stack = [child.pid];
|
|
683
|
+
const seen = new Set();
|
|
684
|
+
while (stack.length > 0) {
|
|
685
|
+
const pid = stack.pop();
|
|
686
|
+
if (seen.has(pid))
|
|
687
|
+
continue;
|
|
688
|
+
seen.add(pid);
|
|
689
|
+
total += rssOf.get(pid) ?? 0;
|
|
690
|
+
stack.push(...(byParent.get(pid) ?? []));
|
|
691
|
+
}
|
|
692
|
+
if (total / 1024 > spec.memoryMb)
|
|
693
|
+
finish("memory");
|
|
694
|
+
}
|
|
695
|
+
catch (err) {
|
|
696
|
+
// Never swallow this. The watchdog is the *only* thing bounding
|
|
697
|
+
// memory anywhere, so a broken watchdog means the memory budget is
|
|
698
|
+
// not enforced at all — and a silent catch here is exactly how that
|
|
699
|
+
// ships unnoticed. Record it, stop claiming the enforcement, and
|
|
700
|
+
// fail the run closed rather than letting it allocate freely.
|
|
701
|
+
watchdogFailed = err.message;
|
|
702
|
+
finish("memory");
|
|
703
|
+
}
|
|
704
|
+
}, RSS_POLL_MS);
|
|
705
|
+
const exit = await new Promise((resolve) => {
|
|
706
|
+
child.on("close", (code, signal) => resolve({ code: code ?? -1, signal }));
|
|
707
|
+
child.on("error", () => resolve({ code: -1, signal: null }));
|
|
708
|
+
});
|
|
709
|
+
clearTimeout(wallTimer);
|
|
710
|
+
clearInterval(rssTimer);
|
|
711
|
+
setTimeout(() => {
|
|
712
|
+
rmSync(scratch, { recursive: true, force: true });
|
|
713
|
+
}, KILL_GRACE_MS).unref();
|
|
714
|
+
// SIGXCPU is RLIMIT_CPU firing: exit 152 via a shell, or the signal
|
|
715
|
+
// directly. This is the CPU budget, not a wall-clock timeout.
|
|
716
|
+
if (termination === undefined) {
|
|
717
|
+
if (exit.signal === "SIGXCPU" || exit.code === 152) {
|
|
718
|
+
termination = "cpu";
|
|
719
|
+
}
|
|
720
|
+
else if (exit.code === 0) {
|
|
721
|
+
termination = "completed";
|
|
722
|
+
}
|
|
723
|
+
else {
|
|
724
|
+
termination = "error";
|
|
725
|
+
}
|
|
726
|
+
}
|
|
727
|
+
let violations = [];
|
|
728
|
+
try {
|
|
729
|
+
const store = sm.getSandboxViolationStore();
|
|
730
|
+
const events = store?.getViolationsForCommand?.(commandId) ?? [];
|
|
731
|
+
violations = events.map((e) => typeof e === "string" ? e : JSON.stringify(e));
|
|
732
|
+
}
|
|
733
|
+
catch {
|
|
734
|
+
violations = [];
|
|
735
|
+
}
|
|
736
|
+
const enforcement = buildEnforcement(true);
|
|
737
|
+
if (watchdogFailed !== undefined) {
|
|
738
|
+
enforcement.memory = false;
|
|
739
|
+
enforcement.notes.push(`memory watchdog failed (${watchdogFailed}); the memory budget was NOT enforced for this run`);
|
|
740
|
+
}
|
|
741
|
+
return {
|
|
742
|
+
stdout,
|
|
743
|
+
stderr,
|
|
744
|
+
exitCode: exit.code,
|
|
745
|
+
timedOut: termination === "wallClock" || termination === "cpu",
|
|
746
|
+
truncated,
|
|
747
|
+
durationMs: Date.now() - started,
|
|
748
|
+
termination,
|
|
749
|
+
enforcement,
|
|
750
|
+
violations,
|
|
751
|
+
};
|
|
752
|
+
}
|
|
753
|
+
}
|
|
754
|
+
}
|
|
755
|
+
export { SANDBOX_RUNTIME_VERSION };
|
|
756
|
+
//# sourceMappingURL=node-sandbox.js.map
|