@clear-capabilities/agentic-security-scanner 0.161.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 (182) hide show
  1. package/CHANGELOG.md +46 -0
  2. package/bin/agentic-security.js +98 -2
  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 +59 -8
  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 +38 -2
  15. package/dist/8798.index.js +1 -1
  16. package/dist/9207.index.js +5 -0
  17. package/dist/9560.index.js +2336 -27
  18. package/dist/957.index.js +1 -1
  19. package/dist/{1905.index.js → 9999.index.js} +306 -13
  20. package/dist/agentic-security.mjs +12 -12
  21. package/dist/agentic-security.mjs.sha256 +1 -1
  22. package/package.json +39 -7
  23. package/src/capabilities/CLAUDE.md +114 -0
  24. package/src/capabilities/address.js +73 -0
  25. package/src/capabilities/attack-coverage.js +93 -0
  26. package/src/capabilities/decide.js +256 -0
  27. package/src/capabilities/delegate.js +69 -0
  28. package/src/capabilities/egress.js +20 -0
  29. package/src/capabilities/hook-advice.js +50 -0
  30. package/src/capabilities/index.js +32 -0
  31. package/src/capabilities/manifest.js +344 -0
  32. package/src/capabilities/outbound.js +189 -0
  33. package/src/capabilities/paths.js +57 -0
  34. package/src/capabilities/probes.js +211 -0
  35. package/src/capabilities/proxy.js +182 -0
  36. package/src/capabilities/reasons.js +67 -0
  37. package/src/capabilities/receipts.js +356 -0
  38. package/src/capabilities/records.js +58 -0
  39. package/src/capabilities/recovery.js +367 -0
  40. package/src/capabilities/report.js +81 -0
  41. package/src/capabilities/runner.js +236 -0
  42. package/src/capabilities/secrets.js +26 -0
  43. package/src/capabilities/tool-gate.js +119 -0
  44. package/src/capabilities/tool-registry.js +60 -0
  45. package/src/dataflow/CLAUDE.md +29 -0
  46. package/src/dataflow/catalog.js +21 -0
  47. package/src/dataflow/engine.js +20 -0
  48. package/src/dataflow/guard-dominance.js +352 -0
  49. package/src/discovery/CLAUDE.md +18 -0
  50. package/src/discovery/index.js +43 -1
  51. package/src/discovery/lenses.js +18 -0
  52. package/src/discovery/memory.js +7 -5
  53. package/src/engine.js +80 -15
  54. package/src/fix/apply-fix-service.js +33 -3
  55. package/src/integrations/index.js +5 -2
  56. package/src/ir/CLAUDE.md +14 -0
  57. package/src/ir/implicit-handler-params.js +74 -0
  58. package/src/ir/parser-cs.js +20 -0
  59. package/src/ir/parser-go.js +30 -13
  60. package/src/ir/parser-java.js +11 -0
  61. package/src/language/nix-eval-isolation.js +33 -5
  62. package/src/lineage/CLAUDE.md +31 -0
  63. package/src/lineage/deployment/adapter-cloud.js +202 -0
  64. package/src/lineage/deployment/adapter-compose.js +90 -0
  65. package/src/lineage/deployment/adapter-kit.js +142 -0
  66. package/src/lineage/deployment/adapter-kubernetes.js +247 -0
  67. package/src/lineage/deployment/attack-paths.js +443 -0
  68. package/src/lineage/deployment/boundaries-run.js +223 -0
  69. package/src/lineage/deployment/boundary-graph.js +567 -0
  70. package/src/lineage/deployment/cross-repo.js +253 -0
  71. package/src/lineage/deployment/drift.js +383 -0
  72. package/src/lineage/deployment/ingest.js +221 -0
  73. package/src/lineage/deployment/projection.js +244 -0
  74. package/src/lineage/deployment/trace-correlation.js +541 -0
  75. package/src/lineage/source-registry.js +19 -0
  76. package/src/mcp/CLAUDE.md +14 -3
  77. package/src/mcp/invariant-tools.js +64 -0
  78. package/src/mcp/portfolio-tools.js +79 -0
  79. package/src/mcp/redact.js +15 -0
  80. package/src/mcp/server.js +24 -3
  81. package/src/mcp/tools.js +30 -5
  82. package/src/posture/CLAUDE.md +505 -0
  83. package/src/posture/accuracy-scorecard.js +95 -4
  84. package/src/posture/assurance/baseline-inventory.js +218 -0
  85. package/src/posture/assurance/baseline.js +322 -0
  86. package/src/posture/assurance/bounded-io.js +145 -0
  87. package/src/posture/assurance/config.js +299 -0
  88. package/src/posture/assurance/contracts.js +315 -0
  89. package/src/posture/assurance/identity.js +62 -0
  90. package/src/posture/assurance/migrations.js +259 -0
  91. package/src/posture/assurance/rollback.js +214 -0
  92. package/src/posture/assurance/rollout.js +200 -0
  93. package/src/posture/assurance/schema-kit.js +111 -0
  94. package/src/posture/assurance/verification-record.js +258 -0
  95. package/src/posture/attestation.js +5 -0
  96. package/src/posture/autopilot.js +28 -1
  97. package/src/posture/business-logic.js +26 -4
  98. package/src/posture/evaluation/bakeoff.js +148 -0
  99. package/src/posture/evaluation/custody.js +200 -0
  100. package/src/posture/evaluation/deployment-ablation.js +416 -0
  101. package/src/posture/evaluation/dev-cases.js +65 -0
  102. package/src/posture/evaluation/economics.js +85 -0
  103. package/src/posture/evaluation/gates.js +161 -0
  104. package/src/posture/evaluation/grouping.js +101 -0
  105. package/src/posture/evaluation/interval.js +132 -0
  106. package/src/posture/evaluation/invariant-ablation.js +325 -0
  107. package/src/posture/evaluation/labels.js +200 -0
  108. package/src/posture/evaluation/protocol.js +308 -0
  109. package/src/posture/evaluation/release-gate.js +209 -0
  110. package/src/posture/evaluation/report.js +158 -0
  111. package/src/posture/evaluation/runner.js +273 -0
  112. package/src/posture/evaluation/scan-child.js +80 -0
  113. package/src/posture/evaluation/score.js +174 -0
  114. package/src/posture/evaluation/synthetic.js +90 -0
  115. package/src/posture/evaluation/why-missed.js +219 -0
  116. package/src/posture/evidence-bundle.js +93 -7
  117. package/src/posture/execution-proof.js +11 -1
  118. package/src/posture/fix-verify-loop.js +20 -6
  119. package/src/posture/fix-verify.js +37 -2
  120. package/src/posture/fleet.js +11 -2
  121. package/src/posture/invariants/cli.js +95 -0
  122. package/src/posture/invariants/coverage.js +164 -0
  123. package/src/posture/invariants/export.js +178 -0
  124. package/src/posture/invariants/expressions.js +46 -0
  125. package/src/posture/invariants/lifecycle.js +289 -0
  126. package/src/posture/invariants/project-input.js +55 -0
  127. package/src/posture/invariants/repair.js +340 -0
  128. package/src/posture/invariants/run.js +45 -0
  129. package/src/posture/invariants/scenarios.js +311 -0
  130. package/src/posture/invariants/schema.js +167 -0
  131. package/src/posture/invariants/shrink.js +407 -0
  132. package/src/posture/invariants/state-assertions.js +165 -0
  133. package/src/posture/logic-claims.js +12 -0
  134. package/src/posture/model-routing.js +40 -0
  135. package/src/posture/oracles/adapters.js +531 -0
  136. package/src/posture/oracles/conformance.js +276 -0
  137. package/src/posture/oracles/oracle.js +453 -0
  138. package/src/posture/oracles/registry.js +76 -0
  139. package/src/posture/oracles/scenario-classes.js +223 -0
  140. package/src/posture/portfolio/backend.js +279 -0
  141. package/src/posture/portfolio/bundle.js +314 -0
  142. package/src/posture/portfolio/cli.js +142 -0
  143. package/src/posture/portfolio/manifest.js +263 -0
  144. package/src/posture/portfolio/progress.js +267 -0
  145. package/src/posture/portfolio/resume.js +130 -0
  146. package/src/posture/portfolio/retention.js +207 -0
  147. package/src/posture/portfolio/scheduler.js +488 -0
  148. package/src/posture/portfolio/signing.js +155 -0
  149. package/src/posture/portfolio/wording.js +59 -0
  150. package/src/posture/portfolio/work-units.js +405 -0
  151. package/src/posture/replay/replay.js +250 -0
  152. package/src/posture/routing/adapter.js +140 -0
  153. package/src/posture/routing/calibration.js +278 -0
  154. package/src/posture/routing/canary.js +141 -0
  155. package/src/posture/routing/control.js +35 -0
  156. package/src/posture/routing/decide.js +211 -0
  157. package/src/posture/routing/drift.js +137 -0
  158. package/src/posture/routing/economics.js +185 -0
  159. package/src/posture/routing/feedback.js +207 -0
  160. package/src/posture/routing/index.js +31 -0
  161. package/src/posture/routing/outcomes.js +281 -0
  162. package/src/posture/routing/promotion.js +291 -0
  163. package/src/posture/routing/receipts.js +59 -0
  164. package/src/posture/routing/report.js +142 -0
  165. package/src/posture/routing/shadow.js +53 -0
  166. package/src/posture/specification-mining.js +26 -0
  167. package/src/posture/verification/advisory-state.js +71 -0
  168. package/src/posture/verification/emit.js +257 -0
  169. package/src/posture/verification/hypothesis-promotion.js +211 -0
  170. package/src/posture/verification/patch-negative.js +226 -0
  171. package/src/posture/verification/patch-promotion.js +113 -0
  172. package/src/posture/verification/projection.js +183 -0
  173. package/src/posture/verification/repair-records.js +124 -0
  174. package/src/posture/verifier.js +16 -0
  175. package/src/report/index.js +100 -1
  176. package/src/sandbox/CLAUDE.md +51 -0
  177. package/src/sandbox/backend-namespace.js +7 -0
  178. package/src/sandbox/backend-userspace.js +151 -34
  179. package/src/sandbox/control-probes.js +215 -0
  180. package/src/sandbox/supervise.js +201 -0
  181. package/src/sandbox/trust-boundary.js +113 -0
  182. package/src/sandbox/trust-domains.js +171 -0
