@clear-capabilities/agentic-security-scanner 0.195.29 → 0.195.30

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 (44) hide show
  1. package/CHANGELOG.md +31 -0
  2. package/bin/agentic-security.js +6 -3
  3. package/dist/1340.index.js +11 -6
  4. package/dist/3500.index.js +943 -0
  5. package/dist/5616.index.js +146 -19
  6. package/dist/{c06a1b4c20155c06fb48.json → 7e58a58b8e6ccdde1659.json} +6 -5
  7. package/dist/9560.index.js +29 -5
  8. package/dist/agentic-security.mjs +2 -2
  9. package/dist/agentic-security.mjs.sha256 +1 -1
  10. package/package.json +6 -5
  11. package/src/egress/policy.js +79 -1
  12. package/src/lineage/CLAUDE.md +3 -1
  13. package/src/lineage/deployment/adapter-cloud.js +5 -2
  14. package/src/lineage/deployment/attack-paths.js +107 -30
  15. package/src/lineage/deployment/boundaries-run.js +2 -2
  16. package/src/lineage/deployment/iam-eval.js +105 -0
  17. package/src/lineage/deployment/projection.js +10 -4
  18. package/src/lineage/deployment/proof-binding.js +79 -0
  19. package/src/llm-validator/bounded-context.js +2 -1
  20. package/src/mcp/CLAUDE.md +6 -0
  21. package/src/mcp/tools.js +27 -3
  22. package/src/pipeline/evidence-record.js +1 -1
  23. package/src/posture/CLAUDE.md +2 -0
  24. package/src/posture/assurance/candidate-qualification.js +155 -0
  25. package/src/posture/assurance/release-package-cli.js +44 -2
  26. package/src/posture/auditor-walkthrough.js +2 -1
  27. package/src/posture/dnr/detection-ir.js +2 -2
  28. package/src/posture/dnr/hunt.js +22 -6
  29. package/src/posture/dnr/log-reader.js +47 -3
  30. package/src/posture/dnr/matchers.js +43 -3
  31. package/src/posture/dnr/outputs.js +16 -2
  32. package/src/posture/dnr/payload-classes.js +12 -2
  33. package/src/posture/dnr/validate.js +17 -4
  34. package/src/posture/evaluation/report.js +2 -1
  35. package/src/posture/evaluation/score.js +24 -7
  36. package/src/posture/logic-claims.js +15 -14
  37. package/src/posture/sibling-queue.js +69 -0
  38. package/src/posture/triage-memory.js +106 -26
  39. package/src/posture/verification-separation.js +55 -7
  40. package/src/report/index.js +4 -0
  41. package/src/runScan.js +5 -0
  42. package/src/scope/flags.js +1 -0
  43. package/src/scope/inventory.js +17 -1
  44. package/src/scope/release-scope.js +80 -0
