@checkstack/backend-api 0.20.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 (51) hide show
  1. package/CHANGELOG.md +151 -0
  2. package/package.json +12 -11
  3. package/src/auth-strategy.ts +6 -3
  4. package/src/bearer-token.ts +13 -0
  5. package/src/collector-strategy.ts +9 -0
  6. package/src/config-versioning.test.ts +227 -0
  7. package/src/config-versioning.ts +172 -0
  8. package/src/core-services.ts +14 -0
  9. package/src/esm-script-runner.test.ts +55 -16
  10. package/src/esm-script-runner.ts +212 -55
  11. package/src/index.ts +3 -0
  12. package/src/render-templatable-config.test.ts +168 -0
  13. package/src/render-templatable-config.ts +193 -0
  14. package/src/schema-utils.ts +3 -0
  15. package/src/script-sandbox/capabilities.test.ts +122 -0
  16. package/src/script-sandbox/capabilities.ts +372 -0
  17. package/src/script-sandbox/capped-output.test.ts +116 -0
  18. package/src/script-sandbox/capped-output.ts +172 -0
  19. package/src/script-sandbox/env-guard.test.ts +105 -0
  20. package/src/script-sandbox/env-guard.ts +129 -0
  21. package/src/script-sandbox/filesystem.test.ts +437 -0
  22. package/src/script-sandbox/filesystem.ts +514 -0
  23. package/src/script-sandbox/forkbomb.it.test.ts +121 -0
  24. package/src/script-sandbox/global-default.test.ts +161 -0
  25. package/src/script-sandbox/global-default.ts +100 -0
  26. package/src/script-sandbox/index.ts +14 -0
  27. package/src/script-sandbox/network.test.ts +356 -0
  28. package/src/script-sandbox/network.ts +373 -0
  29. package/src/script-sandbox/observability.test.ts +210 -0
  30. package/src/script-sandbox/observability.ts +168 -0
  31. package/src/script-sandbox/output-truncation.test.ts +53 -0
  32. package/src/script-sandbox/output-truncation.ts +69 -0
  33. package/src/script-sandbox/policy.test.ts +189 -0
  34. package/src/script-sandbox/policy.ts +220 -0
  35. package/src/script-sandbox/provider.test.ts +61 -0
  36. package/src/script-sandbox/provider.ts +134 -0
  37. package/src/script-sandbox/readiness.test.ts +80 -0
  38. package/src/script-sandbox/readiness.ts +117 -0
  39. package/src/script-sandbox/report.ts +88 -0
  40. package/src/script-sandbox/rootless-egress.it.test.ts +86 -0
  41. package/src/script-sandbox/rootless-egress.test.ts +99 -0
  42. package/src/script-sandbox/rootless-egress.ts +218 -0
  43. package/src/script-sandbox/shell-quote.test.ts +32 -0
  44. package/src/script-sandbox/shell-quote.ts +10 -0
  45. package/src/script-sandbox/wrapper.test.ts +1194 -0
  46. package/src/script-sandbox/wrapper.ts +714 -0
  47. package/src/shell-script-runner.test.ts +243 -0
  48. package/src/shell-script-runner.ts +210 -45
  49. package/src/zod-config.test.ts +60 -0
  50. package/src/zod-config.ts +38 -14
  51. package/tsconfig.json +3 -0
