@clear-capabilities/agentic-security-scanner 0.195.28 → 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 (48) hide show
  1. package/CHANGELOG.md +41 -0
  2. package/bin/agentic-security.js +13 -4
  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/{3eabd90d6d9c8c8dbbd9.json → 7e58a58b8e6ccdde1659.json} +7 -6
  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 +7 -6
  11. package/src/egress/policy.js +79 -1
  12. package/src/engine.js +1 -1
  13. package/src/language/CLAUDE.md +1 -1
  14. package/src/language/haskell-guards.js +26 -5
  15. package/src/lineage/CLAUDE.md +3 -1
  16. package/src/lineage/deployment/adapter-cloud.js +5 -2
  17. package/src/lineage/deployment/attack-paths.js +107 -30
  18. package/src/lineage/deployment/boundaries-run.js +2 -2
  19. package/src/lineage/deployment/iam-eval.js +105 -0
  20. package/src/lineage/deployment/projection.js +10 -4
  21. package/src/lineage/deployment/proof-binding.js +79 -0
  22. package/src/llm-validator/bounded-context.js +115 -8
  23. package/src/llm-validator/index.js +1 -1
  24. package/src/mcp/CLAUDE.md +6 -0
  25. package/src/mcp/tools.js +27 -3
  26. package/src/pipeline/evidence-record.js +1 -1
  27. package/src/posture/CLAUDE.md +2 -0
  28. package/src/posture/assurance/candidate-qualification.js +155 -0
  29. package/src/posture/assurance/release-package-cli.js +44 -2
  30. package/src/posture/auditor-walkthrough.js +2 -1
  31. package/src/posture/dnr/detection-ir.js +2 -2
  32. package/src/posture/dnr/hunt.js +22 -6
  33. package/src/posture/dnr/log-reader.js +47 -3
  34. package/src/posture/dnr/matchers.js +43 -3
  35. package/src/posture/dnr/outputs.js +16 -2
  36. package/src/posture/dnr/payload-classes.js +12 -2
  37. package/src/posture/dnr/validate.js +17 -4
  38. package/src/posture/evaluation/report.js +2 -1
  39. package/src/posture/evaluation/score.js +24 -7
  40. package/src/posture/logic-claims.js +15 -14
  41. package/src/posture/sibling-queue.js +69 -0
  42. package/src/posture/triage-memory.js +106 -26
  43. package/src/posture/verification-separation.js +55 -7
  44. package/src/report/index.js +4 -0
  45. package/src/runScan.js +5 -0
  46. package/src/scope/flags.js +1 -0
  47. package/src/scope/inventory.js +17 -1
  48. package/src/scope/release-scope.js +80 -0
@@ -26,9 +26,12 @@ import { indexGraph, validateBoundaryGraph, boundaryCrossings } from './boundary
26
26
  import { semanticId, hypothesisIdFromFinding } from '../../posture/assurance/identity.js';
27
27
  import { validateVerificationRecord, deriveConfirmationLevel } from '../../posture/assurance/verification-record.js';
28
28
  import { nonTaintClassOf, SCENARIO_CLASSES } from '../../posture/oracles/scenario-classes.js';
29
+ import { decideAction, resourceMatches, UNKNOWN_ACTION } from './iam-eval.js';
30
+ import { judgePathPromotion } from './proof-binding.js';
29
31
 
30
32
  export const BOUNDARY_CONTEXT_SCHEMA = 'agentic-security/boundary-context';
31
- export const BOUNDARY_CONTEXT_VERSION = '1.0.0';
33
+ // 1.1.0 (threat-model FR-03.3): optional `truncation` (why a search stopped) and `action` (the action a query asked about).
34
+ export const BOUNDARY_CONTEXT_VERSION = '1.1.0';
32
35
 
33
36
  export const TRAVERSED_RELATIONS = Object.freeze(['routes-to', 'calls', 'depends-on', 'network-allows', 'assumes', 'grants']);
34
37
  const TRAVERSED = new Set(TRAVERSED_RELATIONS);
@@ -122,24 +125,61 @@ function hopsFrom(ix, nodeId) {
122
125
  else if (TRAVERSED.has(e.relation)) h.edges.push(e);
123
126
  hops.set(e.to, h);
124
127
  }