@@ -0,0 +1,124 @@
1
+ // Separate records for patch application, rejection and rollback (X-204.AC03).
2
+ //
3
+ // The CORE-002 verification record carries ONE repair status (`none`, `proposed`, `applied`, `replay-verified`,
4
+ // `rolled-back`). That field answers "where is this repair now"; it cannot also be the history, and it has no value for a
5
+ // rejection. So the history is kept here as an append-only ledger of small records, one per event, each with its own id and
6
+ // a link to the record before it. A rollback is therefore a NEW record after an application, not an edit that erases it, and
7
+ // a rejection is a record of its own, not a status that vanishes when the patch is dropped.
8
+ //
9
+ // Rules the ledger enforces, each with a test (the state is per hypothesis and exact diff):
10
+ // proposed first event for a diff.
11
+ // rejected from proposed or promoted. Needs a reason; carries the incomplete step when verification caused it. Terminal.
12
+ // promoted from proposed, and ONLY through `recordPromotion`, which accepts nothing but a result `promotePatch` itself
13
+ // returned `ok: true` for. The generic append refuses this kind, so a caller cannot write a promotion by hand.
14
+ // applied from promoted only: nothing is applied that was not promoted for that exact diff.
15
+ // rolled-back from applied or promoted (a partial application that was undone). Terminal.
16
+ // `verificationRepairFor` maps the ledger onto the CORE-002 repair field; a rejection has no value there, so the record keeps
17
+ // `proposed` and the ledger holds the rejection (the enum is a foundation contract this change does not widen).
18
+ import { semanticId } from '../assurance/identity.js';
19
+ import { buildVerificationRecord, validateVerificationRecord } from '../assurance/verification-record.js';
20
+ import { isCommit, isDigest, isPlainObject } from '../assurance/schema-kit.js';
21
+ import { isIssuedPromotion } from './patch-promotion.js';
22
+
23
+ const REPAIR_RECORD_SCHEMA = 'agentic-security/repair-record';
24
+ const REPAIR_RECORD_VERSION = '1.0.0';
25
+ const REPAIR_KINDS = Object.freeze(['proposed', 'rejected', 'promoted', 'applied', 'rolled-back']);
26
+
27
+ const ID_FIELDS = ['kind', 'hypothesisId', 'revision', 'diffDigest', 'reason', 'step', 'receiptDigests', 'verificationRecordId', 'previous'];
28
+ const TRANSITIONS = Object.freeze({
29
+ proposed: [null],
30
+ rejected: ['proposed', 'promoted'],
31
+ promoted: ['proposed'],
32
+ applied: ['promoted'],
33
+ 'rolled-back': ['applied', 'promoted'],
34
+ });
35
+
36
+ function build(fields) {
37
+ const rec = {
38
+ schema: REPAIR_RECORD_SCHEMA, schemaVersion: REPAIR_RECORD_VERSION,
39
+ kind: fields.kind, hypothesisId: fields.hypothesisId, revision: fields.revision ?? null, diffDigest: fields.diffDigest,
40
+ reason: fields.reason ?? null, step: fields.step ?? null, receiptDigests: fields.receiptDigests ?? null,
41
+ verificationRecordId: fields.verificationRecordId ?? null, previous: fields.previous ?? null,
42
+ };
43
+ rec.id = semanticId('rrec', rec, ID_FIELDS);
44
+ return Object.freeze(rec);
45
+ }
46
+
47
+ /** The latest event kind recorded for a hypothesis and exact diff, or null. */
48
+ function stateOf(records, hypothesisId, diffDigest) {
49
+ for (let i = records.length - 1; i >= 0; i--) {
50
+ if (records[i].hypothesisId === hypothesisId && records[i].diffDigest === diffDigest) return records[i];
51
+ }
52
+ return null;
53
+ }
54
+
55
+ function check(records, fields) {
56
+ const errs = [];
57
+ if (!REPAIR_KINDS.includes(fields.kind)) errs.push(`unknown repair kind '${fields.kind}'`);
58
+ if (typeof fields.hypothesisId !== 'string' || !fields.hypothesisId) errs.push('hypothesisId is required');
59
+ if (!isDigest(fields.diffDigest)) errs.push('diffDigest must be sha256:<64 hex>');
60
+ if (fields.revision !== undefined && fields.revision !== null && !isCommit(fields.revision)) errs.push('revision must be an exact commit or null');
61
+ if (['rejected', 'rolled-back'].includes(fields.kind) && !(typeof fields.reason === 'string' && fields.reason.trim())) errs.push(`a '${fields.kind}' record needs a reason`);
62
+ if (errs.length) return errs;
63
+ const last = stateOf(records, fields.hypothesisId, fields.diffDigest);
64
+ const from = last ? last.kind : null;
65
+ if (!TRANSITIONS[fields.kind].includes(from)) errs.push(`'${fields.kind}' cannot follow ${from ? `'${from}'` : 'no earlier record'} for this diff`);
66
+ return errs;
67
+ }
68
+
69
+ /**
70
+ * Append one event. Returns a NEW list (the input is never changed) and the record. `promoted` is refused here: use
71
+ * `recordPromotion`. Never throws.
72
+ * @returns {{ ok: boolean, records: object[], record: object|null, errors: string[] }}
73
+ */
74
+ export function appendRepairRecord(records, fields) {
75
+ const list = Array.isArray(records) ? records : [];
76
+ if (!isPlainObject(fields)) return { ok: false, records: list, record: null, errors: ['fields must be an object'] };
77
+ if (fields.kind === 'promoted') return { ok: false, records: list, record: null, errors: ['a promotion is recorded only through recordPromotion, from a promotePatch result'] };
78
+ const errors = check(list, fields);
79
+ if (errors.length) return { ok: false, records: list, record: null, errors };
80
+ const last = stateOf(list, fields.hypothesisId, fields.diffDigest);
81
+ const record = build({ ...fields, previous: last ? last.id : null });
82
+ return { ok: true, records: [...list, record], record, errors: [] };
83
+ }
84
+
85
+ /** Record a promotion. Accepts only a result `promotePatch` returned with `ok: true`. */
86
+ export function recordPromotion(records, promotion, { verificationRecordId = null } = {}) {
87
+ const list = Array.isArray(records) ? records : [];
88
+ if (!isIssuedPromotion(promotion)) return { ok: false, records: list, record: null, errors: ['not a promotion issued by promotePatch'] };
89
+ const fields = {
90
+ kind: 'promoted', hypothesisId: promotion.hypothesisId, revision: promotion.revision, diffDigest: promotion.diffDigest,
91
+ reason: 'independent verifier receipts match the exact proposed diff and revision', receiptDigests: promotion.receiptDigests, verificationRecordId,
92
+ };
93
+ const errors = check(list, fields);
94
+ if (errors.length) return { ok: false, records: list, record: null, errors };
95
+ const last = stateOf(list, fields.hypothesisId, fields.diffDigest);
96
+ const record = build({ ...fields, previous: last ? last.id : null });
97
+ return { ok: true, records: [...list, record], record, errors: [] };
98
+ }
99
+
100
+ /** The current CORE-002 `repair` object for a diff, derived from the ledger. */
101
+ export function verificationRepairFor(records, hypothesisId, diffDigest, patchedRecordId = null) {
102
+ const last = stateOf(Array.isArray(records) ? records : [], hypothesisId, diffDigest);
103
+ if (!last) return { status: 'none' };
104
+ if (last.kind === 'applied') return { status: 'applied', patchDigest: diffDigest };
105
+ if (last.kind === 'rolled-back') return { status: 'rolled-back', patchDigest: diffDigest };
106
+ if (last.kind === 'promoted' && typeof patchedRecordId === 'string' && patchedRecordId.startsWith('vrec:')) {
107
+ return { status: 'replay-verified', patchDigest: diffDigest, replayRecordId: patchedRecordId };
108
+ }
109
+ return { status: 'proposed', patchDigest: diffDigest };
110
+ }
111
+
112
+ /**
113
+ * A copy of a verification record with its repair status replaced, rebuilt and re-validated (the id is recomputed, the
114
+ * confirmation level stays what its evidence supports). Returns null if the result is not a valid record.
115
+ */
116
+ export function withRepairStatus(record, repair) {
117
+ if (!isPlainObject(record)) return null;
118
+ const rebuilt = buildVerificationRecord({
119
+ hypothesisId: record.hypothesisId, commit: record.commit, detectorOrigin: record.detectorOrigin, oracle: record.oracle,
120
+ attempt: record.attempt, outcome: record.outcome, reason: record.reason, evidence: record.evidence, evidenceRefs: record.evidenceRefs,
121
+ scope: record.scope, preconditions: record.preconditions, repair,
122
+ });
123
+ return validateVerificationRecord(rebuilt).ok ? rebuilt : null;
124
+ }
@@ -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) {
@@ -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`
@@ -12,6 +13,13 @@ import { applyLegacyCompat, legacyFieldDeprecationNotice } from '../pipeline/leg
12
13
  // (sca/sigstore-verify.js's SLSA/Sigstore build attestation). Three unrelated
13
14
  // things, three distinct keys — do not collapse them.
14
15
  import { redactFindingProvenance, sanitizeForTerminal } from '../posture/provenance/schema.js';
16
+ // X-206: the one projection of the verification record. This report, the MCP tools and the autopilot response all call it.
17
+ import { verificationFields, verificationCoverage } from '../posture/verification/projection.js';
18
+ import { invariantCoverageFields } from '../posture/invariants/coverage.js';
19
+ // X-207: a hunt hypothesis is advisory and is never a finding in any gating output; promotion is the only way across.
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';
15
23
  // Re-exported: FR-PROV-026. Lives in provenance/schema.js (shared with
16
24
  // posture/auditor-walkthrough.js, a second CLI renderer of the same
17
25
  // untrusted fields — see that module's header for why it lives there
@@ -222,7 +230,11 @@ export function normalizeFindings(scan){
222
230
  });
223
231
  return true;
224
232
  };
233
+ const advisoryExcluded = [];
225
234
  for (const f of (scan.findings||[])) {
235
+ // X-207.AC01: an unpromoted hunt hypothesis cannot gate. It is excluded here, the one place every output format and every
236
+ // exit code derives from, and the exclusion is recorded (never silent) as `advisoryExcluded`.
237
+ 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
238
  if (suppress(f.vuln || f.type, f.file, f.line || f.source?.line || 0, f.snippet)) continue;
227
239
  out.push({
228
240
  id: f.id || fingerprint(f),
@@ -328,6 +340,16 @@ export function normalizeFindings(scan){
328
340
  // a finding with no proof backing simply omits these fields.
329
341
  ...(f.proofTier !== undefined ? { proofTier: f.proofTier } : {}),
330
342
  ...(f.proofEvidence !== undefined ? { proofEvidence: f.proofEvidence } : {}),
343
+ // X-206: the version-1 verification record and its shared projection, ADDITIVE and present only when the finding carries
344
+ // a record (a plain scan output is byte-identical). `verificationReplay` is the typed prerequisite list of a replay attempt.
345
+ ...(f.verificationRecord && typeof f.verificationRecord === 'object'
346
+ ? { verificationRecord: f.verificationRecord, ...(f.verificationReplay ? { verificationReplay: f.verificationReplay } : {}), ...verificationFields(f.verificationRecord, { replay: f.verificationReplay }) }
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
+ : {}),
331
353
  // Phase-1 next-gen P1.3 (FR-UX-1, FR-UX-2): calibrated probability +
332
354
  // 95% Wilson CI + sample size. Null when N < MIN_SAMPLES_FOR_CALIBRATION
333
355
  // for this family; `calibration_reason` explains why.
@@ -587,6 +609,7 @@ export function normalizeFindings(scan){
587
609
  // all derive from this one function) — one place, covers every output
588
610
  // format, matches this function's own role as "the canonical shape".
589
611
  for (const f of out) applyLegacyCompat(f);
612
+ if (advisoryExcluded.length) scan.advisoryExcluded = advisoryExcluded; else delete scan.advisoryExcluded;
590
613
 
591
614
  // Sort by severity tier, then within a tier by EPSS percentile (desc) so that
592
615
  // CVEs with active in-the-wild abuse float above theoretical CVEs.
@@ -734,6 +757,15 @@ export function toJSON(scan, meta={}, opts={}){
734
757
  // hand-built `scan` object in a test), same convention as the S7 fields
735
758
  // above; a real scan always computes it.
736
759
  scanHealth: scan.scanHealth || null,
760
+ // X-207: advisory hypotheses a caller put in the finding list; excluded from `findings`, listed here so it is never silent.
761
+ ...(Array.isArray(scan.advisoryExcluded) && scan.advisoryExcluded.length ? { advisoryExcluded: scan.advisoryExcluded } : {}),
762
+ // X-206: trusted-negative accounting over the findings that carry a verification record. Absent when none does, so a plain
763
+ // scan's output is unchanged.
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),
737
769
  };
738
770
  if (opts.includeSuppressed) out.suppressed = scan.suppressions||[];
739
771
  return out;
@@ -903,6 +935,25 @@ function _mdFenceLen(text) {
903
935
  return Math.max(3, ...runs.map(r => r.length + 1));
904
936
  }
905
937
 
938
+ /**
939
+ * X-206: the human-readable verification block for a normalized finding, from the SAME projection the JSON and MCP surfaces
940
+ * carry (`finding.verification.text`). Null when the finding has no verification record. Terminal control characters are stripped.
941
+ */
942
+ export function verificationBlock(f) {
943
+ const text = f?.verificationView?.text;
944
+ // sanitizeForTerminal trims, so the line's own indentation is restored after it strips control characters.
945
+ return Array.isArray(text) && text.length ? text.map((l) => String(l).match(/^ */)[0] + sanitizeForTerminal(String(l))) : null;
946
+ }
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
+
906
957
  export function toMarkdown(scan, meta={}){
907
958
  const findings = normalizeFindings(scan);
908
959
  const lines = ['# Agentic Security — Scan Report', ''];
@@ -935,6 +986,40 @@ export function toMarkdown(scan, meta={}){
935
986
  lines.push(`| \`${f.file}:${f.line}\` | ${f.vuln} | ${f.cwe||'—'} | ${epss} | ${fix.replace(/\|/g,'\\|').slice(0,140)} |`);
