@clear-capabilities/agentic-security-scanner 0.160.0 → 0.162.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.
Files changed (76) hide show
  1. package/CHANGELOG.md +38 -0
  2. package/bin/agentic-security.js +11 -2
  3. package/dist/1919.index.js +1 -1
  4. package/dist/2432.index.js +10 -1
  5. package/dist/4113.index.js +60 -9
  6. package/dist/5613.index.js +500 -0
  7. package/dist/6239.index.js +469 -0
  8. package/dist/7033.index.js +4 -1
  9. package/dist/7838.index.js +39 -3
  10. package/dist/8714.index.js +1 -1
  11. package/dist/8798.index.js +2 -2
  12. package/dist/9207.index.js +5 -0
  13. package/dist/9560.index.js +205 -62
  14. package/dist/{1905.index.js → 9999.index.js} +265 -8
  15. package/dist/agentic-security.mjs +12 -12
  16. package/dist/agentic-security.mjs.sha256 +1 -1
  17. package/package.json +14 -9
  18. package/src/dataflow/engine.js +20 -4
  19. package/src/discovery/CLAUDE.md +9 -0
  20. package/src/discovery/index.js +28 -0
  21. package/src/discovery/memory.js +7 -5
  22. package/src/engine.js +29 -8
  23. package/src/fix/apply-fix-service.js +33 -3
  24. package/src/language/CLAUDE.md +6 -1
  25. package/src/language/aibom.js +4 -1
  26. package/src/language/fix-lifecycle.js +9 -0
  27. package/src/language/haskell-ir.js +47 -7
  28. package/src/language/haskell-sca.js +87 -7
  29. package/src/language/haskell-supply.js +175 -6
  30. package/src/language/haskell-web.js +200 -18
  31. package/src/language/nix-fix.js +1 -1
  32. package/src/language/resolved-pass.js +4 -1
  33. package/src/language/witness.js +1 -1
  34. package/src/mcp/CLAUDE.md +43 -4
  35. package/src/mcp/tools.js +185 -43
  36. package/src/posture/CLAUDE.md +174 -0
  37. package/src/posture/assurance/baseline-inventory.js +218 -0
  38. package/src/posture/assurance/baseline.js +322 -0
  39. package/src/posture/assurance/bounded-io.js +145 -0
  40. package/src/posture/assurance/config.js +299 -0
  41. package/src/posture/assurance/contracts.js +315 -0
  42. package/src/posture/assurance/identity.js +62 -0
  43. package/src/posture/assurance/migrations.js +259 -0
  44. package/src/posture/assurance/schema-kit.js +111 -0
  45. package/src/posture/assurance/verification-record.js +258 -0
  46. package/src/posture/attestation.js +5 -0
  47. package/src/posture/autopilot.js +28 -1
  48. package/src/posture/blast-radius.js +15 -1
  49. package/src/posture/evidence-bundle.js +93 -7
  50. package/src/posture/execution-proof.js +11 -1
  51. package/src/posture/fix-verify-loop.js +20 -6
  52. package/src/posture/fix-verify.js +37 -2
  53. package/src/posture/oracles/adapters.js +360 -0
  54. package/src/posture/oracles/conformance.js +275 -0
  55. package/src/posture/oracles/oracle.js +433 -0
  56. package/src/posture/oracles/registry.js +76 -0
  57. package/src/posture/oracles/scenario-classes.js +223 -0
  58. package/src/posture/remediation-ledger.js +10 -1
  59. package/src/posture/replay/replay.js +249 -0
  60. package/src/posture/verification/advisory-state.js +71 -0
  61. package/src/posture/verification/emit.js +257 -0
  62. package/src/posture/verification/hypothesis-promotion.js +211 -0
  63. package/src/posture/verification/patch-negative.js +214 -0
  64. package/src/posture/verification/patch-promotion.js +105 -0
  65. package/src/posture/verification/projection.js +183 -0
  66. package/src/posture/verification/repair-records.js +124 -0
  67. package/src/posture/verifier.js +16 -0
  68. package/src/report/index.js +49 -0
  69. package/src/sandbox/CLAUDE.md +41 -0
  70. package/src/sandbox/backend-namespace.js +7 -0
  71. package/src/sandbox/backend-userspace.js +68 -29
  72. package/src/sandbox/control-probes.js +215 -0
  73. package/src/sandbox/supervise.js +201 -0
  74. package/src/sandbox/trust-boundary.js +113 -0
  75. package/src/sandbox/trust-domains.js +171 -0
  76. package/src/sast/pipeline.js +51 -4
@@ -33,6 +33,7 @@ import * as os from 'node:os';
33
33
  import { runConfined, sandboxAvailable } from '../sandbox/index.js';
34
34
  import { isExplicitlyNoPoc } from './poc-cwe-map.js';