@@ -0,0 +1,514 @@
1
+ import { existsSync, realpathSync } from "node:fs";
2
+ import type { SandboxCapabilities } from "./capabilities";
3
+ import type { NetworkDecision } from "./network";
4
+ import type { FilesystemPolicy } from "./policy";
5
+
6
+ /**
7
+ * Filesystem isolation (plan §5.2) composed with network egress control
8
+ * (plan §5.3). Both are delivered by the SAME namespace-capable external
9
+ * wrapper (`bwrap` first, then `nsjail`) per the external-wrapper-first
10
+ * decision (§6 / D2): re-implementing unprivileged mount/user/net namespaces
11
+ * natively is a large, security-critical surface, and these tools are small,
12
+ * audited, and built exactly for this.
13
+ *
14
+ * Filesystem modes:
15
+ * - `scratch-only`: the child sees a minimal read-only base system
16
+ * (`/usr`, `/bin`, `/lib`, `/lib64`, `/etc`) plus its per-run scratch dir
17
+ * mounted read-write. Everything else on the host FS is invisible.
18
+ * - `scratch-plus-ro`: `scratch-only` PLUS a read-only bind of the reconciled
19
+ * `resolutionRoot/node_modules` so managed-package imports still resolve.
20
+ *
21
+ * Network composition (Phase 3): the FS wrapper used to hard-code
22
+ * `--share-net` / `--disable_clone_newnet` to keep the host network namespace,
23
+ * because network was a separate, not-yet-built layer. Now the FS and network
24
+ * layers COMPOSE in one wrapper invocation: when the resolved
25
+ * {@link NetworkDecision} keeps the host net we still emit `--share-net`; when
26
+ * it asks for a fresh namespace we emit the net-unshare flags (and, for
27
+ * `nsjail`, install the egress nftables ruleset). A network-only run (FS
28
+ * `off` but `network.deny`/`allowlist`) still produces a wrapper invocation so
29
+ * the namespace can be created.
30
+ *
31
+ * `firejail` is detected for capability reporting but never used to DELIVER
32
+ * namespaces here (its profile model does not map onto the per-run bind/net set
33
+ * we build), so a firejail-only host degrades both layers (surfaced).
34
+ */
35
+
36
+ /** Inputs the runner supplies for the FS layer of a single run. */
37
+ export interface FilesystemRunInputs {
38
+ /**
39
+ * Per-run writable scratch directory (the ESM runner's `mkdtemp` dir). The
40
+ * child's CWD. Required to build any FS confinement — without a scratch dir
41
+ * there is nothing safe to make writable, so the FS layer degrades. A
42
+ * network-only run (no FS confinement) does not need it.
43
+ */
44
+ scratchDir?: string;
45
+ /**
46
+ * Absolute path to the reconciled `node_modules` tree to expose read-only
47
+ * under `scratch-plus-ro`. When the resolution root is unset (the ESM runner
48
+ * was given no `resolutionRoot`), there are no managed packages to bind.
49
+ */
50
+ nodeModulesDir?: string;
51
+ /**
52
+ * Absolute path to the language interpreter the runner execs (e.g.
53
+ * `process.execPath` for the ESM runner's Bun runtime). Under FS confinement
54
+ * the host FS is hidden, so the interpreter binary — which commonly lives
55
+ * outside `/usr`/`/bin` (e.g. `~/.bun/bin/bun`, `/usr/local/bin/bun`) — must
56
+ * be read-only bound into the namespace or the child cannot exec the runtime.
57
+ * The shell runner omits it (`sh` resolves from the bound `/bin`).
58
+ */
59
+ interpreterPath?: string;
60
+ /**
61
+ * The dedicated low-privilege UID/GID to drop to via the NAMESPACE WRAPPER
62
+ * (`bwrap --uid/--gid`, `nsjail --user/--group`). Set ONLY on the legacy
63
+ * ROOT-supervisor path: a root supervisor needs the wrapper to map the child
64
+ * to a dedicated low-priv id. It is left UNSET under the shipped NON-ROOT
65
+ * supervisor model, where the child INHERITS the supervisor's non-root uid by
66
+ * construction (and a rootless `--uid` to a DIFFERENT id is impossible without
67
+ * subuid/newuidmap, and unnecessary). The runner never passes `uid`/`gid` to
68
+ * `Bun.spawn` either: Bun silently ignores those, and honouring them would
69
+ * spawn the wrapper itself as the dropped id and break userns creation.
70
+ */
71
+ dropUid?: number;
72
+ dropGid?: number;
73
+ }
74
+
75
+ export type FilesystemBuildResult =
76
+ | { kind: "off" }
77
+ | {
78
+ kind: "enforced";
79
+ /** argv prelude that wraps the real command (e.g. `bwrap ... --`). */
80
+ prelude: string[];
81
+ }
82
+ | {
83
+ /**
84
+ * The ROOTLESS egress path: the wrapper cannot be a plain argv prelude
85
+ * because `slirp4netns` must be orchestrated from OUTSIDE the namespace
86
+ * (in the parent netns) with a race-free PID + ready handshake. So this
87
+ * returns the `bwrap` argv (WITHOUT the trailing `--`) for
88
+ * {@link buildSpawnHardening} to fold into a generated launcher script
89
+ * (see `rootless-egress.ts`), which is staged on disk and exec'd as the
90
+ * actual prelude. The launcher wires the nft filter (fail-closed) + the
91
+ * slirp4netns userspace stack into the same namespace.
92
+ */
93
+ kind: "enforced-rootless-egress";
94
+ /** `bwrap` argv WITHOUT the trailing `--` (the launcher appends it). */
95
+ bwrapArgv: string[];
96
+ }
97
+ | { kind: "degrade"; reason: string };
98
+
99
+ /**
100
+ * Read-only base system paths exposed inside the namespace so ordinary
101
+ * binaries (`sh`, coreutils) and their shared libraries resolve. Only paths
102
+ * that exist on the host are bound; a missing path is skipped (e.g. `/lib64` is
103
+ * absent on some distros). The language interpreter is bound separately (it may
104
+ * live outside these roots).
105
+ */
106
+ const RO_BASE_PATHS = ["/usr", "/bin", "/sbin", "/lib", "/lib64", "/etc"] as const;
107
+
108
+ /**
109
+ * Resolve the interpreter to a stable real path and dedupe it against the
110
+ * read-only base binds. Returns undefined when there is nothing extra to bind
111
+ * (no interpreter given, or it already lives under a bound base path).
112
+ */
113
+ function resolveInterpreterBind({
114
+ interpreterPath,
115
+ pathExists,
116
+ realpath,
117
+ }: {
118
+ interpreterPath: string | undefined;
119
+ pathExists: (path: string) => boolean;
120
+ realpath: (path: string) => string;
121
+ }): string | undefined {
122
+ if (interpreterPath === undefined) {
123
+ return undefined;
124
+ }
125
+ let resolved: string;
126
+ try {
127
+ resolved = realpath(interpreterPath);
128
+ } catch {
129
+ resolved = interpreterPath;
130
+ }
131
+ if (!pathExists(resolved)) {
132
+ return undefined;
133
+ }
134
+ // Already covered by a RO base bind → no separate bind needed.
135
+ if (RO_BASE_PATHS.some((base) => resolved === base || resolved.startsWith(`${base}/`))) {
136
+ return undefined;
137
+ }
138
+ return resolved;
139
+ }
140
+
141
+ /**
142
+ * Build a `bwrap` argv prelude. Confines the child to `scratchDir` (read-write)
143
+ * over a read-only minimal base system when FS confinement is on, and applies
144
+ * the resolved network decision: `--share-net` to keep the host net, or
145
+ * `--unshare-net` for a fresh (deny / nsjail-handled) namespace.
146
+ */
147
+ function buildBwrapPrelude({
148
+ fsConfinement,
149
+ scratchDir,
150
+ nodeModulesDir,
151
+ interpreterBind,
152
+ net,
153
+ dropUid,
154
+ dropGid,
155
+ pathExists,
156
+ terminate = true,
157
+ }: {
158
+ fsConfinement: boolean;
159
+ scratchDir: string | undefined;
160
+ nodeModulesDir: string | undefined;
161
+ interpreterBind: string | undefined;
162
+ net: NetworkDecision;
163
+ /** Low-priv UID/GID to drop to inside the namespace (`bwrap --uid/--gid`). */
164
+ dropUid: number | undefined;
165
+ dropGid: number | undefined;
166
+ pathExists: (path: string) => boolean;
167
+ /**
168
+ * Append the trailing `--` that separates the bwrap flags from the child
169
+ * argv. True for the ordinary argv-prelude path; FALSE for the rootless
170
+ * egress path, where the launcher appends `--info-fd N --` itself.
171
+ */
172
+ terminate?: boolean;
173
+ }): string[] {
174
+ // Net flag composes with the FS unshare: keep host net, or take a fresh one.
175
+ const netFlag = net.kind === "namespaced" ? "--unshare-net" : "--share-net";
176
+ const args: string[] = ["bwrap", "--unshare-all", netFlag, "--die-with-parent"];
177
+
178
+ // Privilege drop INSIDE the user namespace. `--unshare-all` includes
179
+ // `--unshare-user`, so `--uid`/`--gid` map the child to the dedicated
180
+ // low-priv id. This is the path that ACTUALLY drops privilege (Bun.spawn's
181
+ // uid/gid is silently ignored), so the EffectiveSandbox `enforced.privilege`
182
+ // is only truthful when the wrapper is engaged. Emit gid first so the
183
+ // supplementary-group reset that bwrap performs lands on the right primary.
184
+ if (dropUid !== undefined) {
185
+ if (dropGid !== undefined) {
186
+ args.push("--gid", String(dropGid));
187
+ }
188
+ args.push("--uid", String(dropUid));
189
+ }
190
+
191
+ // Filesystem binds FIRST, then `--proc` / `--dev` overlay on top of the
192
+ // confined tree (bwrap applies operations in order, so a later `--proc`
193
+ // correctly mounts over the bound root rather than being hidden by it).
194
+ if (fsConfinement && scratchDir !== undefined) {
195
+ for (const base of RO_BASE_PATHS) {
196
+ if (pathExists(base)) {
197
+ args.push("--ro-bind", base, base);
198
+ }
199
+ }
200
+ if (interpreterBind !== undefined) {
201
+ args.push("--ro-bind", interpreterBind, interpreterBind);
202
+ }
203
+ // Fresh tmpfs at /tmp BEFORE the scratch bind. The per-run scratch dir is
204
+ // commonly created under the host `/tmp` (os.tmpdir()), so mounting the
205
+ // tmpfs first and binding the scratch dir ON TOP of it keeps the scratch
206
+ // bind visible. The reverse order would let the tmpfs MASK the scratch
207
+ // bind, leaving the child with no CWD (`chdir` then fails and the run
208
+ // hangs/breaks). bwrap applies operations in order, so order matters here.
209
+ args.push("--tmpfs", "/tmp", "--bind", scratchDir, scratchDir);
210
+ if (nodeModulesDir !== undefined && pathExists(nodeModulesDir)) {
211
+ args.push("--ro-bind", nodeModulesDir, nodeModulesDir);
212
+ }
213
+ // The standard pseudo-filesystems on top of the confined tree.
214
+ args.push("--proc", "/proc", "--dev", "/dev", "--chdir", scratchDir);
215
+ } else {
216
+ // Network-only confinement: `--unshare-all` also unshares the mount
217
+ // namespace, so without binds the child would see an empty root. Keep the
218
+ // host filesystem fully visible (no FS layer requested) by binding `/`,
219
+ // then overlay fresh /proc and /dev for the new namespaces.
220
+ args.push("--bind", "/", "/", "--proc", "/proc", "--dev", "/dev");
221
+ }
222
+
223
+ if (terminate) {
224
+ args.push("--");
225
+ }
226
+ return args;
227
+ }
228
+
229
+ /**
230
+ * Build the nsjail network flags for a decision.
231
+ *
232
+ * - `host` → `--disable_clone_newnet` (keep host net).
233
+ * - `deny` (no iface) → `--clone_newnet` only: a ROUTELESS namespace,
234
+ * loopback only — that is the goal, no filter/uplink.
235
+ * - `allowlist` (+iface) → `--clone_newnet` + `--macvlan_iface <iface>` to
236
+ * plumb REAL egress into the namespace, the
237
+ * `--macvlan_vs_ip/_nm/_gw` triple to ADDRESS the
238
+ * endpoint so it actually routes (an unaddressed
239
+ * macvlan still blackholes), + a final
240
+ * `--nftables_file <path>` to filter it. The plumbed +
241
+ * ADDRESSED uplink is what makes the allowlist
242
+ * reachable rather than a blackhole.
243
+ */
244
+ function buildNsjailNetFlags({
245
+ net,
246
+ nftRulesetPath,
247
+ }: {
248
+ net: NetworkDecision;
249
+ nftRulesetPath: string | undefined;
250
+ }): string[] {
251
+ if (net.kind !== "namespaced") {
252
+ return ["--disable_clone_newnet"];
253
+ }
254
+ const flags: string[] = ["--clone_newnet"];
255
+ // Plumb real egress for a filtered allowlist (never for routeless deny).
256
+ if (net.egressIface !== undefined) {
257
+ flags.push("--macvlan_iface", net.egressIface);
258
+ // Address the endpoint so traffic ROUTES out of the macvlan; without these
259
+ // the macvlan interface is up but has no IP/route and still blackholes. The
260
+ // decision only carries addressing when it was available, and `allowlist` /
261
+ // metadata-block only reach `namespaced` WITH addressing (see network.ts).
262
+ if (net.egressAddressing !== undefined) {
263
+ flags.push(
264
+ "--macvlan_vs_ip",
265
+ net.egressAddressing.ip,
266
+ "--macvlan_vs_nm",
267
+ net.egressAddressing.netmask,
268
+ "--macvlan_vs_gw",
269
+ net.egressAddressing.gateway,
270
+ );
271
+ }
272
+ }
273
+ // Install the egress filter when we have both a ruleset and a place to stage
274
+ // it. (Only meaningful alongside the macvlan uplink above; a deny namespace
275
+ // carries no ruleset.)
276
+ if (net.nftRuleset !== undefined && nftRulesetPath !== undefined) {
277
+ flags.push("--nftables_file", nftRulesetPath);
278
+ }
279
+ return flags;
280
+ }
281
+
282
+ /**
283
+ * Build an `nsjail` argv prelude with the equivalent bind set + network
284
+ * handling (see {@link buildNsjailNetFlags}).
285
+ */
286
+ function buildNsjailPrelude({
287
+ fsConfinement,
288
+ scratchDir,
289
+ nodeModulesDir,
290
+ interpreterBind,
291
+ net,
292
+ dropUid,
293
+ dropGid,
294
+ nftRulesetPath,
295
+ pathExists,
296
+ }: {
297
+ fsConfinement: boolean;
298
+ scratchDir: string | undefined;
299
+ nodeModulesDir: string | undefined;
300
+ interpreterBind: string | undefined;
301
+ net: NetworkDecision;
302
+ /** Low-priv UID/GID to drop to inside the namespace (`nsjail --user/--group`). */
303
+ dropUid: number | undefined;
304
+ dropGid: number | undefined;
305
+ nftRulesetPath: string | undefined;
306
+ pathExists: (path: string) => boolean;
307
+ }): string[] {
308
+ const netFlags = buildNsjailNetFlags({ net, nftRulesetPath });
309
+ const args: string[] = ["nsjail", "--quiet", "--mode", "o", ...netFlags];
310
+
311
+ // Privilege drop inside the nsjail user namespace. `--user`/`--group` map the
312
+ // inside-namespace id to the dedicated low-priv id (the path that actually
313
+ // drops; Bun.spawn's uid/gid is ignored).
314
+ if (dropUid !== undefined) {
315
+ args.push("--user", String(dropUid));
316
+ if (dropGid !== undefined) {
317
+ args.push("--group", String(dropGid));
318
+ }
319
+ }
320
+
321
+ if (fsConfinement && scratchDir !== undefined) {
322
+ for (const base of RO_BASE_PATHS) {
323
+ if (pathExists(base)) {
324
+ args.push("--bindmount_ro", `${base}:${base}`);
325
+ }
326
+ }
327
+ if (interpreterBind !== undefined) {
328
+ args.push("--bindmount_ro", `${interpreterBind}:${interpreterBind}`);
329
+ }
330
+ args.push("--bindmount", `${scratchDir}:${scratchDir}`);
331
+ if (nodeModulesDir !== undefined && pathExists(nodeModulesDir)) {
332
+ args.push("--bindmount_ro", `${nodeModulesDir}:${nodeModulesDir}`);
333
+ }
334
+ args.push("--cwd", scratchDir);
335
+ } else {
336
+ // Network-only confinement: keep the host filesystem visible (no FS layer
337
+ // requested) by binding the host root, so ordinary binaries still resolve.
338
+ args.push("--bindmount", "/:/");
339
+ }
340
+
341
+ args.push("--");
342
+ return args;
343
+ }
344
+
345
+ /**
346
+ * Resolve the combined filesystem + network namespace layer for a run into
347
+ * either an argv prelude (enforced) or a degrade reason. Pure & synchronous;
348
+ * the only side-channels are the injectable `pathExists` / `realpath` probes
349
+ * (default to the real `node:fs` calls) so tests can drive them without
350
+ * touching disk.
351
+ *
352
+ * The `network` decision (resolved upstream by `buildNetworkLayer`) is folded
353
+ * into the SAME wrapper invocation so FS and net compose rather than fight. A
354
+ * run that confines only the network (FS `off` but `network` namespaced) still
355
+ * produces a wrapper prelude.
356
+ *
357
+ * @returns `off` when neither FS nor network needs a wrapper; `enforced` with
358
+ * the wrapper prelude when one is needed AND deliverable; `degrade` (with a
359
+ * reason) when FS confinement was requested but cannot be delivered. The
360
+ * network degrade decision is resolved before this is called and handled by
361
+ * the caller; here a `network.kind === "host"` simply keeps host net.
362
+ */
363
+ export function buildFilesystemLayer({
364
+ policy,
365
+ caps,
366
+ inputs,
367
+ network,
368
+ nftRulesetPath,
369
+ pathExists = existsSyncDefault,
370
+ realpath = realpathDefault,
371
+ }: {
372
+ policy: FilesystemPolicy;
373
+ caps: SandboxCapabilities;
374
+ inputs: FilesystemRunInputs;
375
+ /**
376
+ * The resolved network decision to compose into the wrapper. When omitted,
377
+ * the host net is kept (the pre-Phase-3 behavior).
378
+ */
379
+ network?: NetworkDecision;
380
+ /**
381
+ * Path to a file holding the nftables ruleset (written by the runner) for
382
+ * `nsjail --nftables_file`. Required only when `network.nftRuleset` is set.
383
+ */
384
+ nftRulesetPath?: string;
385
+ pathExists?: (path: string) => boolean;
386
+ realpath?: (path: string) => string;
387
+ }): FilesystemBuildResult {
388
+ const net: NetworkDecision = network ?? {
389
+ kind: "host",
390
+ metadataBlockUnenforceable: false,
391
+ };
392
+ const wantsFsConfinement = policy.mode !== "off";
393
+ const wantsNetNamespace = net.kind === "namespaced";
394
+
395
+ // Nothing needs a wrapper: FS off AND net stays on the host.
396
+ if (!wantsFsConfinement && !wantsNetNamespace) {
397
+ return { kind: "off" };
398
+ }
399
+
400
+ // A wrapper is needed (for FS confinement and/or a net namespace). Gate on
401
+ // wrapper capability. FS-specific degrade reasons are only relevant when FS
402
+ // confinement was actually requested; otherwise this is a network-only
403
+ // wrapper and the network degrade was already resolved upstream — but we
404
+ // still need the same primitives, so guard them here too.
405
+ if (caps.platform !== "linux") {
406
+ return {
407
+ kind: "degrade",
408
+ reason: `${wantsFsConfinement ? "filesystem" : "network"} isolation requires Linux namespaces (platform=${caps.platform}); running with full host FS/net`,
409
+ };
410
+ }
411
+ if (!caps.userNamespaces) {
412
+ return {
413
+ kind: "degrade",
414
+ reason:
415
+ "namespace isolation requires unprivileged user namespaces, which are disabled on this host; running with full host FS/net",
416
+ };
417
+ }
418
+ if (caps.wrapper === null) {
419
+ return {
420
+ kind: "degrade",
421
+ reason:
422
+ "namespace isolation requires a wrapper (bwrap/nsjail), none found on PATH; running with full host FS/net",
423
+ };
424
+ }
425
+ if (caps.wrapper === "firejail") {
426
+ return {
427
+ kind: "degrade",
428
+ reason:
429
+ "namespace isolation via firejail is not supported (profile model); install bwrap or nsjail; running with full host FS/net",
430
+ };
431
+ }
432
+ if (wantsFsConfinement && inputs.scratchDir === undefined) {
433
+ return {
434
+ kind: "degrade",
435
+ reason:
436
+ "filesystem isolation needs a per-run scratch dir, none was provided by this runner; running with full host FS",
437
+ };
438
+ }
439
+
440
+ const interpreterBind = resolveInterpreterBind({
441
+ interpreterPath: inputs.interpreterPath,
442
+ pathExists,
443
+ realpath,
444
+ });
445
+
446
+ const nodeModulesDir =
447
+ policy.mode === "scratch-plus-ro" ? inputs.nodeModulesDir : undefined;
448
+
449
+ // The ROOTLESS egress path is bwrap-only and cannot be a plain argv prelude:
450
+ // slirp4netns is orchestrated from the parent netns via a launcher. Build the
451
+ // bwrap argv WITHOUT the trailing `--` and hand it to the caller, which folds
452
+ // it into the launcher script (see rootless-egress.ts). `--unshare-net` is
453
+ // already in the bwrap argv (the `namespaced` decision selects it below), so
454
+ // bwrap takes the fresh net namespace slirp4netns then plumbs into.
455
+ if (net.kind === "namespaced" && net.egressPath === "rootless") {
456
+ // The rootless path is only reachable when caps.wrapper === "bwrap" (see
457
+ // capabilities.ts: netEgressRootless gates on bwrap). Guard defensively.
458
+ if (caps.wrapper !== "bwrap") {
459
+ return {
460
+ kind: "degrade",
461
+ reason:
462
+ "rootless egress (slirp4netns) is delivered only via bwrap; this host's wrapper cannot orchestrate it; egress unrestricted",
463
+ };
464
+ }
465
+ const bwrapArgv = buildBwrapPrelude({
466
+ fsConfinement: wantsFsConfinement,
467
+ scratchDir: inputs.scratchDir,
468
+ nodeModulesDir,
469
+ interpreterBind,
470
+ net,
471
+ dropUid: inputs.dropUid,
472
+ dropGid: inputs.dropGid,
473
+ pathExists,
474
+ terminate: false,
475
+ });
476
+ return { kind: "enforced-rootless-egress", bwrapArgv };
477
+ }
478
+
479
+ const prelude =
480
+ caps.wrapper === "bwrap"
481
+ ? buildBwrapPrelude({
482
+ fsConfinement: wantsFsConfinement,
483
+ scratchDir: inputs.scratchDir,
484
+ nodeModulesDir,
485
+ interpreterBind,
486
+ net,
487
+ dropUid: inputs.dropUid,
488
+ dropGid: inputs.dropGid,
489
+ pathExists,
490
+ })
491
+ : buildNsjailPrelude({
492
+ fsConfinement: wantsFsConfinement,
493
+ scratchDir: inputs.scratchDir,
494
+ nodeModulesDir,
495
+ interpreterBind,
496
+ net,
497
+ dropUid: inputs.dropUid,
498
+ dropGid: inputs.dropGid,
499
+ nftRulesetPath,
500
+ pathExists,
501
+ });
502
+
503
+ return { kind: "enforced", prelude };
504
+ }
505
+
506
+ /** Default on-disk probe. Tests inject a fake to stay off the filesystem. */
507
+ function existsSyncDefault(path: string): boolean {
508
+ return existsSync(path);
509
+ }
510
+
511
+ /** Default realpath resolver. Tests inject a fake to stay off the filesystem. */
512
+ function realpathDefault(path: string): string {
513
+ return realpathSync(path);
514
+ }
@@ -0,0 +1,121 @@
1
+ /**
2
+ * Integration test for PER-RUN FORK-BOMB CONTAINMENT (Item 1), exercising the
3
+ * REAL shell + ESM runners against a real `bwrap` + `prlimit` on a host that can
4
+ * create unprivileged user namespaces. Pure/argv-level coverage (that `--nproc`
5
+ * is emitted inside the bwrap userns) lives in `wrapper.test.ts`; this pins the
6
+ * one thing that cannot: that the cap + the per-run PID namespace genuinely
7
+ * CONTAIN an aggressive fork bomb (it fails cleanly, capped) while the
8
+ * supervisor process stays alive and able to fork.
9
+ *
10
+ * The mechanism: rootless `bwrap --unshare-all` creates a fresh USER namespace
11
+ * (so the per-(uid, userns) RLIMIT_NPROC isolates THIS run's process count even
12
+ * though the child shares the supervisor's uid 65532) AND a fresh PID namespace
13
+ * (so a single kill of the wrapper reaps the whole fork tree). Verified
14
+ * in-container: the bomb hits the cap and the supervisor keeps forking.
15
+ *
16
+ * Gated behind `CHECKSTACK_IT=1` AND auto-skipped when the host cannot create a
17
+ * user namespace + lacks a wrapper + prlimit (the detected capabilities say no),
18
+ * so the default `bun test` and non-Linux/non-rootless CI never run it.
19
+ */
20
+ import { describe, expect, it } from "bun:test";
21
+ import { detectSandboxCapabilities } from "./capabilities";
22
+ import {
23
+ DEFAULT_SANDBOX_PROFILE,
24
+ sandboxPolicySchema,
25
+ type SandboxPolicy,
26
+ } from "./policy";
27
+ import { registerSandboxPolicyProvider } from "./provider";
28
+ import { defaultShellScriptRunner } from "../shell-script-runner";
29
+ import { defaultEsmScriptRunner } from "../esm-script-runner";
30
+
31
+ const caps = detectSandboxCapabilities();
32
+ // The cap is per-run-isolated only when the bwrap user namespace can actually be
33
+ // created and rlimits are enforceable; mirror exactly the in-container target.
34
+ const enabled =
35
+ Boolean(process.env.CHECKSTACK_IT) &&
36
+ caps.platform === "linux" &&
37
+ caps.wrapper === "bwrap" &&
38
+ caps.userNsCreatable &&
39
+ caps.rlimitNative;
40
+
41
+ /** Can the supervisor still spawn a process right now? */
42
+ function supervisorCanFork(): boolean {
43
+ const r = Bun.spawnSync(["sh", "-c", "echo alive"]);
44
+ return (
45
+ r.exitCode === 0 && new TextDecoder().decode(r.stdout).trim() === "alive"
46
+ );
47
+ }
48
+
49
+ describe.skipIf(!enabled)("per-run fork-bomb containment (real bwrap)", () => {
50
+ it("caps a shell fork bomb and keeps the supervisor alive", async () => {
51
+ // A low maxProcesses so the cap bites quickly and deterministically.
52
+ const policy: SandboxPolicy = sandboxPolicySchema.parse({
53
+ ...DEFAULT_SANDBOX_PROFILE,
54
+ resources: { ...DEFAULT_SANDBOX_PROFILE.resources, maxProcesses: 64 },
55
+ });
56
+ registerSandboxPolicyProvider(async () => policy);
57
+
58
+ const start = Date.now();
59
+ const res = await defaultShellScriptRunner.run({
60
+ script: ":(){ :|:& };:",
61
+ timeoutMs: 8000,
62
+ });
63
+ const elapsed = Date.now() - start;
64
+
65
+ // The run fails cleanly (the cap kills the bomb), WITHOUT timing out, and
66
+ // every other layer stays enforced with no downgrades.
67
+ expect(res.timedOut).toBe(false);
68
+ expect(elapsed).toBeLessThan(8000);
69
+ expect(res.sandbox?.enforced.resources).toBe(true);
70
+ expect(res.sandbox?.enforced.filesystem).toBe(true);
71
+ expect(res.sandbox?.enforced.network).toBe(true);
72
+ expect(res.sandbox?.enforced.privilege).toBe(true);
73
+ expect(res.sandbox?.downgrades ?? []).toHaveLength(0);
74
+ // The fork-bomb cap is genuinely enforced per-run, so there is NO
75
+ // "RLIMIT_NPROC not applied" note on this wrapped path.
76
+ expect(
77
+ (res.sandbox?.notes ?? []).some((n) => n.note.includes("RLIMIT_NPROC")),
78
+ ).toBe(false);
79
+ // The load-bearing assertion: the supervisor survived the bomb.
80
+ expect(supervisorCanFork()).toBe(true);
81
+ });
82
+
83
+ it("caps an ESM spawn-loop bomb and keeps the supervisor alive", async () => {
84
+ const policy: SandboxPolicy = sandboxPolicySchema.parse({
85
+ ...DEFAULT_SANDBOX_PROFILE,
86
+ resources: { ...DEFAULT_SANDBOX_PROFILE.resources, maxProcesses: 64 },
87
+ });
88
+ registerSandboxPolicyProvider(async () => policy);
89
+
90
+ const script = [
91
+ 'const { spawn } = await import("node:child_process");',
92
+ "let n = 0;",
93
+ "try {",
94
+ " while (n < 100000) { spawn('sleep', ['30']); n++; }",
95
+ "} catch {}",
96
+ "export default { spawned: n };",
97
+ ].join("\n");
98
+
99
+ const res = await defaultEsmScriptRunner.run({
100
+ script,
101
+ context: {},
102
+ timeoutMs: 8000,
103
+ });
104
+ expect(res.timedOut).toBe(false);
105
+ expect(res.sandbox?.enforced.resources).toBe(true);
106
+ expect(res.sandbox?.enforced.privilege).toBe(true);
107
+ expect(supervisorCanFork()).toBe(true);
108
+ });
109
+
110
+ it("still runs a benign script to success under the same fail-closed default", async () => {
111
+ registerSandboxPolicyProvider(async () => DEFAULT_SANDBOX_PROFILE);
112
+ const ok = await defaultShellScriptRunner.run({
113
+ script: "echo hi; id -u",
114
+ timeoutMs: 5000,
115
+ });
116
+ expect(ok.exitCode).toBe(0);
117
+ // The script runs as the non-root supervisor uid by inheritance.
118
+ expect(ok.stdout).toContain("hi");
119
+ expect(ok.sandbox?.downgrades ?? []).toHaveLength(0);
120
+ });
121
+ });