936
987
  }
937
988
  }
989
+ // X-206: verification blocks come from the shared projection, never a second renderer.
990
+ const withVerification = bySev[sev].filter(f => verificationBlock(f));
991
+ if (withVerification.length) {
992
+ lines.push('');
993
+ lines.push('<details><summary>Verification</summary>');
994
+ lines.push('');
995
+ for (const f of withVerification) {
996
+ const block = verificationBlock(f).join('\n');
997
+ lines.push(`**\`${f.file}:${f.line}\`**: ${f.vuln}`);
998
+ const fence = '`'.repeat(_mdFenceLen(block));
999
+ lines.push(fence);
1000
+ lines.push(block);
1001
+ lines.push(fence);
1002
+ lines.push('');
1003
+ }
1004
+ lines.push('</details>');
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
+ }
938
1023
  // FR-PROV-018: one provenance block per finding that has one, reusing
939
1024
  // explainProvenance's content — never a second, divergent renderer.
940
1025
  const withProvenance = bySev[sev].filter(f => f.findingProvenance);
@@ -1432,6 +1517,10 @@ export function toCLI(scan, { verbose=false, color=true, provenance=false }={}){
1432
1517
  if (ex.how) lines.push(` ${c('how:', DIM)} ${ex.how}`);
1433
1518
  if (ex.fix) lines.push(` ${c('fix:', DIM)} ${ex.fix}`);
1434
1519
  if (ex.fixCode) for (const ln of ex.fixCode.split('\n').slice(0, 6)) lines.push(` ${c(ln, DIM)}`);
1520
+ const vblock = verificationBlock(f);
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)}`);
1435
1524
  if (provenance) {
1436
1525
  // `f` here is already normalized, so its findingProvenance has been
1437
1526
  // through redactFindingProvenance — the email is gone before it can
@@ -1443,6 +1532,9 @@ export function toCLI(scan, { verbose=false, color=true, provenance=false }={}){
1443
1532
  lines.push('');
1444
1533
  const counts = { critical: 0, high: 0, medium: 0, low: 0, info: 0 };
1445
1534
  for (const f of findings) counts[f.severity] = (counts[f.severity]||0) + 1;
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); }
1446
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}`);
1447
1539
  return lines.join('\n');
