@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,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
+ }
@@ -0,0 +1,289 @@
1
+ // Inferred versus approved invariants (X-402).
2
+ //
3
+ // A contract proposed by a model, or inferred from code by specification mining, is a HYPOTHESIS about what the product
4
+ // should do. It enters the ledger as `proposed`, with its source evidence and an uncertainty, and stays advisory: it can be run
5
+ // as a scenario and its violations are reported as CANDIDATE violations, but it can never gate and it can never be the ground
6
+ // truth that a verdict is measured against. Only a reviewer's approval changes that, and the approval is a record, not a flag:
7
+ //
8
+ // - the ledger is append-only and hash-chained (`prev`), and every record carries an HMAC signature under the per-install key
9
+ // (`integrity.js`, the same key handling the scan and attestation signatures use), so editing, reordering or forging a
10
+ // transition is detectable. Tamper-evidence for the operator, NOT third-party non-repudiation (the key is symmetric);
11
+ // - an approval must come from a HUMAN reviewer who is authorized by an explicit local policy (a reviewer list) or by the
12
+ // operator's approver registry (`fix/approver-registry.js`). A model, code or the proposer acting as a model cannot approve,
13
+ // and with no policy and no registry nothing can be approved: the default is closed;
14
+ // - the approval binds to the invariant ID, which is a hash of the contract content, so changing a contract after approval is
15
+ // a different contract that must be reviewed again (a revision supersedes, it does not edit);
16
+ // - the transitions are fixed (propose, approve, reject, supersede) and each is recorded with who, why, from and to.
17
+ //
18
+ // "Who is the reviewer" is a claim made by the caller, as it is in the approver registry: the policy lists identities the
19
+ // operator chose to trust, it does not authenticate anyone. What the signature adds is that the claim cannot be rewritten later
20
+ // without the key. Persistence is the caller's (the ledger is plain data), as with the repair-record ledger.
21
+ import * as crypto from 'node:crypto';
22
+ import { digestOf, semanticId } from '../assurance/identity.js';
23
+ import { SCHEMA_VERSION, isPlainObject } from '../assurance/schema-kit.js';
24
+ import { signLastScan } from '../integrity.js';
25
+ import { verifyApprover } from '../../fix/approver-registry.js';
26
+ import { isIssuedReceipt } from '../oracles/oracle.js';
27
+ import { createInvariant, validateInvariant, INVARIANT_CLASSES, BUSINESS_STATE_ORACLE } from './schema.js';
28
+
29
+ export const LEDGER_SCHEMA = 'agentic-security/invariant-ledger';
30
+ const TRANSITION_SCHEMA = 'agentic-security/invariant-transition';
31
+ export const ACTIONS = Object.freeze(['propose', 'approve', 'reject', 'supersede']);
32
+ const TRANSITION_ID_FIELDS = ['seq', 'invariantId', 'key', 'revision', 'action', 'from', 'to', 'actor', 'reason', 'supersededBy', 'prev'];
33
+ const SIGNED_FIELDS = [...TRANSITION_ID_FIELDS, 'id', 'schema', 'schemaVersion', 'policyId', 'origin', 'authorKind', 'uncertainty', 'sourcesDigest'];
34
+ const MAX_REASON = 400;
35
+
36
+ // Which transitions exist: action -> { from states allowed, resulting state }. Anything else is refused.
37
+ const MACHINE = Object.freeze({
38
+ propose: { from: [null], to: 'proposed' },
39
+ approve: { from: ['proposed'], to: 'approved' },
40
+ reject: { from: ['proposed'], to: 'rejected' },
41
+ supersede: { from: ['proposed', 'approved'], to: 'superseded' },
42
+ });
43
+
44
+ /** The default signer: the per-install HMAC key. A test or an embedding tool may inject another `{ sign, verify }`. */
45
+ const defaultSigner = Object.freeze({
46
+ sign: (body) => signLastScan(body),
47
+ verify: (body, sig) => {
48
+ try {
49
+ const want = Buffer.from(signLastScan(body), 'hex');
50
+ const have = Buffer.from(String(sig), 'hex');
51
+ return want.length === have.length && crypto.timingSafeEqual(want, have);
52
+ } catch { return false; }
53
+ },
54
+ });
55
+
56
+ export function emptyLedger() { return { schema: LEDGER_SCHEMA, schemaVersion: SCHEMA_VERSION, records: [] }; }
57
+
58
+ const bodyOf = (rec) => { const o = {}; for (const f of SIGNED_FIELDS) o[f] = rec[f] === undefined ? null : rec[f]; return JSON.stringify(sortKeys(o)); };
59
+ function sortKeys(v) {
60
+ if (Array.isArray(v)) return v.map(sortKeys);
61
+ if (v && typeof v === 'object') return Object.fromEntries(Object.keys(v).sort().map((k) => [k, sortKeys(v[k])]));
62
+ return v;
63
+ }
64
+
65
+ const err = (code, message) => ({ code, message });
66
+
67
+ /**
68
+ * Who may approve. A reviewer is a HUMAN named by the local policy's reviewer list or verified by the approver registry. Nothing
69
+ * else is an authority: no model, no code, no verifier, and no policy at all means no approval.
70
+ */
71
+ export function authorizeReviewer(actor, { policy = null, registry = null } = {}) {
72
+ if (!isPlainObject(actor) || typeof actor.id !== 'string' || !actor.id.trim()) return { ok: false, code: 'anonymous-reviewer', reason: 'an approval names its reviewer' };
73
+ if (actor.kind !== 'human') return { ok: false, code: 'not-a-human-reviewer', reason: `a ${String(actor.kind).slice(0, 20)} cannot approve a contract: model-generated or code-derived contracts do not establish their own ground truth` };
74
+ const listed = isPlainObject(policy) && Array.isArray(policy.reviewers) && policy.reviewers.includes(actor.id);
75
+ const policyConfigured = isPlainObject(policy) && Array.isArray(policy.reviewers) && policy.reviewers.length > 0;
76
+ if (!policyConfigured && !registry) return { ok: false, code: 'no-authorized-policy', reason: 'no local reviewer policy and no approver registry is configured, so nothing can be approved' };
77
+ if (policyConfigured && listed) return { ok: true, authority: `policy:${typeof policy.id === 'string' ? policy.id : 'local'}` };
78
+ if (registry) {
79
+ const v = verifyApprover(registry, actor.id, isPlainObject(policy) && Array.isArray(policy.requiredRoles) ? policy.requiredRoles : []);
80
+ if (v.verified) return { ok: true, authority: 'approver-registry' };
81
+ return { ok: false, code: 'unauthorized-reviewer', reason: v.reason };
82
+ }
83
+ return { ok: false, code: 'unauthorized-reviewer', reason: `'${actor.id.slice(0, 60)}' is not an authorized reviewer under the local policy` };
84
+ }
85
+
86
+ function statesOf(records) {
87
+ const states = new Map();
88
+ const meta = new Map();
89
+ for (const r of records) {
90
+ states.set(r.invariantId, r.to);
91
+ if (r.action === 'propose') meta.set(r.invariantId, { key: r.key, revision: r.revision });
92
+ }
93
+ return { states, meta };
94
+ }
95
+
96
+ /**
97
+ * Append one transition. Never throws; returns a NEW ledger and leaves the input untouched.
98
+ *
99
+ * @param {object} ledger
100
+ * @param {object} req { action, invariant (propose) | invariantId (others), actor: { id, kind }, reason, supersededBy? }
101
+ * @param {object} [o] { policy, registry, signer }
102
+ * @returns {{ ok: boolean, ledger: object, record: object|null, errors: Array<{code, message}> }}
103
+ */
104
+ export function recordTransition(ledger, req, o = {}) {
105
+ const fail = (...errors) => ({ ok: false, ledger, record: null, errors });
106
+ const signer = o.signer || defaultSigner;
107
+ if (!isPlainObject(ledger) || ledger.schema !== LEDGER_SCHEMA || !Array.isArray(ledger.records)) return fail(err('bad-ledger', 'not an invariant ledger'));
108
+ if (!isPlainObject(req) || !ACTIONS.includes(req.action)) return fail(err('unknown-action', `action must be one of ${ACTIONS.join(', ')}`));
109
+ const actor = isPlainObject(req.actor) && typeof req.actor.id === 'string' && req.actor.id.trim() ? { id: req.actor.id.trim().slice(0, 120), kind: req.actor.kind } : null;
110
+ if (!actor || !['human', 'model', 'code'].includes(actor.kind)) return fail(err('bad-actor', 'the actor needs an id and a kind of human, model or code'));
111
+ const reason = typeof req.reason === 'string' ? req.reason.trim().slice(0, MAX_REASON) : '';
112
+ if (!reason) return fail(err('reason-required', 'every transition records its reason'));
113
+
114
+ const { states, meta } = statesOf(ledger.records);
115
+ let invariantId; let key; let revision;
116
+ if (req.action === 'propose') {
117
+ const v = validateInvariant(req.invariant);
118
+ if (!v.ok) return fail(...v.errors.map((e) => err('invalid-invariant', `${e.path}: ${e.message}`)));
119
+ const inv = req.invariant;
120
+ if (inv.review.state !== 'proposed') return fail(err('proposal-must-be-proposed', 'a contract enters the ledger as proposed; approval is a separate recorded transition'));
121
+ if (states.has(inv.id)) return fail(err('already-recorded', `'${inv.id}' is already in the ledger`));
122
+ for (const [id, m] of meta) if (m.key === inv.key && m.revision === inv.revision && id !== inv.id) return fail(err('ambiguous-revision', `'${inv.key}' revision ${inv.revision} already names a different contract (${id})`));
123
+ invariantId = inv.id; key = inv.key; revision = inv.revision;
124
+ } else {
125
+ invariantId = req.invariantId;
126
+ if (typeof invariantId !== 'string' || !states.has(invariantId)) return fail(err('unknown-invariant', 'the invariant is not in the ledger'));
127
+ ({ key, revision } = meta.get(invariantId));
128
+ }
129
+ const from = states.has(invariantId) ? states.get(invariantId) : null;
130
+ const rule = MACHINE[req.action];
131
+ if (!rule.from.includes(from)) return fail(err('illegal-transition', `cannot ${req.action} a contract that is ${from === null ? 'not recorded' : from}`));
132
+
133
+ let authority = 'proposer';
134
+ if (req.action === 'approve' || req.action === 'reject' || req.action === 'supersede') {
135
+ const a = authorizeReviewer(actor, { policy: o.policy, registry: o.registry });
136
+ if (!a.ok) return fail(err(a.code, a.reason));
137
+ authority = a.authority;
138
+ }
139
+ let supersededBy = null;
140
+ if (req.action === 'supersede') {
141
+ supersededBy = req.supersededBy;
142
+ const sm = typeof supersededBy === 'string' ? meta.get(supersededBy) : null;
143
+ if (!sm || sm.key !== key || !(sm.revision > revision) || !['proposed', 'approved'].includes(states.get(supersededBy))) {
144
+ return fail(err('bad-supersession', 'a contract is superseded by a later revision of the same key that is itself proposed or approved'));
145
+ }
146
+ }
147
+
148
+ const prev = ledger.records.length ? ledger.records[ledger.records.length - 1].id : null;
149
+ const rec = {
150
+ schema: TRANSITION_SCHEMA, schemaVersion: SCHEMA_VERSION, seq: ledger.records.length + 1, invariantId, key, revision,
151
+ action: req.action, from, to: rule.to, actor, reason, supersededBy, prev,
152
+ policyId: authority,
153
+ };
154
+ if (req.action === 'propose') {
155
+ rec.origin = req.invariant.review.origin;
156
+ rec.authorKind = req.invariant.author.kind;
157
+ rec.uncertainty = req.invariant.review.uncertainty ?? null;
158
+ rec.sourcesDigest = digestOf(req.invariant.review.sources ?? []);
159
+ }
160
+ rec.id = semanticId('itrn', rec, TRANSITION_ID_FIELDS);
161
+ rec.signature = signer.sign(bodyOf(rec));
162
+ return { ok: true, ledger: { ...ledger, records: [...ledger.records, rec] }, record: rec, errors: [] };
163
+ }
164
+
165
+ /**
166
+ * Verify a whole ledger: contiguous sequence, unbroken hash chain, every id and signature, every transition legal from the state
167
+ * the earlier records left, and every approval made by a human. Returns the state of each contract that survives verification.
168
+ */
169
+ export function verifyLedger(ledger, { signer = defaultSigner } = {}) {
170
+ const errors = [];
171
+ if (!isPlainObject(ledger) || ledger.schema !== LEDGER_SCHEMA || !Array.isArray(ledger.records)) return { ok: false, errors: [err('bad-ledger', 'not an invariant ledger')], states: {} };
172
+ const states = new Map();
173
+ let prev = null;
174
+ ledger.records.forEach((r, i) => {
175
+ const at = `records[${i}]`;
176
+ if (!isPlainObject(r) || r.schema !== TRANSITION_SCHEMA) { errors.push(err('bad-record', `${at}: not a transition record`)); return; }
177
+ if (r.seq !== i + 1) errors.push(err('sequence-broken', `${at}: expected seq ${i + 1}, found ${r.seq}`));
178
+ if (r.prev !== prev) errors.push(err('chain-broken', `${at}: prev does not match the preceding record`));
179
+ if (r.id !== semanticId('itrn', r, TRANSITION_ID_FIELDS)) errors.push(err('id-mismatch', `${at}: the record id does not match its content`));
180
+ if (typeof r.signature !== 'string' || !signer.verify(bodyOf(r), r.signature)) errors.push(err('bad-signature', `${at}: the signature does not verify under the install key`));
181
+ const rule = MACHINE[r.action];
182
+ const from = states.has(r.invariantId) ? states.get(r.invariantId) : null;
183
+ if (!rule || r.from !== from || !rule.from.includes(from) || r.to !== rule.to) errors.push(err('illegal-transition', `${at}: ${String(r.action)} from ${String(r.from)} to ${String(r.to)} is not a legal transition here`));
184
+ if ((r.action === 'approve' || r.action === 'reject' || r.action === 'supersede') && r.actor?.kind !== 'human') errors.push(err('not-a-human-reviewer', `${at}: a ${String(r.actor?.kind)} recorded a reviewer decision`));
185
+ states.set(r.invariantId, r.to);
186
+ prev = r.id;
187
+ });
188
+ return { ok: errors.length === 0, errors, states: Object.fromEntries(states) };
189
+ }
190
+
191
+ /** True only when the ledger verifies AND the contract's latest recorded state is `approved`. A document that merely says approved is not. */
192
+ export function isAuthoritative(ledger, invariantId, o = {}) {
193
+ const v = verifyLedger(ledger, o);
194
+ return v.ok && v.states[invariantId] === 'approved';
195
+ }
196
+
197
+ /**
198
+ * Classify what a scenario run says about one invariant. Only an executed, confirmed result with a receipt the oracle runner
199
+ * issued counts as a violation at all; whether it is a violation of an APPROVED contract (eligible to gate) or a CANDIDATE
200
+ * violation (advisory) depends solely on the verified ledger.
201
+ * @returns {{ kind: 'approved-violation'|'candidate-violation'|'no-violation-observed'|'unverified', gating: boolean, reason: string }}
202
+ */
203
+ export function classifyViolation({ invariant, ledger, run, signer = defaultSigner }) {
204
+ const id = invariant?.id;
205
+ if (!run || !['confirmed', 'refuted'].includes(run.outcome)) {
206
+ return { kind: 'unverified', gating: false, reason: `the scenario did not settle (${run?.outcome ?? 'not run'}), so it says nothing about the contract` };
207
+ }
208
+ if (!isIssuedReceipt(run.receipt)) return { kind: 'unverified', gating: false, reason: 'no receipt issued by the verifier backs this result' };
209
+ if (run.outcome === 'refuted') return { kind: 'no-violation-observed', gating: false, reason: 'the bounded scenario found no violation; that is not proof the contract holds elsewhere' };
210
+ const verified = verifyLedger(ledger, { signer });
211
+ const state = verified.ok ? verified.states[id] : undefined;
212
+ if (state === 'approved') return { kind: 'approved-violation', gating: true, reason: 'a reviewer-approved contract was violated in an executed scenario' };
213
+ const why = !verified.ok ? 'the approval ledger does not verify' : state ? `the contract is ${state}` : 'the contract is not in the ledger';
214
+ const claimed = invariant?.review?.state === 'approved' ? ' (the document claims approval; only the signed ledger counts)' : '';
215
+ return { kind: 'candidate-violation', gating: false, reason: `${why}, so this is an advisory candidate violation, not a violation of an approved requirement${claimed}` };
216
+ }
217
+
218
+ /** Split classified runs into the two groups a report shows separately. Candidate violations never appear under approved ones. */
219
+ export function violationReport(entries) {
220
+ const list = Array.isArray(entries) ? entries : [];
221
+ const pick = (k) => list.filter((e) => e.classification?.kind === k);
222
+ return {
223
+ approvedViolations: pick('approved-violation'), candidateViolations: pick('candidate-violation'),
224
+ noViolationObserved: pick('no-violation-observed'), unverified: pick('unverified'),
225
+ gating: pick('approved-violation').length > 0,
226
+ note: 'only violations of reviewer-approved contracts can gate; candidate violations are advisory and a model-generated contract is never its own ground truth',
227
+ };
228
+ }
229
+
230
+ // ---------------------------------------------------------------- proposals
231
+
232
+ const TEMPLATES = Object.freeze({
233
+ 'tenant-isolation': (a) => ({
234
+ resources: [{ id: 'res-a', kind: 'record', tenant: 'tenant-a', key: 'records/a-1' }, { id: 'res-b', kind: 'record', tenant: 'tenant-b', key: 'records/b-1' }],
235
+ transitions: [{ id: 'own-update', action: a.action, actors: ['user-a'], resource: 'res-a' }],
236
+ forbidden: [{ id: 'no-cross-tenant-write', op: 'cross-tenant-write', prefix: 'records/', bind: BUSINESS_STATE_ORACLE }],
237
+ }),
238
+ 'privilege-constraint': (a) => ({
239
+ resources: [{ id: 'res-a', kind: 'setting', tenant: 'tenant-a', key: 'settings/a-1' }],
240
+ transitions: [{ id: 'admin-change', action: a.action, actors: ['admin-a'], resource: 'res-a' }],
241
+ forbidden: [{ id: 'no-unprivileged-change', op: 'unauthorized-role-change', actions: [a.action], allowedRoles: ['admin'], bind: BUSINESS_STATE_ORACLE }],
242
+ }),
243
+ 'workflow-order': (a) => ({
244
+ resources: [{ id: 'res-a', kind: 'order', tenant: 'tenant-a', key: 'orders/a-1' }],
245
+ transitions: [{ id: 'advance', action: a.action, actors: ['user-a'], resource: 'res-a' }],
246
+ forbidden: [{ id: 'no-skipped-step', op: 'transition-outside', prefix: 'orders/', field: 'status', allowed: a.allowed || [{ from: 'new', to: 'paid' }, { from: 'paid', to: 'shipped' }], bind: BUSINESS_STATE_ORACLE }],
247
+ }),
248
+ });
249
+
250
+ const ACTORS = Object.freeze({
251
+ 'tenant-isolation': [{ id: 'user-a', tenant: 'tenant-a', role: 'member' }, { id: 'user-b', tenant: 'tenant-b', role: 'member' }],
252
+ 'privilege-constraint': [{ id: 'admin-a', tenant: 'tenant-a', role: 'admin' }, { id: 'member-a', tenant: 'tenant-a', role: 'member' }],
253
+ 'workflow-order': [{ id: 'user-a', tenant: 'tenant-a', role: 'member' }],
254
+ });
255
+
256
+ /**
257
+ * Build an INFERRED invariant proposal from a miner's observation: a skeleton contract with synthetic tenants and actors, the
258
+ * evidence that suggested it, and an explicit uncertainty. The result is `proposed`, authored by a model or by code, and not yet in
259
+ * any ledger. It is a starting point for a reviewer to revise (the application scope, the factory and the exact resources are
260
+ * guesses), never a requirement. `null` for a class this build has no skeleton for.
261
+ *
262
+ * @param {object} p
263
+ * @param {string} p.class one of INVARIANT_CLASSES (idempotency has no skeleton: a duplicate-effect contract needs an event name only a reviewer knows)
264
+ * @param {string} p.source 'specification-mining' | 'discovery-lens' | 'logic-claim' | 'state-machine'
265
+ * @param {object} p.evidence what suggested it ({ file, line, ... }); must be present
266
+ * @param {number} p.uncertainty 0..1
267
+ * @param {string} p.action the application action the contract concerns (an identifier)
268
+ * @param {string} p.application application slug (defaults to the entry file's stem)
269
+ */
270
+ export function inferredInvariant(p) {
271
+ if (!INVARIANT_CLASSES.includes(p?.class) || !TEMPLATES[p.class] || !isPlainObject(p.evidence) || typeof p.evidence.file !== 'string') return null;
272
+ const kind = p.source === 'discovery-lens' || p.source === 'logic-claim' ? 'model' : 'code';
273
+ const action = typeof p.action === 'string' && /^[A-Za-z_$][\w$]{0,63}$/.test(p.action) ? p.action : 'updateRecord';
274
+ const stem = p.evidence.file.split('/').pop().replace(/\.[^.]+$/, '').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
275
+ const application = typeof p.application === 'string' && /^[a-z][a-z0-9-]{1,63}$/.test(p.application) ? p.application : (/^[a-z]/.test(stem) && stem.length > 1 ? stem.slice(0, 60) : `app-${stem || 'unknown'}`);
276
+ const parts = TEMPLATES[p.class]({ action, allowed: p.allowed });
277
+ const tenants = [{ id: 'tenant-a' }, { id: 'tenant-b' }];
278
+ const inv = createInvariant({
279
+ key: `${application}-${p.class}-${action.toLowerCase()}`.slice(0, 63), revision: 1,
280
+ name: `${p.class} for ${action} (inferred from ${p.source})`,
281
+ class: p.class,
282
+ scope: { application, entry: p.evidence.file.replace(/^\/+/, '').slice(0, 120), factory: 'createApp', environment: 'disposable-fixture' },
283
+ actors: ACTORS[p.class], tenants, ...parts,
284
+ oracle: { adapter: BUSINESS_STATE_ORACLE, version: '1' },
285
+ author: { id: `${p.source}:miner`, kind },
286
+ review: { state: 'proposed', origin: 'inferred', sources: [{ source: p.source, ...p.evidence }], uncertainty: Math.min(1, Math.max(0, Number(p.uncertainty) || 0.8)) },
287
+ });
288
+ return validateInvariant(inv).ok ? inv : null;
289
+ }
@@ -0,0 +1,55 @@
1
+ // Reading contracts, ledgers and disposable fixtures from a project directory (X-407.AC03).
2
+ //
3
+ // The CLI and the MCP tool both start from files, and both must read them the same bounded, link-free way. This is the only
4
+ // place that does: a fixture is a small directory of text files (no symlinks, no hidden or dependency directories, a file and
5
+ // byte ceiling), a contract or ledger is one bounded JSON file. Path CONFINEMENT is the caller's (the MCP tool passes its
6
+ // session-root confinement; the CLI resolves under the working directory), so this module takes already-resolved absolute paths
7
+ // and refuses anything that is a link or not a regular file.
8
+ import * as fs from 'node:fs';
9
+ import * as path from 'node:path';
10
+
11
+ const INPUT_LIMITS = Object.freeze({ files: 64, fileBytes: 256 * 1024, totalBytes: 1024 * 1024, jsonBytes: 1024 * 1024 });
12
+ // hidden directories (.git and the like) are skipped by the leading-dot rule in the walk below
13
+ const SKIP_DIRS = new Set(['node_modules']);
14
+
15
+ /** Read one bounded JSON document. `{ ok, value }` or `{ ok: false, reason }`; never throws. */
16
+ export function readJsonFile(abs) {
17
+ try {
18
+ const st = fs.lstatSync(abs);
19
+ if (st.isSymbolicLink() || !st.isFile()) return { ok: false, reason: 'not a regular file' };
20
+ if (st.size > INPUT_LIMITS.jsonBytes) return { ok: false, reason: `larger than ${INPUT_LIMITS.jsonBytes} bytes` };
21
+ return { ok: true, value: JSON.parse(fs.readFileSync(abs, 'utf8')) };
22
+ } catch (e) {
23
+ return { ok: false, reason: `unreadable: ${String(e?.message || e).slice(0, 120)}` };
24
+ }
25
+ }
26
+
27
+ /** Read a fixture directory into `{ relative/posix/path: text }`. Refuses links, non-text files and anything over the limits. */
28
+ export function readFixtureDir(abs) {
29
+ const files = {};
30
+ let total = 0;
31
+ const errors = [];
32
+ const walk = (dir, rel) => {
33
+ let entries;
34
+ try { entries = fs.readdirSync(dir, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name)); } catch (e) { errors.push(`unreadable directory ${rel || '.'}`); return; }
35
+ for (const e of entries) {
36
+ const r = rel ? `${rel}/${e.name}` : e.name;
37
+ if (e.isSymbolicLink()) { errors.push(`${r}: symbolic links are refused`); continue; }
38
+ if (e.isDirectory()) { if (!SKIP_DIRS.has(e.name) && !e.name.startsWith('.')) walk(path.join(dir, e.name), r); continue; }
39
+ if (!e.isFile() || e.name.startsWith('.')) continue;
40
+ if (Object.keys(files).length >= INPUT_LIMITS.files) { errors.push(`more than ${INPUT_LIMITS.files} files`); return; }
41
+ const p = path.join(dir, e.name);
42
+ const size = fs.statSync(p).size;
43
+ if (size > INPUT_LIMITS.fileBytes || total + size > INPUT_LIMITS.totalBytes) { errors.push(`${r}: over the size limit`); continue; }
44
+ const text = fs.readFileSync(p, 'utf8');
45
+ if (text.includes('\u0000')) { errors.push(`${r}: not a text file`); continue; }
46
+ files[r] = text; total += size;
47
+ }
48
+ };
49
+ try {
50
+ const st = fs.lstatSync(abs);
51
+ if (st.isSymbolicLink() || !st.isDirectory()) return { ok: false, files: {}, errors: ['the fixture path is not a directory'] };
52
+ } catch { return { ok: false, files: {}, errors: ['the fixture directory does not exist'] }; }
53
+ walk(abs, '');
54
+ return { ok: errors.length === 0 && Object.keys(files).length > 0, files, errors: Object.keys(files).length ? errors : [...errors, 'the fixture directory holds no files'] };
55
+ }