@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.
@@ -1 +1 @@
1
- ce6a06e07ced6e9bc14c71c7025f5bd9d50e91b9fffafcde1cf2ea54125a5654 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.163.1",
3
+ "version": "0.165.0",
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",
@@ -68,7 +68,7 @@
68
68
  "test:extras": "AGENTIC_SECURITY_CPP_DATAFLOW=1 node --test test/cpp-dataflow.test.js && node --experimental-test-module-mocks --test test/fault-injection.test.js && node --experimental-test-module-mocks --test test/detector-fault-injection.test.js && node --experimental-test-module-mocks --test test/detector-fault-injection-k8s-admission.test.js && node --experimental-test-module-mocks --test test/lineage-fault-injection.test.js && npm run test:python",
69
69
  "loop": "node ../scripts/loop-engineering/run.mjs",
70
70
  "test:loop-real-prd": "node --test ../scripts/loop-engineering/test/loop-real-prd.test.js",
71
- "test:loop": "node --test --test-concurrency=1 ../scripts/loop-engineering/test/loop-manifest.test.js ../scripts/loop-engineering/test/loop-watchdog.test.js ../scripts/loop-engineering/test/loop-background.test.js ../scripts/loop-engineering/test/loop-dashboard.test.js ../scripts/loop-engineering/test/loop-evidence.test.js ../scripts/loop-engineering/test/loop-recovery.test.js ../scripts/loop-engineering/test/loop-remote.test.js ../scripts/loop-engineering/test/loop-runbook.test.js ../scripts/loop-engineering/test/loop-assurance-profile.test.js ../scripts/loop-engineering/test/loop-assurance-runtime.test.js",
71
+ "test:loop": "node --test --test-concurrency=1 ../scripts/loop-engineering/test/loop-manifest.test.js ../scripts/loop-engineering/test/loop-watchdog.test.js ../scripts/loop-engineering/test/loop-background.test.js ../scripts/loop-engineering/test/loop-dashboard.test.js ../scripts/loop-engineering/test/loop-evidence.test.js ../scripts/loop-engineering/test/loop-recovery.test.js ../scripts/loop-engineering/test/loop-remote.test.js ../scripts/loop-engineering/test/loop-runbook.test.js ../scripts/loop-engineering/test/loop-assurance-profile.test.js ../scripts/loop-engineering/test/loop-assurance-runtime.test.js ../scripts/loop-engineering/test/loop-bounds.test.js ../scripts/loop-engineering/test/loop-spend.test.js",
72
72
  "test:python": "node ../scripts/run-python-tests.mjs",
73
73
  "test:smoke": "node --test test/smoke.test.js test/demo-app.test.js",
74
74
  "test:ollama-e2e": "AGENTIC_SECURITY_OLLAMA_E2E=1 node --test test/ollama-e2e.test.js",
@@ -106,11 +106,11 @@
106
106
  "evaluation": "node ../scripts/evaluation.mjs",
107
107
  "evaluation:synthetic": "node ../scripts/evaluation.mjs synthetic",
108
108
  "test:routing": "node --test test/routing/outcomes.test.js test/routing/economics.test.js test/routing/adapter.test.js test/routing/calibration.test.js test/routing/decide.test.js test/routing/unchanged-when-off.test.js test/routing/shadow.test.js test/routing/promotion.test.js test/routing/drift.test.js test/routing/canary.test.js test/routing/feedback.test.js test/routing/report.test.js",
109
- "test:release-closure": "node --test test/release-closure/closure-plan.test.js test/release-closure/closure-record.test.js test/release-closure/compatibility.test.js test/release-closure/rollout-stages.test.js test/release-closure/migration-readers.test.js test/release-closure/rollback.test.js test/release-closure/wrappers.test.js test/release-closure/final-closure.test.js test/release-closure/final-closure-controller.test.js test/release-closure/runner-hardening.test.js",
109
+ "test:release-closure": "node --test test/release-closure/closure-plan.test.js test/release-closure/closure-record.test.js test/release-closure/compatibility.test.js test/release-closure/rollout-stages.test.js test/release-closure/migration-readers.test.js test/release-closure/rollback.test.js test/release-closure/wrappers.test.js test/release-closure/final-closure.test.js test/release-closure/final-closure-controller.test.js test/release-closure/runner-hardening.test.js test/release-closure/remote-attestation.test.js",
110
110
  "release:closure:check": "node ../scripts/release-closure.mjs --run",