1448
1540
  }
@@ -1567,7 +1659,14 @@ export function toShipVerdict(scan, options = {}) {
1567
1659
  const clean = actionable.length === 0 && !scanIncomplete;
1568
1660
  lines.push(clean ? approveFace({ color }) : alertFace({ color }));
1569
1661
  lines.push(bar);
1570
- 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) {
1571
1670
  lines.push(c(' ✅ Safe to deploy', SEV_COLOR.low + BOLD));
1572
1671
  } else if (actionable.length === 0) {
1573
1672
  lines.push(c(' ⚠️ Scan incomplete — cannot confirm safe to deploy', SEV_COLOR.high + BOLD));
@@ -347,6 +347,57 @@ 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
+
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
+
350
401
  ## Extending this module
351
402
 
352
403
  - 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,192 @@
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
- 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 = [
22
36
  '(version 1)',
23
37
  '(deny default)',
24
38
  '(allow process-exec process-fork)',
25
39
  '(allow sysctl-read)',
26
- '(allow file-read*)',
27
- '(allow file-write* (subpath (param "ROOT")))',
28
- allowNetwork ? '(allow network*)' : '',
29
- ].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');
30
68
  }
31
69
 
32
- export function runUserspace(argv, {
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;
81
+ }
82
+
83
+ /**
84
+ * Build the exact spawn invocation for a userspace-confined command, without
85
+ * running it. Shared by the synchronous runner below and the supervised
86
+ * (process-tree-killing) runner, so both are confined by the SAME profile.
87
+ * Returns `{ error }` in the documented error shape instead of throwing.
88
+ */
89
+ export function buildUserspaceInvocation(argv, {
33
90
  root,
34
- timeoutMs = 10000,
35
91
  allowNetwork = false,
36
92
  limits = {},
37
93
  env = {},
38
- maxBuffer = 8 * 1024 * 1024,
94
+ denyReadPaths = [],
95
+ readRoots = null,
96
+ writeRoots = [],
97
+ networkProxyPort = null,
98
+ cwd = null,
39
99
  } = {}) {
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');
100
+ if (!root) return { error: errorResult('userspace', 'runUserspace requires a sandbox root') };
43
101
 
44
102
  const bin = resolveUserspaceBin();
45
- if (!bin) return errorResult('userspace', 'no userspace confinement binary found on this host');
103
+ if (!bin) return { error: errorResult('userspace', 'no userspace confinement binary found on this host') };
46
104
 
47
105
  let resolvedRoot;
48
106
  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
107
  resolvedRoot = fs.realpathSync(root);
52
108
  } catch (e) {
53
- return errorResult('userspace', `sandbox root is not usable: ${e.message}`);
109
+ return { error: errorResult('userspace', `sandbox root is not usable: ${e.message}`) };
110
+ }
111
+
112
+ // Resolve each denied path as far as it exists (a symlink would otherwise
113
+ // let the same file be reached by a path the profile does not name). A path
114
+ // that does not exist yet is denied by its literal text.
115
+ const denyRead = [];
116
+ for (const p of denyReadPaths || []) {
117
+ if (typeof p !== 'string' || !p) continue;
118
+ let real = path.resolve(p);
119
+ try { real = fs.realpathSync(real); } catch { /* not present: deny the literal path */ }
120
+ if (!denyRead.includes(real)) denyRead.push(real);
121
+ }
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
+ }
54
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();
55
143
 