@@ -98,7 +98,7 @@ export function readScanFindings(file, { maxBytes, verify } = {}) {
98
98
  * @param {string} [o.now] ISO time used to judge trace staleness; defaults to the current time
99
99
  * @returns {object} typed result; `status` is ok | disabled | blocked | unsupported | error. Never throws.
100
100
  */
101
- export function runBoundaries({ config, from, environment = 'prod', repository = null, revision = null, findingsFile = null, findings: suppliedFindings = null, verifySignature, now } = {}) {
101
+ export function runBoundaries({ config, from, environment = 'prod', repository = null, revision = null, findingsFile = null, findings: suppliedFindings = null, verifySignature, now, action = null } = {}) {
102
102
  const gate = featureStatus(config, DEPLOYMENT_FEATURE);
103
103
  if (gate.status !== 'ok') return { ...gate, report: null };
104
104
  const limits = limitValues(config);
@@ -175,7 +175,7 @@ export function runBoundaries({ config, from, environment = 'prod', repository =
175
175
  if (integrity === 'unsigned') notes.push('the scan result carries no signature, so its integrity was not verified');
176
176
  }
177
177
 
178
- const attached = attachBoundaryContext({ config, findings, graph, bindings, now: clock });
178
+ const attached = attachBoundaryContext({ config, findings, graph, bindings, now: clock, action });
179
179
  if (attached.status !== 'ok') return { ...attached, report: null };
180
180
  const contexts = attached.findings.map((f) => f.boundaryContext).filter(Boolean);
181
181
  const rows = attached.findings.map((f) => ({
@@ -0,0 +1,105 @@
1
+ // Action-specific IAM decisions for the boundary graph (threat-model PRD FR-03.1, FR-03.4; TM-03).
2
+ //
3
+ // A deny blocks a requested action only when its action and resource patterns match that action and resource AND its condition holds.
4
+ // Before this, any deny edge on a hop blocked it: an unrelated `s3:DeleteObject` deny closed an `s3:GetObject` path, and a conditional
5
+ // deny whose predicate was never evaluated closed it too.
6
+ //
7
+ // The finite policy subset this module decides, and nothing more:
8
+ // actions and resources exact strings and IAM globs (`*`, `?`), case-insensitive for actions, case-sensitive for resources
9
+ // conditions StringEquals, StringNotEquals, Bool, ArnEquals; every operator and every key must hold (AND), any listed
10
+ // value of a key satisfies it (OR); a key the request context does not supply is UNKNOWN
11
+ // anything else (an `...IfExists` or set operator, a policy variable, a value outside the attribute grammar, a clipped
12
+ // action list) is recorded as unsupported and leaves the decision UNRESOLVED, never allowed or blocked
13
+ //
14
+ // Network denies are not IAM decisions and are not judged here: they still block the hop they sit on.
15
+ const SUPPORTED_OPERATORS = new Set(['StringEquals', 'StringNotEquals', 'Bool', 'ArnEquals']);
16
+ const MAX_CONDITIONS = 8;
17
+ const VALUE_GRAMMAR = /^[A-Za-z0-9_.:\/*,@ +=-]{0,200}$/;
18
+
19
+ /** IAM glob to RegExp. */
20
+ function glob(pattern, flags = '') {
21
+ return new RegExp(`^${String(pattern).replace(/[.+^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*').replace(/\?/g, '.')}$`, flags);
22
+ }
23
+ export const actionMatches = (pattern, action) => glob(pattern, 'i').test(String(action));
24
+ export const resourceMatches = (pattern, resource) => glob(pattern).test(String(resource));
25
+
26
+ /**
27
+ * The structured attributes a grants edge carries for one statement. Fits the graph's attribute grammar (at most 16 keys; values
28
+ * limited to a safe character set), so the graph contract and its digests are unchanged for every existing field.
29
+ */
30
+ export function statementAttrs(actions, condition) {
31
+ const joined = actions.join(',');
32
+ const attrs = { actions: joined.slice(0, 250), actionsComplete: joined.length <= 250 };
33
+ if (!condition || typeof condition !== 'object' || !Object.keys(condition).length) return attrs;
34
+ attrs.conditioned = true;
35
+ let n = 0; let supported = true;
36
+ for (const [op, block] of Object.entries(condition)) {
37
+ if (!SUPPORTED_OPERATORS.has(op) || !block || typeof block !== 'object') { supported = false; continue; }
38
+ for (const [key, raw] of Object.entries(block)) {
39
+ const values = [].concat(raw).map(String);
40
+ const text = `${op} ${key}=${values.join(',')}`;
41
+ if (n >= MAX_CONDITIONS || !VALUE_GRAMMAR.test(text) || values.some((v) => v.includes(',') || v.includes('${'))) { supported = false; continue; }
42
+ attrs[`cond${n++}`] = text;
43
+ }
44
+ }
45
+ attrs.conditionSupported = supported;
46
+ return attrs;
47
+ }
48
+
49
+ /** Evaluate an edge's encoded condition against a request context. 'true' | 'false' | 'unknown' (with the reason). */
50
+ export function evaluateCondition(attrs, context = {}) {
51
+ if (!attrs || !attrs.conditioned) return { result: 'true', reason: 'unconditional' };
52
+ if (attrs.conditionSupported !== true) return { result: 'unknown', reason: 'the condition uses an operator or value outside the supported subset' };
53
+ const ctx = context && typeof context === 'object' ? context : {};
54
+ let unknown = null;
55
+ for (const [k, text] of Object.entries(attrs)) {
56
+ if (!/^cond\d+$/.test(k)) continue;
57
+ const m = /^(\S+) ([^=]+)=(.*)$/.exec(String(text));
58
+ if (!m) return { result: 'unknown', reason: 'an encoded condition could not be read' };
59
+ const [, op, key, list] = m;
60
+ const values = list.split(',');
61
+ if (!Object.prototype.hasOwnProperty.call(ctx, key)) { unknown = unknown ?? `the request context does not supply ${key}`; continue; }
62
+ const actual = String(ctx[key]);
63
+ const holds = op === 'StringNotEquals' ? !values.includes(actual) : op === 'Bool' ? values.map((v) => v.toLowerCase()).includes(actual.toLowerCase()) : values.includes(actual);
64
+ if (!holds) return { result: 'false', reason: `${op} ${key} does not hold for ${actual}` };
65
+ }
66
+ return unknown ? { result: 'unknown', reason: unknown } : { result: 'true', reason: 'every condition holds for the request context' };
67
+ }
68
+
69
+ const actionsOf = (e) => String(e.attrs?.actions ?? '').split(',').filter(Boolean);
70
+ const clipped = (e) => e.attrs?.actionsComplete === false;
71
+ /** An edge that records no action list (another adapter, a hand-built graph): what it covers is not known. */
72
+ export const opaque = (e) => actionsOf(e).length === 0;
73
+ /** The pseudo-action used when the allow statements record no actions: "whatever was granted". */
74
+ export const UNKNOWN_ACTION = '(unrecorded)';
75
+
76
+ /**
77
+ * Decide one action across a hop's allow and deny grants edges.
78
+ * @returns {{action:string, outcome:'possible'|'blocked'|'unresolved'|'not-granted', blockers:object[], unresolvedBy:string[]}}
79
+ */
80
+ export function decideAction(allows, denies, action, context) {
81
+ const unresolvedBy = [];
82
+ let granted = false;
83
+ for (const e of allows) {
84
+ const pats = actionsOf(e);
85
+ if (opaque(e)) { granted = true; continue; } // an allow with no action list grants something; it is not narrowed here
86
+ if (!pats.some((p) => actionMatches(p, action))) { if (clipped(e)) unresolvedBy.push(`allow ${e.id}: its action list was clipped, so ${action} may be granted`); continue; }
87
+ const c = evaluateCondition(e.attrs, context);
88
+ if (c.result === 'true') granted = true;
89
+ else if (c.result === 'unknown') { granted = true; unresolvedBy.push(`allow ${e.id}: ${c.reason}`); }
90
+ }
91
+ if (!granted && !unresolvedBy.length) return { action, outcome: 'not-granted', blockers: [], unresolvedBy };
92
+ const blockers = [];
93
+ for (const d of denies) {
94
+ const pats = actionsOf(d);
95
+ // a deny with no action list, or any deny when the granted actions are themselves unrecorded, cannot be matched: unresolved
96
+ if (opaque(d)) { unresolvedBy.push(`deny ${d.id}: it records no action list, so whether it covers ${action} is unknown`); continue; }
97
+ if (action === UNKNOWN_ACTION) { unresolvedBy.push(`deny ${d.id}: the granted actions are not recorded, so whether it covers them is unknown`); continue; }
98
+ if (!pats.some((p) => actionMatches(p, action))) { if (clipped(d)) unresolvedBy.push(`deny ${d.id}: its action list was clipped`); continue; }
99
+ const c = evaluateCondition(d.attrs, context);
100
+ if (c.result === 'true') blockers.push({ edgeId: d.id, action, matchedActions: pats.filter((p) => actionMatches(p, action)), condition: c.reason });
101
+ else if (c.result === 'unknown') unresolvedBy.push(`deny ${d.id}: ${c.reason}`);
102
+ }
103
+ if (blockers.length) return { action, outcome: 'blocked', blockers, unresolvedBy };
104
+ return { action, outcome: unresolvedBy.length ? 'unresolved' : 'possible', blockers, unresolvedBy };
105
+ }
@@ -27,6 +27,9 @@ export const COVERAGE_SCHEMA = 'agentic-security/boundary-coverage';
27
27
  export const BOUNDARY_FEATURE = 'deployment-boundaries';
28
28
 
29
29
  const CONTEXT_KEYS = ['schema', 'schemaVersion', 'id', 'hypothesisId', 'finding', 'binding', 'graph', 'exposure', 'paths', 'truncated', 'verification', 'uncovered', 'warnings'];
30
+ // Optional from 1.1.0: a 1.0.0 context stays valid without them, and may not carry them.
31
+ const OPTIONAL_KEYS_1_1 = ['truncation', 'action'];
32
+ const SUPPORTED_VERSIONS = ['1.0.0', '1.1.0'];
30
33
  const FILTER_KEYS = ['service', 'repository', 'environment', 'tenant'];
31
34
  const MAX_STRING = 600;
32
35
 
@@ -72,7 +75,9 @@ export function validateBoundaryContext(ctx) {
72
75
  const errors = [];
73
76
  if (!ctx || typeof ctx !== 'object' || Array.isArray(ctx)) return { ok: false, errors: [{ code: 'BAD_TYPE', path: '', message: 'a boundary context must be an object' }] };
74
77
  if (ctx.schema !== BOUNDARY_CONTEXT_SCHEMA) errors.push({ code: 'BAD_SCHEMA', path: 'schema', message: `schema must be '${BOUNDARY_CONTEXT_SCHEMA}'` });
75
- for (const k of Object.keys(ctx)) if (!CONTEXT_KEYS.includes(k)) errors.push({ code: 'UNKNOWN_FIELD', path: k, message: `field '${k}' is not part of a boundary context` });
78
+ if (!SUPPORTED_VERSIONS.includes(ctx.schemaVersion)) errors.push({ code: 'BAD_SCHEMA', path: 'schemaVersion', message: `schemaVersion must be one of ${SUPPORTED_VERSIONS.join(', ')}` });
79
+ const allowed = ctx.schemaVersion === '1.0.0' ? CONTEXT_KEYS : [...CONTEXT_KEYS, ...OPTIONAL_KEYS_1_1];
80
+ for (const k of Object.keys(ctx)) if (!allowed.includes(k)) errors.push({ code: 'UNKNOWN_FIELD', path: k, message: `field '${k}' is not part of a boundary context ${ctx.schemaVersion}` });
76
81
  for (const k of CONTEXT_KEYS) if (!(k in ctx)) errors.push({ code: 'MISSING_FIELD', path: k, message: `field '${k}' is required` });
77
82
  if (errors.length) return { ok: false, errors };
78
83
  if (typeof ctx.id !== 'string' || !ctx.id) errors.push({ code: 'BAD_TYPE', path: 'id', message: 'id must be a non-empty string' });
@@ -95,13 +100,14 @@ function pathView(p) {
95
100
  environments: p.environments, tenants: p.tenants, services, repositories, provenance: p.provenance,
96
101
  entry: p.entry, target: p.target,
97
102
  hops: p.hops.map((h) => ({
98
- index: h.index, from: h.from, to: h.to, relations: h.relations, state: h.state,
103
+ index: h.index, from: h.from, to: h.to, relations: h.relations, state: h.state, ...(h.actions ? { actions: h.actions } : {}),
99
104
  edges: h.edges.map((e) => ({
100
105
  id: e.id, relation: e.relation, provenance: e.provenance, effect: e.effect, confidence: e.confidence, pathState: e.pathState,
101
106
  sourceRevision: e.sourceRevision, source: e.source, observationInterval: e.observationInterval, completeness: e.completeness,
102
107
  })),
103
108
  })),
104
109
  supportingEdgeIds: p.supportingEdgeIds, conditions: p.conditions, blockers: p.blockers, assumptions: p.assumptions,
110
+ ...(p.actionOutcomes ? { actionOutcomes: p.actionOutcomes } : {}),
105
111
  crossings: p.crossings, uncoveredBoundaries: uncovered,
106
112
  freshness: {
107
113
  observedFrom: p.coverage.observedFrom, observedUntil: p.coverage.observedUntil, staleHops: p.coverage.staleHops,
@@ -231,12 +237,12 @@ export function boundaryCoverage(contexts) {
231
237
  *
232
238
  * @returns {{ status: string, code?: string, reason?: string, findings: object[], errors: object[] }}
233
239
  */
234
- export function attachBoundaryContext({ config, findings, graph, bindings = [], records = [], now } = {}) {
240
+ export function attachBoundaryContext({ config, findings, graph, bindings = [], records = [], now, action = null } = {}) {
235
241
  const gate = featureStatus(config, BOUNDARY_FEATURE);
236
242
  if (gate.status !== 'ok') return { ...gate, findings, errors: [] };
237
243
  const out = [], errors = [];
238
244
  for (const f of Array.isArray(findings) ? findings : []) {
239
- const r = analyzeFindingBoundaries({ graph, finding: f, bindings, records, now });
245
+ const r = analyzeFindingBoundaries({ graph, finding: f, bindings, records, now, ...(action ? { action } : {}) });
240
246
  if (!r.ok) { errors.push(...r.errors); out.push(f); continue; }
241
247
  out.push({ ...f, boundaryContext: r.context });
242
248
  }
@@ -0,0 +1,79 @@
1
+ // Scope-bound proof for deployed paths (threat-model PRD FR-04.1, FR-04.2; TM-05).
2
+ //
3
+ // A verification record proves a COMPONENT behaviour at one commit. Before this, a record for revision A promoted both the exposure
4
+ // and the impact paths of a revision B graph to exploitable. Now:
5
+ //
6
+ // component proof the record is applicable (validated, trusted runtime proof, the class's oracle) AND its commit is the graph's
7
+ // revision. A record for another revision is kept as historical evidence with the mismatch, never applied.
8
+ // path promotion additionally needs a proof-binding envelope, version 1, for that record and that exact path: the artifact and
9
+ // policy (graph) digests, environment, tenant, actor, route and action, the path id and its kind (exposure or
10
+ // impact), the oracle version and the effect the oracle observed, issued and signed by an issuer the operator
11
+ // trusts (an Ed25519 public key in the trust root). A matching digest or a caller's label is not authority.
12
+ //
13
+ // Every refusal has its own code, so a reviewer can tell a malformed envelope from a tampered, an expired or a replayed one. An exact
14
+ // duplicate envelope adds nothing.
15
+ import crypto from 'node:crypto';
16
+
17
+ export const PROOF_BINDING_SCHEMA = 'agentic-security/proof-binding';
18
+ export const PROOF_BINDING_VERSION = 1;
19
+ const FIELDS = ['schema', 'version', 'recordId', 'commit', 'artifactDigest', 'policyDigest', 'environment', 'tenant', 'actor', 'route', 'action', 'pathId', 'claim', 'oracleVersion', 'effect', 'issuer', 'issuedAt', 'expires'];
20
+ const REQUIRED = ['recordId', 'commit', 'artifactDigest', 'policyDigest', 'environment', 'actor', 'pathId', 'claim', 'oracleVersion', 'effect', 'issuer', 'issuedAt', 'expires'];
21
+ const CLAIMS = new Set(['exposure', 'impact']);
22
+
23
+ const canonical = (e) => JSON.stringify(FIELDS.map((k) => [k, e[k] ?? null]));
24
+ export const bindingDigest = (e) => crypto.createHash('sha256').update(canonical(e)).digest('hex');
25
+
26
+ /** Sign an envelope with an issuer's Ed25519 private key (used by an issuer and by tests). */
27
+ export function signProofBinding(envelope, privateKeyPem) {
28
+ const body = { schema: PROOF_BINDING_SCHEMA, version: PROOF_BINDING_VERSION, ...envelope };
29
+ return { ...body, signature: crypto.sign(null, Buffer.from(canonical(body)), privateKeyPem).toString('base64') };
30
+ }
31
+
32
+ /** Structural and authority check of one envelope. Returns { ok, code, reason }. */
33
+ export function verifyProofBinding(e, { trustRoot = {}, now = Date.now() } = {}) {
34
+ if (!e || typeof e !== 'object' || Array.isArray(e)) return { ok: false, code: 'malformed', reason: 'the binding is not an object' };
35
+ if (e.schema !== PROOF_BINDING_SCHEMA || e.version !== PROOF_BINDING_VERSION) return { ok: false, code: 'unsupported-version', reason: 'the binding schema or version is not supported' };
36
+ for (const k of Object.keys(e)) if (!FIELDS.includes(k) && k !== 'signature') return { ok: false, code: 'malformed', reason: `unknown binding field ${k}` };
37
+ for (const k of REQUIRED) if (e[k] === undefined || e[k] === null || e[k] === '') return { ok: false, code: 'missing-binding', reason: `the binding has no ${k}` };
38
+ if (!CLAIMS.has(e.claim)) return { ok: false, code: 'malformed', reason: 'claim must be exposure or impact' };
39
+ const key = trustRoot[e.issuer];
40
+ if (!key) return { ok: false, code: 'untrusted-issuer', reason: `issuer ${String(e.issuer).slice(0, 60)} is not in the operator's trust root` };
41
+ let good = false;
42
+ try { good = typeof e.signature === 'string' && crypto.verify(null, Buffer.from(canonical(e)), key, Buffer.from(e.signature, 'base64')); } catch { good = false; }
43
+ if (!good) return { ok: false, code: 'tampered', reason: 'the signature does not verify against the issuer\'s key' };
44
+ if (!(Date.parse(e.expires) > now)) return { ok: false, code: 'expired', reason: 'the binding has expired' };
45
+ return { ok: true, code: 'verified', reason: null };
46
+ }
47
+
48
+ /**
49
+ * Decide whether a path may be promoted by a component proof, given the bindings supplied for it.
50
+ * @param {{path:object, kind:'exposure'|'impact', graph:object, record:object, bindings:object[], trustRoot:object, now:number,
51
+ * expect:{artifactDigest?:string, environment?:string, tenant?:string|null, actor?:string, route?:string, action?:string}}} a
52
+ * @returns {{promote:boolean, code:string, reason:string, bindingId:string|null, diagnostics:object[]}}
53
+ */
54
+ export function judgePathPromotion({ path, kind, graph, record, bindings = [], trustRoot = {}, now = Date.now(), expect = {} }) {
55
+ const diagnostics = [];
56
+ const seen = new Set();
57
+ const forRecord = bindings.filter((b) => b && b.recordId === record.id);
58
+ if (!forRecord.length) return { promote: false, code: 'component-only', reason: 'the proof covers the component; no issued binding ties it to this deployed path', bindingId: null, diagnostics };
59
+ for (const b of forRecord) {
60
+ const id = bindingDigest(b);
61
+ if (seen.has(id)) continue; // an exact duplicate adds no authority
62
+ seen.add(id);
63
+ const v = verifyProofBinding(b, { trustRoot, now });
64
+ if (!v.ok) { diagnostics.push({ bindingId: id.slice(0, 16), code: v.code, reason: v.reason }); continue; }
65
+ const mismatch = [];
66
+ if (b.commit !== record.commit) mismatch.push('commit');
67
+ if (graph.revision && b.commit !== graph.revision) mismatch.push('revision');
68
+ if (b.policyDigest !== graph.digest) mismatch.push('policy');
69
+ if (b.pathId !== path.id) mismatch.push('path');
70
+ if (b.claim !== kind) mismatch.push('claim');
71
+ if (record.oracle && b.oracleVersion !== String(record.oracle.version ?? '')) mismatch.push('oracle');
72
+ if (!(path.environments || []).includes(b.environment)) mismatch.push('environment');
73
+ if ((path.tenants || []).length && !(path.tenants || []).includes(b.tenant)) mismatch.push('tenant');
74
+ for (const k of ['artifactDigest', 'environment', 'tenant', 'actor', 'route', 'action']) if (expect[k] !== undefined && expect[k] !== null && b[k] !== expect[k]) mismatch.push(k);
75
+ if (mismatch.length) { diagnostics.push({ bindingId: id.slice(0, 16), code: 'context-mismatch', reason: `the binding was issued for another ${[...new Set(mismatch)].join(', ')}` }); continue; }
76
+ return { promote: true, code: 'bound', reason: `issued binding ${id.slice(0, 16)} by ${b.issuer} ties the proof to this ${kind} path`, bindingId: id.slice(0, 16), diagnostics };
77
+ }
78
+ return { promote: false, code: diagnostics.length ? diagnostics[0].code : 'component-only', reason: diagnostics.length ? diagnostics.map((d) => d.reason).join('; ') : 'no binding applies', bindingId: null, diagnostics };
79
+ }
@@ -156,7 +156,8 @@ function collectConfiguration(finding, fileContents, { window, maxConfig, add, g
156
156
  if (typeof code !== 'string' || !CONFIG_FILE.test(file) || code.length > 500_000) continue;
157
157
  const cl = code.split('\n');
158
158
  for (const key of keys) {
159
- const re = new RegExp(`(?<![\\w])${escapeRe(key)}(?![\\w])`);
159
+ // the key is escaped before it reaches the pattern, which has no quantifier at all
160
+ const re = new RegExp(`(?<![\\w])${escapeRe(key)}(?![\\w])`); // agentic-security-ignore: CWE-1333
160
161
  const i = cl.findIndex((t) => re.test(t));
161
162
  if (i < 0) continue;
162
163
  found.add(key);
package/src/mcp/CLAUDE.md CHANGED
@@ -88,6 +88,12 @@ the before and after image, **no content**) and `languageFix.planDigest` after r
88
88
  caller then sends `apply_fix {finding_id, confirm:true, plan_digest}` (optionally `dry_run:true`, `fixMeta.approval` for a
89
89
  high-impact change).
90
90
 
91
+ **Approval binding (threat-model FR-06.3).** `fixMeta.approval` may carry `expires` (ISO time) and `scope: {tool, action, target,
92
+ change}`: `tool` is `apply_fix`, `action` is `apply-plan` (multi-file) or `apply-patch`, `target` is the finding's stableId, and
93
+ `change` is the plan digest or the digest of the exact edits (path, before and after image hashes). `approvalBindingRefusal` refuses
94
+ an expired approval and one issued for another tool, action, target or change. Under the restricted execution profile
95
+ (`AGENTIC_SECURITY_EGRESS_PROFILE=restricted`) both fields are required; elsewhere an approval without them works as before.
96
+
91
97
  Design decision: the caller **cannot supply file content** on this path (`plan_digest` plus `patch` is refused). The server
92
98
  re-derives the plan with `planNixFix` over `loadLanguageProject` and refuses unless the digest matches, so "the plan applied is
93
99
  exactly the plan previewed" holds by construction, and a changed project file, a changed finding or an altered digest all give
package/src/mcp/tools.js CHANGED
@@ -640,7 +640,26 @@ export const explain_finding = {
640
640
  // FR-307/FR-1002/FR-1003: the high-impact-change approval gate (approval evidence, approver identity, separation of duties),
641
641
  // shared by the caller-patch branch and the multi-file plan branch so the two cannot drift. Returns a refusal result, or null
642
642
  // when the change is not high-impact or is properly approved.
643
- function _highImpactApprovalRefusal(ctx, materialClassification, fixMeta) {
643
+ // Threat-model PRD FR-06.3: an approval is bound to what it approved. With a `scope` it names the tool, the action, the target finding
644
+ // and the change (the digest of the exact edits), and with `expires` it lapses. Under the restricted execution profile
645
+ // (AGENTIC_SECURITY_EGRESS_PROFILE=restricted) both are required; elsewhere a supplied scope or expiry is still enforced. An approval
646
+ // copied to another finding, another change or another tool, or used after it expired, is refused.
647
+ export function approvalBindingRefusal(approval, expected, { now = Date.now(), restricted = process.env.AGENTIC_SECURITY_EGRESS_PROFILE === 'restricted' } = {}) {
648
+ if (restricted && (!approval.expires || !approval.scope)) return 'under the restricted profile an approval must carry an expiry and a scope {tool, action, target, change}';
649
+ if (approval.expires !== undefined) {
650
+ const t = Date.parse(approval.expires);
651
+ if (!Number.isFinite(t)) return 'the approval expiry is not a date';
652
+ if (!(t > now)) return 'the approval has expired';
653
+ }
654
+ if (approval.scope !== undefined) {
655
+ const sc = approval.scope;
656
+ if (!sc || typeof sc !== 'object') return 'the approval scope is not an object';
657
+ for (const k of ['tool', 'action', 'target', 'change']) if (sc[k] !== expected[k]) return `the approval was issued for another ${k}`;
658
+ }
659
+ return null;
660
+ }
661
+
662
+ function _highImpactApprovalRefusal(ctx, materialClassification, fixMeta, expected = null) {
644
663
  if (!materialClassification.highImpactCategories.length) return null;
645
664
  const cats = materialClassification.highImpactCategories.join(', ');
646
665
  const approval = fixMeta && typeof fixMeta === 'object' ? fixMeta.approval : null;
@@ -654,6 +673,10 @@ function _highImpactApprovalRefusal(ctx, materialClassification, fixMeta) {
654
673
  materialClassification,
655
674
  };
656
675
  }
676
+ if (expected) {
677
+ const bound = approvalBindingRefusal(approval, expected);
678
+ if (bound) return { _meta: META, applied: false, reason: `high-impact change (${cats}) approval rejected: ${bound}`, materialClassification };
679
+ }
657
680
  const approverRegistry = loadApproverRegistry(ctx.sessionRoot);
658
681
  const requiredRoles = requiredRolesFor(approverRegistry, materialClassification.highImpactCategories);
659
682
  const identityCheck = verifyApprover(approverRegistry, approval.approvedBy, requiredRoles);
@@ -739,7 +762,7 @@ async function _applyMultiFilePlan({ f, planDigest, dryRun, fixMeta, ctx, status
739
762
  if (!targets.ok) return refuse(targets.reason);
740
763
  const classification = classifyFixMaterialRisk(Object.fromEntries(plan.edits.map((e) => [e.file, { before: e.before, after: e.after }])));
741
764
  if (!dryRun) {
742
- const approvalRefusal = _highImpactApprovalRefusal(ctx, classification, fixMeta);
765
+ const approvalRefusal = _highImpactApprovalRefusal(ctx, classification, fixMeta, { tool: 'apply_fix', action: 'apply-plan', target: f.stableId || f.id, change: planDigest });
743
766
  if (approvalRefusal) return { ...approvalRefusal, multiFile: true };
744
767
  }
745
768
  // The verification gates (syntax, rescan with no new finding, effective value changed as intended) run inside the lifecycle,
@@ -942,7 +965,8 @@ export const apply_fix = {
942
965
  if (dry_run) {
943
966
  return { _meta: META, applied: false, dryRun: true, verified: true, files: Object.keys(confinedAbs), summary: verdict.summary, verificationRecord: verdict.verificationRecord ?? null, ...verificationFields(verdict.verificationRecord), materialClassification, ...(fixVerification ? additiveFields(fixVerification) : {}) };
944
967
  }
945
- const approvalRefusal = _highImpactApprovalRefusal(ctx, materialClassification, fixMeta);
968
+ const changeDigest = _sha256(JSON.stringify(Object.entries(filesForMaterialClassification).sort(([a], [b]) => (a < b ? -1 : 1)).map(([k, v]) => [k, _sha256(v.before), _sha256(v.after)])));
969
+ const approvalRefusal = _highImpactApprovalRefusal(ctx, materialClassification, fixMeta, { tool: 'apply_fix', action: 'apply-patch', target: f.stableId || f.id, change: changeDigest });
946
970
  if (approvalRefusal) return approvalRefusal;
947
971
  const written = [];
948
972
  try {
@@ -55,7 +55,7 @@ export function buildScanRecord({ root, scan, meta, argv = [], engineVersion, mo
55
55
  tool: { name: 'agentic-security', version: engineVersion || null },
56
56
  source: sourceState(root),
57
57
  invocation: { flags: invocationSummary(argv), mode: mode || (meta && meta.mode) || 'full' },
58
- ignorePolicy: scan.scope ? { mode: scan.scope.policy.mode, ruleDigest: scan.scope.policy.ruleDigest, trackedFileSemantics: scan.scope.policy.trackedFileSemantics } : { mode: 'legacy-walk', ruleDigest: null, trackedFileSemantics: false },
58
+ ignorePolicy: scan.scope ? { mode: scan.scope.policy.mode, ruleDigest: scan.scope.policy.ruleDigest, trackedFileSemantics: scan.scope.policy.trackedFileSemantics, inventoryDigest: scan.scope.inventoryDigest ?? null } : { mode: 'legacy-walk', ruleDigest: null, trackedFileSemantics: false, inventoryDigest: null },
59
59
  rules: { rulesetVersion: scan._rulesetVersion || null, rulesetSource: scan._rulesetVersionSource || null },
60
60
  model: model ? { provider: model.provider, model: model.model, egress: model.egress, stages: model.stages } : { configured: false },
61
61
  capability: { status: h.status || null, conditions: Array.isArray(h.conditions) ? h.conditions.length : 0 },
@@ -73,6 +73,8 @@ function is not exported.
73
73
 
74
74
  Its contract is **recall-preserving, same precedent as `falsification.js` / `dataflow/proof-gate.js`**: it never removes a finding, never touches `severity`, and never asserts `unreachable` without positive evidence — a negative verdict additionally requires the intra-repo import graph to be hole-free *along the reachable set* (an unresolved relative import or a non-literal `require(x)` in a reachable file hides a possible edge, so every would-be `unreachable` degrades to `unknown`). `null`/`'unknown'` is a first-class state and is **not** the same as `false`. Wired in `engine.js` after the entry-point inventory is built and after every finding has been appended (multi-sink and cross-language chains included) so nothing escapes annotation; this is the one annotator that deliberately runs after `why-fired`.
75
75
 
76
+ **Threat-model hardening (local, threat-model PRD).** `verification-separation.js` `consensusOf` now votes by independent evidence group (a shared `evidence.observationId`, `generator` or `lineage`, else one verifier), keeps the per-lens counts, reports `conflict` and a `refutationAuthority`; `refutationRemoves` lets a refutation take a critical finding out of the queue only with `sufficient` authority. `triage-memory.js`: a family/directory bucket is advisory (no confidence change); only an exact (stable id + file) decision applies, and for critical/high it also needs a reviewer, expiry, matching scope, unchanged fingerprint and the install's HMAC signature (`pastDecision.status`, `pastDecisionApplies`). `assurance/candidate-qualification.js`: the candidate packet and `evaluateCandidate`, which recomputes gates G-01..G-07 from case evidence (met, violated, unresolved, blocked) and never inherits a pass; `writeCandidateDecision` is atomic and idempotent.
77
+
76
78
  **Enforced verification separation (R7)** — `verification-separation.js`. The falsification pass could already try to *disprove* a finding; what it could not do was prove the checker was not the producer. This module supplies that structural guarantee:
77
79
 
78
80
  - `recordProducer(finding, producerId)` stamps provenance **write-once** — a later party cannot re-stamp itself as producer to manufacture separation.
@@ -0,0 +1,155 @@
1
+ // Candidate qualification (threat-model PRD FR-10.1, FR-10.2, section 6 "Candidate release gates").
2
+ //
3
+ // A release claim is qualified for an EXACT scanner, target artifact, deployment profile, policy and observation window, and only for
4
+ // the cases it passes. The packet carries those identities and one record per required case (requirement, gate, expected and observed
5
+ // outcome, verdict and reason code, artifact and policy bindings, evidence digests, expiry, owner). The decision is recomputed here from
6
+ // the evidence, never read from the packet: a case whose bindings do not match the packet's target, that has expired, whose feed is
7
+ // stale or whose legitimate control failed keeps its gate open, and the candidate is qualified only when every required gate is met.
8
+ // A higher-level status never inherits a pass from a lower-level one: a gate with no case is unresolved, not met.
9
+ //
10
+ // Outcome per case: met (the expectation held), violated (it did not), unresolved (evidence missing, stale, expired or differently
11
+ // scoped) or blocked (a prerequisite such as an attested runner or an independent population is unavailable). Pure apart from the
12
+ // optional signed write, which is atomic.
13
+ //
14
+ // Evidence tiers stay separate qualifications (PRD section 1 and section 8). Each case carries `tier`: `local` (loopback fixtures on
15
+ // this host, the default), `staging` (the owner-authorized staging deployment) or `independent` (an independently held population or
16
+ // reviewer). A gate the caller declares in `requiredTiers` is met only by cases of that tier: local evidence never satisfies a staging
17
+ // or independent gate by being more of it. A decision records the target it was computed for, and `revalidateDecision` refuses it for
18
+ // any other artifact, policy or scanner bundle, so an upgrade invalidates candidate-bound evidence and a rollback is qualified only by
19
+ // the earlier artifact's own unexpired decision (FR-10.4).
20
+ import crypto from 'node:crypto';
21
+ import fs from 'node:fs';
22
+ import path from 'node:path';
23
+
24
+ const CANDIDATE_SCHEMA = 'agentic-security/candidate-packet';
25
+ const CANDIDATE_VERSION = 1;
26
+ export const GATES = Object.freeze({
27
+ 'G-01': 'code regression', 'G-02': 'scan and graph coverage', 'G-03': 'prevention and repair', 'G-04': 'evidence and harness integrity',
28
+ 'G-05': 'detection and response', 'G-06': 'independent accuracy', 'G-07': 'operability',
29
+ });
30
+ const EXPECT = new Set(['positive', 'negative', 'legitimate']);
31
+ const TIERS = new Set(['local', 'staging', 'independent']);
32
+ const DOMAIN = `${CANDIDATE_SCHEMA}@1\n`;
33
+ const canon = (v) => {
34
+ if (v === null || typeof v !== 'object') return JSON.stringify(v ?? null);
35
+ if (Array.isArray(v)) return `[${v.map(canon).join(',')}]`;
36
+ // an undefined value is absent, exactly as JSON would carry it, so a packet and its JSON round trip have one digest
37
+ return `{${Object.keys(v).filter((k) => v[k] !== undefined).sort().map((k) => `${JSON.stringify(k)}:${canon(v[k])}`).join(',')}}`;
38
+ };
39
+ export const packetDigest = (p) => `sha256:${crypto.createHash('sha256').update(canon({ ...p, signature: undefined })).digest('hex')}`;
40
+
41
+ /** Sign a packet with an Ed25519 private key; `verifyCandidatePacket` checks it with the key the caller trusts. */
42
+ export function signCandidatePacket(packet, privateKeyPem) {
43
+ const body = { schema: CANDIDATE_SCHEMA, version: CANDIDATE_VERSION, ...packet };
44
+ delete body.signature;
45
+ return { ...body, signature: crypto.sign(null, Buffer.from(DOMAIN + canon(body)), privateKeyPem).toString('base64') };
46
+ }
47
+ export function verifyCandidatePacket(packet, publicKeyPem) {
48
+ if (!packet || packet.schema !== CANDIDATE_SCHEMA) return { ok: false, code: 'FORMAT' };
49
+ // a packet from a newer or older writer is refused by name, never read with this version's meaning
50
+ if (packet.version !== CANDIDATE_VERSION) return { ok: false, code: 'UNSUPPORTED_VERSION', version: packet.version ?? null };
51
+ const { signature, ...body } = packet;
52
+ try { return crypto.verify(null, Buffer.from(DOMAIN + canon(body)), publicKeyPem, Buffer.from(String(signature), 'base64')) ? { ok: true, code: 'VERIFIED' } : { ok: false, code: 'SIGNATURE' }; } catch { return { ok: false, code: 'SIGNATURE' }; }
53
+ }
54
+
55
+ /** Judge one case record against the packet's target. */
56
+ function judgeCase(c, packet, now) {
57
+ const t = packet.target || {};
58
+ const miss = [];
59
+ for (const k of ['caseId', 'requirement', 'gate', 'expected', 'verdict', 'owner', 'expires']) if (c[k] === undefined || c[k] === null || c[k] === '') miss.push(k);
60
+ if (miss.length) return { outcome: 'unresolved', reason: `missing ${miss.join(', ')}`, owner: c.owner ?? null };
61
+ if (!GATES[c.gate] || !EXPECT.has(c.expected)) return { outcome: 'unresolved', reason: 'unknown gate or expectation' };
62
+ if (c.tier !== undefined && !TIERS.has(c.tier)) return { outcome: 'unresolved', reason: 'unknown evidence tier' };
63
+ if (c.blocked) return { outcome: 'blocked', reason: String(c.blocked) };
64
+ if (!(Date.parse(c.expires) > now)) return { outcome: 'unresolved', reason: 'the evidence has expired' };
65
+ if (c.artifactDigest && t.artifactDigest && c.artifactDigest !== t.artifactDigest) return { outcome: 'unresolved', reason: 'the evidence was produced against another artifact' };
66
+ if (c.policyDigest && t.policyDigest && c.policyDigest !== t.policyDigest) return { outcome: 'unresolved', reason: 'the evidence was produced under another policy' };
67
+ if (c.scannerBundle && packet.scanner?.bundleDigest && c.scannerBundle !== packet.scanner.bundleDigest) return { outcome: 'unresolved', reason: 'the evidence came from another scanner bundle' };
68
+ if (c.requiresArtifactBinding !== false && !c.artifactDigest) return { outcome: 'unresolved', reason: 'the evidence is not bound to an artifact' };
69
+ const o = c.observed;
70
+ if (!o || typeof o !== 'object') return { outcome: 'unresolved', reason: 'no observation is recorded' };
71
+ // the observation and the expectation stay separately inspectable; met/violated is derived, not asserted
72
+ if (c.kind === 'detection') {
73
+ // a detection case judges the signal, not the effect: positive means the rule must fire, negative that it must stay quiet,
74
+ // and an observation that does not say whether the rule fired leaves the case open
75
+ if (typeof o.detected !== 'boolean') return { outcome: 'unresolved', reason: 'the observation does not record whether the detection fired' };
76
+ if (c.expected === 'positive') return o.detected ? { outcome: 'met', reason: 'the detection fired on the reproduced effect' } : { outcome: 'violated', reason: 'the detection missed the reproduced effect' };
77
+ return o.detected ? { outcome: 'violated', reason: 'the detection fired where it must not' } : { outcome: 'met', reason: 'the detection stayed quiet where it must' };
78
+ }
79
+ const forbidden = o.forbiddenEffect === true; const legit = o.legitimateEffect === true;
80
+ if (c.expected === 'positive') return forbidden ? { outcome: 'met', reason: 'the vulnerable control reproduced the forbidden effect' } : { outcome: 'violated', reason: 'the vulnerable control did not reproduce the effect, so the fixture shows nothing' };
81
+ if (c.expected === 'negative') return forbidden ? { outcome: 'violated', reason: 'the forbidden effect occurred on the fixed artifact' } : { outcome: 'met', reason: 'no forbidden effect' };
82
+ return legit && !forbidden ? { outcome: 'met', reason: 'the legitimate operation succeeded' } : { outcome: 'violated', reason: 'the legitimate operation did not succeed' };
83
+ }
84
+
85
+ /**
86
+ * Recompute every gate from the packet's evidence.
87
+ * @param {object} packet
88
+ * @param {{requiredGates?:string[], requiredCases?:string[], requiredTiers?:Object<string,string>, now?:number, maxFeedAgeMs?:number}} o
89
+ * @returns {{status:'qualified'|'not-qualified', gates:object, cases:object[], blockers:object[], tiers:object}}
90
+ */
91
+ export function evaluateCandidate(packet, { requiredGates = Object.keys(GATES), requiredCases = [], requiredTiers = {}, now = Date.now(), maxFeedAgeMs = 15 * 60 * 1000 } = {}) {
92
+ const cases = (packet.cases || []).map((c) => ({ caseId: c.caseId ?? null, gate: c.gate ?? null, requirement: c.requirement ?? null, owner: c.owner ?? null, tier: c.tier ?? 'local', ...judgeCase(c, packet, now) }));
93
+ const blockers = [];
94
+ for (const id of requiredCases) if (!cases.some((c) => c.caseId === id)) { cases.push({ caseId: id, gate: null, outcome: 'unresolved', reason: 'a required case has no record' }); blockers.push({ gate: null, caseId: id, reason: 'missing required case' }); }
95
+ // gate-level evidence that is not a case
96
+ const extra = {};
97
+ if (!packet.scope || packet.scope.status !== 'satisfied') extra['G-02'] = { outcome: 'unresolved', reason: packet.scope ? `the scope reconciliation is ${packet.scope.status}` : 'no scope reconciliation is in the packet' };
98
+ if (!packet.runner || packet.runner.attested !== true) extra['G-04'] = { outcome: 'blocked', reason: packet.runner?.reason || 'the runner did not attest the confinement the execution cases need' };
99
+ const ev = packet.evaluation;
100
+ if (!ev || ev.status !== 'measured') extra['G-06'] = { outcome: 'blocked', reason: ev?.reason || 'independent accuracy is unmeasured for this candidate' };
101
+ else if (ev.meetsFloors !== true) extra['G-06'] = { outcome: 'violated', reason: 'the measured population does not meet the registered floors' };
102
+ for (const f of packet.telemetry?.feeds || []) {
103
+ if (!f.lastHeartbeat || now - Date.parse(f.lastHeartbeat) > maxFeedAgeMs) { extra['G-05'] = { outcome: 'unresolved', reason: `feed ${f.name} is stale or has no heartbeat` }; break; }
104
+ }
105
+ const gates = {};
106
+ for (const g of requiredGates) {
107
+ const need = requiredTiers[g];
108
+ const ofGate = cases.filter((c) => c.gate === g);
109
+ // a case of another tier is reported, but it cannot meet a gate that names its tier
110
+ const mine = need ? ofGate.filter((c) => c.tier === need) : ofGate;
111
+ const rank = { violated: 3, blocked: 2, unresolved: 1, met: 0 };
112
+ let worst = mine.reduce((w, c) => (rank[c.outcome] > rank[w] ? c.outcome : w), 'met');
113
+ const reasons = mine.filter((c) => c.outcome !== 'met').map((c) => `${c.caseId}: ${c.reason}`);
114
+ if (extra[g]) { if (rank[extra[g].outcome] > rank[worst]) worst = extra[g].outcome; reasons.push(extra[g].reason); }
115
+ // no evidence is not a pass: a gate with no case and no gate-level evidence stays unresolved
116
+ if (need && !mine.length) { worst = rank[worst] > rank.unresolved ? worst : 'unresolved'; reasons.push(`no ${need} evidence for this gate${ofGate.length ? ` (${ofGate.length} case(s) of another tier do not count)` : ''}`); }
117
+ else if (!mine.length && !extra[g] && !(g === 'G-02' && packet.scope?.status === 'satisfied') && !(g === 'G-04' && packet.runner?.attested === true) && !(g === 'G-06' && ev?.status === 'measured')) { worst = 'unresolved'; reasons.push('no evidence for this gate'); }
118
+ gates[g] = { name: GATES[g], outcome: worst, reasons };
119
+ if (worst !== 'met') blockers.push({ gate: g, reason: reasons.join('; ') });
120
+ }
121
+ const tiers = { local: 0, staging: 0, independent: 0 };
122
+ for (const c of cases) if (tiers[c.tier] !== undefined) tiers[c.tier]++;
123
+ return { status: blockers.length ? 'not-qualified' : 'qualified', scope: packet.target ? { ...packet.target } : null, scannerBundle: packet.scanner?.bundleDigest ?? null, packetDigest: packetDigest(packet), gates, cases, blockers, tiers };
124
+ }
125
+
126
+ /**
127
+ * Is a recorded decision still the decision for this target? A decision qualifies only the artifact, policy and scanner bundle it was
128
+ * computed for and only until its earliest case expiry, so an upgrade or policy change invalidates it and a rollback is covered only by
129
+ * the earlier artifact's own decision.
130
+ * @param {object} decision an `evaluateCandidate` result
131
+ * @param {{artifactDigest?:string, policyDigest?:string, scannerBundle?:string}} target
132
+ */
133
+ export function revalidateDecision(decision, target, { packet = null, now = Date.now() } = {}) {
134
+ if (!decision || typeof decision !== 'object' || !decision.scope) return { valid: false, reason: 'no recorded decision scope' };
135
+ if (decision.status !== 'qualified') return { valid: false, reason: 'the recorded decision did not qualify the candidate' };
136
+ if (!target || !target.artifactDigest || decision.scope.artifactDigest !== target.artifactDigest) return { valid: false, reason: 'the artifact differs from the one the decision qualified' };
137
+ if (decision.scope.policyDigest && decision.scope.policyDigest !== target.policyDigest) return { valid: false, reason: 'the deployment policy differs from the one the decision qualified' };
138
+ if (decision.scannerBundle && target.scannerBundle && decision.scannerBundle !== target.scannerBundle) return { valid: false, reason: 'the scanner bundle differs from the one the decision qualified' };
139
+ if (packet) {
140
+ if (packetDigest(packet) !== decision.packetDigest) return { valid: false, reason: 'the packet is not the one the decision was computed from' };
141
+ const soonest = Math.min(...(packet.cases || []).map((c) => Date.parse(c.expires)).filter(Number.isFinite));
142
+ if (Number.isFinite(soonest) && !(soonest > now)) return { valid: false, reason: 'evidence the decision rests on has expired' };
143
+ }
144
+ return { valid: true, reason: 'the decision covers this artifact, policy and scanner bundle' };
145
+ }
146
+
147
+ /** Write a decision atomically; an identical retried write leaves the same bytes. */
148
+ export function writeCandidateDecision(file, decision) {
149
+ const body = `${canon(decision)}\n`;
150
+ try { if (fs.readFileSync(file, 'utf8') === body) return { written: false, unchanged: true }; } catch { /* absent */ }
151
+ const tmp = path.join(path.dirname(file), `.${path.basename(file)}.${process.pid}.${crypto.randomBytes(4).toString('hex')}.tmp`);
152
+ fs.writeFileSync(tmp, body, { mode: 0o644 });
153
+ fs.renameSync(tmp, file);
154
+ return { written: true, unchanged: false };
155
+ }
@@ -2,9 +2,16 @@
2
2
  //
3
3
  // release-package build [path] --private-key <pem> [--output <file>] [--fix-records <file>] [--public-key-out <file>]
4
4
  // release-package verify <package.json> --public-key <pem> [--require kind,kind]
5
+ // release-package qualify <candidate-packet.json> --public-key <pem> [--require-case id,id] [--require-gate G-01,G-03]
6
+ // [--require-tier G-05=staging,...] [--output <decision.json>] [--format text|json]
7
+ //
8
+ // `qualify` is the strict candidate qualification of the threat-model PRD (FR-10.3): it verifies the packet's signature, recomputes
9
+ // every gate from the case evidence (`candidate-qualification.js`) and exits 0 only when the candidate is qualified. It never changes
10
+ // what an ordinary scan exits with.
5
11
  //
6
12
  // `io.git(root, ...args)` answers a read-only git query (or null); the caller supplies it so this layer never spawns a process itself.
7
- // Exit codes: 0 built or verified; 1 the scan state failed its integrity check, or a package was rejected; 2 a usage problem or unreadable input.
13
+ // Exit codes: 0 built, verified or qualified; 1 the scan state failed its integrity check, a package was rejected or a candidate was not
14
+ // qualified; 2 a usage problem or unreadable input.
8
15
  //
9
16
  // What goes in: the scan's health record and one record per finding (and, with --fix-records, one verification record per fix claimed). The proof tier of a
10
17
  // finding is whatever the scan recorded; a finding that recorded none is `taint-proven` when the taint engine found its path and `unproven` otherwise. An
@@ -18,8 +25,9 @@ import { buildSnapshotRef } from './evidence-registry.js';
18
25
  import { buildReleasePackage, signReleasePackage } from './release-package.js';
19
26
  import { releaseVerdict, verifyStatedVerdict } from './release-verdict.js';
20
27
  import { verifyReleasePackage } from './release-package-verify.js';
28
+ import { evaluateCandidate, verifyCandidatePacket, writeCandidateDecision, GATES } from './candidate-qualification.js';
21
29
 
22
- const USAGE = 'Usage: agentic-security release-package build [path] --private-key <pem> [--output <file>] [--fix-records <file>] [--public-key-out <file>]\n agentic-security release-package verify <package.json> --public-key <pem> [--require kind,kind]';
30
+ const USAGE = 'Usage: agentic-security release-package build [path] --private-key <pem> [--output <file>] [--fix-records <file>] [--public-key-out <file>]\n agentic-security release-package verify <package.json> --public-key <pem> [--require kind,kind]\n agentic-security release-package qualify <candidate-packet.json> --public-key <pem> [--require-case id,id] [--require-gate G-01,G-03] [--require-tier G-05=staging] [--output <decision.json>] [--format text|json]';
23
31
  const DEFAULT_OUT = 'release-package.json';
24
32
 
25
33
 
@@ -104,11 +112,45 @@ async function verify(args, say) {
104
112
  return 1;
105
113
  }
106
114
 
115
+ const listFlag = (v) => String(v ?? '').split(',').map((x) => x.trim()).filter(Boolean);
116
+
117
+ async function qualify(args, say) {
118
+ const file = args._[2];
119
+ const keyFile = args.flags['public-key'];
120
+ if (!file || !keyFile) { say.err(USAGE); return 2; }
121
+ let packet; let key;
122
+ try { packet = JSON.parse(fs.readFileSync(path.resolve(file), 'utf8')); key = fs.readFileSync(path.resolve(String(keyFile)), 'utf8'); } catch (e) { say.err(`Could not read the input: ${e.message}`); return 2; }
123
+ const requiredGates = args.flags['require-gate'] ? listFlag(args.flags['require-gate']) : Object.keys(GATES);
124
+ const unknown = requiredGates.filter((g) => !GATES[g]);
125
+ if (unknown.length) { say.err(`Unknown gate: ${unknown.join(', ')}. Gates: ${Object.keys(GATES).join(', ')}.`); return 2; }
126
+ const requiredTiers = {};
127
+ for (const pair of listFlag(args.flags['require-tier'])) {
128
+ const [g, t] = pair.split('=');
129
+ if (!GATES[g] || !['local', 'staging', 'independent'].includes(t)) { say.err(`--require-tier takes gate=tier pairs (tier local, staging or independent), got ${pair}`); return 2; }
130
+ requiredTiers[g] = t;
131
+ }
132
+ const sig = verifyCandidatePacket(packet, key);
133
+ if (!sig.ok) { say.err(`${sig.code}: the candidate packet ${sig.code === 'UNSUPPORTED_VERSION' ? `has version ${sig.version}, which this scanner does not read` : 'did not verify under the given key'}`); say.err('candidate NOT QUALIFIED'); return 1; }
134
+ const d = evaluateCandidate(packet, { requiredGates, requiredCases: listFlag(args.flags['require-case']), requiredTiers });
135
+ if (args.flags.output) {
136
+ try { writeCandidateDecision(path.resolve(String(args.flags.output)), d); } catch (e) { say.err(`Could not write the decision: ${e.message}`); return 2; }
137
+ }
138
+ if (args.flags.format === 'json') say.out(JSON.stringify(d, null, 2));
139
+ else {
140
+ const t = d.scope || {};
141
+ say.out(`candidate ${d.status === 'qualified' ? 'QUALIFIED' : 'NOT QUALIFIED'} for ${t.service || t.repository || 'an unnamed target'} artifact ${String(t.artifactDigest || 'unbound').slice(0, 19)} (${t.deploymentProfile || 'no profile'})`);
142
+ for (const [g, x] of Object.entries(d.gates)) say.out(` ${g} ${x.name}: ${x.outcome}${x.reasons.length ? ` (${x.reasons.join('; ')})` : ''}`);
143
+ say.out(` cases by tier: local ${d.tiers.local}, staging ${d.tiers.staging}, independent ${d.tiers.independent}`);
144
+ }
145
+ return d.status === 'qualified' ? 0 : 1;
146
+ }
147
+
107
148
  export async function cmdReleasePackage(args, io = {}) {
108
149
  const say = { out: io.out || ((s) => console.log(s)), err: io.err || ((s) => console.error(s)) };
109
150
  const sub = args._[1];
110
151
  if (sub === 'build') return build(args, say, io.git || (() => null));
111
152
  if (sub === 'verify') return verify(args, say);
153
+ if (sub === 'qualify') return qualify(args, say);
112
154
  say.err(USAGE);
113
155
  return 2;
114
156
  }