@clear-capabilities/agentic-security-scanner 0.164.0 → 0.165.1

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.
@@ -1 +1 @@
1
- cca41fc14779bcf557d1a825a32e919b51bcd4c59d73e814d37ab2ed93ca8278 agentic-security.mjs
1
+ 5a4520f3bff6f1e77c0bb2bfaa0346b11c1447eb3a8433bc1661d2f58c03537a agentic-security.mjs
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@clear-capabilities/agentic-security-scanner",
3
- "version": "0.164.0",
3
+ "version": "0.165.1",
4
4
  "description": "Scanner engine for the agentic-security Claude Code plugin: SAST, SCA with reachability and CISA KEV, secrets, IaC, Haskell and Nix/NixOS, prompt-injection, MCP/agent-tool audit, authZ analysis, SBOM, compliance attestation, and more.",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
@@ -74,13 +74,18 @@ declared roots and multi-root writes on the namespace backend (a pivoted tmpfs
74
74
  root; protected paths absent or masked; a PID namespace the kernel tears down).
75
75
  NOT verified: **mediated network** is not implemented on Linux (an empty network
76
76
  namespace has no path to a proxy), so a task that declares a destination is
77
- `blocked` there with `network-mediation is unsupported`, never allowed; and
78
- **process-count caps** are never asserted on any backend (on Linux the cause of
79
- the earlier non-refusal is that the resource prelude runs under dash, whose
80
- `ulimit` has no `-u`; no replacement is proved). `maxProcesses` is carried and
81
- reported `unverified`, memory caps `not-enforced`. The execution suites under
82
- `test/capabilities/` still skip on Linux (their helpers select the userspace
83
- backend only and several assertions encode macOS semantics); see the gaps below.
77
+ `blocked` there with `network-mediation is unsupported`, never allowed. The
78
+ **process-count cap** IS proved on Linux (the `sandbox-linux` job's `process-cap`
79
+ probe): the resource prelude runs under dash, whose `ulimit` has no `-u`, so the
80
+ namespace backend applies the cap with `prlimit --nproc` to the task's command
81
+ after the confinement is built, and `maxProcesses` in a manifest is passed through
82
+ and reported `enforced` when the run is at the `enforced` level (on macOS it stays
83
+ `unverified`: the cap is per-user and system-wide there). Memory caps are
84
+ `not-enforced`. The capability, trust-boundary and corpus suites RUN on the Linux
85
+ backend and assert Linux semantics (an undeclared path has no name, so a refusal
86
+ is ENOENT or EROFS rather than `denied`; liveness is judged by heartbeat, not host
87
+ pid; a declared network destination blocks the task); `sandbox-linux` fails if any
88
+ of them skips.
84
89
 
85
90
  ## Known limits (also in `REPORT_LIMITATIONS`)
86
91
 
@@ -112,7 +117,5 @@ attempts were stopped on this backend in this run; it does not show the sandbox
112
117
  ### What is still not claimed
113
118
 
114
119
  Tool and delegation checks are policy at the tool boundary (the tool then runs in the server process): the
115
- runner remains the only enforcement layer. Linux is `partially-verified` in the platform statement (the controls listed there, on the hosted job only) and the attack-coverage record still says `unverified` for Linux, because the corpus is not run on Linux. No process-count cap is asserted. The corpus is not wired into the
116
- `sandbox-linux` CI job: it would need execution cases that skip loudly (never silently pass) on Linux, and that
117
- has not been shown from this workspace. The enforced-mode release gate (`enforcedModeReleaseGate`) is a library
120
+ runner remains the only enforcement layer. Linux is `partially-verified` in the platform statement (the controls listed there, on the hosted job only) and the attack-coverage record keeps `platforms.linux: 'unverified'` as a standing statement even though the corpus now runs on the hosted Linux runner inside the capabilities suite: turning that into a verified claim is a decision for the owners, not a side effect of the suite passing. The enforced-mode release gate (`enforcedModeReleaseGate`) is a library
118
121
  function used by the suite; it is not yet called from `scripts/release-check.mjs`.