111
111
  "release:closure:static": "node ../scripts/release-closure.mjs --static",
112
112
  "test:documentation": "node --test test/documentation/drift-check.test.js test/documentation/generated-pages.test.js test/documentation/scope-and-examples.test.js test/documentation/policy-cards.test.js test/documentation/operations-and-review.test.js",
113
- "test:capabilities": "node --test test/capabilities/manifest.test.js test/capabilities/filesystem.test.js test/capabilities/exec.test.js test/capabilities/network.test.js test/capabilities/canary.test.js test/capabilities/tools.test.js test/capabilities/recovery.test.js test/capabilities/receipts.test.js test/capabilities/adversarial.test.js",
113
+ "test:capabilities": "node --test test/capabilities/manifest.test.js test/capabilities/filesystem.test.js test/capabilities/exec.test.js test/capabilities/network.test.js test/capabilities/canary.test.js test/capabilities/tools.test.js test/capabilities/recovery.test.js test/capabilities/receipts.test.js test/capabilities/adversarial.test.js test/capabilities/linux-enforcement.test.js",
114
114
  "test:verification": "node --test test/verification/verification-record.test.js test/verification/oracle-adapters.test.js test/verification/replay-manifest.test.js test/verification/patch-negative.test.js test/verification/non-taint-classes.test.js test/verification/interface-equivalence.test.js test/verification/advisory-gating.test.js test/verification/oracle-conformance.test.js",
115
115
  "test:deployment": "node --test test/deployment/boundary-graph.test.js test/deployment/config-adapters.test.js test/deployment/trace-correlation.test.js test/deployment/cross-repo.test.js test/deployment/attack-paths.test.js test/deployment/drift.test.js test/deployment/boundary-presentation.test.js test/deployment/differentiation-ablation.test.js test/deployment/boundaries-cli.test.js",
116
116
  "test:portfolio": "node --test test/portfolio/manifest.test.js test/portfolio/bundle.test.js test/portfolio/signing.test.js test/portfolio/work-units.test.js test/portfolio/resume.test.js test/portfolio/scheduler.test.js test/portfolio/progress.test.js test/portfolio/retention.test.js test/portfolio/backend.test.js",
@@ -67,15 +67,25 @@ report says so. Without the opt-in an isolation-required task is `unsupported`
67
67
  (`platform-unsupported`). A control that is not proved BLOCKS the run (typed
68
68
  `blocked`, `missing-execution-backend`, the target never executes).
69
69
 
