@clear-capabilities/agentic-security-scanner 0.162.0 → 0.163.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 (153) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/bin/agentic-security.js +87 -0
  3. package/dist/1340.index.js +1871 -0
  4. package/dist/3477.index.js +727 -0
  5. package/dist/3499.index.js +23 -3
  6. package/dist/4113.index.js +1 -1
  7. package/dist/5014.index.js +833 -0
  8. package/dist/5756.index.js +2 -2
  9. package/dist/6257.index.js +1 -1
  10. package/dist/6907.index.js +248 -0
  11. package/dist/6994.index.js +1 -1
  12. package/dist/7033.index.js +33 -5
  13. package/dist/7439.index.js +1822 -0
  14. package/dist/7838.index.js +1 -1
  15. package/dist/9560.index.js +2318 -23
  16. package/dist/957.index.js +1 -1
  17. package/dist/9999.index.js +43 -7
  18. package/dist/agentic-security.mjs +12 -12
  19. package/dist/agentic-security.mjs.sha256 +1 -1
  20. package/package.json +33 -6
  21. package/src/capabilities/CLAUDE.md +114 -0
  22. package/src/capabilities/address.js +73 -0
  23. package/src/capabilities/attack-coverage.js +93 -0
  24. package/src/capabilities/decide.js +256 -0
  25. package/src/capabilities/delegate.js +69 -0
  26. package/src/capabilities/egress.js +20 -0
  27. package/src/capabilities/hook-advice.js +50 -0
  28. package/src/capabilities/index.js +32 -0
  29. package/src/capabilities/manifest.js +344 -0
  30. package/src/capabilities/outbound.js +189 -0
  31. package/src/capabilities/paths.js +57 -0
  32. package/src/capabilities/probes.js +211 -0
  33. package/src/capabilities/proxy.js +182 -0
  34. package/src/capabilities/reasons.js +67 -0
  35. package/src/capabilities/receipts.js +356 -0
  36. package/src/capabilities/records.js +58 -0
  37. package/src/capabilities/recovery.js +367 -0
  38. package/src/capabilities/report.js +81 -0
  39. package/src/capabilities/runner.js +236 -0
  40. package/src/capabilities/secrets.js +26 -0
  41. package/src/capabilities/tool-gate.js +119 -0
  42. package/src/capabilities/tool-registry.js +60 -0
  43. package/src/dataflow/CLAUDE.md +29 -0
  44. package/src/dataflow/catalog.js +21 -0
  45. package/src/dataflow/engine.js +20 -0
  46. package/src/dataflow/guard-dominance.js +352 -0
  47. package/src/discovery/CLAUDE.md +9 -0
  48. package/src/discovery/index.js +15 -1
  49. package/src/discovery/lenses.js +18 -0
  50. package/src/engine.js +80 -15
  51. package/src/integrations/index.js +5 -2
  52. package/src/ir/CLAUDE.md +14 -0
  53. package/src/ir/implicit-handler-params.js +74 -0
  54. package/src/ir/parser-cs.js +20 -0
  55. package/src/ir/parser-go.js +30 -13
  56. package/src/ir/parser-java.js +11 -0
  57. package/src/language/nix-eval-isolation.js +33 -5
  58. package/src/lineage/CLAUDE.md +31 -0
  59. package/src/lineage/deployment/adapter-cloud.js +202 -0
  60. package/src/lineage/deployment/adapter-compose.js +90 -0
  61. package/src/lineage/deployment/adapter-kit.js +142 -0
  62. package/src/lineage/deployment/adapter-kubernetes.js +247 -0
  63. package/src/lineage/deployment/attack-paths.js +443 -0
  64. package/src/lineage/deployment/boundaries-run.js +223 -0
  65. package/src/lineage/deployment/boundary-graph.js +567 -0
  66. package/src/lineage/deployment/cross-repo.js +253 -0
  67. package/src/lineage/deployment/drift.js +383 -0
  68. package/src/lineage/deployment/ingest.js +221 -0
  69. package/src/lineage/deployment/projection.js +244 -0
  70. package/src/lineage/deployment/trace-correlation.js +541 -0
  71. package/src/lineage/source-registry.js +19 -0
  72. package/src/mcp/CLAUDE.md +5 -2
  73. package/src/mcp/invariant-tools.js +64 -0
  74. package/src/mcp/portfolio-tools.js +79 -0
  75. package/src/mcp/redact.js +15 -0
  76. package/src/mcp/server.js +24 -3
  77. package/src/mcp/tools.js +13 -1
  78. package/src/posture/CLAUDE.md +332 -1
  79. package/src/posture/accuracy-scorecard.js +95 -4
  80. package/src/posture/assurance/rollback.js +214 -0
  81. package/src/posture/assurance/rollout.js +200 -0
  82. package/src/posture/business-logic.js +26 -4
  83. package/src/posture/evaluation/bakeoff.js +148 -0
  84. package/src/posture/evaluation/custody.js +200 -0
  85. package/src/posture/evaluation/deployment-ablation.js +416 -0
  86. package/src/posture/evaluation/dev-cases.js +65 -0
  87. package/src/posture/evaluation/economics.js +85 -0
  88. package/src/posture/evaluation/gates.js +161 -0
  89. package/src/posture/evaluation/grouping.js +101 -0
  90. package/src/posture/evaluation/interval.js +132 -0
  91. package/src/posture/evaluation/invariant-ablation.js +325 -0
  92. package/src/posture/evaluation/labels.js +200 -0
  93. package/src/posture/evaluation/protocol.js +308 -0
  94. package/src/posture/evaluation/release-gate.js +209 -0
  95. package/src/posture/evaluation/report.js +158 -0
  96. package/src/posture/evaluation/runner.js +273 -0
  97. package/src/posture/evaluation/scan-child.js +80 -0
  98. package/src/posture/evaluation/score.js +174 -0
  99. package/src/posture/evaluation/synthetic.js +90 -0
  100. package/src/posture/evaluation/why-missed.js +219 -0
  101. package/src/posture/fleet.js +11 -2
  102. package/src/posture/invariants/cli.js +95 -0
  103. package/src/posture/invariants/coverage.js +164 -0
  104. package/src/posture/invariants/export.js +178 -0
  105. package/src/posture/invariants/expressions.js +46 -0
  106. package/src/posture/invariants/lifecycle.js +289 -0
  107. package/src/posture/invariants/project-input.js +55 -0
  108. package/src/posture/invariants/repair.js +340 -0
  109. package/src/posture/invariants/run.js +45 -0
  110. package/src/posture/invariants/scenarios.js +311 -0
  111. package/src/posture/invariants/schema.js +167 -0
  112. package/src/posture/invariants/shrink.js +407 -0
  113. package/src/posture/invariants/state-assertions.js +165 -0
  114. package/src/posture/logic-claims.js +12 -0
  115. package/src/posture/model-routing.js +40 -0
  116. package/src/posture/oracles/adapters.js +172 -1
  117. package/src/posture/oracles/conformance.js +3 -2
  118. package/src/posture/oracles/oracle.js +23 -3
  119. package/src/posture/oracles/registry.js +1 -1
  120. package/src/posture/portfolio/backend.js +279 -0
  121. package/src/posture/portfolio/bundle.js +314 -0
  122. package/src/posture/portfolio/cli.js +142 -0
  123. package/src/posture/portfolio/manifest.js +263 -0
  124. package/src/posture/portfolio/progress.js +267 -0
  125. package/src/posture/portfolio/resume.js +130 -0
  126. package/src/posture/portfolio/retention.js +207 -0
  127. package/src/posture/portfolio/scheduler.js +488 -0
  128. package/src/posture/portfolio/signing.js +155 -0
  129. package/src/posture/portfolio/wording.js +59 -0
  130. package/src/posture/portfolio/work-units.js +405 -0
  131. package/src/posture/replay/replay.js +1 -0
  132. package/src/posture/routing/adapter.js +140 -0
  133. package/src/posture/routing/calibration.js +278 -0
  134. package/src/posture/routing/canary.js +141 -0
  135. package/src/posture/routing/control.js +35 -0
  136. package/src/posture/routing/decide.js +211 -0
  137. package/src/posture/routing/drift.js +137 -0
  138. package/src/posture/routing/economics.js +185 -0
  139. package/src/posture/routing/feedback.js +207 -0
  140. package/src/posture/routing/index.js +31 -0
  141. package/src/posture/routing/outcomes.js +281 -0
  142. package/src/posture/routing/promotion.js +291 -0
  143. package/src/posture/routing/receipts.js +59 -0
  144. package/src/posture/routing/report.js +142 -0
  145. package/src/posture/routing/shadow.js +53 -0
  146. package/src/posture/specification-mining.js +26 -0
  147. package/src/posture/verification/patch-negative.js +16 -4
  148. package/src/posture/verification/patch-promotion.js +10 -2
  149. package/src/report/index.js +51 -1
  150. package/src/sandbox/CLAUDE.md +10 -0
  151. package/src/sandbox/backend-userspace.js +91 -13
  152. package/dist/5613.index.js +0 -500
  153. package/dist/6239.index.js +0 -469