@@ -34,7 +34,7 @@ export const KNOWN_LIMITS = Object.freeze([
34
34
  'HTTPS to a declared destination is an opaque tunnel: its payload is not inspected.',
35
35
  'The mediation proxy accepts connections from any local process, and forwards only to the declared destinations.',
36
36
  'Tool and delegation checks are policy at the tool boundary (in-process); only the runner enforces.',
37
- 'Process-count caps are not asserted on any backend.',
37
+ 'A process-count cap is claimed only on the Linux namespace backend, where an active probe proved it on the hosted job; it is not claimed on macOS.',
38
38
  ]);
39
39
 
40
40
  /**
@@ -44,8 +44,8 @@ const MANIFEST_REQUIRED = ['schema', 'schemaVersion', 'taskId', 'repository', 'p
44
44
 
45
45
  // Resource limits a manifest can carry. The first three come straight from the
46
46
  // assurance config (same ranges), the file-size cap is enforced by the sandbox
47
- // prelude, and the last two are CARRIED but never claimed enforced: see
48
- // `RESOURCE_ENFORCEMENT` in runner.js.
47
+ // prelude, the process-count cap is enforced on the Linux namespace backend only (prlimit, proved by an
48
+ // active probe), and the memory cap is CARRIED but never claimed enforced.
49
49
  export const RESOURCE_RANGES = Object.freeze({
50
50
  timeoutMs: { min: LIMITS.timeoutMs.min, max: LIMITS.timeoutMs.max },
51
51
  maxOutputBytes: { min: LIMITS.maxOutputBytes.min, max: LIMITS.maxOutputBytes.max },
@@ -208,11 +208,11 @@ export function platformStatements() {
208
208
  evidence: 'sandbox-linux CI job (hosted ubuntu-latest, x86_64); scripts/sandbox-linux-verify.mjs',
209
209
  verifiedControls: Object.freeze([
210
210
  'write-confinement', 'read-denial', 'env-scrub', 'network', 'tree-termination', 'file-size-limit',
211
- 'fs-read-confinement', 'fs-multi-root-write',
211
+ 'fs-read-confinement', 'fs-multi-root-write', 'process-cap',
212
212
  ]),
213
213
  unsupportedControls: Object.freeze(['network-mediation']),
214
- unassertedControls: Object.freeze(['process-cap']),
215
- note: 'advertised for enforced mode. Proved by active probes on the hosted Linux runner (not on every Linux host): write-confinement, read-denial, env-scrub, network, tree-termination, file-size-limit, fs-read-confinement, fs-multi-root-write. Mediated network is NOT implemented on the namespace backend (an empty network namespace has no path to a proxy), so a task that declares a network destination is blocked on Linux, never allowed. Process-count caps are never claimed: the cause of the earlier non-refusal was found (the shell used for the resource prelude has no process-limit option, so the cap was never applied) and no replacement has been proved. The attack corpus is not run in the sandbox-linux job, so corpus coverage stays unverified on Linux.',
214
+ unassertedControls: Object.freeze([]),
215
+ note: 'advertised for enforced mode. Proved by active probes on the hosted Linux runner (not on every Linux host): write-confinement, read-denial, env-scrub, network, tree-termination, file-size-limit, fs-read-confinement, fs-multi-root-write, process-cap (a per-user-namespace process-count cap applied with prlimit after the confinement is built; the shell ulimit it replaces does not exist in dash). Mediated network is NOT implemented on the namespace backend (an empty network namespace has no path to a proxy), so a task that declares a network destination is blocked on Linux, never allowed. Process-count caps are claimed on Linux only, and only as that probe shows them; memory caps are not enforced. The attack corpus is not run in the sandbox-linux job, so corpus coverage stays unverified on Linux.',
216
216
  },
217
217
  darwin: {
218
218
  backend: 'userspace', status: 'host-proved-not-advertised',
@@ -13,8 +13,9 @@
13
13
  //
14
14
  // A capability that only an in-process check covers (tools, delegation) says so
15
15
  // and is never `enforced`; that check explains a decision, it does not stop the
16
- // task. Process-count caps are carried and reported `unverified`, on every
17
- // backend.
16
+ // task. A process-count cap is `enforced` only on a run whose process-cap probe
17
+ // proved it (the Linux namespace backend); on the macOS backend it is carried and
18
+ // reported `unverified`.
18
19
  import { platformStatements } from './probes.js';
19
20
 
20
21
  const FS_READ = ['fs-read-confinement', 'read-denial'];
@@ -28,7 +29,7 @@ export const REPORT_LIMITATIONS = Object.freeze([
28
29
  'HTTPS is an opaque tunnel to a declared destination: its payload is not inspected. Plaintext HTTP is filtered for secrets before it is forwarded.',
29
30
  'The mediation proxy accepts connections from any local process. It only forwards to declared destinations, so the exposure is the declared set.',
30
31
  'A process that double-forks and calls setsid between two supervisor sweeps can outlive the task; only a PID namespace or cgroup closes that gap.',
31
- 'Process-count caps are per-user and system-wide on the host and did not refuse on a hosted Linux runner in a previous release; none is claimed enforced on any backend.',
32
+ 'A process-count cap is enforced only on the Linux namespace backend, where it is applied by prlimit after the confinement is built and was proved by an active probe; on macOS it is per-user and system-wide on the host, a soft brake that is carried and not claimed.',
32
33
  'Address-space (memory) caps are not enforceable on macOS and are carried, not enforced.',
33
34
  ]);
34
35
 
@@ -73,7 +74,7 @@ export function buildCapabilityReport({ bound, probeReport, level, required }) {
73
74
  timeoutMs: { requested: m.resources.timeoutMs ?? null, enforced: level === 'enforced', by: 'supervisor deadline with process-tree termination' },
74
75
  maxOutputBytes: { requested: m.resources.maxOutputBytes ?? null, enforced: level === 'enforced', by: 'supervisor output cap with process-tree termination' },
75
76
  maxFileSizeKb: { requested: m.resources.maxFileSizeKb ?? null, enforced: level === 'enforced' && fileLimitProved, state: c['file-size-limit']?.state ?? 'not-probed' },
76
- maxProcesses: { requested: m.resources.maxProcesses ?? null, enforced: false, state: 'unverified' },
77
+ maxProcesses: { requested: m.resources.maxProcesses ?? null, enforced: m.resources.maxProcesses != null && level === 'enforced' && c['process-cap']?.state === 'proved', state: c['process-cap']?.state === 'proved' ? 'proved' : 'unverified' },
77
78
  maxMemoryMiB: { requested: m.resources.maxMemoryMiB ?? null, enforced: false, state: 'not-enforced' },
78
79
  },
79
80
  limitations: [...REPORT_LIMITATIONS],
@@ -190,7 +190,12 @@ export async function runCapabilityTask(bound, request, opts = {}) {
190
190
  denyReadPaths: protectedPaths,
191
191
  env: taskEnv, networkProxyPort: proxy ? proxy.port : null,
192
192
  timeoutMs, graceMs: 1000, maxOutputBytes, signal, force,
193
- limits: m.resources.maxFileSizeKb ? { maxFileSizeKb: m.resources.maxFileSizeKb } : {},
193
+ limits: {
194
+ ...(m.resources.maxFileSizeKb ? { maxFileSizeKb: m.resources.maxFileSizeKb } : {}),
195
+ // Only the namespace backend applies it (prlimit, after the confinement is built). The macOS backend keeps its own ambient-relative
196
+ // default: a requested cap there would be per-user and system-wide, which is not what the manifest asked for.
197
+ ...(m.resources.maxProcesses && backend === 'namespace' ? { maxProcs: m.resources.maxProcesses } : {}),
198
+ },
194
199
  });
195
200
  const executed = r.supervised === true;
196
201
  if (!executed) {
@@ -383,13 +383,24 @@ then runs supervised with protected paths read-denied.
383
383
  where `--kill-child` is available (see above).
384
384
  - **Active probes**: write confinement, read denial, env scrub, network, tree
385
385
  termination and the file-size limit each run an attack plus a positive
386
- control through the real backend. `process-cap` is reported `unverified` on
387
- every backend and must not be claimed enforced. Per-uid and system-wide on
388
- macOS. On Linux the cause of the earlier non-refusal is now known: the
389
- resource prelude is run by `/bin/sh`, which is dash on the hosted runner, and
390
- dash's `ulimit` has no `-u` option (`ulimit: Illegal option -u` appears in
391
- the `sandbox-linux` log), so the cap was never applied at all. No replacement
392
- (for example `prlimit`) has been proved, so the cap stays unasserted.
386
+ control through the real backend. `process-cap` is `unverified` on the macOS
387
+ backend and must not be claimed enforced there: the cap is per-uid and
388
+ system-wide, a soft brake. On Linux the earlier non-refusal had a known cause:
389
+ the resource prelude is run by `/bin/sh`, which is dash on the hosted runner,
390
+ and dash's `ulimit` has no `-u` option (`ulimit: Illegal option -u` appears in
391
+ the `sandbox-linux` log), so the cap was never applied at all. The namespace
392
+ backend now applies it with `prlimit --nproc=N --` (util-linux), in the final
393
+ stage and to the caller's command alone (`FINAL_SCRIPT`; a cap applied earlier
394
+ would starve the confinement setup of processes). Inside the user namespace the
395
+ limit binds: the kernel waives RLIMIT_NPROC only for a process holding
396
+ CAP_SYS_RESOURCE or CAP_SYS_ADMIN in the INITIAL namespace, and a namespace root
397
+ has neither (its capability set is dropped as well). With no requested cap none
398
+ is applied (the old ambient-relative default never took effect on Linux), and a
399
+ host without `prlimit` declares `maxProcs` in `unsupported` rather than dropping
400
+ it. `probeProcessCap` (`control-probes.js`) is the proof: six concurrent
401
+ children all start under a generous cap and the kernel refuses some under a cap
402
+ of 3, in default and in capability mode; `linux-enforcement.test.js` runs it
403
+ against runners that ignore the cap and that cap everything.
393
404
  `capabilityReport()` carries the standing note that Linux enforcement is
394
405
  evidenced only where the `sandbox-linux` CI job ran the same probes.
395
406
  - **Status**: target output is returned labelled `untrusted` and is never parsed
@@ -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
@@ -127,10 +128,10 @@ import fs from 'node:fs';
127
128
  import os from 'node:os';
128
129
  import path from 'node:path';
129
130
  import {
130
- resolveNamespaceBin, resolveMountBin, resolvePrivDropBin, resolvePivotBin, resolveUmountBin,
131
+ resolveNamespaceBin, resolveMountBin, resolvePrivDropBin, resolvePivotBin, resolveUmountBin, resolvePrlimitBin,
131
132
  cachedNamespaceVariant, cacheNamespaceVariant,
132
133
  } from './capabilities.js';
133
- import { buildLimitPrelude, ambientRelativeMaxProcs } from './limits.js';
134
+ import { buildLimitPrelude, validateMaxProcs } from './limits.js';
134
135
  import { buildResult, errorResult, buildConfinedEnv } from './result.js';
135
136
  import {
136
137
  buildRootPlan, serializePlan, PIVOT_SETUP_SCRIPT, FINAL_SCRIPT, MARK_SETUP_FAILED, MARK_NO_PRIVDROP,
@@ -308,7 +309,7 @@ export function buildNamespaceInvocation(argv, {
308
309
  } = {}, deps = {}) {
309
310
  const d = {
310
311
  nsBin: resolveNamespaceBin, mountBin: resolveMountBin, privDropBin: resolvePrivDropBin,
311
- pivotBin: resolvePivotBin, umountBin: resolveUmountBin, nsArgs: resolveNamespaceArgs, ...deps,
312
+ pivotBin: resolvePivotBin, umountBin: resolveUmountBin, prlimitBin: resolvePrlimitBin, nsArgs: resolveNamespaceArgs, ...deps,
312
313
  };
313
314
  if (!root) return { error: errorResult('namespace', 'runNamespace requires a sandbox root') };
314
315
  if (networkProxyPort != null) {
@@ -358,18 +359,31 @@ export function buildNamespaceInvocation(argv, {
358
359
  if (wd !== resolvedRoot) return { error: errorResult('namespace', 'a working directory other than the sandbox root needs capability mode (readRoots); refusing to execute') };
359
360
  }
360
361
 
361
- // Same per-uid RLIMIT_NPROC trap as the userspace backend, and worse here:
362
- // the confined shell has to fork several helpers to BUILD its confinement,
363
- // so a fixed cap below the ambient count for this uid makes the setup itself
364
- // fail and the sandbox look broken. See `ambientRelativeMaxProcs`.
365
- const effectiveLimits = { ...limits, maxProcs: limits.maxProcs ?? ambientRelativeMaxProcs() };
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
+ }
366
379
 
367
380
  let prelude, unsupported;
368
381
  try {
369
- ({ prelude, unsupported } = buildLimitPrelude(effectiveLimits));
382
+ ({ prelude, unsupported } = buildLimitPrelude({ ...limits, maxProcs: null }));
370
383
  } catch (e) {
371
384
  return { error: errorResult('namespace', `invalid resource limit: ${e.message}`) };
372
385
  }
386
+ if (nproc != null && !prlimitBin) unsupported = [...unsupported, 'maxProcs'];
373
387
 
374
388
  // Fail closed: no usable variant means the confinement cannot be
375
389
  // established, so nothing is executed. There is deliberately no path that
@@ -401,6 +415,7 @@ export function buildNamespaceInvocation(argv, {
401
415
  SBX_CANARY: canary,
402
416
  SBX_FINAL: FINAL_SCRIPT,
403
417
  };
418
+ if (nproc != null && prlimitBin) { sbxEnv.SBX_NPROC = String(nproc); sbxEnv.SBX_PRLIMIT = prlimitBin; }
404
419
  let script;
405
420
  if (strict) {
406
421
  Object.assign(sbxEnv, {
@@ -77,6 +77,15 @@ export const CONFINE_BINS_UMOUNT = Object.freeze([
77
77
  '/usr/bin/umount', '/bin/umount', '/sbin/umount', '/usr/sbin/umount',
78
78
  ]);
79
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
+
80
89
  // Back-compat single-path exports: the first (canonical) candidate.
81
90
  export const CONFINE_BIN_USERSPACE = CONFINE_BINS_USERSPACE[0];
82
91
  export const CONFINE_BIN_NAMESPACE = CONFINE_BINS_NAMESPACE[0];
@@ -107,6 +116,7 @@ export function resolveNamespaceBin() { return resolveConfineBin(CONFINE_BINS_NA
107
116
  export function resolveMountBin() { return resolveConfineBin(CONFINE_BINS_MOUNT); }
108
117
  export function resolvePrivDropBin() { return resolveConfineBin(CONFINE_BINS_PRIVDROP); }
109
118
  export function resolvePivotBin() { return resolveConfineBin(CONFINE_BINS_PIVOT); }
119
+ export function resolvePrlimitBin() { return resolveConfineBin(CONFINE_BINS_PRLIMIT); }
110
120
  export function resolveUmountBin() { return resolveConfineBin(CONFINE_BINS_UMOUNT); }
111
121
 
112
122
  // Bounded on purpose: a capability check must never hang a scan. The probe is
@@ -195,9 +195,41 @@ function probeFileSize() {
195
195
 
196
196
  const UNPROBED_PROCESS_CAP = {
197
197
  state: 'unverified',
198
- reason: 'a process-count cap is per-uid and system-wide on macOS (a soft brake, not a wall) and did not refuse on the hosted Linux runner in the last release; no enforcement is claimed on any backend',
198
+ reason: 'a process-count cap is per-uid and system-wide on macOS (a soft brake, not a wall); no enforcement is claimed on that backend. On the Linux namespace backend the cap is probed (see probeProcessCap)',
199
199
  };
200
200
 
201
+ const PROC_CAP_FORKS = 6;
202
+ /** Forks `PROC_CAP_FORKS` concurrent children; each child announces itself, so a refusal shows as missing announcements. */
203
+ export const PROC_CAP_SCRIPT = `i=0; while [ $i -lt ${PROC_CAP_FORKS} ]; do i=$((i+1)); ( echo CHILD_$i; sleep 1 ) & done; wait; echo DONE`;
204
+
205
+ const countChildren = (r) => (String(r.stdout || '').match(/CHILD_\d/g) || []).length;
206
+
207
+ /**
208
+ * The process-count cap on the namespace backend. A command that forks six
209
+ * concurrent children is run twice through the real backend: under a generous cap
210
+ * every child must start (the positive control: the probe can succeed, and the cap
211
+ * mechanism itself does not break ordinary forking), and under a cap far below
212
+ * what it needs the kernel must refuse some of them. RLIMIT_NPROC is not enforced
213
+ * for a process holding CAP_SYS_RESOURCE in the initial namespace, so whether it
214
+ * binds inside the user namespace is exactly what this measures rather than assumes.
215
+ *
216
+ * `run` is a seam so a test can hand it a runner that ignores the cap (or one that
217
+ * refuses everything) and see the probe say so.
218
+ */
219
+ export function probeProcessCap({ run = runConfined, mode = {} } = {}) {
220
+ const root = mk('agsec-probe-p-');
221
+ try {
222
+ const cmd = ['/bin/sh', '-c', PROC_CAP_SCRIPT];
223
+ const generous = run(cmd, { root, ...mode, timeoutMs: 10000, limits: { maxProcs: 512 } });
224
+ const gOk = countChildren(generous);
225
+ if (gOk !== PROC_CAP_FORKS) return notProved(`positive control failed: only ${gOk} of ${PROC_CAP_FORKS} children started under a generous cap (${generous.status})`);
226
+ const tight = run(cmd, { root, ...mode, timeoutMs: 10000, limits: { maxProcs: 3 } });
227
+ const tOk = countChildren(tight);
228
+ if (tOk >= PROC_CAP_FORKS) return notProved(`the process-count cap did not refuse: all ${tOk} children started under a cap of 3`);
229
+ return proved(`${gOk} of ${PROC_CAP_FORKS} children started under a cap of 512; only ${tOk} under a cap of 3`);
230
+ } finally { rm(root); }
231
+ }
232
+
201
233
  const _cache = new Map();