128
+ // threat-model FR-03.1: a deny statement whose resource is a pattern (`*`, `arn:aws:s3:::bucket/*`) is a node of its own, so before
129
+ // this it never reached the hop to the concrete resource it covers. It now applies to every resource its pattern matches.
130
+ const patternDenies = [];
131
+ for (const [to, h] of hops) {
132
+ const name = ix.nodesById.get(to)?.name ?? '';
133
+ if (/[*?]/.test(name)) for (const d of h.denies) if (d.relation === 'grants') patternDenies.push({ d, pattern: name });
134
+ }
135
+ for (const [to, h] of hops) {
136
+ const name = ix.nodesById.get(to)?.name ?? '';
137
+ for (const { d, pattern } of patternDenies) if (d.to !== to && resourceMatches(pattern, name) && !h.denies.includes(d)) h.denies.push(d);
138
+ }
125
139
  for (const [k, h] of [...hops]) if (!h.edges.length) hops.delete(k);
126
140
  return hops;
127
141
  }
128
142
 
129
- function hopState(h, from, to) {
130
- if (h.denies.length) return 'blocked';
143
+ function baseState(h, from, to) {
131
144
  if (h.edges.some((e) => e.pathState === 'runtime-supported')) return 'runtime-supported';
132
145
  if (!from.resolved || !to.resolved || h.edges.every((e) => e.pathState === 'unresolved' || e.pathState === 'blocked')) return 'unresolved';
133
146
  return 'possible';
134
147
  }
135
148
 
149
+ /**
150
+ * Decide a hop. A network deny, or a deny on a relation other than an IAM grant, blocks it as before. An IAM hop is decided per
151
+ * action (threat-model FR-03.1): with a requested action, that action only; without one, every action the allow statements grant, and
152
+ * the hop is possible when any of them is. A deny blocks only an action it matches, under a condition that holds for the request
153
+ * context; a condition that cannot be evaluated leaves the action unresolved.
154
+ * @returns {{state:string, denies:object[], actions:object[]|null}}
155
+ */
156
+ function hopDecision(h, from, to, q = null) {
157
+ const other = h.denies.filter((d) => d.relation !== 'grants');
158
+ if (other.length) return { state: 'blocked', denies: other.map((d) => ({ d, why: null })), actions: null };
159
+ const allows = h.edges.filter((e) => e.relation === 'grants' && e.effect === 'allow');
160
+ const iamDenies = h.denies.filter((d) => d.relation === 'grants');
161
+ const base = baseState(h, from, to);
162
+ if (!allows.length) return { state: base, denies: [], actions: null };
163
+ const recorded = [...new Set(allows.flatMap((e) => String(e.attrs?.actions ?? '').split(',').filter(Boolean)))].sort();
164
+ // with no IAM deny on the hop and no requested action there is nothing to decide per action: the grant stands as before
165
+ if (!iamDenies.length && !(q && q.action)) return { state: base, denies: [], actions: recorded.length ? recorded.map((action) => ({ action, outcome: base === 'unresolved' ? 'unresolved' : 'possible', blockedBy: [], unresolvedBy: [] })) : null };
166
+ const wanted = q && q.action ? [q.action] : (recorded.length ? recorded : [UNKNOWN_ACTION]);
167
+ const decisions = wanted.map((a) => decideAction(allows, iamDenies, a, q && q.context));
168
+ const pick = (o) => decisions.some((d) => d.outcome === o);
169
+ const state = pick('possible') ? base : pick('unresolved') ? 'unresolved' : pick('blocked') ? 'blocked' : 'not-granted';
170
+ const denies = state === 'blocked' ? decisions.flatMap((d) => d.blockers).map((b) => ({ d: iamDenies.find((x) => x.id === b.edgeId), why: b })) : [];
171
+ return { state, denies, actions: decisions.map(({ action, outcome, blockers, unresolvedBy }) => ({ action, outcome, blockedBy: blockers.map((b) => b.edgeId), unresolvedBy })) };
172
+ }
173
+
174
+ function hopState(h, from, to, q = null) { return hopDecision(h, from, to, q).state; }
175
+
136
176
  function isStale(e, nowMs, staleAfterMs) {
137
177
  if (e.attrs?.stale === true) return true;
138
178
  if (nowMs === null || !e.observationInterval) return false;
139
179
  return nowMs - Date.parse(e.observationInterval.end) > staleAfterMs;
140
180
  }
141
181
 
142
- function buildPath(ix, seq, hopList, nowMs, staleAfterMs) {
182
+ function buildPath(ix, seq, hopList, nowMs, staleAfterMs, q = null) {
143
183
  const nodes = seq.map((id) => ix.nodesById.get(id));
144
184
  const hops = [];
145
185
  const conditions = [];
@@ -151,16 +191,20 @@ function buildPath(ix, seq, hopList, nowMs, staleAfterMs) {
151
191
  hopList.forEach((h, i) => {
152
192
  from = nodes[i];
153
193
  const to = nodes[i + 1];
154
- const state = hopState(h, from, to);
194
+ const dec = hopDecision(h, from, to, q);
195
+ const state = dec.state;
155
196
  const crossing = boundaryCrossings(h.edges[0], ix.nodesById);
156
197
  for (const c of crossing) crossings.push({ hop: i, type: c.type, detail: c.detail });
157
198
  hops.push({
158
199
  index: i, from: nodeSummary(from), to: nodeSummary(to), relations: sortedUnique(h.edges.map((e) => e.relation)), state,
159
200
  edges: h.edges.map(edgeSummary).sort((a, b) => (a.id < b.id ? -1 : 1)),
201
+ ...(dec.actions ? { actions: dec.actions } : {}),
160
202
  });
161
- for (const d of h.denies) {
203
+ // each blocker is the rule that actually decided the hop, with the action and the condition it matched
204
+ for (const { d, why } of dec.denies) {
205
+ if (!d) continue;
162
206
  blockers.push({ hop: i, edgeId: d.id, relation: d.relation, provenance: d.provenance, source: d.source ? { file: d.source.file, digest: d.source.digest } : null,
163
- detail: `${d.relation} (deny) from ${from.name} to ${to.name}` });
207
+ detail: `${d.relation} (deny) from ${from.name} to ${to.name}`, ...(why ? { action: why.action, matchedActions: why.matchedActions, condition: why.condition } : {}) });
164
208
  }
165
209
  if (from.tenant !== null && to.tenant !== null && from.tenant !== to.tenant) {
166
210
  conditions.push({ kind: 'tenant-binding', hop: i, detail: `${from.name} (tenant ${from.tenant}) reaches ${to.name} (tenant ${to.tenant}): reachable only if no tenant binding is enforced on this transition, and the configuration does not establish either` });
@@ -193,6 +237,10 @@ function buildPath(ix, seq, hopList, nowMs, staleAfterMs) {
193
237
  else if (networkHops.length && networkHops.every((h) => h.state === 'runtime-supported')) state = 'runtime-supported';
194
238
  else state = 'possible';
195
239
 
240
+ // threat-model FR-03.2: a path that carries several actions keeps their separate outcomes; one is the worst over its IAM hops
241
+ const actionOutcomes = {};
242
+ const RANK = { possible: 0, unresolved: 1, blocked: 2, 'not-granted': 3 };
243
+ for (const hp of hops) for (const a of hp.actions || []) if (!(a.action in actionOutcomes) || RANK[a.outcome] > RANK[actionOutcomes[a.action]]) actionOutcomes[a.action] = a.outcome;
196
244
  const observedEnds = hops.flatMap((h) => h.edges).filter((e) => e.observationInterval).map((e) => e.observationInterval);
197
245
  const supportingEdgeIds = sortedUnique(hops.flatMap((h) => h.edges.map((e) => e.id)));
198
246
  const envs = sortedUnique([...hops.flatMap((h) => h.edges.map((e) => e.environment)), ...nodes.map((n) => n.environment)].filter(Boolean));
@@ -202,6 +250,8 @@ function buildPath(ix, seq, hopList, nowMs, staleAfterMs) {
202
250
  id: semanticId('bpath', { hops: hops.map((h) => h.edges.map((e) => e.id).sort()) }, ['hops']),
203
251
  entry: nodeSummary(nodes[0]), target: nodeSummary(nodes[nodes.length - 1]),
204
252
  state, conditional: conditions.length > 0, conditions, blockers, assumptions: [...assumptions].sort(),
253
+ ...(Object.keys(actionOutcomes).length ? { actionOutcomes: Object.fromEntries(Object.entries(actionOutcomes).sort()) } : {}),
254
+ ...(q && q.action ? { action: q.action } : {}),
205
255
  environments: envs, tenants,
206
256
  provenance: sortedUnique(hops.flatMap((h) => h.edges.map((e) => e.provenance))),
207
257
  crossings: dedupCross, supportingEdgeIds, hops,
@@ -239,7 +289,7 @@ function defaultTargets(graph) {
239
289
  */
240
290
  export function computeAttackPaths(graph, o = {}) {
241
291
  const v = validateBoundaryGraph(graph);
242
- if (!v.ok) return { ok: false, paths: [], entries: [], targets: [], truncated: false, limits: { ...PATH_LIMITS }, statement: 'the graph failed validation, so no path was computed', errors: v.errors };
292
+ if (!v.ok) return { ok: false, paths: [], entries: [], targets: [], truncated: false, truncation: { reasons: [], depthCutoffs: 0, frontier: [], frontierCapped: false }, limits: { ...PATH_LIMITS }, statement: 'the graph failed validation, so no path was computed', errors: v.errors };
243
293
  const limits = {
244
294
  maxHops: Math.min(o.maxHops ?? PATH_LIMITS.maxHops, PATH_LIMITS.maxHops), maxPaths: Math.min(o.maxPaths ?? PATH_LIMITS.maxPaths, PATH_LIMITS.maxPaths),
245
295
  maxExpansions: Math.min(o.maxExpansions ?? PATH_LIMITS.maxExpansions, PATH_LIMITS.maxExpansions),
@@ -252,25 +302,34 @@ export function computeAttackPaths(graph, o = {}) {
252
302
  const staleAfterMs = o.staleAfterMs ?? DEFAULT_STALE_AFTER_MS;
253
303
  const found = [];
254
304
  let expansions = 0;
255
- let truncated = false;
305
+ let stopped = false;
306
+ const q = o.action ? { action: String(o.action), context: o.context ?? {} } : (o.context ? { action: null, context: o.context } : null);
307
+ // threat-model FR-03.3 (TM-04): every bound that ended the search is recorded, so "no path" after a depth cutoff is never read as
308
+ // "no path exists". Reaching the depth bound on a node with no unvisited continuation is not a cutoff.
309
+ const cut = { depth: 0, results: false, expansions: false, frontier: [] };
310
+ const next = (seq, here) => [...hopsFrom(ix, here)].sort((a, b) => (a[0] < b[0] ? -1 : 1))
311
+ .filter(([to, h]) => !seq.includes(to) && !(q && q.action && hopState(h, ix.nodesById.get(here), ix.nodesById.get(to), q) === 'not-granted'));
256
312
 
257
313
  const walk = (seq, hopList) => {
258
- if (truncated) return;
314
+ if (stopped) return;
259
315
  const here = seq[seq.length - 1];
260
316
  if (hopList.length && targetSet.has(here)) {
261
- found.push(buildPath(ix, seq, hopList, Number.isNaN(nowMs) ? null : nowMs, staleAfterMs));
262
- if (found.length >= limits.maxPaths) { truncated = true; return; }
317
+ found.push(buildPath(ix, seq, hopList, Number.isNaN(nowMs) ? null : nowMs, staleAfterMs, q));
318
+ if (found.length >= limits.maxPaths) { stopped = true; cut.results = true; return; }
319
+ }
320
+ const cont = next(seq, here);
321
+ if (hopList.length >= limits.maxHops) {
322
+ if (cont.length) { cut.depth++; if (cut.frontier.length < 20) cut.frontier.push({ node: here, depth: hopList.length, unexplored: cont.length }); }
323
+ return;
263
324
  }
264
- if (hopList.length >= limits.maxHops) return;
265
- for (const [to, h] of [...hopsFrom(ix, here)].sort((a, b) => (a[0] < b[0] ? -1 : 1))) {
266
- if (seq.includes(to)) continue;
267
- if (++expansions > limits.maxExpansions) { truncated = true; return; }
325
+ for (const [to, h] of cont) {
326
+ if (++expansions > limits.maxExpansions) { stopped = true; cut.expansions = true; return; }
268
327
  walk([...seq, to], [...hopList, h]);
269
328
  }
270
329
  };
271
330
  for (const entry of entries) {
272
331
  if (o.includeSelf && targetSet.has(entry)) {
273
- const p = buildPath(ix, [entry], [], Number.isNaN(nowMs) ? null : nowMs, staleAfterMs);
332
+ const p = buildPath(ix, [entry], [], Number.isNaN(nowMs) ? null : nowMs, staleAfterMs, q);
274
333
  p.assumptions = sortedUnique([...p.assumptions, 'the entry point is itself the target; its public trust zone is declared in configuration']);
275
334
  found.push(p);
276
335
  }
@@ -278,9 +337,13 @@ export function computeAttackPaths(graph, o = {}) {
278
337
  }
279
338
  found.sort((a, b) => STATE_RANK[a.state] - STATE_RANK[b.state] || a.hops.length - b.hops.length || (a.id < b.id ? -1 : 1));
280
339
  const n = found.length;
340
+ const reasons = [...(cut.depth ? ['depth'] : []), ...(cut.results ? ['result-count'] : []), ...(cut.expansions ? ['expansion'] : [])];
341
+ const truncated = reasons.length > 0;
281
342
  return {
282
343
  ok: true, paths: found, entries, targets, truncated, limits,
283
- statement: `${n} path(s) found in the supplied configuration${truncated ? ' (search stopped at a limit; more may exist)' : ''}; a path that is not listed has not been shown to exist or to be absent`,
344
+ truncation: { reasons, depthCutoffs: cut.depth, frontier: cut.frontier, frontierCapped: cut.depth > cut.frontier.length },
345
+ ...(q && q.action ? { action: q.action } : {}),
346
+ statement: `${n} path(s) found in the supplied configuration${truncated ? ` (search stopped at a limit: ${reasons.join(', ')}; more may exist)` : ''}; a path that is not listed has not been shown to exist or to be absent`,
284
347
  errors: [],
285
348
  };
286
349
  }
@@ -306,7 +369,7 @@ export function exposureReach(graph, { entries } = {}) {
306
369
  const cur = best.get(id);
307
370
  for (const [to, h] of hopsFrom(ix, id)) {
308
371
  const st = hopState(h, ix.nodesById.get(id), ix.nodesById.get(to));
309
- if (st === 'blocked') continue;
372
+ if (st === 'blocked' || st === 'not-granted') continue;
310
373
  const state = cur.state === 'unresolved' || st === 'unresolved' ? 'unresolved' : 'definite';
311
374
  const prev = best.get(to);
312
375
  if (prev && (prev.state === 'definite' || state === 'unresolved')) continue;
@@ -324,7 +387,7 @@ export function exposureReach(graph, { entries } = {}) {
324
387
  * own claim: the record is validated, its confirmation level is recomputed from its evidence, and its oracle must be the one the
325
388
  * finding's class names (a finding of an unsupported class has no applicable oracle).
326
389
  */
327
- export function assessVerification(finding, records, hypothesisId = hypothesisIdFromFinding(finding)) {
390
+ export function assessVerification(finding, records, hypothesisId = hypothesisIdFromFinding(finding), { revision = null } = {}) {
328
391
  const out = [];
329
392
  const cls = nonTaintClassOf(finding);
330
393
  for (const rec of Array.isArray(records) ? records : []) {
@@ -340,12 +403,16 @@ export function assessVerification(finding, records, hypothesisId = hypothesisId
340
403
  const adapter = SCENARIO_CLASSES.find((c) => c.id === cls.classId)?.adapterId;
341
404
  if (!rec.oracle || rec.oracle.id !== adapter) reasons.push(`the record's oracle is not '${adapter}', the oracle for class '${cls.classId}'`);
342
405
  }
343
- out.push({ recordId: rec.id, outcome: rec.outcome, applicable: reasons.length === 0, reasons });
406
+ // threat-model FR-04.1: an exact matching commit is the minimum for a CURRENT component proof; a record for another revision is
407
+ // kept, with the reason, as historical evidence and never applied to this graph
408
+ let historical = false;
409
+ if (revision && rec.commit !== revision) { historical = true; reasons.push(`the record was made at ${String(rec.commit).slice(0, 12)}, not this graph's revision ${String(revision).slice(0, 12)}; it is historical evidence for that revision only`); }
410
+ out.push({ recordId: rec.id, outcome: rec.outcome, applicable: reasons.length === 0, reasons, ...(historical ? { historical: true, commit: rec.commit } : {}) });
344
411
  }
345
412
  return out.sort((a, b) => String(a.recordId).localeCompare(String(b.recordId)));
346
413
  }
347
414
 
348
- function labelPath(path, assessments) {
415
+ function labelPath(path, assessments, pc = null) {
349
416
  const ok = assessments.find((a) => a.applicable);
350
417
  if (!ok) {
351
418
  const why = assessments.length ? assessments.flatMap((a) => a.reasons).slice(0, 3).join('; ') : 'no verification record for this hypothesis was supplied';
@@ -353,7 +420,12 @@ function labelPath(path, assessments) {
353
420
  }
354
421
  if (path.state === 'blocked') return { label: 'oracle-confirmed-path-blocked', recordId: ok.recordId, reason: 'the oracle confirmed the effect in its declared scenario, but configuration explicitly denies a hop on this path' };
355
422
  if (path.state === 'unresolved') return { label: 'oracle-confirmed-path-unresolved', recordId: ok.recordId, reason: 'the oracle confirmed the effect in its declared scenario, but deployment reachability along this path is unresolved' };
356
- return { label: 'exploitable-confirmed', recordId: ok.recordId, reason: 'a trusted oracle confirmed the effect in its declared scenario; deployment reachability comes from configuration or sampled observation, not from a production probe' };
423
+ // threat-model FR-04.1 (TM-05): promotion to exploitable needs an issued binding of this proof to THIS path; a component proof alone
424
+ // is reported beside the path and does not promote it
425
+ const record = pc && pc.records.find((r) => r && r.id === ok.recordId);
426
+ const j = record ? judgePathPromotion({ path, kind: pc.kind, graph: pc.graph, record, bindings: pc.proofBindings, trustRoot: pc.trustRoot, now: pc.now, expect: pc.expect }) : { promote: false, code: 'component-only', reason: 'no binding was supplied', diagnostics: [] };
427
+ if (!j.promote) return { label: 'not-established', recordId: null, reason: `a trusted oracle confirmed the component, but it is not shown on this ${pc ? pc.kind : 'deployed'} path: ${j.reason}`, componentProof: { recordId: ok.recordId, code: j.code, diagnostics: j.diagnostics } };
428
+ return { label: 'exploitable-confirmed', recordId: ok.recordId, reason: 'a trusted oracle confirmed the effect in its declared scenario, and an issued binding ties that proof to this path; deployment reachability comes from configuration or sampled observation, not from a production probe', bindingId: j.bindingId };
357
429
  }
358
430
 
359
431
  // ------------------------------------------------------------ the per-finding context
@@ -384,21 +456,25 @@ function summarizeExposure(paths) {
384
456
  * @param {Array} [args.records] version-1 verification records
385
457
  * @param {number|string} [args.now]
386
458
  */
387
- export function analyzeFindingBoundaries({ graph, finding, bindings = [], records = [], now, maxHops, maxPaths } = {}) {
459
+ export function analyzeFindingBoundaries({ graph, finding, bindings = [], records = [], now, maxHops, maxPaths, action, context: requestContext, proofBindings = [], trustRoot = {}, expect = {} } = {}) {
388
460
  const v = validateBoundaryGraph(graph);
389
461
  if (!v.ok) return { ok: false, context: null, errors: v.errors };
390
462
  const hypothesisId = hypothesisIdFromFinding(finding);
391
463
  const binding = bindFindingToService(finding, bindings, graph);
392
- const assessments = assessVerification(finding, records, hypothesisId);
464
+ const assessments = assessVerification(finding, records, hypothesisId, { revision: graph.revision ?? null });
465
+ const clock = now === undefined || now === null ? Date.now() : (typeof now === 'number' ? now : Date.parse(now));
466
+ const pc = (kind) => ({ kind, graph, records: Array.isArray(records) ? records : [], proofBindings, trustRoot, now: clock, expect });
393
467
  const paths = [];
394
468
  let truncated = false;
395
- const popts = { now, maxHops, maxPaths };
469
+ const truncationReasons = new Set();
470
+ const popts = { now, maxHops, maxPaths, ...(action ? { action } : {}), ...(requestContext ? { context: requestContext } : {}) };
396
471
  if (binding.status === 'bound') {
397
472
  const exposure = computeAttackPaths(graph, { ...popts, targets: [binding.service.id], includeSelf: true });
398
473
  const impact = computeAttackPaths(graph, { ...popts, entries: [binding.service.id] });
399
474
  truncated = exposure.truncated || impact.truncated;
400
- for (const p of exposure.paths) paths.push({ kind: 'exposure', ...p, exploitability: labelPath(p, assessments) });
401
- for (const p of impact.paths) paths.push({ kind: 'impact', ...p, exploitability: labelPath(p, assessments) });
475
+ for (const r of [...exposure.truncation.reasons, ...impact.truncation.reasons]) truncationReasons.add(r);
476
+ for (const p of exposure.paths) paths.push({ kind: 'exposure', ...p, exploitability: labelPath(p, assessments, pc('exposure')) });
477
+ for (const p of impact.paths) paths.push({ kind: 'impact', ...p, exploitability: labelPath(p, assessments, pc('impact')) });
402
478
  }
403
479
  const exposurePaths = paths.filter((p) => p.kind === 'exposure');
404
480
  const exposure = binding.status === 'bound' ? summarizeExposure(exposurePaths)
@@ -422,7 +498,7 @@ export function analyzeFindingBoundaries({ graph, finding, bindings = [], record
422
498
  const warnings = ['Runtime traffic coverage is not established by any supplied observation; configured relationships were not exercised.'];
423
499
  if (binding.status !== 'bound') warnings.push(`This finding is not tied to a deployed service (${binding.status}); no path was computed.`);
424
500
  if (graph.gaps.length) warnings.push(`The boundary graph has ${graph.gaps.length} gap(s) (${sortedUnique(graph.gaps.map((g) => g.code)).join(', ')}); relationships behind them are missing, not absent.`);
425
- if (truncated) warnings.push('The path search stopped at a limit; more paths may exist.');
501
+ if (truncated) warnings.push(`The path search stopped at a limit (${[...truncationReasons].sort().join(', ')}); more paths may exist, so no listed absence is a proof of absence.`);
426
502
 
427
503
  const context = {
428
504
  schema: BOUNDARY_CONTEXT_SCHEMA, schemaVersion: BOUNDARY_CONTEXT_VERSION, id: '', hypothesisId,
@@ -435,7 +511,8 @@ export function analyzeFindingBoundaries({ graph, finding, bindings = [], record
435
511
  tenants: sortedUnique(graph.nodes.map((n) => n.tenant).filter(Boolean)),
436
512
  gapCodes: sortedUnique(graph.gaps.map((g) => g.code)), gapCount: graph.gaps.length,
437
513
  },
438
- exposure, paths, truncated,
514
+ exposure, paths, truncated, truncation: { reasons: [...truncationReasons].sort() },
515
+ ...(action ? { action } : {}),
439
516
  verification: assessments, uncovered, warnings,
440
517
  };
441
518
  context.id = semanticId('bctx', { hypothesisId, graph: graph.digest, binding: binding.binding, service: binding.service?.id ?? null, paths: paths.map((p) => p.id) }, ['hypothesisId', 'graph', 'binding', 'service', 'paths']);
@@ -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
+ }