56
144
  const effectiveLimits = {
57
145
  ...limits,
58
146
  maxProcs: limits.maxProcs ?? ambientRelativeMaxProcs(),
59
147
  };
60
-
61
148
  let prelude, unsupported;
62
149
  try {
63
150
  ({ prelude, unsupported } = buildLimitPrelude(effectiveLimits));
64
151
  } catch (e) {
65
- return errorResult('userspace', `invalid resource limit: ${e.message}`);
152
+ return { error: errorResult('userspace', `invalid resource limit: ${e.message}`) };
66
153
  }
67
154
  const inner = `${prelude}exec "$@"`;
68
155
 
69
- const r = spawnSync(
156
+ return {
70
157
  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
- );
158
+ args: [
159
+ '-p', _profile({ allowNetwork, proxyPort: networkProxyPort, denyRead, confineReads, readRoots: rd, writeRoots: wr, metaLiterals }),
160
+ '-D', `ROOT=${resolvedRoot}`,
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}`]),
165
+ '/bin/sh', '-c', inner, '_sbx', ...argv,
166
+ ],
167
+ cwd: resolvedCwd,
168
+ env: buildConfinedEnv({ root: resolvedRoot, env }),
169
+ unsupported,
170
+ };
171
+ }
172
+
173
+ export function runUserspace(argv, {
174
+ root,
175
+ timeoutMs = 10000,
176
+ allowNetwork = false,
177
+ limits = {},
178
+ env = {},
179
+ maxBuffer = 8 * 1024 * 1024,
180
+ denyReadPaths = [],
181
+ readRoots = null,
182
+ writeRoots = [],
183
+ networkProxyPort = null,
184
+ cwd = null,
185
+ } = {}) {
186
+ // Documented shape, never a throw: a caller that wraps this in try/catch and
187
+ // "falls back" is a classic route to unconfined execution.
188
+ const inv = buildUserspaceInvocation(argv, { root, allowNetwork, limits, env, denyReadPaths, readRoots, writeRoots, networkProxyPort, cwd });
189
+ if (inv.error) return inv.error;
190
+
191
+ const r = spawnSync(inv.bin, inv.args, {
192
+ encoding: 'utf8',
193
+ timeout: timeoutMs,
194
+ // Match the namespace backend: SIGKILL cannot be ignored, SIGTERM can.
195
+ // A payload that installs a SIGTERM handler would otherwise outlive its
196
+ // own budget while the caller is told it timed out.
197
+ killSignal: 'SIGKILL',
198
+ maxBuffer,
199
+ cwd: inv.cwd,
200
+ env: inv.env,
201
+ });
85
202
 
86
- return buildResult({ backend: 'userspace', spawnResult: r, unsupported });
203
+ return buildResult({ backend: 'userspace', spawnResult: r, unsupported: inv.unsupported });
87
204
  }