202
234
  export function resetProbeCache() { _cache.clear(); }
203
235
 
@@ -229,7 +261,9 @@ export async function probeControls({ force, probes = {} } = {}) {
229
261
  await run('network', probeNetwork);
230
262
  await run('tree-termination', () => probeTree(backend));
231
263
  await run('file-size-limit', probeFileSize);
232
- controls['process-cap'] = probes['process-cap'] ? await probes['process-cap']() : UNPROBED_PROCESS_CAP;
264
+ if (probes['process-cap']) controls['process-cap'] = await probes['process-cap']();
265
+ else if (backend === 'namespace') await run('process-cap', () => probeProcessCap());
266
+ else controls['process-cap'] = UNPROBED_PROCESS_CAP;
233
267
  }
234
268
  const report = { platform: process.platform, backend, controls };
235
269
  if (!Object.keys(probes).length) _cache.set(backend, report);
@@ -259,7 +293,7 @@ export function capabilityReport(report) {
259
293
  notes: [
260
294
  'A state of proved was established by an active probe on THIS host in THIS process; it is not inherited from another platform.',
261
295
  'Linux enforcement cannot be exercised on a macOS host. It is evidenced only where the sandbox-linux CI job ran this same probe set.',
262
- 'Process-count caps are not claimed enforced on any backend.',
296
+ 'A process-count cap is claimed only where its probe reports proved (the Linux namespace backend, on the hosted job); it is never claimed on the macOS backend.',
263
297
  ],
264
298
  };
