@checkstack/backend-api 0.19.0 → 0.21.0

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/CHANGELOG.md +205 -0
  2. package/package.json +12 -11
  3. package/src/advisory-lock-pool.it.test.ts +282 -0
  4. package/src/advisory-lock.test.ts +144 -3
  5. package/src/advisory-lock.ts +97 -55
  6. package/src/auth-strategy.ts +6 -3
  7. package/src/bearer-token.ts +13 -0
  8. package/src/collector-strategy.ts +9 -0
  9. package/src/config-versioning.test.ts +227 -0
  10. package/src/config-versioning.ts +172 -0
  11. package/src/core-services.ts +14 -0
  12. package/src/esm-script-runner.test.ts +55 -16
  13. package/src/esm-script-runner.ts +212 -55
  14. package/src/index.ts +3 -0
  15. package/src/render-templatable-config.test.ts +168 -0
  16. package/src/render-templatable-config.ts +193 -0
  17. package/src/schema-utils.ts +3 -0
  18. package/src/script-sandbox/capabilities.test.ts +122 -0
  19. package/src/script-sandbox/capabilities.ts +372 -0
  20. package/src/script-sandbox/capped-output.test.ts +116 -0
  21. package/src/script-sandbox/capped-output.ts +172 -0
  22. package/src/script-sandbox/env-guard.test.ts +105 -0
  23. package/src/script-sandbox/env-guard.ts +129 -0
  24. package/src/script-sandbox/filesystem.test.ts +437 -0
  25. package/src/script-sandbox/filesystem.ts +514 -0
  26. package/src/script-sandbox/forkbomb.it.test.ts +121 -0
  27. package/src/script-sandbox/global-default.test.ts +161 -0
  28. package/src/script-sandbox/global-default.ts +100 -0
  29. package/src/script-sandbox/index.ts +14 -0
  30. package/src/script-sandbox/network.test.ts +356 -0
  31. package/src/script-sandbox/network.ts +373 -0
  32. package/src/script-sandbox/observability.test.ts +210 -0
  33. package/src/script-sandbox/observability.ts +168 -0
  34. package/src/script-sandbox/output-truncation.test.ts +53 -0
  35. package/src/script-sandbox/output-truncation.ts +69 -0
  36. package/src/script-sandbox/policy.test.ts +189 -0
  37. package/src/script-sandbox/policy.ts +220 -0
  38. package/src/script-sandbox/provider.test.ts +61 -0
  39. package/src/script-sandbox/provider.ts +134 -0
  40. package/src/script-sandbox/readiness.test.ts +80 -0
  41. package/src/script-sandbox/readiness.ts +117 -0
  42. package/src/script-sandbox/report.ts +88 -0
  43. package/src/script-sandbox/rootless-egress.it.test.ts +86 -0
  44. package/src/script-sandbox/rootless-egress.test.ts +99 -0
  45. package/src/script-sandbox/rootless-egress.ts +218 -0
  46. package/src/script-sandbox/shell-quote.test.ts +32 -0
  47. package/src/script-sandbox/shell-quote.ts +10 -0
  48. package/src/script-sandbox/wrapper.test.ts +1194 -0
  49. package/src/script-sandbox/wrapper.ts +714 -0
  50. package/src/shell-script-runner.test.ts +243 -0
  51. package/src/shell-script-runner.ts +210 -45
  52. package/src/zod-config.test.ts +60 -0
  53. package/src/zod-config.ts +38 -14
  54. package/tsconfig.json +3 -0