@@ -27,6 +27,10 @@
27
27
  // environment-mismatch the runs were not in equivalent environments
28
28
  // promotion-refused the receipts do not match the exact proposed diff and revision
29
29
  //
30
+ // The behaviour check is the functional-regression oracle by default. A requester may name `business-state` as `functional.oracleId`
31
+ // (X-406, `invariants/repair.js`): the same two runs and the same expectation, with an authorized-workflow scenario as the declared
32
+ // case, and `promotePatch` then requires the functional receipts to come from that oracle class instead.
33
+ //
30
34
  // Scope, stated so no caller overclaims: the exploit scenario and the functional cases are the REQUESTER's; this verifies the
31
35
  // declared scenario and cases, not that the patch is correct elsewhere. Linux is not claimed (the oracle platform statements
32
36
  // stay `unverified`). Receipts are valid inside this process only (see patch-promotion.js).
@@ -40,6 +44,9 @@ import { appendRepairRecord, recordPromotion, verificationRepairFor, withRepairS
40
44
 
41
45
  const PATCH_NEGATIVE_FEATURE = 'patch-negative-verification';
42
46
  const FUNCTIONAL_ORACLE = 'functional-regression';
47
+ // The check that behaviour is preserved is normally the functional-regression oracle. A requester may name `business-state`
48
+ // instead (X-406): the same two runs, the same expectation (refuted), with an authorized-workflow scenario as the declared case.
49
+ const FUNCTIONAL_ORACLES = Object.freeze([FUNCTIONAL_ORACLE, 'business-state']);
43
50
 
44
51
  const STEPS = Object.freeze(['original-positive', 'patched-negative', 'functional-baseline', 'functional-regression']);
45
52
 
@@ -64,7 +71,8 @@ function classify(step, run) {
64
71
  }
65
72
  const outcome = run.outcome;
66
73
  const observed = run.receipt?.observed || {};
67
- const loadBroken = observed.targetLoaded === false || observed.benignCompleted === false;
74
+ // a business-state run reports its control flow under `preconditions`: a control flow that no longer works is a broken revision
75
+ const loadBroken = observed.targetLoaded === false || observed.benignCompleted === false || observed.preconditions?.controlRan === false || observed.preconditions?.controlDurableEffect === false;
68
76
  if (outcome === 'error') return { status: 'incomplete', code: 'harness-error', reason: String(run.record?.reason || 'the oracle run failed').slice(0, 300), outcome };
69
77
  if (outcome === EXPECTED[step]) return { status: 'passed', code: null, reason: run.record?.reason ?? null, outcome };
70
78
  const reason = String(run.record?.reason || '').slice(0, 300);
@@ -150,6 +158,10 @@ export async function verifyPatchNegative(req, o = {}) {
150
158
  if (!isPlainObject(fn) || !isPlainObject(fn.inputs)) {
151
159
  return refuse('functional-regression', 'functional-omitted', 'no functional regression check was supplied: a patch that was never checked for behaviour cannot be a verified fix');
152
160
  }
161
+ const functionalOracle = fn.oracleId === undefined ? FUNCTIONAL_ORACLE : fn.oracleId;
162
+ if (!FUNCTIONAL_ORACLES.includes(functionalOracle)) {
163
+ return refuse('input-validation', 'bad-request', `the functional check oracle must be one of ${FUNCTIONAL_ORACLES.join(', ')}`);
164
+ }
153
165
 
154
166
  const patchDigest = digestOf(patchFiles);
155
167
  base.patchDigest = patchDigest;
@@ -158,8 +170,8 @@ export async function verifyPatchNegative(req, o = {}) {
158
170
  const plan = [
159
171
  ['original-positive', { oracleId: original.oracleId, entry: original.entry, inputs: original.inputs, patchFiles: null, budgets: original.budgets }],
160
172
  ['patched-negative', { oracleId: original.oracleId, entry: original.entry, inputs: original.inputs, patchFiles, budgets: original.budgets }],
161
- ['functional-baseline', { oracleId: FUNCTIONAL_ORACLE, entry: original.entry, inputs: fn.inputs, patchFiles: null, budgets: fn.budgets }],
162
- ['functional-regression', { oracleId: FUNCTIONAL_ORACLE, entry: original.entry, inputs: fn.inputs, patchFiles, budgets: fn.budgets }],
173
+ ['functional-baseline', { oracleId: functionalOracle, entry: original.entry, inputs: fn.inputs, patchFiles: null, budgets: fn.budgets }],
174
+ ['functional-regression', { oracleId: functionalOracle, entry: original.entry, inputs: fn.inputs, patchFiles, budgets: fn.budgets }],
163
175
  ];
164
176
  const runs = {};
165
177
  let failed = null;
@@ -186,7 +198,7 @@ export async function verifyPatchNegative(req, o = {}) {
186
198
 
187
199
  // ---- the receipts must match the exact proposed diff and revision before anything is promoted
188
200
  const receipts = Object.fromEntries(Object.entries(runs).map(([k, r]) => [k, r.receipt]));
189
- const promotion = promotePatch({ proposal: { hypothesisId: req.hypothesisId, revision: req.commit, originalFiles: original.files, patchFiles }, receipts });
201
+ const promotion = promotePatch({ proposal: { hypothesisId: req.hypothesisId, revision: req.commit, originalFiles: original.files, patchFiles, functionalOracle }, receipts });
190
202
  if (!promotion.ok) {
191
203
  const envBroken = promotion.reasons.some((r) => r.code === 'environment-mismatch');
192
204
  const code = envBroken ? 'environment-mismatch' : promotion.reasons.some((r) => r.code === 'oracle-changed') ? 'oracle-changed' : 'promotion-refused';
@@ -23,6 +23,9 @@ import { isCommit, isPlainObject } from '../assurance/schema-kit.js';
23
23
  import { isIssuedReceipt } from '../oracles/oracle.js';
24
24
 
25
25
  const PROMOTION_ROLES = Object.freeze(['original', 'patched', 'functionalBaseline', 'functional']);
26
+ // The oracle class the behaviour-preservation runs must come from. Default `functional-regression`; a proposal may name
27
+ // `business-state` (X-406: an authorized-workflow scenario as the declared case). Nothing else is accepted.
28
+ const FUNCTIONAL_CLASSES = Object.freeze({ 'functional-regression': 'functional-regression', 'business-state': 'business-state' });
26
29
 
27
30
  const ISSUED_PROMOTIONS = new WeakSet();
28
31
 
@@ -36,7 +39,7 @@ const same = (a, b) => digestOf(a ?? null) === digestOf(b ?? null);
36
39
 
37
40
  /**
38
41
  * @param {object} args
39
- * @param {object} args.proposal { hypothesisId, revision, originalFiles, patchFiles }
42
+ * @param {object} args.proposal { hypothesisId, revision, originalFiles, patchFiles, functionalOracle? }
40
43
  * @param {object} args.receipts { original, patched, functionalBaseline, functional }, each issued by runOracle
41
44
  * @returns {{ ok: boolean, diffDigest: string|null, revision: string|null, reasons: {code,role,message}[], receiptDigests: object }}
42
45
  */
@@ -59,6 +62,11 @@ export function promotePatch({ proposal, receipts } = {}) {
59
62
  why('revision-unbound', null, 'a promotion is bound to one exact 40 or 64 character commit; none was given');
60
63
  return out();
61
64
  }
65
+ const functionalClass = proposal.functionalOracle === undefined ? 'functional-regression' : FUNCTIONAL_CLASSES[proposal.functionalOracle];
66
+ if (!functionalClass) {
67
+ why('bad-proposal', null, 'the functional check oracle must be functional-regression or business-state');
68
+ return out();
69
+ }
62
70
  const diffDigest = digestOf(proposal.patchFiles);
63
71
  const originalDigest = digestOf(proposal.originalFiles);
64
72
  const patchedDigest = digestOf({ ...proposal.originalFiles, ...proposal.patchFiles });
@@ -92,7 +100,7 @@ export function promotePatch({ proposal, receipts } = {}) {
92
100
  if (!same(functionalBaseline.oracle, functional.oracle) || functionalBaseline.request.inputsDigest !== functional.request.inputsDigest) {
93
101
  why('oracle-changed', 'functional', 'the functional check on the patched revision did not use the same oracle and cases as its baseline');
94
102
  }
95
- if (functional.oracle.class !== 'functional-regression') why('oracle-changed', 'functional', 'the functional check is not a functional-regression oracle');
103
+ if (functional.oracle.class !== functionalClass) why('oracle-changed', 'functional', `the functional check is not a ${functionalClass} oracle`);
96
104
  if (original.oracle.class === 'functional-regression') why('oracle-changed', 'original', 'the exploit oracle must not be the functional-regression oracle');
97
105
  // equivalent disposable environments: one sanitized environment identity across every run
98
106
  const envDigest = digestOf(original.environment);
@@ -4,6 +4,7 @@ import { _isCustomSuppressed } from '../engine.js';
4
4
  import { alertFace, approveFace } from './mascot.js';
5
5
  import { SCANNER_VERSION } from '../posture/version.js';
6
6
  import { proofBlock } from '../posture/proof-artifact.js';
7
+ import { portfolioAssuranceEnabled, scanVerdictWording, NOT_A_GUARANTEE } from '../posture/portfolio/wording.js';
7
8
  import { applyLegacyCompat, legacyFieldDeprecationNotice } from '../pipeline/legacy-compat.js';
8
9
  // Finding Provenance (M0/M1). NOTE the name: `findingProvenance` is the
9
10
  // git-origin record from posture/provenance/. It is NOT `finding.provenance`
@@ -14,8 +15,11 @@ import { applyLegacyCompat, legacyFieldDeprecationNotice } from '../pipeline/leg
14
15
  import { redactFindingProvenance, sanitizeForTerminal } from '../posture/provenance/schema.js';
15
16
  // X-206: the one projection of the verification record. This report, the MCP tools and the autopilot response all call it.
16
17
  import { verificationFields, verificationCoverage } from '../posture/verification/projection.js';
18
+ import { invariantCoverageFields } from '../posture/invariants/coverage.js';
17
19
  // X-207: a hunt hypothesis is advisory and is never a finding in any gating output; promotion is the only way across.
18
20
  import { isAdvisoryHypothesis } from '../posture/verification/advisory-state.js';
21
+ // X-307: the one projection of the deployment boundary context. Additive and present only when a finding carries a context.
22
+ import { boundaryFields, boundaryCoverage } from '../lineage/deployment/projection.js';
19
23
  // Re-exported: FR-PROV-026. Lives in provenance/schema.js (shared with
20
24
  // posture/auditor-walkthrough.js, a second CLI renderer of the same
21
25
  // untrusted fields — see that module's header for why it lives there
@@ -341,6 +345,11 @@ export function normalizeFindings(scan){
341
345
  ...(f.verificationRecord && typeof f.verificationRecord === 'object'
342
346
  ? { verificationRecord: f.verificationRecord, ...(f.verificationReplay ? { verificationReplay: f.verificationReplay } : {}), ...verificationFields(f.verificationRecord, { replay: f.verificationReplay }) }
343
347
  : {}),
348
+ // X-307: the deployment boundary context and its projection, ADDITIVE and present only when a producer (the
349
+ // `deployment-boundaries` feature) attached one. A plain scan output is byte-identical.
350
+ ...(f.boundaryContext && typeof f.boundaryContext === 'object'
351
+ ? boundaryFields(f.boundaryContext)
352
+ : {}),
344
353
  // Phase-1 next-gen P1.3 (FR-UX-1, FR-UX-2): calibrated probability +
345
354
  // 95% Wilson CI + sample size. Null when N < MIN_SAMPLES_FOR_CALIBRATION
346
355
  // for this family; `calibration_reason` explains why.
@@ -753,6 +762,10 @@ export function toJSON(scan, meta={}, opts={}){
753
762
  // X-206: trusted-negative accounting over the findings that carry a verification record. Absent when none does, so a plain
754
763
  // scan's output is unchanged.
755
764
  ...(findings.some(f => f.verificationRecord) ? { verificationCoverage: verificationCoverage(findings.map(f => f.verificationRecord).filter(Boolean)) } : {}),
765
+ // X-307: boundary accounting over the findings that carry a context; absent when none does.
766
+ ...(findings.some(f => f.boundaryContext) ? { boundaryCoverage: boundaryCoverage(findings.map(f => f.boundaryContext).filter(Boolean)) } : {}),
767
+ // X-407: bounded business-logic coverage, when a caller attached one AND the invariant-scenarios feature is on (off by default).
768
+ ...invariantCoverageFields(scan.invariantCoverage),
756
769
  };
757
770
  if (opts.includeSuppressed) out.suppressed = scan.suppressions||[];
758
771
  return out;
@@ -932,6 +945,15 @@ export function verificationBlock(f) {
932
945
  return Array.isArray(text) && text.length ? text.map((l) => String(l).match(/^ */)[0] + sanitizeForTerminal(String(l))) : null;
933
946
  }
934
947
 
948
+ /**
949
+ * X-307: the human-readable boundary block for a normalized finding, from the SAME projection the JSON and MCP surfaces carry.
950
+ * Null when the finding has no boundary context. Terminal control characters are stripped.
951
+ */
952
+ export function boundaryBlock(f) {
953
+ const text = f?.boundaryView?.text;
954
+ return Array.isArray(text) && text.length ? text.map((l) => String(l).match(/^ */)[0] + sanitizeForTerminal(String(l))) : null;
955
+ }
956
+
935
957
  export function toMarkdown(scan, meta={}){
936
958
  const findings = normalizeFindings(scan);
937
959
  const lines = ['# Agentic Security — Scan Report', ''];
@@ -981,6 +1003,23 @@ export function toMarkdown(scan, meta={}){
981
1003
  }
982
1004
  lines.push('</details>');
983
1005
  }
1006
+ // X-307: boundary blocks come from the shared projection, never a second renderer.
1007
+ const withBoundary = bySev[sev].filter(f => boundaryBlock(f));
1008
+ if (withBoundary.length) {
1009
+ lines.push('');
1010
+ lines.push('<details><summary>Deployment boundaries</summary>');
1011
+ lines.push('');
1012
+ for (const f of withBoundary) {
1013
+ const block = boundaryBlock(f).join('\n');
1014
+ lines.push(`**\`${f.file}:${f.line}\`**: ${f.vuln}`);
1015
+ const fence = '`'.repeat(_mdFenceLen(block));
1016
+ lines.push(fence);
1017
+ lines.push(block);
1018
+ lines.push(fence);
1019
+ lines.push('');
1020
+ }
1021
+ lines.push('</details>');
1022
+ }
984
1023
  // FR-PROV-018: one provenance block per finding that has one, reusing
985
1024
  // explainProvenance's content — never a second, divergent renderer.
986
1025
  const withProvenance = bySev[sev].filter(f => f.findingProvenance);
@@ -1480,6 +1519,8 @@ export function toCLI(scan, { verbose=false, color=true, provenance=false }={}){
1480
1519
  if (ex.fixCode) for (const ln of ex.fixCode.split('\n').slice(0, 6)) lines.push(` ${c(ln, DIM)}`);
1481
1520
  const vblock = verificationBlock(f);
1482
1521
  if (vblock) for (const ln of vblock) lines.push(` ${c(ln, DIM)}`);
1522
+ const bblock = boundaryBlock(f);
1523
+ if (bblock) for (const ln of bblock) lines.push(` ${c(ln, DIM)}`);
1483
1524
  if (provenance) {
1484
1525
  // `f` here is already normalized, so its findingProvenance has been
1485
1526
  // through redactFindingProvenance — the email is gone before it can
@@ -1492,6 +1533,8 @@ export function toCLI(scan, { verbose=false, color=true, provenance=false }={}){
1492
1533
  const counts = { critical: 0, high: 0, medium: 0, low: 0, info: 0 };
1493
1534
  for (const f of findings) counts[f.severity] = (counts[f.severity]||0) + 1;
1494
1535
  if (findings.some(f => f.verificationRecord)) lines.push(verificationCoverage(findings.map(f => f.verificationRecord).filter(Boolean)).lines[0]);
1536
+ if (findings.some(f => f.boundaryContext)) lines.push(boundaryCoverage(findings.map(f => f.boundaryContext).filter(Boolean)).lines[0]);
1537
+ { const ic = invariantCoverageFields(scan.invariantCoverage); if (ic.invariantCoverage) lines.push(...ic.invariantCoverage.lines); }
1495
1538
  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}`);
1496
1539
  return lines.join('\n');
1497
1540
  }
@@ -1616,7 +1659,14 @@ export function toShipVerdict(scan, options = {}) {
1616
1659
  const clean = actionable.length === 0 && !scanIncomplete;
1617
1660
  lines.push(clean ? approveFace({ color }) : alertFace({ color }));
1618
1661
  lines.push(bar);
1619
- if (clean) {
1662
+ // X-703.AC03: with the `portfolio-assurance` feature on, the headline is the bounded claim followed by scope and gaps. Off
1663
+ // (the default) the wording below is unchanged, because existing output and documentation pin it.
1664
+ const _bounded = portfolioAssuranceEnabled({ option: options.portfolioAssurance, scanRoot: options.scanRoot });
1665
+ if (_bounded) {
1666
+ const w = scanVerdictWording({ clean, scanIncomplete: !!scanIncomplete, actionableCount: actionable.length });
1667
+ lines.push(c(` ${w.icon} ${w.headline}`, w.tier === 'ok' ? SEV_COLOR.low + BOLD : w.tier === 'incomplete' ? SEV_COLOR.high + BOLD : SEV_COLOR.critical + BOLD));
1668
+ lines.push(c(` Scope: completed supported checks only${scanIncomplete ? '; gaps listed below' : '; no gaps recorded by scan health'}. ${NOT_A_GUARANTEE}`, DIM));
1669
+ } else if (clean) {
1620
1670
  lines.push(c(' ✅ Safe to deploy', SEV_COLOR.low + BOLD));
1621
1671
  } else if (actionable.length === 0) {
1622
1672
  lines.push(c(' ⚠️ Scan incomplete — cannot confirm safe to deploy', SEV_COLOR.high + BOLD));
@@ -388,6 +388,16 @@ then runs supervised with protected paths read-denied.
388
388
  - Tests: `test/trust-boundary.test.js` (attack fixture, tree kill with `ps`
389
389
  orphan checks, blocked-never-pass), in `test:lifecycle`.
390
390
 
391
+ ## Capability mode (X-502, X-504)
392
+
393
+ `backend-userspace.js` has an opt-in mode used by `capabilities/runner.js`; every pre-existing caller passes none of these options and gets the profile it always had.
394
+
395
+ - `readRoots` (array): reads are confined to a fixed baseline of system paths (loader, libraries, locale, timezone, device nodes), the sandbox root, the write roots and these roots. File metadata is granted only for the ancestors of those roots, so a path outside them cannot be probed for existence. `denyReadPaths` still apply last.
396
+ - `writeRoots`: further writable subtrees (readable too). `cwd`: working directory, which must exist.
397
+ - `networkProxyPort`: opens exactly one loopback port (`(remote ip "localhost:PORT")`); DNS, every other port and every other address stay denied.
398
+
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.
400
+
391
401
  ## Extending this module
392
402
 
393
403
  - Both real backends (`backend-userspace.js`, `backend-namespace.js`) must
@@ -18,20 +18,66 @@ import { resolveUserspaceBin } from './capabilities.js';
18
18
  import { buildLimitPrelude, ambientRelativeMaxProcs } from './limits.js';
19
19
  import { buildResult, errorResult, buildConfinedEnv } from './result.js';
20
20
 
21
- function _profile({ allowNetwork, denyRead = [] }) {
22
- return [
21
+ // Host paths every confined process needs just to start (dynamic loader, system
22
+ // libraries, locale and timezone data, device nodes). Used ONLY when reads are
23
+ // confined; the default profile below still allows reads globally. Metadata
24
+ // (stat) is granted on the ancestors of each readable root and nowhere else, so
25
+ // a path outside the readable set cannot even be probed for existence.
26
+ const BASELINE_READ_SUBPATHS = Object.freeze([
27
+ '/usr', '/bin', '/sbin', '/System', '/Library/Preferences', '/private/etc', '/dev',
28
+ '/private/var/db/dyld', '/private/var/db/timezone', '/private/var/select',
29
+ ]);
30
+ const BASELINE_META_LITERALS = Object.freeze([
31
+ '/', '/private', '/private/var', '/private/var/db', '/var', '/etc', '/tmp', '/Library', '/private/tmp',
32
+ ]);
33
+
34
+ function _profile({ allowNetwork, proxyPort = null, denyRead = [], confineReads = false, readRoots = [], writeRoots = [], metaLiterals = [] }) {
35
+ const lines = [
23
36
  '(version 1)',
24
37
  '(deny default)',
25
38
  '(allow process-exec process-fork)',
26
39
  '(allow sysctl-read)',
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}")))`),
32
- '(allow file-write* (subpath (param "ROOT")))',
33
- allowNetwork ? '(allow network*)' : '',
34
- ].filter(Boolean).join('\n');
40
+ ];
41
+ if (!confineReads) {
42
+ lines.push('(allow file-read*)');
43
+ } else {
44
+ // Reads are confined to the baseline above, the declared readable roots and
45
+ // the writable roots. Every path is a parameter or a fixed literal, never
46
+ // spliced text, so a path cannot inject policy.
47
+ lines.push(`(allow file-read* (literal "/") ${BASELINE_READ_SUBPATHS.map((p) => `(subpath "${p}")`).join(' ')})`);
48
+ lines.push(`(allow file-read-metadata ${BASELINE_META_LITERALS.map((p) => `(literal "${p}")`).join(' ')})`);
49
+ lines.push('(allow file-read* (subpath (param "ROOT")))');
50
+ readRoots.forEach((_, i) => lines.push(`(allow file-read* (subpath (param "RD${i}")))`));
51
+ writeRoots.forEach((_, i) => lines.push(`(allow file-read* (subpath (param "WR${i}")))`));
52
+ metaLiterals.forEach((_, i) => lines.push(`(allow file-read-metadata (literal (param "META${i}")))`));
53
+ lines.push('(allow file-write* (literal "/dev/null"))');
54
+ }
55
+ // Read denial (CORE-003). SBPL is last-match-wins, so these deny rules MUST
56
+ // come after every read allow.
57
+ denyRead.forEach((_, i) => lines.push(`(deny file-read* (subpath (param "DENY${i}")))`));
58
+ lines.push('(allow file-write* (subpath (param "ROOT")))');
59
+ writeRoots.forEach((_, i) => lines.push(`(allow file-write* (subpath (param "WR${i}")))`));
60
+ if (allowNetwork) lines.push('(allow network*)');
61
+ // Mediated network (X-504): the only reachable endpoint is the runner's own
62
+ // loopback proxy port; everything else, including every other loopback port
63
+ // and DNS, stays denied by the default rule.
64
+ else if (Number.isInteger(proxyPort) && proxyPort > 0 && proxyPort < 65536) {
65
+ lines.push(`(allow network-outbound (remote ip "localhost:${proxyPort}"))`);
66
+ }
67
+ return lines.join('\n');
68
+ }
69
+
70
+ function _realOrResolved(p) {
71
+ let real = path.resolve(p);
72
+ try { real = fs.realpathSync(real); } catch { /* not present: use the literal path */ }
73
+ return real;
74
+ }
75
+
76
+ function _ancestors(p) {
77
+ const out = [];
78
+ let cur = path.dirname(p);
79
+ while (cur && cur !== path.dirname(cur)) { out.push(cur); cur = path.dirname(cur); }
80
+ return out;
35
81
  }
36
82
 
37
83
  /**
@@ -46,6 +92,10 @@ export function buildUserspaceInvocation(argv, {
46
92
  limits = {},
47
93
  env = {},
48
94
  denyReadPaths = [],
95
+ readRoots = null,
96
+ writeRoots = [],
97
+ networkProxyPort = null,
98
+ cwd = null,
49
99
  } = {}) {
50
100
  if (!root) return { error: errorResult('userspace', 'runUserspace requires a sandbox root') };
51
101
 
@@ -70,6 +120,27 @@ export function buildUserspaceInvocation(argv, {
70
120
  if (!denyRead.includes(real)) denyRead.push(real);
71
121
  }
72
122
 
123
+ // Capability mode (X-502/X-504). `readRoots` non-null confines reads to the
124
+ // baseline plus those roots; `writeRoots` adds writable subtrees beyond
125
+ // `root`; `networkProxyPort` opens exactly one loopback port. All of them are
126
+ // absent for every pre-existing caller, whose profile is unchanged.
127
+ const confineReads = Array.isArray(readRoots);
128
+ const rd = confineReads ? [...new Set(readRoots.filter((p) => typeof p === 'string' && p).map(_realOrResolved))] : [];
129
+ const wr = [...new Set((writeRoots || []).filter((p) => typeof p === 'string' && p).map(_realOrResolved))];
130
+ let resolvedCwd = resolvedRoot;
131
+ if (cwd) {
132
+ resolvedCwd = _realOrResolved(cwd);
133
+ try { if (!fs.statSync(resolvedCwd).isDirectory()) throw new Error('not a directory'); } catch (e) {
134
+ return { error: errorResult('userspace', `working directory is not usable: ${e.message}`) };
135
+ }
136
+ }
137
+ const metaSet = new Set();
138
+ if (confineReads) {
139
+ for (const p of [resolvedRoot, resolvedCwd, ...rd, ...wr]) for (const a of _ancestors(p)) metaSet.add(a);
140
+ for (const b of BASELINE_META_LITERALS) metaSet.delete(b);
141
+ }
142
+ const metaLiterals = [...metaSet].sort();
143
+
73
144
  const effectiveLimits = {
74
145
  ...limits,
75
146
  maxProcs: limits.maxProcs ?? ambientRelativeMaxProcs(),
@@ -85,12 +156,15 @@ export function buildUserspaceInvocation(argv, {
85
156
  return {
86
157
  bin,
87
158
  args: [
88
- '-p', _profile({ allowNetwork, denyRead }),
159
+ '-p', _profile({ allowNetwork, proxyPort: networkProxyPort, denyRead, confineReads, readRoots: rd, writeRoots: wr, metaLiterals }),
89
160
  '-D', `ROOT=${resolvedRoot}`,
90
161
  ...denyRead.flatMap((p, i) => ['-D', `DENY${i}=${p}`]),
162
+ ...rd.flatMap((p, i) => ['-D', `RD${i}=${p}`]),
163
+ ...wr.flatMap((p, i) => ['-D', `WR${i}=${p}`]),
164
+ ...metaLiterals.flatMap((p, i) => ['-D', `META${i}=${p}`]),
91
165
  '/bin/sh', '-c', inner, '_sbx', ...argv,
92
166
  ],
93
- cwd: resolvedRoot,
167
+ cwd: resolvedCwd,
94
168
  env: buildConfinedEnv({ root: resolvedRoot, env }),
95
169
  unsupported,
96
170
  };
@@ -104,10 +178,14 @@ export function runUserspace(argv, {
104
178
  env = {},
105
179
  maxBuffer = 8 * 1024 * 1024,
106
180
  denyReadPaths = [],
181
+ readRoots = null,
182
+ writeRoots = [],
183
+ networkProxyPort = null,
184
+ cwd = null,
107
185
  } = {}) {
108
186
  // Documented shape, never a throw: a caller that wraps this in try/catch and
109
187
  // "falls back" is a classic route to unconfined execution.
110
- const inv = buildUserspaceInvocation(argv, { root, allowNetwork, limits, env, denyReadPaths });
188
+ const inv = buildUserspaceInvocation(argv, { root, allowNetwork, limits, env, denyReadPaths, readRoots, writeRoots, networkProxyPort, cwd });
111
189
  if (inv.error) return inv.error;
112
190
 
113
191
  const r = spawnSync(inv.bin, inv.args, {