@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,311 @@
1
+ // Bounded stateful scenario generation (X-403).
2
+ //
3
+ // From one invariant this builds the adversarial sequences that could violate it, as plain data a business-state oracle run
4
+ // (`oracles/adapters.js`) can replay: synthetic tenants and actors, a seeded setup, a CONTROL sequence of legitimate operations,
5
+ // an ATTACK sequence, the forbidden outcomes to assert, and a cleanup. Five families, matching how business-state defects occur:
6
+ // cross-tenant access, privilege change, reordered workflow, duplicate request and concurrent operations.
7
+ //
8
+ // What is bounded, and where it is enforced:
9
+ // actors the generator uses at most `bounds.actors` of the contract's actors;
10
+ // state depth at most `bounds.depth` sequence items (a step or one parallel group) per sequence;
11
+ // requests at most `bounds.requests` operations in the attack sequence (the oracle also refuses more than 16 outright);
12
+ // scheduling a parallel group has at most 4 steps and a fixed, SEEDED start order, so the same seed always yields the same
13
+ // schedule; the oracle runs the attack twice and reports a disagreement as inconclusive, never as a verdict;
14
+ // time `bounds.timeBudgetMs` becomes the run's deadline in the replay manifest, capped by the oracle's own ceiling.
15
+ // Every bound has a hard ceiling (`HARD_BOUNDS`); a request over a ceiling or a non-integer is rejected, not silently clamped.
16
+ //
17
+ // Determinism. Given the same invariant, fixture, bounds and seed the output is byte-identical: ids are content hashes, the only
18
+ // randomness is a seeded generator, and there is no clock. The scenario pins the fixture digest, and `runScenario` builds the
19
+ // replay manifest (`replay/replay.js`) so the environment identity, toolchain and oracle logic digest are pinned the same way as
20
+ // every other replayed verification.
21
+ //
22
+ // Confinement. Scenarios are generated only for contracts scoped to a DISPOSABLE FIXTURE, from fixture files the caller supplies
23
+ // (the application under test is imported into the oracle's workspace; nothing here touches a running or shared system), and only
24
+ // when the `invariant-scenarios` feature is enabled. Anything the generator cannot build (a second tenant with a resource, an
25
+ // unprivileged actor, a transition to repeat) is returned as an explicit `unsupported` entry with its reason, never skipped.
26
+ import { digestOf, semanticId } from '../assurance/identity.js';
27
+ import { SCHEMA_VERSION, isPlainObject, isCommit } from '../assurance/schema-kit.js';
28
+ import { featureStatus, resolveAssuranceConfig } from '../assurance/config.js';
29
+ import { createReplayManifest, replayManifest } from '../replay/replay.js';
30
+ import { validateInvariant } from './schema.js';
31
+
32
+ const SCENARIO_SCHEMA = 'agentic-security/invariant-scenario';
33
+ export const FEATURE = 'invariant-scenarios';
34
+ export const SCENARIO_KINDS = Object.freeze(['cross-tenant-access', 'privilege-change', 'reordered-workflow', 'duplicate-request', 'concurrent-operations']);
35
+ export const HARD_BOUNDS = Object.freeze({ actors: 4, depth: 12, requests: 16, scenarios: 6, timeBudgetMs: 8000 });
36
+ const DEFAULT_BOUNDS = Object.freeze({ actors: 4, depth: 6, requests: 10, scenarios: 4, timeBudgetMs: 5000 });
37
+ const MAX_PARALLEL = 4;
38
+ const SCENARIO_ID_FIELDS = ['invariant', 'kind', 'seed', 'inputs', 'fixtureDigest'];
39
+
40
+ /** Which scenario families each invariant class gets. */
41
+ export const KINDS_BY_CLASS = Object.freeze({
42
+ 'tenant-isolation': ['cross-tenant-access'],
43
+ 'privilege-constraint': ['privilege-change'],
44
+ 'workflow-order': ['reordered-workflow'],
45
+ 'value-conservation': ['duplicate-request', 'concurrent-operations'],
46
+ idempotency: ['duplicate-request', 'concurrent-operations'],
47
+ });
48
+
49
+ /** Validate and complete the bounds. Returns `{ ok, bounds, errors }`; nothing is clamped silently. */
50
+ export function resolveBounds(input = {}) {
51
+ const errors = [];
52
+ const bounds = { ...DEFAULT_BOUNDS };
53
+ if (!isPlainObject(input)) return { ok: false, bounds, errors: ['bounds must be an object'] };
54
+ for (const [k, v] of Object.entries(input)) {
55
+ if (!(k in HARD_BOUNDS)) { errors.push(`unknown bound '${k}'`); continue; }
56
+ if (!Number.isInteger(v) || v < 1) errors.push(`bound '${k}' must be a positive integer`);
57
+ else if (v > HARD_BOUNDS[k]) errors.push(`bound '${k}' (${v}) exceeds the ceiling of ${HARD_BOUNDS[k]}`);
58
+ else bounds[k] = v;
59
+ }
60
+ return { ok: errors.length === 0, bounds, errors };
61
+ }
62
+
63
+ function mulberry32(seed) {
64
+ let a = seed >>> 0;
65
+ return () => {
66
+ a = (a + 0x6D2B79F5) | 0;
67
+ let t = Math.imul(a ^ (a >>> 15), 1 | a);
68
+ t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
69
+ return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
70
+ };
71
+ }
72
+
73
+ function permutation(n, rng) {
74
+ const p = [...Array(n).keys()];
75
+ for (let i = n - 1; i > 0; i--) { const j = Math.floor(rng() * (i + 1)); [p[i], p[j]] = [p[j], p[i]]; }
76
+ // never the identity for a group that exists to exercise an interleaving, when n > 1 and the draw landed on it, rotate once
77
+ if (n > 1 && p.every((v, i) => v === i)) p.push(p.shift());
78
+ return p;
79
+ }
80
+
81
+ // ---------------------------------------------------------------- building blocks
82
+
83
+ const stripUndefined = (o) => Object.fromEntries(Object.entries(o).filter(([, v]) => v !== undefined));
84
+
85
+ function seedRecords(inv) {
86
+ return inv.resources.map((r) => {
87
+ const value = stripUndefined({ tenant: r.tenant ?? undefined, note: 'initial', marker: r.marker });
88
+ for (const f of inv.forbidden) {
89
+ if (f.op === 'sum-not-conserved' && r.key.startsWith(f.prefix)) value[f.field] = 100;
90
+ if (f.op === 'transition-outside' && r.key.startsWith(f.prefix)) value[f.field] = f.allowed[0].from;
91
+ }
92
+ return { key: r.key, value };
93
+ });
94
+ }
95
+
96
+ const resourceOf = (inv, id) => inv.resources.find((r) => r.id === id);
97
+ const step = (actor, action, key, payload = {}) => ({ actor, action, args: [key, stripUndefined(payload)] });
98
+ const tenantOfActor = (inv, id) => inv.actors.find((a) => a.id === id)?.tenant;
99
+
100
+ function legitSteps(inv, limit = 2, requestPrefix = 'req-control') {
101
+ return inv.transitions.slice(0, limit).map((t, i) => {
102
+ const r = resourceOf(inv, t.resource);
103
+ return step(t.actors[0], t.action, r.key, { to: t.to, note: 'legitimate', amount: 10, requestId: `${requestPrefix}-${i + 1}` });
104
+ });
105
+ }
106
+
107
+ function chainOf(forbidden) {
108
+ // the allowed transitions as an ordered chain of states: from the first `from`, follow `to` to `from` links
109
+ const next = new Map(forbidden.allowed.map((t) => [t.from, t.to]));
110
+ const chain = [forbidden.allowed[0].from];
111
+ while (next.has(chain[chain.length - 1]) && chain.length <= forbidden.allowed.length + 1) chain.push(next.get(chain[chain.length - 1]));
112
+ return chain;
113
+ }
114
+
115
+ // Each builder returns { control, attack } or { unsupported: reason }.
116
+ const BUILDERS = {
117
+ 'cross-tenant-access'(inv, b) {
118
+ if (!inv.transitions.length) return { unsupported: 'the contract declares no transition to attempt across tenants' };
119
+ const t0 = inv.transitions[0];
120
+ const steps = [];
121
+ for (const a of inv.actors.slice(0, b.actors)) {
122
+ for (const r of inv.resources) {
123
+ if (r.tenant !== null && r.tenant !== a.tenant) steps.push(step(a.id, t0.action, r.key, { to: t0.to, note: 'cross-tenant', amount: 10, requestId: `req-x-${steps.length + 1}` }));
124
+ }
125
+ }
126
+ if (!steps.length) return { unsupported: 'a cross-tenant scenario needs an actor and a resource owned by a different tenant' };
127
+ return { control: legitSteps(inv, 1), attack: steps };
128
+ },
129
+ 'privilege-change'(inv, b) {
130
+ const f = inv.forbidden.find((x) => x.op === 'unauthorized-role-change');
131
+ if (!f) return { unsupported: 'the contract has no unauthorized-role-change outcome' };
132
+ const low = inv.actors.slice(0, b.actors).filter((a) => !f.allowedRoles.includes(a.role));
133
+ const high = inv.actors.find((a) => f.allowedRoles.includes(a.role));
134
+ if (!low.length || !high) return { unsupported: 'a privilege scenario needs an unprivileged actor and a privileged one' };
135
+ const res = inv.resources[0];
136
+ if (!res) return { unsupported: 'the contract declares no resource' };
137
+ const attack = [];
138
+ for (const a of low) for (const action of f.actions) attack.push(step(a.id, action, res.key, { note: 'escalation', amount: 10, requestId: `req-p-${attack.length + 1}` }));
139
+ return { control: [step(high.id, f.actions[0], res.key, { note: 'legitimate', amount: 10, requestId: 'req-control-1' })], attack };
140
+ },
141
+ 'reordered-workflow'(inv, b) {
142
+ const f = inv.forbidden.find((x) => x.op === 'transition-outside');
143
+ if (!f || !inv.transitions.length) return { unsupported: 'the contract has no workflow transition to reorder' };
144
+ const t0 = inv.transitions[0];
145
+ const res = inv.resources.find((r) => r.key.startsWith(f.prefix));
146
+ if (!res) return { unsupported: 'no resource falls under the workflow prefix' };
147
+ const chain = chainOf(f);
148
+ if (chain.length < 2) return { unsupported: 'the allowed transitions form no chain to reorder' };
149
+ const actor = t0.actors[0];
150
+ const control = chain.slice(1).map((to, i) => step(actor, t0.action, res.key, { to, requestId: `req-control-${i + 1}` }));
151
+ // skip a step when the chain is long enough, otherwise run the transition backwards
152
+ const skipTo = chain.length >= 3 ? chain[chain.length - 1] : chain[0];
153
+ return { control, attack: [step(actor, t0.action, res.key, { to: skipTo, requestId: 'req-reorder-1' })] };
154
+ },
155
+ 'duplicate-request'(inv) {
156
+ if (!inv.transitions.length) return { unsupported: 'the contract declares no transition to repeat' };
157
+ const t0 = inv.transitions[0];
158
+ const res = resourceOf(inv, t0.resource);
159
+ const mk = (id) => step(t0.actors[0], t0.action, res.key, { to: t0.to, amount: 10, requestId: id });
160
+ return { control: [mk('req-control-1'), mk('req-control-2')], attack: [mk('req-dup-1'), mk('req-dup-1')] };
161
+ },
162
+ 'concurrent-operations'(inv, b, rng) {
163
+ if (!inv.transitions.length) return { unsupported: 'the contract declares no transition to run concurrently' };
164
+ const t0 = inv.transitions[0];
165
+ const res = resourceOf(inv, t0.resource);
166
+ const mk = (id) => step(t0.actors[0], t0.action, res.key, { to: t0.to, amount: 10, requestId: id });
167
+ const width = Math.min(3, MAX_PARALLEL);
168
+ return { control: [mk('req-control-1'), mk('req-control-2')], attack: [{ parallel: Array.from({ length: width }, () => mk('req-race-1')), schedule: permutation(width, rng) }] };
169
+ },
170
+ };
171
+
172
+ const opCount = (items) => items.reduce((n, it) => n + (it.parallel ? it.parallel.length : 1), 0);
173
+
174
+ function limitsOf(inputs, timeBudgetMs, truncated) {
175
+ const items = [...inputs.attack, ...inputs.control];
176
+ return { actors: new Set(items.flatMap((it) => (it.parallel || [it]).map((x) => x.actor))).size, depth: inputs.attack.length, requests: opCount(inputs.attack), timeBudgetMs, truncated };
177
+ }
178
+
179
+ function determinismOf(inputs, seed) {
180
+ return inputs.attack.some((it) => it.parallel)
181
+ ? { scheduling: 'cooperative-fixed-start-order', seed, support: 'the oracle runs the attack twice; a disagreement is reported inconclusive' }
182
+ : { scheduling: 'sequential', seed, support: 'deterministic by construction' };
183
+ }
184
+
185
+ /**
186
+ * What a scenario would exercise, as plain data: every step of both sequences (parallel groups flattened) with its actor, action,
187
+ * the record key it addresses and the target state it asks for. Used by coverage reporting; reads the scenario, runs nothing.
188
+ */
189
+ export function exerciseOf(scenario) {
190
+ const flat = (items) => items.flatMap((it) => (it.parallel || [it]).map((s) => ({
191
+ actor: s.actor, action: s.action, key: typeof s.args?.[0] === 'string' ? s.args[0] : null, to: typeof s.args?.[1]?.to === 'string' ? s.args[1].to : null,
192
+ })));
193
+ return { control: flat(scenario.inputs.control), attack: flat(scenario.inputs.attack) };
194
+ }
195
+
196
+ /**
197
+ * The scenario that has the same pinned invariant, fixture, entry, seed and budget as `base` but different `inputs` (shrinking
198
+ * removes parts of a sequence; it never edits one). Limits, determinism and the content-hash id are recomputed from the new
199
+ * inputs, so a derived scenario is exactly as self-describing as a generated one. Pure; the caller validates the inputs by
200
+ * running them (the oracle refuses a malformed sequence).
201
+ */
202
+ export function deriveScenario(base, inputs) {
203
+ const derived = {
204
+ ...base, inputs,
205
+ limits: limitsOf(inputs, base.limits.timeBudgetMs, base.limits.truncated),
206
+ determinism: determinismOf(inputs, base.seed),
207
+ };
208
+ derived.id = semanticId('iscn', derived, SCENARIO_ID_FIELDS);
209
+ return derived;
210
+ }
211
+
212
+ function trim(items, b) {
213
+ let out = items.slice(0, b.depth);
214
+ while (out.length > 1 && opCount(out) > b.requests) out = out.slice(0, -1);
215
+ return { items: out, truncated: out.length < items.length };
216
+ }
217
+
218
+ /**
219
+ * Generate the bounded scenarios for one invariant.
220
+ *
221
+ * @param {object} invariant a valid invariant document
222
+ * @param {object} o
223
+ * @param {{ files: object, digest?: string }} o.fixture the disposable fixture's files (relative path to text); required
224
+ * @param {number} [o.seed] integer seed (default 1); the same seed gives the same scenarios
225
+ * @param {object} [o.bounds] see `HARD_BOUNDS`
226
+ * @param {object} [o.config] assurance config; the `invariant-scenarios` feature must be enabled
227
+ * @returns {{ status: 'ok'|'disabled'|'blocked'|'unsupported'|'rejected', scenarios: object[], unsupported: object[], reason?: string, errors?: string[] }}
228
+ */
229
+ export function generateScenarios(invariant, o = {}) {
230
+ const none = (status, reason, extra = {}) => ({ status, scenarios: [], unsupported: [], reason, ...extra });
231
+ const config = o.config || resolveAssuranceConfig({ env: process.env });
232
+ const gate = featureStatus(config, FEATURE);
233
+ if (gate.status !== 'ok') return none(gate.status, `${FEATURE} is not available: ${gate.reason}`, { gate });
234
+
235
+ const valid = validateInvariant(invariant);
236
+ if (!valid.ok) return none('rejected', 'the invariant is not valid', { errors: valid.errors.map((e) => `${e.path}: ${e.message}`) });
237
+ const rb = resolveBounds(o.bounds);
238
+ if (!rb.ok) return none('rejected', 'the bounds are not valid', { errors: rb.errors });
239
+ const seed = o.seed === undefined ? 1 : o.seed;
240
+ if (!Number.isInteger(seed) || seed < 0 || seed > 0xffffffff) return none('rejected', 'the seed must be an integer from 0 to 4294967295', { errors: ['seed'] });
241
+
242
+ // confinement: authorized disposable fixtures only
243
+ if (invariant.scope.environment !== 'disposable-fixture') {
244
+ return none('unsupported', `scenarios run only against a disposable fixture; this contract is scoped to '${invariant.scope.environment}', and nothing is generated for a shared or production system`);
245
+ }
246
+ const files = o.fixture?.files;
247
+ if (!isPlainObject(files) || !(invariant.scope.entry in files)) {
248
+ return none('unsupported', 'setup is unavailable: no disposable fixture files were supplied, or they do not contain the contract\'s entry file');
249
+ }
250
+ const fixtureDigest = digestOf(files);
251
+ if (o.fixture.digest !== undefined && o.fixture.digest !== fixtureDigest) return none('rejected', 'the fixture does not match its stated digest', { errors: ['fixture.digest'] });
252
+
253
+ const b = rb.bounds;
254
+ const kinds = KINDS_BY_CLASS[invariant.class] || [];
255
+ const scenarios = [];
256
+ const unsupported = [];
257
+ const rng = mulberry32(seed);
258
+ for (const kind of kinds) {
259
+ if (scenarios.length >= b.scenarios) { unsupported.push({ kind, reason: `the scenario bound of ${b.scenarios} was reached` }); continue; }
260
+ const built = BUILDERS[kind](invariant, b, rng);
261
+ if (built.unsupported) { unsupported.push({ kind, reason: built.unsupported }); continue; }
262
+ const atk = trim(built.attack, b);
263
+ const ctl = trim(built.control, b);
264
+ const inputs = {
265
+ export: invariant.scope.factory,
266
+ invariant: { id: invariant.id, key: invariant.key, revision: invariant.revision, class: invariant.class },
267
+ actors: invariant.actors.map((a) => ({ id: a.id, tenant: a.tenant, role: a.role })),
268
+ markers: invariant.resources.filter((r) => r.marker && r.tenant !== null).map((r) => ({ tenant: r.tenant, marker: r.marker })),
269
+ seed: seedRecords(invariant), control: ctl.items, attack: atk.items,
270
+ forbidden: invariant.forbidden.map((f) => ({ ...f })), cleanup: [{ action: 'discard-world' }],
271
+ };
272
+ const scenario = {
273
+ schema: SCENARIO_SCHEMA, schemaVersion: SCHEMA_VERSION, kind,
274
+ invariant: inputs.invariant, seed, fixtureDigest, entry: invariant.scope.entry,
275
+ inputs,
276
+ limits: limitsOf(inputs, b.timeBudgetMs, atk.truncated || ctl.truncated),
277
+ determinism: determinismOf(inputs, seed),
278
+ };
279
+ scenario.id = semanticId('iscn', scenario, SCENARIO_ID_FIELDS);
280
+ scenarios.push(scenario);
281
+ }
282
+ if (!kinds.length) unsupported.push({ kind: null, reason: `no scenario family is defined for class '${invariant.class}'` });
283
+ return { status: 'ok', scenarios, unsupported, bounds: b };
284
+ }
285
+
286
+ /**
287
+ * Replay one generated scenario through the business-state oracle, via a replay manifest so the environment, toolchain and
288
+ * oracle logic are pinned. Never throws. The result is exactly what `replayManifest` returned plus the scenario id.
289
+ *
290
+ * @param {object} scenario from `generateScenarios`
291
+ * @param {object} o
292
+ * @param {object} o.fixture { files }, the same files the scenario was generated from
293
+ * @param {string} o.commit exact 40 or 64 character commit the fixture belongs to (a decided verdict needs one)
294
+ * @param {object} [o.config] assurance config (both `invariant-scenarios` and `verification-oracles` must be enabled)
295
+ * @param {object} [o.runOptions] test seams passed to the oracle runner
296
+ */
297
+ export async function runScenario(scenario, o = {}) {
298
+ const files = o.fixture?.files;
299
+ if (!isPlainObject(scenario) || scenario.schema !== SCENARIO_SCHEMA || !isPlainObject(files) || digestOf(files) !== scenario.fixtureDigest) {
300
+ return { scenarioId: scenario?.id ?? null, status: 'rejected', executed: false, outcome: null, record: null, receipt: null, errors: [{ code: 'FIXTURE_MISMATCH', path: 'fixture', message: 'the fixture is missing or does not match the scenario\'s pinned digest' }] };
301
+ }
302
+ if (!isCommit(o.commit)) {
303
+ return { scenarioId: scenario.id, status: 'rejected', executed: false, outcome: null, record: null, receipt: null, errors: [{ code: 'BAD_COMMIT', path: 'commit', message: 'a scenario run is bound to an exact commit' }] };
304
+ }
305
+ const manifest = createReplayManifest({
306
+ hypothesisId: scenario.id, commit: o.commit, fixtureFiles: files, oracleId: 'business-state', entry: scenario.entry, inputs: scenario.inputs,
307
+ budgets: { timeoutMs: scenario.limits.timeBudgetMs },
308
+ });
309
+ const replay = await replayManifest({ manifest, fixtureFiles: files }, { config: o.config, runOptions: o.runOptions });
310
+ return { scenarioId: scenario.id, manifestId: manifest.id, ...replay };
311
+ }
@@ -0,0 +1,167 @@
1
+ // The executable invariant DSL (X-401).
2
+ //
3
+ // An INVARIANT is a declarative contract about tenant or business state: who the actors are, which tenants and resources
4
+ // exist, which transitions are allowed, which outcomes are forbidden, and which oracle adapter judges them. It is data, never
5
+ // code: every field is a closed-world value, every forbidden outcome is an expression from a fixed grammar, and every string
6
+ // inside an expression is restricted to a conservative identifier charset, so nothing in a declarative field can carry
7
+ // executable content. The oracle (`business-state`, oracles/adapters.js) evaluates the expressions verifier-side with
8
+ // `state-assertions.js`, from durable state it observed itself.
9
+ //
10
+ // Identity. `key` names a contract across its revisions; `revision` counts them; `id` is a hash over the semantic content
11
+ // (key, revision, scope, actors, tenants, resources, transitions, forbidden, oracle). Review state, evidence and author are NOT
12
+ // in the id, so approving a contract does not change what is approved, and an approval binds to the exact content it was given
13
+ // for. Two actors, resources or transitions sharing an identity are rejected as ambiguous.
14
+ //
15
+ // Review state recorded in the document (`review.state`) is a claim, not authority. Authority is decided only by the signed
16
+ // approval ledger (`lifecycle.js`), so a document that says "approved" is not approved.
17
+ import { semanticId } from '../assurance/identity.js';
18
+ import { SCHEMA_VERSION, checkHeader, checkFields, checkEnum, result, guardObject, isPlainObject, isNonEmptyString } from '../assurance/schema-kit.js';
19
+ import { getOracle } from '../oracles/registry.js';
20
+ import { EXPRESSIONS, expressionProblems, isSafeText as safe } from './expressions.js';
21
+
22
+ const INVARIANT_SCHEMA = 'agentic-security/invariant';
23
+ const INVARIANT_ID_PREFIX = 'inv';
24
+ export const INVARIANT_CLASSES = Object.freeze(['tenant-isolation', 'privilege-constraint', 'value-conservation', 'workflow-order', 'idempotency']);
25
+ export const REVIEW_STATES = Object.freeze(['proposed', 'approved', 'rejected', 'superseded']);
26
+ const AUTHOR_KINDS = Object.freeze(['human', 'model', 'code']);
27
+ export const ORIGINS = Object.freeze(['authored', 'inferred']);
28
+ const ENVIRONMENTS = Object.freeze(['disposable-fixture', 'shared', 'production']);
29
+ export const BUSINESS_STATE_ORACLE = 'business-state';
30
+
31
+ const ALLOWED = ['schema', 'schemaVersion', 'id', 'key', 'revision', 'name', 'description', 'class', 'scope', 'actors', 'tenants', 'resources', 'transitions', 'forbidden', 'oracle', 'author', 'review', 'supersedes'];
32
+ const REQUIRED = ['schema', 'schemaVersion', 'id', 'key', 'revision', 'name', 'class', 'scope', 'actors', 'tenants', 'resources', 'transitions', 'forbidden', 'oracle', 'author', 'review'];
33
+ const ID_FIELDS = ['key', 'revision', 'class', 'scope', 'actors', 'tenants', 'resources', 'transitions', 'forbidden', 'oracle'];
34
+
35
+ // Bounds, so a contract cannot be used to request unbounded work from the scenario generator.
36
+ export const LIMITS = Object.freeze({ actors: 8, tenants: 4, resources: 16, transitions: 24, forbidden: 8 });
37
+
38
+ const SLUG = /^[a-z][a-z0-9-]{1,63}$/;
39
+ const IDENT = /^[A-Za-z_$][\w$]{0,63}$/;
40
+ export function invariantId(inv) { return semanticId(INVARIANT_ID_PREFIX, inv, ID_FIELDS); }
41
+
42
+ /** Build an invariant document from its parts: fills the schema header and the content id. Does not validate. */
43
+ export function createInvariant(parts) {
44
+ const inv = { schema: INVARIANT_SCHEMA, schemaVersion: SCHEMA_VERSION, ...parts };
45
+ inv.id = invariantId(inv);
46
+ return inv;
47
+ }
48
+
49
+ function dup(ctx, base, list, what) {
50
+ const seen = new Set();
51
+ for (const [i, x] of list.entries()) {
52
+ if (!isPlainObject(x) || typeof x.id !== 'string') continue;
53
+ if (seen.has(x.id)) ctx.err('DUPLICATE_ID', `${base}[${i}].id`, `${what} identity '${x.id}' is ambiguous: it appears more than once`);
54
+ seen.add(x.id);
55
+ }
56
+ }
57
+
58
+ function listOf(ctx, rec, field, max) {
59
+ const v = rec[field];
60
+ if (!Array.isArray(v)) { ctx.err('BAD_TYPE', field, 'must be an array'); return []; }
61
+ if (v.length > max) ctx.err('BAD_TYPE', field, `at most ${max} entries`);
62
+ return v;
63
+ }
64
+
65
+ /** Validate an invariant document. Pure: reads nothing but the oracle registry. */
66
+ export function validateInvariant(rec) {
67
+ const g = guardObject(rec);
68
+ if (!g.ok) return result(g.ctx);
69
+ const { ctx } = g;
70
+ if (!checkHeader(ctx, rec, INVARIANT_SCHEMA)) return result(ctx);
71
+ checkFields(ctx, rec, ALLOWED, REQUIRED);
72
+ if (ctx.errors.length) return result(ctx);
73
+
74
+ if (typeof rec.key !== 'string' || !SLUG.test(rec.key)) ctx.err('BAD_TYPE', 'key', 'must be a lowercase slug');
75
+ if (!Number.isInteger(rec.revision) || rec.revision < 1) ctx.err('BAD_TYPE', 'revision', 'must be a positive integer');
76
+ if (!isNonEmptyString(rec.name)) ctx.err('BAD_TYPE', 'name', 'must be a non-empty string');
77
+ if (rec.description !== undefined && typeof rec.description !== 'string') ctx.err('BAD_TYPE', 'description', 'must be a string');
78
+ checkEnum(ctx, 'class', rec.class, INVARIANT_CLASSES);
79
+
80
+ // scope: an explicit application scope, never "everywhere"
81
+ const sc = rec.scope;
82
+ if (!isPlainObject(sc)) ctx.err('BAD_TYPE', 'scope', 'must be an object naming the application scope');
83
+ else {
84
+ for (const k of Object.keys(sc)) if (!['application', 'entry', 'factory', 'environment'].includes(k)) ctx.err('UNKNOWN_FIELD', `scope.${k}`, 'not part of the scope');
85
+ if (typeof sc.application !== 'string' || !SLUG.test(sc.application)) ctx.err('BAD_TYPE', 'scope.application', 'must be a lowercase slug naming the application');
86
+ if (!safe(sc.entry) || sc.entry.includes('..') || sc.entry.startsWith('/')) ctx.err('BAD_TYPE', 'scope.entry', 'must be a safe relative path');
87
+ if (typeof sc.factory !== 'string' || !IDENT.test(sc.factory)) ctx.err('BAD_TYPE', 'scope.factory', 'must be an identifier');
88
+ checkEnum(ctx, 'scope.environment', sc.environment, ENVIRONMENTS);
89
+ }
90
+
91
+ const tenants = listOf(ctx, rec, 'tenants', LIMITS.tenants);
92
+ const actors = listOf(ctx, rec, 'actors', LIMITS.actors);
93
+ const resources = listOf(ctx, rec, 'resources', LIMITS.resources);
94
+ const transitions = listOf(ctx, rec, 'transitions', LIMITS.transitions);
95
+ const forbidden = listOf(ctx, rec, 'forbidden', LIMITS.forbidden);
96
+ if (tenants.length < 1) ctx.err('MISSING_FIELD', 'tenants', 'at least one tenant is required');
97
+ if (actors.length < 1) ctx.err('MISSING_FIELD', 'actors', 'at least one actor is required');
98
+ if (forbidden.length < 1) ctx.err('MISSING_FIELD', 'forbidden', 'at least one forbidden outcome is required');
99
+
100
+ for (const [i, t] of tenants.entries()) if (!isPlainObject(t) || !safe(t.id)) ctx.err('BAD_ID', `tenants[${i}].id`, 'a tenant needs a safe id');
101
+ for (const [i, a] of actors.entries()) {
102
+ if (!isPlainObject(a) || !safe(a.id)) ctx.err('BAD_ID', `actors[${i}].id`, 'an actor needs a safe id');
103
+ else if (!safe(a.role) || !tenants.some((t) => t && t.id === a.tenant)) ctx.err('BAD_TYPE', `actors[${i}]`, 'an actor needs a safe role and a tenant declared in tenants');
104
+ }
105
+ for (const [i, r] of resources.entries()) {
106
+ if (!isPlainObject(r) || !safe(r.id) || !safe(r.kind) || !safe(r.key)) ctx.err('BAD_ID', `resources[${i}]`, 'a resource needs a safe id, kind and key');
107
+ else if (r.tenant !== null && !tenants.some((t) => t && t.id === r.tenant)) ctx.err('BAD_TYPE', `resources[${i}].tenant`, 'must be a declared tenant or null (shared)');
108
+ else if (r.marker !== undefined && !safe(r.marker)) ctx.err('BAD_TYPE', `resources[${i}].marker`, 'must be a short safe string');
109
+ }
110
+ for (const [i, t] of transitions.entries()) {
111
+ if (!isPlainObject(t) || !safe(t.id) || !safe(t.action)) { ctx.err('BAD_ID', `transitions[${i}]`, 'a transition needs a safe id and action'); continue; }
112
+ if (!Array.isArray(t.actors) || t.actors.length < 1 || !t.actors.every((id) => actors.some((a) => a && a.id === id))) ctx.err('DANGLING_REF', `transitions[${i}].actors`, 'every transition actor must be a declared actor');
113
+ if (!resources.some((r) => r && r.id === t.resource)) ctx.err('DANGLING_REF', `transitions[${i}].resource`, 'the transition resource must be a declared resource');
114
+ for (const k of ['from', 'to']) if (t[k] !== undefined && !safe(t[k])) ctx.err('BAD_TYPE', `transitions[${i}].${k}`, 'must be a short safe string');
115
+ }
116
+ dup(ctx, 'tenants', tenants, 'tenant');
117
+ dup(ctx, 'actors', actors, 'actor');
118
+ dup(ctx, 'resources', resources, 'resource');
119
+ dup(ctx, 'transitions', transitions, 'transition');
120
+ dup(ctx, 'forbidden', forbidden, 'forbidden outcome');
121
+ const resourceKeys = resources.filter((r) => isPlainObject(r)).map((r) => r.key);
122
+ if (new Set(resourceKeys).size !== resourceKeys.length) ctx.err('DUPLICATE_ID', 'resources', 'two resources share one store key, so their identities are ambiguous');
123
+
124
+ // oracle binding: a registered adapter of the business-state class, and every forbidden outcome bound to it
125
+ const orc = rec.oracle;
126
+ if (!isPlainObject(orc) || typeof orc.adapter !== 'string' || !orc.adapter) ctx.err('MISSING_FIELD', 'oracle.adapter', 'an oracle binding is required');
127
+ else {
128
+ const bound = getOracle(orc.adapter);
129
+ if (!bound) ctx.err('DANGLING_REF', 'oracle.adapter', `'${orc.adapter}' is not a registered oracle adapter`);
130
+ else if (bound.class !== 'business-state') ctx.err('RULE_VIOLATION', 'oracle.adapter', `'${orc.adapter}' is a ${bound.class} oracle, not a business-state oracle`);
131
+ else if (orc.version !== bound.version) ctx.err('RULE_VIOLATION', 'oracle.version', `the binding names version ${JSON.stringify(orc.version)} but the registered adapter is version ${bound.version}`);
132
+ for (const k of Object.keys(orc)) if (!['adapter', 'version'].includes(k)) ctx.err('UNKNOWN_FIELD', `oracle.${k}`, 'not part of an oracle binding');
133
+ }
134
+ for (const [i, f] of forbidden.entries()) {
135
+ if (!isPlainObject(f) || !safe(f.id)) { ctx.err('BAD_ID', `forbidden[${i}].id`, 'a forbidden outcome needs a safe id'); continue; }
136
+ const spec = EXPRESSIONS[f.op];
137
+ if (!spec) { ctx.err('RULE_VIOLATION', `forbidden[${i}].op`, expressionProblems(f)[0]); continue; }
138
+ for (const p of expressionProblems(f)) ctx.err(p.includes('is not a parameter') ? 'UNKNOWN_FIELD' : 'BAD_TYPE', `forbidden[${i}]`, p);
139
+ if (!spec.classes.includes(rec.class)) ctx.err('RULE_VIOLATION', `forbidden[${i}].op`, `'${f.op}' does not express a ${rec.class} contract`);
140
+ if (!isPlainObject(orc) || f.bind !== orc.adapter) ctx.err('MISSING_FIELD', `forbidden[${i}].bind`, 'every forbidden outcome must name the oracle adapter that judges it');
141
+ }
142
+
143
+ // authorship and review state are preserved, not defaulted
144
+ const au = rec.author;
145
+ if (!isPlainObject(au) || !safe(au.id)) ctx.err('MISSING_FIELD', 'author.id', 'an author id is required');
146
+ else checkEnum(ctx, 'author.kind', au.kind, AUTHOR_KINDS);
147
+ const rv = rec.review;
148
+ if (!isPlainObject(rv)) ctx.err('BAD_TYPE', 'review', 'must be an object');
149
+ else {
150
+ checkEnum(ctx, 'review.state', rv.state, REVIEW_STATES);
151
+ checkEnum(ctx, 'review.origin', rv.origin, ORIGINS);
152
+ for (const k of Object.keys(rv)) if (!['state', 'origin', 'sources', 'uncertainty'].includes(k)) ctx.err('UNKNOWN_FIELD', `review.${k}`, 'not part of the review block');
153
+ if (rv.origin === 'inferred') {
154
+ if (isPlainObject(au) && au.kind === 'human') ctx.err('RULE_VIOLATION', 'author.kind', 'an inferred invariant is authored by a model or by code, not a human');
155
+ if (!Array.isArray(rv.sources) || rv.sources.length < 1) ctx.err('MISSING_FIELD', 'review.sources', 'an inferred invariant carries its source evidence');
156
+ if (typeof rv.uncertainty !== 'number' || !(rv.uncertainty >= 0 && rv.uncertainty <= 1)) ctx.err('MISSING_FIELD', 'review.uncertainty', 'an inferred invariant states its uncertainty as a number from 0 to 1');
157
+ } else if (rv.origin === 'authored' && isPlainObject(au) && au.kind !== 'human') {
158
+ ctx.err('RULE_VIOLATION', 'author.kind', 'an authored invariant has a human author');
159
+ }
160
+ }
161
+ if (rec.supersedes !== undefined && (typeof rec.supersedes !== 'string' || !rec.supersedes.startsWith(`${INVARIANT_ID_PREFIX}:`))) ctx.err('BAD_ID', 'supersedes', 'must name an invariant id');
162
+ if (!ctx.errors.length) {
163
+ const expected = invariantId(rec);
164
+ if (rec.id !== expected) ctx.err('ID_MISMATCH', 'id', `the id does not match the invariant content (expected ${expected})`);
165
+ }
166
+ return result(ctx);
167
+ }