35
35
  import { isSafeStateDir, stateDir, statePath, stateWritesEnabled } from './state-dir.js';
36
+ import { emitVerification } from './verification/emit.js';
36
37
 
37
38
  // ─── PoC static validation ──────────────────────────────────────────────────
38
39
  //
@@ -218,6 +219,21 @@ export function annotateVerifierVerdicts(findings, opts = {}) {
218
219
  }
219
220
  }
220
221
 
222
+ // ─── Verification records (X-201) ───────────────────────────────────────────
223
+ //
224
+ // The version-1 record for each annotated finding. Returned, never written onto the finding: `verifier_verdict` and
225
+ // its siblings stay exactly as they were, and a finding that later lands in last-scan.json is not changed by this.
226
+ // None of the verdicts above ran under the trusted boundary, so none maps to `confirmed` or `refuted`.
227
+ export function verifierVerificationRecords(findings, { commit = null } = {}) {
228
+ const out = [];
229
+ for (const f of findings || []) {
230
+ if (!f || typeof f !== 'object' || !f.verifier_verdict) continue;
231
+ const e = emitVerification('verifier', f, { finding: f, commit });
232
+ if (e.ok) out.push(e.record);
233
+ }
234
+ return out;
235
+ }
236
+
221
237
  // ─── Summary helpers ────────────────────────────────────────────────────────
222
238
 
223
239
  export function verifierCoverageSummary(findings) {
@@ -12,6 +12,10 @@ import { applyLegacyCompat, legacyFieldDeprecationNotice } from '../pipeline/leg
12
12
  // (sca/sigstore-verify.js's SLSA/Sigstore build attestation). Three unrelated
13
13
  // things, three distinct keys — do not collapse them.
14
14
  import { redactFindingProvenance, sanitizeForTerminal } from '../posture/provenance/schema.js';
15
+ // X-206: the one projection of the verification record. This report, the MCP tools and the autopilot response all call it.
16
+ import { verificationFields, verificationCoverage } from '../posture/verification/projection.js';
17
+ // X-207: a hunt hypothesis is advisory and is never a finding in any gating output; promotion is the only way across.
18
+ import { isAdvisoryHypothesis } from '../posture/verification/advisory-state.js';
15
19
  // Re-exported: FR-PROV-026. Lives in provenance/schema.js (shared with
16
20
  // posture/auditor-walkthrough.js, a second CLI renderer of the same
17
21
  // untrusted fields — see that module's header for why it lives there
@@ -222,7 +226,11 @@ export function normalizeFindings(scan){
222
226
  });
223
227
  return true;
224
228
  };