70
- **Linux is `unverified`.** The namespace backend does not implement read
71
- confinement to declared roots, several write roots, mediated network or
72
- supervised tree termination, so every Linux run is currently blocked with the
73
- missing control named. Nothing here asserts a Linux outcome that has not been
74
- executed; the `sandbox-linux` job is where it will be exercised. Process-count
75
- caps are never asserted on any backend (per-user and system-wide on macOS; they
76
- did not refuse on the hosted Linux runner in a previous release and the cause was
77
- never established); `maxProcesses` is carried and reported `unverified`, memory
78
- caps are carried and reported `not-enforced`.
70
+ **Linux is `partially-verified`**, and only by the hosted `sandbox-linux` job. Its
71
+ active probes proved write confinement, read denial, environment scrubbing, the
72
+ no-network default, tree termination, the file-size limit, read confinement to
73
+ declared roots and multi-root writes on the namespace backend (a pivoted tmpfs
74
+ root; protected paths absent or masked; a PID namespace the kernel tears down).
75
+ NOT verified: **mediated network** is not implemented on Linux (an empty network
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. 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.
79
89
 
80
90
  ## Known limits (also in `REPORT_LIMITATIONS`)
81
91
 
@@ -107,8 +117,5 @@ attempts were stopped on this backend in this run; it does not show the sandbox
107
117
  ### What is still not claimed
108
118
 
109
119
  Tool and delegation checks are policy at the tool boundary (the tool then runs in the server process): the
110
- runner remains the only enforcement layer. Linux stays `unverified` in every report, receipt and coverage
111
- record, and no Linux outcome is asserted. No process-count cap is asserted. The corpus is not wired into the
112
- `sandbox-linux` CI job: it would need execution cases that skip loudly (never silently pass) on Linux, and that
113
- 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
114
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 },
@@ -48,15 +48,18 @@ function mk(prefix) { return fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(
48
48
  function rm(p) { try { fs.rmSync(p, { recursive: true, force: true }); } catch { /* best effort */ } }
49
49
  const token = (p) => `${p}-${crypto.randomBytes(8).toString('hex')}`;
50
50
 
51
- async function probeFsRead(backend) {
52
- if (backend !== 'userspace') return unsupported(`read confinement to declared roots is not implemented on the ${backend} backend`);
51
+ // `run` is a seam so a test can hand the probe a runner that is deliberately
52
+ // wrong (a read root left too wide, a root left writable) and see the probe say
53
+ // so: a probe that cannot fail proves nothing.
54
+ export async function probeFsRead(backend, runner = runConfinedSupervised) {
55
+ if (backend !== 'userspace' && backend !== 'namespace') return unsupported(`read confinement to declared roots is not implemented on the ${backend} backend`);
53
56
  const root = mk('agsec-cap-r-'); const ro = mk('agsec-cap-ro-'); const outside = mk('agsec-cap-out-');
54
57
  try {
55
58
  const okTok = token('READABLE'); const secTok = token('SEALED');
56
59
  fs.writeFileSync(path.join(ro, 'r.txt'), okTok);
57
60
  fs.writeFileSync(path.join(outside, 's.txt'), secTok);
58
61
  fs.symlinkSync(path.join(outside, 's.txt'), path.join(ro, 'link'));
59
- const run = (script, readRoots) => runConfinedSupervised(['/bin/sh', '-c', script], { root, readRoots, timeoutMs: 8000, graceMs: 300 });
62
+ const run = (script, readRoots) => runner(['/bin/sh', '-c', script], { root, readRoots, timeoutMs: 8000, graceMs: 300 });
60
63
  const pos = await run(`cat '${ro}/r.txt'`, [ro]);
61
64
  if (!pos.stdout.includes(okTok)) return notProved(`positive control failed: a file inside a declared root was not readable (${pos.status})`);
62
65
  const open = await run(`cat '${outside}/s.txt'`, [ro, outside]);
@@ -75,13 +78,13 @@ async function probeFsRead(backend) {
75
78
  } finally { rm(root); rm(ro); rm(outside); }
76
79
  }
77
80
 
78
- async function probeMultiWrite(backend) {
79
- if (backend !== 'userspace') return unsupported(`several declared write roots are not implemented on the ${backend} backend`);
81
+ export async function probeMultiWrite(backend, runner = runConfinedSupervised) {
82
+ if (backend !== 'userspace' && backend !== 'namespace') return unsupported(`several declared write roots are not implemented on the ${backend} backend`);
80
83
  const root = mk('agsec-cap-w-'); const w1 = mk('agsec-cap-w1-'); const w2 = mk('agsec-cap-w2-');
81
84
  const ro = mk('agsec-cap-wro-'); const outside = mk('agsec-cap-wout-');
82
85
  try {
83
86
  const script = [`echo a > '${w1}/f'`, `echo b > '${w2}/f'`, `echo c > '${ro}/f'`, `echo d > '${outside}/f'`, 'true'].join('; ');
84
- await runConfinedSupervised(['/bin/sh', '-c', script], { root, readRoots: [ro], writeRoots: [w1, w2], timeoutMs: 8000, graceMs: 300 });
87
+ await runner(['/bin/sh', '-c', script], { root, readRoots: [ro], writeRoots: [w1, w2], timeoutMs: 8000, graceMs: 300 });
85
88
  if (!fs.existsSync(path.join(w1, 'f')) || !fs.existsSync(path.join(w2, 'f'))) return notProved('positive control failed: a declared write root was not writable');
86
89
  if (fs.existsSync(path.join(ro, 'f'))) return notProved('a root declared read-only was writable');
87
90
  if (fs.existsSync(path.join(outside, 'f'))) return notProved('an undeclared directory was writable');
@@ -197,8 +200,19 @@ export function isAdvertisedBackend(backend, platform = process.platform) {
197
200
  export function platformStatements() {
198
201
  return Object.freeze({
199
202
  linux: {
200
- backend: 'namespace', status: 'unverified',
201
- note: 'advertised for enforced mode, but the control probes in this module have not run on a Linux host from this workspace. Several controls are not implemented on the namespace backend (read confinement to declared roots, several write roots, mediated network, supervised tree termination), so enforced mode is blocked there until they are implemented and the sandbox-linux job shows them proved. Process-count caps are never claimed.',
203
+ backend: 'namespace', status: 'partially-verified',
204
+ // Evidence is the hosted sandbox-linux CI job (ubuntu-latest, x86_64), whose active probes
205
+ // (attack plus positive control, with fault-injection counterparts) proved these controls.
206
+ // Anything not listed here is not claimed: a verified control is verified on that runner image
207
+ // and kernel, not on every Linux host, which is why the job runs on every push.
208
+ evidence: 'sandbox-linux CI job (hosted ubuntu-latest, x86_64); scripts/sandbox-linux-verify.mjs',
209
+ verifiedControls: Object.freeze([
210
+ 'write-confinement', 'read-denial', 'env-scrub', 'network', 'tree-termination', 'file-size-limit',
211
+ 'fs-read-confinement', 'fs-multi-root-write', 'process-cap',
212
+ ]),
213
+ unsupportedControls: Object.freeze(['network-mediation']),
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.',
202
216
  },
203
217
  darwin: {
204
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) {
@@ -281,9 +281,9 @@ privilegeDrop`), the same mechanism `limits.js` uses for an unenforceable
281
281
  limit. It is never silently skipped, and the escape test that covers the
282
282
  rebind attack fails if it is missing rather than quietly passing.
283
283
 
284
- **None of the above has been executed anywhere yet.** It is asserted by
285
- `sandbox-escape.test.js`'s kernel-namespace suite, which skips on macOS. Until
286
- a CI log shows that suite `RAN`, this section describes code, not evidence.
284
+ **Executed on a hosted Linux runner.** The `sandbox-linux` job ran the suite
285
+ (see the verification sections below); this section describes code that has
286
+ been exercised, on that runner image and kernel only.
287
287
 
288
288
  ## Timeout does not kill the process tree
289
289
 
@@ -297,18 +297,24 @@ inside the policy profile — their writes and egress remain confined — but th
297
297
  are still running and still consuming resources. A caller that needs a hard
298
298
  tree kill must implement it.
299
299
 
300
- The namespace backend is structurally better here: it runs the confined
301
- command under `--pid --fork`, so the direct child is pid 1 of a new PID
302
- namespace and killing it should take the namespace's processes with it. That
303
- is a reasoned expectation from the flags, **not** an executed result — it
304
- needs the same Linux-host verification as everything else on that backend.
300
+ The namespace backend closes this with the kernel, not a sweep: the command
301
+ runs under `--pid --fork --kill-child`, so when the `unshare` supervisor dies
302
+ the namespace's init gets SIGKILL and the kernel kills every member, including
303
+ a double-forked `setsid` descendant that no process-group or sweep logic can
304
+ find. `--kill-child` is used only where `unshare` advertises it
305
+ (`namespaceTreeKill`); without it supervised execution is refused. Proved by
306
+ the `linux-probes.js` probes on the hosted runner (kill the supervisor, kill
307
+ the init directly). Earlier text here said killing the direct child should reap
308
+ the tree; CI showed it did not (the killed process was the supervisor, not the
309
+ init) until `--kill-child` was added.
305
310
 
306
311
  ## Known limitation, deliberately accepted: reads are not confined
307
312
 
308
313
  The userspace policy allows `(allow file-read*)` globally — that backend
309
314
  confines **writes**, network egress, and resource use, but **not reads**. (The
310
- kernel-namespace backend has the same cut: per the section above its mount
311
- tree is rebound read-only, not detached, so everything on it stays readable.)
315
+ kernel-namespace backend has the same cut in its DEFAULT mode: its mount
316
+ tree is rebound read-only, not detached, so everything on it stays readable.
317
+ Its capability mode, entered with `readRoots`, does confine reads; see below.)
312
318
  A confined command can read any file on the host the OS-level
313
319
  permissions allow, including outside the sandbox root. Exfiltration of
314
320
  readable host files (writing what was read to network or to a location the
@@ -364,20 +370,37 @@ then runs supervised with protected paths read-denied.
364
370
  default protected set is the key directory, common credential directories
365
371
  under the home directory, plus caller-supplied sealed-label and evidence
366
372
  directories. A denylist is a floor, not a claim of host-wide read isolation.
367
- The namespace backend does NOT implement it and returns `status:'error'`
368
- without executing when asked, so on Linux the boundary is `blocked`.
373
+ The namespace backend implements it by masking: an empty tmpfs over a
374
+ protected directory, `/dev/null` over a protected file (in capability mode a
375
+ protected path outside every declared root is simply absent). Proved by the
376
+ protected-canary probes on the hosted runner.
369
377
  - **Process trees** (`supervise.js`): own process group, periodic descendant
370
378
  sweep, SIGTERM, grace, SIGKILL, then a poll that confirms no survivors; the
371
379
  same cleanup runs on a clean exit. Cancel is an `AbortSignal` (status
372
380
  `cancelled`). Output is capped. Known gap: a double-fork plus setsid between
373
381
  two sweeps escapes; only a PID namespace or cgroup closes it. Supervised
374
- execution exists ONLY for the userspace backend; `namespace` is refused
375
- because tree termination has not been implemented and executed there.
382
+ execution exists for the userspace backend and for the namespace backend
383
+ where `--kill-child` is available (see above).
376
384
  - **Active probes**: write confinement, read denial, env scrub, network, tree
377
385
  termination and the file-size limit each run an attack plus a positive
378
- control through the real backend. `process-cap` is reported `unverified` on
379
- every backend and must not be claimed enforced (per-uid and system-wide on
380
- macOS, and it did not refuse on the hosted Linux runner in the last release).
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.
381
404
  `capabilityReport()` carries the standing note that Linux enforcement is
382
405
  evidenced only where the `sandbox-linux` CI job ran the same probes.
383
406
  - **Status**: target output is returned labelled `untrusted` and is never parsed
@@ -396,7 +419,27 @@ then runs supervised with protected paths read-denied.
396
419
  - `writeRoots`: further writable subtrees (readable too). `cwd`: working directory, which must exist.
397
420
  - `networkProxyPort`: opens exactly one loopback port (`(remote ip "localhost:PORT")`); DNS, every other port and every other address stay denied.
398
421
 
399
- Verified by execution on macOS only (`test/capabilities/`, probes in `capabilities/probes.js`). The namespace backend implements none of these options; asked for them it ignores them, which is why the capability runner never asks it: its probes report these controls `unsupported` there and the run is blocked.
422
+ Verified by execution on macOS only (`test/capabilities/`, probes in `capabilities/probes.js`). The namespace backend implements `readRoots`, `writeRoots`, `cwd` and
423
+ `denyReadPaths` as **capability mode** (entered when `readRoots` is an array): a
424
+ tmpfs root holding only the runtime baseline (`/usr`, `/bin`, `/lib*`, a few
425
+ identity files, a fresh `/proc` and a minimal `/dev`) plus the declared roots,
426
+ read-only for read roots and read-write for write roots, protected paths absent
427
+ or masked, then `pivot_root` and a detached old root (`linux-rootfs.js` builds
428
+ the plan and the setup script; `backend-namespace.js` runs it). Capability drop
429
+ (`setpriv`, with no-new-privs) is required in this mode, not best effort.
430
+ `networkProxyPort` is REFUSED: mediated network is not implemented on Linux, so
431
+ a task that declares a destination is blocked there.
432
+
433
+ ### Linux evidence
434
+
435
+ `linux-probes.js` and the generic probes in `control-probes.js` and
436
+ `capabilities/probes.js` attack each control and pair it with a positive
437
+ control; `test/capabilities/linux-enforcement.test.js` also runs every probe
438
+ against a deliberately wrong runner (fault injection) and requires it to say so.
439
+ `scripts/sandbox-linux-verify.mjs` (the `sandbox-linux` job) fails if a required
440
+ suite skips, a required control is not proved, or a control that must stay
441
+ unasserted claims to be proved, and uploads the evidence table. Exploratory runs
442
+ on a local aarch64 VM are not evidence; only the hosted x86_64 job is.
400
443
 
401
444
  ## Extending this module
402
445