@@ -0,0 +1,1194 @@
1
+ import { describe, expect, it } from "bun:test";
2
+ import type { SandboxCapabilities } from "./capabilities";
3
+ import { DEFAULT_SANDBOX_PROFILE, sandboxPolicySchema } from "./policy";
4
+ import { SandboxUnavailableError } from "./report";
5
+ import { buildSpawnHardening } from "./wrapper";
6
+
7
+ const baseEnv = { PATH: "/usr/bin", HOME: "/home/u" };
8
+
9
+ const LINUX_ROOT: SandboxCapabilities = {
10
+ platform: "linux",
11
+ euidIsRoot: true,
12
+ hasPrlimit: true,
13
+ rlimitNative: true,
14
+ wrapper: null,
15
+ userNamespaces: true,
16
+ userNsCreatable: true,
17
+ netNamespaces: false,
18
+ netEgressIface: null,
19
+ netEgressAddressing: null,
20
+ netEgressRootless: false,
21
+ };
22
+
23
+ const LINUX_NONROOT_BWRAP: SandboxCapabilities = {
24
+ platform: "linux",
25
+ euidIsRoot: false,
26
+ hasPrlimit: true,
27
+ rlimitNative: true,
28
+ wrapper: "bwrap",
29
+ userNamespaces: true,
30
+ userNsCreatable: true,
31
+ netNamespaces: true,
32
+ netEgressIface: null, // bwrap cannot plumb egress (macvlan); rootless tested separately
33
+ netEgressAddressing: null,
34
+ netEgressRootless: false,
35
+ };
36
+
37
+ const LINUX_NONROOT_NSJAIL: SandboxCapabilities = {
38
+ ...LINUX_NONROOT_BWRAP,
39
+ wrapper: "nsjail",
40
+ };
41
+
42
+ // Root host WITH a bwrap wrapper + user namespaces: the wrapper can carry the
43
+ // `--uid` privilege drop (the only path that actually drops, since Bun.spawn
44
+ // ignores uid/gid). This is the container model the shipped images target.
45
+ const LINUX_NONROOT_BWRAP_ROOT: SandboxCapabilities = {
46
+ ...LINUX_NONROOT_BWRAP,
47
+ euidIsRoot: true,
48
+ };
49
+
50
+ // nsjail host that CAN plumb real egress (root + uplink + static addressing) —
51
+ // required for a functional allowlist / metadata block.
52
+ const LINUX_ROOT_NSJAIL_PLUMBED: SandboxCapabilities = {
53
+ ...LINUX_NONROOT_NSJAIL,
54
+ euidIsRoot: true,
55
+ netEgressIface: "eth0",
56
+ netEgressAddressing: {
57
+ ip: "10.0.0.2",
58
+ netmask: "255.255.255.0",
59
+ gateway: "10.0.0.1",
60
+ },
61
+ };
62
+
63
+ // bwrap host with the ROOTLESS slirp4netns egress path (no privileged macvlan):
64
+ // the common rootless-container case. allowlist + metadata block are deliverable
65
+ // here via the launcher, without root / uplink / operator addressing.
66
+ const LINUX_BWRAP_ROOTLESS: SandboxCapabilities = {
67
+ ...LINUX_NONROOT_BWRAP,
68
+ netEgressRootless: true,
69
+ };
70
+
71
+ const MACOS_NONE: SandboxCapabilities = {
72
+ platform: "darwin",
73
+ euidIsRoot: false,
74
+ hasPrlimit: false,
75
+ rlimitNative: false,
76
+ wrapper: null,
77
+ userNamespaces: false,
78
+ userNsCreatable: false,
79
+ netNamespaces: false,
80
+ netEgressIface: null,
81
+ netEgressAddressing: null,
82
+ netEgressRootless: false,
83
+ };
84
+
85
+ describe("buildSpawnHardening — disabled sandbox", () => {
86
+ it("behaves like the prior merge with no denylist / caps / uid", () => {
87
+ const policy = sandboxPolicySchema.parse({ enabled: false });
88
+ const h = buildSpawnHardening({
89
+ policy,
90
+ caps: LINUX_ROOT,
91
+ baseEnv,
92
+ envOverrides: { LD_PRELOAD: "/x.so", FOO: "bar" },
93
+ });
94
+ expect(h.wrapCmd(["sh", "-c", "x"])).toEqual(["sh", "-c", "x"]);
95
+ expect(h.env.LD_PRELOAD).toBe("/x.so"); // NOT dropped when disabled
96
+ expect(h.env.FOO).toBe("bar");
97
+ expect(h.uid).toBeUndefined();
98
+ expect(h.maxOutputBytes).toBeUndefined();
99
+ expect(h.droppedEnvKeys).toEqual([]);
100
+ });
101
+ });
102
+
103
+ describe("buildSpawnHardening — resources on a capable Linux root host", () => {
104
+ // A genuinely capable scenario for the RESOURCE + PRIVILEGE layers. The
105
+ // privilege drop is performed by the WRAPPER (bwrap `--uid`), not by
106
+ // Bun.spawn (which ignores uid/gid), so RLIMIT_NPROC — which is per-UID and
107
+ // only safe once the drop is in effect — requires a wrapper-capable host AND
108
+ // FS confinement so the wrapper is engaged. A scratch dir is supplied so the
109
+ // bwrap prelude is built.
110
+ const h = buildSpawnHardening({
111
+ policy: sandboxPolicySchema.parse({
112
+ ...DEFAULT_SANDBOX_PROFILE,
113
+ // Keep the network on the host so this stays a focused resource/privilege
114
+ // test (no egress-plumbing dependency).
115
+ network: { mode: "unrestricted", denyLinkLocalAndMetadata: false },
116
+ privilege: { mode: "drop-to-uid", uid: 1001, gid: 1001 },
117
+ }),
118
+ caps: LINUX_NONROOT_BWRAP_ROOT,
119
+ baseEnv,
120
+ envOverrides: {},
121
+ filesystem: { scratchDir: "/tmp/run-res" },
122
+ });
123
+
124
+ it("prepends an FS wrapper + prlimit prelude carrying the uid drop", () => {
125
+ const wrapped = h.wrapCmd(["sh", "-c", "echo hi"]);
126
+ expect(wrapped[0]).toBe("bwrap");
127
+ // The wrapper carries the privilege drop (the path that actually drops).
128
+ expect(wrapped).toContain("--uid");
129
+ expect(wrapped).toContain("1001");
130
+ expect(wrapped).toContain("--gid");
131
+ // prlimit applies INSIDE the wrapper, with all caps including nproc (safe
132
+ // now that the drop is genuinely in effect).
133
+ expect(wrapped).toContain("prlimit");
134
+ expect(wrapped).toContain("--cpu=60");
135
+ expect(wrapped).toContain("--nofile=1024");
136
+ expect(wrapped).toContain("--nproc=256");
137
+ expect(wrapped).toContain("--fsize=268435456");
138
+ // Memory is NOT capped via `--as` (RLIMIT_AS breaks the interpreter); it is
139
+ // the JS heap cap + the cgroup limit instead.
140
+ expect(wrapped.some((a) => a.startsWith("--as="))).toBe(false);
141
+ expect(wrapped.slice(-3)).toEqual(["sh", "-c", "echo hi"]);
142
+ });
143
+
144
+ it("marks resources + privilege enforced and surfaces maxOutputBytes + heap cap", () => {
145
+ expect(h.effective.enforced.resources).toBe(true);
146
+ expect(h.effective.enforced.privilege).toBe(true);
147
+ expect(h.maxOutputBytes).toBe(5 * 1024 * 1024);
148
+ // Memory enforced via the JS heap cap, not RLIMIT_AS.
149
+ expect(h.nodeMemoryFlagEnv?.NODE_OPTIONS).toContain("--max-old-space-size=512");
150
+ });
151
+
152
+ it("drops privilege to the configured uid via the wrapper when euid is root + uid set", () => {
153
+ const withUid = buildSpawnHardening({
154
+ policy: sandboxPolicySchema.parse({
155
+ filesystem: { mode: "scratch-only" },
156
+ privilege: { mode: "drop-to-uid", uid: 1001, gid: 1001 },
157
+ }),
158
+ caps: LINUX_NONROOT_BWRAP_ROOT,
159
+ baseEnv,
160
+ filesystem: { scratchDir: "/tmp/run-res2" },
161
+ });
162
+ // `h.uid`/`gid` are set for OBSERVABILITY (the wrapper carries the real
163
+ // `--uid` drop); the runner never passes them to Bun.spawn.
164
+ expect(withUid.uid).toBe(1001);
165
+ expect(withUid.gid).toBe(1001);
166
+ expect(withUid.effective.enforced.privilege).toBe(true);
167
+ expect(withUid.wrapCmd(["sh", "-c", "x"])).toContain("--uid");
168
+ });
169
+
170
+ it("does NOT enforce privilege (and surfaces it) when no wrapper engages", () => {
171
+ // FS off + host net = no wrapper. Bun.spawn's uid is a silent no-op, so the
172
+ // drop cannot happen; the layer must report not-enforced + a downgrade
173
+ // rather than a false positive.
174
+ const noWrap = buildSpawnHardening({
175
+ policy: sandboxPolicySchema.parse({
176
+ onUnavailable: "degrade",
177
+ filesystem: { mode: "off" },
178
+ network: { mode: "unrestricted", denyLinkLocalAndMetadata: false },
179
+ privilege: { mode: "drop-to-uid", uid: 1001, gid: 1001 },
180
+ }),
181
+ caps: LINUX_NONROOT_BWRAP_ROOT,
182
+ baseEnv,
183
+ });
184
+ expect(noWrap.effective.enforced.privilege).toBe(false);
185
+ const reasons = noWrap.effective.downgrades
186
+ .filter((d) => d.layer === "privilege")
187
+ .map((d) => d.reason);
188
+ expect(reasons.some((r) => r.includes("namespace wrapper"))).toBe(true);
189
+ });
190
+ });
191
+
192
+ describe("buildSpawnHardening — degraded macOS host (onUnavailable: degrade)", () => {
193
+ // The shipped DEFAULT_SANDBOX_PROFILE is now fail-closed, so to exercise the
194
+ // degrade path on a capability-less host we explicitly use the opt-in
195
+ // `degrade` variant (the operator's documented escape hatch for weak hosts).
196
+ const DEGRADE_DEFAULT = sandboxPolicySchema.parse({
197
+ ...DEFAULT_SANDBOX_PROFILE,
198
+ onUnavailable: "degrade",
199
+ });
200
+ const h = buildSpawnHardening({
201
+ policy: DEGRADE_DEFAULT,
202
+ caps: MACOS_NONE,
203
+ baseEnv,
204
+ envOverrides: {},
205
+ });
206
+
207
+ it("does NOT hard-break: still returns a hardening result", () => {
208
+ expect(h).toBeDefined();
209
+ });
210
+
211
+ it("does not add a prlimit prelude (no rlimit capability)", () => {
212
+ expect(h.wrapCmd(["sh", "-c", "x"])).toEqual(["sh", "-c", "x"]);
213
+ });
214
+
215
+ it("surfaces the resource downgrade; privilege is enforced by non-root inheritance", () => {
216
+ const layers = h.effective.downgrades.map((d) => d.layer);
217
+ expect(layers).toContain("resources");
218
+ // Privilege is NOT a downgrade here: MACOS_NONE has a NON-root supervisor
219
+ // (euidIsRoot:false), so the child inherits non-root and cannot be
220
+ // host-root - enforced by construction even with no wrapper.
221
+ expect(layers).not.toContain("privilege");
222
+ expect(h.effective.enforced.resources).toBe(false);
223
+ expect(h.effective.enforced.privilege).toBe(true);
224
+ });
225
+
226
+ it("still enforces the portable subset: output cap + ESM memory fallback", () => {
227
+ expect(h.maxOutputBytes).toBe(5 * 1024 * 1024);
228
+ expect(h.nodeMemoryFlagEnv?.NODE_OPTIONS).toContain("--max-old-space-size=");
229
+ });
230
+
231
+ it("still applies the env denylist (portable, every platform)", () => {
232
+ const denied = buildSpawnHardening({
233
+ policy: DEGRADE_DEFAULT,
234
+ caps: MACOS_NONE,
235
+ baseEnv,
236
+ envOverrides: { LD_PRELOAD: "/x.so" },
237
+ });
238
+ expect(denied.env.LD_PRELOAD).toBeUndefined();
239
+ expect(denied.droppedEnvKeys).toContain("LD_PRELOAD");
240
+ });
241
+ });
242
+
243
+ describe("buildSpawnHardening — RLIMIT_NPROC fork-bomb cap (Item 1: per-run containment)", () => {
244
+ it("omits --nproc and surfaces a downgrade when no wrapper namespace engages (root, inherit)", () => {
245
+ // Root supervisor, no wrapper-capable host (wrapper:null) => no user
246
+ // namespace to isolate the count, and the child stays host-root: the cap
247
+ // would throttle the host uid, so it is omitted and surfaced.
248
+ const policy = sandboxPolicySchema.parse({
249
+ resources: { maxProcesses: 256, cpuSeconds: 10 },
250
+ privilege: { mode: "inherit" },
251
+ });
252
+ const h = buildSpawnHardening({ policy, caps: LINUX_ROOT, baseEnv });
253
+ const wrapped = h.wrapCmd(["sh", "-c", "x"]);
254
+ expect(wrapped).toContain("--cpu=10");
255
+ expect(wrapped.some((a) => a.startsWith("--nproc="))).toBe(false);
256
+ const resourceDowngrade = h.effective.downgrades.find(
257
+ (d) => d.layer === "resources",
258
+ );
259
+ expect(resourceDowngrade?.reason).toContain("RLIMIT_NPROC");
260
+ });
261
+
262
+ it("APPLIES --nproc under a NON-ROOT supervisor when the bwrap wrapper engages (the shipped model)", () => {
263
+ // The core fix: rootless `bwrap --unshare-all` creates a fresh USER
264
+ // namespace, so the per-(uid, userns) RLIMIT_NPROC isolates THIS run's
265
+ // process count even though the child shares the supervisor's uid 65532.
266
+ // No privilege drop, no root: the fork-bomb cap is genuinely per-run here.
267
+ const policy = sandboxPolicySchema.parse({
268
+ resources: { maxProcesses: 128 },
269
+ filesystem: { mode: "scratch-plus-ro" },
270
+ privilege: { mode: "drop-to-uid" }, // satisfied by inheritance (non-root)
271
+ network: { mode: "unrestricted", denyLinkLocalAndMetadata: false },
272
+ });
273
+ const h = buildSpawnHardening({
274
+ policy,
275
+ caps: LINUX_NONROOT_BWRAP,
276
+ baseEnv,
277
+ filesystem: { scratchDir: "/tmp/run-fb" },
278
+ });
279
+ const wrapped = h.wrapCmd(["sh", "-c", "x"]);
280
+ // bwrap wrapper engaged (its own user + PID namespace) -> nproc applied.
281
+ expect(wrapped).toContain("bwrap");
282
+ expect(wrapped).toContain("--unshare-all");
283
+ expect(wrapped).toContain("--nproc=128");
284
+ // No spawn-level uid/gid (inheritance, not a wrapper --uid drop).
285
+ expect(h.uid).toBeUndefined();
286
+ // Privilege enforced by inheritance; resources enforced; NO nproc note.
287
+ expect(h.effective.enforced.privilege).toBe(true);
288
+ expect(h.effective.enforced.resources).toBe(true);
289
+ expect(
290
+ h.effective.notes.some((n) => n.note.includes("RLIMIT_NPROC")),
291
+ ).toBe(false);
292
+ });
293
+
294
+ it("omits --nproc + emits a NON-FATAL note when no wrapper engages (non-root, FS off, host net)", () => {
295
+ // The corner case: non-root supervisor but FS off AND host net => no
296
+ // wrapper user namespace, so the child shares the supervisor's uid+userns
297
+ // and a per-uid cap would throttle the supervisor. Omitted, surfaced as a
298
+ // note (never a downgrade, so fail-closed still runs), never applied unsafely.
299
+ const policy = sandboxPolicySchema.parse({
300
+ resources: { maxProcesses: 256, cpuSeconds: 10 },
301
+ filesystem: { mode: "off" },
302
+ network: { mode: "unrestricted", denyLinkLocalAndMetadata: false },
303
+ privilege: { mode: "inherit" },
304
+ });
305
+ const h = buildSpawnHardening({ policy, caps: LINUX_NONROOT_BWRAP, baseEnv });
306
+ expect(
307
+ h.wrapCmd(["sh", "-c", "x"]).some((a) => a.startsWith("--nproc=")),
308
+ ).toBe(false);
309
+ expect(
310
+ h.effective.notes.some((n) => n.note.includes("RLIMIT_NPROC")),
311
+ ).toBe(true);
312
+ // It is a NOTE, not a downgrade (must not trip onUnavailable: fail).
313
+ expect(
314
+ h.effective.downgrades.some((d) => d.layer === "resources"),
315
+ ).toBe(false);
316
+ });
317
+
318
+ it("emits --nproc when the privilege drop actually takes effect (wrapper carries --uid)", () => {
319
+ // The drop is performed by the wrapper, so it needs a wrapper-capable root
320
+ // host + FS confinement to engage. Only then is RLIMIT_NPROC safe.
321
+ const policy = sandboxPolicySchema.parse({
322
+ resources: { maxProcesses: 256 },
323
+ filesystem: { mode: "scratch-only" },
324
+ privilege: { mode: "drop-to-uid", uid: 1001, gid: 1001 },
325
+ });
326
+ const h = buildSpawnHardening({
327
+ policy,
328
+ caps: LINUX_NONROOT_BWRAP_ROOT,
329
+ baseEnv,
330
+ filesystem: { scratchDir: "/tmp/run-nproc" },
331
+ });
332
+ const wrapped = h.wrapCmd(["sh", "-c", "x"]);
333
+ expect(wrapped).toContain("--nproc=256");
334
+ expect(wrapped).toContain("--uid");
335
+ // `h.uid` is set for observability; the wrapper carries the real drop.
336
+ expect(h.uid).toBe(1001);
337
+ expect(h.effective.enforced.privilege).toBe(true);
338
+ });
339
+
340
+ it("omits --nproc when drop-to-uid was requested but euid is not root", () => {
341
+ const policy = sandboxPolicySchema.parse({
342
+ resources: { maxProcesses: 256, cpuSeconds: 10 },
343
+ privilege: { mode: "drop-to-uid", uid: 1001 },
344
+ });
345
+ const h = buildSpawnHardening({ policy, caps: LINUX_NONROOT_BWRAP, baseEnv });
346
+ expect(h.wrapCmd(["sh", "-c", "x"]).some((a) => a.startsWith("--nproc="))).toBe(
347
+ false,
348
+ );
349
+ });
350
+ });
351
+
352
+ describe("buildSpawnHardening — filesystem layer (Phase 2)", () => {
353
+ it("wraps the command in a bwrap prelude when FS isolation is enforceable", () => {
354
+ const policy = sandboxPolicySchema.parse({
355
+ filesystem: { mode: "scratch-plus-ro" },
356
+ privilege: { mode: "inherit" },
357
+ });
358
+ const h = buildSpawnHardening({
359
+ policy,
360
+ caps: LINUX_NONROOT_BWRAP,
361
+ baseEnv,
362
+ filesystem: {
363
+ scratchDir: "/tmp/run-a",
364
+ nodeModulesDir: "/store/current/node_modules",
365
+ },
366
+ });
367
+ const wrapped = h.wrapCmd([process.execPath, "runner.mjs"]);
368
+ expect(wrapped[0]).toBe("bwrap");
369
+ // The real command is still at the tail.
370
+ expect(wrapped.slice(-2)).toEqual([process.execPath, "runner.mjs"]);
371
+ expect(h.effective.enforced.filesystem).toBe(true);
372
+ });
373
+
374
+ it("composes the FS wrapper OUTSIDE the prlimit prelude", () => {
375
+ const policy = sandboxPolicySchema.parse({
376
+ filesystem: { mode: "scratch-only" },
377
+ resources: { cpuSeconds: 30 },
378
+ privilege: { mode: "inherit" },
379
+ });
380
+ const h = buildSpawnHardening({
381
+ policy,
382
+ caps: LINUX_NONROOT_BWRAP,
383
+ baseEnv,
384
+ filesystem: { scratchDir: "/tmp/run-b" },
385
+ });
386
+ const wrapped = h.wrapCmd(["sh", "-c", "x"]);
387
+ const bwrapIdx = wrapped.indexOf("bwrap");
388
+ const prlimitIdx = wrapped.indexOf("prlimit");
389
+ expect(bwrapIdx).toBe(0);
390
+ expect(prlimitIdx).toBeGreaterThan(bwrapIdx);
391
+ });
392
+
393
+ it("degrades FS to off + surfaces it on a no-wrapper host", () => {
394
+ const policy = sandboxPolicySchema.parse({
395
+ filesystem: { mode: "scratch-only" },
396
+ privilege: { mode: "inherit" },
397
+ });
398
+ const h = buildSpawnHardening({
399
+ policy,
400
+ caps: LINUX_ROOT, // wrapper: null
401
+ baseEnv,
402
+ filesystem: { scratchDir: "/tmp/run-c" },
403
+ });
404
+ expect(h.effective.enforced.filesystem).toBe(false);
405
+ expect(h.effective.downgrades.map((d) => d.layer)).toContain("filesystem");
406
+ // No bwrap/nsjail in the wrapped argv when degraded.
407
+ expect(h.wrapCmd(["sh", "-c", "x"])).not.toContain("bwrap");
408
+ });
409
+
410
+ it("degrades FS when the runner provides no scratch dir (e.g. shell runner)", () => {
411
+ const policy = sandboxPolicySchema.parse({
412
+ filesystem: { mode: "scratch-only" },
413
+ privilege: { mode: "inherit" },
414
+ });
415
+ const h = buildSpawnHardening({
416
+ policy,
417
+ caps: LINUX_NONROOT_BWRAP,
418
+ baseEnv,
419
+ // no filesystem inputs
420
+ });
421
+ expect(h.effective.enforced.filesystem).toBe(false);
422
+ const fsDowngrade = h.effective.downgrades.find(
423
+ (d) => d.layer === "filesystem",
424
+ );
425
+ expect(fsDowngrade?.reason).toContain("scratch dir");
426
+ });
427
+
428
+ it("fail-closed: FS unenforceable + onUnavailable:fail throws before spawn", () => {
429
+ const policy = sandboxPolicySchema.parse({
430
+ onUnavailable: "fail",
431
+ filesystem: { mode: "scratch-only" },
432
+ privilege: { mode: "inherit" },
433
+ network: { denyLinkLocalAndMetadata: false },
434
+ });
435
+ expect(() =>
436
+ buildSpawnHardening({
437
+ policy,
438
+ caps: LINUX_ROOT, // no wrapper
439
+ baseEnv,
440
+ filesystem: { scratchDir: "/tmp/run-d" },
441
+ }),
442
+ ).toThrow(SandboxUnavailableError);
443
+ });
444
+ });
445
+
446
+ describe("buildSpawnHardening — filesystem interpreter bind (P2 fix)", () => {
447
+ it("ro-binds the interpreter when it lives outside the base paths", () => {
448
+ const policy = sandboxPolicySchema.parse({
449
+ filesystem: { mode: "scratch-only" },
450
+ privilege: { mode: "inherit" },
451
+ network: { denyLinkLocalAndMetadata: false },
452
+ });
453
+ const h = buildSpawnHardening({
454
+ policy,
455
+ caps: LINUX_NONROOT_BWRAP,
456
+ baseEnv,
457
+ filesystem: {
458
+ scratchDir: "/tmp/run-i",
459
+ interpreterPath: "/home/u/.bun/bin/bun",
460
+ },
461
+ // pathExists/realpath are real here; assert via wrapping the prelude.
462
+ });
463
+ const wrapped = h.wrapCmd(["/home/u/.bun/bin/bun", "runner.mjs"]).join(" ");
464
+ // Bind only emitted when the interpreter exists on disk; this is a unit
465
+ // host where it likely does not, so we assert the COMPOSITION shape instead
466
+ // in the dedicated filesystem.test. Here we at least confirm no crash and a
467
+ // bwrap prelude.
468
+ expect(wrapped.startsWith("bwrap")).toBe(true);
469
+ });
470
+ });
471
+
472
+ describe("buildSpawnHardening — TMPDIR override under FS confinement (P2 fix)", () => {
473
+ it("pins TMPDIR=/tmp when the FS wrapper is enforced", () => {
474
+ const policy = sandboxPolicySchema.parse({
475
+ filesystem: { mode: "scratch-only" },
476
+ privilege: { mode: "inherit" },
477
+ network: { denyLinkLocalAndMetadata: false },
478
+ });
479
+ const h = buildSpawnHardening({
480
+ policy,
481
+ caps: LINUX_NONROOT_BWRAP,
482
+ baseEnv: { ...baseEnv, TMPDIR: "/host/tmp" },
483
+ filesystem: { scratchDir: "/tmp/run-t" },
484
+ });
485
+ expect(h.env.TMPDIR).toBe("/tmp");
486
+ });
487
+
488
+ it("does NOT override TMPDIR when FS confinement is off", () => {
489
+ const policy = sandboxPolicySchema.parse({
490
+ filesystem: { mode: "off" },
491
+ privilege: { mode: "inherit" },
492
+ network: { denyLinkLocalAndMetadata: false },
493
+ });
494
+ const h = buildSpawnHardening({
495
+ policy,
496
+ caps: LINUX_NONROOT_BWRAP,
497
+ baseEnv: { ...baseEnv, TMPDIR: "/host/tmp" },
498
+ });
499
+ expect(h.env.TMPDIR).toBe("/host/tmp");
500
+ });
501
+ });
502
+
503
+ describe("buildSpawnHardening — network egress (Phase 3)", () => {
504
+ it("deny: composes a fresh net namespace into the FS wrapper (bwrap)", () => {
505
+ const policy = sandboxPolicySchema.parse({
506
+ filesystem: { mode: "scratch-only" },
507
+ network: { mode: "deny" },
508
+ privilege: { mode: "inherit" },
509
+ });
510
+ const h = buildSpawnHardening({
511
+ policy,
512
+ caps: LINUX_NONROOT_BWRAP,
513
+ baseEnv,
514
+ filesystem: { scratchDir: "/tmp/run-n1" },
515
+ });
516
+ const wrapped = h.wrapCmd(["sh", "-c", "x"]);
517
+ // ONE wrapper invocation, taking a fresh net namespace (no --share-net).
518
+ expect(wrapped[0]).toBe("bwrap");
519
+ expect(wrapped).toContain("--unshare-net");
520
+ expect(wrapped).not.toContain("--share-net");
521
+ expect(h.effective.enforced.network).toBe(true);
522
+ expect(h.effective.enforced.filesystem).toBe(true);
523
+ expect(h.nftRuleset).toBeUndefined(); // deny needs no ruleset
524
+ });
525
+
526
+ it("FS stays on host net when network is unrestricted + block disabled", () => {
527
+ const policy = sandboxPolicySchema.parse({
528
+ filesystem: { mode: "scratch-only" },
529
+ network: { mode: "unrestricted", denyLinkLocalAndMetadata: false },
530
+ privilege: { mode: "inherit" },
531
+ });
532
+ const h = buildSpawnHardening({
533
+ policy,
534
+ caps: LINUX_NONROOT_BWRAP,
535
+ baseEnv,
536
+ filesystem: { scratchDir: "/tmp/run-n2" },
537
+ });
538
+ const wrapped = h.wrapCmd(["sh", "-c", "x"]);
539
+ expect(wrapped).toContain("--share-net");
540
+ expect(wrapped).not.toContain("--unshare-net");
541
+ expect(h.effective.enforced.network).toBe(true);
542
+ });
543
+
544
+ it("allowlist: plumbs macvlan egress + nft ruleset on a plumbed nsjail host", () => {
545
+ const policy = sandboxPolicySchema.parse({
546
+ filesystem: { mode: "scratch-plus-ro" },
547
+ network: { mode: "allowlist", allow: ["10.0.0.0/8"] },
548
+ privilege: { mode: "inherit" },
549
+ });
550
+ const h = buildSpawnHardening({
551
+ policy,
552
+ caps: LINUX_ROOT_NSJAIL_PLUMBED,
553
+ baseEnv,
554
+ filesystem: { scratchDir: "/tmp/run-n3" },
555
+ nftRulesetPath: "/tmp/run-n3/egress.nft",
556
+ });
557
+ const wrapped = h.wrapCmd([process.execPath, "runner.mjs"]);
558
+ expect(wrapped[0]).toBe("nsjail");
559
+ expect(wrapped).toContain("--clone_newnet");
560
+ // Real uplink plumbed in → allowed CIDR reachable (not a blackhole).
561
+ expect(wrapped).toContain("--macvlan_iface");
562
+ expect(wrapped).toContain("eth0");
563
+ expect(wrapped).toContain("--nftables_file");
564
+ expect(wrapped).toContain("/tmp/run-n3/egress.nft");
565
+ expect(h.nftRuleset).toContain("10.0.0.0/8");
566
+ expect(h.effective.enforced.network).toBe(true);
567
+ });
568
+
569
+ it("allowlist degrades (surfaced, NOT blackhole) on an nsjail host without plumbing", () => {
570
+ const policy = sandboxPolicySchema.parse({
571
+ filesystem: { mode: "off" },
572
+ network: { mode: "allowlist", allow: ["10.0.0.0/8"] },
573
+ privilege: { mode: "inherit" },
574
+ });
575
+ const h = buildSpawnHardening({
576
+ policy,
577
+ caps: LINUX_NONROOT_NSJAIL, // nsjail but euid not root / no iface
578
+ baseEnv,
579
+ });
580
+ expect(h.effective.enforced.network).toBe(false);
581
+ expect(h.effective.downgrades.map((d) => d.layer)).toContain("network");
582
+ // Degrade-to-host-net: NO routeless namespace engaged.
583
+ expect(h.wrapCmd(["sh", "-c", "x"])).not.toContain("--clone_newnet");
584
+ });
585
+
586
+ it("allowlist degrades on bwrap (no plumbing), surfaced", () => {
587
+ const policy = sandboxPolicySchema.parse({
588
+ filesystem: { mode: "off" },
589
+ network: { mode: "allowlist", allow: ["10.0.0.0/8"] },
590
+ privilege: { mode: "inherit" },
591
+ });
592
+ const h = buildSpawnHardening({
593
+ policy,
594
+ caps: LINUX_NONROOT_BWRAP,
595
+ baseEnv,
596
+ });
597
+ expect(h.effective.enforced.network).toBe(false);
598
+ expect(h.effective.downgrades.map((d) => d.layer)).toContain("network");
599
+ expect(h.wrapCmd(["sh", "-c", "x"])).not.toContain("--unshare-net");
600
+ });
601
+
602
+ it("allowlist: ROOTLESS path builds a launcher prelude + nft filter, enforced.network truthful", () => {
603
+ const policy = sandboxPolicySchema.parse({
604
+ filesystem: { mode: "scratch-plus-ro" },
605
+ network: { mode: "allowlist", allow: ["10.0.0.0/8"] },
606
+ privilege: { mode: "inherit" },
607
+ });
608
+ const h = buildSpawnHardening({
609
+ policy,
610
+ caps: LINUX_BWRAP_ROOTLESS,
611
+ baseEnv,
612
+ filesystem: { scratchDir: "/tmp/run-rl1" },
613
+ nftRulesetPath: "/tmp/run-rl1/egress.nft",
614
+ rootlessLauncherPath: "/tmp/run-rl1/rootless-egress.sh",
615
+ });
616
+ // The prelude is the staged launcher script (slirp4netns + fail-closed nft).
617
+ const wrapped = h.wrapCmd([process.execPath, "runner.mjs"]);
618
+ expect(wrapped[0]).toBe("sh");
619
+ expect(wrapped[1]).toBe("/tmp/run-rl1/rootless-egress.sh");
620
+ // The real command is appended as the launcher's positional args.
621
+ expect(wrapped).toContain(process.execPath);
622
+ expect(wrapped).toContain("runner.mjs");
623
+ // The launcher script + nft ruleset are produced for the runner to stage.
624
+ expect(h.rootlessLauncher).toBeDefined();
625
+ expect(h.rootlessLauncher).toContain("slirp4netns --configure");
626
+ expect(h.rootlessLauncher).toContain("--unshare-net"); // bwrap takes a fresh netns
627
+ expect(h.nftRuleset).toContain("10.0.0.0/8");
628
+ expect(h.nftRuleset).toContain("169.254.0.0/16"); // metadata still blocked
629
+ // enforced.network is TRUE only because the filter is loaded fail-closed
630
+ // inside a plumbed (slirp4netns) namespace — real, filtered egress.
631
+ expect(h.effective.enforced.network).toBe(true);
632
+ expect(h.effective.enforced.filesystem).toBe(true);
633
+ });
634
+
635
+ it("ROOTLESS path threads the prlimit prelude AFTER the launcher, BEFORE the command", () => {
636
+ // A rootless bwrap host that is ALSO euid-root (so the uid drop takes
637
+ // effect, enabling RLIMIT_NPROC) with full rlimit caps. The launcher path
638
+ // must come first; the prlimit prelude + real command are appended as the
639
+ // launcher's positional args, so prlimit runs INSIDE the namespace (via the
640
+ // inner `exec "$@"`) and caps the real command — not the launcher itself.
641
+ const rootlessRoot: SandboxCapabilities = {
642
+ ...LINUX_BWRAP_ROOTLESS,
643
+ euidIsRoot: true,
644
+ };
645
+ const policy = sandboxPolicySchema.parse({
646
+ ...DEFAULT_SANDBOX_PROFILE,
647
+ filesystem: { mode: "scratch-plus-ro" },
648
+ network: { mode: "allowlist", allow: ["10.0.0.0/8"] },
649
+ privilege: { mode: "drop-to-uid", uid: 1001, gid: 1001 },
650
+ });
651
+ const h = buildSpawnHardening({
652
+ policy,
653
+ caps: rootlessRoot,
654
+ baseEnv,
655
+ filesystem: { scratchDir: "/tmp/run-rl4" },
656
+ nftRulesetPath: "/tmp/run-rl4/egress.nft",
657
+ rootlessLauncherPath: "/tmp/run-rl4/rootless-egress.sh",
658
+ });
659
+ // The uid drop took effect (precondition for --nproc) and resources/network
660
+ // are both enforced. `h.uid` is set for observability (the wrapper carries
661
+ // the real `--uid` drop; the runner never passes it to Bun.spawn).
662
+ expect(h.uid).toBe(1001);
663
+ expect(h.effective.enforced.resources).toBe(true);
664
+ expect(h.effective.enforced.network).toBe(true);
665
+
666
+ const wrapped = h.wrapCmd([process.execPath, "runner.mjs"]);
667
+ // Launcher first.
668
+ expect(wrapped[0]).toBe("sh");
669
+ expect(wrapped[1]).toBe("/tmp/run-rl4/rootless-egress.sh");
670
+ // The prlimit prelude is threaded as the launcher's positional args, AFTER
671
+ // the launcher path, terminated by `--`, then the real command.
672
+ const prlimitIdx = wrapped.indexOf("prlimit");
673
+ const sepIdx = wrapped.indexOf("--", prlimitIdx);
674
+ const cmdIdx = wrapped.indexOf(process.execPath);
675
+ expect(prlimitIdx).toBeGreaterThan(1); // after `sh <launcher>`
676
+ expect(wrapped).toContain("--cpu=60");
677
+ expect(wrapped.some((a) => a.startsWith("--as="))).toBe(false); // no RLIMIT_AS
678
+ expect(wrapped).toContain("--nproc=256"); // emitted because the uid dropped
679
+ // Ordering: launcher path < prlimit < `--` < real command.
680
+ expect(prlimitIdx).toBeLessThan(sepIdx);
681
+ expect(sepIdx).toBeLessThan(cmdIdx);
682
+ // The launcher itself is NOT wrapped by prlimit (it precedes the prelude).
683
+ expect(wrapped.indexOf("/tmp/run-rl4/rootless-egress.sh")).toBeLessThan(
684
+ prlimitIdx,
685
+ );
686
+ });
687
+
688
+ it("ROOTLESS path degrades (surfaced) when the runner cannot stage a launcher", () => {
689
+ // No rootlessLauncherPath provided → cannot orchestrate slirp4netns → must
690
+ // degrade to host net, NOT take a routeless namespace (blackhole).
691
+ const policy = sandboxPolicySchema.parse({
692
+ filesystem: { mode: "off" },
693
+ network: { mode: "allowlist", allow: ["10.0.0.0/8"] },
694
+ privilege: { mode: "inherit" },
695
+ });
696
+ const h = buildSpawnHardening({
697
+ policy,
698
+ caps: LINUX_BWRAP_ROOTLESS,
699
+ baseEnv,
700
+ nftRulesetPath: "/tmp/run-rl2/egress.nft",
701
+ // rootlessLauncherPath intentionally omitted.
702
+ });
703
+ expect(h.effective.enforced.network).toBe(false);
704
+ expect(h.effective.downgrades.map((d) => d.layer)).toContain("network");
705
+ expect(h.rootlessLauncher).toBeUndefined();
706
+ // Degrade-to-host-net: the cmd runs as-is (no launcher prelude, no netns).
707
+ const wrapped = h.wrapCmd(["sh", "-c", "x"]);
708
+ expect(wrapped).toEqual(["sh", "-c", "x"]);
709
+ expect(wrapped).not.toContain("/tmp/run-rl2/rootless-egress.sh");
710
+ expect(wrapped).not.toContain("--unshare-net");
711
+ });
712
+
713
+ it("a NON-EMPTY allowlist enforces egress via the ROOTLESS path", () => {
714
+ // A non-empty allowlist genuinely needs plumbed+filtered egress; on a
715
+ // rootless bwrap host it engages the slirp4netns launcher so the nft filter
716
+ // is installed in a real namespace - enforced.network truthful, never a
717
+ // blackhole. (The EMPTY-allowlist secure default takes the simpler routeless
718
+ // deny path; see the dedicated test below.)
719
+ const h = buildSpawnHardening({
720
+ policy: sandboxPolicySchema.parse({
721
+ ...DEFAULT_SANDBOX_PROFILE,
722
+ onUnavailable: "degrade",
723
+ network: { mode: "allowlist", allow: ["10.0.0.0/8"] },
724
+ }),
725
+ caps: LINUX_BWRAP_ROOTLESS,
726
+ baseEnv,
727
+ filesystem: { scratchDir: "/tmp/run-rl3" },
728
+ nftRulesetPath: "/tmp/run-rl3/egress.nft",
729
+ rootlessLauncherPath: "/tmp/run-rl3/rootless-egress.sh",
730
+ });
731
+ expect(h.rootlessLauncher).toBeDefined();
732
+ expect(h.nftRuleset).toContain("169.254.0.0/16");
733
+ expect(h.effective.enforced.network).toBe(true);
734
+ });
735
+
736
+ it("EMPTY-allowlist secure default resolves to the routeless DENY netns (works without plumbing)", () => {
737
+ // The shipped secure default is `allowlist` with an EMPTY allow list =
738
+ // deny ALL egress. That is semantically `deny`, so it takes the routeless
739
+ // namespace path: NO slirp4netns/macvlan plumbing and NO nftables ruleset
740
+ // are needed, so it enforces on ANY netns-capable host (incl. rootless
741
+ // containers where in-namespace nft is not permitted). A routeless netns
742
+ // also inherently blocks metadata (nothing routes).
743
+ const h = buildSpawnHardening({
744
+ policy: sandboxPolicySchema.parse({
745
+ ...DEFAULT_SANDBOX_PROFILE,
746
+ privilege: { mode: "drop-to-uid", uid: 1001, gid: 1001 },
747
+ }),
748
+ caps: LINUX_ROOT_NSJAIL_PLUMBED,
749
+ baseEnv,
750
+ filesystem: { scratchDir: "/tmp/run-id1" },
751
+ nftRulesetPath: "/tmp/run-id1/egress.nft",
752
+ });
753
+ const wrapped = h.wrapCmd([process.execPath, "runner.mjs"]);
754
+ // Routeless deny netns: NO macvlan uplink, NO egress filter file.
755
+ expect(wrapped).toContain("--clone_newnet");
756
+ expect(wrapped).not.toContain("--macvlan_iface");
757
+ expect(wrapped).not.toContain("--nftables_file");
758
+ expect(h.nftRuleset).toBeUndefined();
759
+ expect(h.effective.enforced.network).toBe(true);
760
+ expect(h.effective.downgrades.map((d) => d.layer)).not.toContain("network");
761
+ });
762
+
763
+ it("a NON-EMPTY allowlist enforces via the macvlan uplink on a plumbed nsjail host", () => {
764
+ const h = buildSpawnHardening({
765
+ policy: sandboxPolicySchema.parse({
766
+ ...DEFAULT_SANDBOX_PROFILE,
767
+ network: { mode: "allowlist", allow: ["203.0.113.0/24"] },
768
+ privilege: { mode: "drop-to-uid", uid: 1001, gid: 1001 },
769
+ }),
770
+ caps: LINUX_ROOT_NSJAIL_PLUMBED,
771
+ baseEnv,
772
+ filesystem: { scratchDir: "/tmp/run-id1b" },
773
+ nftRulesetPath: "/tmp/run-id1b/egress.nft",
774
+ });
775
+ const wrapped = h.wrapCmd([process.execPath, "runner.mjs"]);
776
+ expect(wrapped).toContain("--macvlan_iface");
777
+ expect(wrapped).toContain("--nftables_file");
778
+ expect(h.nftRuleset).toContain("169.254.0.0/16");
779
+ expect(h.effective.enforced.network).toBe(true);
780
+ });
781
+
782
+ it("a NON-EMPTY allowlist does NOT blackhole on an nsjail host without plumbing (host net, surfaced)", () => {
783
+ // BLOCKER guard: a genuine (non-empty) allowlist on a host that cannot plumb
784
+ // egress must keep HOST net (no routeless netns that would blackhole the
785
+ // allowed destinations), surface the gap, and report enforced.network=false.
786
+ const h = buildSpawnHardening({
787
+ policy: sandboxPolicySchema.parse({
788
+ ...DEFAULT_SANDBOX_PROFILE,
789
+ onUnavailable: "degrade",
790
+ network: { mode: "allowlist", allow: ["10.0.0.0/8"] },
791
+ }),
792
+ caps: LINUX_NONROOT_NSJAIL,
793
+ baseEnv,
794
+ filesystem: { scratchDir: "/tmp/run-id2" },
795
+ });
796
+ const wrapped = h.wrapCmd([process.execPath, "runner.mjs"]);
797
+ expect(wrapped).not.toContain("--clone_newnet"); // no severing namespace
798
+ expect(wrapped).not.toContain("--unshare-net");
799
+ expect(h.effective.enforced.network).toBe(false);
800
+ const nd = h.effective.downgrades.find((d) => d.layer === "network");
801
+ // The allowlist could not be plumbed; egress is left on host net rather
802
+ // than blackholed.
803
+ expect(nd?.reason).toContain("blackhole");
804
+ expect(h.nftRuleset).toBeUndefined();
805
+ });
806
+
807
+ it("network-only deny (no FS) still builds a wrapper keeping host FS visible", () => {
808
+ const policy = sandboxPolicySchema.parse({
809
+ filesystem: { mode: "off" },
810
+ network: { mode: "deny" },
811
+ privilege: { mode: "inherit" },
812
+ });
813
+ const h = buildSpawnHardening({
814
+ policy,
815
+ caps: LINUX_NONROOT_BWRAP,
816
+ baseEnv,
817
+ });
818
+ const wrapped = h.wrapCmd(["sh", "-c", "x"]);
819
+ expect(wrapped[0]).toBe("bwrap");
820
+ expect(wrapped).toContain("--unshare-net");
821
+ // No FS confinement → host FS bound in so `sh` still resolves.
822
+ expect(wrapped.join(" ")).toContain("--bind / /");
823
+ expect(h.effective.enforced.network).toBe(true);
824
+ });
825
+
826
+ it("metadata block requested but unenforceable (bwrap) is surfaced, not pretended", () => {
827
+ const policy = sandboxPolicySchema.parse({
828
+ filesystem: { mode: "off" },
829
+ network: { mode: "unrestricted", denyLinkLocalAndMetadata: true },
830
+ privilege: { mode: "inherit" },
831
+ });
832
+ const h = buildSpawnHardening({
833
+ policy,
834
+ caps: LINUX_NONROOT_BWRAP,
835
+ baseEnv,
836
+ });
837
+ expect(h.effective.enforced.network).toBe(false);
838
+ const nd = h.effective.downgrades.find((d) => d.layer === "network");
839
+ expect(nd?.reason).toContain("metadata");
840
+ });
841
+
842
+ it("network deny degrades on macOS and is surfaced", () => {
843
+ const policy = sandboxPolicySchema.parse({
844
+ filesystem: { mode: "off" },
845
+ network: { mode: "deny" },
846
+ privilege: { mode: "inherit" },
847
+ });
848
+ const h = buildSpawnHardening({ policy, caps: MACOS_NONE, baseEnv });
849
+ expect(h.effective.enforced.network).toBe(false);
850
+ expect(h.effective.downgrades.map((d) => d.layer)).toContain("network");
851
+ expect(h.wrapCmd(["sh", "-c", "x"])).toEqual(["sh", "-c", "x"]);
852
+ });
853
+
854
+ it("fail-closed: network deny unenforceable + onUnavailable:fail throws", () => {
855
+ const policy = sandboxPolicySchema.parse({
856
+ onUnavailable: "fail",
857
+ filesystem: { mode: "off" },
858
+ network: { mode: "deny" },
859
+ privilege: { mode: "inherit" },
860
+ });
861
+ expect(() =>
862
+ buildSpawnHardening({ policy, caps: MACOS_NONE, baseEnv }),
863
+ ).toThrow(SandboxUnavailableError);
864
+ });
865
+ });
866
+
867
+ describe("buildSpawnHardening — non-root supervisor privilege model", () => {
868
+ // The shipped images run the SUPERVISOR as non-root (uid 65532). The script
869
+ // inherits non-root from the supervisor by construction, so it can NEVER be
870
+ // host-root, regardless of whether a wrapper carries a `--uid` drop. A
871
+ // rootless `--uid` to a DIFFERENT id is impossible (no subuid/newuidmap) and
872
+ // not needed. So `enforced.privilege` must be TRUE on every non-root-euid
873
+ // path, and the wrapper must NOT carry a `--uid` flag.
874
+ const policy = sandboxPolicySchema.parse({
875
+ filesystem: { mode: "scratch-plus-ro" },
876
+ network: { mode: "deny" },
877
+ privilege: { mode: "drop-to-uid", uid: 65532, gid: 65532 },
878
+ });
879
+
880
+ it("enforces privilege by inheritance when euid is non-root + wrapper engaged", () => {
881
+ const h = buildSpawnHardening({
882
+ policy,
883
+ caps: LINUX_NONROOT_BWRAP, // euidIsRoot: false
884
+ baseEnv,
885
+ filesystem: { scratchDir: "/tmp/run-nr1" },
886
+ });
887
+ expect(h.effective.enforced.privilege).toBe(true);
888
+ // No wrapper-level uid drop (rootless cannot map to a different id).
889
+ const wrapped = h.wrapCmd(["sh", "-c", "x"]);
890
+ expect(wrapped).not.toContain("--uid");
891
+ expect(wrapped).not.toContain("--gid");
892
+ // Spawn-level uid/gid are NOT carried (no-op + forward-compat hazard).
893
+ expect(h.uid).toBeUndefined();
894
+ expect(h.gid).toBeUndefined();
895
+ // No privilege downgrade surfaced (it IS enforced by inheritance).
896
+ expect(h.effective.downgrades.map((d) => d.layer)).not.toContain("privilege");
897
+ });
898
+
899
+ it("enforces privilege by inheritance when euid is non-root + NO wrapper", () => {
900
+ // FS off + host net = no wrapper. With a non-root supervisor the child STILL
901
+ // cannot be host-root (inheritance), so privilege is enforced and there is
902
+ // no spawn uid/gid and no downgrade.
903
+ const h = buildSpawnHardening({
904
+ policy: sandboxPolicySchema.parse({
905
+ onUnavailable: "degrade",
906
+ filesystem: { mode: "off" },
907
+ network: { mode: "unrestricted", denyLinkLocalAndMetadata: false },
908
+ privilege: { mode: "drop-to-uid", uid: 65532, gid: 65532 },
909
+ }),
910
+ caps: LINUX_NONROOT_BWRAP,
911
+ baseEnv,
912
+ });
913
+ expect(h.effective.enforced.privilege).toBe(true);
914
+ expect(h.uid).toBeUndefined();
915
+ expect(h.gid).toBeUndefined();
916
+ expect(h.effective.downgrades.map((d) => d.layer)).not.toContain("privilege");
917
+ });
918
+
919
+ it("does NOT report privilege enforced when euid IS root and NO wrapper carries the drop", () => {
920
+ // Legacy root-supervisor path: a drop-to-uid with no wrapper engaged cannot
921
+ // drop (Bun.spawn uid/gid is a silent no-op), so the child WOULD run as
922
+ // host-root. Must NOT claim enforced; must surface a downgrade.
923
+ const h = buildSpawnHardening({
924
+ policy: sandboxPolicySchema.parse({
925
+ onUnavailable: "degrade",
926
+ filesystem: { mode: "off" },
927
+ network: { mode: "unrestricted", denyLinkLocalAndMetadata: false },
928
+ privilege: { mode: "drop-to-uid", uid: 1001, gid: 1001 },
929
+ }),
930
+ caps: LINUX_NONROOT_BWRAP_ROOT, // euidIsRoot: true
931
+ baseEnv,
932
+ });
933
+ expect(h.effective.enforced.privilege).toBe(false);
934
+ expect(h.effective.downgrades.map((d) => d.layer)).toContain("privilege");
935
+ // No spawn uid/gid even on the root path when no wrapper carries the drop:
936
+ // passing them is a no-op today and a forward-compat hazard (a future Bun
937
+ // honoring them would spawn bwrap itself as the dropped id, breaking userns).
938
+ expect(h.uid).toBeUndefined();
939
+ expect(h.gid).toBeUndefined();
940
+ });
941
+
942
+ it("root supervisor + wrapper still carries --uid drop and reports enforced", () => {
943
+ const h = buildSpawnHardening({
944
+ policy: sandboxPolicySchema.parse({
945
+ filesystem: { mode: "scratch-only" },
946
+ privilege: { mode: "drop-to-uid", uid: 1001, gid: 1001 },
947
+ }),
948
+ caps: LINUX_NONROOT_BWRAP_ROOT,
949
+ baseEnv,
950
+ filesystem: { scratchDir: "/tmp/run-root-drop" },
951
+ });
952
+ expect(h.effective.enforced.privilege).toBe(true);
953
+ const wrapped = h.wrapCmd(["sh", "-c", "x"]);
954
+ expect(wrapped).toContain("--uid");
955
+ expect(wrapped).toContain("1001");
956
+ // On the genuine root-wrapper drop path `h.uid` IS set, but for
957
+ // OBSERVABILITY only - the runners never pass it to Bun.spawn (verified in
958
+ // the runner tests). The wrapper's `--uid` is what actually drops.
959
+ expect(h.uid).toBe(1001);
960
+ expect(h.gid).toBe(1001);
961
+ });
962
+
963
+ it("fail-closed default works on a non-root rootless host (no unexpected downgrades)", () => {
964
+ // The KEY acceptance: under the shipped fail-default on a non-root rootless
965
+ // bwrap host, every applicable layer is enforced and the run is NOT refused.
966
+ const h = buildSpawnHardening({
967
+ policy: sandboxPolicySchema.parse({
968
+ ...DEFAULT_SANDBOX_PROFILE, // onUnavailable: "fail"
969
+ privilege: { mode: "drop-to-uid", uid: 65532, gid: 65532 },
970
+ }),
971
+ caps: { ...LINUX_BWRAP_ROOTLESS, euidIsRoot: false },
972
+ baseEnv,
973
+ filesystem: { scratchDir: "/tmp/run-faildef" },
974
+ nftRulesetPath: "/tmp/run-faildef/egress.nft",
975
+ rootlessLauncherPath: "/tmp/run-faildef/rootless-egress.sh",
976
+ });
977
+ // Empty allowlist => routeless deny netns (no plumbing needed).
978
+ expect(h.effective.enforced.network).toBe(true);
979
+ expect(h.effective.enforced.filesystem).toBe(true);
980
+ expect(h.effective.enforced.resources).toBe(true);
981
+ expect(h.effective.enforced.privilege).toBe(true);
982
+ expect(h.effective.downgrades).toEqual([]);
983
+ });
984
+ });
985
+
986
+ describe("buildSpawnHardening — shell memory honesty (review MAJOR)", () => {
987
+ it("emits a non-fatal memory note (NOT a downgrade) when the node heap cap is not applied", () => {
988
+ // Shell runs do not consume NODE_OPTIONS=--max-old-space-size, so per-run
989
+ // memory is NOT enforced; the ceiling is the cgroup. This must be surfaced
990
+ // as a NOTE, never a downgrade, and must NOT fail-close.
991
+ const policy = sandboxPolicySchema.parse({
992
+ ...DEFAULT_SANDBOX_PROFILE,
993
+ onUnavailable: "fail",
994
+ privilege: { mode: "drop-to-uid", uid: 65532, gid: 65532 },
995
+ });
996
+ const h = buildSpawnHardening({
997
+ policy,
998
+ caps: { ...LINUX_BWRAP_ROOTLESS, euidIsRoot: false },
999
+ baseEnv,
1000
+ filesystem: { scratchDir: "/tmp/run-shmem" },
1001
+ appliesNodeMemoryCap: false, // shell runner
1002
+ });
1003
+ const memNote = h.effective.notes.find(
1004
+ (n) => n.layer === "resources" && n.note.includes("memoryBytes"),
1005
+ );
1006
+ expect(memNote).toBeDefined();
1007
+ expect(memNote?.note).toContain("cgroup");
1008
+ // Memory must NOT appear as a downgrade (would fail-close + break all shell).
1009
+ expect(
1010
+ h.effective.downgrades.some(
1011
+ (d) => d.layer === "resources" && d.reason.includes("memory"),
1012
+ ),
1013
+ ).toBe(false);
1014
+ });
1015
+
1016
+ it("does NOT emit the shell memory note when the node heap cap IS applied (ESM)", () => {
1017
+ const policy = sandboxPolicySchema.parse({
1018
+ ...DEFAULT_SANDBOX_PROFILE,
1019
+ privilege: { mode: "drop-to-uid", uid: 65532, gid: 65532 },
1020
+ });
1021
+ const h = buildSpawnHardening({
1022
+ policy,
1023
+ caps: { ...LINUX_BWRAP_ROOTLESS, euidIsRoot: false },
1024
+ baseEnv,
1025
+ filesystem: { scratchDir: "/tmp/run-esmmem" },
1026
+ appliesNodeMemoryCap: true, // ESM runner
1027
+ });
1028
+ // No MEMORY note for ESM (the heap cap IS applied). An unrelated nproc note
1029
+ // may exist under the non-root model; we assert specifically on memory.
1030
+ expect(
1031
+ h.effective.notes.find(
1032
+ (n) => n.layer === "resources" && n.note.includes("memoryBytes"),
1033
+ ),
1034
+ ).toBeUndefined();
1035
+ expect(h.nodeMemoryFlagEnv?.NODE_OPTIONS).toContain("--max-old-space-size=");
1036
+ });
1037
+
1038
+ it("REGRESSION GUARD: shell memory is never reported as per-run enforced", () => {
1039
+ // This test must FAIL if a future change ever makes the hardening builder
1040
+ // imply a per-run memory guarantee for a runner that does not apply the heap
1041
+ // cap (i.e. the shell runner, appliesNodeMemoryCap !== true). The honest
1042
+ // representation is: a memory note is present whenever memoryBytes is set but
1043
+ // the node heap cap is not applied.
1044
+ for (const caps of [
1045
+ LINUX_NONROOT_BWRAP,
1046
+ LINUX_NONROOT_BWRAP_ROOT,
1047
+ MACOS_NONE,
1048
+ ] as const) {
1049
+ const h = buildSpawnHardening({
1050
+ policy: sandboxPolicySchema.parse({
1051
+ onUnavailable: "degrade",
1052
+ resources: { memoryBytes: 512 * 1024 * 1024 },
1053
+ filesystem: { mode: "off" },
1054
+ network: { mode: "unrestricted", denyLinkLocalAndMetadata: false },
1055
+ privilege: { mode: "inherit" },
1056
+ }),
1057
+ caps,
1058
+ baseEnv,
1059
+ // appliesNodeMemoryCap omitted => false => the shell-runner contract.
1060
+ });
1061
+ const memNote = h.effective.notes.find(
1062
+ (n) => n.layer === "resources" && n.note.includes("memoryBytes"),
1063
+ );
1064
+ expect(memNote).toBeDefined();
1065
+ // And memory is never surfaced as a fatal downgrade for shell.
1066
+ expect(
1067
+ h.effective.downgrades.some(
1068
+ (d) => d.layer === "resources" && d.reason.includes("memory"),
1069
+ ),
1070
+ ).toBe(false);
1071
+ }
1072
+ });
1073
+
1074
+ it("emits no memory note when no memory cap is requested", () => {
1075
+ const policy = sandboxPolicySchema.parse({
1076
+ onUnavailable: "degrade",
1077
+ resources: { maxOutputBytes: 1024 },
1078
+ network: { mode: "unrestricted", denyLinkLocalAndMetadata: false },
1079
+ privilege: { mode: "inherit" },
1080
+ });
1081
+ const h = buildSpawnHardening({
1082
+ policy,
1083
+ caps: LINUX_NONROOT_BWRAP,
1084
+ baseEnv,
1085
+ appliesNodeMemoryCap: false,
1086
+ });
1087
+ expect(h.effective.notes.find((n) => n.layer === "resources")).toBeUndefined();
1088
+ });
1089
+ });
1090
+
1091
+ describe("buildSpawnHardening — onUnavailable: fail", () => {
1092
+ it("throws SandboxUnavailableError before spawn on a degraded host", () => {
1093
+ const policy = sandboxPolicySchema.parse({
1094
+ ...DEFAULT_SANDBOX_PROFILE,
1095
+ onUnavailable: "fail",
1096
+ });
1097
+ expect(() =>
1098
+ buildSpawnHardening({ policy, caps: MACOS_NONE, baseEnv }),
1099
+ ).toThrow(SandboxUnavailableError);
1100
+ });
1101
+
1102
+ it("does NOT throw when everything is enforceable", () => {
1103
+ // A policy that only requests output truncation + inherit privilege is
1104
+ // fully enforceable everywhere.
1105
+ const policy = sandboxPolicySchema.parse({
1106
+ onUnavailable: "fail",
1107
+ resources: { maxOutputBytes: 1024 },
1108
+ network: { mode: "unrestricted", denyLinkLocalAndMetadata: false },
1109
+ });
1110
+ expect(() =>
1111
+ buildSpawnHardening({ policy, caps: MACOS_NONE, baseEnv }),
1112
+ ).not.toThrow();
1113
+ });
1114
+ });
1115
+
1116
+ describe("buildSpawnHardening — live netns probe verdict (Item 3)", () => {
1117
+ // A bwrap host where the LIVE clone probe SUCCEEDED: netNamespaces +
1118
+ // userNsCreatable are true, so a fresh-netns deny is genuinely deliverable.
1119
+ const PROBE_TRUE: SandboxCapabilities = {
1120
+ ...LINUX_NONROOT_BWRAP,
1121
+ userNamespaces: true,
1122
+ userNsCreatable: true,
1123
+ netNamespaces: true,
1124
+ };
1125
+ // The SAME bwrap host, but the live clone(CLONE_NEWUSER|CLONE_NEWNET) probe
1126
+ // FAILED (e.g. default Docker seccomp blocks the clone even though a wrapper
1127
+ // is on PATH). capabilities.ts derives netNamespaces=false + userNamespaces=
1128
+ // false from the live probe, so the wrapper cannot create the namespace.
1129
+ const PROBE_FALSE: SandboxCapabilities = {
1130
+ ...LINUX_NONROOT_BWRAP,
1131
+ userNamespaces: false,
1132
+ userNsCreatable: false,
1133
+ netNamespaces: false,
1134
+ netEgressRootless: false,
1135
+ };
1136
+
1137
+ it("probe true -> network deny enforced (no downgrade)", () => {
1138
+ const policy = sandboxPolicySchema.parse({
1139
+ onUnavailable: "fail",
1140
+ filesystem: { mode: "off" },
1141
+ network: { mode: "deny", denyLinkLocalAndMetadata: true },
1142
+ privilege: { mode: "inherit" },
1143
+ });
1144
+ const h = buildSpawnHardening({
1145
+ policy,
1146
+ caps: PROBE_TRUE,
1147
+ baseEnv,
1148
+ filesystem: { scratchDir: "/tmp/scratch" },
1149
+ });
1150
+ expect(h.effective.enforced.network).toBe(true);
1151
+ expect(
1152
+ h.effective.downgrades.some((d) => d.layer === "network"),
1153
+ ).toBe(false);
1154
+ });
1155
+
1156
+ it("probe false -> network NOT enforced + downgrade (fail-closed refuses)", () => {
1157
+ const policy = sandboxPolicySchema.parse({
1158
+ onUnavailable: "fail",
1159
+ filesystem: { mode: "off" },
1160
+ network: { mode: "deny", denyLinkLocalAndMetadata: true },
1161
+ privilege: { mode: "inherit" },
1162
+ });
1163
+ // Under fail-closed the run is REFUSED rather than falsely reported
1164
+ // enforced — the silent gap Item 3 closes (bwrap on PATH but the live clone
1165
+ // is blocked, so a routeless namespace cannot be created at spawn).
1166
+ expect(() =>
1167
+ buildSpawnHardening({
1168
+ policy,
1169
+ caps: PROBE_FALSE,
1170
+ baseEnv,
1171
+ filesystem: { scratchDir: "/tmp/scratch" },
1172
+ }),
1173
+ ).toThrow(SandboxUnavailableError);
1174
+ });
1175
+
1176
+ it("probe false under degrade -> enforced.network false + surfaced downgrade", () => {
1177
+ const policy = sandboxPolicySchema.parse({
1178
+ onUnavailable: "degrade",
1179
+ filesystem: { mode: "off" },
1180
+ network: { mode: "deny", denyLinkLocalAndMetadata: true },
1181
+ privilege: { mode: "inherit" },
1182
+ });
1183
+ const h = buildSpawnHardening({
1184
+ policy,
1185
+ caps: PROBE_FALSE,
1186
+ baseEnv,
1187
+ filesystem: { scratchDir: "/tmp/scratch" },
1188
+ });
1189
+ expect(h.effective.enforced.network).toBe(false);
1190
+ expect(
1191
+ h.effective.downgrades.some((d) => d.layer === "network"),
1192
+ ).toBe(true);
1193
+ });
1194
+ });