@clear-capabilities/agentic-security-scanner 0.163.1 → 0.165.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.
@@ -20,7 +20,8 @@
20
20
  // same shape the userspace backend produces, which is the main reason
21
21
  // this shape was chosen over `pivot_root` (see below).
22
22
  //
23
- // 3. RESOURCE CAPS — the shared `ulimit` prelude.
23
+ // 3. RESOURCE CAPS — the shared `ulimit` prelude for the file-size and
24
+ // address-space caps; the process-count cap is applied by `prlimit` (below).
24
25
  //
25
26
  // WHY READ-ONLY REBIND RATHER THAN pivot_root. `pivot_root` into the sandbox
26
27
  // root is the stronger primitive: after detaching the old root, out-of-root
@@ -102,16 +103,39 @@
102
103
  // confinement, so `--net` is part of every probed variant when `allowNetwork`
103
104
  // is false, and `--mount` is in every variant unconditionally because the
104
105
  // write confinement is built inside it.
106
+ // TWO MODES. The default (legacy) mode above is unchanged: the whole host tree
107
+ // stays visible and is rebound read-only. CAPABILITY MODE is entered when the
108
+ // caller passes `readRoots` (an array, possibly empty): the process pivots into
109
+ // a tmpfs root that holds only the runtime baseline and the declared roots (see
110
+ // `linux-rootfs.js`), so a path outside them has no name at all. In both modes
111
+ // `denyReadPaths` masks the named host paths (an empty tmpfs over a directory,
112
+ // /dev/null over a file; absent in capability mode unless under a declared
113
+ // root), and the process runs under a PID namespace created with
114
+ // `--kill-child`: when the unshare process dies, the namespace's init gets
115
+ // SIGKILL and the kernel kills every member, which no `setsid` or double fork
116
+ // can leave. `--kill-child` is used only where `unshare` advertises it; where it
117
+ // does not, `treeKill` is false and supervised execution is refused.
118
+ //
119
+ // Mediated network (a proxy reachable from inside an empty network namespace)
120
+ // is NOT implemented. `networkProxyPort` is refused, so a task that needs a
121
+ // network destination is blocked on this backend, never quietly allowed.
122
+ //
123
+ // Nothing above is a verification claim. What has been executed on a Linux
124
+ // host is stated by `sandbox/CLAUDE.md` and evidenced only by the
125
+ // `sandbox-linux` CI job's probes (`linux-probes.js`).
105
126
  import { spawnSync } from 'node:child_process';
106
127
  import fs from 'node:fs';
107
128
  import os from 'node:os';
108
129
  import path from 'node:path';
109
130
  import {
110
- resolveNamespaceBin, resolveMountBin, resolvePrivDropBin,
131
+ resolveNamespaceBin, resolveMountBin, resolvePrivDropBin, resolvePivotBin, resolveUmountBin, resolvePrlimitBin,
111
132
  cachedNamespaceVariant, cacheNamespaceVariant,
112
133
  } from './capabilities.js';
113
- import { buildLimitPrelude, ambientRelativeMaxProcs } from './limits.js';
134
+ import { buildLimitPrelude, validateMaxProcs } from './limits.js';
114
135
  import { buildResult, errorResult, buildConfinedEnv } from './result.js';
136
+ import {
137
+ buildRootPlan, serializePlan, PIVOT_SETUP_SCRIPT, FINAL_SCRIPT, MARK_SETUP_FAILED, MARK_NO_PRIVDROP,
138
+ } from './linux-rootfs.js';
115
139
 
116
140
  // Ordered most-portable-first. Each entry is only the PRIVILEGE-acquisition
117
141
  // prefix; the namespace flags themselves are appended identically to all of
@@ -131,34 +155,35 @@ const NS_PRIVILEGE_VARIANTS = Object.freeze([
131
155
  Object.freeze([]),
132
156
  ]);
133
157
 
