@clear-capabilities/agentic-security-scanner 0.162.0 → 0.163.1

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 +34 -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 +30 -0
  44. package/src/dataflow/catalog.js +42 -1
  45. package/src/dataflow/engine.js +67 -2
  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
@@ -0,0 +1,164 @@
1
+ // Bounded business-logic coverage (X-407).
2
+ //
3
+ // "We ran scenarios" is not "the business logic is covered". This turns the contracts a project has and the runs it made into a
4
+ // report that says what was exercised AND what was not, and keeps every gap explicit:
5
+ //
6
+ // inventory approved versus proposed (and rejected, superseded, unrecorded) contracts, judged by the VERIFIED approval ledger:
7
+ // a document that merely claims approval is listed as proposed-by-ledger with the claim noted;
8
+ // exercised which actors, states and declared transitions the executed scenarios actually drove, against what each contract
9
+ // declares;
10
+ // gaps untested transitions, actors and states; the sequence depth the scenarios were bounded to; races a contract
11
+ // class cannot model (and the cooperative-scheduling limit where one was run); scenarios that could not be built
12
+ // or did not settle; and absent contracts (a class with no contract at all, or no contracts whatsoever).
13
+ //
14
+ // It never claims exhaustive correctness: the report states, in a field and in the text, that it describes a bounded sample.
15
+ // A run that did not execute contributes nothing to "exercised": an unsupported or inconclusive scenario is a gap, never
16
+ // coverage.
17
+ //
18
+ // Additive output. `invariantCoverageFields` is the one place a surface (the JSON/CLI report) asks for the fields: it returns
19
+ // `{}` unless the `invariant-scenarios` feature is on AND a coverage object was supplied, so a default scan's report is
20
+ // byte-identical to what it was (pinned in `test/fixtures/invariant-compat/`). The key is `invariantCoverage`.
21
+ import { SCHEMA_VERSION, isPlainObject } from '../assurance/schema-kit.js';
22
+ import { featureStatus, resolveAssuranceConfig } from '../assurance/config.js';
23
+ import { INVARIANT_CLASSES } from './schema.js';
24
+ import { verifyLedger } from './lifecycle.js';
25
+ import { FEATURE, HARD_BOUNDS, KINDS_BY_CLASS } from './scenarios.js';
26
+ import { NOT_EXHAUSTIVE } from './export.js';
27
+
28
+ export const COVERAGE_SCHEMA = 'agentic-security/business-coverage';
29
+ export const GAP_CODES = Object.freeze([
30
+ 'absent-contract', 'unapproved-contract', 'not-executed', 'untested-transition', 'untested-actor', 'untested-state',
31
+ 'bounded-depth', 'unsupported-scenario', 'unsettled-scenario', 'unsupported-race', 'cooperative-scheduling',
32
+ ]);
33
+
34
+ const sorted = (set) => [...set].sort();
35
+
36
+ function declaredStates(inv) {
37
+ const wf = inv.forbidden.find((f) => f.op === 'transition-outside');
38
+ if (wf) return sorted(new Set(wf.allowed.flatMap((t) => [t.from, t.to])));
39
+ return sorted(new Set(inv.resources.map((r) => r.id)));
40
+ }
41
+
42
+ function exercisedStates(inv, steps) {
43
+ const wf = inv.forbidden.find((f) => f.op === 'transition-outside');
44
+ const keyToId = new Map(inv.resources.map((r) => [r.key, r.id]));
45
+ const out = new Set();
46
+ if (wf) {
47
+ const declared = new Set(declaredStates(inv));
48
+ const reached = steps.filter((s) => s.to && declared.has(s.to));
49
+ if (reached.length) out.add(wf.allowed[0].from); // the seeded initial state is where a workflow starts
50
+ for (const s of reached) out.add(s.to);
51
+ } else {
52
+ for (const s of steps) if (s.key && keyToId.has(s.key)) out.add(keyToId.get(s.key));
53
+ }
54
+ return sorted(out);
55
+ }
56
+
57
+ /**
58
+ * @param {object} p
59
+ * @param {Array<{ invariant: object, result?: object }>} p.entries each contract, with the `verifyInvariant` result of its run (or none)
60
+ * @param {object} [p.ledger] the approval ledger; without a verifying one no contract is approved
61
+ * @param {object} [p.signer] ledger signer (test seam)
62
+ * @returns {object} the coverage report (plain data, deterministic: no clock)
63
+ */
64
+ export function businessCoverage({ entries, ledger, signer } = {}) {
65
+ const list = (Array.isArray(entries) ? entries : []).filter((e) => isPlainObject(e) && isPlainObject(e.invariant) && Array.isArray(e.invariant.actors));
66
+ const verified = verifyLedger(ledger, { signer });
67
+ const stateOf = (inv) => (verified.ok ? (verified.states[inv.id] ?? 'unrecorded') : 'unrecorded');
68
+ const gaps = [];
69
+ const gap = (code, detail, extra = {}) => gaps.push({ code, detail, ...extra });
70
+ const invariants = [];
71
+ const inventory = { approved: [], proposed: [], rejected: [], superseded: [], unrecorded: [] };
72
+ let maxDepth = 0; let maxRequests = 0; let truncated = 0;
73
+ const totals = { actors: { declared: 0, exercised: 0 }, transitions: { declared: 0, exercised: 0 }, states: { declared: 0, exercised: 0 } };
74
+
75
+ for (const { invariant: inv, result } of list) {
76
+ const state = stateOf(inv);
77
+ const ref = { id: inv.id, key: inv.key, revision: inv.revision, class: inv.class };
78
+ inventory[state === 'approved' ? 'approved' : state === 'proposed' ? 'proposed' : state === 'rejected' ? 'rejected' : state === 'superseded' ? 'superseded' : 'unrecorded'].push(ref);
79
+ const claimsApproval = inv.review?.state === 'approved' && state !== 'approved';
80
+
81
+ const runs = Array.isArray(result?.results) ? result.results : [];
82
+ const executed = runs.filter((r) => r.status === 'completed');
83
+ const decided = executed.filter((r) => r.outcome === 'confirmed' || r.outcome === 'refuted');
84
+ const steps = executed.flatMap((r) => [...(r.exercise?.control || []), ...(r.exercise?.attack || [])]);
85
+ for (const r of executed) {
86
+ maxDepth = Math.max(maxDepth, r.limits?.depth ?? 0); maxRequests = Math.max(maxRequests, r.limits?.requests ?? 0);
87
+ if (r.limits?.truncated) truncated++;
88
+ }
89
+
90
+ const actorsDeclared = sorted(new Set(inv.actors.map((a) => a.id)));
91
+ const actorsExercised = sorted(new Set(steps.map((s) => s.actor).filter((a) => actorsDeclared.includes(a))));
92
+ const keyOf = new Map(inv.resources.map((r) => [r.id, r.key]));
93
+ const transitionsDeclared = inv.transitions.map((t) => t.id);
94
+ const transitionsExercised = inv.transitions.filter((t) => steps.some((s) => t.actors.includes(s.actor) && s.action === t.action && s.key === keyOf.get(t.resource))).map((t) => t.id);
95
+ const statesDeclared = declaredStates(inv);
96
+ const statesExercised = exercisedStates(inv, steps);
97
+
98
+ const mine = { id: inv.id, key: inv.key };
99
+ if (!executed.length) {
100
+ gap('not-executed', `no scenario for '${inv.key}' was executed${result?.status && result.status !== 'ok' ? ` (${result.status}: ${result.reason ?? 'no reason given'})` : ''}`, mine);
101
+ } else {
102
+ for (const t of transitionsDeclared.filter((x) => !transitionsExercised.includes(x))) gap('untested-transition', `declared transition '${t}' of '${inv.key}' was not exercised by any executed scenario`, { ...mine, transition: t });
103
+ for (const a of actorsDeclared.filter((x) => !actorsExercised.includes(x))) gap('untested-actor', `declared actor '${a}' of '${inv.key}' performed no step in any executed scenario`, { ...mine, actor: a });
104
+ for (const s of statesDeclared.filter((x) => !statesExercised.includes(x))) gap('untested-state', `declared state '${s}' of '${inv.key}' was not reached by any executed scenario`, { ...mine, state: s });
105
+ }
106
+ for (const u of result?.unsupported || []) gap('unsupported-scenario', `${u.kind ?? 'scenario'} for '${inv.key}' could not be built: ${u.reason}`, { ...mine, kind: u.kind ?? null });
107
+ for (const r of runs.filter((x) => x.status !== 'completed' || !['confirmed', 'refuted'].includes(x.outcome))) {
108
+ gap('unsettled-scenario', `${r.kind} for '${inv.key}' did not settle (${r.status === 'completed' ? `outcome '${r.outcome}'` : `status '${r.status}'`})${r.reason ? `: ${String(r.reason).slice(0, 160)}` : ''}`, { ...mine, kind: r.kind });
109
+ }
110
+ // races: a class with no concurrent family cannot be race-tested here; one that has it needs an executed, settled run
111
+ const hasRaceFamily = (KINDS_BY_CLASS[inv.class] || []).includes('concurrent-operations');
112
+ const raceRuns = decided.filter((r) => r.kind === 'concurrent-operations');
113
+ if (!hasRaceFamily) gap('unsupported-race', `no concurrent-operations scenario exists for the '${inv.class}' class, so races against '${inv.key}' are not tested`, mine);
114
+ else if (!raceRuns.length) gap('unsupported-race', `no concurrent-operations scenario for '${inv.key}' settled, so races against it are not tested`, mine);
115
+ else gap('cooperative-scheduling', `races against '${inv.key}' were exercised under cooperative scheduling in one process with a fixed start order; parallel execution and other interleavings are not covered`, mine);
116
+ if (state !== 'approved') gap('unapproved-contract', `'${inv.key}' is ${state}${claimsApproval ? ' (its document claims approval; only the verified ledger counts)' : ''}: a violation of it would be an advisory candidate, not a failure of a requirement`, mine);
117
+
118
+ totals.actors.declared += actorsDeclared.length; totals.actors.exercised += actorsExercised.length;
119
+ totals.transitions.declared += transitionsDeclared.length; totals.transitions.exercised += transitionsExercised.length;
120
+ totals.states.declared += statesDeclared.length; totals.states.exercised += statesExercised.length;
121
+ invariants.push({
122
+ ...ref, name: inv.name, state, claimsApproval,
123
+ declared: { actors: actorsDeclared, transitions: transitionsDeclared, states: statesDeclared },
124
+ exercised: { actors: actorsExercised, transitions: transitionsExercised, states: statesExercised },
125
+ scenarios: { total: runs.length, executed: executed.length, decided: decided.length, unsupported: (result?.unsupported || []).length },
126
+ violations: { approved: runs.filter((r) => r.classification?.kind === 'approved-violation').length, candidate: runs.filter((r) => r.classification?.kind === 'candidate-violation').length },
127
+ });
128
+ }
129
+
130
+ // contracts that do not exist are gaps too
131
+ if (!list.length) gap('absent-contract', 'no contract was supplied: no business-logic behaviour is covered');
132
+ else for (const cls of INVARIANT_CLASSES) {
133
+ if (!list.some((e) => e.invariant.class === cls)) gap('absent-contract', `no contract exists for the '${cls}' class: that kind of behaviour is not covered at all`, { class: cls });
134
+ }
135
+ const executedAny = invariants.some((i) => i.scenarios.executed > 0);
136
+ if (executedAny) gap('bounded-depth', `scenarios were bounded to at most ${maxDepth} attack step(s) and ${maxRequests} request(s) (hard ceilings: depth ${HARD_BOUNDS.depth}, requests ${HARD_BOUNDS.requests})${truncated ? `; ${truncated} scenario(s) were truncated to fit` : ''}; longer sequences are not explored`, { maxDepth, maxRequests, truncated });
137
+
138
+ const counts = Object.fromEntries(Object.entries(inventory).map(([k, v]) => [k, v.length]));
139
+ const lines = [
140
+ `Business-logic coverage (bounded sample, not exhaustive): ${counts.approved} approved, ${counts.proposed} proposed, ${counts.rejected + counts.superseded + counts.unrecorded} other contract(s)`,
141
+ ` Exercised: ${totals.actors.exercised}/${totals.actors.declared} actors, ${totals.states.exercised}/${totals.states.declared} states, ${totals.transitions.exercised}/${totals.transitions.declared} transitions`,
142
+ ` Gaps: ${gaps.length}${gaps.length ? ` (${[...new Set(gaps.map((g) => g.code))].sort().join(', ')})` : ''}`,
143
+ ` ${NOT_EXHAUSTIVE}`,
144
+ ];
145
+ return {
146
+ schema: COVERAGE_SCHEMA, schemaVersion: SCHEMA_VERSION,
147
+ ledger: { verified: verified.ok, note: verified.ok ? 'approval state comes from the verified ledger' : 'no verifying approval ledger: no contract counts as approved' },
148
+ inventory, counts, invariants, totals, gaps,
149
+ bounds: { maxDepth, maxRequests, truncatedScenarios: truncated, hard: HARD_BOUNDS },
150
+ claims: { exhaustive: false, statement: NOT_EXHAUSTIVE },
151
+ lines,
152
+ };
153
+ }
154
+
155
+ /**
156
+ * The fields a report adds for business coverage: `{ invariantCoverage }`, or `{}` when the feature is off or there is nothing to
157
+ * report. ADDITIVE: with the feature off (the default) a report is byte-identical to what it was.
158
+ */
159
+ export function invariantCoverageFields(coverage, { config } = {}) {
160
+ if (!isPlainObject(coverage) || coverage.schema !== COVERAGE_SCHEMA) return {};
161
+ const cfg = config || resolveAssuranceConfig({ env: process.env });
162
+ if (featureStatus(cfg, FEATURE).status !== 'ok') return {};
163
+ return { invariantCoverage: coverage };
164
+ }
@@ -0,0 +1,178 @@
1
+ // Reproducible scenario export, without tenant secrets (X-407.AC03).
2
+ //
3
+ // A reviewer or another tool should be able to take a bounded scenario away, replay it on the same pinned fixture, and get the
4
+ // same verdict. This builds that package from generated scenarios, and keeps three promises:
5
+ //
6
+ // - It carries no tenant secret. The fixture SOURCE is never exported (only its digest: the recipient already holds the
7
+ // fixture, or cannot reproduce anyway); the scenario inputs are scanned for secret-looking keys and token shapes, and a
8
+ // scenario that holds one is WITHHELD with the paths named (never the values), not exported with a hole in it, because a
9
+ // scenario with a value removed is a different scenario and would not replay to the same digest.
10
+ // - It is self-checking. Every scenario carries its content digest and its pinned fixture digest; `verifyScenarioExport`
11
+ // recomputes them (and the replay manifest ids, when a commit was supplied) so an edited export is detected.
12
+ // - It never claims more than it did. The package states that it is a bounded sample of one contract's behaviour, lists the
13
+ // bounds that applied, the scenario families that could not be built, and says plainly that it is not a proof of
14
+ // business-logic correctness.
15
+ //
16
+ // Gate: the `invariant-scenarios` feature, off by default. With it off nothing is built.
17
+ //
18
+ // `findSecrets` is shared with the regression artifact (`repair.js`), which embeds the fixture and the patch and therefore
19
+ // must refuse to embed a credential. It reports WHERE (file and kind), never WHAT. Pattern checks over text: a paraphrased or
20
+ // encoded secret is not caught, and the export is only as clean as the synthetic fixture it was generated from.
21
+ import { digestOf, semanticId } from '../assurance/identity.js';
22
+ import { SCHEMA_VERSION, isPlainObject } from '../assurance/schema-kit.js';
23
+ import { featureStatus, resolveAssuranceConfig } from '../assurance/config.js';
24
+ import { createReplayManifest } from '../replay/replay.js';
25
+ import { generateScenarios, HARD_BOUNDS, FEATURE } from './scenarios.js';
26
+ import { verifyLedger } from './lifecycle.js';
27
+ import { readJsonFile, readFixtureDir } from './project-input.js';
28
+
29
+ export const EXPORT_SCHEMA = 'agentic-security/invariant-scenario-export';
30
+ export const NOT_EXHAUSTIVE = 'This is a bounded sample of one contract on one disposable fixture. A clean result covers only the scenarios listed here; it is not a proof of business-logic correctness.';
31
+
32
+ const SECRET_KEY = /secret|token|passw|api[_-]?key|authorization|cookie|credential|private[_-]?key|ssn/i;
33
+ const SECRET_SHAPES = [
34
+ [/-----BEGIN [A-Z ]*PRIVATE KEY-----/, 'private key block'],
35
+ [/\bAKIA[0-9A-Z]{16}\b/, 'cloud access key id'],
36
+ [/\bgh[pousr]_[A-Za-z0-9]{30,}\b/, 'source host token'],
37
+ [/\bsk-[A-Za-z0-9_-]{10,}/, 'secret key token'],
38
+ [/\bxox[abprs]-[A-Za-z0-9-]{10,}/, 'chat token'],
39
+ [/\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{5,}/, 'signed web token'],
40
+ [/\b(?:password|passwd|secret|api[_-]?key|token|authorization|bearer)\b["']?\s*[:=]\s*["'][^"'\s]{6,}["']/i, 'credential assignment'],
41
+ ];
42
+
43
+ /** Secret-looking content in a map of file name to text. Names the file and the kind; never returns the matched text. */
44
+ export function findSecrets(files) {
45
+ const out = [];
46
+ for (const [file, text] of Object.entries(isPlainObject(files) ? files : {})) {
47
+ if (typeof text !== 'string') continue;
48
+ for (const [re, kind] of SECRET_SHAPES) if (re.test(text)) out.push({ where: file, kind });
49
+ }
50
+ return out;
51
+ }
52
+
53
+ /** Secret-looking keys or values anywhere in a JSON-able value. Returns paths and kinds, never the values. */
54
+ export function findSecretsIn(value, path = '$', out = []) {
55
+ if (typeof value === 'string') {
56
+ for (const [re, kind] of SECRET_SHAPES) if (re.test(value)) { out.push({ where: path, kind }); break; }
57
+ } else if (Array.isArray(value)) {
58
+ value.forEach((v, i) => findSecretsIn(v, `${path}[${i}]`, out));
59
+ } else if (isPlainObject(value)) {
60
+ for (const [k, v] of Object.entries(value)) {
61
+ if (SECRET_KEY.test(k) && v !== null && v !== undefined && v !== '' && typeof v !== 'boolean') out.push({ where: `${path}.${k}`, kind: 'secret-looking field' });
62
+ else findSecretsIn(v, `${path}.${k}`, out);
63
+ }
64
+ }
65
+ return out;
66
+ }
67
+
68
+ const copy = (v) => JSON.parse(JSON.stringify(v));
69
+
70
+ /**
71
+ * Build the export for one invariant. Never throws.
72
+ *
73
+ * @param {object} p
74
+ * @param {object} p.invariant a valid invariant document
75
+ * @param {object} p.fixture { files } of the disposable fixture (used for its digest and the entry check only; never exported)
76
+ * @param {object} [p.ledger] the approval ledger: only a ledger that verifies can say a contract is approved
77
+ * @param {string} [p.commit] exact commit; with it the replay manifests are included, without it they are listed as needed
78
+ * @param {number} [p.seed]
79
+ * @param {object} [p.bounds]
80
+ * @param {object} [p.config] assurance config
81
+ * @param {object} [p.signer] ledger signer (test seam)
82
+ * @returns {{ status: 'ok'|'disabled'|'unsupported'|'rejected'|'blocked', reason?: string, export?: object, withheld?: object[] }}
83
+ */
84
+ export function exportScenarios(p = {}) {
85
+ const config = p.config || resolveAssuranceConfig({ env: process.env });
86
+ const gate = featureStatus(config, FEATURE);
87
+ if (gate.status !== 'ok') return { status: 'disabled', reason: `${FEATURE} is not available: ${gate.reason}` };
88
+ const gen = generateScenarios(p.invariant, { fixture: p.fixture, seed: p.seed, bounds: p.bounds, config });
89
+ if (gen.status !== 'ok') return { status: gen.status, reason: gen.reason, errors: gen.errors };
90
+
91
+ // the identifying text that travels with the export is checked too: a credential pasted into a contract's name must not leave
92
+ const meta = findSecretsIn({ name: p.invariant.name, key: p.invariant.key, application: p.invariant.scope.application }, 'invariant');
93
+ if (meta.length) return { status: 'blocked', reason: 'secret-looking content in the contract\'s name, key or application', withheld: [{ scenarioId: null, kind: null, reason: 'secret-looking content in the contract metadata', where: meta }] };
94
+
95
+ const withheld = [];
96
+ const scenarios = [];
97
+ for (const s of gen.scenarios) {
98
+ const hits = findSecretsIn(s.inputs, 'inputs');
99
+ if (hits.length) { withheld.push({ scenarioId: s.id, kind: s.kind, reason: 'secret-looking content in the scenario inputs', where: hits }); continue; }
100
+ const doc = copy(s);
101
+ const entry = { scenario: doc, digest: digestOf(doc) };
102
+ if (p.commit) {
103
+ try { entry.manifest = createReplayManifest({ hypothesisId: s.id, commit: p.commit, fixtureFiles: p.fixture.files, oracleId: 'business-state', entry: s.entry, inputs: s.inputs, budgets: { timeoutMs: s.limits.timeBudgetMs } }); } catch { entry.manifest = null; }
104
+ }
105
+ scenarios.push(entry);
106
+ }
107
+ if (!scenarios.length && withheld.length) return { status: 'blocked', reason: 'every scenario was withheld because it held secret-looking content', withheld };
108
+
109
+ let approval = { state: 'unrecorded', source: 'no ledger supplied' };
110
+ if (p.ledger) {
111
+ const v = verifyLedger(p.ledger, { signer: p.signer });
112
+ approval = v.ok ? { state: v.states[p.invariant.id] || 'unrecorded', source: 'verified approval ledger' } : { state: 'unverified', source: 'the approval ledger does not verify' };
113
+ }
114
+ const body = {
115
+ schema: EXPORT_SCHEMA, schemaVersion: SCHEMA_VERSION,
116
+ invariant: { id: p.invariant.id, key: p.invariant.key, revision: p.invariant.revision, class: p.invariant.class, name: p.invariant.name },
117
+ approval: { ...approval, documentClaims: p.invariant.review?.state ?? null },
118
+ fixture: { digest: gen.scenarios[0]?.fixtureDigest ?? digestOf(p.fixture.files), entry: p.invariant.scope.entry, included: false, note: 'the fixture source is not exported: replay needs the fixture whose digest is listed' },
119
+ oracle: { id: 'business-state', version: '1' },
120
+ bounds: { applied: gen.bounds, hard: HARD_BOUNDS },
121
+ scenarios,
122
+ notBuilt: gen.unsupported,
123
+ withheld,
124
+ secretsExported: false,
125
+ claims: { exhaustive: false, statement: NOT_EXHAUSTIVE },
126
+ reproduce: p.commit
127
+ ? 'replay each manifest against the fixture with the listed digest (replayManifest), then compare the outcome with an executed run'
128
+ : 'supply an exact commit to obtain replay manifests; the scenario documents alone pin the fixture digest, the seed and the inputs',
129
+ };
130
+ body.id = semanticId('sexp', { ...body, scenarioDigests: scenarios.map((e) => e.digest) }, ['invariant', 'fixture', 'scenarioDigests', 'bounds']);
131
+ return { status: 'ok', export: body, withheld };
132
+ }
133
+
134
+ /**
135
+ * Check an export: every scenario digest, content-addressed id and fixture pin recomputes, no secret-looking content is present,
136
+ * and (given the fixture files) the pinned digest matches. Never throws.
137
+ */
138
+ export function verifyScenarioExport(doc, { fixtureFiles } = {}) {
139
+ const errors = [];
140
+ const fail = (code, message) => errors.push({ code, message });
141
+ try {
142
+ if (!isPlainObject(doc) || doc.schema !== EXPORT_SCHEMA) return { ok: false, errors: [{ code: 'malformed', message: 'not a scenario export' }] };
143
+ if (doc.claims?.exhaustive !== false || typeof doc.claims?.statement !== 'string') fail('overclaim', 'an export must state that it is not exhaustive');
144
+ if (doc.secretsExported !== false) fail('secrets-flag', 'the export must record that no secret was exported');
145
+ for (const e of doc.scenarios || []) {
146
+ if (digestOf(e.scenario) !== e.digest) fail('scenario-tampered', `scenario ${e.scenario?.id} does not match its digest`);
147
+ if (e.scenario?.fixtureDigest !== doc.fixture?.digest) fail('fixture-pin-mismatch', `scenario ${e.scenario?.id} pins a different fixture than the export`);
148
+ if (e.manifest && e.manifest.fixture?.digest !== doc.fixture?.digest) fail('manifest-fixture-mismatch', `the manifest for ${e.scenario?.id} pins a different fixture`);
149
+ const leaked = findSecretsIn(e.scenario?.inputs, 'inputs');
150
+ if (leaked.length) fail('secret-in-export', `scenario ${e.scenario?.id} holds secret-looking content at ${leaked.map((l) => l.where).join(', ')}`);
151
+ }
152
+ if (fixtureFiles !== undefined && digestOf(fixtureFiles) !== doc.fixture?.digest) fail('fixture-mismatch', 'the supplied fixture does not match the digest the export pins');
153
+ if (doc.fixture?.included !== false) fail('fixture-included', 'the export must not include the fixture source');
154
+ } catch (e) {
155
+ fail('malformed', `the check could not complete: ${String(e?.message || e).slice(0, 120)}`);
156
+ }
157
+ return { ok: errors.length === 0, errors };
158
+ }
159
+
160
+ /**
161
+ * The CLI and MCP entry: build an export from files already resolved (and confined) to absolute paths by the caller.
162
+ * Reads the contract, the fixture directory and, optionally, the ledger through the bounded readers in `project-input.js`.
163
+ * Never throws; every failure is a typed status with its reason.
164
+ */
165
+ export function exportFromFiles({ invariantPath, fixturePath, ledgerPath, commit, seed, bounds, config, signer } = {}) {
166
+ const inv = readJsonFile(invariantPath);
167
+ if (!inv.ok) return { status: 'rejected', reason: `the invariant file: ${inv.reason}` };
168
+ const fx = readFixtureDir(fixturePath);
169
+ if (!fx.ok) return { status: 'rejected', reason: `the fixture directory: ${fx.errors.join('; ')}` };
170
+ let ledger;
171
+ if (ledgerPath) {
172
+ const l = readJsonFile(ledgerPath);
173
+ if (!l.ok) return { status: 'rejected', reason: `the ledger file: ${l.reason}` };
174
+ ledger = l.value;
175
+ }
176
+ if (commit !== undefined && !/^[0-9a-f]{40}([0-9a-f]{24})?$/.test(String(commit))) return { status: 'rejected', reason: 'commit must be an exact 40 or 64 character hex id' };
177
+ return exportScenarios({ invariant: inv.value, fixture: { files: fx.files }, ledger, commit, seed, bounds, config, signer });
178
+ }
@@ -0,0 +1,46 @@
1
+ // The closed grammar of forbidden outcomes (X-401).
2
+ //
3
+ // Kept apart from `schema.js` so the oracle adapter can validate the expressions it is handed without importing the schema
4
+ // (the schema imports the oracle registry, which imports the adapters). Every expression is data: an `op` from the table below
5
+ // plus parameters whose strings are restricted to a conservative charset, so no declarative field can carry executable content.
6
+ // An op that is not in the table is an unsupported expression and is rejected, never interpreted.
7
+ const LIST_LIMIT = 8;
8
+
9
+ // Letters, digits and a few separators. No spaces, quotes, braces, parentheses, operators or template syntax.
10
+ const SAFE_TEXT = /^[A-Za-z0-9_.:/-]{1,64}$/;
11
+ export const isSafeText = (v) => typeof v === 'string' && SAFE_TEXT.test(v);
12
+ const safeList = (v, max = LIST_LIMIT) => Array.isArray(v) && v.length >= 1 && v.length <= max && v.every(isSafeText);
13
+ const isObj = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);
14
+
15
+ export const EXPRESSIONS = Object.freeze({
16
+ 'cross-tenant-write': { classes: ['tenant-isolation'], keys: ['op', 'bind', 'id', 'prefix'], check: (e) => (e.prefix === undefined || isSafeText(e.prefix) ? null : 'prefix must be a short safe string') },
17
+ 'cross-tenant-read': { classes: ['tenant-isolation'], keys: ['op', 'bind', 'id'], check: () => null },
18
+ 'unauthorized-role-change': {
19
+ classes: ['privilege-constraint'], keys: ['op', 'bind', 'id', 'actions', 'allowedRoles'],
20
+ check: (e) => (safeList(e.actions) && safeList(e.allowedRoles) ? null : 'actions and allowedRoles must be short lists of safe strings'),
21
+ },
22
+ 'sum-not-conserved': {
23
+ classes: ['value-conservation'], keys: ['op', 'bind', 'id', 'prefix', 'field', 'allowedActions'],
24
+ check: (e) => (isSafeText(e.prefix) && isSafeText(e.field) && (e.allowedActions === undefined || (Array.isArray(e.allowedActions) && e.allowedActions.length <= LIST_LIMIT && e.allowedActions.every(isSafeText))) ? null : 'prefix and field are required safe strings; allowedActions is a short list'),
25
+ },
26
+ 'transition-outside': {
27
+ classes: ['workflow-order'], keys: ['op', 'bind', 'id', 'prefix', 'field', 'allowed'],
28
+ check: (e) => (isSafeText(e.prefix) && isSafeText(e.field) && Array.isArray(e.allowed) && e.allowed.length >= 1 && e.allowed.length <= 16
29
+ && e.allowed.every((t) => isObj(t) && Object.keys(t).every((k) => k === 'from' || k === 'to') && isSafeText(t.from) && isSafeText(t.to)) ? null : 'prefix, field and a list of { from, to } transitions are required'),
30
+ },
31
+ 'duplicate-effect': {
32
+ classes: ['idempotency'], keys: ['op', 'bind', 'id', 'event', 'max'],
33
+ check: (e) => (isSafeText(e.event) && (e.max === undefined || (Number.isInteger(e.max) && e.max >= 1 && e.max <= 5)) ? null : 'event must be a safe string and max an integer 1 to 5'),
34
+ },
35
+ });
36
+
37
+ /** Problems with one expression (empty list when it is supported and well formed). Does not check class or oracle binding. */
38
+ export function expressionProblems(expr) {
39
+ if (!isObj(expr) || !isSafeText(expr.id)) return ['a forbidden outcome needs a safe id'];
40
+ const spec = EXPRESSIONS[expr.op];
41
+ if (!spec) return [`'${String(expr.op).slice(0, 40)}' is not a supported expression (supported: ${Object.keys(EXPRESSIONS).join(', ')})`];
42
+ const out = Object.keys(expr).filter((k) => !spec.keys.includes(k)).map((k) => `'${k}' is not a parameter of ${expr.op}`);
43
+ const why = spec.check(expr);
44
+ if (why) out.push(why);
45
+ return out;
46
+ }