@clear-capabilities/agentic-security-scanner 0.163.0 → 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.
package/CHANGELOG.md CHANGED
@@ -9,6 +9,28 @@
9
9
  > make the history less accurate, not more.
10
10
 
11
11
 
12
+ ## 0.164.0 - Linux isolation proved on a hosted runner, six controller limits enforced, closure evidence from CI
13
+
14
+ Closing gaps from the Differentiation PRD that software can close. The gaps that remain are named at the end; none of them is hidden by this release.
15
+
16
+ **Linux isolation controls, proved on a real hosted Linux runner.** The namespace backend now implements capability mode: a tmpfs root holding only the runtime baseline and the declared roots (read roots read-only, write roots read-write, protected paths absent or masked), entered with `pivot_root`, inside a PID namespace created with `--kill-child` so that when the supervisor or init dies every descendant dies, and an empty network namespace for tasks that declare no network. The hosted `sandbox-linux` job (x86_64 `ubuntu-latest`) now fails loudly if the namespace suites skip or any required control is not proved, and proved all of these with active probes, each paired with a positive control and a fault-injection counterpart that must fail against a deliberately wrong runner: write confinement, read denial, environment scrub, network blocking, file-size limit, filesystem read confinement (direct, through a symlink, through `..`, and from a descendant), multi-root write, PID-namespace isolation, tree termination across four process shapes including a double-forked `setsid` member (which the macOS backend cannot stop), reaping after a SIGKILL of the supervisor and of the init, masking of protected files, and network in capability mode. Linux is recorded as `partially-verified` on exactly that evidence. **Not proved and stated as such:** mediated network (unsupported on Linux: a task that declares a destination is blocked, never allowed), process-count caps, the full attack corpus, and the oracle adapters' Linux status; the capabilities execution suites still skip on Linux because they encode macOS semantics.
17
+
18
+ **The process-cap mystery is explained.** The Linux resource prelude runs under `dash`, whose `ulimit` has no `-u` (`ulimit: Illegal option -u` is in the hosted log), so a process-count cap was never applied on Linux, which is why a cap of 1 did not refuse in 0.162.0. The cap stays unasserted until a replacement is proved.
19
+
20
+ **Six controller limits that were listed as unenforced are enforced**, each removed from the profile's `unenforced` list only after a boundary test passed in both directions: subprocess wall time (a process-group runner with TERM, a grace period, KILL, then a sweep, so a hung child cannot outlive its bound), network request time and retries with a capped backoff, a separately metered provider and infrastructure spend envelope (off unless a person preauthorizes it), an upper-bound reserve for unknown billing (never zero), per-class suite ceilings, and per-child leases inside each suite wrapper (a hung test file is named and killed while later files still run). Two entries remain: Linux enforcement of the controller's own limits, and the documentation-only note that budgets are caps, not authorization.
21
+
22
+ **Closure evidence from CI.** `scripts/release-closure.mjs --attest-from-ci` reads the hosted check runs for the exact commit and attests the two remote prerequisites (the Haskell toolchain job and both NixOS runtime legs) only when each job ran for that commit and succeeded, with no failed step beyond those the plan tolerates. Anything else (wrong commit, in progress, skipped, cancelled, partial matrix, `gh` unavailable) produces no attestation and a typed reason. Checked against real GitHub: attested for the 0.163.1 commit, refused for a commit that never ran CI and for a malformed sha. `ship.mjs` prints the result as information after a release; it never gates or undoes one.
23
+
24
+ **Still open, and why.** The PRD's real-code accuracy gates (per-language F1 of at least 0.80 on sealed, independently adjudicated data with at least 100 positives per language) and the routing promotion gate (at least 200 paired adjudicated tasks) cannot be met because no such population exists and independent human adjudication cannot be produced by code; they read `unmeasured` and every figure this programme has produced on its own fixtures is synthetic and labelled so. The PRD's closure verification therefore still reports not complete.
25
+
26
+ ## 0.163.1 - a regular expression's `.exec()` on request data is no longer reported as command injection
27
+
28
+ The taint engine treated any call named `exec` as a command-execution sink regardless of its receiver, so `SEMVER.exec(req.query.v)` or `/^(\d+)/.exec(req.query.w)` produced a high-severity CWE-78 finding. Regular-expression matching on request data is everywhere in JavaScript, so this was a high-volume false positive (disclosed as a known issue in 0.163.0, present since at least 0.161.0).
29
+
30
+ The `js-exec` sink now excludes a receiver the engine can confidently resolve to a `RegExp`: a regex literal, `RegExp(...)` or `new RegExp(...)`, or a variable whose every assignment in the file is one of those and which is never a parameter. Anything it cannot resolve keeps firing exactly as before, and `child_process.exec` (including destructured, aliased and `require('child_process')` forms) still fires; a name that is also bound to something else anywhere in the file is not trusted. The mutation bench gained 7 cases for this gate (3 where a regex must not fire, 4 where a real exec must still fire); the CVE-replay corpus and the per-layer recall baseline are unchanged. In this repository's own self-scan, the regular-expression calls that had been recorded as reviewed false positives disappear.
31
+
32
+ **Not covered.** The IFDS, tabulation and backward-slice analyses call the sink matcher without the engine's receiver types, so a regex held in a variable is not recognised there (a regex literal as the receiver is); a name bound by an import or destructuring that leaves no assignment in the IR, and also assigned a regex in an unrelated scope of the same file, could be mistaken for a regex.
33
+
12
34
  ## 0.163.0 - the rest of the differentiation programme: deployment graph, invariants, capability enforcement, routing, portfolio assurance, evaluation machinery, loop controller and closure
13
35
 