265
299
  }
@@ -54,6 +54,8 @@ export function buildLimitPrelude({
54
54
  * e.g. `'0 2>/dev/null; true'` swallows the failure. Coerce and reject
55
55
  * anything that is not a finite, non-negative number.
56
56
  */
57
+ export function validateMaxProcs(v) { return _num('maxProcs', v); }
58
+
57
59
  function _num(name, v) {
58
60
  const n = Number(v);
59
61
  if (!Number.isFinite(n) || n < 0) {
@@ -27,7 +27,7 @@ import os from 'node:os';
27
27
  import path from 'node:path';
28
28
  import { runConfined } from './index.js';
29
29
  import { buildNamespaceInvocation } from './backend-namespace.js';
30
- import { heartbeatShell } from './control-probes.js';
30
+ import { heartbeatShell, probeProcessCap } from './control-probes.js';
31
31
 
32
32
  const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
33
33
  const NODE = process.execPath;
@@ -174,5 +174,6 @@ export async function runLinuxProbes() {
174
174
  await step('protected-canary-masked:default', () => probeProtectedCanaryMasked({ run: (a, o) => runConfined(a, { ...o, readRoots: undefined }) }));
175
175
  await step('protected-canary-masked:capability', () => probeProtectedCanaryMasked());
176
176
  await step('capability-network', () => probeCapabilityNetwork());
177
+ await step('process-cap:capability', () => probeProcessCap({ mode: { readRoots: [] } }));
177
178
  return out;
178
179
  }
@@ -251,6 +251,13 @@ if ! ( : > "$ROOT/.agsec-sbx-wcheck" ) 2>/dev/null; then
251
251
  fi
252
252
  rm -f "$ROOT/.agsec-sbx-wcheck"
253
253
  _cwd="\${SBX_CWD:-$ROOT}"
254
- unset SBX_PLAN SBX_FINAL SBX_MOUNT SBX_UMOUNT SBX_PIVOT SBX_PRIVDROP SBX_NEWROOT SBX_MASKS SBX_CANARY SBX_STRICT SBX_CWD
254
+ _nproc="$SBX_NPROC"; _prlimit="$SBX_PRLIMIT"
255
+ unset SBX_PLAN SBX_FINAL SBX_MOUNT SBX_UMOUNT SBX_PIVOT SBX_PRIVDROP SBX_NEWROOT SBX_MASKS SBX_CANARY SBX_STRICT SBX_CWD SBX_NPROC SBX_PRLIMIT
256
+ # The process-count cap is applied last, to the caller's command only: the checks
257
+ # above fork, and so does the setup before them. A prlimit that cannot run stops
258
+ # the command (the exec fails), never lets it run uncapped.
259
+ if [ -n "$_nproc" ]; then
260
+ cd "$_cwd" && exec "$_prlimit" --nproc="$_nproc" -- "$@"
261
+ fi
255
262
  cd "$_cwd" && exec "$@"
256
263
  `;
@@ -87,7 +87,10 @@ export function superviseSpawn(bin, args, {
87
87
  let timer = null;
88
88
 
89
89
  const sweep = () => { if (pgid) for (const p of snapshotTree(pgid)) tracked.add(p); };
90
- const sweeper = setInterval(sweep, sweepMs);
90
+ // sweepMs <= 0 turns the periodic sweep off. Each sweep is a SYNCHRONOUS `ps`, which blocks the controller's event loop for as long as
91
+ // it runs; on the namespace backend the kernel ends every member with the supervisor (--kill-child), so the periodic sweep adds nothing
92
+ // there and only costs responsiveness. The cleanup below still takes its own snapshots.
93
+ const sweeper = sweepMs > 0 ? setInterval(sweep, sweepMs) : null;
91
94
 
92
95
  const survivors = () => {
93
96
  sweep();
@@ -126,7 +129,8 @@ export function superviseSpawn(bin, args, {
126
129
  function finish() {
127
130
  if (finishing) return finishing;
128
131
  finishing = (async () => {
129
- clearInterval(sweeper); clearTimeout(timer);
132
+ if (sweeper) clearInterval(sweeper);
133
+ clearTimeout(timer);
130
134
  const termination = await terminate();
131
135
  try { child.stdout?.destroy(); child.stderr?.destroy(); } catch { /* ignore */ }
132
136
  resolve({ spawnError, exitCode, exitSignal, stdout, stderr, timedOut, cancelled, outputCapped, termination });
@@ -180,7 +184,7 @@ export async function runConfinedSupervised(argv, opts = {}) {
180
184
  if (backend !== 'userspace' && backend !== 'namespace') {
181
185
  return errorResult(backend, `process-tree termination is not implemented or verified on the ${backend} backend; refusing to execute`);
182
186
  }
183
- const inv = backend === 'userspace' ? buildUserspaceInvocation(argv, opts) : buildNamespaceInvocation(argv, opts);
187
+ const inv = backend === 'userspace' ? buildUserspaceInvocation(argv, opts) : buildNamespaceInvocation(argv, opts, opts.deps);
184
188
  if (inv.error) return inv.error;
185
189
  if (backend === 'namespace' && !inv.treeKill) {
186
190
  inv.dispose();
@@ -188,7 +192,7 @@ export async function runConfinedSupervised(argv, opts = {}) {
188
192
  }
189
193
  const r = await superviseSpawn(inv.bin, inv.args, {
190
194
  cwd: inv.cwd, env: inv.env, timeoutMs: opts.timeoutMs ?? 10000, graceMs: opts.graceMs ?? 1000,
191
- signal: opts.signal, maxOutputBytes: opts.maxOutputBytes,
195
+ signal: opts.signal, maxOutputBytes: opts.maxOutputBytes, ...(backend === 'namespace' ? { sweepMs: 0 } : {}),
192
196
  });
193
197
  let rawStderr = r.stderr;
194
198
  let unsupported = inv.unsupported;