229
+ const advisoryExcluded = [];
225
230
  for (const f of (scan.findings||[])) {
231
+ // X-207.AC01: an unpromoted hunt hypothesis cannot gate. It is excluded here, the one place every output format and every
232
+ // exit code derives from, and the exclusion is recorded (never silent) as `advisoryExcluded`.
233
+ if (isAdvisoryHypothesis(f)) { advisoryExcluded.push({ id: f.id ?? null, stableId: f.stableId ?? null, reason: 'advisory hunt hypothesis: not a finding until promoted by a verifier receipt and policy' }); continue; }
226
234
  if (suppress(f.vuln || f.type, f.file, f.line || f.source?.line || 0, f.snippet)) continue;
227
235
  out.push({
228
236
  id: f.id || fingerprint(f),
@@ -328,6 +336,11 @@ export function normalizeFindings(scan){
328
336
  // a finding with no proof backing simply omits these fields.
329
337
  ...(f.proofTier !== undefined ? { proofTier: f.proofTier } : {}),
330
338
  ...(f.proofEvidence !== undefined ? { proofEvidence: f.proofEvidence } : {}),
339
+ // X-206: the version-1 verification record and its shared projection, ADDITIVE and present only when the finding carries
340
+ // a record (a plain scan output is byte-identical). `verificationReplay` is the typed prerequisite list of a replay attempt.
341
+ ...(f.verificationRecord && typeof f.verificationRecord === 'object'
342
+ ? { verificationRecord: f.verificationRecord, ...(f.verificationReplay ? { verificationReplay: f.verificationReplay } : {}), ...verificationFields(f.verificationRecord, { replay: f.verificationReplay }) }
343
+ : {}),
331
344
  // Phase-1 next-gen P1.3 (FR-UX-1, FR-UX-2): calibrated probability +
332
345
  // 95% Wilson CI + sample size. Null when N < MIN_SAMPLES_FOR_CALIBRATION
333
346
  // for this family; `calibration_reason` explains why.
@@ -587,6 +600,7 @@ export function normalizeFindings(scan){
587
600
  // all derive from this one function) — one place, covers every output
588
601
  // format, matches this function's own role as "the canonical shape".
589
602
  for (const f of out) applyLegacyCompat(f);
603
+ if (advisoryExcluded.length) scan.advisoryExcluded = advisoryExcluded; else delete scan.advisoryExcluded;
590
604
 
591
605
  // Sort by severity tier, then within a tier by EPSS percentile (desc) so that
592
606
  // CVEs with active in-the-wild abuse float above theoretical CVEs.
@@ -734,6 +748,11 @@ export function toJSON(scan, meta={}, opts={}){
734
748
  // hand-built `scan` object in a test), same convention as the S7 fields
735
749
  // above; a real scan always computes it.
736
750
  scanHealth: scan.scanHealth || null,
751
+ // X-207: advisory hypotheses a caller put in the finding list; excluded from `findings`, listed here so it is never silent.
752
+ ...(Array.isArray(scan.advisoryExcluded) && scan.advisoryExcluded.length ? { advisoryExcluded: scan.advisoryExcluded } : {}),
753
+ // X-206: trusted-negative accounting over the findings that carry a verification record. Absent when none does, so a plain
754
+ // scan's output is unchanged.
755
+ ...(findings.some(f => f.verificationRecord) ? { verificationCoverage: verificationCoverage(findings.map(f => f.verificationRecord).filter(Boolean)) } : {}),
737
756
  };
738
757
  if (opts.includeSuppressed) out.suppressed = scan.suppressions||[];
739
758
  return out;
@@ -903,6 +922,16 @@ function _mdFenceLen(text) {
903
922
  return Math.max(3, ...runs.map(r => r.length + 1));
904
923
  }
905
924
 
925
+ /**
926
+ * X-206: the human-readable verification block for a normalized finding, from the SAME projection the JSON and MCP surfaces
927
+ * carry (`finding.verification.text`). Null when the finding has no verification record. Terminal control characters are stripped.
928
+ */
929
+ export function verificationBlock(f) {
930
+ const text = f?.verificationView?.text;
931
+ // sanitizeForTerminal trims, so the line's own indentation is restored after it strips control characters.
932
+ return Array.isArray(text) && text.length ? text.map((l) => String(l).match(/^ */)[0] + sanitizeForTerminal(String(l))) : null;
933
+ }
934
+
906
935
  export function toMarkdown(scan, meta={}){
907
936
  const findings = normalizeFindings(scan);
908
937
  const lines = ['# Agentic Security — Scan Report', ''];
@@ -935,6 +964,23 @@ export function toMarkdown(scan, meta={}){
935
964
  lines.push(`| \`${f.file}:${f.line}\` | ${f.vuln} | ${f.cwe||'—'} | ${epss} | ${fix.replace(/\|/g,'\\|').slice(0,140)} |`);
936
965
  }
937
966
  }
967
+ // X-206: verification blocks come from the shared projection, never a second renderer.
968
+ const withVerification = bySev[sev].filter(f => verificationBlock(f));
969
+ if (withVerification.length) {
970
+ lines.push('');
971
+ lines.push('<details><summary>Verification</summary>');
972
+ lines.push('');
973
+ for (const f of withVerification) {
974
+ const block = verificationBlock(f).join('\n');
975
+ lines.push(`**\`${f.file}:${f.line}\`**: ${f.vuln}`);
976
+ const fence = '`'.repeat(_mdFenceLen(block));
977
+ lines.push(fence);
978
+ lines.push(block);
979
+ lines.push(fence);
980
+ lines.push('');
981
+ }
982
+ lines.push('</details>');
983
+ }
938
984
  // FR-PROV-018: one provenance block per finding that has one, reusing
939
985
  // explainProvenance's content — never a second, divergent renderer.
940
986
  const withProvenance = bySev[sev].filter(f => f.findingProvenance);
@@ -1432,6 +1478,8 @@ export function toCLI(scan, { verbose=false, color=true, provenance=false }={}){
1432
1478
  if (ex.how) lines.push(` ${c('how:', DIM)} ${ex.how}`);
1433
1479
  if (ex.fix) lines.push(` ${c('fix:', DIM)} ${ex.fix}`);
1434
1480
  if (ex.fixCode) for (const ln of ex.fixCode.split('\n').slice(0, 6)) lines.push(` ${c(ln, DIM)}`);
1481
+ const vblock = verificationBlock(f);
1482
+ if (vblock) for (const ln of vblock) lines.push(` ${c(ln, DIM)}`);
1435
1483
  if (provenance) {
1436
1484
  // `f` here is already normalized, so its findingProvenance has been
1437
1485
  // through redactFindingProvenance — the email is gone before it can
@@ -1443,6 +1491,7 @@ export function toCLI(scan, { verbose=false, color=true, provenance=false }={}){
1443
1491
  lines.push('');
1444
1492
  const counts = { critical: 0, high: 0, medium: 0, low: 0, info: 0 };
1445
1493
  for (const f of findings) counts[f.severity] = (counts[f.severity]||0) + 1;
1494
+ if (findings.some(f => f.verificationRecord)) lines.push(verificationCoverage(findings.map(f => f.verificationRecord).filter(Boolean)).lines[0]);
1446
1495
  lines.push(`${c('Critical:', SEV_COLOR.critical)} ${counts.critical} ${c('High:', SEV_COLOR.high)} ${counts.high} ${c('Medium:', SEV_COLOR.medium)} ${counts.medium} ${c('Low:', SEV_COLOR.low)} ${counts.low} ${c('Info:', SEV_COLOR.info)} ${counts.info}`);
1447
1496
  return lines.join('\n');
1448
1497
  }
@@ -347,6 +347,47 @@ its payload ran — not an escape (the prelude runs inside the confinement) but
347
347
  a way for a config-derived value to silently *disable* the limits it was
348
348
  supposed to set.
349
349
 
350
+ ## Trust boundary (CORE-003)
351
+
352
+ Four trust domains (target, worker, verifier/custodian, signer) are defined in
353
+ `trust-domains.js` as a default-deny policy plus pure helpers (protected host
354
+ paths, secret-env detection, verification-status formation). The ENFORCEMENT is
355
+ `trust-boundary.js` `runInBoundary()`, the one function verifier-side code uses
356
+ to run untrusted code. It: refuses secret-looking env for the target; refuses a
357
+ workspace overlapping a protected path; probes the backend's controls
358
+ (`control-probes.js`) and returns `status:'blocked'` (target never executed,
359
+ `verificationStatus:'not-run'`) when any required control is not `proved`;
360
+ then runs supervised with protected paths read-denied.
361
+
362
+ - **Read denial** is the new control `denyReadPaths` (userspace backend: SBPL
363
+ deny rules after the blanket read allow, paths passed as parameters). The
364
+ default protected set is the key directory, common credential directories
365
+ under the home directory, plus caller-supplied sealed-label and evidence
366
+ 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`.
369
+ - **Process trees** (`supervise.js`): own process group, periodic descendant
370
+ sweep, SIGTERM, grace, SIGKILL, then a poll that confirms no survivors; the
371
+ same cleanup runs on a clean exit. Cancel is an `AbortSignal` (status
372
+ `cancelled`). Output is capped. Known gap: a double-fork plus setsid between
373
+ 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.
376
+ - **Active probes**: write confinement, read denial, env scrub, network, tree
377
+ 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).
381
+ `capabilityReport()` carries the standing note that Linux enforcement is
382
+ evidenced only where the `sandbox-linux` CI job ran the same probes.
383
+ - **Status**: target output is returned labelled `untrusted` and is never parsed
384
+ for a status. `classifyRun` can yield at most `inconclusive`; `confirmed`
385
+ and `refuted` come only from `settleVerification` given evidence with
386
+ `observedBy: 'verifier'`. Oracle adapters (X-202) must consume this.
387
+ - **Consumer**: the oracle adapters (`posture/oracles/oracle.js`, X-202) run every target through `runInBoundary` and nowhere else, and replay (`posture/replay/replay.js`, X-203) goes through them. A `blocked` boundary is reported by the oracle runner as `unsupported` (an unmet isolation prerequisite), never as a pass or a failure of the hypothesis.
388
+ - Tests: `test/trust-boundary.test.js` (attack fixture, tree kill with `ps`
389
+ orphan checks, blocked-never-pass), in `test:lifecycle`.
390
+
350
391
  ## Extending this module
351
392
 
352
393
  - Both real backends (`backend-userspace.js`, `backend-namespace.js`) must
@@ -244,8 +244,15 @@ export function runNamespace(argv, {
244
244
  limits = {},
245
245
  env = {},
246
246
  maxBuffer = 8 * 1024 * 1024,
247
+ denyReadPaths = [],
247
248
  } = {}) {
248
249
  // Documented shape, never a throw — see the same note in backend-userspace.
250
+ // Read denial is NOT implemented on this backend (its mount tree is rebound
251
+ // read-only, not detached). A caller that asks for it must get a refusal,
252
+ // never a run that silently leaves the path readable.
253
+ if (Array.isArray(denyReadPaths) && denyReadPaths.length) {
254
+ return errorResult('namespace', 'read denial of host paths is not implemented on this backend; refusing to execute');
255
+ }
249
256
  if (!root) return errorResult('namespace', 'runNamespace requires a sandbox root');
250
257
 
251
258
  const bin = resolveNamespaceBin();
@@ -13,75 +13,114 @@
13
13
  // running. Callers that need a hard tree kill must supply it themselves.
14
14
  import { spawnSync } from 'node:child_process';
15
15
  import fs from 'node:fs';
16
+ import path from 'node:path';
16
17
  import { resolveUserspaceBin } from './capabilities.js';
17
18
  import { buildLimitPrelude, ambientRelativeMaxProcs } from './limits.js';
18
19
  import { buildResult, errorResult, buildConfinedEnv } from './result.js';
19
20
 
20
- function _profile({ allowNetwork }) {
21
+ function _profile({ allowNetwork, denyRead = [] }) {
21
22
  return [
22
23
  '(version 1)',
23
24
  '(deny default)',
24
25
  '(allow process-exec process-fork)',
25
26
  '(allow sysctl-read)',
26
27
  '(allow file-read*)',
28
+ // Read denial (CORE-003). SBPL is last-match-wins, so these deny rules MUST
29
+ // come after the blanket read allow. Each path is a parameter, never
30
+ // spliced into the profile text, so a path cannot inject policy.
31
+ ...denyRead.map((_, i) => `(deny file-read* (subpath (param "DENY${i}")))`),
27
32
  '(allow file-write* (subpath (param "ROOT")))',
28
33
  allowNetwork ? '(allow network*)' : '',
29
34
  ].filter(Boolean).join('\n');
30
35
  }
31
36
 
32
- export function runUserspace(argv, {
37
+ /**
38
+ * Build the exact spawn invocation for a userspace-confined command, without
39
+ * running it. Shared by the synchronous runner below and the supervised
40
+ * (process-tree-killing) runner, so both are confined by the SAME profile.
41
+ * Returns `{ error }` in the documented error shape instead of throwing.
42
+ */
43
+ export function buildUserspaceInvocation(argv, {
33
44
  root,
34
- timeoutMs = 10000,
35
45
  allowNetwork = false,
36
46
  limits = {},
37
47
  env = {},
38
- maxBuffer = 8 * 1024 * 1024,
48
+ denyReadPaths = [],
39
49
  } = {}) {
40
- // Documented shape, never a throw: a caller that wraps this in try/catch and
41
- // "falls back" is a classic route to unconfined execution.
42
- if (!root) return errorResult('userspace', 'runUserspace requires a sandbox root');
50
+ if (!root) return { error: errorResult('userspace', 'runUserspace requires a sandbox root') };
43
51
 
44
52
  const bin = resolveUserspaceBin();
45
- if (!bin) return errorResult('userspace', 'no userspace confinement binary found on this host');
53
+ if (!bin) return { error: errorResult('userspace', 'no userspace confinement binary found on this host') };
46
54
 
47
55
  let resolvedRoot;
48
56
  try {
49
- // Resolve symlinks (e.g. macOS /var -> /private/var) so the profile's
50
- // subpath param matches the path the kernel actually sees.
51
57
  resolvedRoot = fs.realpathSync(root);
52
58
  } catch (e) {
53
- return errorResult('userspace', `sandbox root is not usable: ${e.message}`);
59
+ return { error: errorResult('userspace', `sandbox root is not usable: ${e.message}`) };
60
+ }
61
+
62
+ // Resolve each denied path as far as it exists (a symlink would otherwise
63
+ // let the same file be reached by a path the profile does not name). A path
64
+ // that does not exist yet is denied by its literal text.
65
+ const denyRead = [];
66
+ for (const p of denyReadPaths || []) {
67
+ if (typeof p !== 'string' || !p) continue;
68
+ let real = path.resolve(p);
69
+ try { real = fs.realpathSync(real); } catch { /* not present: deny the literal path */ }
70
+ if (!denyRead.includes(real)) denyRead.push(real);
54
71
  }
55
72
 
56
73
  const effectiveLimits = {
57
74
  ...limits,
58
75
  maxProcs: limits.maxProcs ?? ambientRelativeMaxProcs(),
59
76
  };
60
-
61
77
  let prelude, unsupported;
62
78
  try {
63
79
  ({ prelude, unsupported } = buildLimitPrelude(effectiveLimits));
64
80
  } catch (e) {
65
- return errorResult('userspace', `invalid resource limit: ${e.message}`);
81
+ return { error: errorResult('userspace', `invalid resource limit: ${e.message}`) };
66
82
  }
67
83
  const inner = `${prelude}exec "$@"`;
68
84
 
69
- const r = spawnSync(
85
+ return {
70
86
  bin,
71
- ['-p', _profile({ allowNetwork }), '-D', `ROOT=${resolvedRoot}`,
72
- '/bin/sh', '-c', inner, '_sbx', ...argv],
73
- {
74
- encoding: 'utf8',
75
- timeout: timeoutMs,
76
- // Match the namespace backend: SIGKILL cannot be ignored, SIGTERM can.
77
- // A payload that installs a SIGTERM handler would otherwise outlive its
78
- // own budget while the caller is told it timed out.
79
- killSignal: 'SIGKILL',
80
- maxBuffer,
81
- cwd: resolvedRoot,
82
- env: buildConfinedEnv({ root: resolvedRoot, env }),
83
- },
84
- );
87
+ args: [
88
+ '-p', _profile({ allowNetwork, denyRead }),
89
+ '-D', `ROOT=${resolvedRoot}`,
90
+ ...denyRead.flatMap((p, i) => ['-D', `DENY${i}=${p}`]),
91
+ '/bin/sh', '-c', inner, '_sbx', ...argv,
92
+ ],
93
+ cwd: resolvedRoot,
94
+ env: buildConfinedEnv({ root: resolvedRoot, env }),
95
+ unsupported,
96
+ };
97
+ }
98
+
99
+ export function runUserspace(argv, {
100
+ root,
101
+ timeoutMs = 10000,
102
+ allowNetwork = false,
103
+ limits = {},
104
+ env = {},
105
+ maxBuffer = 8 * 1024 * 1024,
106
+ denyReadPaths = [],
107
+ } = {}) {
108
+ // Documented shape, never a throw: a caller that wraps this in try/catch and
109
+ // "falls back" is a classic route to unconfined execution.
110
+ const inv = buildUserspaceInvocation(argv, { root, allowNetwork, limits, env, denyReadPaths });
111
+ if (inv.error) return inv.error;
112
+
113
+ const r = spawnSync(inv.bin, inv.args, {
114
+ encoding: 'utf8',
115
+ timeout: timeoutMs,
116
+ // Match the namespace backend: SIGKILL cannot be ignored, SIGTERM can.
117
+ // A payload that installs a SIGTERM handler would otherwise outlive its
118
+ // own budget while the caller is told it timed out.
119
+ killSignal: 'SIGKILL',
120
+ maxBuffer,
121
+ cwd: inv.cwd,
122
+ env: inv.env,
123
+ });
85
124
 
86
- return buildResult({ backend: 'userspace', spawnResult: r, unsupported });
125
+ return buildResult({ backend: 'userspace', spawnResult: r, unsupported: inv.unsupported });
87
126
  }
@@ -0,0 +1,215 @@
1
+ // Active probes for every control the boundary relies on (CORE-003.AC02).
2
+ //
3
+ // A control is "proved" only when a probe ATTACKED it through the real
4
+ // backend and the attack observably failed, AND a paired positive control
5
+ // showed the same probe succeeds when the control is off (so a probe that can
6
+ // never succeed cannot pass for a working control). An executable existing, a
7
+ // flag being passed or an earlier run succeeding proves nothing here.
8
+ //
9
+ // States:
10
+ // proved attack failed, positive control succeeded, both observed
11
+ // not-proved the probe ran and the control did not hold, or the probe could
12
+ // not establish its own positive control
13
+ // unsupported this backend does not implement the control at all
14
+ // unavailable no confinement backend works on this host
15
+ // unverified deliberately not probed; the reason says why (never a pass)
16
+ //
17
+ // The probes are generic, so on a Linux host they run against the namespace
18
+ // backend for real. Nothing in this file asserts a Linux outcome in advance.
19
+ import crypto from 'node:crypto';
20
+ import fs from 'node:fs';
21
+ import net from 'node:net';
22
+ import os from 'node:os';
23
+ import path from 'node:path';
24
+ import { detectBackend } from './capabilities.js';
25
+ import { runConfined } from './index.js';
26
+ import { runConfinedSupervised } from './supervise.js';
27
+
28
+ export const CONTROLS = Object.freeze([
29
+ 'write-confinement', 'read-denial', 'env-scrub', 'network',
30
+ 'tree-termination', 'file-size-limit', 'process-cap',
31
+ ]);
32
+
33
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
34
+ const alive = (pid) => { try { process.kill(pid, 0); return true; } catch (e) { return e.code === 'EPERM'; } };
35
+ const NODE = process.execPath;
36
+
37
+ function mk(prefix) { return fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), prefix))); }
38
+ function rm(p) { try { fs.rmSync(p, { recursive: true, force: true }); } catch { /* best effort */ } }
39
+ const proved = (evidence) => ({ state: 'proved', evidence });
40
+ const notProved = (reason) => ({ state: 'not-proved', reason });
41
+
42
+ function probeWrite() {
43
+ const root = mk('agsec-probe-w-'); const outside = mk('agsec-probe-wo-');
44
+ try {
45
+ const good = runConfined(['/bin/sh', '-c', 'echo x > "$ROOT/in.txt"'], { root });
46
+ const inOk = fs.existsSync(path.join(root, 'in.txt'));
47
+ runConfined(['/bin/sh', '-c', `echo x > '${outside}/escape.txt'; true`], { root });
48
+ const escaped = fs.existsSync(path.join(outside, 'escape.txt'));
49
+ if (!inOk) return notProved(`positive control failed: in-root write did not happen (${good.status})`);
50
+ if (escaped) return notProved('an out-of-root write succeeded');
51
+ return proved('in-root write succeeded; out-of-root write created no file');
52
+ } finally { rm(root); rm(outside); }
53
+ }
54
+
55
+ function probeReadDenial(backend) {
56
+ if (backend !== 'userspace') return { state: 'unsupported', reason: `read denial of host paths is not implemented on the ${backend} backend` };
57
+ const root = mk('agsec-probe-r-'); const secretDir = mk('agsec-probe-rs-');
58
+ try {
59
+ const secret = path.join(secretDir, 'sealed.txt');
60
+ const token = `SEALED-${crypto.randomBytes(8).toString('hex')}`;
61
+ fs.writeFileSync(secret, token);
62
+ const cmd = ['/bin/sh', '-c', `cat '${secret}' 2>&1; true`];
63
+ const open = runConfined(cmd, { root });
64
+ if (!open.stdout.includes(token)) return notProved('positive control failed: the probe could not read the canary even without denial');
65
+ const closed = runConfined(cmd, { root, denyReadPaths: [secretDir] });
66
+ if (closed.stdout.includes(token)) return notProved('a denied path was still readable');
67
+ return proved('canary readable without denial, unreadable with denyReadPaths');
68
+ } finally { rm(root); rm(secretDir); }
69
+ }
70
+
71
+ function probeEnv() {
72
+ const root = mk('agsec-probe-e-');
73
+ const name = 'AGSEC_PROBE_PARENT_CANARY';
74
+ const prev = process.env[name];
75
+ process.env[name] = 'parent-env-canary-value';
76
+ try {
77
+ const r = runConfined(['/usr/bin/env'], { root, env: { AGSEC_PROBE_EXPLICIT: 'explicit-ok' } });
78
+ if (!r.stdout.includes('AGSEC_PROBE_EXPLICIT=explicit-ok')) return notProved(`positive control failed: explicit env not visible (${r.status})`);
79
+ if (r.stdout.includes(name) || r.stdout.includes('parent-env-canary-value')) return notProved('the parent environment leaked into the target');
80
+ return proved('explicit variable visible, parent canary variable absent');
81
+ } finally {
82
+ if (prev === undefined) delete process.env[name]; else process.env[name] = prev;
83
+ rm(root);
84
+ }
85
+ }
86
+
87
+ async function probeNetwork() {
88
+ const root = mk('agsec-probe-n-');
89
+ let hits = 0;
90
+ const server = net.createServer((s) => { hits++; s.destroy(); });
91
+ try {
92
+ await new Promise((res, rej) => { server.once('error', rej); server.listen(0, '127.0.0.1', res); });
93
+ const port = server.address().port;
94
+ const script = `require('net').connect(${port},'127.0.0.1').on('error',()=>process.exit(0)).on('connect',()=>process.exit(0));setTimeout(()=>process.exit(0),1500)`;
95
+ runConfined([NODE, '-e', script], { root, timeoutMs: 6000, allowNetwork: true });
96
+ await sleep(200);
97
+ const open = hits;
98
+ if (open < 1) return notProved('positive control failed: loopback listener saw no connection with network allowed');
99
+ runConfined([NODE, '-e', script], { root, timeoutMs: 6000, allowNetwork: false });
100
+ await sleep(200);
101
+ if (hits > open) return notProved('a connection reached a loopback listener with network denied');
102
+ return proved('connection seen with network allowed, none with network denied');
103
+ } finally { server.close(); rm(root); }
104
+ }
105
+
106
+ async function probeTree() {
107
+ const root = mk('agsec-probe-t-');
108
+ try {
109
+ // Three tree shapes: a plain background child, a grandchild, and one that
110
+ // ignores SIGTERM and so needs the SIGKILL escalation.
111
+ const script = [
112
+ 'sleep 300 & echo $! > "$ROOT/p1"',
113
+ '(sleep 300 & echo $! > "$ROOT/p2"; wait) &',
114
+ `(trap '' TERM; sleep 300 & echo $! > "$ROOT/p3"; while :; do sleep 1; done) &`,
115
+ 'wait',
116
+ ].join('\n');
117
+ const r = await runConfinedSupervised(['/bin/sh', '-c', script], { root, timeoutMs: 1500, graceMs: 300 });
118
+ if (r.backend !== 'userspace' || !r.supervised) {
119
+ return { state: 'unsupported', reason: `supervised tree termination is not implemented on the ${r.backend} backend (${r.status})` };
120
+ }
121
+ const pids = ['p1', 'p2', 'p3'].map((f) => { try { return Number(fs.readFileSync(path.join(root, f), 'utf8')); } catch { return NaN; } });
122
+ if (pids.some((p) => !Number.isFinite(p))) return notProved('positive control failed: the probe tree did not start');
123
+ await sleep(100);
124
+ const live = pids.filter(alive);
125
+ for (const p of live) { try { process.kill(p, 'SIGKILL'); } catch { /* probe cleanup */ } }
126
+ if (live.length) return notProved(`processes survived the timeout: ${live.join(',')}`);
127
+ return proved('timeout ended a three-process tree including a SIGTERM-ignoring member');
128
+ } finally { rm(root); }
129
+ }
130
+
131
+ function probeFileSize() {
132
+ const root = mk('agsec-probe-f-');
133
+ try {
134
+ const cmd = ['/bin/sh', '-c', 'dd if=/dev/zero of="$ROOT/big" bs=1024 count=400 2>&1; true'];
135
+ runConfined(cmd, { root, limits: { maxFileSizeKb: 65536 } });
136
+ const full = fs.existsSync(path.join(root, 'big')) ? fs.statSync(path.join(root, 'big')).size : 0;
137
+ if (full < 400 * 1024) return notProved('positive control failed: the unrestricted write did not complete');
138
+ fs.rmSync(path.join(root, 'big'));
139
+ runConfined(cmd, { root, limits: { maxFileSizeKb: 16 } });
140
+ const capped = fs.existsSync(path.join(root, 'big')) ? fs.statSync(path.join(root, 'big')).size : 0;
141
+ if (capped >= 400 * 1024) return notProved('the file size limit did not stop a large write');
142
+ return proved(`400 KiB write completed unrestricted and stopped at ${capped} bytes under a 16 KiB limit`);
143
+ } finally { rm(root); }
144
+ }
145
+
146
+ const UNPROBED_PROCESS_CAP = {
147
+ state: 'unverified',
148
+ 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',
149
+ };
150
+
151
+ const _cache = new Map();
152
+ export function resetProbeCache() { _cache.clear(); }
153
+
154
+ /**
155
+ * Probe every control on the active backend.
156
+ * @param {object} [o]
157
+ * @param {string} [o.force] backend override (same meaning as runConfined's)
158
+ * @param {object} [o.probes] per-control probe overrides (test seam). A probe
159
+ * may only make the report MORE restrictive in
160
+ * practice, since the boundary consults the report
161
+ * before any run; it cannot make a run unconfined.
162
+ */
163
+ export async function probeControls({ force, probes = {} } = {}) {
164
+ const backend = detectBackend({ force });
165
+ const key = `${backend}:${Object.keys(probes).sort().join(',')}`;
166
+ if (!Object.keys(probes).length && _cache.has(backend)) return _cache.get(backend);
167
+
168
+ const controls = {};
169
+ if (backend === 'disabled') {
170
+ for (const c of CONTROLS) controls[c] = { state: 'unavailable', reason: 'no confinement backend works on this host' };
171
+ } else {
172
+ const run = async (name, fn) => {
173
+ if (probes[name]) { controls[name] = await probes[name](); return; }
174
+ try { controls[name] = await fn(); } catch (e) { controls[name] = notProved(`probe threw: ${e.message}`); }
175
+ };
176
+ await run('write-confinement', probeWrite);
177
+ await run('read-denial', () => probeReadDenial(backend));
178
+ await run('env-scrub', probeEnv);
179
+ await run('network', probeNetwork);
180
+ await run('tree-termination', probeTree);
181
+ await run('file-size-limit', probeFileSize);
182
+ controls['process-cap'] = probes['process-cap'] ? await probes['process-cap']() : UNPROBED_PROCESS_CAP;
183
+ }
184
+ const report = { platform: process.platform, backend, controls };
185
+ if (!Object.keys(probes).length) _cache.set(backend, report);
186
+ void key;
187
+ return report;
188
+ }
189
+
190
+ /** Controls from `required` that are not proved, with the reason for each. */
191
+ export function unmetControls(report, required) {
192
+ const out = [];
193
+ for (const c of required) {
194
+ const v = report?.controls?.[c];
195
+ if (!v || v.state !== 'proved') out.push({ control: c, state: v?.state ?? 'not-probed', reason: v?.reason ?? 'no probe result' });
196
+ }
197
+ return out;
198
+ }
199
+
200
+ /**
201
+ * Human-readable capability matrix. States what each control is on this host
202
+ * and carries the standing disclosure that Linux enforcement is only evidenced
203
+ * where the hosted `sandbox-linux` job actually ran.
204
+ */
205
+ export function capabilityReport(report) {
206
+ return {
207
+ ...report,
208
+ advertisedIsolationBackends: ['userspace (macOS family)', 'namespace (Linux family)'],
209
+ notes: [
210
+ 'A state of proved was established by an active probe on THIS host in THIS process; it is not inherited from another platform.',
211
+ 'Linux enforcement cannot be exercised on a macOS host. It is evidenced only where the sandbox-linux CI job ran this same probe set.',
212
+ 'Process-count caps are not claimed enforced on any backend.',
213
+ ],
214
+ };
215
+ }