134
- function _nsArgs(privilegeFlags, allowNetwork) {
158
+ function _nsArgs(privilegeFlags, allowNetwork, killChild = false) {
135
159
  const a = [...privilegeFlags, '--mount', '--pid', '--ipc', '--uts', '--fork'];
160
+ if (killChild) a.push('--kill-child');
136
161
  if (!allowNetwork) a.push('--net');
137
162
  return a;
138
163
  }
139
164
 
140
- // Markers the confined shell writes to its own stderr so the parent can tell
141
- // a confinement-setup failure from ordinary program output. They are stripped
142
- // from the stderr handed back to the caller.
143
- //
144
- // A payload that PRINTS one of these strings can force `status:'error'` (or a
145
- // false `privilegeDrop` unenforced note). That is the safe direction: the
146
- // worst it achieves is making its own run look like it did not happen, which
147
- // no downstream tier reads as evidence of anything. It cannot make an
148
- // unconfined run look confined.
149
- const MARK_SETUP_FAILED = 'AGSEC_SANDBOX_SETUP_FAILED:';
150
- const MARK_NO_PRIVDROP = 'AGSEC_SANDBOX_PRIVDROP_UNAVAILABLE';
165
+ /** Whether a resolved argument list carries the PID-namespace tree kill. */
166
+ export function namespaceTreeKill(args) {
167
+ return Array.isArray(args) && args.includes('--kill-child');
168
+ }
169
+
170
+ // The markers live in linux-rootfs.js so both modes agree. A payload that
171
+ // PRINTS one of these strings can force `status:'error'` (or a false
172
+ // `privilegeDrop` unenforced note). That is the safe direction: the worst it
173
+ // achieves is making its own run look like it did not happen, which no
174
+ // downstream tier reads as evidence of anything. It cannot make an unconfined
175
+ // run look confined.
151
176
 
152
- // Runs inside the namespaces, still privileged, before the caller's command.
153
- // Builds the write confinement, then hands off to $SBX_FINAL with the
154
- // capability set dropped.
177
+ // Default mode: runs inside the namespaces, still privileged, before the
178
+ // caller's command. Builds the write confinement, then hands off to $SBX_FINAL
179
+ // with the capability set dropped.
155
180
  //
156
181
  // Order matters: the sandbox root is bound onto itself while the tree is still
157
182
  // writable, so the read-only pass and the read-write rebind of the root never
158
183
  // have to fight each other. Individual sub-mounts are best-effort (some pseudo
159
184
  // filesystems legitimately refuse a rebind); the canary check in $SBX_FINAL is
160
185
  // what actually decides whether the result is trustworthy.
161
- const SETUP_SCRIPT = `
186
+ export const LEGACY_SETUP_SCRIPT = `
162
187
  _fail() { echo "${MARK_SETUP_FAILED} $1" >&2; exit 91; }
163
188
  "$SBX_MOUNT" --make-rprivate / || _fail "mount propagation could not be made private"
164
189
  "$SBX_MOUNT" -t proc proc /proc 2>/dev/null || true
@@ -175,6 +200,18 @@ done
175
200
  # it, so this is normally a no-op. Its return code is NOT the gate — the
176
201
  # executed in-root write check in $SBX_FINAL is, and that one fails closed.
177
202
  "$SBX_MOUNT" -o remount,bind,rw "$ROOT" 2>/dev/null || true
203
+ if [ -n "$SBX_MASKS" ]; then
204
+ while IFS= read -r _m; do
205
+ [ -n "$_m" ] || continue
206
+ if [ -d "$_m" ]; then
207
+ "$SBX_MOUNT" -t tmpfs -o size=4k,mode=000 tmpfs "$_m" || _fail "a protected directory could not be masked"
208
+ elif [ -e "$_m" ]; then
209
+ "$SBX_MOUNT" --bind /dev/null "$_m" || _fail "a protected file could not be masked"
210
+ fi
211
+ done <<SBX_MASK_EOF
212
+ $SBX_MASKS
213
+ SBX_MASK_EOF
214
+ fi
178
215
  if [ -n "$SBX_PRIVDROP" ] && "$SBX_PRIVDROP" --securebits=+noroot,+noroot_locked --bounding-set=-all --inh-caps=-all /bin/sh -c 'exit 0' 2>/dev/null; then
179
216
  exec "$SBX_PRIVDROP" --securebits=+noroot,+noroot_locked --bounding-set=-all --inh-caps=-all /bin/sh -c "$SBX_FINAL" _sbx "$@"
180
217
  fi
@@ -182,36 +219,31 @@ echo "${MARK_NO_PRIVDROP}" >&2
182
219
  exec /bin/sh -c "$SBX_FINAL" _sbx "$@"
183
220
  `;
184
221
 
185
- // Runs in the FINAL privilege state, immediately before the caller's command.
186
- // Both directions are checked by execution, every run: the out-of-root canary
187
- // must be refused, and an in-root write must succeed. Either check failing
188
- // means the sandbox is not what it claims, so the command is not run.
189
- const FINAL_SCRIPT = `
190
- _fail() { echo "${MARK_SETUP_FAILED} $1" >&2; exit 91; }
191
- if ( : > "$SBX_CANARY" ) 2>/dev/null; then
192
- _fail "an out-of-root write is still possible; refusing to execute"
193
- fi
194
- if ! ( : > "$ROOT/.agsec-sbx-wcheck" ) 2>/dev/null; then
195
- _fail "the sandbox root is not writable; refusing to execute"
196
- fi
197
- rm -f "$ROOT/.agsec-sbx-wcheck"
198
- cd "$ROOT" && exec "$@"
199
- `;
200
-
201
222
  /**
202
223
  * The first privilege variant under which the requested namespaces can
203
224
  * actually be created on this host, or null when none can. Probed by running
204
225
  * a trivial command — a reasoned expectation about which flags "should" work
205
226
  * is exactly what made this backend unusable on an unprivileged runner.
227
+ *
228
+ * `--kill-child` is added only when the binary advertises it in its help text;
229
+ * it is an addition to the confinement, never a requirement for it, so a host
230
+ * without it still gets the same confinement and simply reports `treeKill`
231
+ * false (see `namespaceTreeKill`).
206
232
  */
207
233
  export function resolveNamespaceArgs(bin, allowNetwork, { probeTimeoutMs = 5000 } = {}) {
208
234
  const key = `${bin}:${allowNetwork ? 'net' : 'nonet'}`;
209
235
  const cached = cachedNamespaceVariant(key);
210
236
  if (cached !== undefined) return cached;
211
237
 
238
+ let killChild = false;
239
+ try {
240
+ const help = spawnSync(bin, ['--help'], { encoding: 'utf8', timeout: probeTimeoutMs });
241
+ killChild = /--kill-child/.test(`${help.stdout || ''}${help.stderr || ''}`);
242
+ } catch { killChild = false; }
243
+
212
244
  let chosen = null;
213
245
  for (const variant of NS_PRIVILEGE_VARIANTS) {
214
- const args = _nsArgs(variant, allowNetwork);
246
+ const args = _nsArgs(variant, allowNetwork, killChild);
215
247
  const probe = spawnSync(bin, [...args, '/bin/sh', '-c', 'exit 0'], {
216
248
  encoding: 'utf8', timeout: probeTimeoutMs, stdio: ['ignore', 'pipe', 'pipe'],
217
249
  });
@@ -237,33 +269,62 @@ function _setupFailureReason(stderr) {
237
269
  return null;
238
270
  }
239
271
 
240
- export function runNamespace(argv, {
272
+ const _within = (parent, child) => child === parent || child.startsWith(parent.endsWith('/') ? parent : parent + '/');
273
+
274
+ function _maskPaths(denyReadPaths, resolvedRoot) {
275
+ const out = [];
276
+ for (const p of denyReadPaths || []) {
277
+ if (typeof p !== 'string' || !p) continue;
278
+ let real = path.resolve(p);
279
+ try { real = fs.realpathSync(real); } catch { /* not present: nothing to mask, and nothing to read */ }
280
+ if (/[\0\n]/.test(real)) return { error: `a denied path cannot be carried into the mount setup (${JSON.stringify(p)})` };
281
+ if (_within(real, resolvedRoot)) return { error: `a denied path overlaps the sandbox root (${real})` };
282
+ if (!out.includes(real)) out.push(real);
283
+ }
284
+ return { paths: out };
285
+ }
286
+
287
+ /**
288
+ * Build the exact spawn invocation for a namespace-confined command, without
289
+ * running it. Shared by the synchronous runner and the supervised runner, so
290
+ * both are confined by the SAME setup. Returns `{ error }` in the documented
291
+ * error shape instead of throwing. On success the caller MUST call
292
+ * `inv.finish(rawStderr, {hadError})` exactly once after the process ends: it
293
+ * checks the host-side canary, extracts any setup failure, removes the
294
+ * temporary directories, and returns `{ error }` or `{ stderr, unsupported }`.
295
+ *
296
+ * @param {object} [deps] test seam: tool resolvers and the argument resolver.
297
+ * Cannot weaken the setup: it only changes WHERE the tools are found.
298
+ */
299
+ export function buildNamespaceInvocation(argv, {
241
300
  root,
242
- timeoutMs = 10000,
243
301
  allowNetwork = false,
244
302
  limits = {},
245
303
  env = {},
246
- maxBuffer = 8 * 1024 * 1024,
247
304
  denyReadPaths = [],
248
- } = {}) {
249
- // Documented shape, never a throw — see the same note in backend-userspace.
250
- // Read denial is NOT implemented on this backend (its mount tree is rebound
251
- // read-only, not detached). A caller that asks for it must get a refusal,
252
- // never a run that silently leaves the path readable.
253
- if (Array.isArray(denyReadPaths) && denyReadPaths.length) {
254
- return errorResult('namespace', 'read denial of host paths is not implemented on this backend; refusing to execute');
305
+ readRoots = null,
306
+ writeRoots = [],
307
+ networkProxyPort = null,
308
+ cwd = null,
309
+ } = {}, deps = {}) {
310
+ const d = {
311
+ nsBin: resolveNamespaceBin, mountBin: resolveMountBin, privDropBin: resolvePrivDropBin,
312
+ pivotBin: resolvePivotBin, umountBin: resolveUmountBin, prlimitBin: resolvePrlimitBin, nsArgs: resolveNamespaceArgs, ...deps,
313
+ };
314
+ if (!root) return { error: errorResult('namespace', 'runNamespace requires a sandbox root') };
315
+ if (networkProxyPort != null) {
316
+ return { error: errorResult('namespace', 'mediated network access is not implemented on this backend (an empty network namespace has no path to a proxy); refusing to execute') };
255
317
  }
256
- if (!root) return errorResult('namespace', 'runNamespace requires a sandbox root');
257
318
 
258
- const bin = resolveNamespaceBin();
259
- if (!bin) return errorResult('namespace', 'no kernel-namespace binary found on this host');
319
+ const bin = d.nsBin();
320
+ if (!bin) return { error: errorResult('namespace', 'no kernel-namespace binary found on this host') };
260
321
 
261
322
  // Write confinement is built with this utility. No utility, no confinement,
262
323
  // no run — there is deliberately no branch that proceeds without it.
263
- const mountBin = resolveMountBin();
324
+ const mountBin = d.mountBin();
264
325
  if (!mountBin) {
265
- return errorResult('namespace',
266
- 'no filesystem-attach binary found on this host, so write confinement cannot be established; refusing to execute unconfined');
326
+ return { error: errorResult('namespace',
327
+ 'no filesystem-attach binary found on this host, so write confinement cannot be established; refusing to execute unconfined') };
267
328
  }
268
329
 
269
330
  let resolvedRoot;
@@ -272,99 +333,167 @@ export function runNamespace(argv, {
272
333
  // hand to the child.
273
334
  resolvedRoot = fs.realpathSync(root);
274
335
  } catch (e) {
275
- return errorResult('namespace', `sandbox root is not usable: ${e.message}`);
336
+ return { error: errorResult('namespace', `sandbox root is not usable: ${e.message}`) };
276
337
  }
277
338
 
278
- // Same per-uid RLIMIT_NPROC trap as the userspace backend, and worse here:
279
- // the confined shell has to fork several helpers to BUILD its confinement,
280
- // so a fixed cap below the ambient count for this uid makes the setup itself
281
- // fail and the sandbox look broken. See `ambientRelativeMaxProcs`.
282
- const effectiveLimits = { ...limits, maxProcs: limits.maxProcs ?? ambientRelativeMaxProcs() };
339
+ const strict = Array.isArray(readRoots);
340
+ const masked = _maskPaths(denyReadPaths, resolvedRoot);
341
+ if (masked.error) return { error: errorResult('namespace', masked.error) };
342
+
343
+ let plan = null;
344
+ let pivotBin = null; let umountBin = null;
345
+ if (strict) {
346
+ pivotBin = d.pivotBin();
347
+ umountBin = d.umountBin();
348
+ if (!pivotBin || !umountBin) {
349
+ return { error: errorResult('namespace', 'capability mode needs the root-switching and unmount tools, and this host lacks them; refusing to execute') };
350
+ }
351
+ plan = buildRootPlan({ root: resolvedRoot, readRoots, writeRoots, denyReadPaths: masked.paths, cwd });
352
+ if (!plan.ok) return { error: errorResult('namespace', `the filesystem plan is not acceptable: ${plan.error}`) };
353
+ } else if (cwd) {
354
+ // Default mode keeps its one working directory. Silently ignoring a
355
+ // requested directory would run the command somewhere the caller did not
356
+ // choose.
357
+ let wd = null;
358
+ try { wd = fs.realpathSync(cwd); } catch { /* handled below */ }
359
+ if (wd !== resolvedRoot) return { error: errorResult('namespace', 'a working directory other than the sandbox root needs capability mode (readRoots); refusing to execute') };
360
+ }
361
+
362
+ // PROCESS-COUNT CAP. Not part of the shell prelude on this backend: that shell is
363
+ // often dash, whose `ulimit` has no `-u`, so a cap written there was never applied
364
+ // (found in the hosted log: `ulimit: Illegal option -u`). The cap is applied to the
365
+ // caller's command alone, by `prlimit` in the final stage, AFTER the confinement is
366
+ // built, because the setup itself forks and a cap below its needs would break it.
367
+ // It can bind inside the user namespace: RLIMIT_NPROC is waived only for a process
368
+ // holding CAP_SYS_RESOURCE or CAP_SYS_ADMIN in the INITIAL namespace, which a
369
+ // namespace root does not have (and the capability set is dropped anyway). There is
370
+ // no default cap (the old ambient-relative default was never applied here either);
371
+ // a caller that asks for one gets it, or an honest `unsupported` entry.
372
+ let nproc = null; let prlimitBin = null;
373
+ if (limits.maxProcs != null) {
374
+ try { nproc = validateMaxProcs(limits.maxProcs); } catch (e) {
375
+ return { error: errorResult('namespace', `invalid resource limit: ${e.message}`) };
376
+ }
377
+ prlimitBin = d.prlimitBin();
378
+ }
283
379
 
284
380
  let prelude, unsupported;
285
381
  try {
286
- ({ prelude, unsupported } = buildLimitPrelude(effectiveLimits));
382
+ ({ prelude, unsupported } = buildLimitPrelude({ ...limits, maxProcs: null }));
287
383
  } catch (e) {
288
- return errorResult('namespace', `invalid resource limit: ${e.message}`);
384
+ return { error: errorResult('namespace', `invalid resource limit: ${e.message}`) };
289
385
  }
386
+ if (nproc != null && !prlimitBin) unsupported = [...unsupported, 'maxProcs'];
290
387
 
291
388
  // Fail closed: no usable variant means the confinement cannot be
292
389
  // established, so nothing is executed. There is deliberately no path that
293
390
  // drops confinement flags and runs anyway.
294
- const nsArgs = resolveNamespaceArgs(bin, allowNetwork);
391
+ const nsArgs = d.nsArgs(bin, allowNetwork);
295
392
  if (!nsArgs) {
296
- return errorResult('namespace', 'kernel namespaces could not be created on this host (unprivileged user-namespace creation appears to be denied); refusing to execute unconfined');
393
+ return { error: errorResult('namespace', 'kernel namespaces could not be created on this host (unprivileged user-namespace creation appears to be denied); refusing to execute unconfined') };
297
394
  }
298
395
 
299
396
  // The canary lives OUTSIDE the sandbox root, in a directory this process
300
397
  // just created and can write. If the confined shell can create it, the
301
398
  // confinement is not in force and the command is not run.
302
- let canaryDir = null;
399
+ const made = [];
400
+ const dispose = () => { for (const p of made.splice(0)) { try { fs.rmSync(p, { recursive: true, force: true }); } catch { /* best effort */ } } };
401
+ let canaryDir; let newRoot = null;
303
402
  try {
304
403
  canaryDir = fs.mkdtempSync(path.join(os.tmpdir(), 'agsec-sbx-canary-'));
404
+ made.push(canaryDir);
405
+ if (strict) { newRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'agsec-sbx-newroot-')); made.push(newRoot); }
305
406
  } catch (e) {
306
- return errorResult('namespace', `could not create the confinement canary: ${e.message}`);
407
+ dispose();
408
+ return { error: errorResult('namespace', `could not create the confinement scratch directories: ${e.message}`) };
307
409
  }
308
410
  const canary = path.join(canaryDir, 'out-of-root.canary');
309
411
 
310
- let r;
311
- try {
312
- r = spawnSync(
313
- bin,
314
- [...nsArgs, '/bin/sh', '-c', prelude + SETUP_SCRIPT, '_sbx', ...argv],
315
- {
316
- encoding: 'utf8',
317
- timeout: timeoutMs,
318
- // SIGKILL, not the SIGTERM default, and this is the whole reason the
319
- // timeout did not work here. The direct child is pid 1 of a new PID
320
- // namespace (`--pid --fork`), and the kernel does not deliver
321
- // default-action signals to a namespace's pid 1 from outside it — a
322
- // process with no handler installed for SIGTERM simply does not die.
323
- // SIGKILL is the one signal that cannot be ignored or blocked, so it
324
- // is the only signal that can bound a payload here.
325
- //
326
- // Found by CI, not by reasoning: the first Linux run of the tree-kill
327
- // test recorded duration_ms 30057 against a 1200 ms budget with the
328
- // payload run to completion. The comment above this function used to
329
- // claim the opposite.
330
- killSignal: 'SIGKILL',
331
- maxBuffer,
332
- cwd: resolvedRoot,
333
- env: {
334
- ...buildConfinedEnv({ root: resolvedRoot, env }),
335
- SBX_MOUNT: mountBin,
336
- SBX_PRIVDROP: resolvePrivDropBin() || '',
337
- SBX_CANARY: canary,
338
- SBX_FINAL: FINAL_SCRIPT,
339
- },
340
- },
341
- );
342
-
343
- // Parent-side confirmation of the same fact the canary check asserts from
344
- // the inside. Cheap, and it does not depend on the confined shell being
345
- // honest about its own exit code.
346
- if (fs.existsSync(canary)) {
347
- return errorResult('namespace',
348
- 'the confined process created a file outside the sandbox root: write confinement is NOT in force on this host');
349
- }
350
- } finally {
351
- try { fs.rmSync(canaryDir, { recursive: true, force: true }); } catch { /* best effort */ }
412
+ const sbxEnv = {
413
+ SBX_MOUNT: mountBin,
414
+ SBX_PRIVDROP: d.privDropBin() || '',
415
+ SBX_CANARY: canary,
416
+ SBX_FINAL: FINAL_SCRIPT,
417
+ };
418
+ if (nproc != null && prlimitBin) { sbxEnv.SBX_NPROC = String(nproc); sbxEnv.SBX_PRLIMIT = prlimitBin; }
419
+ let script;
420
+ if (strict) {
421
+ Object.assign(sbxEnv, {
422
+ SBX_STRICT: '1', SBX_NEWROOT: newRoot, SBX_PLAN: serializePlan(plan), SBX_PIVOT: pivotBin, SBX_UMOUNT: umountBin, SBX_CWD: plan.cwd,
423
+ });
424
+ script = PIVOT_SETUP_SCRIPT;
425
+ } else {
426
+ if (masked.paths.length) sbxEnv.SBX_MASKS = masked.paths.join('\n');
427
+ script = LEGACY_SETUP_SCRIPT;
352
428
  }
353
429
 
354
- const rawStderr = r.stderr ?? '';
355
- const setupFailure = _setupFailureReason(rawStderr);
356
- if (setupFailure && !r.error) {
357
- // Confinement could not be established (or could not be proven). Nothing
358
- // ran: the shell exits before `exec`ing the caller's command.
359
- return errorResult('namespace', `confinement could not be established: ${setupFailure}`);
360
- }
430
+ return {
431
+ bin,
432
+ args: [...nsArgs, '/bin/sh', '-c', prelude + script, '_sbx', ...argv],
433
+ cwd: resolvedRoot,
434
+ env: { ...buildConfinedEnv({ root: resolvedRoot, env }), ...sbxEnv },
435
+ unsupported,
436
+ mode: strict ? 'capability' : 'default',
437
+ treeKill: namespaceTreeKill(nsArgs),
438
+ plan,
439
+ dispose,
440
+ finish(rawStderr, { hadError = false } = {}) {
441
+ try {
442
+ // Parent-side confirmation of the same fact the canary check asserts
443
+ // from the inside. Cheap, and it does not depend on the confined shell
444
+ // being honest about its own exit code.
445
+ if (fs.existsSync(canary)) {
446
+ return { error: errorResult('namespace',
447
+ 'the confined process created a file outside the sandbox root: write confinement is NOT in force on this host') };
448
+ }
449
+ } finally { dispose(); }
450
+ const raw = String(rawStderr || '');
451
+ const setupFailure = _setupFailureReason(raw);
452
+ if (setupFailure && !hadError) {
453
+ // Confinement could not be established (or could not be proven). Nothing
454
+ // ran: the shell exits before `exec`ing the caller's command.
455
+ return { error: errorResult('namespace', `confinement could not be established: ${setupFailure}`) };
456
+ }
457
+ const eff = [...unsupported];
458
+ if (raw.includes(MARK_NO_PRIVDROP)) eff.push('privilegeDrop');
459
+ return { stderr: _cleanStderr(raw), unsupported: eff };
460
+ },
461
+ };
462
+ }
463
+
464
+ export function runNamespace(argv, opts = {}) {
465
+ const { timeoutMs = 10000, maxBuffer = 8 * 1024 * 1024 } = opts;
466
+ // Documented shape, never a throw — see the same note in backend-userspace.
467
+ const inv = buildNamespaceInvocation(argv, opts);
468
+ if (inv.error) return inv.error;
361
469
 
362
- const effectiveUnsupported = [...unsupported];
363
- if (rawStderr.includes(MARK_NO_PRIVDROP)) effectiveUnsupported.push('privilegeDrop');
470
+ let r;
471
+ try {
472
+ r = spawnSync(inv.bin, inv.args, {
473
+ encoding: 'utf8',
474
+ timeout: timeoutMs,
475
+ // SIGKILL, not the SIGTERM default: the direct child is pid 1 of a new
476
+ // PID namespace, and the kernel does not deliver default-action signals
477
+ // to a namespace's pid 1 from outside it. SIGKILL cannot be ignored.
478
+ // Found by CI: the first Linux run of the tree-kill test recorded
479
+ // duration_ms 30057 against a 1200 ms budget. With `--kill-child` the
480
+ // same SIGKILL, delivered to the unshare process, also reaps the whole
481
+ // namespace (see the header).
482
+ killSignal: 'SIGKILL',
483
+ maxBuffer,
484
+ cwd: inv.cwd,
485
+ env: inv.env,
486
+ });
487
+ } catch (e) {
488
+ inv.dispose();
489
+ return errorResult('namespace', `the confined process could not be started: ${e.message}`);
490
+ }
364
491
 
492
+ const fin = inv.finish(r.stderr ?? '', { hadError: !!r.error });
493
+ if (fin.error) return fin.error;
365
494
  return buildResult({
366
495
  backend: 'namespace',
367
- spawnResult: { ...r, stderr: _cleanStderr(rawStderr) },
368
- unsupported: effectiveUnsupported,
496
+ spawnResult: { ...r, stderr: fin.stderr },
497
+ unsupported: fin.unsupported,
369
498
  });
370
499
  }
@@ -67,6 +67,25 @@ export const CONFINE_BINS_PRIVDROP = Object.freeze([
67
67
  '/usr/sbin/setpriv',
68
68
  ]);
69
69
 
70
+ // The two further tools capability mode needs: `pivot_root` to enter the new
71
+ // root and `umount` to detach the old one. Absent either, capability mode
72
+ // cannot be established and fails closed (the legacy mode does not use them).
73
+ export const CONFINE_BINS_PIVOT = Object.freeze([
74
+ '/usr/sbin/pivot_root', '/sbin/pivot_root', '/usr/bin/pivot_root', '/bin/pivot_root',
75
+ ]);
76
+ export const CONFINE_BINS_UMOUNT = Object.freeze([
77
+ '/usr/bin/umount', '/bin/umount', '/sbin/umount', '/usr/sbin/umount',
78
+ ]);
79
+
80
+ // The process-limit utility. The resource prelude runs under `/bin/sh`, which is dash on
81
+ // many Linux hosts, and dash's `ulimit` has no `-u`: a process-count cap written there is
82
+ // never applied. The namespace backend therefore applies the cap with this tool, in the
83
+ // final stage and only to the caller's command (a cap applied earlier would starve the
84
+ // confinement setup of the processes it needs). Absent => the cap is declared unsupported.
85
+ export const CONFINE_BINS_PRLIMIT = Object.freeze([
86
+ '/usr/bin/prlimit', '/bin/prlimit', '/usr/local/bin/prlimit', '/usr/sbin/prlimit', '/sbin/prlimit',
87
+ ]);
88
+
70
89
  // Back-compat single-path exports: the first (canonical) candidate.
71
90
  export const CONFINE_BIN_USERSPACE = CONFINE_BINS_USERSPACE[0];
72
91
  export const CONFINE_BIN_NAMESPACE = CONFINE_BINS_NAMESPACE[0];
@@ -96,6 +115,9 @@ export function resolveUserspaceBin() { return resolveConfineBin(CONFINE_BINS_US
96
115
  export function resolveNamespaceBin() { return resolveConfineBin(CONFINE_BINS_NAMESPACE); }
97
116
  export function resolveMountBin() { return resolveConfineBin(CONFINE_BINS_MOUNT); }
98
117
  export function resolvePrivDropBin() { return resolveConfineBin(CONFINE_BINS_PRIVDROP); }
118
+ export function resolvePivotBin() { return resolveConfineBin(CONFINE_BINS_PIVOT); }
119
+ export function resolvePrlimitBin() { return resolveConfineBin(CONFINE_BINS_PRLIMIT); }
120
+ export function resolveUmountBin() { return resolveConfineBin(CONFINE_BINS_UMOUNT); }
99
121
 
100
122
  // Bounded on purpose: a capability check must never hang a scan. The probe is
101
123
  // a single `exit 0` under confinement, so anything beyond a couple of seconds