@clear-capabilities/agentic-security-scanner 0.163.1 → 0.164.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
+ cca41fc14779bcf557d1a825a32e919b51bcd4c59d73e814d37ab2ed93ca8278 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.164.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,20 @@ 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; 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.
79
84
 
80
85
  ## Known limits (also in `REPORT_LIMITATIONS`)
81
86
 
@@ -107,8 +112,7 @@ attempts were stopped on this backend in this run; it does not show the sandbox
107
112
  ### What is still not claimed
108
113
 
109
114
  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
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
112
116
  `sandbox-linux` CI job: it would need execution cases that skip loudly (never silently pass) on Linux, and that
113
117
  has not been shown from this workspace. The enforced-mode release gate (`enforcedModeReleaseGate`) is a library
114
118
  function used by the suite; it is not yet called from `scripts/release-check.mjs`.
@@ -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',
212
+ ]),
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.',
202
216
  },
203
217
  darwin: {
204
218
  backend: 'userspace', status: 'host-proved-not-advertised',
@@ -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,26 @@ 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
386
  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).
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.
381
393
  `capabilityReport()` carries the standing note that Linux enforcement is
382
394
  evidenced only where the `sandbox-linux` CI job ran the same probes.
383
395
  - **Status**: target output is returned labelled `untrusted` and is never parsed
@@ -396,7 +408,27 @@ then runs supervised with protected paths read-denied.
396
408
  - `writeRoots`: further writable subtrees (readable too). `cwd`: working directory, which must exist.
397
409
  - `networkProxyPort`: opens exactly one loopback port (`(remote ip "localhost:PORT")`); DNS, every other port and every other address stay denied.
398
410
 
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.
411
+ Verified by execution on macOS only (`test/capabilities/`, probes in `capabilities/probes.js`). The namespace backend implements `readRoots`, `writeRoots`, `cwd` and
412
+ `denyReadPaths` as **capability mode** (entered when `readRoots` is an array): a
413
+ tmpfs root holding only the runtime baseline (`/usr`, `/bin`, `/lib*`, a few
414
+ identity files, a fresh `/proc` and a minimal `/dev`) plus the declared roots,
415
+ read-only for read roots and read-write for write roots, protected paths absent
416
+ or masked, then `pivot_root` and a detached old root (`linux-rootfs.js` builds
417
+ the plan and the setup script; `backend-namespace.js` runs it). Capability drop
418
+ (`setpriv`, with no-new-privs) is required in this mode, not best effort.
419
+ `networkProxyPort` is REFUSED: mediated network is not implemented on Linux, so
420
+ a task that declares a destination is blocked there.
421
+
422
+ ### Linux evidence
423
+
424
+ `linux-probes.js` and the generic probes in `control-probes.js` and
425
+ `capabilities/probes.js` attack each control and pair it with a positive
426
+ control; `test/capabilities/linux-enforcement.test.js` also runs every probe
427
+ against a deliberately wrong runner (fault injection) and requires it to say so.
428
+ `scripts/sandbox-linux-verify.mjs` (the `sandbox-linux` job) fails if a required
429
+ suite skips, a required control is not proved, or a control that must stay
430
+ unasserted claims to be proved, and uploads the evidence table. Exploratory runs
431
+ on a local aarch64 VM are not evidence; only the hosted x86_64 job is.
400
432
 
401
433
  ## Extending this module
402
434