@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.
Files changed (54) hide show
  1. package/dist/app.d.ts +16 -1
  2. package/dist/app.d.ts.map +1 -1
  3. package/dist/app.js +38 -0
  4. package/dist/app.js.map +1 -1
  5. package/dist/bootstrap.d.ts +12 -2
  6. package/dist/bootstrap.d.ts.map +1 -1
  7. package/dist/bootstrap.js +36 -0
  8. package/dist/bootstrap.js.map +1 -1
  9. package/dist/query/mcp-ask-port.d.ts +33 -0
  10. package/dist/query/mcp-ask-port.d.ts.map +1 -0
  11. package/dist/query/mcp-ask-port.js +61 -0
  12. package/dist/query/mcp-ask-port.js.map +1 -0
  13. package/dist/query/node-sandbox.d.ts +96 -0
  14. package/dist/query/node-sandbox.d.ts.map +1 -0
  15. package/dist/query/node-sandbox.js +756 -0
  16. package/dist/query/node-sandbox.js.map +1 -0
  17. package/dist/query/pkg-runtime.d.ts +109 -0
  18. package/dist/query/pkg-runtime.d.ts.map +1 -0
  19. package/dist/query/pkg-runtime.js +196 -0
  20. package/dist/query/pkg-runtime.js.map +1 -0
  21. package/dist/query/query-service.d.ts +281 -0
  22. package/dist/query/query-service.d.ts.map +1 -0
  23. package/dist/query/query-service.js +462 -0
  24. package/dist/query/query-service.js.map +1 -0
  25. package/dist/query/read-paths.d.ts +35 -0
  26. package/dist/query/read-paths.d.ts.map +1 -0
  27. package/dist/query/read-paths.js +106 -0
  28. package/dist/query/read-paths.js.map +1 -0
  29. package/dist/query/runner-entry.d.ts +68 -0
  30. package/dist/query/runner-entry.d.ts.map +1 -0
  31. package/dist/query/runner-entry.js +182 -0
  32. package/dist/query/runner-entry.js.map +1 -0
  33. package/dist/query/runner.js +7190 -0
  34. package/dist/query/sandbox-tool-host.d.ts +106 -0
  35. package/dist/query/sandbox-tool-host.d.ts.map +1 -0
  36. package/dist/query/sandbox-tool-host.js +320 -0
  37. package/dist/query/sandbox-tool-host.js.map +1 -0
  38. package/dist/query/search-bridge.d.ts +31 -0
  39. package/dist/query/search-bridge.d.ts.map +1 -0
  40. package/dist/query/search-bridge.js +66 -0
  41. package/dist/query/search-bridge.js.map +1 -0
  42. package/dist/routes/mcp.d.ts +7 -1
  43. package/dist/routes/mcp.d.ts.map +1 -1
  44. package/dist/routes/mcp.js +6 -0
  45. package/dist/routes/mcp.js.map +1 -1
  46. package/dist/routes/query.d.ts +72 -0
  47. package/dist/routes/query.d.ts.map +1 -0
  48. package/dist/routes/query.js +241 -0
  49. package/dist/routes/query.js.map +1 -0
  50. package/dist/storage/question-store.d.ts.map +1 -1
  51. package/dist/storage/question-store.js +43 -9
  52. package/dist/storage/question-store.js.map +1 -1
  53. package/dist/ui/ps-lite-debug.js +13723 -7697
  54. 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