14
36
  This completes the **implementation** of the Agentic Security Differentiation PRD (all 70 requirements are built, each with tests named by criterion id and deliberate-breakage checks). It does **not** close the PRD: the controller's real final verification ends `blocked`, and several gates need data or hosts this repository cannot supply. Read the status section before relying on anything here. Every new execution-capable feature is off by default and gated by the assurance feature flags.
@@ -1372,15 +1372,18 @@ function mk(prefix) { return fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(
1372
1372
  function rm(p) { try { fs.rmSync(p, { recursive: true, force: true }); } catch { /* best effort */ } }
1373
1373
  const token = (p) => `${p}-${crypto.randomBytes(8).toString('hex')}`;
1374
1374
 
1375
- async function probeFsRead(backend) {
1376
- if (backend !== 'userspace') return unsupported(`read confinement to declared roots is not implemented on the ${backend} backend`);
1375
+ // `run` is a seam so a test can hand the probe a runner that is deliberately
1376
+ // wrong (a read root left too wide, a root left writable) and see the probe say
1377
+ // so: a probe that cannot fail proves nothing.
1378
+ async function probeFsRead(backend, runner = runConfinedSupervised) {
1379
+ if (backend !== 'userspace' && backend !== 'namespace') return unsupported(`read confinement to declared roots is not implemented on the ${backend} backend`);
1377
1380
  const root = mk('agsec-cap-r-'); const ro = mk('agsec-cap-ro-'); const outside = mk('agsec-cap-out-');
1378
1381
  try {
1379
1382
  const okTok = token('READABLE'); const secTok = token('SEALED');
1380
1383
  fs.writeFileSync(path.join(ro, 'r.txt'), okTok);
1381
1384
  fs.writeFileSync(path.join(outside, 's.txt'), secTok);
1382
1385
  fs.symlinkSync(path.join(outside, 's.txt'), path.join(ro, 'link'));
1383
- const run = (script, readRoots) => runConfinedSupervised(['/bin/sh', '-c', script], { root, readRoots, timeoutMs: 8000, graceMs: 300 });
1386
+ const run = (script, readRoots) => runner(['/bin/sh', '-c', script], { root, readRoots, timeoutMs: 8000, graceMs: 300 });
1384
1387
  const pos = await run(`cat '${ro}/r.txt'`, [ro]);
1385
1388
  if (!pos.stdout.includes(okTok)) return notProved(`positive control failed: a file inside a declared root was not readable (${pos.status})`);
1386
1389
  const open = await run(`cat '${outside}/s.txt'`, [ro, outside]);
@@ -1399,13 +1402,13 @@ async function probeFsRead(backend) {
1399
1402
  } finally { rm(root); rm(ro); rm(outside); }
1400
1403
  }
1401
1404
 
1402
- async function probeMultiWrite(backend) {
1403
- if (backend !== 'userspace') return unsupported(`several declared write roots are not implemented on the ${backend} backend`);
1405
+ async function probeMultiWrite(backend, runner = runConfinedSupervised) {
1406
+ if (backend !== 'userspace' && backend !== 'namespace') return unsupported(`several declared write roots are not implemented on the ${backend} backend`);
1404
1407
  const root = mk('agsec-cap-w-'); const w1 = mk('agsec-cap-w1-'); const w2 = mk('agsec-cap-w2-');
1405
1408
  const ro = mk('agsec-cap-wro-'); const outside = mk('agsec-cap-wout-');
1406
1409
  try {
1407
1410
  const script = [`echo a > '${w1}/f'`, `echo b > '${w2}/f'`, `echo c > '${ro}/f'`, `echo d > '${outside}/f'`, 'true'].join('; ');
1408
- await runConfinedSupervised(['/bin/sh', '-c', script], { root, readRoots: [ro], writeRoots: [w1, w2], timeoutMs: 8000, graceMs: 300 });
1411
+ await runner(['/bin/sh', '-c', script], { root, readRoots: [ro], writeRoots: [w1, w2], timeoutMs: 8000, graceMs: 300 });
1409
1412
  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');
1410
1413
  if (fs.existsSync(path.join(ro, 'f'))) return notProved('a root declared read-only was writable');
1411
1414
  if (fs.existsSync(path.join(outside, 'f'))) return notProved('an undeclared directory was writable');
@@ -1521,8 +1524,19 @@ function isAdvertisedBackend(backend, platform = process.platform) {
1521
1524
  function platformStatements() {
1522
1525
  return Object.freeze({
1523
1526
  linux: {
1524
- backend: 'namespace', status: 'unverified',
1525
- 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.',
1527
+ backend: 'namespace', status: 'partially-verified',
1528
+ // Evidence is the hosted sandbox-linux CI job (ubuntu-latest, x86_64), whose active probes
1529
+ // (attack plus positive control, with fault-injection counterparts) proved these controls.
1530
+ // Anything not listed here is not claimed: a verified control is verified on that runner image
1531
+ // and kernel, not on every Linux host, which is why the job runs on every push.
1532
+ evidence: 'sandbox-linux CI job (hosted ubuntu-latest, x86_64); scripts/sandbox-linux-verify.mjs',
1533
+ verifiedControls: Object.freeze([
1534
+ 'write-confinement', 'read-denial', 'env-scrub', 'network', 'tree-termination', 'file-size-limit',
1535
+ 'fs-read-confinement', 'fs-multi-root-write',
1536
+ ]),
1537
+ unsupportedControls: Object.freeze(['network-mediation']),
1538
+ unassertedControls: Object.freeze(['process-cap']),
1539
+ 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.',
1526
1540
  },
1527
1541
  darwin: {
1528
1542
  backend: 'userspace', status: 'host-proved-not-advertised',