From 66298598dbce02edc863ee1d320d8555ab6b2cb5 Mon Sep 17 00:00:00 2001 From: Animesh Srivastava Date: Fri, 2 Oct 2026 13:35:57 +0530 Subject: [PATCH 1/4] feat(reach): show agent reach in the report and on the dashboard `@agents`, `@reaches`, `@effects` and `@gates` reach `lookup`, `diff`, the model JSON (`reach_analysis`) and the pentest SARIF, but nothing a person reads drew them: the report and the dashboard ignored all four. This adds one derived view, `src/reach`, and draws it on both surfaces so they cannot disagree. It adds no join of its own: unentitled reaches come from `findUnentitledReaches` and gating from `classifyEffects`, the same answers `reach_analysis` and the SARIF export carry. What the view holds, for every actor that reaches something: - the reach map: actor x asset, with the capabilities exposed on the asset and the effects bound to the same code as the actor's reach. An effect with no reach bound to its code goes in a "not tied to a reach" row rather than a row the model never drew; - unentitled reaches, each near miss with the reason it does not cover; - ungated mutations, each gate near miss with its reason; - gates, egress from a reached surface (a `@flows` that leaves the model or crosses a `@boundary`), and injection-to-tool routes; - the OWASP Top 10 for LLM Applications rows, keyed on `@agents`: LLM06 (unentitled reach, a broader execution identity with no gate, an ungated mutation), LLM01 (outside input that flows into an agent with an ungated mutation) and LLM05 (a mutating effect bound to the agent tool's code). Surfaces: - `guardlink report` gains an "Agents and LLM Reach" section when any reach annotation exists, plus three summary rows; a model without them reports byte-for-byte as before. - `guardlink report --agents` (and `guardlink_report` with `agents: true`) writes the same section as its own document, threat-model-agents.md. - The dashboard gains an Agents & Reach page with an empty state, an Agent Reach diagram tab, an Actors table on Data & Boundaries, a Reach block in the asset drawer, an AI-agent mark on agent tiles, and "What to do next" items for unentitled reaches and ungated mutations. - `guardlink threat-report` hands the LLM the reach claims and the derived lists as `agent_reach`, and the prompt describes the four verbs. Tests: tests/agent-reach-surfaces.test.ts reads tests/fixtures/support-desk, the fixture the reach export and pentest SARIF are pinned on, and pins the derived view, the report section, the agent-only report and the reach diagram as golden files beside it. It checks that the view agrees with `reach_analysis`, covers injection, egress and a broader identity on a small inline app, and covers the empty states, escaping on the dashboard and the `--agents` CLI path. SARIF and the JSON export are unchanged. --- CHANGELOG.md | 7 + docs/GUARDLINK_REFERENCE.md | 3 +- docs/SPEC.md | 2 + src/analyze/index.ts | 40 ++ src/analyze/prompts.ts | 4 + src/cli/index.ts | 15 +- src/dashboard/analytics.ts | 31 +- src/dashboard/client.ts | 10 + src/dashboard/data.ts | 26 + src/dashboard/diagrams.ts | 100 ++- src/dashboard/generate.ts | 21 +- src/dashboard/pages/agents.ts | 207 ++++++ src/dashboard/pages/assets.ts | 4 +- src/dashboard/pages/context.ts | 5 +- src/dashboard/pages/data-boundaries.ts | 21 + src/dashboard/pages/diagrams.ts | 7 + src/dashboard/styles.ts | 36 + src/mcp/server.ts | 14 +- src/reach/index.ts | 11 + src/reach/summary.ts | 601 ++++++++++++++++ src/report/agent-reach.ts | 261 +++++++ src/report/index.ts | 1 + src/report/report.ts | 19 + tests/agent-reach-surfaces.test.ts | 337 +++++++++ tests/fixtures/support-desk/FIXTURE.md | 8 + .../golden/agent-reach-section.md | 126 ++++ .../support-desk/golden/agent-reach.mmd | 34 + .../support-desk/golden/reach-summary.json | 677 ++++++++++++++++++ .../golden/threat-model-agents.md | 137 ++++ 29 files changed, 2748 insertions(+), 17 deletions(-) create mode 100644 src/dashboard/pages/agents.ts create mode 100644 src/reach/index.ts create mode 100644 src/reach/summary.ts create mode 100644 src/report/agent-reach.ts create mode 100644 tests/agent-reach-surfaces.test.ts create mode 100644 tests/fixtures/support-desk/golden/agent-reach-section.md create mode 100644 tests/fixtures/support-desk/golden/agent-reach.mmd create mode 100644 tests/fixtures/support-desk/golden/reach-summary.json create mode 100644 tests/fixtures/support-desk/golden/threat-model-agents.md diff --git a/CHANGELOG.md b/CHANGELOG.md index c35e3f4e..95705530 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,13 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), ### Added +- **The agent part of the threat model, in the report and on the dashboard.** What `@agents`, `@reaches`, `@effects` and `@gates` declare is now drawn where people read the model, from one derived view (`src/reach`) so the two cannot disagree (SPEC §3.2.1, *Where they are read*): + - **`guardlink report`** gains an **Agents and LLM Reach** section when any reach annotation exists: per actor, each capability, whether it is entitled and what it leads to; the reach map (actor × asset, capability and effect per cell); unentitled reaches with each near miss's reason; **ungated mutations** (an effect other than `read` with no `@gates` in front of it); gates; egress from a reached surface; open exposures on reached assets; and the OWASP Top 10 for LLM Applications rows — LLM06 Excessive Agency, LLM01 Prompt Injection, LLM05 Improper Output Handling. A model without reach annotations reports exactly as before. + - **`guardlink report --agents`** (and `guardlink_report` with `agents: true`) writes that section as a document of its own, `threat-model-agents.md`, so an LLM threat model can be published alone or as part of the whole one. + - **The dashboard** gains an **Agents & Reach** page (the reach map, the same lists and the OWASP rows, with an empty state when nothing declares reach), an **Agent Reach** diagram tab, an **Actors** table on Data & Boundaries marking each actor an AI agent, principal or approver, a **Reach** block in the asset drawer, and "What to do next" items for unentitled reaches and ungated mutations. + - **`guardlink threat-report`** hands the LLM the reach claims and the same derived lists as `agent_reach`, and its prompt asks for an agents section mapped to the OWASP LLM rows. + - An effect is tied to an actor only when a reach of that actor is bound to the same code, and whether it is gated comes from `classifyEffects`, the join `reach_analysis` and the SARIF export use, so every surface gives one answer. + - **Reach: what an embedded LLM agent can access, as annotations.** An application that embeds an agent hands it capabilities through its harness — tool definitions, MCP servers, file, shell and database access — and none of that was declarable, so `guardlink diff` did not move when someone registered a `run_sql` tool. Four relation verbs declare it, each where its fact lives (SPEC §3.2.1): - `@agents to [on ] [as ]` on the tool registration, and `@reaches` in the same shape for a principal that is not an LLM agent (a CI runner, a service account). Writing `@agents` about an actor is what marks it as an agent, and one actor named under both verbs is a validation error, `agent-reach-conflict`. - `@effects on [as ]` on the code that acts. The effect set is closed, like `@handles`; egress stays on `@flows … -> External.X`. diff --git a/docs/GUARDLINK_REFERENCE.md b/docs/GUARDLINK_REFERENCE.md index 49889411..47d33ff8 100644 --- a/docs/GUARDLINK_REFERENCE.md +++ b/docs/GUARDLINK_REFERENCE.md @@ -64,7 +64,7 @@ Append after severity: `cwe:CWE-89`, `owasp:A03:2021`, `capec:CAPEC-66`, `attack 4. **Every `@exposes` needs a response.** Match with `@mitigates` (fix exists) or `@audit` (flag for human review). AI agents must NEVER write `@accepts` — that is a human-only governance decision. Use `@audit` instead. 5. **Use the full verb set.** `@flows` for data movement, `@handles` for data classification, `@boundary` for trust boundaries. 6. **`@entitles` is proposed, never written by hand.** An over-grant closes a real privilege escalation as by-design, so the claim goes through a review artifact: propose it (`guardlink entitle --propose`, or the `guardlink_entitlement_propose` MCP tool), and a human accepts it with `guardlink entitle` — acceptance is what writes the annotation, with their name next to it. An `@entitles` in source with no accepted proposal behind it is a validation error. The rationale must cite the authz code (`Authz: common/api/metadata.go:189`); without a `file:line` pointer the claim is **inert** — parsed but ignored — and accepting it takes an explicit acknowledgement of that. It never hides a finding and never gates testing; it only changes what triage recommends. Never propose one for an ownership question (IDOR, tenant isolation): both peers hold the capability, so entitlement cannot answer *whose object it was*. -7. **Declare what an embedded agent can reach.** When code hands an LLM agent a tool, an MCP server, file, shell or database access, write `@agents` on the registration, `@effects` on the code that acts and `@gates` on any approval step. `@reaches` is the same claim for a principal that is not an agent; naming one actor under both verbs is a validation error, because writing `@agents` about an actor is what marks it as an agent. The capability is the token an `@entitles` would use, so `guardlink lookup unentitled reaches` lists the capabilities no human approved (SPEC §3.2.1). +7. **Declare what an embedded agent can reach.** When code hands an LLM agent a tool, an MCP server, file, shell or database access, write `@agents` on the registration, `@effects` on the code that acts and `@gates` on any approval step. `@reaches` is the same claim for a principal that is not an agent; naming one actor under both verbs is a validation error, because writing `@agents` about an actor is what marks it as an agent. The capability is the token an `@entitles` would use, so `guardlink lookup unentitled reaches` lists the capabilities no human approved (SPEC §3.2.1). `guardlink report` adds an **Agents and LLM Reach** section mapped to the OWASP Top 10 for LLM Applications (`--agents` writes it alone), and the dashboard's **Agents & Reach** page draws the reach map. ### Standalone `.gal` Files @@ -146,6 +146,7 @@ guardlink ci . --expiring-within 30 # Days of notice before an @a # Reports & Export guardlink report [dir] # Generate threat-model.md + optional JSON +guardlink report . --agents # Agent threat model alone: reach map, unentitled reaches, ungated mutations, OWASP LLM mapping guardlink dashboard [dir] # Interactive HTML dashboard with Mermaid diagrams guardlink sarif [dir] [-o file] # SARIF 2.1.0 for GitHub Advanced Security / VS Code; @exposes and @confirmed results carry guardlink/threatId, and @exposes results also carry guardlink/claimKey; each also carries its declared context — CWE/OWASP taxa, boundaries and assumptions on its asset, the @flows chain into it — and the run carries the flow/boundary graph (SPEC §6.6) guardlink sarif [dir] --profile pentest [--baseline old.sarif] # the same results, then covered exposures (guardlink/mitigated-exposure, with suppressions), one guardlink/boundary-claim per @boundary and one guardlink/agent-reach per reach and per mutating effect appended after them; every claim carries its hypothesis-ledger state; --baseline sets baselineState (SPEC §6.8) diff --git a/docs/SPEC.md b/docs/SPEC.md index 6a840d0d..bfede956 100644 --- a/docs/SPEC.md +++ b/docs/SPEC.md @@ -765,6 +765,8 @@ async function requireApproval(refund: Refund) { … } All four verbs are agent-writable. None of them has export semantics: they add no `github`-profile SARIF result, remove none, and never close a finding (§6.1). The `pentest` profile lists every reach and every mutating effect as a target to test (§6.8). `guardlink validate` reports an actor or approver never declared with `@actor` as `undeclared-actor` (an error, as on `@entitles`) and an undefined asset or identity `#id` as `dangling-ref`. +**Where they are read.** `guardlink report` adds an *Agents and LLM Reach* section when any of the four verbs is present (`guardlink report --agents` writes it as a document of its own), and the dashboard draws the same data on its *Agents & Reach* page. Both show the reach map (actor × asset, with the capabilities and effects in each cell), the unentitled reaches with each near miss's reason, the ungated effects with the gate near misses (the join above, the one `reach_analysis` carries), the gates, and `@flows` out of a reached surface that leave the model or cross a `@boundary`. An effect sits in an actor's row only when a reach of that actor is bound to the same code; an effect with none is shown as not tied to a reach, because reaching it through calls across handlers is a call-graph question. The OWASP Top 10 for LLM Applications (2025) rows key on `@agents`: **LLM06** Excessive Agency (an unentitled reach; an effect running as a different identity than the agent presents with no gate between; an ungated mutation bound to the agent's reach), **LLM01** Prompt Injection (a `@flows` source that is neither a declared asset nor an actor, with nothing flowing into it, reaching an agent that has an ungated mutation), and **LLM05** Improper Output Handling (a write, delete, execute or notify bound to the same code as the agent's tool). Each is a target for review or test, never a finding. + ### 3.3. Lifecycle Annotations These annotations support security process and governance. diff --git a/src/analyze/index.ts b/src/analyze/index.ts index f72fa0f0..b24012cc 100644 --- a/src/analyze/index.ts +++ b/src/analyze/index.ts @@ -25,6 +25,7 @@ import { type LLMConfig, chatCompletion } from './llm.js'; import { GUARDLINK_TOOLS, createToolExecutor } from './tools.js'; import { formatConfidence, redactEvidence } from './format.js'; import { loadProjectConfig } from '../agents/config.js'; +import { hasReach, summarizeReach } from '../reach/index.js'; import { findUnmitigatedExposures, annotationCount } from '../parser/coverage.js'; import { parseFindingsBlock, validateFindings, stripFindingsBlock, type Finding, type Unresolved } from './findings.js'; import type { ReportShapeId } from '../playbooks/index.js'; @@ -420,6 +421,8 @@ export function serializeModel(model: ThreatModel): string { description: en.description, citation: en.citation?.raw, inert: en.inert, file: en.location.file, line: en.location.line, })); + const reach = serializeAgentReach(model); + if (reach) compact.agent_reach = reach; if (model.comments.length) compact.comments = model.comments.map(c => ({ description: c.description, file: c.location.file, line: c.location.line, })); @@ -449,6 +452,39 @@ export function serializeModel(model: ThreatModel): string { return JSON.stringify(compact, null, 2); } +/** + * The agent part of the model, for a threat report's LLM: every reach, effect + * and gate with its location, and the derived lists the report section prints + * (unentitled reaches, ungated mutations, egress, and the OWASP LLM Top 10 + * rows), so a framework report reasons from the same can-minus-may answer as + * `guardlink report`. Absent when the model has no reach annotation. + */ +export function serializeAgentReach(model: ThreatModel): Record | undefined { + if (!hasReach(model)) return undefined; + const s = summarizeReach(model); + const at = (l: { file: string; line: number }) => `${l.file}:${l.line}`; + return { + reaches: (model.reaches || []).map(r => ({ + actor: r.actor, agent: r.agent, capability: r.canonical_capability, asset: r.asset, identity: r.identity, + description: r.description, file: r.location.file, line: r.location.line, + })), + effects: (model.effects || []).map(e => ({ + effect: e.effect, asset: e.asset, identity: e.identity, description: e.description, + file: e.location.file, line: e.location.line, + })), + gates: (model.gates || []).map(g => ({ + asset: g.asset, approver: g.approver, capability: g.canonical_capability, description: g.description, + file: g.location.file, line: g.location.line, + })), + unentitled_reaches: s.unentitled.map(u => `${u.actor}${u.agent ? ' (agent)' : ''} to ${u.capability}${u.asset ? ` on ${u.asset}` : ''} (${at(u.loc)})${u.near_misses.length ? ` — ${u.near_misses.map(n => n.reason).join('; ')}` : ''}`), + ungated_mutations: s.ungated.map(u => `${u.effect} on ${u.asset} (${at(u.loc)})${u.via.length ? ` via ${u.via.map(v => `${v.actor} ${v.capability}`).join(', ')}` : ''}`), + egress: s.egress.map(e => `${e.actors.join(', ')}: ${e.source} -> ${e.target}${e.boundaries.length ? ` crossing ${e.boundaries.join(', ')}` : ''} (${at(e.loc)})`), + owasp_llm: s.owasp.filter(o => o.items.length > 0).map(o => ({ + id: o.id, title: o.title, items: o.items.map(i => `${i.facet}: ${i.agent} ${i.text} (${at(i.loc)})`), + })), + }; +} + /** * Compact serialization for MCP agent mode. * @@ -533,6 +569,10 @@ export function serializeModelCompact(model: ThreatModel): string { compact.data_handling = model.data_handling.map(h => `${h.asset}: ${h.classification}`); } + // What agents can reach, and what of it is unentitled or ungated — the LLM part of the model + const reach = serializeAgentReach(model); + if (reach) compact.agent_reach = reach; + // Mitigation count per asset (not full details — just "how defended is each asset?") if (model.mitigations.length) { const mitByAsset: Record = {}; diff --git a/src/analyze/prompts.ts b/src/analyze/prompts.ts index aa005d53..ab9e6f6c 100644 --- a/src/analyze/prompts.ts +++ b/src/analyze/prompts.ts @@ -59,6 +59,9 @@ You will receive: - **@assumes** — a security assumption the developer is relying on (potential blind spot) - **@actor** — a principal in the authorization model (a role, not a person) - **@entitles** — a capability an actor holds **by design**: the privilege needed to trigger the effect is a privilege that already grants that effect. It is NOT a mitigation and NOT an acceptance — the exposure is still real and still testable. Only treat it as a reason a finding is by-design when the *measured* minimum privilege matches the entitled actor, and never for an ownership question (IDOR, tenant isolation), where both peers hold the capability. An entitlement whose description cites no authorization code is inert; ignore it. +- **@agents** / **@reaches** — an LLM agent (\`@agents\`) or another principal (\`@reaches\`) **can** invoke a capability: what the harness hands out. Not a permission. \`agent_reach.unentitled_reaches\` lists the ones no cited @entitles covers — for an agent, the OWASP LLM06 Excessive Agency list. +- **@effects** — what code **does** to an asset: read, write, delete, execute, spend or notify, optionally as an execution identity. A mutating effect with no @gates in front of it is listed in \`agent_reach.ungated_mutations\`. +- **@gates** — a named approver decides before effects on an asset land. It suppresses nothing; it is a node on a path, not a mitigation. - **@audit** — marks an asset as requiring human review - **hypothesis** (a field on an @exposes row, when present) — what happened when it was tested: 'confirmed' with evidence is proven; 'refuted' with evidence was tried and found not exploitable and is **not an open risk** (report it as refuted, keep it out of the priority list); 'retest' was confirmed before and the code beneath it changed since @@ -70,6 +73,7 @@ You will receive: - Challenge accepted risks: "You accepted this — is that reasonable given the severity and blast radius?" - Distinguish **pentest-confirmable threats** from **governance/design-only gaps**. Pentest-confirmable issues should include concrete validation ideas; governance/design-only risks should be called out explicitly as **audit-required** items with suggested @audit/@comment annotations instead of fake exploit claims. - Always reference **specific files, assets, and threat IDs** from the model. Never give generic advice. +- If the model carries **agent_reach**, include an **Agents and LLM reach** section: each agent, what it can reach, which reaches are unentitled and which mutations are ungated, mapped to the OWASP Top 10 for LLM Applications rows in \`agent_reach.owasp_llm\` (LLM06 Excessive Agency, LLM01 Prompt Injection, LLM05 Improper Output Handling). Treat each as a target to verify against the code, not as a proven finding. ## Output structure diff --git a/src/cli/index.ts b/src/cli/index.ts index 82d325f7..f19c440a 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -53,7 +53,7 @@ import { runCiChecks, formatCiReport, ESTATE_ROUTE } from '../ci/index.js'; import type { CiWorkspaceScope } from '../ci/index.js'; import { initProject, detectProject, promptAgentSelection, syncAgentFiles } from '../init/index.js'; import { ensurePromptMd } from '../init/migrate.js'; -import { generateReport, generateMermaid } from '../report/index.js'; +import { generateReport, generateMermaid, generateAgentReachReport } from '../report/index.js'; import { diffModels, formatDiff, formatDiffMarkdown, parseAtRef, getChangedFiles } from '../diff/index.js'; import { findUnmitigatedPaths, classifyEndpoints } from '../paths/index.js'; import { formatPaths } from '../paths/format.js'; @@ -843,7 +843,8 @@ program .option('--json', 'Also output threat-model.json alongside the report (legacy; prefer --format)') .option('--feature ', 'Filter report to specific feature(s) (comma-separated)') .option('--blame', 'Add an Attribution section: who introduced and fixed each claim, per person and per AI tool (read from git)') - .action(async (dir: string, opts: { project: string; output?: string; format: string; diagramOnly?: boolean; json?: boolean; feature?: string; blame?: boolean }) => { + .option('--agents', 'Write only the agent threat model: what each agent and principal can reach, unentitled reaches, ungated mutations and the OWASP LLM mapping (default file threat-model-agents.md)') + .action(async (dir: string, opts: { project: string; output?: string; format: string; diagramOnly?: boolean; json?: boolean; feature?: string; blame?: boolean; agents?: boolean }) => { const root = resolve(dir); // Validate --format before doing any work. An unrecognised value used to @@ -909,6 +910,16 @@ program } const { writeFile } = await import('node:fs/promises'); + + // The agent threat model alone: the same section the full report carries, + // with no JSON beside it, because the export is the whole model's. + if (opts.agents) { + const mdFile = opts.output || 'threat-model-agents.md'; + await writeFile(resolve(root, mdFile), generateAgentReachReport(enrichedModel) + '\n'); + console.error(`✓ Wrote agent threat model to ${mdFile}`); + return; + } + const wantJson = opts.format === 'json' || opts.format === 'both' || opts.json; const wantMd = opts.format === 'md' || opts.format === 'both' || opts.json; diff --git a/src/dashboard/analytics.ts b/src/dashboard/analytics.ts index 73f48db0..cb874b2b 100644 --- a/src/dashboard/analytics.ts +++ b/src/dashboard/analytics.ts @@ -13,6 +13,8 @@ import type { ThreatModel } from '../types/index.js'; import type { ClaimState } from '../parser/verification.js'; import type { ThreatModelDiff } from '../diff/engine.js'; import type { AssetHeatmapEntry } from './data.js'; +import type { ReachSummary } from '../reach/index.js'; +import { canonicaliser } from '../parser/canonical-ref.js'; export type SevKey = 'critical' | 'high' | 'medium' | 'low' | 'unset'; export const SEV_ORDER: SevKey[] = ['critical', 'high', 'medium', 'low', 'unset']; @@ -427,13 +429,39 @@ export interface AssetDetail { attribution: { introducers: { identity: string; count: number }[]; ai: number; aiTools: string[]; oldestOpenDays: number | null } | null; /** Indices into claimsData for this asset's claims. */ claimIdx: number[]; + /** Who can reach this asset and what the code does to it; null when no reach annotation names it. */ + reach: { + capabilities: { actor: string; agent: boolean; capability: string; entitled: boolean }[]; + effects: { actor: string | null; effect: string; gated: boolean | null; approvers: string[]; via: string[] }[]; + gates: { approver: string; capability: string | null }[]; + } | null; } const DAY = 86_400_000; /** Everything the asset drawer shows, one entry per heatmap tile (same order). */ -export function computeAssetDetails(model: ThreatModel, claims: ClaimLike[], heatmap: AssetHeatmapEntry[], asOf: string | null): AssetDetail[] { +export function computeAssetDetails(model: ThreatModel, claims: ClaimLike[], heatmap: AssetHeatmapEntry[], asOf: string | null, reach?: ReachSummary): AssetDetail[] { const key = (ref: string): string => ref.trim().toLowerCase(); + const reachKey = canonicaliser(model); + const actorOf = new Map((reach?.actors ?? []).map(a => [a.key, a])); + const reachFor = (names: Set): AssetDetail['reach'] => { + if (!reach) return null; + const keys = new Set([...names].map(n => reachKey(n))); + const cells = reach.cells.filter(c => keys.has(c.asset)); + const loose = reach.loose.filter(l => keys.has(l.asset)); + const gates = reach.gates.filter(g => keys.has(reachKey(g.asset))); + if (cells.length === 0 && loose.length === 0 && gates.length === 0) return null; + return { + capabilities: cells.flatMap(c => c.capabilities.map(cap => ({ + actor: actorOf.get(c.actor)?.ref ?? c.actor, agent: actorOf.get(c.actor)?.agent ?? false, capability: cap.capability, entitled: cap.entitled, + }))), + effects: [ + ...cells.flatMap(c => c.effects.map(e => ({ actor: actorOf.get(c.actor)?.ref ?? c.actor, effect: e.effect, gated: e.gated, approvers: e.approvers, via: e.via }))), + ...loose.flatMap(l => l.effects.map(e => ({ actor: null, effect: e.effect, gated: e.gated, approvers: e.approvers, via: [] }))), + ], + gates: gates.map(g => ({ approver: g.approver, capability: g.capability ?? null })), + }; + }; const asOfMs = asOf ? Date.parse(asOf) : Number.NaN; return heatmap.map(tile => { const names = new Set([tile.name, ...tile.aliases].map(key)); @@ -499,6 +527,7 @@ export function computeAssetDetails(model: ThreatModel, claims: ClaimLike[], hea oldestOpenDays: oldest, } : null, claimIdx: mine.map(c => c.idx), + reach: reachFor(names), }; }); } diff --git a/src/dashboard/client.ts b/src/dashboard/client.ts index 2add08ae..35b48d8d 100644 --- a/src/dashboard/client.ts +++ b/src/dashboard/client.ts @@ -467,6 +467,16 @@ function renderAssetDrawer(idx) { + a.flowsOut.map(function (f) { return '
' + esc(a.name) + ' → ' + esc(f.to) + '' + (f.via ? ' via ' + esc(f.via) + '' : '') + '
'; }).join('') + ''; } + if (a.reach) { + var rp = []; + a.reach.capabilities.forEach(function (c) { rp.push('
' + esc(c.actor) + '' + (c.agent ? ' AI agent' : '') + ' can ' + esc(c.capability) + '
'); }); + a.reach.effects.forEach(function (e) { + var st = e.gated === null ? 'read' : e.gated ? 'gated' : 'ungated'; + rp.push('
' + (e.actor ? '' + esc(e.actor) + '' : 'code not tied to a reach') + ' does ' + esc(e.effect) + (e.gated === false ? ' · no gate' : e.gated ? ' · gated by ' + esc(e.approvers.join(', ')) : '') + '' + (e.via.length ? ' via ' + esc(e.via.join(', ')) + '' : '') + '
'); + }); + a.reach.gates.forEach(function (g) { rp.push('
' + esc(g.approver) + ' approves first' + (g.capability ? ' for ' + esc(g.capability) + '' : '') + '
'); }); + h += '
Reach
' + rp.join('') + '
'; + } var facts = []; if (a.dataHandling.length) facts.push(sec('Handles', a.dataHandling.map(function (d) { return '' + esc(d) + ''; }).join(' '))); if (a.boundaries.length) facts.push(sec('Trust boundaries with', a.boundaries.map(function (b) { return '' + esc(b) + ''; }).join(' '))); diff --git a/src/dashboard/data.ts b/src/dashboard/data.ts index 0d86d7d6..709014ee 100644 --- a/src/dashboard/data.ts +++ b/src/dashboard/data.ts @@ -3,6 +3,7 @@ * Converts ThreatModel into dashboard-ready statistics. */ +import type { ReachSummary } from '../reach/index.js'; import type { ExposureHypothesis, ThreatModel } from '../types/index.js'; import { buildCoverageIndex, annotationCount } from '../parser/coverage.js'; import { entriesFromModel, summarise } from '../blame/summary.js'; @@ -369,6 +370,8 @@ export interface ActionInput { verification: VerificationReport | null; attribution: AttributionData | null; scope: string[] | null; + /** What agents and principals can reach; absent in callers that predate it. */ + reach?: ReachSummary; } const sevKey = (s: string): string => { @@ -453,6 +456,29 @@ export function computeActions(input: ActionInput): DashboardAction[] { } } + if (input.reach && input.reach.totals.unentitled > 0) { + const u = input.reach.unentitled; + const agents = u.filter(r => r.agent).length; + out.push({ + id: 'unentitled-reaches', level: agents > 0 ? 'high' : 'medium', count: u.length, + title: `${u.length} ${plural(u.length, 'capability', 'capabilities')} handed out that nobody approved`, + detail: `${agents} held by an AI agent (OWASP LLM06 Excessive Agency), ${u.length - agents} by another principal. Remove the tool, or have a human accept an entitlement for it.`, + href: '#agents', + command: 'guardlink lookup "unentitled reaches"', + }); + } + if (input.reach && input.reach.totals.ungated > 0) { + const g = input.reach.ungated; + const agents = g.filter(r => r.agent).length; + out.push({ + id: 'ungated-mutations', level: agents > 0 ? 'high' : 'medium', count: g.length, + title: `${g.length} ${plural(g.length, 'mutation')} with no one deciding first`, + detail: `${agents} reachable by an AI agent. A write, delete, execute, spend or notify with no @gates in front of it lands on the model's say-so alone.`, + href: '#agents', + command: 'guardlink lookup "effects"', + }); + } + const inert = (model.entitlements || []).filter(e => e.inert).length; if (inert > 0) { out.push({ diff --git a/src/dashboard/diagrams.ts b/src/dashboard/diagrams.ts index 88402c97..7f22dffe 100644 --- a/src/dashboard/diagrams.ts +++ b/src/dashboard/diagrams.ts @@ -1,10 +1,11 @@ /** * GuardLink Dashboard — diagram generators. * - * Three Mermaid diagrams. + * Four Mermaid diagrams. * - generateThreatGraph — LR flowchart of assets, threats, controls, mitigations * - generateDataFlowDiagram — LR flow graph with trust boundary groupings * - generateAttackSurface — TB grouping of exposures per asset, severity-coloured + * - generateReachDiagram — LR graph of what agents and principals reach, from a ReachSummary * * All three generators share a single alias map so that #id, bare id, name, and * path.join() forms of an asset/threat/control collapse onto the same node. @@ -18,6 +19,7 @@ import type { ThreatModel } from '../types/index.js'; import { buildCoverageIndex } from '../parser/coverage.js'; +import type { ReachSummary } from '../reach/index.js'; /* ══════════════════════════════════════════════════════════════════════════ * Shared sanitizers and ranking utilities @@ -772,3 +774,99 @@ export function generateAttackSurface(model: ThreatModel, opts: DiagramOptions = return lines.join('\n'); } + +/* ══════════════════════════════════════════════════════════════════════════ + * Diagram 4: Agent Reach + * ══════════════════════════════════════════════════════════════════════════ */ + +/** + * LR graph of what each agent and principal can reach: one edge per actor and + * asset for the capabilities (dashed and red when one is unentitled), one per + * actor and asset for the effects (red when a mutation has no gate), and a + * dotted edge from each approver to the asset it gates. Built from the same + * `ReachSummary` the Agents page tabulates, so the two cannot disagree. + * Empty when nothing declares reach. + */ +export function generateReachDiagram(s: ReachSummary): string { + if (s.totals.reaches === 0 && s.totals.effects === 0) return ''; + const lines: string[] = [ + '%%{init: {"flowchart": {"nodeSpacing": 40, "rankSpacing": 90, "curve": "basis", "htmlLabels": false}}}%%', + 'graph LR', + ]; + const red: number[] = []; + const ok: number[] = []; + let edge = 0; + const actorId = (k: string) => `A_${mid(k)}`; + const assetId = (k: string) => `S_${mid(k)}`; + const colRef = new Map(s.columns.map(c => [c.key, c.ref])); + const used = new Set(); + + for (const a of s.actors) { + if (a.reaches === 0 && a.approves === 0) continue; + const text = labelFull(a.ref); + lines.push(a.agent + ? ` ${actorId(a.key)}(["${text} · AI agent"]):::agent` + : a.reaches > 0 ? ` ${actorId(a.key)}["${text}"]:::principal` : ` ${actorId(a.key)}(("${text}")):::approver`); + } + const node = (k: string) => { + if (used.has(k)) return assetId(k); + used.add(k); + lines.push(` ${assetId(k)}[("${labelFull(colRef.get(k) ?? k)}")]`); + return assetId(k); + }; + + for (const c of s.cells) { + const to = node(c.asset); + const granted = c.capabilities.filter(x => x.entitled).map(x => x.capability); + const unapproved = c.capabilities.filter(x => !x.entitled).map(x => x.capability); + // One capability edge per cell, so two labels never stack on the same pair of nodes. + if (unapproved.length > 0) { + const text = `unentitled: ${unapproved.join(', ')}${granted.length > 0 ? ` · entitled: ${granted.join(', ')}` : ''}`; + lines.push(` ${actorId(c.actor)} -. "${label(text, 140)}" .-> ${to}`); + red.push(edge++); + } else if (granted.length > 0) { + lines.push(` ${actorId(c.actor)} -- "${label(granted.join(', '), 60)}" --> ${to}`); + ok.push(edge++); + } + const ungated = c.effects.filter(e => e.gated === false).map(e => e.effect); + const rest = c.effects.filter(e => e.gated !== false).map(e => (e.gated ? `${e.effect} (gated)` : e.effect)); + if (ungated.length > 0) { lines.push(` ${actorId(c.actor)} == "${label(`${[...new Set(ungated)].join(', ')} · no gate`, 60)}" ==> ${to}`); red.push(edge++); } + if (rest.length > 0) { lines.push(` ${actorId(c.actor)} -- "${label([...new Set(rest)].join(', '), 60)}" --> ${to}`); edge++; } + } + if (s.loose.length > 0) { + lines.push(' X_loose["code not tied to a reach"]:::loose'); + for (const l of s.loose) { + const to = node(l.asset); + const ungated = l.effects.some(e => e.gated === false); + lines.push(` X_loose ${ungated ? '==' : '--'} "${label([...new Set(l.effects.map(e => e.effect))].join(', '), 60)}" ${ungated ? '==>' : '-->'} ${to}`); + if (ungated) red.push(edge); + edge++; + } + } + for (const g of s.gates) { + const approver = s.actors.find(a => a.ref === g.approver); + if (!approver) continue; + const k = s.columns.find(c => c.ref === g.asset)?.key; + if (!k) continue; + lines.push(` ${actorId(approver.key)} -. "gates${g.capability ? ` ${label(g.capability, 30)}` : ''}" .-> ${node(k)}`); + ok.push(edge++); + } + for (const e of s.egress) { + const from = s.columns.find(c => c.ref === e.source)?.key; + const src = from ? node(from) : null; + if (!src) continue; + const tid = `E_${mid(e.target)}`; + if (!used.has(tid)) { used.add(tid); lines.push(` ${tid}>"${labelFull(e.target)}"]:::external`); } + lines.push(` ${src} -- "${label(`${e.mechanism ?? 'egress'}${e.boundaries.length ? ` · crosses ${e.boundaries.join(', ')}` : ''}`, 60)}" --> ${tid}`); + edge++; + } + + lines.push(' classDef agent stroke:#33d49d,stroke-width:2px'); + lines.push(' classDef principal stroke:#1a98d0,stroke-width:1.5px'); + lines.push(' classDef approver stroke:#33d49d,stroke-dasharray:4 3'); + lines.push(' classDef loose stroke-dasharray:4 3'); + lines.push(' classDef external stroke:#ea1d1d,stroke-dasharray:4 3'); + if (red.length > 0) lines.push(` linkStyle ${red.join(',')} stroke:#ea1d1d,stroke-width:2px`); + if (ok.length > 0) lines.push(` linkStyle ${ok.join(',')} stroke:#33d49d`); + return lines.join('\n'); +} diff --git a/src/dashboard/generate.ts b/src/dashboard/generate.ts index 607ac3d6..d897a783 100644 --- a/src/dashboard/generate.ts +++ b/src/dashboard/generate.ts @@ -14,7 +14,9 @@ * * Pages: Summary (with a "what changed since " strip under --since), * Analytics (heatmaps, owners, sensitive data and distributions over the same - * claim rows the tables show, recomputed per feature), Threats, Diagrams (with + * claim rows the tables show, recomputed per feature), Threats, Agents & Reach + * (the reach map, unentitled reaches, ungated mutations, gates and the OWASP + * LLM rows, with an empty state when nothing declares reach), Diagrams (with * one focused graph per exposed asset), Code (riskiest file first), Reports, * Data, Assets and, with --blame, Attribution. * @@ -43,7 +45,7 @@ import type { ThreatReportWithContent } from '../analyze/index.js'; import { computeStats, computeSeverity, computeSeverityOf, computeExposures, computeConfirmed, computeAssetHeatmap, computeAttribution, computeActions, computeLedgerStates, computeAssetDetails, computeOwnership, computeFileRisk, fileRiskRank, computeChanges, newClaimKeys } from './data.js'; import type { SinceInput } from './analytics.js'; import type { SeverityBreakdown } from './data.js'; -import { generateThreatGraph, generateDataFlowDiagram, generateAttackSurface } from './diagrams.js'; +import { generateThreatGraph, generateDataFlowDiagram, generateAttackSurface, generateReachDiagram } from './diagrams.js'; import { detectRepoLinks } from './links.js'; import { readHypotheses, classifyHypotheses, attachHypotheses } from '../hypothesis/index.js'; import { buildFileAnnotations, buildAnalysisData } from './annotations.js'; @@ -63,6 +65,8 @@ import { renderAssetsPage } from './pages/assets.js'; import { renderAttributionPage } from './pages/attribution.js'; import { renderAnalyticsPage, type AnalyticsVariant } from './pages/analytics.js'; import { buildExploreData } from './explore.js'; +import { renderAgentsPage } from './pages/agents.js'; +import { summarizeReach } from '../reach/index.js'; export function computeRiskGrade(sev: SeverityBreakdown, unmitigatedCount: number, totalExposures: number, confirmedCount = 0): { grade: string; label: string; summary: string } { if (confirmedCount > 0) return { grade: 'F', label: 'Critical Risk', summary: `${confirmedCount} confirmed exploitable finding(s) — immediate remediation required` }; @@ -119,7 +123,7 @@ function countByAsset(rows: { asset: string }[], model: ThreatModel): Map = { summary: 'layout', analytics: 'grid', threats: 'alert', explore: 'search', diagrams: 'diagram', code: 'code', 'ai-analysis': 'file', data: 'lock', assets: 'map', attribution: 'users' }; +const NAV_ICON: Record = { summary: 'layout', analytics: 'grid', threats: 'alert', agents: 'zap', explore: 'search', diagrams: 'diagram', code: 'code', 'ai-analysis': 'file', data: 'lock', assets: 'map', attribution: 'users' }; function navLink(page: string, label: string, active = false, badge?: string): string { return `${icon(NAV_ICON[page] ?? 'square')} ${label}${badge ? `${badge}` : ''}`; @@ -180,9 +184,11 @@ export function generateDashboardHTML(rawModel: ThreatModel, root?: string, anal const fm = filterByFeature(model, [f]); return { feature: f, input: { scope: [f], model: fm, claims: buildClaims(fm, links, ledger, { newKeys }), attribution: computeAttribution(fm), heatmap: computeAssetHeatmap(fm) } }; }); - const actions = computeActions({ model, exposures, confirmed, verification: ledgerRead?.report ?? null, attribution, scope, unownedExposed: ownership.unowned.map(u => u.asset) }); + // What agents and other principals can reach: the Agents page, the asset drawer, the actor table and the actions read it. + const reach = summarizeReach(model); + const actions = computeActions({ model, exposures, confirmed, verification: ledgerRead?.report ?? null, attribution, scope, unownedExposed: ownership.unowned.map(u => u.asset), reach }); // One record per heatmap tile, in tile order: the asset drawer indexes it by the tile's position. - const assetsData = computeAssetDetails(model, claims, heatmap, attribution?.as_of ?? null); + const assetsData = computeAssetDetails(model, claims, heatmap, attribution?.as_of ?? null, reach); // Every Explore answer, selected and drawn here rather than in the browser — // see src/dashboard/explore.ts for why the selection stays on this side. const exploreData = buildExploreData({ @@ -195,7 +201,7 @@ export function generateDashboardHTML(rawModel: ThreatModel, root?: string, anal const ctx: PageContext = { model, scope, scopeFiles, links, hostLabel: hostLabel(links), stats, severity, exposures, confirmed, unmitigated, mitigatedCount, mitigationCoveragePercent, risk, - claims, ledger, attribution, actions, heatmap, fileAnnotations, + claims, ledger, attribution, actions, heatmap, fileAnnotations, reach, diagrams: { // No emoji on the page: shapes and severity classes carry what the // committed .mmd artifacts say with icons. @@ -204,6 +210,7 @@ export function generateDashboardHTML(rawModel: ThreatModel, root?: string, anal dataFlow: generateDataFlowDiagram(model, { icons: 'none' }), attackSurface: generateAttackSurface(model, { icons: 'none' }), focus, + reach: generateReachDiagram(reach), }, analyses: analyses || [], changes, @@ -293,6 +300,7 @@ ${scope ? `
${navLink('summary', 'Executive Summary', true)} ${navLink('analytics', 'Analytics')} ${navLink('threats', 'Threats & Exposures', false, unmitigated.length > 0 ? String(unmitigated.length) : undefined)} + ${navLink('agents', 'Agents & Reach', false, reach.totals.unentitled + reach.totals.ungated > 0 ? String(reach.totals.unentitled + reach.totals.ungated) : undefined)} ${navLink('explore', 'Explore')} ${navLink('diagrams', 'Diagrams')} ${navLink('code', 'Code & Annotations')} @@ -315,6 +323,7 @@ ${renderSummaryPage(ctx)} ${renderAnalyticsPage(ctx, variants)} ${renderReportsPage(ctx)} ${renderThreatsPage(ctx)} +${renderAgentsPage(ctx, reach)} ${renderExplorePage(ctx, exploreData)} ${renderDiagramsPage(ctx)} ${renderCodePage(ctx)} diff --git a/src/dashboard/pages/agents.ts b/src/dashboard/pages/agents.ts new file mode 100644 index 00000000..86ff9611 --- /dev/null +++ b/src/dashboard/pages/agents.ts @@ -0,0 +1,207 @@ +/** + * GuardLink Dashboard — Agents & Reach: what each embedded LLM agent and other + * principal can reach, what a human approved, and what acts with no one + * deciding first. + * + * The reach map is actor × asset with the capabilities and effects in each + * cell; below it the unentitled reaches (with why a near-miss entitlement does + * not cover each), the ungated mutations, the gates, egress from a reach, the + * injection-to-tool routes and the OWASP LLM Top 10 rows. Every number comes + * from `summarizeReach`, which the report's "Agents and LLM Reach" section + * prints too. + * + * @mitigates #dashboard against #xss using #output-encoding -- "Actor, asset and capability names, descriptions and citations are model text; every one goes through esc(), including chip titles" + * @flows ThreatModel -> #dashboard via summarizeReach -- "Reaches, effects, gates, entitlements and flows for the Agents page" + */ +import { esc, scopeLabel, sectionHead, subHead, sortableHead, rowAttrs, locCellShort, descCell, pager, icon, plural } from '../html.js'; +import type { ReachSummary, ReachEffectChip, ReachCapabilityChip, ReachLoc } from '../../reach/index.js'; +import type { PageContext } from './context.js'; + +const where = (l: ReachLoc): string => `${l.file}:${l.line}`; + +/** + * The empty state's example, with `@` written as an entity so this source file + * does not read as carrying the annotations it shows. + */ +const REACH_EXAMPLE = [ + '@agents #support-agent to run-sql on #tool-surface -- "run_sql tool"', + '@effects write on #users-db as #db-admin -- "Runs model-written SQL"', + '@gates #payments by #support-human for issue-refund -- "requireApproval() waits for a person"', +].join('\n'); + +function capChip(c: ReachCapabilityChip): string { + const title = `${c.entitled ? 'Entitled' : 'Unentitled: no cited @entitles covers it'}${c.identity ? ` · as ${c.identity}` : ''} · ${where(c.loc)}`; + return `${c.entitled ? icon('check') : icon('x')}${esc(c.capability)}`; +} + +function effectChip(e: ReachEffectChip): string { + const state = e.gated === null ? 'read' : e.gated ? 'gated' : 'ungated'; + const label = e.gated === null ? e.effect : e.gated ? `${e.effect} · gated` : `${e.effect} · no gate`; + const title = [ + e.gated === null ? 'Read: no gate needed' : e.gated ? `Gated by ${e.approvers.join(', ')}` : 'Mutation with no @gates in front of it', + e.via.length ? `via ${e.via.join(', ')}` : '', + e.via.length ? 'bound to the same code as the reach' : 'not tied to any reach', + e.identity ? `runs as ${e.identity}` : '', + where(e.loc), + ].filter(Boolean).join(' · '); + return `${esc(label)}`; +} + +const jump = (id: string): string => + `onclick="event.preventDefault();var t=document.getElementById('${id}');if(t)t.scrollIntoView({behavior:'smooth',block:'start'})"`; + +function kpiJump(value: number, label: string, target: string, tone: string, hint: string): string { + return `${value}${esc(label)}${esc(hint)}`; +} + +function reachMap(s: ReachSummary): string { + const rows = s.actors.filter(a => a.reaches > 0); + if (rows.length === 0 || s.columns.length === 0) return ''; + const head = `Actor${s.columns.map(c => `${esc(c.ref)}`).join('')}`; + const body = rows.map(a => { + const cells = s.columns.map(col => { + const c = s.cells.find(x => x.actor === a.key && x.asset === col.key); + if (!c) return ''; + return `${c.capabilities.map(capChip).join('')}${c.effects.map(effectChip).join('')}`; + }).join(''); + return `${esc(a.ref)}${a.agent ? 'AI agent' : 'principal'}${cells}`; + }).join(''); + const loose = s.loose.length > 0 + ? `not tied to a reachcall graph${s.columns.map(col => { + const l = s.loose.find(x => x.asset === col.key); + return l ? `${l.effects.map(effectChip).join('')}` : ''; + }).join('')}` + : ''; + return `
${head}${body}${loose}
+
+ ${icon('check')}capability entitled + ${icon('x')}capability no cited @entitles + write · no gate mutation, no @gates + spend · gated a named approver decides first + read read +
`; +} + +export function renderAgentsPage(ctx: PageContext, s: ReachSummary): string { + const { scope, links } = ctx; + const loc = (l: ReachLoc): string => locCellShort(l.file, l.line, links); + const t = s.totals; + const empty = t.reaches === 0 && t.effects === 0 && t.gates === 0; + + if (empty) { + return ` +
+ ${sectionHead(icon('zap'), 'Agents & Reach', scope)} +

${scope + ? `No @agents, @reaches, @effects or @gates in the files tagged ${esc(scopeLabel(scope))}. The project may declare them elsewhere — this slice does not show them.` + : 'No @agents, @reaches, @effects or @gates annotations, so nothing in the model says what an embedded agent or another principal can reach.'}

+

Declare it where each fact lives in the code — on the tool registration, the code that acts, and the approval step:

+
${REACH_EXAMPLE}
+
`; + } + + const llm = s.owasp.reduce((n, o) => n + o.items.length, 0); + return ` +
+ ${sectionHead(icon('zap'), 'Agents & Reach', scope, `${t.agents} ${plural(t.agents, 'agent')} · ${t.principals} other ${plural(t.principals, 'principal')}`)} +

What each embedded LLM agent (@agents) and other principal (@reaches) can invoke, what the code does (@effects), who decides first (@gates), and what of it a human approved (@entitles). An effect sits in an actor's row only when it is written in the same doc-block as the actor's reach, so both are bound to the same code.${scope ? ` Only reach annotations in the files tagged ${esc(scopeLabel(scope))} are shown.` : ''}

+
+ ${kpiJump(t.reaches, 'Reaches', 'agents-map', '', `${t.agents} ${plural(t.agents, 'agent')}, ${t.principals} other`)} + ${kpiJump(t.unentitled, 'Unentitled', 'agents-unentitled', t.unentitled > 0 ? 'danger' : 'success', 'can, but no one approved')} + ${kpiJump(t.ungated, 'Ungated mutations', 'agents-ungated', t.ungated > 0 ? 'danger' : 'success', `of ${t.mutations} ${plural(t.mutations, 'mutation')}`)} + ${kpiJump(t.gates, 'Gates', 'agents-gates', '', 'a named approver decides')} + ${kpiJump(t.egress, 'Egress', 'agents-egress', t.egress > 0 ? 'warn' : '', 'leaves the model or a boundary')} + ${kpiJump(llm, 'OWASP LLM items', 'agents-owasp', llm > 0 ? 'warn' : '', 'LLM06 · LLM01 · LLM05')} +
+ + + ${subHead('Reach map', '', `${s.columns.length} ${plural(s.columns.length, 'asset')}`).replace('
', '
')} +

Rows are actors, columns the assets they reach; each cell holds the capabilities exposed on that asset and the effects the actor's tools have on it. The Diagrams page draws the same as a graph.

+ ${reachMap(s)} + + ${subHead('Unentitled reaches', '', `${s.unentitled.length}`).replace('
', '
')} +

Capabilities the code hands out that no cited @entitles covers — can minus may. For an agent this is the OWASP LLM06 Excessive Agency list. Only a human closes one, by accepting a proposal from guardlink entitle --propose.

+ ${s.unentitled.length > 0 ? ` +
+ ${sortableHead([{ key: 'actor', label: 'Actor' }, { key: 'capability', label: 'Capability' }, { key: 'asset', label: 'On' }, { key: 'why', label: 'Why nothing covers it', plain: true }, { key: 'location', label: 'Location', cls: 'loc' }])} + + ${s.unentitled.map(u => ` + n.blocker)] })}> + + + + + ${loc(u.loc)} + `).join('')} + +
${esc(u.actor)}${u.agent ? ' AI agent' : ''}${esc(u.capability)}${u.asset ? `${esc(u.asset)}` : '—'}${u.near_misses.length === 0 + ? 'No @entitles for this actor and capability' + : u.near_misses.map(n => `
${esc(n.blocker)} ${esc(n.reason)} ${esc(where(n.loc))}
`).join('')}
+ ${pager('agent-unentitled')}` : '

Every reach is covered by a cited entitlement.

'} + + ${subHead('Ungated mutations', '', `${s.ungated.length}`).replace('
', '
')} +

Effects other than read with no @gates in front of them. A gate suppresses nothing; its absence means nothing in the model says a person or a check decides before the effect lands.

+ ${s.ungated.length > 0 ? ` +
+ ${sortableHead([{ key: 'effect', label: 'Effect' }, { key: 'asset', label: 'Asset' }, { key: 'identity', label: 'As' }, { key: 'via', label: 'Reached through' }, { key: 'description', label: 'Description', plain: true }, { key: 'location', label: 'Location', cls: 'loc' }])} + + ${s.ungated.map(u => ` + [v.actor, v.capability])] })}> + + + + + ${descCell(u.description, '—')} + ${loc(u.loc)} + `).join('')} + +
${esc(u.effect)}${esc(u.asset)}${u.identity ? `${esc(u.identity)}` : '—'}${u.via.length > 0 ? u.via.map(v => `${esc(v.actor)} ${esc(v.capability)}${v.agent ? ' AI agent' : ''}`).join('
') : 'No reach on this code'}${u.gate_near_misses.map(n => `
${esc(n.blocker)} ${esc(n.approver)}${n.capability ? ` for ${esc(n.capability)}` : ''}: ${esc(n.reason)}
`).join('')}
+ ${pager('agent-ungated')}` : `

${t.mutations > 0 ? 'Every mutation has a gate in front of it.' : 'No mutating effect is declared.'}

`} + + ${subHead('Gates', '', `${s.gates.length}`).replace('
', '
')} + ${s.gates.length > 0 ? ` +
+ ${sortableHead([{ key: 'asset', label: 'Asset' }, { key: 'approver', label: 'Approver' }, { key: 'for', label: 'For' }, { key: 'covers', label: 'Stands in front of', plain: true }, { key: 'description', label: 'Description', plain: true }, { key: 'location', label: 'Location', cls: 'loc' }])} + + ${s.gates.map(g => ` + + + + + + ${descCell(g.description, '—')} + ${loc(g.loc)} + `).join('')} + +
${esc(g.asset)}${esc(g.approver)}${g.capability ? `${esc(g.capability)}` : 'every capability'}${g.covers.length > 0 ? g.covers.map(c => `${esc(c.effect)} ${esc(c.asset)}`).join('
') : 'No declared mutation on this asset'}
` : '

No @gates: nothing in the model says a person decides before any effect lands.

'} + + ${s.egress.length > 0 ? ` + ${subHead('Egress from a reach', '', `${s.egress.length}`).replace('
', '
')} +

@flows out of an actor or a surface it reaches that leave the model or cross a declared @boundary.

+
+ ${sortableHead([{ key: 'actors', label: 'Actors' }, { key: 'flow', label: 'Flow' }, { key: 'external', label: 'Leaves the model' }, { key: 'crosses', label: 'Crosses' }, { key: 'location', label: 'Location', cls: 'loc' }])} + + ${s.egress.map(e => ` + + + + + + ${loc(e.loc)} + `).join('')} + +
${e.actors.map(a => `${esc(a)}`).join(' ')}${esc(e.source)} → ${esc(e.target)}${e.mechanism ? ` via ${esc(e.mechanism)}` : ''}${e.external ? 'yes' : 'no'}${e.boundaries.length > 0 ? e.boundaries.map(b => `${esc(b)}`).join(' ') : '—'}
` : ''} + + ${t.agents > 0 ? ` + ${subHead('OWASP Top 10 for LLM Applications', '', `${llm}`).replace('
', '
')} +

What the reach annotations add up to for the 2025 list. Each row is a target for review or a test, not a finding the model has proved.

+
+ ${s.owasp.map(o => ` +
+
${esc(o.id)} ${esc(o.title)} ${o.items.length}
+

${esc(o.basis)}

+ ${o.items.length > 0 ? `
    ${o.items.map(i => `
  • ${esc(i.facet)} ${esc(i.agent)} ${esc(i.text)} ${esc(where(i.loc))}
  • `).join('')}
` : '

Nothing found.

'} +
`).join('')} +
` : ''} +
`; +} diff --git a/src/dashboard/pages/assets.ts b/src/dashboard/pages/assets.ts index 0bca8d58..6cf6ce96 100644 --- a/src/dashboard/pages/assets.ts +++ b/src/dashboard/pages/assets.ts @@ -9,6 +9,8 @@ import type { PageContext } from './context.js'; export function renderAssetsPage(ctx: PageContext): string { const { heatmap, scope } = ctx; + // A flow can name an agent actor as an endpoint, which puts it on this grid; say what it is. + const agentRefs = new Set(ctx.reach.actors.filter(a => a.agent).flatMap(a => [a.ref.toLowerCase(), a.name.toLowerCase()])); const counts = { critical: 0, high: 0, medium: 0, low: 0, none: 0 }; for (const a of heatmap) counts[a.riskLevel]++; return ` @@ -28,7 +30,7 @@ export function renderAssetsPage(ctx: PageContext): string {
${heatmap.map((a, i) => `
-
${esc(a.name)}
+
${esc(a.name)}${agentRefs.has(a.name.toLowerCase()) ? ' AI agent' : ''}
${icon('alert')}${a.exposures} ${icon('shield')}${a.mitigations} diff --git a/src/dashboard/pages/context.ts b/src/dashboard/pages/context.ts index c893ac82..1285df0d 100644 --- a/src/dashboard/pages/context.ts +++ b/src/dashboard/pages/context.ts @@ -12,6 +12,7 @@ * @handles pii on #dashboard -- "Author identities from attribution, already in the configured identity mode" * @comment -- "Pure; every string here is raw and is escaped by the page that renders it" */ +import type { ReachSummary } from '../../reach/index.js'; import type { ExposureHypothesis, ThreatModel } from '../../types/index.js'; import type { ClaimState, VerificationReport } from '../../parser/verification.js'; import { buildCoverageIndex } from '../../parser/coverage.js'; @@ -98,12 +99,14 @@ export interface PageContext { actions: DashboardAction[]; heatmap: AssetHeatmapEntry[]; fileAnnotations: FileAnnotationGroup[]; - diagrams: { threatGraph: string; threatGraphFull: string; dataFlow: string; attackSurface: string; focus: { name: string; src: string }[] }; + diagrams: { threatGraph: string; threatGraphFull: string; dataFlow: string; attackSurface: string; focus: { name: string; src: string }[]; /** Empty when nothing declares reach. */ reach?: string }; analyses: ThreatReportWithContent[]; /** What changed since --since , or null without the flag. */ changes: ChangeSummary | null; /** Per annotated file: what it carries, for the Code page order and badges. */ fileRisk: Map; + /** What agents and other principals can reach (src/reach). */ + reach: ReachSummary; } const aiLabel = (ref: CommitRef): string[] => ref.assisted_by.map(a => (a.model ? `${a.tool} (${a.model})` : a.tool)); diff --git a/src/dashboard/pages/data-boundaries.ts b/src/dashboard/pages/data-boundaries.ts index 4df488a8..80f6e9f7 100644 --- a/src/dashboard/pages/data-boundaries.ts +++ b/src/dashboard/pages/data-boundaries.ts @@ -20,6 +20,7 @@ export function renderDataPage(ctx: PageContext): string { add('validations', 'Validations', model.validations.length); add('ownership', 'Ownership', model.ownership.length); add('audits', 'Audit Items', model.audits.length); + add('actors', 'Actors', ctx.reach.actors.length); add('entitlements', 'Entitlements', (model.entitlements || []).length); add('assumptions', 'Assumptions', model.assumptions.length); add('shields', 'Shielded Regions', model.shields.length); @@ -136,6 +137,26 @@ export function renderDataPage(ctx: PageContext): string {
${pager('audits')}` : ''} + ${ctx.reach.actors.length > 0 ? ` + ${subHead('Actors', '', `${ctx.reach.actors.length}`).replace('
', '
')} +

Principals in the authorization model. An actor is an AI agent when some @agents names it; Agents & Reach shows what each one can reach.

+
+ ${sortableHead([{ key: 'actor', label: 'Actor' }, { key: 'kind', label: 'Kind' }, { key: 'reaches', label: 'Reaches' }, { key: 'unentitled', label: 'Unentitled' }, { key: 'approves', label: 'Approves' }, { key: 'description', label: 'Description', plain: true }, { key: 'location', label: 'Location', cls: 'loc' }])} + + ${ctx.reach.actors.map(a => ` + 0 ? 'principal' : '', a.approves > 0 ? 'approver' : '', a.declared ? '' : 'undeclared', a.ref, a.name, a.description ?? ''] })}> + + + + + + ${descCell(a.description, '—')} + ${loc(a.loc)} + `).join('')} + +
${esc(a.ref)}${a.agent ? 'AI agent' : a.reaches > 0 ? 'principal' : a.approves > 0 ? 'approver' : '—'}${a.declared ? '' : ' undeclared'}${a.reaches}${a.unentitled > 0 ? `${a.unentitled}` : '0'}${a.approves}
+ ${pager('actors')}` : ''} + ${(model.entitlements || []).length > 0 ? ` ${subHead('Entitlements', '', `${model.entitlements!.length}`).replace('
', '
')}

Capabilities a principal is claimed to hold by design. The join is (actor, asset, threat) — a row with either missing joins no finding and cannot demote one. An entitlement never suppresses a finding and never gates testing; it only changes what downstream triage recommends. A claim that cites no authorization code is inert and has no effect.

diff --git a/src/dashboard/pages/diagrams.ts b/src/dashboard/pages/diagrams.ts index cd056032..38bb6354 100644 --- a/src/dashboard/pages/diagrams.ts +++ b/src/dashboard/pages/diagrams.ts @@ -119,6 +119,7 @@ const TOOLS = ` const SW = (c: string): string => ``; const LEGEND_THREAT = `${icon('square')} asset${icon('hexagon')} threat${icon('pill')} control${SW('#ea1d1d')} critical / high${SW('#55899e')} medium${SW('#0360a2')} lowdashed box: trust zone`; const LEGEND_FLOW = `${icon('pill')} client or user${icon('flag')} external party${icon('cylinder')} data store${icon('square')} servicedashed box: trust zone`; +const LEGEND_REACH = `${icon('pill')} AI agent${icon('square')} principal${SW('#33d49d')} entitled, or gated${SW('#ea1d1d')} unentitled, or a mutation with no gate`; const LEGEND_SURFACE = `${icon('hexagon')} confirmed${icon('square')} open${icon('pill')} mitigated${icon('slant')} acceptedcolour is severity`; function shell(id: string, title: string, body: string, meta: string, extra = '', active = false): string { @@ -177,6 +178,12 @@ export function renderDiagramsPage(ctx: PageContext): string { panels.push(shell('attack-surface', 'Attack Surface', `${budgetBanner(b.reasons)}${legibilityNotice([b.tooBig].filter(Boolean))}
\n${esc(b.src)}\n
`, budgetMeta(b.reasons.length > 0, `Exposures per asset. ${LEGEND_SURFACE}`), '', panels.length === 0)); } + if (ctx.diagrams.reach) { + tabs.push({ id: 'agent-reach', label: 'Agent Reach', icon: icon('zap') }); + const b = budgeted('Agent Reach', ctx.diagrams.reach); + panels.push(shell('agent-reach', 'Agent Reach', `${budgetBanner(b.reasons)}${legibilityNotice([b.tooBig].filter(Boolean))}
\n${esc(b.src)}\n
`, + budgetMeta(b.reasons.length > 0, `What each agent and principal can reach. ${LEGEND_REACH} The same data as a table: Agents & Reach.`), '', panels.length === 0)); + } if (tabs.length === 0) { return ` diff --git a/src/dashboard/styles.ts b/src/dashboard/styles.ts index 8d950991..7ce6ba49 100644 --- a/src/dashboard/styles.ts +++ b/src/dashboard/styles.ts @@ -1527,4 +1527,40 @@ table.heat .heat-col span { font-size: .68rem; } .diagram-toobig ul { margin: 0 0 .5rem; padding-left: 1.1rem; } .diagram-toobig li { margin-bottom: .2rem; } .diagram-toobig p { margin: 0; color: var(--muted); } + +/* ── Agents & Reach ─────────────────────────────────────────────── */ +table.heat.reach-map { border-spacing: 4px; } +.table-wrap table.heat.reach-map td.reach-cell { padding: 5px; vertical-align: top; background: var(--surface2); border: 1px solid var(--border-subtle); border-radius: var(--radius-sm); min-width: 108px; max-width: 220px; } +.table-wrap table.heat.reach-map td.reach-cell.empty { background: transparent; border-color: transparent; } +.table-wrap table.heat.reach-map td.reach-cell.empty::after { content: "·"; color: var(--text-dim); display: block; text-align: center; } +table.heat.reach-map .reach-actor { vertical-align: top; padding-top: .35rem; } +table.heat.reach-map .reach-actor span:first-child { max-width: 170px; } +.reach-kind { display: inline-block; font-family: var(--font-ui, inherit); font-size: .58rem; font-weight: 600; letter-spacing: .04em; text-transform: uppercase; padding: .05rem .4rem; border-radius: 999px; border: 1px solid var(--border); color: var(--muted); white-space: nowrap; } +table.heat .reach-actor .reach-kind { margin-top: .2rem; } +.reach-kind.agent { color: var(--accent); border-color: var(--accent-dim); background: var(--accent-soft); } +.reach-kind.loose { border-style: dashed; } +.reach-chip { display: flex; align-items: center; gap: .25rem; width: max-content; max-width: 100%; font-family: var(--font-mono); font-size: .66rem; font-weight: 500; line-height: 1.35; padding: .1rem .4rem; margin: 0 0 3px; border-radius: 5px; border: 1px solid var(--border); background: var(--surface); color: var(--text); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +td > .reach-chip, li > .reach-chip, .d-value .reach-chip { display: inline-flex; margin: 0; vertical-align: middle; } +.reach-chip .ico { width: 11px; height: 11px; flex: none; } +.reach-cap.ok { color: var(--green-text); border-color: color-mix(in oklab, var(--green) 45%, transparent); background: color-mix(in oklab, var(--green) 10%, var(--surface)); } +.reach-cap.bad, .reach-eff.ungated { color: var(--badge-red-fg, var(--red)); border-color: color-mix(in oklab, var(--red) 50%, transparent); background: var(--badge-red-bg); } +.reach-eff.gated { color: var(--accent); border-color: var(--accent-dim); background: var(--accent-soft); } +.reach-eff.read { color: var(--muted); } +.reach-legend { display: flex; flex-wrap: wrap; gap: .4rem 1rem; align-items: center; font-size: .7rem; color: var(--muted); margin: .2rem 0 1.4rem; } +.reach-legend > span { display: inline-flex; align-items: center; gap: .35rem; } +.reach-legend .reach-chip { margin: 0; } +.d-reach > div { font-size: .74rem; padding: .18rem 0; border-bottom: 1px dashed var(--border-subtle); display: flex; flex-wrap: wrap; align-items: center; gap: .35rem; } +.d-reach > div:last-child { border-bottom: 0; } +.reach-miss { margin-bottom: .2rem; font-size: .74rem; } +.reach-example { background: var(--surface2); border: 1px solid var(--border); border-radius: var(--radius-md); padding: .8rem 1rem; font-size: .74rem; overflow-x: auto; max-width: 100%; } +.reach-owasp { display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); gap: .7rem; margin-bottom: 1.2rem; } +.reach-owasp-item { background: var(--surface); border: 1px solid var(--border-subtle); border-radius: var(--radius-md); padding: .75rem .9rem; min-width: 0; } +.reach-owasp-item.has { border-left: 3px solid var(--red); } +.reach-owasp-h { font-weight: 650; font-size: .82rem; display: flex; align-items: baseline; gap: .45rem; margin-bottom: .35rem; } +.reach-owasp-id { font-family: var(--font-mono); font-size: .7rem; color: var(--accent); } +.reach-owasp-n { margin-left: auto; font-variant-numeric: tabular-nums; color: var(--muted); } +.reach-owasp-item.has .reach-owasp-n { color: var(--red); } +.reach-owasp-item ul { margin: 0; padding-left: 1rem; font-size: .74rem; line-height: 1.5; } +.reach-owasp-item li { margin-bottom: .3rem; } +.reach-facet { font-size: .6rem; text-transform: uppercase; letter-spacing: .04em; font-weight: 600; color: var(--muted); } `; diff --git a/src/mcp/server.ts b/src/mcp/server.ts index a4ad54cd..2b98a427 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -75,7 +75,7 @@ import { } from '../review/entitlements.js'; import { generateSarif, SARIF_PROFILES } from '../analyzer/index.js'; import { readHypotheses } from '../hypothesis/index.js'; -import { generateReport } from '../report/index.js'; +import { generateReport, generateAgentReachReport } from '../report/index.js'; import { generateDashboardHTML, generateThreatGraph } from '../dashboard/index.js'; import { diffModels, parseAtRef } from '../diff/index.js'; import { findUnmitigatedPaths, classifyEndpoints } from '../paths/index.js'; @@ -771,12 +771,14 @@ export function createServer(): McpServer { registerTool( server, cache, 'guardlink_report', - 'Generate a markdown threat model report with Mermaid diagram. Also writes threat-model.json alongside.', + 'Generate a markdown threat model report with Mermaid diagram. Also writes threat-model.json alongside. With agents: true, writes only the agent threat model (reach map, unentitled reaches, ungated mutations, OWASP LLM mapping) and no JSON.', { root: z.string().describe('Project root directory').default('.'), - output: z.string().describe('Output filename (default: threat-model.md)').default('threat-model.md'), + output: z.string().describe('Output filename (default: threat-model.md, or threat-model-agents.md with agents)').optional(), + agents: z.boolean().describe('Write only the agent threat model, the same section the full report carries').default(false), }, - async ({ root, output }) => { + async ({ root, output: requested, agents }) => { + const output = requested ?? (agents ? 'threat-model-agents.md' : 'threat-model.md'); const { model } = await getModel(root); if (model.annotations_parsed === 0) { return { content: [{ type: 'text', text: JSON.stringify({ error: 'No annotations found.' }) }] }; @@ -790,6 +792,10 @@ export function createServer(): McpServer { if (promptContent.trim()) model.prompt = promptContent.trim(); } catch { /* no prompt file */ } + if (agents) { + await writeFile(resolve(root, output), generateAgentReachReport(model) + '\n'); + return { content: [{ type: 'text', text: JSON.stringify({ report: output, annotations: model.annotations_parsed, reaches: (model.reaches || []).length }) }] }; + } const report = generateReport(model); await writeFile(resolve(root, output), report + '\n'); const jsonFile = output.replace(/\.md$/, '.json'); diff --git a/src/reach/index.ts b/src/reach/index.ts new file mode 100644 index 00000000..29258262 --- /dev/null +++ b/src/reach/index.ts @@ -0,0 +1,11 @@ +/** + * GuardLink Reach — what each agent and principal can reach, for the dashboard + * and the report. + * + * @comment -- "Re-exports only; the derivation lives in summary.ts and the joins in parser/reach.ts" + */ +export { summarizeReach, hasReach } from './summary.js'; +export type { + ReachSummary, ReachActorRow, ReachColumn, ReachCell, ReachCapabilityChip, ReachEffectChip, LooseEffects, + UnentitledRow, NearMissRow, UngatedRow, GateRow, EgressRow, InjectionRoute, OwaspRow, OwaspItem, OwaspId, ReachLoc, +} from './summary.js'; diff --git a/src/reach/summary.ts b/src/reach/summary.ts new file mode 100644 index 00000000..dd7957aa --- /dev/null +++ b/src/reach/summary.ts @@ -0,0 +1,601 @@ +/** + * GuardLink — what each agent and principal can reach, as one derived view. + * + * The dashboard's Agents page and the report's "Agents and LLM Reach" section + * draw the same pictures: the reach map (actor × asset, with the capability and + * the effect in each cell), the unentitled reaches, the ungated mutations, the + * gates, the egress, and the OWASP LLM Top 10 items those add up to. This + * module computes them once so the two surfaces cannot disagree, and it adds no + * join of its own: can-minus-may and effects-against-gates both come from + * `parser/reach.ts`, which `lookup` and `diff` answer from too. + * + * ── What it can and cannot tie together ───────────────────────────── + * + * An effect lands in an actor's row only where the annotations connect them: + * the effect and the actor's `@agents`/`@reaches` are bound to the same code + * (`boundCode`, one doc-block on one declaration). Whether that effect is gated + * is `classifyEffects`, the join `reach_analysis` and the SARIF export use. An + * effect reached through calls across handlers has no declared route; it is + * listed under "not tied to a reach" rather than placed in a row the model + * never drew. + * + * The OWASP mapping (2025 list) keys on `@agents` only, as SPEC §3.2.1 says + * agent-only checks do: + * + * - LLM06 Excessive Agency — functionality: an unentitled `@agents`; + * permissions: an effect running under a different identity than the agent + * presents, with no gate between; autonomy: a mutation the agent reaches + * with no `@gates` in front of it. + * - LLM01 Prompt Injection — input from outside the model (a `@flows` source + * that is neither a declared asset nor an actor, with nothing flowing into + * it) that reaches an agent which has an ungated mutation. + * - LLM05 Improper Output Handling — a write, delete, execute or notify bound + * to the same code as the agent's tool registration, so model-written + * arguments reach it directly. + * + * Each is a target for review or test, never a finding the model has proved. + * + * @comment -- "Pure functions over an assembled ThreatModel; no I/O and no input surface. Every row keeps the file:line of the annotation it came from, so each picture can be traced back to source" + */ +import type { ThreatModel, ThreatModelReach, EffectClass, SourceLocation } from '../types/index.js'; +import { + actorResolver, boundCode, findUnentitledReaches, classifyEffects, isMutatingEffect, + type EffectGating, type GateCoverBlocker, type ReachCoverBlocker, +} from '../parser/reach.js'; +import { canonicaliser } from '../parser/canonical-ref.js'; +import { canonicalizeModelOrder } from '../parser/canonical-order.js'; + +export interface ReachLoc { file: string; line: number } + +export interface ReachActorRow { + /** Resolved identity: the declared id, or the bare ref when undeclared. */ + key: string; + /** How to write it: `#id` when declared with one, else the name as written. */ + ref: string; + name: string; + description?: string; + /** Named by some `@agents`. */ + agent: boolean; + /** Declared with `@actor`. */ + declared: boolean; + reaches: number; + unentitled: number; + /** Mutations this actor reaches with no gate in front of its route. */ + ungated: number; + /** `@gates` naming this actor as the approver. */ + approves: number; + loc?: ReachLoc; +} + +export interface ReachColumn { key: string; ref: string; declared: boolean } + +export interface ReachCapabilityChip { + capability: string; + entitled: boolean; + identity?: string; + loc: ReachLoc; +} + +export interface ReachEffectChip { + effect: EffectClass; + mutating: boolean; + /** Null for a read: a gate question only arises for a mutation. */ + gated: boolean | null; + approvers: string[]; + /** This row's actor's capabilities bound to the same code as the effect; empty in the "not tied to a reach" row. */ + via: string[]; + identity?: string; + loc: ReachLoc; +} + +export interface ReachCell { + actor: string; + asset: string; + capabilities: ReachCapabilityChip[]; + effects: ReachEffectChip[]; +} + +export interface LooseEffects { asset: string; effects: ReachEffectChip[] } + +export interface NearMissRow { + blocker: ReachCoverBlocker; + /** The blocker as a sentence a reviewer can act on. */ + reason: string; + asset?: string; + citation?: string; + loc: ReachLoc; +} + +export interface UnentitledRow { + actor: string; + agent: boolean; + capability: string; + asset?: string; + identity?: string; + description?: string; + loc: ReachLoc; + near_misses: NearMissRow[]; +} + +export interface UngatedRow { + effect: EffectClass; + asset: string; + identity?: string; + description?: string; + loc: ReachLoc; + /** Reaches bound to the same code; empty when none is. */ + via: { actor: string; agent: boolean; capability: string }[]; + /** Gates on the asset that still do not cover it, and why. */ + gate_near_misses: { approver: string; capability?: string; blocker: GateCoverBlocker; reason: string }[]; + /** True when some agent reaches it. */ + agent: boolean; +} + +export interface GateRow { + asset: string; + approver: string; + capability?: string; + description?: string; + loc: ReachLoc; + /** The mutations this gate stands in front of. */ + covers: { effect: EffectClass; asset: string; loc: ReachLoc }[]; +} + +export interface EgressRow { + actors: string[]; + source: string; + target: string; + mechanism?: string; + /** The target is no declared asset: the data leaves the model. */ + external: boolean; + /** Ids of the `@boundary` declared on this hop. */ + boundaries: string[]; + description?: string; + loc: ReachLoc; +} + +export interface InjectionRoute { + entry: string; + /** Labels from the entry to the agent's surface, as the `@flows` spell them. */ + chain: string[]; + agent: string; + /** The hops, each with the `@flows` it came from. */ + hops: ReachLoc[]; + effects: { effect: EffectClass; asset: string; loc: ReachLoc }[]; +} + +export type OwaspId = 'LLM01' | 'LLM05' | 'LLM06'; + +export interface OwaspItem { + /** The part of the OWASP definition this row is evidence for. */ + facet: string; + agent: string; + text: string; + loc: ReachLoc; +} + +export interface OwaspRow { + id: OwaspId; + title: string; + /** What GuardLink looks for, in one sentence. */ + basis: string; + items: OwaspItem[]; +} + +export interface ReachSummary { + /** Every actor that is declared, reaches something, or approves something. Agents first. */ + actors: ReachActorRow[]; + /** Assets the reach map has a column for: named by a reach or an effect. */ + columns: ReachColumn[]; + /** Non-empty cells of the reach map. */ + cells: ReachCell[]; + /** Effects no reach is tied to, per asset. */ + loose: LooseEffects[]; + unentitled: UnentitledRow[]; + ungated: UngatedRow[]; + gates: GateRow[]; + egress: EgressRow[]; + injection: InjectionRoute[]; + owasp: OwaspRow[]; + totals: { + agents: number; principals: number; reaches: number; unentitled: number; + effects: number; mutations: number; ungated: number; gates: number; egress: number; injection: number; + }; +} + +const loc = (l: SourceLocation): ReachLoc => ({ file: l.file, line: l.line }); + +const GATE_MISS_REASON: Record = { + 'capability-unknown': 'the gate is for one capability, and no reach on this code says which capability gets here', + 'other-capability': 'the gate is for another capability than the one bound to this code', +}; + +/** + * PR-local view of `classifyEffects`: the effect, whether it mutates, the + * reaches bound to its code, and whether a gate covers it. + */ +interface Gated extends EffectGating { mutating: boolean; gated: boolean } + +const NEAR_MISS_REASON: Record = { + uncited: 'the entitlement cites no authorization code, so it covers nothing', + 'no-asset': 'the entitlement names no asset, and this reach names one', + 'other-asset': 'the entitlement is for another asset', +}; + +/** True when the model carries any reach annotation at all. */ +export function hasReach(model: ThreatModel): boolean { + return (model.reaches ?? []).length > 0 || (model.effects ?? []).length > 0 || (model.gates ?? []).length > 0; +} + +export function summarizeReach(rawModel: ThreatModel): ReachSummary { + // Rows come out in model order, and parse order follows the file walk. + const model = canonicalizeModelOrder(rawModel); + const actorKey = actorResolver(model); + const assetKey = canonicaliser(model); + const reaches = model.reaches ?? []; + const gating: Gated[] = classifyEffects(model).map(g => { + const mutating = isMutatingEffect(g.effect.effect); + return { ...g, mutating, gated: mutating && g.gates.length > 0 }; + }); + const unentitledReaches = findUnentitledReaches(model); + const unentitledSet = new Set(unentitledReaches.map(u => u.reach)); + + // ── Display labels ── + const assetRef = new Map(); + for (const a of model.assets ?? []) { + const k = assetKey(a.id || a.path.join('.')); + assetRef.set(k, { ref: a.id ? `#${a.id}` : a.path.join('.'), declared: true }); + } + const assetLabel = (ref: string): string => assetRef.get(assetKey(ref))?.ref ?? ref; + + const actorRows = new Map(); + for (const ac of model.actors ?? []) { + const key = actorKey(ac.id ? `#${ac.id}` : ac.name); + actorRows.set(key, { + key, ref: ac.id ? `#${ac.id}` : ac.name, name: ac.name, description: ac.description, + agent: false, declared: true, reaches: 0, unentitled: 0, ungated: 0, approves: 0, loc: loc(ac.location), + }); + } + const actorRow = (ref: string): ReachActorRow => { + const key = actorKey(ref); + let row = actorRows.get(key); + if (!row) { + row = { key, ref, name: ref.replace(/^#/, ''), agent: false, declared: false, reaches: 0, unentitled: 0, ungated: 0, approves: 0 }; + actorRows.set(key, row); + } + return row; + }; + const actorLabel = (ref: string): string => actorRow(ref).ref; + + for (const r of reaches) { + const row = actorRow(r.actor); + row.reaches++; + if (r.agent) row.agent = true; + if (unentitledSet.has(r)) row.unentitled++; + } + for (const g of model.gates ?? []) actorRow(g.approver).approves++; + + // ── Reach map ── + const columns: ReachColumn[] = []; + const seenCol = new Set(); + const colRef = new Map(); + for (const r of reaches) if (r.asset && !colRef.has(assetKey(r.asset))) colRef.set(assetKey(r.asset), assetLabel(r.asset)); + for (const e of model.effects ?? []) if (!colRef.has(assetKey(e.asset))) colRef.set(assetKey(e.asset), assetLabel(e.asset)); + const addColumn = (k: string) => { + if (seenCol.has(k)) return; + seenCol.add(k); + columns.push({ key: k, ref: colRef.get(k) ?? k, declared: assetRef.get(k)?.declared ?? false }); + }; + + const cells = new Map(); + const cell = (actor: string, asset: string): ReachCell => { + const k = `${actor}\u0000${asset}`; + let c = cells.get(k); + if (!c) { c = { actor, asset, capabilities: [], effects: [] }; cells.set(k, c); } + return c; + }; + for (const r of reaches) { + if (!r.asset) continue; + cell(actorKey(r.actor), assetKey(r.asset)).capabilities.push({ + capability: r.capability, entitled: !unentitledSet.has(r), identity: r.identity, loc: loc(r.location), + }); + } + + const approversOf = (g: EffectGating) => [...new Set(g.gates.map(x => actorLabel(x.approver)))]; + const loose = new Map(); + for (const g of gating) { + const e = g.effect; + if (g.colocated_reaches.length === 0) { + const list = loose.get(assetKey(e.asset)) ?? []; + list.push({ + effect: e.effect, mutating: g.mutating, gated: g.mutating ? g.gated : null, approvers: approversOf(g), + via: [], identity: e.identity, loc: loc(e.location), + }); + loose.set(assetKey(e.asset), list); + continue; + } + // One chip per actor whose reach is bound to the effect's code. + const byActor = new Map(); + for (const r of g.colocated_reaches) { + const k = actorKey(r.actor); + byActor.set(k, [...(byActor.get(k) ?? []), r]); + } + for (const [actor, rs] of byActor) { + if (g.mutating && !g.gated) actorRows.get(actor)!.ungated++; + cell(actor, assetKey(e.asset)).effects.push({ + effect: e.effect, mutating: g.mutating, gated: g.mutating ? g.gated : null, + approvers: approversOf(g), via: [...new Set(rs.map(r => r.capability))], + identity: e.identity, loc: loc(e.location), + }); + } + } + + // ── Lists ── + const unentitled: UnentitledRow[] = unentitledReaches.map(({ reach: r, near_misses }) => ({ + actor: actorLabel(r.actor), agent: r.agent, capability: r.capability, + asset: r.asset ? assetLabel(r.asset) : undefined, identity: r.identity, description: r.description, + loc: loc(r.location), + near_misses: near_misses.map(n => ({ + blocker: n.blocker, reason: NEAR_MISS_REASON[n.blocker], + asset: n.entitlement.asset ? assetLabel(n.entitlement.asset) : undefined, + citation: n.entitlement.citation?.raw, loc: loc(n.entitlement.location), + })), + })); + + const ungated: UngatedRow[] = gating.filter(g => g.mutating && !g.gated).map(g => ({ + effect: g.effect.effect, asset: assetLabel(g.effect.asset), identity: g.effect.identity, + description: g.effect.description, loc: loc(g.effect.location), + via: g.colocated_reaches.map(r => ({ actor: actorLabel(r.actor), agent: r.agent, capability: r.capability })), + gate_near_misses: g.near_misses.map(n => ({ + approver: actorLabel(n.gate.approver), capability: n.gate.capability, blocker: n.blocker, reason: GATE_MISS_REASON[n.blocker], + })), + agent: g.colocated_reaches.some(r => r.agent), + })); + + const gates: GateRow[] = (model.gates ?? []).map(gate => ({ + asset: assetLabel(gate.asset), approver: actorLabel(gate.approver), capability: gate.capability, + description: gate.description, loc: loc(gate.location), + covers: gating + .filter(g => g.mutating && g.gates.includes(gate)) + .map(g => ({ effect: g.effect.effect, asset: assetLabel(g.effect.asset), loc: loc(g.effect.location) })), + })); + + const egress = findEgress(model, reaches, actorKey, assetKey, actorLabel, assetLabel); + const injection = findInjectionRoutes(model, gating, actorKey, assetKey, actorLabel, assetLabel); + const owasp = mapOwasp(unentitled, gating, injection, actorKey, actorLabel, assetLabel); + + const actors = [...actorRows.values()].sort((a, b) => + Number(b.agent) - Number(a.agent) || Number(b.reaches > 0) - Number(a.reaches > 0) || a.ref.localeCompare(b.ref)); + // Columns read left to right in row order: each actor's surfaces, then what its tools do; loose effects last. + const cellList = [...cells.values()]; + for (const a of actors) { + for (const c of cellList) if (c.actor === a.key && c.capabilities.length > 0) addColumn(c.asset); + for (const c of cellList) if (c.actor === a.key && c.capabilities.length === 0) addColumn(c.asset); + } + for (const k of loose.keys()) addColumn(k); + const mutations = gating.filter(g => g.mutating).length; + + return { + actors, + columns, + cells: cellList, + loose: [...loose].map(([asset, effects]) => ({ asset, effects })), + unentitled, + ungated, + gates, + egress, + injection, + owasp, + totals: { + agents: actors.filter(a => a.agent).length, + principals: actors.filter(a => !a.agent && a.reaches > 0).length, + reaches: reaches.length, + unentitled: unentitled.length, + effects: gating.length, + mutations, + ungated: ungated.length, + gates: gates.length, + egress: egress.length, + injection: injection.length, + }, + }; +} + +type Resolve = (ref: string) => string; + +const pairKey = (a: string, b: string): string => [a, b].sort().join('\u0000'); + +function boundaryIndex(model: ThreatModel, assetKey: Resolve): Map { + const out = new Map(); + for (const b of model.boundaries ?? []) { + const k = pairKey(assetKey(b.asset_a), assetKey(b.asset_b)); + out.set(k, [...(out.get(k) ?? []), b.id ? `#${b.id}` : `${b.asset_a} ↔ ${b.asset_b}`]); + } + return out; +} + +/** + * Data a principal's surface sends out: a `@flows` from the actor, from an + * asset it reaches, or bound to the same code as its reach, whose target + * is no declared asset or whose hop is a declared `@boundary`. + */ +function findEgress( + model: ThreatModel, reaches: ThreatModelReach[], actorKey: Resolve, assetKey: Resolve, + actorLabel: Resolve, assetLabel: Resolve, +): EgressRow[] { + const declared = new Set((model.assets ?? []).map(a => assetKey(a.id || a.path.join('.')))); + const actorKeys = new Set((model.actors ?? []).map(a => actorKey(a.id ? `#${a.id}` : a.name))); + const boundaries = boundaryIndex(model, assetKey); + const out: EgressRow[] = []; + for (const f of model.flows ?? []) { + const src = assetKey(f.source); + const tgt = assetKey(f.target); + const crossed = boundaries.get(pairKey(src, tgt)) ?? []; + const external = !declared.has(tgt) && !actorKeys.has(actorKey(f.target)); + if (!external && crossed.length === 0) continue; + const code = boundCode(f.location); + const who = new Set(); + for (const r of reaches) { + if (actorKey(f.source) === actorKey(r.actor) + || (r.asset && assetKey(r.asset) === src) + || (code && code === boundCode(r.location))) who.add(actorLabel(r.actor)); + } + if (who.size === 0) continue; + out.push({ + actors: [...who], source: assetLabel(f.source), target: assetLabel(f.target), mechanism: f.mechanism, + external, boundaries: crossed, description: f.description, loc: loc(f.location), + }); + } + return out; +} + +/** + * Input from outside the model that reaches an agent with an ungated mutation. + * Breadth-first over `@flows` from each entry — a source that is neither a + * declared asset nor an actor, with nothing flowing into it — stopping at the + * first node that is the agent or an asset it reaches. + */ +function findInjectionRoutes( + model: ThreatModel, gating: Gated[], actorKey: Resolve, assetKey: Resolve, + actorLabel: Resolve, assetLabel: Resolve, +): InjectionRoute[] { + const actorIds = new Set((model.actors ?? []).map(a => actorKey(a.id ? `#${a.id}` : a.name))); + for (const r of model.reaches ?? []) actorIds.add(actorKey(r.actor)); + // One node id per spelling: an actor resolves to its identity, anything else to its asset key. + const node = (ref: string): string => (actorIds.has(actorKey(ref)) ? `actor:${actorKey(ref)}` : `asset:${assetKey(ref)}`); + const declared = new Set((model.assets ?? []).map(a => `asset:${assetKey(a.id || a.path.join('.'))}`)); + const flows = model.flows ?? []; + const inbound = new Set(flows.map(f => node(f.target))); + const label = new Map(); + for (const f of flows) { + if (!label.has(node(f.source))) label.set(node(f.source), assetLabel(f.source)); + if (!label.has(node(f.target))) label.set(node(f.target), assetLabel(f.target)); + } + const entries = [...new Set(flows.map(f => node(f.source)))] + .filter(n => n.startsWith('asset:') && !declared.has(n) && !inbound.has(n)); + + // Each agent's surface, and the ungated mutations it reaches. + const agents = new Map; effects: InjectionRoute['effects'] }>(); + for (const r of model.reaches ?? []) { + if (!r.agent) continue; + const k = actorKey(r.actor); + const a = agents.get(k) ?? { surface: new Set([`actor:${k}`]), effects: [] }; + if (r.asset) a.surface.add(`asset:${assetKey(r.asset)}`); + agents.set(k, a); + } + for (const g of gating) { + if (!g.mutating || g.gated) continue; + for (const r of g.colocated_reaches) { + const a = agents.get(actorKey(r.actor)); + if (!a || !r.agent) continue; + if (!a.effects.some(e => e.loc.file === g.effect.location.file && e.loc.line === g.effect.location.line)) { + a.effects.push({ effect: g.effect.effect, asset: assetLabel(g.effect.asset), loc: loc(g.effect.location) }); + } + } + } + + const out: InjectionRoute[] = []; + for (const entry of entries) { + const prev = new Map(); + const seen = new Set([entry]); + let frontier = [entry]; + const hit = new Map(); + while (frontier.length > 0) { + const next: string[] = []; + for (const n of frontier) { + for (const [k, a] of agents) if (!hit.has(k) && a.surface.has(n) && n !== entry) hit.set(k, n); + for (const f of flows) { + if (node(f.source) !== n || seen.has(node(f.target))) continue; + seen.add(node(f.target)); + prev.set(node(f.target), { from: n, flow: loc(f.location) }); + next.push(node(f.target)); + } + } + frontier = next; + } + for (const [agent, at] of hit) { + const a = agents.get(agent)!; + if (a.effects.length === 0) continue; + const chain: string[] = []; + const hops: ReachLoc[] = []; + for (let cur = at; ; cur = prev.get(cur)!.from) { + chain.unshift(label.get(cur) ?? cur); + if (!prev.has(cur)) break; + hops.unshift(prev.get(cur)!.flow); + } + out.push({ entry: label.get(entry) ?? entry, chain, agent: actorLabel(agent), hops, effects: a.effects }); + } + } + return out; +} + +const LLM05_EFFECTS = new Set(['write', 'delete', 'execute', 'notify']); + +function mapOwasp( + unentitled: UnentitledRow[], gating: Gated[], injection: InjectionRoute[], + actorKey: Resolve, actorLabel: Resolve, assetLabel: Resolve, +): OwaspRow[] { + const llm06: OwaspItem[] = []; + for (const u of unentitled.filter(x => x.agent)) { + llm06.push({ + facet: 'functionality', agent: u.actor, loc: u.loc, + text: `can ${u.capability}${u.asset ? ` on ${u.asset}` : ''} and no cited @entitles covers it`, + }); + } + for (const g of gating) { + for (const r of g.colocated_reaches) { + if (!r.agent) continue; + const open = g.mutating && !g.gated; + const e = g.effect; + if (e.identity && r.identity && actorKey(e.identity) !== actorKey(r.identity) && (!g.mutating || open)) { + llm06.push({ + facet: 'permissions', agent: actorLabel(r.actor), loc: loc(e.location), + text: `${r.capability} presents ${r.identity}, and the ${e.effect} on ${assetLabel(e.asset)} runs as ${e.identity}${g.mutating ? ' with no gate between' : ''}`, + }); + } + if (open) { + llm06.push({ + facet: 'autonomy', agent: actorLabel(r.actor), loc: loc(e.location), + text: `${e.effect} on ${assetLabel(e.asset)} through ${r.capability}, with no @gates in front of it`, + }); + } + } + } + + const llm01: OwaspItem[] = injection.map(route => ({ + facet: 'injection-to-tool route', agent: route.agent, loc: route.hops[0] ?? route.effects[0].loc, + text: `input from ${route.chain.join(' → ')} reaches it, and it has ${route.effects.length} ungated ${route.effects.length === 1 ? 'mutation' : 'mutations'} (${route.effects.map(e => `${e.effect} on ${e.asset}`).join(', ')})`, + })); + + const llm05: OwaspItem[] = []; + for (const g of gating) { + if (!LLM05_EFFECTS.has(g.effect.effect)) continue; + for (const r of g.colocated_reaches) { + if (!r.agent) continue; + llm05.push({ + facet: 'model output into an effect', agent: actorLabel(r.actor), loc: loc(g.effect.location), + text: `${r.capability} hands model-written arguments to a ${g.effect.effect} on ${assetLabel(g.effect.asset)} in the same code`, + }); + } + } + + const FACETS = ['functionality', 'permissions', 'autonomy']; + llm06.sort((a, b) => FACETS.indexOf(a.facet) - FACETS.indexOf(b.facet)); + return [ + { + id: 'LLM06', title: 'Excessive Agency', + basis: 'Unentitled @agents (functionality), an effect running as a broader identity than the agent presents (permissions), and mutations with no @gates (autonomy).', + items: llm06, + }, + { + id: 'LLM01', title: 'Prompt Injection', + basis: 'Input from outside the model that @flows into an agent which can reach an ungated mutation.', + items: llm01, + }, + { + id: 'LLM05', title: 'Improper Output Handling', + basis: 'A write, delete, execute or notify bound to the same code as the agent\'s tool, so model output reaches it directly.', + items: llm05, + }, + ]; +} diff --git a/src/report/agent-reach.ts b/src/report/agent-reach.ts new file mode 100644 index 00000000..5668d9c6 --- /dev/null +++ b/src/report/agent-reach.ts @@ -0,0 +1,261 @@ +/** + * GuardLink Report — the "Agents and LLM Reach" section, and the agent-focused + * report built from it. + * + * One section, two documents: `generateReport` places it in the main threat + * model when the model carries any reach annotation, and + * `generateAgentReachReport` (`guardlink report --agents`) wraps the same lines + * in a document of their own, so a team can publish an LLM threat model alone + * or as part of the whole one. Every number comes from `summarizeReach`, which + * the dashboard's Agents page draws too. + * + * @comment -- "Pure function: ThreatModel to markdown lines. Model text lands in markdown table cells, where mdCell keeps a pipe or newline from breaking out of the cell; no HTML or shell is produced here" + * @flows ThreatModel -> #report via summarizeReach -- "Reaches, effects, gates, entitlements and flows for the agent section" + */ +import type { ThreatModel } from '../types/index.js'; +import { summarizeReach, hasReach, type ReachSummary, type ReachEffectChip, type ReachLoc } from '../reach/index.js'; +import { findUnmitigatedExposures } from '../parser/coverage.js'; +import { canonicaliser } from '../parser/canonical-ref.js'; + +const cell = (s: string): string => s.replace(/\|/g, '\\|').replace(/\r?\n/g, ' '); +const at = (l: ReachLoc): string => `${l.file}:${l.line}`; +const code = (s: string): string => `\`${s}\``; + +function effectText(e: ReachEffectChip): string { + if (e.gated === null) return e.effect; + if (e.gated) return `${e.effect} (gated by ${e.approvers.join(', ')})`; + return `**${e.effect} (ungated)**`; +} + +/** + * The section body, under a `##` heading the caller writes. `headingSuffix` is + * appended to every `###` so a feature slice says so wherever it is read. + */ +export function emitAgentReach(model: ThreatModel, lines: string[], headingSuffix = ''): void { + const s = summarizeReach(model); + const h3 = (t: string) => `### ${t}${headingSuffix}`; + + if (!hasReach(model)) { + lines.push('_No `@agents`, `@reaches`, `@effects` or `@gates` annotations. Nothing in the model says what an embedded'); + lines.push('agent or another principal can reach. Declare it on the tool registrations, the code that acts and the'); + lines.push('approval steps (SPEC §3.2.1) to get the reach map, the unentitled reaches and the OWASP LLM mapping here._'); + lines.push(''); + return; + } + + lines.push('What the code lets each agent and principal do — `@agents` and `@reaches` (can invoke), `@effects` (what the'); + lines.push('code does) and `@gates` (who decides first) — set against what a human approved with `@entitles`. An effect is'); + lines.push('tied to an actor only when both are written in one doc-block, bound to the same code; reaching an effect'); + lines.push('through calls across handlers takes a call graph and is not inferred here.'); + lines.push(''); + lines.push('| Measure | Count |'); + lines.push('|---------|-------|'); + lines.push(`| Agents (\`@agents\`) | ${s.totals.agents} |`); + lines.push(`| Other principals (\`@reaches\`) | ${s.totals.principals} |`); + lines.push(`| Reaches | ${s.totals.reaches} |`); + lines.push(`| **Unentitled reaches** | **${s.totals.unentitled}** |`); + lines.push(`| Effects | ${s.totals.effects} (${s.totals.mutations} mutating) |`); + lines.push(`| **Ungated mutations** | **${s.totals.ungated}** |`); + lines.push(`| Gates | ${s.totals.gates} |`); + lines.push(`| Egress from a reach | ${s.totals.egress} |`); + lines.push(`| Injection-to-tool routes | ${s.totals.injection} |`); + lines.push(''); + + emitActors(s, lines, h3); + emitReachMap(s, lines, h3); + emitLists(s, lines, h3); + emitOwasp(s, lines, h3); + emitReachedExposures(model, s, lines, h3); +} + +/** One subsection per actor that reaches something: each capability and what it leads to. */ +function emitActors(s: ReachSummary, lines: string[], h3: (t: string) => string): void { + for (const a of s.actors.filter(x => x.reaches > 0)) { + lines.push(h3(`${a.ref} — ${a.agent ? 'AI agent' : 'principal'}`)); + lines.push(''); + if (a.description) lines.push(`${a.declared ? '' : '_Undeclared actor._ '}${a.description}`, ''); + else if (!a.declared) lines.push('_Undeclared: no `@actor` names it — `guardlink validate` reports it._', ''); + lines.push(`${a.reaches} ${a.reaches === 1 ? 'capability' : 'capabilities'}, ${a.unentitled} unentitled, ${a.ungated} ungated ${a.ungated === 1 ? 'mutation' : 'mutations'}.`); + lines.push(''); + lines.push('| Capability | On | As | Entitled | What it leads to | Location |'); + lines.push('|------------|----|----|----------|------------------|----------|'); + const mine = s.cells.filter(c => c.actor === a.key); + const label = (k: string) => s.columns.find(c => c.key === k)?.ref ?? k; + for (const c of mine) { + for (const cap of c.capabilities) { + const leads = mine.flatMap(x => x.effects.filter(e => e.via.includes(cap.capability)).map(e => `${effectText(e)} ${label(x.asset)}`)); + lines.push(`| ${code(cap.capability)} | ${label(c.asset)} | ${cap.identity ?? '—'} | ${cap.entitled ? 'yes' : '**no**'} | ${cell(leads.join(', ') || '—')} | ${at(cap.loc)} |`); + } + } + lines.push(''); + } +} + +/** Actor × asset, with the capabilities and effects in each cell. */ +function emitReachMap(s: ReachSummary, lines: string[], h3: (t: string) => string): void { + const rows = s.actors.filter(a => a.reaches > 0); + if (rows.length === 0 || s.columns.length === 0) return; + lines.push(h3('Reach Map')); + lines.push(''); + lines.push('Rows are actors, columns the assets they reach. A capability is marked ✗ when no cited `@entitles` covers it;'); + lines.push('an effect is marked ungated when it mutates and no `@gates` stands in front of it.'); + lines.push(''); + lines.push(`| Actor | ${s.columns.map(c => c.ref).join(' | ')} |`); + lines.push(`|-------|${s.columns.map(() => '---').join('|')}|`); + const render = (caps: { capability: string; entitled: boolean }[], effects: ReachEffectChip[]) => + cell([...caps.map(c => `${code(c.capability)} ${c.entitled ? '✓' : '✗'}`), ...effects.map(effectText)].join('
') || '·'); + for (const a of rows) { + const cols = s.columns.map(col => { + const c = s.cells.find(x => x.actor === a.key && x.asset === col.key); + return c ? render(c.capabilities, c.effects) : '·'; + }); + lines.push(`| ${a.ref}${a.agent ? ' (agent)' : ''} | ${cols.join(' | ')} |`); + } + if (s.loose.length > 0) { + const cols = s.columns.map(col => { + const l = s.loose.find(x => x.asset === col.key); + return l ? render([], l.effects) : '·'; + }); + lines.push(`| _not tied to a reach_ | ${cols.join(' | ')} |`); + } + lines.push(''); +} + +function emitLists(s: ReachSummary, lines: string[], h3: (t: string) => string): void { + if (s.unentitled.length > 0) { + lines.push(h3('Unentitled Reaches')); + lines.push(''); + lines.push('Capabilities the code hands out that no cited `@entitles` covers: can minus may. For an agent this is the'); + lines.push('Excessive Agency list. Only a human closes one, by accepting an entitlement proposal (`guardlink entitle --propose`).'); + lines.push(''); + lines.push('| Actor | Capability | On | Why nothing covers it | Location |'); + lines.push('|-------|------------|----|-----------------------|----------|'); + for (const u of s.unentitled) { + const why = u.near_misses.length === 0 + ? 'no `@entitles` for this actor and capability' + : u.near_misses.map(n => `${n.reason} (${at(n.loc)})`).join('; '); + lines.push(`| ${u.actor}${u.agent ? ' (agent)' : ''} | ${code(u.capability)} | ${u.asset ?? '—'} | ${cell(why)} | ${at(u.loc)} |`); + } + lines.push(''); + } + + if (s.ungated.length > 0) { + lines.push(h3('Ungated Mutations')); + lines.push(''); + lines.push('Effects other than `read` with no `@gates` in front of them. A gate suppresses nothing; its absence means'); + lines.push('nothing in the model says a person or a check decides before the effect lands.'); + lines.push(''); + lines.push('| Effect | Asset | As | Reached through | Location |'); + lines.push('|--------|-------|----|-----------------|----------|'); + for (const u of s.ungated) { + const via = (u.via.length === 0 + ? '_no reach on this code_' + : u.via.map(v => `${v.actor} ${code(v.capability)}`).join(', ')) + + u.gate_near_misses.map(n => `; ${n.approver}${n.capability ? ` for ${code(n.capability)}` : ''} does not cover it: ${n.reason}`).join(''); + lines.push(`| ${u.effect} | ${u.asset} | ${u.identity ?? '—'} | ${cell(via)} | ${at(u.loc)} |`); + } + lines.push(''); + } + + if (s.gates.length > 0) { + lines.push(h3('Gates')); + lines.push(''); + lines.push('| Asset | Approver | For | Stands in front of | Location |'); + lines.push('|-------|----------|-----|--------------------|----------|'); + for (const g of s.gates) { + const covers = g.covers.map(c => `${c.effect} on ${c.asset} (${at(c.loc)})`).join(', ') || '—'; + lines.push(`| ${g.asset} | ${g.approver} | ${g.capability ? code(g.capability) : 'every capability'} | ${cell(covers)} | ${at(g.loc)} |`); + } + lines.push(''); + } + + if (s.egress.length > 0) { + lines.push(h3('Egress From a Reach')); + lines.push(''); + lines.push('`@flows` out of an actor or a surface it reaches that leave the model or cross a declared `@boundary`.'); + lines.push(''); + lines.push('| Actors | Flow | Leaves the model | Crosses | Location |'); + lines.push('|--------|------|------------------|---------|----------|'); + for (const e of s.egress) { + lines.push(`| ${e.actors.join(', ')} | ${cell(`${e.source} → ${e.target}${e.mechanism ? ` via ${e.mechanism}` : ''}`)} | ${e.external ? 'yes' : 'no'} | ${e.boundaries.join(', ') || '—'} | ${at(e.loc)} |`); + } + lines.push(''); + } + + if (s.loose.length > 0) { + lines.push(h3('Effects Not Tied to a Reach')); + lines.push(''); + lines.push('No `@agents` or `@reaches` is bound to the same code, so which principal reaches these'); + lines.push('is a call-graph question. They still count as ungated mutations when nothing gates them.'); + lines.push(''); + for (const l of s.loose) { + for (const e of l.effects) lines.push(`- ${effectText(e)} on ${s.columns.find(c => c.key === l.asset)?.ref ?? l.asset} (${at(e.loc)})`); + } + lines.push(''); + } +} + +/** The OWASP Top 10 for LLM Applications (2025) items reach evidence speaks to. */ +function emitOwasp(s: ReachSummary, lines: string[], h3: (t: string) => string): void { + if (s.totals.agents === 0) return; + lines.push(h3('OWASP Top 10 for LLM Applications')); + lines.push(''); + lines.push('Each row is a target for review or test, not a finding the model has proved.'); + lines.push(''); + lines.push('| Item | What GuardLink looks for | Found |'); + lines.push('|------|--------------------------|-------|'); + for (const o of s.owasp) lines.push(`| ${o.id} ${o.title} | ${cell(o.basis)} | ${o.items.length} |`); + lines.push(''); + for (const o of s.owasp.filter(x => x.items.length > 0)) { + lines.push(`**${o.id} ${o.title}**`); + lines.push(''); + for (const i of o.items) lines.push(`- _${i.facet}_ — ${i.agent}: ${i.text} (${at(i.loc)})`); + lines.push(''); + } +} + +/** Open exposures on the assets in the reach map, so the agent document carries the risks already written down. */ +function emitReachedExposures(model: ThreatModel, s: ReachSummary, lines: string[], h3: (t: string) => string): void { + const key = canonicaliser(model); + const reached = new Set(s.columns.map(c => c.key)); + const open = findUnmitigatedExposures(model).filter(e => reached.has(key(e.asset))); + if (open.length === 0) return; + lines.push(h3('Open Exposures on Reached Assets')); + lines.push(''); + lines.push('| Severity | Asset | Threat | Description | Location |'); + lines.push('|----------|-------|--------|-------------|----------|'); + for (const e of open) { + lines.push(`| ${e.severity ?? '—'} | ${e.asset} | ${e.threat} | ${cell(e.description ?? '—')} | ${e.location.file}:${e.location.line} |`); + } + lines.push(''); +} + +/** + * The agent threat model on its own: the same section as the main report, + * with a header that says what it covers. `guardlink report --agents`. + */ +export function generateAgentReachReport(model: ThreatModel): string { + const features = (model as ThreatModel & { filtered_by_features?: string[] }).filtered_by_features ?? []; + const suffix = features.length > 0 ? ` — feature${features.length > 1 ? 's' : ''} ${features.map(f => `"${f}"`).join(', ')}` : ''; + const lines: string[] = []; + lines.push(`# Agent and LLM Threat Model — ${model.project}${suffix}`); + lines.push(''); + lines.push(`> Generated: ${model.generated_at} `); + lines.push(`> Files scanned: ${model.source_files} | Annotations: ${model.annotations_parsed}`); + if (model.metadata?.commit_sha) { + lines.push(`> Commit: ${model.metadata.commit_sha}${model.metadata.branch ? ` (${model.metadata.branch})` : ''}`); + } + if (features.length > 0) { + lines.push('>'); + lines.push(`> **Feature slice**: only annotations in the files tagged ${features.map(f => `"${f}"`).join(', ')}.`); + } + lines.push(''); + lines.push('This is the agent part of the threat model: what each embedded LLM agent and other principal can reach,'); + lines.push('what of that a human approved, and what acts with no one deciding first. `guardlink report` without'); + lines.push('`--agents` places the same section in the full threat model.'); + lines.push(''); + lines.push(`## Agents and LLM Reach${suffix}`); + lines.push(''); + emitAgentReach(model, lines, suffix); + return lines.join('\n'); +} diff --git a/src/report/index.ts b/src/report/index.ts index 823ce448..b6ed0332 100644 --- a/src/report/index.ts +++ b/src/report/index.ts @@ -7,4 +7,5 @@ export { generateMermaid } from './mermaid.js'; export { generateReport } from './report.js'; +export { generateAgentReachReport, emitAgentReach } from './agent-reach.js'; export { generateSequenceDiagram } from './sequence.js'; diff --git a/src/report/report.ts b/src/report/report.ts index cb2f802f..219d6171 100644 --- a/src/report/report.ts +++ b/src/report/report.ts @@ -22,6 +22,8 @@ import { findUnmitigatedExposures, normalizeRef } from '../parser/coverage.js'; import { ACCEPTANCE_REGISTER_SHORT, ACCEPTANCE_REGISTER_NOTE } from '../parser/acceptance.js'; import { normalizeName } from '../parser/normalize.js'; import { entitlementDemotionBlockers } from '../parser/parse-project.js'; +import { hasReach, summarizeReach } from '../reach/index.js'; +import { emitAgentReach } from './agent-reach.js'; // ═══════════════════════════════════════════════════════════════════════ // Feature slice — is this document the project, or one slice of it? @@ -231,6 +233,17 @@ export function generateReport(rawModel: ThreatModel): string { emitAIDetails(model, lines); } + // ══════════════════════════════════════════════════════════════════════ + // SECTION 11: Agents and LLM Reach (conditional) + // ══════════════════════════════════════════════════════════════════════ + // Only when some reach annotation exists, so a model without them reports + // exactly as before. `guardlink report --agents` prints this section alone. + if (hasReach(model)) { + lines.push(h2('Agents and LLM Reach', slice)); + lines.push(''); + emitAgentReach(model, lines, slice.headingSuffix); + } + // ══════════════════════════════════════════════════════════════════════ // EXISTING SECTIONS: Executive Summary + Findings // ══════════════════════════════════════════════════════════════════════ @@ -265,6 +278,12 @@ export function generateReport(rawModel: ThreatModel): string { lines.push(`| Validations | ${model.validations.length} |`); lines.push(`| Ownership records | ${model.ownership.length} |`); if (model.shields.length > 0) lines.push(`| Shielded regions | ${model.shields.length} |`); + if (hasReach(model)) { + const reach = summarizeReach(model); + lines.push(`| Agent and principal reaches | ${reach.totals.reaches} |`); + lines.push(`| **Unentitled reaches** | **${reach.totals.unentitled}** |`); + lines.push(`| **Ungated mutations** | **${reach.totals.ungated}** |`); + } lines.push(''); // ── Threat Model Diagram ── diff --git a/tests/agent-reach-surfaces.test.ts b/tests/agent-reach-surfaces.test.ts new file mode 100644 index 00000000..f26e0a8e --- /dev/null +++ b/tests/agent-reach-surfaces.test.ts @@ -0,0 +1,337 @@ +/** + * Reach on the surfaces people read: the dashboard's Agents & Reach page, the + * report's "Agents and LLM Reach" section, the agent-only report + * (`report --agents`), and the threat-report serialisation. + * + * Driven by `tests/fixtures/support-desk` — the same fixture the reach export + * and the pentest SARIF are pinned on — plus a small inline app for what that + * fixture does not declare (untrusted input, egress, a broader identity). The derived + * view (`summarizeReach`), the report section, the agent report and the reach + * diagram are pinned as golden files beside the fixture; regenerate them with + * `npx vitest run tests/agent-reach-surfaces.test.ts -u` and read the diff. + * + * @validates #output-encoding for #dashboard -- "Actor, capability and description text from reach annotations is HTML-escaped on the Agents page and the actor table" + */ +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { cpSync, mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync, existsSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join, dirname } from 'node:path'; +import { execFile } from 'node:child_process'; +import { createRequire } from 'node:module'; +import { fileURLToPath } from 'node:url'; +import { parseProject } from '../src/parser/parse-project.js'; +import { summarizeReach, hasReach } from '../src/reach/index.js'; +import { buildReachAnalysis } from '../src/parser/reach.js'; +import { generateReport, generateAgentReachReport, emitAgentReach } from '../src/report/index.js'; +import { generateDashboardHTML } from '../src/dashboard/index.js'; +import { generateReachDiagram } from '../src/dashboard/diagrams.js'; +import { serializeAgentReach, serializeModel } from '../src/analyze/index.js'; +import type { ThreatModel } from '../src/types/index.js'; + +const repoRoot = join(dirname(fileURLToPath(import.meta.url)), '..'); +const FIXTURE = join(repoRoot, 'tests', 'fixtures', 'support-desk'); +const GOLDEN = './fixtures/support-desk/golden'; + +const roots: string[] = []; +afterAll(() => { for (const r of roots) rmSync(r, { recursive: true, force: true }); }); + +function writeProject(files: Record): string { + const root = mkdtempSync(join(tmpdir(), 'gl-reach-surfaces-')); + roots.push(root); + for (const [path, body] of Object.entries(files)) { + mkdirSync(dirname(join(root, path)), { recursive: true }); + writeFileSync(join(root, path), body); + } + return root; +} + +const parse = async (root: string) => (await parseProject({ root, project: 'support-desk' })).model; + +/** The section between one `
` and the next. */ +function page(html: string, id: string): string { + const start = html.indexOf(`
#bot via chat -- "the prompt" + */ +export function chat(): void {} + +/** + * @agents #bot to run-sql on #tools as #session + * @effects write on #db as #db-admin -- "model-written SQL" + */ +export function runSql(): void {} + +/** + * @agents #bot to fetch-url on #tools + * @flows #tools -> External.Internet via fetch + * @boundary between #tools and External.Internet (#egress) -- "leaves the app" + */ +export function fetchUrl(): void {} +`; + let s: ReturnType; + beforeAll(async () => { s = summarizeReach(await parse(writeProject({ '.guardlink/definitions.ts': DEFS, 'src/app.ts': APP }))); }); + + it('finds the injection-to-tool route from outside input to the ungated write', () => { + expect(s.injection).toMatchObject([{ entry: 'Customer', chain: ['Customer', '#bot'], agent: '#bot', effects: [{ effect: 'write', asset: '#db' }] }]); + expect(s.owasp.find(o => o.id === 'LLM01')!.items).toHaveLength(1); + }); + + it('reports egress across the boundary, and the broader execution identity', () => { + expect(s.egress).toMatchObject([{ actors: ['#bot'], source: '#tools', target: 'External.Internet', external: true, boundaries: ['#egress'] }]); + const llm06 = s.owasp.find(o => o.id === 'LLM06')!.items; + expect(llm06.filter(i => i.facet === 'permissions').map(i => i.text)) + .toEqual(['run-sql presents #session, and the write on #db runs as #db-admin with no gate between']); + }); +}); + +// ─── The report ────────────────────────────────────────────────────── + +describe('the report', () => { + const stamp = (md: string) => md.replace(/^> Generated: .*$/m, '> Generated: '); + + it('matches the golden "Agents and LLM Reach" section', async () => { + const lines: string[] = []; + emitAgentReach(model, lines); + await expect(`${lines.join('\n')}\n`).toMatchFileSnapshot(`${GOLDEN}/agent-reach-section.md`); + }); + + it('matches the golden agent-only threat model', async () => { + await expect(`${stamp(generateAgentReachReport(model))}\n`).toMatchFileSnapshot(`${GOLDEN}/threat-model-agents.md`); + }); + + it('places the same section in the main threat model, and counts it in the summary', () => { + const md = generateReport(model); + expect(md).toContain('## Agents and LLM Reach'); + const section = md.slice(md.indexOf('## Agents and LLM Reach'), md.indexOf('## Executive Summary')); + const lines: string[] = []; + emitAgentReach(model, lines); + expect(section).toContain(lines.join('\n')); + expect(md).toContain('| **Unentitled reaches** | **5** |'); + expect(md).toContain('| **Ungated mutations** | **5** |'); + }); + + it('leaves a model with no reach annotation exactly as it reported before', async () => { + const m = await parse(writeProject({ + '.guardlink/definitions.ts': '/**\n * @asset App (#app) -- "a"\n * @threat Sqli (#sqli) [high] -- "s"\n */\nexport {};\n', + 'src/a.ts': 'const x = 1;\n\n/**\n * @exposes #app to #sqli [high] -- "raw sql"\n */\nexport function q(): void {}\n', + })); + expect(hasReach(m)).toBe(false); + const md = generateReport(m); + expect(md).not.toContain('Agents and LLM Reach'); + expect(md).not.toContain('Unentitled reaches'); + expect(generateAgentReachReport(m)).toContain('_No `@agents`, `@reaches`, `@effects` or `@gates` annotations.'); + }); + + it('carries a feature slice into every heading of the agent report', () => { + const sliced = { ...model, filtered_by_features: ['Support'] } as ThreatModel; + const md = generateAgentReachReport(sliced); + expect(md).toContain('# Agent and LLM Threat Model — support-desk — feature "Support"'); + for (const h of md.split('\n').filter(l => l.startsWith('## ') || l.startsWith('### '))) { + expect(h, h).toContain('feature "Support"'); + } + }); +}); + +// ─── The threat-report path ────────────────────────────────────────── + +describe('the threat-report serialisation', () => { + it('hands the LLM the reach claims and the derived lists', () => { + const reach = serializeAgentReach(model)!; + expect(reach.unentitled_reaches).toHaveLength(5); + expect(reach.ungated_mutations).toHaveLength(5); + // Only the rows with evidence: this fixture declares no untrusted input, so no LLM01. + expect((reach.owasp_llm as { id: string }[]).map(o => o.id)).toEqual(['LLM06', 'LLM05']); + expect(JSON.parse(serializeModel(model)).agent_reach).toEqual(reach); + }); + + it('adds nothing for a model with no reach annotation', async () => { + const m = await parse(writeProject({ '.guardlink/definitions.ts': '// @asset App (#app) -- "a"\nexport {};\n' })); + expect(serializeAgentReach(m)).toBeUndefined(); + expect(JSON.parse(serializeModel(m))).not.toHaveProperty('agent_reach'); + }); +}); + +// ─── The dashboard ─────────────────────────────────────────────────── + +describe('the dashboard', () => { + let html: string; + beforeAll(() => { html = generateDashboardHTML(model, FIXTURE); }); + + it('adds an Agents & Reach page with the reach map, its lists and the OWASP rows', () => { + expect(html).toContain(''); + const agents = page(html, 'agents'); + expect(agents).toContain('id="reach-map"'); + expect(agents).toContain('id="agent-unentitled"'); + expect(agents).toContain('id="agent-ungated"'); + expect(agents).toContain('id="agent-gates"'); + expect(agents).toContain('id="agents-owasp"'); + // One row per actor that reaches something, the agent marked, and one for effects no reach is bound to. + expect(agents.match(/not tied to a reach'); + expect(agents).toContain('AI agent'); + expect(agents.match(/reach-chip reach-cap bad/g)!.length).toBeGreaterThanOrEqual(5); + }); + + it('draws the reach map as a diagram tab, matching the golden Mermaid source', async () => { + expect(page(html, 'diagrams')).toContain('Agent Reach'); + await expect(`${generateReachDiagram(summarizeReach(model))}\n`).toMatchFileSnapshot(`${GOLDEN}/agent-reach.mmd`); + }); + + it('marks agents in the actor table, and gives reached assets a reach section in their drawer', () => { + const data = page(html, 'data'); + expect(data).toContain('id="actors"'); + expect(data).toMatch(/#support-agent<\/code><\/td>\s*AI agent<\/span>/); + expect(data).toMatch(/#support-human<\/code><\/td>\s*approver<\/span>/); + const assets = JSON.parse(html.match(/const assetsData = (.*);\n/)![1]) as { name: string; reach: unknown }[]; + expect(assets.find(a => a.name === '#payments')!.reach).toEqual({ + capabilities: [], + effects: [ + { actor: '#support-agent', effect: 'spend', gated: true, approvers: ['#support-human'], via: ['issue-refund'] }, + // The nightly sweep: no reach on its code, so the issue-refund gate does not cover it. + { actor: null, effect: 'spend', gated: false, approvers: [], via: [] }, + ], + gates: [{ approver: '#support-human', capability: 'issue-refund' }], + }); + expect(assets.find(a => a.name === '#agent-session')!.reach).toBeNull(); + }); + + it('puts unentitled reaches and ungated mutations on the summary\'s to-do list', () => { + const summary = page(html, 'summary'); + expect(summary).toContain('5 capabilities handed out that nobody approved'); + expect(summary).toContain('5 mutations with no one deciding first'); + }); + + it('shows an empty state, not an empty grid, when nothing declares reach', async () => { + const m = await parse(writeProject({ + '.guardlink/definitions.ts': '/**\n * @asset App (#app) -- "a"\n * @threat Sqli (#sqli) [high] -- "s"\n */\nexport {};\n', + 'src/a.ts': 'const x = 1;\n\n/**\n * @exposes #app to #sqli [high] -- "raw sql"\n */\nexport function q(): void {}\n', + })); + const out = generateDashboardHTML(m); + const agents = page(out, 'agents'); + expect(agents).toContain('class="empty-state"'); + expect(agents).not.toContain('id="reach-map"'); + expect(page(out, 'diagrams')).not.toContain('Agent Reach'); + expect(page(out, 'summary')).not.toContain('nobody approved'); + }); + + it('escapes reach text from the model', async () => { + const m = await parse(writeProject({ + '.guardlink/definitions.ts': '/**\n * @asset Tools (#tools) -- "t"\n * @actor Bot (#bot) -- ""\n */\nexport {};\n', + 'src/a.ts': 'const x = 1;\n\n/**\n * @agents #bot to run-sql on #tools -- ""\n */\nexport function q(): void {}\n', + })); + const out = generateDashboardHTML(m); + for (const id of ['agents', 'data']) { + expect(page(out, id)).not.toContain('alert'); + } + expect(page(out, 'agents')).toContain('<img src=x onerror=alert(1)>'); + }); +}); + +// ─── The CLI ───────────────────────────────────────────────────────── + +describe('guardlink report --agents', () => { + const cli = join(repoRoot, 'src', 'cli', 'index.ts'); + const tsx = createRequire(import.meta.url).resolve('tsx/cli'); + const run = (cwd: string, ...args: string[]) => new Promise<{ status: number; stderr: string }>(resolve => { + execFile(process.execPath, [tsx, cli, ...args], { cwd, encoding: 'utf-8' }, (err, _stdout, stderr) => { + const code = (err as { code?: number | string } | null)?.code; + resolve({ status: typeof code === 'number' ? code : err ? 1 : 0, stderr }); + }); + }); + + it('writes the agent threat model alone, and the full report still carries the section', async () => { + const root = mkdtempSync(join(tmpdir(), 'gl-reach-cli-')); + roots.push(root); + cpSync(FIXTURE, root, { recursive: true, filter: src => !src.includes(`${'/'}golden`) }); + + const agents = await run(root, 'report', '.', '--agents'); + expect(agents.status, agents.stderr).toBe(0); + const md = readFileSync(join(root, 'threat-model-agents.md'), 'utf-8'); + expect(md.startsWith('# Agent and LLM Threat Model — support-desk')).toBe(true); + expect(md).toContain('### Unentitled Reaches'); + expect(existsSync(join(root, 'threat-model.json'))).toBe(false); + + const full = await run(root, 'report', '.'); + expect(full.status, full.stderr).toBe(0); + expect(readFileSync(join(root, 'threat-model.md'), 'utf-8')).toContain('## Agents and LLM Reach'); + }, 60_000); +}); diff --git a/tests/fixtures/support-desk/FIXTURE.md b/tests/fixtures/support-desk/FIXTURE.md index 2cc397a4..c24b587b 100644 --- a/tests/fixtures/support-desk/FIXTURE.md +++ b/tests/fixtures/support-desk/FIXTURE.md @@ -31,3 +31,11 @@ The `@entitles` lines have no accepted proposal behind them, so It does not perturb this repository's own model: `**/tests/**` is in the parser's `DEFAULT_EXCLUDE`. + +`golden/` holds what `tests/agent-reach-surfaces.test.ts` pins from this +fixture for the human-facing surfaces: the derived view the dashboard's Agents +& Reach page and the report both draw (`reach-summary.json`), the report's +"Agents and LLM Reach" section (`agent-reach-section.md`), the agent-only +report (`threat-model-agents.md`) and the reach diagram (`agent-reach.mmd`). +Editing the fixture changes them too; regenerate with +`npx vitest run tests/agent-reach-surfaces.test.ts -u` and read the diff. diff --git a/tests/fixtures/support-desk/golden/agent-reach-section.md b/tests/fixtures/support-desk/golden/agent-reach-section.md new file mode 100644 index 00000000..1d1a8336 --- /dev/null +++ b/tests/fixtures/support-desk/golden/agent-reach-section.md @@ -0,0 +1,126 @@ +What the code lets each agent and principal do — `@agents` and `@reaches` (can invoke), `@effects` (what the +code does) and `@gates` (who decides first) — set against what a human approved with `@entitles`. An effect is +tied to an actor only when both are written in one doc-block, bound to the same code; reaching an effect +through calls across handlers takes a call graph and is not inferred here. + +| Measure | Count | +|---------|-------| +| Agents (`@agents`) | 1 | +| Other principals (`@reaches`) | 1 | +| Reaches | 7 | +| **Unentitled reaches** | **5** | +| Effects | 9 (7 mutating) | +| **Ungated mutations** | **5** | +| Gates | 2 | +| Egress from a reach | 0 | +| Injection-to-tool routes | 0 | + +### #support-agent — AI agent + +LLM agent; acts for the signed-in customer + +6 capabilities, 4 unentitled, 3 ungated mutations. + +| Capability | On | As | Entitled | What it leads to | Location | +|------------|----|----|----------|------------------|----------| +| `lookup-order` | #tool-surface | #agent-session | yes | read #orders-db | src/tools.ts:5 | +| `run-sql` | #tool-surface | — | **no** | **delete (ungated)** #orders-db, **write (ungated)** #users-db | src/tools.ts:14 | +| `issue-refund` | #tool-surface | — | yes | spend (gated by #support-human) #payments | src/tools.ts:25 | +| `search-kb` | #tool-surface | — | **no** | read #kb | src/tools.ts:34 | +| `fetch-url` | #tool-surface | — | **no** | — | src/tools.ts:43 | +| `mcp-files` | #tool-surface | — | **no** | **write (ungated)** #host-fs | src/tools.ts:51 | + +### #ci-runner — principal + +Release pipeline + +1 capability, 1 unentitled, 1 ungated mutation. + +| Capability | On | As | Entitled | What it leads to | Location | +|------------|----|----|----------|------------------|----------| +| `publish-package` | #registry | — | **no** | **write (ungated)** #registry | src/billing.ts:12 | + +### Reach Map + +Rows are actors, columns the assets they reach. A capability is marked ✗ when no cited `@entitles` covers it; +an effect is marked ungated when it mutates and no `@gates` stands in front of it. + +| Actor | #tool-surface | #orders-db | #users-db | #payments | #kb | #host-fs | #registry | #outbox | +|-------|---|---|---|---|---|---|---|---| +| #support-agent (agent) | `lookup-order` ✓
`run-sql` ✗
`issue-refund` ✓
`search-kb` ✗
`fetch-url` ✗
`mcp-files` ✗ | read
**delete (ungated)** | **write (ungated)** | spend (gated by #support-human) | read | **write (ungated)** | · | · | +| #ci-runner | · | · | · | · | · | · | `publish-package` ✗
**write (ungated)** | · | +| _not tied to a reach_ | · | · | · | **spend (ungated)** | · | · | · | notify (gated by #support-human) | + +### Unentitled Reaches + +Capabilities the code hands out that no cited `@entitles` covers: can minus may. For an agent this is the +Excessive Agency list. Only a human closes one, by accepting an entitlement proposal (`guardlink entitle --propose`). + +| Actor | Capability | On | Why nothing covers it | Location | +|-------|------------|----|-----------------------|----------| +| #ci-runner | `publish-package` | #registry | no `@entitles` for this actor and capability | src/billing.ts:12 | +| #support-agent (agent) | `run-sql` | #tool-surface | no `@entitles` for this actor and capability | src/tools.ts:14 | +| #support-agent (agent) | `search-kb` | #tool-surface | the entitlement is for another asset (src/tools.ts:36) | src/tools.ts:34 | +| #support-agent (agent) | `fetch-url` | #tool-surface | the entitlement cites no authorization code, so it covers nothing (src/tools.ts:44) | src/tools.ts:43 | +| #support-agent (agent) | `mcp-files` | #tool-surface | no `@entitles` for this actor and capability | src/tools.ts:51 | + +### Ungated Mutations + +Effects other than `read` with no `@gates` in front of them. A gate suppresses nothing; its absence means +nothing in the model says a person or a check decides before the effect lands. + +| Effect | Asset | As | Reached through | Location | +|--------|-------|----|-----------------|----------| +| spend | #payments | #billing-sa | _no reach on this code_; #support-human for `issue-refund` does not cover it: the gate is for one capability, and no reach on this code says which capability gets here | src/billing.ts:5 | +| write | #registry | — | #ci-runner `publish-package` | src/billing.ts:13 | +| delete | #orders-db | — | #support-agent `run-sql` | src/tools.ts:15 | +| write | #users-db | — | #support-agent `run-sql` | src/tools.ts:16 | +| write | #host-fs | — | #support-agent `mcp-files` | src/tools.ts:52 | + +### Gates + +| Asset | Approver | For | Stands in front of | Location | +|-------|----------|-----|--------------------|----------| +| #payments | #support-human | `issue-refund` | spend on #payments (src/tools.ts:26) | src/approval.ts:5 | +| #outbox | #support-human | every capability | notify on #outbox (src/approval.ts:13) | src/approval.ts:12 | + +### Effects Not Tied to a Reach + +No `@agents` or `@reaches` is bound to the same code, so which principal reaches these +is a call-graph question. They still count as ungated mutations when nothing gates them. + +- notify (gated by #support-human) on #outbox (src/approval.ts:13) +- **spend (ungated)** on #payments (src/billing.ts:5) + +### OWASP Top 10 for LLM Applications + +Each row is a target for review or test, not a finding the model has proved. + +| Item | What GuardLink looks for | Found | +|------|--------------------------|-------| +| LLM06 Excessive Agency | Unentitled @agents (functionality), an effect running as a broader identity than the agent presents (permissions), and mutations with no @gates (autonomy). | 7 | +| LLM01 Prompt Injection | Input from outside the model that @flows into an agent which can reach an ungated mutation. | 0 | +| LLM05 Improper Output Handling | A write, delete, execute or notify bound to the same code as the agent's tool, so model output reaches it directly. | 3 | + +**LLM06 Excessive Agency** + +- _functionality_ — #support-agent: can run-sql on #tool-surface and no cited @entitles covers it (src/tools.ts:14) +- _functionality_ — #support-agent: can search-kb on #tool-surface and no cited @entitles covers it (src/tools.ts:34) +- _functionality_ — #support-agent: can fetch-url on #tool-surface and no cited @entitles covers it (src/tools.ts:43) +- _functionality_ — #support-agent: can mcp-files on #tool-surface and no cited @entitles covers it (src/tools.ts:51) +- _autonomy_ — #support-agent: delete on #orders-db through run-sql, with no @gates in front of it (src/tools.ts:15) +- _autonomy_ — #support-agent: write on #users-db through run-sql, with no @gates in front of it (src/tools.ts:16) +- _autonomy_ — #support-agent: write on #host-fs through mcp-files, with no @gates in front of it (src/tools.ts:52) + +**LLM05 Improper Output Handling** + +- _model output into an effect_ — #support-agent: run-sql hands model-written arguments to a delete on #orders-db in the same code (src/tools.ts:15) +- _model output into an effect_ — #support-agent: run-sql hands model-written arguments to a write on #users-db in the same code (src/tools.ts:16) +- _model output into an effect_ — #support-agent: mcp-files hands model-written arguments to a write on #host-fs in the same code (src/tools.ts:52) + +### Open Exposures on Reached Assets + +| Severity | Asset | Threat | Description | Location | +|----------|-------|--------|-------------|----------| +| high | #users-db | #excessive-agency | Reachable from the agent's tool surface, nothing approves the write | src/tools.ts:17 | + diff --git a/tests/fixtures/support-desk/golden/agent-reach.mmd b/tests/fixtures/support-desk/golden/agent-reach.mmd new file mode 100644 index 00000000..1412fe0b --- /dev/null +++ b/tests/fixtures/support-desk/golden/agent-reach.mmd @@ -0,0 +1,34 @@ +%%{init: {"flowchart": {"nodeSpacing": 40, "rankSpacing": 90, "curve": "basis", "htmlLabels": false}}}%% +graph LR + A_support_agent(["#support-agent · AI agent"]):::agent + A_ci_runner["#ci-runner"]:::principal + A_support_human(("#support-human")):::approver + S_registry[("#registry")] + A_ci_runner -. "unentitled: publish-package" .-> S_registry + A_ci_runner == "write · no gate" ==> S_registry + S_tool_surface[("#tool-surface")] + A_support_agent -. "unentitled: run-sql, search-kb, fetch-url, mcp-files · entitled: lookup-order, issue-refund" .-> S_tool_surface + S_orders_db[("#orders-db")] + A_support_agent == "delete · no gate" ==> S_orders_db + A_support_agent -- "read" --> S_orders_db + S_users_db[("#users-db")] + A_support_agent == "write · no gate" ==> S_users_db + S_payments[("#payments")] + A_support_agent -- "spend gated" --> S_payments + S_kb[("#kb")] + A_support_agent -- "read" --> S_kb + S_host_fs[("#host-fs")] + A_support_agent == "write · no gate" ==> S_host_fs + X_loose["code not tied to a reach"]:::loose + S_outbox[("#outbox")] + X_loose -- "notify" --> S_outbox + X_loose == "spend" ==> S_payments + A_support_human -. "gates issue-refund" .-> S_payments + A_support_human -. "gates" .-> S_outbox + classDef agent stroke:#33d49d,stroke-width:2px + classDef principal stroke:#1a98d0,stroke-width:1.5px + classDef approver stroke:#33d49d,stroke-dasharray:4 3 + classDef loose stroke-dasharray:4 3 + classDef external stroke:#ea1d1d,stroke-dasharray:4 3 + linkStyle 0,1,2,3,5,8,10 stroke:#ea1d1d,stroke-width:2px + linkStyle 11,12 stroke:#33d49d diff --git a/tests/fixtures/support-desk/golden/reach-summary.json b/tests/fixtures/support-desk/golden/reach-summary.json new file mode 100644 index 00000000..5461b6e6 --- /dev/null +++ b/tests/fixtures/support-desk/golden/reach-summary.json @@ -0,0 +1,677 @@ +{ + "actors": [ + { + "key": "support-agent", + "ref": "#support-agent", + "name": "Support_Agent", + "description": "LLM agent; acts for the signed-in customer", + "agent": true, + "declared": true, + "reaches": 6, + "unentitled": 4, + "ungated": 3, + "approves": 0, + "loc": { + "file": ".guardlink/definitions.ts", + "line": 16 + } + }, + { + "key": "ci-runner", + "ref": "#ci-runner", + "name": "CI_Runner", + "description": "Release pipeline", + "agent": false, + "declared": true, + "reaches": 1, + "unentitled": 1, + "ungated": 1, + "approves": 0, + "loc": { + "file": ".guardlink/definitions.ts", + "line": 18 + } + }, + { + "key": "support-human", + "ref": "#support-human", + "name": "Support_Human", + "description": "Approves refunds and outbound email in the review queue", + "agent": false, + "declared": true, + "reaches": 0, + "unentitled": 0, + "ungated": 0, + "approves": 2, + "loc": { + "file": ".guardlink/definitions.ts", + "line": 17 + } + } + ], + "columns": [ + { + "key": "tool-surface", + "ref": "#tool-surface", + "declared": true + }, + { + "key": "orders-db", + "ref": "#orders-db", + "declared": true + }, + { + "key": "users-db", + "ref": "#users-db", + "declared": true + }, + { + "key": "payments", + "ref": "#payments", + "declared": true + }, + { + "key": "kb", + "ref": "#kb", + "declared": true + }, + { + "key": "host-fs", + "ref": "#host-fs", + "declared": true + }, + { + "key": "registry", + "ref": "#registry", + "declared": true + }, + { + "key": "outbox", + "ref": "#outbox", + "declared": true + } + ], + "cells": [ + { + "actor": "ci-runner", + "asset": "registry", + "capabilities": [ + { + "capability": "publish-package", + "entitled": false, + "loc": { + "file": "src/billing.ts", + "line": 12 + } + } + ], + "effects": [ + { + "effect": "write", + "mutating": true, + "gated": false, + "approvers": [], + "via": [ + "publish-package" + ], + "loc": { + "file": "src/billing.ts", + "line": 13 + } + } + ] + }, + { + "actor": "support-agent", + "asset": "tool-surface", + "capabilities": [ + { + "capability": "lookup-order", + "entitled": true, + "identity": "#agent-session", + "loc": { + "file": "src/tools.ts", + "line": 5 + } + }, + { + "capability": "run-sql", + "entitled": false, + "loc": { + "file": "src/tools.ts", + "line": 14 + } + }, + { + "capability": "issue-refund", + "entitled": true, + "loc": { + "file": "src/tools.ts", + "line": 25 + } + }, + { + "capability": "search-kb", + "entitled": false, + "loc": { + "file": "src/tools.ts", + "line": 34 + } + }, + { + "capability": "fetch-url", + "entitled": false, + "loc": { + "file": "src/tools.ts", + "line": 43 + } + }, + { + "capability": "mcp-files", + "entitled": false, + "loc": { + "file": "src/tools.ts", + "line": 51 + } + } + ], + "effects": [] + }, + { + "actor": "support-agent", + "asset": "orders-db", + "capabilities": [], + "effects": [ + { + "effect": "read", + "mutating": false, + "gated": null, + "approvers": [], + "via": [ + "lookup-order" + ], + "identity": "#agent-session", + "loc": { + "file": "src/tools.ts", + "line": 6 + } + }, + { + "effect": "delete", + "mutating": true, + "gated": false, + "approvers": [], + "via": [ + "run-sql" + ], + "loc": { + "file": "src/tools.ts", + "line": 15 + } + } + ] + }, + { + "actor": "support-agent", + "asset": "users-db", + "capabilities": [], + "effects": [ + { + "effect": "write", + "mutating": true, + "gated": false, + "approvers": [], + "via": [ + "run-sql" + ], + "loc": { + "file": "src/tools.ts", + "line": 16 + } + } + ] + }, + { + "actor": "support-agent", + "asset": "payments", + "capabilities": [], + "effects": [ + { + "effect": "spend", + "mutating": true, + "gated": true, + "approvers": [ + "#support-human" + ], + "via": [ + "issue-refund" + ], + "identity": "#billing-sa", + "loc": { + "file": "src/tools.ts", + "line": 26 + } + } + ] + }, + { + "actor": "support-agent", + "asset": "kb", + "capabilities": [], + "effects": [ + { + "effect": "read", + "mutating": false, + "gated": null, + "approvers": [], + "via": [ + "search-kb" + ], + "loc": { + "file": "src/tools.ts", + "line": 35 + } + } + ] + }, + { + "actor": "support-agent", + "asset": "host-fs", + "capabilities": [], + "effects": [ + { + "effect": "write", + "mutating": true, + "gated": false, + "approvers": [], + "via": [ + "mcp-files" + ], + "loc": { + "file": "src/tools.ts", + "line": 52 + } + } + ] + } + ], + "loose": [ + { + "asset": "outbox", + "effects": [ + { + "effect": "notify", + "mutating": true, + "gated": true, + "approvers": [ + "#support-human" + ], + "via": [], + "loc": { + "file": "src/approval.ts", + "line": 13 + } + } + ] + }, + { + "asset": "payments", + "effects": [ + { + "effect": "spend", + "mutating": true, + "gated": false, + "approvers": [], + "via": [], + "identity": "#billing-sa", + "loc": { + "file": "src/billing.ts", + "line": 5 + } + } + ] + } + ], + "unentitled": [ + { + "actor": "#ci-runner", + "agent": false, + "capability": "publish-package", + "asset": "#registry", + "description": "npm publish in the release workflow", + "loc": { + "file": "src/billing.ts", + "line": 12 + }, + "near_misses": [] + }, + { + "actor": "#support-agent", + "agent": true, + "capability": "run-sql", + "asset": "#tool-surface", + "description": "Debug tool, still registered", + "loc": { + "file": "src/tools.ts", + "line": 14 + }, + "near_misses": [] + }, + { + "actor": "#support-agent", + "agent": true, + "capability": "search-kb", + "asset": "#tool-surface", + "loc": { + "file": "src/tools.ts", + "line": 34 + }, + "near_misses": [ + { + "blocker": "other-asset", + "reason": "the entitlement is for another asset", + "asset": "#kb", + "citation": "src/agent/policy.ts:18", + "loc": { + "file": "src/tools.ts", + "line": 36 + } + } + ] + }, + { + "actor": "#support-agent", + "agent": true, + "capability": "fetch-url", + "asset": "#tool-surface", + "description": "Fetches any URL the model names", + "loc": { + "file": "src/tools.ts", + "line": 43 + }, + "near_misses": [ + { + "blocker": "uncited", + "reason": "the entitlement cites no authorization code, so it covers nothing", + "loc": { + "file": "src/tools.ts", + "line": 44 + } + } + ] + }, + { + "actor": "#support-agent", + "agent": true, + "capability": "mcp-files", + "asset": "#tool-surface", + "description": "Filesystem MCP server rooted at /", + "loc": { + "file": "src/tools.ts", + "line": 51 + }, + "near_misses": [] + } + ], + "ungated": [ + { + "effect": "spend", + "asset": "#payments", + "identity": "#billing-sa", + "description": "Nightly sweep retries failed refunds", + "loc": { + "file": "src/billing.ts", + "line": 5 + }, + "via": [], + "gate_near_misses": [ + { + "approver": "#support-human", + "capability": "issue-refund", + "blocker": "capability-unknown", + "reason": "the gate is for one capability, and no reach on this code says which capability gets here" + } + ], + "agent": false + }, + { + "effect": "write", + "asset": "#registry", + "description": "publish", + "loc": { + "file": "src/billing.ts", + "line": 13 + }, + "via": [ + { + "actor": "#ci-runner", + "agent": false, + "capability": "publish-package" + } + ], + "gate_near_misses": [], + "agent": false + }, + { + "effect": "delete", + "asset": "#orders-db", + "description": "runRaw executes the model's SQL", + "loc": { + "file": "src/tools.ts", + "line": 15 + }, + "via": [ + { + "actor": "#support-agent", + "agent": true, + "capability": "run-sql" + } + ], + "gate_near_misses": [], + "agent": true + }, + { + "effect": "write", + "asset": "#users-db", + "description": "runRaw executes the model's SQL", + "loc": { + "file": "src/tools.ts", + "line": 16 + }, + "via": [ + { + "actor": "#support-agent", + "agent": true, + "capability": "run-sql" + } + ], + "gate_near_misses": [], + "agent": true + }, + { + "effect": "write", + "asset": "#host-fs", + "description": "mountFiles", + "loc": { + "file": "src/tools.ts", + "line": 52 + }, + "via": [ + { + "actor": "#support-agent", + "agent": true, + "capability": "mcp-files" + } + ], + "gate_near_misses": [], + "agent": true + } + ], + "gates": [ + { + "asset": "#payments", + "approver": "#support-human", + "capability": "issue-refund", + "description": "requireApproval() parks the refund until a human approves it", + "loc": { + "file": "src/approval.ts", + "line": 5 + }, + "covers": [ + { + "effect": "spend", + "asset": "#payments", + "loc": { + "file": "src/tools.ts", + "line": 26 + } + } + ] + }, + { + "asset": "#outbox", + "approver": "#support-human", + "description": "Every outbound email waits in the review queue", + "loc": { + "file": "src/approval.ts", + "line": 12 + }, + "covers": [ + { + "effect": "notify", + "asset": "#outbox", + "loc": { + "file": "src/approval.ts", + "line": 13 + } + } + ] + } + ], + "egress": [], + "injection": [], + "owasp": [ + { + "id": "LLM06", + "title": "Excessive Agency", + "basis": "Unentitled @agents (functionality), an effect running as a broader identity than the agent presents (permissions), and mutations with no @gates (autonomy).", + "items": [ + { + "facet": "functionality", + "agent": "#support-agent", + "loc": { + "file": "src/tools.ts", + "line": 14 + }, + "text": "can run-sql on #tool-surface and no cited @entitles covers it" + }, + { + "facet": "functionality", + "agent": "#support-agent", + "loc": { + "file": "src/tools.ts", + "line": 34 + }, + "text": "can search-kb on #tool-surface and no cited @entitles covers it" + }, + { + "facet": "functionality", + "agent": "#support-agent", + "loc": { + "file": "src/tools.ts", + "line": 43 + }, + "text": "can fetch-url on #tool-surface and no cited @entitles covers it" + }, + { + "facet": "functionality", + "agent": "#support-agent", + "loc": { + "file": "src/tools.ts", + "line": 51 + }, + "text": "can mcp-files on #tool-surface and no cited @entitles covers it" + }, + { + "facet": "autonomy", + "agent": "#support-agent", + "loc": { + "file": "src/tools.ts", + "line": 15 + }, + "text": "delete on #orders-db through run-sql, with no @gates in front of it" + }, + { + "facet": "autonomy", + "agent": "#support-agent", + "loc": { + "file": "src/tools.ts", + "line": 16 + }, + "text": "write on #users-db through run-sql, with no @gates in front of it" + }, + { + "facet": "autonomy", + "agent": "#support-agent", + "loc": { + "file": "src/tools.ts", + "line": 52 + }, + "text": "write on #host-fs through mcp-files, with no @gates in front of it" + } + ] + }, + { + "id": "LLM01", + "title": "Prompt Injection", + "basis": "Input from outside the model that @flows into an agent which can reach an ungated mutation.", + "items": [] + }, + { + "id": "LLM05", + "title": "Improper Output Handling", + "basis": "A write, delete, execute or notify bound to the same code as the agent's tool, so model output reaches it directly.", + "items": [ + { + "facet": "model output into an effect", + "agent": "#support-agent", + "loc": { + "file": "src/tools.ts", + "line": 15 + }, + "text": "run-sql hands model-written arguments to a delete on #orders-db in the same code" + }, + { + "facet": "model output into an effect", + "agent": "#support-agent", + "loc": { + "file": "src/tools.ts", + "line": 16 + }, + "text": "run-sql hands model-written arguments to a write on #users-db in the same code" + }, + { + "facet": "model output into an effect", + "agent": "#support-agent", + "loc": { + "file": "src/tools.ts", + "line": 52 + }, + "text": "mcp-files hands model-written arguments to a write on #host-fs in the same code" + } + ] + } + ], + "totals": { + "agents": 1, + "principals": 1, + "reaches": 7, + "unentitled": 5, + "effects": 9, + "mutations": 7, + "ungated": 5, + "gates": 2, + "egress": 0, + "injection": 0 + } +} diff --git a/tests/fixtures/support-desk/golden/threat-model-agents.md b/tests/fixtures/support-desk/golden/threat-model-agents.md new file mode 100644 index 00000000..f15935e9 --- /dev/null +++ b/tests/fixtures/support-desk/golden/threat-model-agents.md @@ -0,0 +1,137 @@ +# Agent and LLM Threat Model — support-desk + +> Generated: +> Files scanned: 4 | Annotations: 38 + +This is the agent part of the threat model: what each embedded LLM agent and other principal can reach, +what of that a human approved, and what acts with no one deciding first. `guardlink report` without +`--agents` places the same section in the full threat model. + +## Agents and LLM Reach + +What the code lets each agent and principal do — `@agents` and `@reaches` (can invoke), `@effects` (what the +code does) and `@gates` (who decides first) — set against what a human approved with `@entitles`. An effect is +tied to an actor only when both are written in one doc-block, bound to the same code; reaching an effect +through calls across handlers takes a call graph and is not inferred here. + +| Measure | Count | +|---------|-------| +| Agents (`@agents`) | 1 | +| Other principals (`@reaches`) | 1 | +| Reaches | 7 | +| **Unentitled reaches** | **5** | +| Effects | 9 (7 mutating) | +| **Ungated mutations** | **5** | +| Gates | 2 | +| Egress from a reach | 0 | +| Injection-to-tool routes | 0 | + +### #support-agent — AI agent + +LLM agent; acts for the signed-in customer + +6 capabilities, 4 unentitled, 3 ungated mutations. + +| Capability | On | As | Entitled | What it leads to | Location | +|------------|----|----|----------|------------------|----------| +| `lookup-order` | #tool-surface | #agent-session | yes | read #orders-db | src/tools.ts:5 | +| `run-sql` | #tool-surface | — | **no** | **delete (ungated)** #orders-db, **write (ungated)** #users-db | src/tools.ts:14 | +| `issue-refund` | #tool-surface | — | yes | spend (gated by #support-human) #payments | src/tools.ts:25 | +| `search-kb` | #tool-surface | — | **no** | read #kb | src/tools.ts:34 | +| `fetch-url` | #tool-surface | — | **no** | — | src/tools.ts:43 | +| `mcp-files` | #tool-surface | — | **no** | **write (ungated)** #host-fs | src/tools.ts:51 | + +### #ci-runner — principal + +Release pipeline + +1 capability, 1 unentitled, 1 ungated mutation. + +| Capability | On | As | Entitled | What it leads to | Location | +|------------|----|----|----------|------------------|----------| +| `publish-package` | #registry | — | **no** | **write (ungated)** #registry | src/billing.ts:12 | + +### Reach Map + +Rows are actors, columns the assets they reach. A capability is marked ✗ when no cited `@entitles` covers it; +an effect is marked ungated when it mutates and no `@gates` stands in front of it. + +| Actor | #tool-surface | #orders-db | #users-db | #payments | #kb | #host-fs | #registry | #outbox | +|-------|---|---|---|---|---|---|---|---| +| #support-agent (agent) | `lookup-order` ✓
`run-sql` ✗
`issue-refund` ✓
`search-kb` ✗
`fetch-url` ✗
`mcp-files` ✗ | read
**delete (ungated)** | **write (ungated)** | spend (gated by #support-human) | read | **write (ungated)** | · | · | +| #ci-runner | · | · | · | · | · | · | `publish-package` ✗
**write (ungated)** | · | +| _not tied to a reach_ | · | · | · | **spend (ungated)** | · | · | · | notify (gated by #support-human) | + +### Unentitled Reaches + +Capabilities the code hands out that no cited `@entitles` covers: can minus may. For an agent this is the +Excessive Agency list. Only a human closes one, by accepting an entitlement proposal (`guardlink entitle --propose`). + +| Actor | Capability | On | Why nothing covers it | Location | +|-------|------------|----|-----------------------|----------| +| #ci-runner | `publish-package` | #registry | no `@entitles` for this actor and capability | src/billing.ts:12 | +| #support-agent (agent) | `run-sql` | #tool-surface | no `@entitles` for this actor and capability | src/tools.ts:14 | +| #support-agent (agent) | `search-kb` | #tool-surface | the entitlement is for another asset (src/tools.ts:36) | src/tools.ts:34 | +| #support-agent (agent) | `fetch-url` | #tool-surface | the entitlement cites no authorization code, so it covers nothing (src/tools.ts:44) | src/tools.ts:43 | +| #support-agent (agent) | `mcp-files` | #tool-surface | no `@entitles` for this actor and capability | src/tools.ts:51 | + +### Ungated Mutations + +Effects other than `read` with no `@gates` in front of them. A gate suppresses nothing; its absence means +nothing in the model says a person or a check decides before the effect lands. + +| Effect | Asset | As | Reached through | Location | +|--------|-------|----|-----------------|----------| +| spend | #payments | #billing-sa | _no reach on this code_; #support-human for `issue-refund` does not cover it: the gate is for one capability, and no reach on this code says which capability gets here | src/billing.ts:5 | +| write | #registry | — | #ci-runner `publish-package` | src/billing.ts:13 | +| delete | #orders-db | — | #support-agent `run-sql` | src/tools.ts:15 | +| write | #users-db | — | #support-agent `run-sql` | src/tools.ts:16 | +| write | #host-fs | — | #support-agent `mcp-files` | src/tools.ts:52 | + +### Gates + +| Asset | Approver | For | Stands in front of | Location | +|-------|----------|-----|--------------------|----------| +| #payments | #support-human | `issue-refund` | spend on #payments (src/tools.ts:26) | src/approval.ts:5 | +| #outbox | #support-human | every capability | notify on #outbox (src/approval.ts:13) | src/approval.ts:12 | + +### Effects Not Tied to a Reach + +No `@agents` or `@reaches` is bound to the same code, so which principal reaches these +is a call-graph question. They still count as ungated mutations when nothing gates them. + +- notify (gated by #support-human) on #outbox (src/approval.ts:13) +- **spend (ungated)** on #payments (src/billing.ts:5) + +### OWASP Top 10 for LLM Applications + +Each row is a target for review or test, not a finding the model has proved. + +| Item | What GuardLink looks for | Found | +|------|--------------------------|-------| +| LLM06 Excessive Agency | Unentitled @agents (functionality), an effect running as a broader identity than the agent presents (permissions), and mutations with no @gates (autonomy). | 7 | +| LLM01 Prompt Injection | Input from outside the model that @flows into an agent which can reach an ungated mutation. | 0 | +| LLM05 Improper Output Handling | A write, delete, execute or notify bound to the same code as the agent's tool, so model output reaches it directly. | 3 | + +**LLM06 Excessive Agency** + +- _functionality_ — #support-agent: can run-sql on #tool-surface and no cited @entitles covers it (src/tools.ts:14) +- _functionality_ — #support-agent: can search-kb on #tool-surface and no cited @entitles covers it (src/tools.ts:34) +- _functionality_ — #support-agent: can fetch-url on #tool-surface and no cited @entitles covers it (src/tools.ts:43) +- _functionality_ — #support-agent: can mcp-files on #tool-surface and no cited @entitles covers it (src/tools.ts:51) +- _autonomy_ — #support-agent: delete on #orders-db through run-sql, with no @gates in front of it (src/tools.ts:15) +- _autonomy_ — #support-agent: write on #users-db through run-sql, with no @gates in front of it (src/tools.ts:16) +- _autonomy_ — #support-agent: write on #host-fs through mcp-files, with no @gates in front of it (src/tools.ts:52) + +**LLM05 Improper Output Handling** + +- _model output into an effect_ — #support-agent: run-sql hands model-written arguments to a delete on #orders-db in the same code (src/tools.ts:15) +- _model output into an effect_ — #support-agent: run-sql hands model-written arguments to a write on #users-db in the same code (src/tools.ts:16) +- _model output into an effect_ — #support-agent: mcp-files hands model-written arguments to a write on #host-fs in the same code (src/tools.ts:52) + +### Open Exposures on Reached Assets + +| Severity | Asset | Threat | Description | Location | +|----------|-------|--------|-------------|----------| +| high | #users-db | #excessive-agency | Reachable from the agent's tool surface, nothing approves the write | src/tools.ts:17 | + From 5656f7fb482563153268aaf82e231db8f544b3c9 Mon Sep 17 00:00:00 2001 From: Animesh Srivastava Date: Fri, 2 Oct 2026 13:36:27 +0530 Subject: [PATCH 2/4] chore(artifacts): regenerate agent instructions and threat-model artifacts --- .clinerules | 6 +- .cursor/rules/guardlink.mdc | 6 +- .gemini/GEMINI.md | 6 +- .github/copilot-instructions.md | 6 +- .guardlink/README.md | 4 +- .guardlink/graph/MANIFEST.json | 124 +++++------ .guardlink/graph/attack-surface.mmd | 2 +- .../graph/by-asset/agent-launcher.flows.mmd | 2 +- .../graph/by-asset/agent-launcher.threats.mmd | 2 +- .guardlink/graph/by-asset/blame.flows.mmd | 2 +- .guardlink/graph/by-asset/blame.threats.mmd | 2 +- .guardlink/graph/by-asset/cli.flows.mmd | 2 +- .guardlink/graph/by-asset/cli.threats.mmd | 2 +- .guardlink/graph/by-asset/codegraph.flows.mmd | 2 +- .../graph/by-asset/codegraph.threats.mmd | 2 +- .guardlink/graph/by-asset/dashboard.flows.mmd | 2 +- .../graph/by-asset/dashboard.threats.mmd | 2 +- .guardlink/graph/by-asset/diff.flows.mmd | 2 +- .guardlink/graph/by-asset/diff.threats.mmd | 2 +- .../graph/by-asset/estate-view.flows.mmd | 2 +- .../graph/by-asset/estate-view.threats.mmd | 2 +- .guardlink/graph/by-asset/gate.flows.mmd | 2 +- .guardlink/graph/by-asset/gate.threats.mmd | 2 +- .guardlink/graph/by-asset/init.flows.mmd | 2 +- .guardlink/graph/by-asset/init.threats.mmd | 2 +- .../graph/by-asset/llm-client.flows.mmd | 2 +- .../graph/by-asset/llm-client.threats.mmd | 2 +- .guardlink/graph/by-asset/mcp.flows.mmd | 2 +- .guardlink/graph/by-asset/mcp.threats.mmd | 2 +- .../graph/by-asset/merge-engine.flows.mmd | 2 +- .../graph/by-asset/merge-engine.threats.mmd | 2 +- .guardlink/graph/by-asset/parser.flows.mmd | 2 +- .guardlink/graph/by-asset/parser.threats.mmd | 2 +- .../graph/by-asset/report-metadata.flows.mmd | 2 +- .../by-asset/report-metadata.threats.mmd | 2 +- .guardlink/graph/by-asset/report.flows.mmd | 7 +- .guardlink/graph/by-asset/report.threats.mmd | 2 +- .guardlink/graph/by-asset/sarif.flows.mmd | 2 +- .guardlink/graph/by-asset/sarif.threats.mmd | 2 +- .guardlink/graph/by-asset/suggest.flows.mmd | 2 +- .guardlink/graph/by-asset/suggest.threats.mmd | 2 +- .guardlink/graph/by-asset/tui.flows.mmd | 2 +- .guardlink/graph/by-asset/tui.threats.mmd | 2 +- .../by-asset/workspace-config.threats.mmd | 2 +- .../graph/by-asset/workspace-link.flows.mmd | 2 +- .../graph/by-boundary/agent-boundary.mmd | 2 +- .../graph/by-boundary/cli-input-boundary.mmd | 2 +- .../graph/by-boundary/codegraph-boundary.mmd | 2 +- .guardlink/graph/by-boundary/fs-boundary.mmd | 2 +- .guardlink/graph/by-boundary/git-boundary.mmd | 2 +- .../graph/by-boundary/llm-api-boundary.mmd | 2 +- .guardlink/graph/by-boundary/mcp-boundary.mmd | 2 +- .../graph/by-boundary/mcp-tool-boundary.mmd | 2 +- .../graph/by-boundary/nvd-api-boundary.mmd | 2 +- .../graph/by-boundary/tui-input-boundary.mmd | 2 +- .guardlink/graph/by-feature/dashboard.mmd | 2 +- .../graph/by-feature/mcp-integration.mmd | 2 +- .guardlink/graph/dataflow.mmd | 8 +- .guardlink/graph/index.md | 4 +- .guardlink/graph/threat-graph.mmd | 2 +- .guardlink/model.json | 201 +++++++++++------- .windsurfrules | 6 +- AGENTS.md | 6 +- CLAUDE.md | 6 +- 64 files changed, 276 insertions(+), 216 deletions(-) diff --git a/.clinerules b/.clinerules index 9c8a9358..4e4bd775 100644 --- a/.clinerules +++ b/.clinerules @@ -114,7 +114,7 @@ _Full records with descriptions and locations: `guardlink_lookup("asset ")`, - LLMConfig -> #llm-client via chatCompletion - #llm-client -> LLMProvider via fetch - LLMProvider -> #llm-client via response -- … and 183 more — `guardlink_lookup("flows into X")` for one asset, or `guardlink_graph(from: X)` for a neighbourhood +- … and 185 more — `guardlink_lookup("flows into X")` for one asset, or `guardlink_graph(from: X)` for a neighbourhood ### Features (filter with `--feature`) @@ -123,11 +123,11 @@ _Full records with descriptions and locations: `guardlink_lookup("asset ")`, ### Model Stats -885 annotations, 20 assets, 16 threats, 14 controls, 127 exposures, 0 confirmed, 130 mitigations, 3 actors, 1 entitlements, 203 flows, 2 features +891 annotations, 20 assets, 16 threats, 14 controls, 127 exposures, 0 confirmed, 131 mitigations, 3 actors, 1 entitlements, 205 flows, 2 features ### Block Freshness -- `annotation_hash`: `sha256-v3:9babda79ed52b602afa4ff8920c7a1e2743162457b8a33a234491d30019b2293` +- `annotation_hash`: `sha256-v3:7699fd36a4f42f9233f7bb6b81030a15c52f45618f34baa20928da749b08203b` Every MCP response carries this same hash. If it differs from the one above, this block predates the current annotations — trust the tool, and run `guardlink sync`. diff --git a/.cursor/rules/guardlink.mdc b/.cursor/rules/guardlink.mdc index 7ab21058..0c31265d 100644 --- a/.cursor/rules/guardlink.mdc +++ b/.cursor/rules/guardlink.mdc @@ -119,7 +119,7 @@ _Full records with descriptions and locations: `guardlink_lookup("asset ")`, - LLMConfig -> #llm-client via chatCompletion - #llm-client -> LLMProvider via fetch - LLMProvider -> #llm-client via response -- … and 183 more — `guardlink_lookup("flows into X")` for one asset, or `guardlink_graph(from: X)` for a neighbourhood +- … and 185 more — `guardlink_lookup("flows into X")` for one asset, or `guardlink_graph(from: X)` for a neighbourhood ### Features (filter with `--feature`) @@ -128,11 +128,11 @@ _Full records with descriptions and locations: `guardlink_lookup("asset ")`, ### Model Stats -885 annotations, 20 assets, 16 threats, 14 controls, 127 exposures, 0 confirmed, 130 mitigations, 3 actors, 1 entitlements, 203 flows, 2 features +891 annotations, 20 assets, 16 threats, 14 controls, 127 exposures, 0 confirmed, 131 mitigations, 3 actors, 1 entitlements, 205 flows, 2 features ### Block Freshness -- `annotation_hash`: `sha256-v3:9babda79ed52b602afa4ff8920c7a1e2743162457b8a33a234491d30019b2293` +- `annotation_hash`: `sha256-v3:7699fd36a4f42f9233f7bb6b81030a15c52f45618f34baa20928da749b08203b` Every MCP response carries this same hash. If it differs from the one above, this block predates the current annotations — trust the tool, and run `guardlink sync`. diff --git a/.gemini/GEMINI.md b/.gemini/GEMINI.md index 2e23db1a..e0889084 100644 --- a/.gemini/GEMINI.md +++ b/.gemini/GEMINI.md @@ -170,7 +170,7 @@ _Full records with descriptions and locations: `guardlink_lookup("asset ")`, - LLMConfig -> #llm-client via chatCompletion - #llm-client -> LLMProvider via fetch - LLMProvider -> #llm-client via response -- … and 183 more — `guardlink_lookup("flows into X")` for one asset, or `guardlink_graph(from: X)` for a neighbourhood +- … and 185 more — `guardlink_lookup("flows into X")` for one asset, or `guardlink_graph(from: X)` for a neighbourhood ### Features (filter with `--feature`) @@ -179,11 +179,11 @@ _Full records with descriptions and locations: `guardlink_lookup("asset ")`, ### Model Stats -885 annotations, 20 assets, 16 threats, 14 controls, 127 exposures, 0 confirmed, 130 mitigations, 3 actors, 1 entitlements, 203 flows, 2 features +891 annotations, 20 assets, 16 threats, 14 controls, 127 exposures, 0 confirmed, 131 mitigations, 3 actors, 1 entitlements, 205 flows, 2 features ### Block Freshness -- `annotation_hash`: `sha256-v3:9babda79ed52b602afa4ff8920c7a1e2743162457b8a33a234491d30019b2293` +- `annotation_hash`: `sha256-v3:7699fd36a4f42f9233f7bb6b81030a15c52f45618f34baa20928da749b08203b` Every MCP response carries this same hash. If it differs from the one above, this block predates the current annotations — trust the tool, and run `guardlink sync`. diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 2e23db1a..e0889084 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -170,7 +170,7 @@ _Full records with descriptions and locations: `guardlink_lookup("asset ")`, - LLMConfig -> #llm-client via chatCompletion - #llm-client -> LLMProvider via fetch - LLMProvider -> #llm-client via response -- … and 183 more — `guardlink_lookup("flows into X")` for one asset, or `guardlink_graph(from: X)` for a neighbourhood +- … and 185 more — `guardlink_lookup("flows into X")` for one asset, or `guardlink_graph(from: X)` for a neighbourhood ### Features (filter with `--feature`) @@ -179,11 +179,11 @@ _Full records with descriptions and locations: `guardlink_lookup("asset ")`, ### Model Stats -885 annotations, 20 assets, 16 threats, 14 controls, 127 exposures, 0 confirmed, 130 mitigations, 3 actors, 1 entitlements, 203 flows, 2 features +891 annotations, 20 assets, 16 threats, 14 controls, 127 exposures, 0 confirmed, 131 mitigations, 3 actors, 1 entitlements, 205 flows, 2 features ### Block Freshness -- `annotation_hash`: `sha256-v3:9babda79ed52b602afa4ff8920c7a1e2743162457b8a33a234491d30019b2293` +- `annotation_hash`: `sha256-v3:7699fd36a4f42f9233f7bb6b81030a15c52f45618f34baa20928da749b08203b` Every MCP response carries this same hash. If it differs from the one above, this block predates the current annotations — trust the tool, and run `guardlink sync`. diff --git a/.guardlink/README.md b/.guardlink/README.md index 0ad063d9..334007c6 100644 --- a/.guardlink/README.md +++ b/.guardlink/README.md @@ -11,8 +11,8 @@ accepted. This file is generated. Run `guardlink sync` to refresh it; do not edit it by hand. -Current model: 885 annotations · 20 assets · 16 threats · 14 controls · 127 exposures · 203 flows -Content hash: `sha256-v3:9babda79ed52b602afa4ff8920c7a1e2743162457b8a33a234491d30019b2293` — identical hash means identical model. +Current model: 891 annotations · 20 assets · 16 threats · 14 controls · 127 exposures · 205 flows +Content hash: `sha256-v3:7699fd36a4f42f9233f7bb6b81030a15c52f45618f34baa20928da749b08203b` — identical hash means identical model. --- diff --git a/.guardlink/graph/MANIFEST.json b/.guardlink/graph/MANIFEST.json index ec5fdb0c..f6b38ca2 100644 --- a/.guardlink/graph/MANIFEST.json +++ b/.guardlink/graph/MANIFEST.json @@ -1,12 +1,12 @@ { "schema_version": 2, - "annotation_hash": "sha256-v3:9babda79ed52b602afa4ff8920c7a1e2743162457b8a33a234491d30019b2293", + "annotation_hash": "sha256-v3:7699fd36a4f42f9233f7bb6b81030a15c52f45618f34baa20928da749b08203b", "generator": "guardlink@2.1.0", "artifacts": [ { "path": ".guardlink/graph/threat-graph.mmd", "bytes": 12296, - "annotation_hash": "sha256-v3:9babda79ed52b602afa4ff8920c7a1e2743162457b8a33a234491d30019b2293", + "annotation_hash": "sha256-v3:7699fd36a4f42f9233f7bb6b81030a15c52f45618f34baa20928da749b08203b", "renderable": true, "render": { "text_size": 10687, @@ -15,18 +15,18 @@ }, { "path": ".guardlink/graph/dataflow.mmd", - "bytes": 15487, - "annotation_hash": "sha256-v3:9babda79ed52b602afa4ff8920c7a1e2743162457b8a33a234491d30019b2293", + "bytes": 15590, + "annotation_hash": "sha256-v3:7699fd36a4f42f9233f7bb6b81030a15c52f45618f34baa20928da749b08203b", "renderable": true, "render": { - "text_size": 13397, - "edges": 193 + "text_size": 13496, + "edges": 195 } }, { "path": ".guardlink/graph/attack-surface.mmd", "bytes": 6630, - "annotation_hash": "sha256-v3:9babda79ed52b602afa4ff8920c7a1e2743162457b8a33a234491d30019b2293", + "annotation_hash": "sha256-v3:7699fd36a4f42f9233f7bb6b81030a15c52f45618f34baa20928da749b08203b", "renderable": true, "render": { "text_size": 4957, @@ -36,7 +36,7 @@ { "path": ".guardlink/graph/by-feature/dashboard.mmd", "bytes": 2301, - "annotation_hash": "sha256-v3:9babda79ed52b602afa4ff8920c7a1e2743162457b8a33a234491d30019b2293", + "annotation_hash": "sha256-v3:7699fd36a4f42f9233f7bb6b81030a15c52f45618f34baa20928da749b08203b", "renderable": true, "render": { "text_size": 813, @@ -46,7 +46,7 @@ { "path": ".guardlink/graph/by-feature/mcp-integration.mmd", "bytes": 2749, - "annotation_hash": "sha256-v3:9babda79ed52b602afa4ff8920c7a1e2743162457b8a33a234491d30019b2293", + "annotation_hash": "sha256-v3:7699fd36a4f42f9233f7bb6b81030a15c52f45618f34baa20928da749b08203b", "renderable": true, "render": { "text_size": 1235, @@ -56,7 +56,7 @@ { "path": ".guardlink/graph/by-asset/agent-launcher.threats.mmd", "bytes": 2270, - "annotation_hash": "sha256-v3:9babda79ed52b602afa4ff8920c7a1e2743162457b8a33a234491d30019b2293", + "annotation_hash": "sha256-v3:7699fd36a4f42f9233f7bb6b81030a15c52f45618f34baa20928da749b08203b", "renderable": true, "render": { "text_size": 1437, @@ -66,7 +66,7 @@ { "path": ".guardlink/graph/by-asset/agent-launcher.flows.mmd", "bytes": 2258, - "annotation_hash": "sha256-v3:9babda79ed52b602afa4ff8920c7a1e2743162457b8a33a234491d30019b2293", + "annotation_hash": "sha256-v3:7699fd36a4f42f9233f7bb6b81030a15c52f45618f34baa20928da749b08203b", "renderable": true, "render": { "text_size": 1404, @@ -76,7 +76,7 @@ { "path": ".guardlink/graph/by-asset/blame.threats.mmd", "bytes": 2211, - "annotation_hash": "sha256-v3:9babda79ed52b602afa4ff8920c7a1e2743162457b8a33a234491d30019b2293", + "annotation_hash": "sha256-v3:7699fd36a4f42f9233f7bb6b81030a15c52f45618f34baa20928da749b08203b", "renderable": true, "render": { "text_size": 1477, @@ -86,7 +86,7 @@ { "path": ".guardlink/graph/by-asset/blame.flows.mmd", "bytes": 1621, - "annotation_hash": "sha256-v3:9babda79ed52b602afa4ff8920c7a1e2743162457b8a33a234491d30019b2293", + "annotation_hash": "sha256-v3:7699fd36a4f42f9233f7bb6b81030a15c52f45618f34baa20928da749b08203b", "renderable": true, "render": { "text_size": 803, @@ -96,7 +96,7 @@ { "path": ".guardlink/graph/by-asset/cli.threats.mmd", "bytes": 2185, - "annotation_hash": "sha256-v3:9babda79ed52b602afa4ff8920c7a1e2743162457b8a33a234491d30019b2293", + "annotation_hash": "sha256-v3:7699fd36a4f42f9233f7bb6b81030a15c52f45618f34baa20928da749b08203b", "renderable": true, "render": { "text_size": 1373, @@ -106,7 +106,7 @@ { "path": ".guardlink/graph/by-asset/cli.flows.mmd", "bytes": 1930, - "annotation_hash": "sha256-v3:9babda79ed52b602afa4ff8920c7a1e2743162457b8a33a234491d30019b2293", + "annotation_hash": "sha256-v3:7699fd36a4f42f9233f7bb6b81030a15c52f45618f34baa20928da749b08203b", "renderable": true, "render": { "text_size": 1098, @@ -116,7 +116,7 @@ { "path": ".guardlink/graph/by-asset/codegraph.threats.mmd", "bytes": 1853, - "annotation_hash": "sha256-v3:9babda79ed52b602afa4ff8920c7a1e2743162457b8a33a234491d30019b2293", + "annotation_hash": "sha256-v3:7699fd36a4f42f9233f7bb6b81030a15c52f45618f34baa20928da749b08203b", "renderable": true, "render": { "text_size": 1125, @@ -126,7 +126,7 @@ { "path": ".guardlink/graph/by-asset/codegraph.flows.mmd", "bytes": 2363, - "annotation_hash": "sha256-v3:9babda79ed52b602afa4ff8920c7a1e2743162457b8a33a234491d30019b2293", + "annotation_hash": "sha256-v3:7699fd36a4f42f9233f7bb6b81030a15c52f45618f34baa20928da749b08203b", "renderable": true, "render": { "text_size": 1517, @@ -136,7 +136,7 @@ { "path": ".guardlink/graph/by-asset/dashboard.threats.mmd", "bytes": 2326, - "annotation_hash": "sha256-v3:9babda79ed52b602afa4ff8920c7a1e2743162457b8a33a234491d30019b2293", + "annotation_hash": "sha256-v3:7699fd36a4f42f9233f7bb6b81030a15c52f45618f34baa20928da749b08203b", "renderable": true, "render": { "text_size": 1586, @@ -146,7 +146,7 @@ { "path": ".guardlink/graph/by-asset/dashboard.flows.mmd", "bytes": 2011, - "annotation_hash": "sha256-v3:9babda79ed52b602afa4ff8920c7a1e2743162457b8a33a234491d30019b2293", + "annotation_hash": "sha256-v3:7699fd36a4f42f9233f7bb6b81030a15c52f45618f34baa20928da749b08203b", "renderable": true, "render": { "text_size": 1167, @@ -156,7 +156,7 @@ { "path": ".guardlink/graph/by-asset/diff.threats.mmd", "bytes": 1764, - "annotation_hash": "sha256-v3:9babda79ed52b602afa4ff8920c7a1e2743162457b8a33a234491d30019b2293", + "annotation_hash": "sha256-v3:7699fd36a4f42f9233f7bb6b81030a15c52f45618f34baa20928da749b08203b", "renderable": true, "render": { "text_size": 1044, @@ -166,7 +166,7 @@ { "path": ".guardlink/graph/by-asset/diff.flows.mmd", "bytes": 1559, - "annotation_hash": "sha256-v3:9babda79ed52b602afa4ff8920c7a1e2743162457b8a33a234491d30019b2293", + "annotation_hash": "sha256-v3:7699fd36a4f42f9233f7bb6b81030a15c52f45618f34baa20928da749b08203b", "renderable": true, "render": { "text_size": 751, @@ -176,7 +176,7 @@ { "path": ".guardlink/graph/by-asset/estate-view.threats.mmd", "bytes": 1555, - "annotation_hash": "sha256-v3:9babda79ed52b602afa4ff8920c7a1e2743162457b8a33a234491d30019b2293", + "annotation_hash": "sha256-v3:7699fd36a4f42f9233f7bb6b81030a15c52f45618f34baa20928da749b08203b", "renderable": true, "render": { "text_size": 827, @@ -186,7 +186,7 @@ { "path": ".guardlink/graph/by-asset/estate-view.flows.mmd", "bytes": 1915, - "annotation_hash": "sha256-v3:9babda79ed52b602afa4ff8920c7a1e2743162457b8a33a234491d30019b2293", + "annotation_hash": "sha256-v3:7699fd36a4f42f9233f7bb6b81030a15c52f45618f34baa20928da749b08203b", "renderable": true, "render": { "text_size": 1073, @@ -196,7 +196,7 @@ { "path": ".guardlink/graph/by-asset/gate.threats.mmd", "bytes": 1274, - "annotation_hash": "sha256-v3:9babda79ed52b602afa4ff8920c7a1e2743162457b8a33a234491d30019b2293", + "annotation_hash": "sha256-v3:7699fd36a4f42f9233f7bb6b81030a15c52f45618f34baa20928da749b08203b", "renderable": true, "render": { "text_size": 566, @@ -206,7 +206,7 @@ { "path": ".guardlink/graph/by-asset/gate.flows.mmd", "bytes": 1831, - "annotation_hash": "sha256-v3:9babda79ed52b602afa4ff8920c7a1e2743162457b8a33a234491d30019b2293", + "annotation_hash": "sha256-v3:7699fd36a4f42f9233f7bb6b81030a15c52f45618f34baa20928da749b08203b", "renderable": true, "render": { "text_size": 1013, @@ -216,7 +216,7 @@ { "path": ".guardlink/graph/by-asset/init.threats.mmd", "bytes": 1683, - "annotation_hash": "sha256-v3:9babda79ed52b602afa4ff8920c7a1e2743162457b8a33a234491d30019b2293", + "annotation_hash": "sha256-v3:7699fd36a4f42f9233f7bb6b81030a15c52f45618f34baa20928da749b08203b", "renderable": true, "render": { "text_size": 967, @@ -226,7 +226,7 @@ { "path": ".guardlink/graph/by-asset/init.flows.mmd", "bytes": 2103, - "annotation_hash": "sha256-v3:9babda79ed52b602afa4ff8920c7a1e2743162457b8a33a234491d30019b2293", + "annotation_hash": "sha256-v3:7699fd36a4f42f9233f7bb6b81030a15c52f45618f34baa20928da749b08203b", "renderable": true, "render": { "text_size": 1263, @@ -236,7 +236,7 @@ { "path": ".guardlink/graph/by-asset/llm-client.threats.mmd", "bytes": 1876, - "annotation_hash": "sha256-v3:9babda79ed52b602afa4ff8920c7a1e2743162457b8a33a234491d30019b2293", + "annotation_hash": "sha256-v3:7699fd36a4f42f9233f7bb6b81030a15c52f45618f34baa20928da749b08203b", "renderable": true, "render": { "text_size": 1056, @@ -246,7 +246,7 @@ { "path": ".guardlink/graph/by-asset/llm-client.flows.mmd", "bytes": 2191, - "annotation_hash": "sha256-v3:9babda79ed52b602afa4ff8920c7a1e2743162457b8a33a234491d30019b2293", + "annotation_hash": "sha256-v3:7699fd36a4f42f9233f7bb6b81030a15c52f45618f34baa20928da749b08203b", "renderable": true, "render": { "text_size": 1342, @@ -256,7 +256,7 @@ { "path": ".guardlink/graph/by-asset/mcp.threats.mmd", "bytes": 1807, - "annotation_hash": "sha256-v3:9babda79ed52b602afa4ff8920c7a1e2743162457b8a33a234491d30019b2293", + "annotation_hash": "sha256-v3:7699fd36a4f42f9233f7bb6b81030a15c52f45618f34baa20928da749b08203b", "renderable": true, "render": { "text_size": 1003, @@ -266,7 +266,7 @@ { "path": ".guardlink/graph/by-asset/mcp.flows.mmd", "bytes": 1883, - "annotation_hash": "sha256-v3:9babda79ed52b602afa4ff8920c7a1e2743162457b8a33a234491d30019b2293", + "annotation_hash": "sha256-v3:7699fd36a4f42f9233f7bb6b81030a15c52f45618f34baa20928da749b08203b", "renderable": true, "render": { "text_size": 1057, @@ -276,7 +276,7 @@ { "path": ".guardlink/graph/by-asset/merge-engine.threats.mmd", "bytes": 1485, - "annotation_hash": "sha256-v3:9babda79ed52b602afa4ff8920c7a1e2743162457b8a33a234491d30019b2293", + "annotation_hash": "sha256-v3:7699fd36a4f42f9233f7bb6b81030a15c52f45618f34baa20928da749b08203b", "renderable": true, "render": { "text_size": 755, @@ -286,7 +286,7 @@ { "path": ".guardlink/graph/by-asset/merge-engine.flows.mmd", "bytes": 2157, - "annotation_hash": "sha256-v3:9babda79ed52b602afa4ff8920c7a1e2743162457b8a33a234491d30019b2293", + "annotation_hash": "sha256-v3:7699fd36a4f42f9233f7bb6b81030a15c52f45618f34baa20928da749b08203b", "renderable": true, "render": { "text_size": 1299, @@ -296,7 +296,7 @@ { "path": ".guardlink/graph/by-asset/parser.threats.mmd", "bytes": 1716, - "annotation_hash": "sha256-v3:9babda79ed52b602afa4ff8920c7a1e2743162457b8a33a234491d30019b2293", + "annotation_hash": "sha256-v3:7699fd36a4f42f9233f7bb6b81030a15c52f45618f34baa20928da749b08203b", "renderable": true, "render": { "text_size": 910, @@ -306,7 +306,7 @@ { "path": ".guardlink/graph/by-asset/parser.flows.mmd", "bytes": 2142, - "annotation_hash": "sha256-v3:9babda79ed52b602afa4ff8920c7a1e2743162457b8a33a234491d30019b2293", + "annotation_hash": "sha256-v3:7699fd36a4f42f9233f7bb6b81030a15c52f45618f34baa20928da749b08203b", "renderable": true, "render": { "text_size": 1302, @@ -316,7 +316,7 @@ { "path": ".guardlink/graph/by-asset/report.threats.mmd", "bytes": 1222, - "annotation_hash": "sha256-v3:9babda79ed52b602afa4ff8920c7a1e2743162457b8a33a234491d30019b2293", + "annotation_hash": "sha256-v3:7699fd36a4f42f9233f7bb6b81030a15c52f45618f34baa20928da749b08203b", "renderable": true, "render": { "text_size": 510, @@ -325,18 +325,18 @@ }, { "path": ".guardlink/graph/by-asset/report.flows.mmd", - "bytes": 1225, - "annotation_hash": "sha256-v3:9babda79ed52b602afa4ff8920c7a1e2743162457b8a33a234491d30019b2293", + "bytes": 1275, + "annotation_hash": "sha256-v3:7699fd36a4f42f9233f7bb6b81030a15c52f45618f34baa20928da749b08203b", "renderable": true, "render": { - "text_size": 431, - "edges": 4 + "text_size": 479, + "edges": 5 } }, { "path": ".guardlink/graph/by-asset/report-metadata.threats.mmd", "bytes": 1323, - "annotation_hash": "sha256-v3:9babda79ed52b602afa4ff8920c7a1e2743162457b8a33a234491d30019b2293", + "annotation_hash": "sha256-v3:7699fd36a4f42f9233f7bb6b81030a15c52f45618f34baa20928da749b08203b", "renderable": true, "render": { "text_size": 593, @@ -346,7 +346,7 @@ { "path": ".guardlink/graph/by-asset/report-metadata.flows.mmd", "bytes": 1943, - "annotation_hash": "sha256-v3:9babda79ed52b602afa4ff8920c7a1e2743162457b8a33a234491d30019b2293", + "annotation_hash": "sha256-v3:7699fd36a4f42f9233f7bb6b81030a15c52f45618f34baa20928da749b08203b", "renderable": true, "render": { "text_size": 1105, @@ -356,7 +356,7 @@ { "path": ".guardlink/graph/by-asset/sarif.threats.mmd", "bytes": 1350, - "annotation_hash": "sha256-v3:9babda79ed52b602afa4ff8920c7a1e2743162457b8a33a234491d30019b2293", + "annotation_hash": "sha256-v3:7699fd36a4f42f9233f7bb6b81030a15c52f45618f34baa20928da749b08203b", "renderable": true, "render": { "text_size": 638, @@ -366,7 +366,7 @@ { "path": ".guardlink/graph/by-asset/sarif.flows.mmd", "bytes": 1396, - "annotation_hash": "sha256-v3:9babda79ed52b602afa4ff8920c7a1e2743162457b8a33a234491d30019b2293", + "annotation_hash": "sha256-v3:7699fd36a4f42f9233f7bb6b81030a15c52f45618f34baa20928da749b08203b", "renderable": true, "render": { "text_size": 596, @@ -376,7 +376,7 @@ { "path": ".guardlink/graph/by-asset/suggest.threats.mmd", "bytes": 1582, - "annotation_hash": "sha256-v3:9babda79ed52b602afa4ff8920c7a1e2743162457b8a33a234491d30019b2293", + "annotation_hash": "sha256-v3:7699fd36a4f42f9233f7bb6b81030a15c52f45618f34baa20928da749b08203b", "renderable": true, "render": { "text_size": 860, @@ -386,7 +386,7 @@ { "path": ".guardlink/graph/by-asset/suggest.flows.mmd", "bytes": 2143, - "annotation_hash": "sha256-v3:9babda79ed52b602afa4ff8920c7a1e2743162457b8a33a234491d30019b2293", + "annotation_hash": "sha256-v3:7699fd36a4f42f9233f7bb6b81030a15c52f45618f34baa20928da749b08203b", "renderable": true, "render": { "text_size": 1299, @@ -396,7 +396,7 @@ { "path": ".guardlink/graph/by-asset/tui.threats.mmd", "bytes": 2073, - "annotation_hash": "sha256-v3:9babda79ed52b602afa4ff8920c7a1e2743162457b8a33a234491d30019b2293", + "annotation_hash": "sha256-v3:7699fd36a4f42f9233f7bb6b81030a15c52f45618f34baa20928da749b08203b", "renderable": true, "render": { "text_size": 1349, @@ -406,7 +406,7 @@ { "path": ".guardlink/graph/by-asset/tui.flows.mmd", "bytes": 2052, - "annotation_hash": "sha256-v3:9babda79ed52b602afa4ff8920c7a1e2743162457b8a33a234491d30019b2293", + "annotation_hash": "sha256-v3:7699fd36a4f42f9233f7bb6b81030a15c52f45618f34baa20928da749b08203b", "renderable": true, "render": { "text_size": 1218, @@ -416,7 +416,7 @@ { "path": ".guardlink/graph/by-asset/workspace-config.threats.mmd", "bytes": 1278, - "annotation_hash": "sha256-v3:9babda79ed52b602afa4ff8920c7a1e2743162457b8a33a234491d30019b2293", + "annotation_hash": "sha256-v3:7699fd36a4f42f9233f7bb6b81030a15c52f45618f34baa20928da749b08203b", "renderable": true, "render": { "text_size": 546, @@ -426,7 +426,7 @@ { "path": ".guardlink/graph/by-asset/workspace-link.flows.mmd", "bytes": 1972, - "annotation_hash": "sha256-v3:9babda79ed52b602afa4ff8920c7a1e2743162457b8a33a234491d30019b2293", + "annotation_hash": "sha256-v3:7699fd36a4f42f9233f7bb6b81030a15c52f45618f34baa20928da749b08203b", "renderable": true, "render": { "text_size": 1122, @@ -436,7 +436,7 @@ { "path": ".guardlink/graph/by-boundary/agent-boundary.mmd", "bytes": 2227, - "annotation_hash": "sha256-v3:9babda79ed52b602afa4ff8920c7a1e2743162457b8a33a234491d30019b2293", + "annotation_hash": "sha256-v3:7699fd36a4f42f9233f7bb6b81030a15c52f45618f34baa20928da749b08203b", "renderable": true, "render": { "text_size": 1404, @@ -446,7 +446,7 @@ { "path": ".guardlink/graph/by-boundary/llm-api-boundary.mmd", "bytes": 2169, - "annotation_hash": "sha256-v3:9babda79ed52b602afa4ff8920c7a1e2743162457b8a33a234491d30019b2293", + "annotation_hash": "sha256-v3:7699fd36a4f42f9233f7bb6b81030a15c52f45618f34baa20928da749b08203b", "renderable": true, "render": { "text_size": 1342, @@ -456,7 +456,7 @@ { "path": ".guardlink/graph/by-boundary/nvd-api-boundary.mmd", "bytes": 2156, - "annotation_hash": "sha256-v3:9babda79ed52b602afa4ff8920c7a1e2743162457b8a33a234491d30019b2293", + "annotation_hash": "sha256-v3:7699fd36a4f42f9233f7bb6b81030a15c52f45618f34baa20928da749b08203b", "renderable": true, "render": { "text_size": 1342, @@ -466,7 +466,7 @@ { "path": ".guardlink/graph/by-boundary/cli-input-boundary.mmd", "bytes": 2241, - "annotation_hash": "sha256-v3:9babda79ed52b602afa4ff8920c7a1e2743162457b8a33a234491d30019b2293", + "annotation_hash": "sha256-v3:7699fd36a4f42f9233f7bb6b81030a15c52f45618f34baa20928da749b08203b", "renderable": true, "render": { "text_size": 1410, @@ -476,7 +476,7 @@ { "path": ".guardlink/graph/by-boundary/codegraph-boundary.mmd", "bytes": 2397, - "annotation_hash": "sha256-v3:9babda79ed52b602afa4ff8920c7a1e2743162457b8a33a234491d30019b2293", + "annotation_hash": "sha256-v3:7699fd36a4f42f9233f7bb6b81030a15c52f45618f34baa20928da749b08203b", "renderable": true, "render": { "text_size": 1517, @@ -486,7 +486,7 @@ { "path": ".guardlink/graph/by-boundary/git-boundary.mmd", "bytes": 2046, - "annotation_hash": "sha256-v3:9babda79ed52b602afa4ff8920c7a1e2743162457b8a33a234491d30019b2293", + "annotation_hash": "sha256-v3:7699fd36a4f42f9233f7bb6b81030a15c52f45618f34baa20928da749b08203b", "renderable": true, "render": { "text_size": 1230, @@ -496,7 +496,7 @@ { "path": ".guardlink/graph/by-boundary/mcp-boundary.mmd", "bytes": 2227, - "annotation_hash": "sha256-v3:9babda79ed52b602afa4ff8920c7a1e2743162457b8a33a234491d30019b2293", + "annotation_hash": "sha256-v3:7699fd36a4f42f9233f7bb6b81030a15c52f45618f34baa20928da749b08203b", "renderable": true, "render": { "text_size": 1413, @@ -506,7 +506,7 @@ { "path": ".guardlink/graph/by-boundary/mcp-tool-boundary.mmd", "bytes": 2241, - "annotation_hash": "sha256-v3:9babda79ed52b602afa4ff8920c7a1e2743162457b8a33a234491d30019b2293", + "annotation_hash": "sha256-v3:7699fd36a4f42f9233f7bb6b81030a15c52f45618f34baa20928da749b08203b", "renderable": true, "render": { "text_size": 1413, @@ -516,7 +516,7 @@ { "path": ".guardlink/graph/by-boundary/fs-boundary.mmd", "bytes": 2260, - "annotation_hash": "sha256-v3:9babda79ed52b602afa4ff8920c7a1e2743162457b8a33a234491d30019b2293", + "annotation_hash": "sha256-v3:7699fd36a4f42f9233f7bb6b81030a15c52f45618f34baa20928da749b08203b", "renderable": true, "render": { "text_size": 1421, @@ -526,7 +526,7 @@ { "path": ".guardlink/graph/by-boundary/tui-input-boundary.mmd", "bytes": 2028, - "annotation_hash": "sha256-v3:9babda79ed52b602afa4ff8920c7a1e2743162457b8a33a234491d30019b2293", + "annotation_hash": "sha256-v3:7699fd36a4f42f9233f7bb6b81030a15c52f45618f34baa20928da749b08203b", "renderable": true, "render": { "text_size": 1212, @@ -535,8 +535,8 @@ }, { "path": ".guardlink/model.json", - "bytes": 281922, - "annotation_hash": "sha256-v3:9babda79ed52b602afa4ff8920c7a1e2743162457b8a33a234491d30019b2293" + "bytes": 283866, + "annotation_hash": "sha256-v3:7699fd36a4f42f9233f7bb6b81030a15c52f45618f34baa20928da749b08203b" } ] } diff --git a/.guardlink/graph/attack-surface.mmd b/.guardlink/graph/attack-surface.mmd index 4a3acae9..e368440d 100644 --- a/.guardlink/graph/attack-surface.mmd +++ b/.guardlink/graph/attack-surface.mmd @@ -1,6 +1,6 @@ %% GENERATED FILE — do not edit. Regenerate with: guardlink artifacts . %% artifact: attack-surface.mmd -%% annotation_hash: sha256-v3:9babda79ed52b602afa4ff8920c7a1e2743162457b8a33a234491d30019b2293 +%% annotation_hash: sha256-v3:7699fd36a4f42f9233f7bb6b81030a15c52f45618f34baa20928da749b08203b %% generator: guardlink@2.1.0 %% --- %% This diagram is derived from the annotations in this repository. If diff --git a/.guardlink/graph/by-asset/agent-launcher.flows.mmd b/.guardlink/graph/by-asset/agent-launcher.flows.mmd index 45c0597f..35c1c664 100644 --- a/.guardlink/graph/by-asset/agent-launcher.flows.mmd +++ b/.guardlink/graph/by-asset/agent-launcher.flows.mmd @@ -1,7 +1,7 @@ %% GENERATED FILE — do not edit. Regenerate with: guardlink artifacts . %% artifact: by-asset/agent-launcher.flows.mmd %% scope: PARTIAL — #agent-launcher and its flow neighbourhood, grown until the drawing stopped fitting — 20 neighbour(s) one hop out are not shown -%% annotation_hash: sha256-v3:9babda79ed52b602afa4ff8920c7a1e2743162457b8a33a234491d30019b2293 +%% annotation_hash: sha256-v3:7699fd36a4f42f9233f7bb6b81030a15c52f45618f34baa20928da749b08203b %% generator: guardlink@2.1.0 %% --- %% This diagram is derived from the annotations in this repository. If diff --git a/.guardlink/graph/by-asset/agent-launcher.threats.mmd b/.guardlink/graph/by-asset/agent-launcher.threats.mmd index f2c0b3cf..ae57e87b 100644 --- a/.guardlink/graph/by-asset/agent-launcher.threats.mmd +++ b/.guardlink/graph/by-asset/agent-launcher.threats.mmd @@ -1,7 +1,7 @@ %% GENERATED FILE — do not edit. Regenerate with: guardlink artifacts . %% artifact: by-asset/agent-launcher.threats.mmd %% scope: PARTIAL — #agent-launcher only — its own threats and controls, narrowed to high and critical (8 lower-severity claim(s) omitted to keep it readable) -%% annotation_hash: sha256-v3:9babda79ed52b602afa4ff8920c7a1e2743162457b8a33a234491d30019b2293 +%% annotation_hash: sha256-v3:7699fd36a4f42f9233f7bb6b81030a15c52f45618f34baa20928da749b08203b %% generator: guardlink@2.1.0 %% --- %% This diagram is derived from the annotations in this repository. If diff --git a/.guardlink/graph/by-asset/blame.flows.mmd b/.guardlink/graph/by-asset/blame.flows.mmd index 39d38a5e..89ec95cb 100644 --- a/.guardlink/graph/by-asset/blame.flows.mmd +++ b/.guardlink/graph/by-asset/blame.flows.mmd @@ -1,7 +1,7 @@ %% GENERATED FILE — do not edit. Regenerate with: guardlink artifacts . %% artifact: by-asset/blame.flows.mmd %% scope: PARTIAL — #blame and its flow neighbourhood, grown until the drawing stopped fitting — 26 neighbour(s) one hop out are not shown -%% annotation_hash: sha256-v3:9babda79ed52b602afa4ff8920c7a1e2743162457b8a33a234491d30019b2293 +%% annotation_hash: sha256-v3:7699fd36a4f42f9233f7bb6b81030a15c52f45618f34baa20928da749b08203b %% generator: guardlink@2.1.0 %% --- %% This diagram is derived from the annotations in this repository. If diff --git a/.guardlink/graph/by-asset/blame.threats.mmd b/.guardlink/graph/by-asset/blame.threats.mmd index b72f8029..bd856785 100644 --- a/.guardlink/graph/by-asset/blame.threats.mmd +++ b/.guardlink/graph/by-asset/blame.threats.mmd @@ -1,7 +1,7 @@ %% GENERATED FILE — do not edit. Regenerate with: guardlink artifacts . %% artifact: by-asset/blame.threats.mmd %% scope: PARTIAL — #blame only — its own threats and controls -%% annotation_hash: sha256-v3:9babda79ed52b602afa4ff8920c7a1e2743162457b8a33a234491d30019b2293 +%% annotation_hash: sha256-v3:7699fd36a4f42f9233f7bb6b81030a15c52f45618f34baa20928da749b08203b %% generator: guardlink@2.1.0 %% --- %% This diagram is derived from the annotations in this repository. If diff --git a/.guardlink/graph/by-asset/cli.flows.mmd b/.guardlink/graph/by-asset/cli.flows.mmd index 17512324..0a2917ef 100644 --- a/.guardlink/graph/by-asset/cli.flows.mmd +++ b/.guardlink/graph/by-asset/cli.flows.mmd @@ -1,7 +1,7 @@ %% GENERATED FILE — do not edit. Regenerate with: guardlink artifacts . %% artifact: by-asset/cli.flows.mmd %% scope: PARTIAL — #cli and its flow neighbourhood, grown until the drawing stopped fitting — 25 neighbour(s) one hop out are not shown -%% annotation_hash: sha256-v3:9babda79ed52b602afa4ff8920c7a1e2743162457b8a33a234491d30019b2293 +%% annotation_hash: sha256-v3:7699fd36a4f42f9233f7bb6b81030a15c52f45618f34baa20928da749b08203b %% generator: guardlink@2.1.0 %% --- %% This diagram is derived from the annotations in this repository. If diff --git a/.guardlink/graph/by-asset/cli.threats.mmd b/.guardlink/graph/by-asset/cli.threats.mmd index dce93183..730dd4d8 100644 --- a/.guardlink/graph/by-asset/cli.threats.mmd +++ b/.guardlink/graph/by-asset/cli.threats.mmd @@ -1,7 +1,7 @@ %% GENERATED FILE — do not edit. Regenerate with: guardlink artifacts . %% artifact: by-asset/cli.threats.mmd %% scope: PARTIAL — #cli only — its own threats and controls, narrowed to high and critical (13 lower-severity claim(s) omitted to keep it readable) -%% annotation_hash: sha256-v3:9babda79ed52b602afa4ff8920c7a1e2743162457b8a33a234491d30019b2293 +%% annotation_hash: sha256-v3:7699fd36a4f42f9233f7bb6b81030a15c52f45618f34baa20928da749b08203b %% generator: guardlink@2.1.0 %% --- %% This diagram is derived from the annotations in this repository. If diff --git a/.guardlink/graph/by-asset/codegraph.flows.mmd b/.guardlink/graph/by-asset/codegraph.flows.mmd index 86853e8a..a290d81b 100644 --- a/.guardlink/graph/by-asset/codegraph.flows.mmd +++ b/.guardlink/graph/by-asset/codegraph.flows.mmd @@ -1,7 +1,7 @@ %% GENERATED FILE — do not edit. Regenerate with: guardlink artifacts . %% artifact: by-asset/codegraph.flows.mmd %% scope: PARTIAL — #codegraph and its flow neighbourhood, grown until the drawing stopped fitting — 36 neighbour(s) one hop out are not shown -%% annotation_hash: sha256-v3:9babda79ed52b602afa4ff8920c7a1e2743162457b8a33a234491d30019b2293 +%% annotation_hash: sha256-v3:7699fd36a4f42f9233f7bb6b81030a15c52f45618f34baa20928da749b08203b %% generator: guardlink@2.1.0 %% --- %% This diagram is derived from the annotations in this repository. If diff --git a/.guardlink/graph/by-asset/codegraph.threats.mmd b/.guardlink/graph/by-asset/codegraph.threats.mmd index 01d493dd..9755bb5c 100644 --- a/.guardlink/graph/by-asset/codegraph.threats.mmd +++ b/.guardlink/graph/by-asset/codegraph.threats.mmd @@ -1,7 +1,7 @@ %% GENERATED FILE — do not edit. Regenerate with: guardlink artifacts . %% artifact: by-asset/codegraph.threats.mmd %% scope: PARTIAL — #codegraph only — its own threats and controls -%% annotation_hash: sha256-v3:9babda79ed52b602afa4ff8920c7a1e2743162457b8a33a234491d30019b2293 +%% annotation_hash: sha256-v3:7699fd36a4f42f9233f7bb6b81030a15c52f45618f34baa20928da749b08203b %% generator: guardlink@2.1.0 %% --- %% This diagram is derived from the annotations in this repository. If diff --git a/.guardlink/graph/by-asset/dashboard.flows.mmd b/.guardlink/graph/by-asset/dashboard.flows.mmd index ab5d5090..14beae8c 100644 --- a/.guardlink/graph/by-asset/dashboard.flows.mmd +++ b/.guardlink/graph/by-asset/dashboard.flows.mmd @@ -1,7 +1,7 @@ %% GENERATED FILE — do not edit. Regenerate with: guardlink artifacts . %% artifact: by-asset/dashboard.flows.mmd %% scope: PARTIAL — #dashboard and its flow neighbourhood, grown until the drawing stopped fitting — 12 neighbour(s) one hop out are not shown -%% annotation_hash: sha256-v3:9babda79ed52b602afa4ff8920c7a1e2743162457b8a33a234491d30019b2293 +%% annotation_hash: sha256-v3:7699fd36a4f42f9233f7bb6b81030a15c52f45618f34baa20928da749b08203b %% generator: guardlink@2.1.0 %% --- %% This diagram is derived from the annotations in this repository. If diff --git a/.guardlink/graph/by-asset/dashboard.threats.mmd b/.guardlink/graph/by-asset/dashboard.threats.mmd index f4b53ebc..7b5cd884 100644 --- a/.guardlink/graph/by-asset/dashboard.threats.mmd +++ b/.guardlink/graph/by-asset/dashboard.threats.mmd @@ -1,7 +1,7 @@ %% GENERATED FILE — do not edit. Regenerate with: guardlink artifacts . %% artifact: by-asset/dashboard.threats.mmd %% scope: PARTIAL — #dashboard only — its own threats and controls -%% annotation_hash: sha256-v3:9babda79ed52b602afa4ff8920c7a1e2743162457b8a33a234491d30019b2293 +%% annotation_hash: sha256-v3:7699fd36a4f42f9233f7bb6b81030a15c52f45618f34baa20928da749b08203b %% generator: guardlink@2.1.0 %% --- %% This diagram is derived from the annotations in this repository. If diff --git a/.guardlink/graph/by-asset/diff.flows.mmd b/.guardlink/graph/by-asset/diff.flows.mmd index df356ac4..02b71c08 100644 --- a/.guardlink/graph/by-asset/diff.flows.mmd +++ b/.guardlink/graph/by-asset/diff.flows.mmd @@ -1,7 +1,7 @@ %% GENERATED FILE — do not edit. Regenerate with: guardlink artifacts . %% artifact: by-asset/diff.flows.mmd %% scope: PARTIAL — #diff and its flow neighbourhood, grown until the drawing stopped fitting — 12 neighbour(s) one hop out are not shown -%% annotation_hash: sha256-v3:9babda79ed52b602afa4ff8920c7a1e2743162457b8a33a234491d30019b2293 +%% annotation_hash: sha256-v3:7699fd36a4f42f9233f7bb6b81030a15c52f45618f34baa20928da749b08203b %% generator: guardlink@2.1.0 %% --- %% This diagram is derived from the annotations in this repository. If diff --git a/.guardlink/graph/by-asset/diff.threats.mmd b/.guardlink/graph/by-asset/diff.threats.mmd index ca39cc85..9163a703 100644 --- a/.guardlink/graph/by-asset/diff.threats.mmd +++ b/.guardlink/graph/by-asset/diff.threats.mmd @@ -1,7 +1,7 @@ %% GENERATED FILE — do not edit. Regenerate with: guardlink artifacts . %% artifact: by-asset/diff.threats.mmd %% scope: PARTIAL — #diff only — its own threats and controls -%% annotation_hash: sha256-v3:9babda79ed52b602afa4ff8920c7a1e2743162457b8a33a234491d30019b2293 +%% annotation_hash: sha256-v3:7699fd36a4f42f9233f7bb6b81030a15c52f45618f34baa20928da749b08203b %% generator: guardlink@2.1.0 %% --- %% This diagram is derived from the annotations in this repository. If diff --git a/.guardlink/graph/by-asset/estate-view.flows.mmd b/.guardlink/graph/by-asset/estate-view.flows.mmd index 3cff3de8..7de930f8 100644 --- a/.guardlink/graph/by-asset/estate-view.flows.mmd +++ b/.guardlink/graph/by-asset/estate-view.flows.mmd @@ -1,7 +1,7 @@ %% GENERATED FILE — do not edit. Regenerate with: guardlink artifacts . %% artifact: by-asset/estate-view.flows.mmd %% scope: PARTIAL — #estate-view and its flow neighbourhood, grown until the drawing stopped fitting — 23 neighbour(s) one hop out are not shown -%% annotation_hash: sha256-v3:9babda79ed52b602afa4ff8920c7a1e2743162457b8a33a234491d30019b2293 +%% annotation_hash: sha256-v3:7699fd36a4f42f9233f7bb6b81030a15c52f45618f34baa20928da749b08203b %% generator: guardlink@2.1.0 %% --- %% This diagram is derived from the annotations in this repository. If diff --git a/.guardlink/graph/by-asset/estate-view.threats.mmd b/.guardlink/graph/by-asset/estate-view.threats.mmd index c2e60012..4c9757c0 100644 --- a/.guardlink/graph/by-asset/estate-view.threats.mmd +++ b/.guardlink/graph/by-asset/estate-view.threats.mmd @@ -1,7 +1,7 @@ %% GENERATED FILE — do not edit. Regenerate with: guardlink artifacts . %% artifact: by-asset/estate-view.threats.mmd %% scope: PARTIAL — #estate-view only — its own threats and controls -%% annotation_hash: sha256-v3:9babda79ed52b602afa4ff8920c7a1e2743162457b8a33a234491d30019b2293 +%% annotation_hash: sha256-v3:7699fd36a4f42f9233f7bb6b81030a15c52f45618f34baa20928da749b08203b %% generator: guardlink@2.1.0 %% --- %% This diagram is derived from the annotations in this repository. If diff --git a/.guardlink/graph/by-asset/gate.flows.mmd b/.guardlink/graph/by-asset/gate.flows.mmd index 472bbaa3..f2446bcb 100644 --- a/.guardlink/graph/by-asset/gate.flows.mmd +++ b/.guardlink/graph/by-asset/gate.flows.mmd @@ -1,7 +1,7 @@ %% GENERATED FILE — do not edit. Regenerate with: guardlink artifacts . %% artifact: by-asset/gate.flows.mmd %% scope: PARTIAL — #gate and its flow neighbourhood, grown until the drawing stopped fitting — 24 neighbour(s) one hop out are not shown -%% annotation_hash: sha256-v3:9babda79ed52b602afa4ff8920c7a1e2743162457b8a33a234491d30019b2293 +%% annotation_hash: sha256-v3:7699fd36a4f42f9233f7bb6b81030a15c52f45618f34baa20928da749b08203b %% generator: guardlink@2.1.0 %% --- %% This diagram is derived from the annotations in this repository. If diff --git a/.guardlink/graph/by-asset/gate.threats.mmd b/.guardlink/graph/by-asset/gate.threats.mmd index 0de1f2a9..64851c38 100644 --- a/.guardlink/graph/by-asset/gate.threats.mmd +++ b/.guardlink/graph/by-asset/gate.threats.mmd @@ -1,7 +1,7 @@ %% GENERATED FILE — do not edit. Regenerate with: guardlink artifacts . %% artifact: by-asset/gate.threats.mmd %% scope: PARTIAL — #gate only — its own threats and controls -%% annotation_hash: sha256-v3:9babda79ed52b602afa4ff8920c7a1e2743162457b8a33a234491d30019b2293 +%% annotation_hash: sha256-v3:7699fd36a4f42f9233f7bb6b81030a15c52f45618f34baa20928da749b08203b %% generator: guardlink@2.1.0 %% --- %% This diagram is derived from the annotations in this repository. If diff --git a/.guardlink/graph/by-asset/init.flows.mmd b/.guardlink/graph/by-asset/init.flows.mmd index d17d970f..12eac32a 100644 --- a/.guardlink/graph/by-asset/init.flows.mmd +++ b/.guardlink/graph/by-asset/init.flows.mmd @@ -1,7 +1,7 @@ %% GENERATED FILE — do not edit. Regenerate with: guardlink artifacts . %% artifact: by-asset/init.flows.mmd %% scope: PARTIAL — #init and its flow neighbourhood, grown until the drawing stopped fitting — 28 neighbour(s) one hop out are not shown -%% annotation_hash: sha256-v3:9babda79ed52b602afa4ff8920c7a1e2743162457b8a33a234491d30019b2293 +%% annotation_hash: sha256-v3:7699fd36a4f42f9233f7bb6b81030a15c52f45618f34baa20928da749b08203b %% generator: guardlink@2.1.0 %% --- %% This diagram is derived from the annotations in this repository. If diff --git a/.guardlink/graph/by-asset/init.threats.mmd b/.guardlink/graph/by-asset/init.threats.mmd index 440d345f..8a34db6b 100644 --- a/.guardlink/graph/by-asset/init.threats.mmd +++ b/.guardlink/graph/by-asset/init.threats.mmd @@ -1,7 +1,7 @@ %% GENERATED FILE — do not edit. Regenerate with: guardlink artifacts . %% artifact: by-asset/init.threats.mmd %% scope: PARTIAL — #init only — its own threats and controls -%% annotation_hash: sha256-v3:9babda79ed52b602afa4ff8920c7a1e2743162457b8a33a234491d30019b2293 +%% annotation_hash: sha256-v3:7699fd36a4f42f9233f7bb6b81030a15c52f45618f34baa20928da749b08203b %% generator: guardlink@2.1.0 %% --- %% This diagram is derived from the annotations in this repository. If diff --git a/.guardlink/graph/by-asset/llm-client.flows.mmd b/.guardlink/graph/by-asset/llm-client.flows.mmd index 73ac5ab8..74aab615 100644 --- a/.guardlink/graph/by-asset/llm-client.flows.mmd +++ b/.guardlink/graph/by-asset/llm-client.flows.mmd @@ -1,7 +1,7 @@ %% GENERATED FILE — do not edit. Regenerate with: guardlink artifacts . %% artifact: by-asset/llm-client.flows.mmd %% scope: PARTIAL — #llm-client and its flow neighbourhood, grown until the drawing stopped fitting — 13 neighbour(s) one hop out are not shown -%% annotation_hash: sha256-v3:9babda79ed52b602afa4ff8920c7a1e2743162457b8a33a234491d30019b2293 +%% annotation_hash: sha256-v3:7699fd36a4f42f9233f7bb6b81030a15c52f45618f34baa20928da749b08203b %% generator: guardlink@2.1.0 %% --- %% This diagram is derived from the annotations in this repository. If diff --git a/.guardlink/graph/by-asset/llm-client.threats.mmd b/.guardlink/graph/by-asset/llm-client.threats.mmd index 6952f547..62dc69d0 100644 --- a/.guardlink/graph/by-asset/llm-client.threats.mmd +++ b/.guardlink/graph/by-asset/llm-client.threats.mmd @@ -1,7 +1,7 @@ %% GENERATED FILE — do not edit. Regenerate with: guardlink artifacts . %% artifact: by-asset/llm-client.threats.mmd %% scope: PARTIAL — #llm-client only — its own threats and controls, narrowed to high and critical (13 lower-severity claim(s) omitted to keep it readable) -%% annotation_hash: sha256-v3:9babda79ed52b602afa4ff8920c7a1e2743162457b8a33a234491d30019b2293 +%% annotation_hash: sha256-v3:7699fd36a4f42f9233f7bb6b81030a15c52f45618f34baa20928da749b08203b %% generator: guardlink@2.1.0 %% --- %% This diagram is derived from the annotations in this repository. If diff --git a/.guardlink/graph/by-asset/mcp.flows.mmd b/.guardlink/graph/by-asset/mcp.flows.mmd index c1f9e037..9118434b 100644 --- a/.guardlink/graph/by-asset/mcp.flows.mmd +++ b/.guardlink/graph/by-asset/mcp.flows.mmd @@ -1,7 +1,7 @@ %% GENERATED FILE — do not edit. Regenerate with: guardlink artifacts . %% artifact: by-asset/mcp.flows.mmd %% scope: PARTIAL — #mcp and its flow neighbourhood, grown until the drawing stopped fitting — 20 neighbour(s) one hop out are not shown -%% annotation_hash: sha256-v3:9babda79ed52b602afa4ff8920c7a1e2743162457b8a33a234491d30019b2293 +%% annotation_hash: sha256-v3:7699fd36a4f42f9233f7bb6b81030a15c52f45618f34baa20928da749b08203b %% generator: guardlink@2.1.0 %% --- %% This diagram is derived from the annotations in this repository. If diff --git a/.guardlink/graph/by-asset/mcp.threats.mmd b/.guardlink/graph/by-asset/mcp.threats.mmd index 85848b26..b38393f5 100644 --- a/.guardlink/graph/by-asset/mcp.threats.mmd +++ b/.guardlink/graph/by-asset/mcp.threats.mmd @@ -1,7 +1,7 @@ %% GENERATED FILE — do not edit. Regenerate with: guardlink artifacts . %% artifact: by-asset/mcp.threats.mmd %% scope: PARTIAL — #mcp only — its own threats and controls, narrowed to high and critical (12 lower-severity claim(s) omitted to keep it readable) -%% annotation_hash: sha256-v3:9babda79ed52b602afa4ff8920c7a1e2743162457b8a33a234491d30019b2293 +%% annotation_hash: sha256-v3:7699fd36a4f42f9233f7bb6b81030a15c52f45618f34baa20928da749b08203b %% generator: guardlink@2.1.0 %% --- %% This diagram is derived from the annotations in this repository. If diff --git a/.guardlink/graph/by-asset/merge-engine.flows.mmd b/.guardlink/graph/by-asset/merge-engine.flows.mmd index 93100884..11bf353e 100644 --- a/.guardlink/graph/by-asset/merge-engine.flows.mmd +++ b/.guardlink/graph/by-asset/merge-engine.flows.mmd @@ -1,7 +1,7 @@ %% GENERATED FILE — do not edit. Regenerate with: guardlink artifacts . %% artifact: by-asset/merge-engine.flows.mmd %% scope: PARTIAL — #merge-engine and its flow neighbourhood, grown until the drawing stopped fitting — 34 neighbour(s) one hop out are not shown -%% annotation_hash: sha256-v3:9babda79ed52b602afa4ff8920c7a1e2743162457b8a33a234491d30019b2293 +%% annotation_hash: sha256-v3:7699fd36a4f42f9233f7bb6b81030a15c52f45618f34baa20928da749b08203b %% generator: guardlink@2.1.0 %% --- %% This diagram is derived from the annotations in this repository. If diff --git a/.guardlink/graph/by-asset/merge-engine.threats.mmd b/.guardlink/graph/by-asset/merge-engine.threats.mmd index a01d1f13..125075a6 100644 --- a/.guardlink/graph/by-asset/merge-engine.threats.mmd +++ b/.guardlink/graph/by-asset/merge-engine.threats.mmd @@ -1,7 +1,7 @@ %% GENERATED FILE — do not edit. Regenerate with: guardlink artifacts . %% artifact: by-asset/merge-engine.threats.mmd %% scope: PARTIAL — #merge-engine only — its own threats and controls -%% annotation_hash: sha256-v3:9babda79ed52b602afa4ff8920c7a1e2743162457b8a33a234491d30019b2293 +%% annotation_hash: sha256-v3:7699fd36a4f42f9233f7bb6b81030a15c52f45618f34baa20928da749b08203b %% generator: guardlink@2.1.0 %% --- %% This diagram is derived from the annotations in this repository. If diff --git a/.guardlink/graph/by-asset/parser.flows.mmd b/.guardlink/graph/by-asset/parser.flows.mmd index 39f06c08..13415dcf 100644 --- a/.guardlink/graph/by-asset/parser.flows.mmd +++ b/.guardlink/graph/by-asset/parser.flows.mmd @@ -1,7 +1,7 @@ %% GENERATED FILE — do not edit. Regenerate with: guardlink artifacts . %% artifact: by-asset/parser.flows.mmd %% scope: PARTIAL — #parser and its flow neighbourhood, grown until the drawing stopped fitting — 13 neighbour(s) one hop out are not shown -%% annotation_hash: sha256-v3:9babda79ed52b602afa4ff8920c7a1e2743162457b8a33a234491d30019b2293 +%% annotation_hash: sha256-v3:7699fd36a4f42f9233f7bb6b81030a15c52f45618f34baa20928da749b08203b %% generator: guardlink@2.1.0 %% --- %% This diagram is derived from the annotations in this repository. If diff --git a/.guardlink/graph/by-asset/parser.threats.mmd b/.guardlink/graph/by-asset/parser.threats.mmd index d40e8e78..840f2e4e 100644 --- a/.guardlink/graph/by-asset/parser.threats.mmd +++ b/.guardlink/graph/by-asset/parser.threats.mmd @@ -1,7 +1,7 @@ %% GENERATED FILE — do not edit. Regenerate with: guardlink artifacts . %% artifact: by-asset/parser.threats.mmd %% scope: PARTIAL — #parser only — its own threats and controls, narrowed to high and critical (31 lower-severity claim(s) omitted to keep it readable) -%% annotation_hash: sha256-v3:9babda79ed52b602afa4ff8920c7a1e2743162457b8a33a234491d30019b2293 +%% annotation_hash: sha256-v3:7699fd36a4f42f9233f7bb6b81030a15c52f45618f34baa20928da749b08203b %% generator: guardlink@2.1.0 %% --- %% This diagram is derived from the annotations in this repository. If diff --git a/.guardlink/graph/by-asset/report-metadata.flows.mmd b/.guardlink/graph/by-asset/report-metadata.flows.mmd index da770225..d026da44 100644 --- a/.guardlink/graph/by-asset/report-metadata.flows.mmd +++ b/.guardlink/graph/by-asset/report-metadata.flows.mmd @@ -1,7 +1,7 @@ %% GENERATED FILE — do not edit. Regenerate with: guardlink artifacts . %% artifact: by-asset/report-metadata.flows.mmd %% scope: PARTIAL — #report-metadata and its flow neighbourhood, grown until the drawing stopped fitting — 24 neighbour(s) one hop out are not shown -%% annotation_hash: sha256-v3:9babda79ed52b602afa4ff8920c7a1e2743162457b8a33a234491d30019b2293 +%% annotation_hash: sha256-v3:7699fd36a4f42f9233f7bb6b81030a15c52f45618f34baa20928da749b08203b %% generator: guardlink@2.1.0 %% --- %% This diagram is derived from the annotations in this repository. If diff --git a/.guardlink/graph/by-asset/report-metadata.threats.mmd b/.guardlink/graph/by-asset/report-metadata.threats.mmd index 23becd87..f23534c1 100644 --- a/.guardlink/graph/by-asset/report-metadata.threats.mmd +++ b/.guardlink/graph/by-asset/report-metadata.threats.mmd @@ -1,7 +1,7 @@ %% GENERATED FILE — do not edit. Regenerate with: guardlink artifacts . %% artifact: by-asset/report-metadata.threats.mmd %% scope: PARTIAL — #report-metadata only — its own threats and controls -%% annotation_hash: sha256-v3:9babda79ed52b602afa4ff8920c7a1e2743162457b8a33a234491d30019b2293 +%% annotation_hash: sha256-v3:7699fd36a4f42f9233f7bb6b81030a15c52f45618f34baa20928da749b08203b %% generator: guardlink@2.1.0 %% --- %% This diagram is derived from the annotations in this repository. If diff --git a/.guardlink/graph/by-asset/report.flows.mmd b/.guardlink/graph/by-asset/report.flows.mmd index 3959cea4..3a1b944c 100644 --- a/.guardlink/graph/by-asset/report.flows.mmd +++ b/.guardlink/graph/by-asset/report.flows.mmd @@ -1,7 +1,7 @@ %% GENERATED FILE — do not edit. Regenerate with: guardlink artifacts . %% artifact: by-asset/report.flows.mmd %% scope: PARTIAL — #report and its flow neighbourhood, grown until the drawing stopped fitting — 11 neighbour(s) one hop out are not shown -%% annotation_hash: sha256-v3:9babda79ed52b602afa4ff8920c7a1e2743162457b8a33a234491d30019b2293 +%% annotation_hash: sha256-v3:7699fd36a4f42f9233f7bb6b81030a15c52f45618f34baa20928da749b08203b %% generator: guardlink@2.1.0 %% --- %% This diagram is derived from the annotations in this repository. If @@ -11,10 +11,11 @@ %% unrelated to the diagram, so they are reported at emission time, not stored. %%{init: {"flowchart": {"nodeSpacing": 44, "rankSpacing": 58, "curve": "basis", "htmlLabels": false}}}%% graph LR - featurename["🧩 FeatureName"] - report["🧩 GuardLink.Report"] threatmodel["🧩 ThreatModel"] + report["🧩 GuardLink.Report"] + featurename["🧩 FeatureName"] markdown["🧩 Markdown"] + threatmodel -- "📡 summarizeReach" --> report featurename -- "📡 filtered_by_features" --> report threatmodel -- "📡 generateReport" --> report report -- "📡 return" --> markdown diff --git a/.guardlink/graph/by-asset/report.threats.mmd b/.guardlink/graph/by-asset/report.threats.mmd index c13f8534..bfdebf5a 100644 --- a/.guardlink/graph/by-asset/report.threats.mmd +++ b/.guardlink/graph/by-asset/report.threats.mmd @@ -1,7 +1,7 @@ %% GENERATED FILE — do not edit. Regenerate with: guardlink artifacts . %% artifact: by-asset/report.threats.mmd %% scope: PARTIAL — #report only — its own threats and controls -%% annotation_hash: sha256-v3:9babda79ed52b602afa4ff8920c7a1e2743162457b8a33a234491d30019b2293 +%% annotation_hash: sha256-v3:7699fd36a4f42f9233f7bb6b81030a15c52f45618f34baa20928da749b08203b %% generator: guardlink@2.1.0 %% --- %% This diagram is derived from the annotations in this repository. If diff --git a/.guardlink/graph/by-asset/sarif.flows.mmd b/.guardlink/graph/by-asset/sarif.flows.mmd index a131c53a..ada5d076 100644 --- a/.guardlink/graph/by-asset/sarif.flows.mmd +++ b/.guardlink/graph/by-asset/sarif.flows.mmd @@ -1,7 +1,7 @@ %% GENERATED FILE — do not edit. Regenerate with: guardlink artifacts . %% artifact: by-asset/sarif.flows.mmd %% scope: PARTIAL — #sarif and its flow neighbourhood, grown until the drawing stopped fitting — 11 neighbour(s) one hop out are not shown -%% annotation_hash: sha256-v3:9babda79ed52b602afa4ff8920c7a1e2743162457b8a33a234491d30019b2293 +%% annotation_hash: sha256-v3:7699fd36a4f42f9233f7bb6b81030a15c52f45618f34baa20928da749b08203b %% generator: guardlink@2.1.0 %% --- %% This diagram is derived from the annotations in this repository. If diff --git a/.guardlink/graph/by-asset/sarif.threats.mmd b/.guardlink/graph/by-asset/sarif.threats.mmd index caf31dd6..18d4d80b 100644 --- a/.guardlink/graph/by-asset/sarif.threats.mmd +++ b/.guardlink/graph/by-asset/sarif.threats.mmd @@ -1,7 +1,7 @@ %% GENERATED FILE — do not edit. Regenerate with: guardlink artifacts . %% artifact: by-asset/sarif.threats.mmd %% scope: PARTIAL — #sarif only — its own threats and controls -%% annotation_hash: sha256-v3:9babda79ed52b602afa4ff8920c7a1e2743162457b8a33a234491d30019b2293 +%% annotation_hash: sha256-v3:7699fd36a4f42f9233f7bb6b81030a15c52f45618f34baa20928da749b08203b %% generator: guardlink@2.1.0 %% --- %% This diagram is derived from the annotations in this repository. If diff --git a/.guardlink/graph/by-asset/suggest.flows.mmd b/.guardlink/graph/by-asset/suggest.flows.mmd index 35fbf40f..26f8bbd5 100644 --- a/.guardlink/graph/by-asset/suggest.flows.mmd +++ b/.guardlink/graph/by-asset/suggest.flows.mmd @@ -1,7 +1,7 @@ %% GENERATED FILE — do not edit. Regenerate with: guardlink artifacts . %% artifact: by-asset/suggest.flows.mmd %% scope: PARTIAL — #suggest and its flow neighbourhood, grown until the drawing stopped fitting — 14 neighbour(s) one hop out are not shown -%% annotation_hash: sha256-v3:9babda79ed52b602afa4ff8920c7a1e2743162457b8a33a234491d30019b2293 +%% annotation_hash: sha256-v3:7699fd36a4f42f9233f7bb6b81030a15c52f45618f34baa20928da749b08203b %% generator: guardlink@2.1.0 %% --- %% This diagram is derived from the annotations in this repository. If diff --git a/.guardlink/graph/by-asset/suggest.threats.mmd b/.guardlink/graph/by-asset/suggest.threats.mmd index cebce3a4..03e92476 100644 --- a/.guardlink/graph/by-asset/suggest.threats.mmd +++ b/.guardlink/graph/by-asset/suggest.threats.mmd @@ -1,7 +1,7 @@ %% GENERATED FILE — do not edit. Regenerate with: guardlink artifacts . %% artifact: by-asset/suggest.threats.mmd %% scope: PARTIAL — #suggest only — its own threats and controls -%% annotation_hash: sha256-v3:9babda79ed52b602afa4ff8920c7a1e2743162457b8a33a234491d30019b2293 +%% annotation_hash: sha256-v3:7699fd36a4f42f9233f7bb6b81030a15c52f45618f34baa20928da749b08203b %% generator: guardlink@2.1.0 %% --- %% This diagram is derived from the annotations in this repository. If diff --git a/.guardlink/graph/by-asset/tui.flows.mmd b/.guardlink/graph/by-asset/tui.flows.mmd index 8d836565..21f836e4 100644 --- a/.guardlink/graph/by-asset/tui.flows.mmd +++ b/.guardlink/graph/by-asset/tui.flows.mmd @@ -1,7 +1,7 @@ %% GENERATED FILE — do not edit. Regenerate with: guardlink artifacts . %% artifact: by-asset/tui.flows.mmd %% scope: PARTIAL — #tui and its flow neighbourhood, grown until the drawing stopped fitting — 24 neighbour(s) one hop out are not shown -%% annotation_hash: sha256-v3:9babda79ed52b602afa4ff8920c7a1e2743162457b8a33a234491d30019b2293 +%% annotation_hash: sha256-v3:7699fd36a4f42f9233f7bb6b81030a15c52f45618f34baa20928da749b08203b %% generator: guardlink@2.1.0 %% --- %% This diagram is derived from the annotations in this repository. If diff --git a/.guardlink/graph/by-asset/tui.threats.mmd b/.guardlink/graph/by-asset/tui.threats.mmd index 201016ac..e64cb716 100644 --- a/.guardlink/graph/by-asset/tui.threats.mmd +++ b/.guardlink/graph/by-asset/tui.threats.mmd @@ -1,7 +1,7 @@ %% GENERATED FILE — do not edit. Regenerate with: guardlink artifacts . %% artifact: by-asset/tui.threats.mmd %% scope: PARTIAL — #tui only — its own threats and controls -%% annotation_hash: sha256-v3:9babda79ed52b602afa4ff8920c7a1e2743162457b8a33a234491d30019b2293 +%% annotation_hash: sha256-v3:7699fd36a4f42f9233f7bb6b81030a15c52f45618f34baa20928da749b08203b %% generator: guardlink@2.1.0 %% --- %% This diagram is derived from the annotations in this repository. If diff --git a/.guardlink/graph/by-asset/workspace-config.threats.mmd b/.guardlink/graph/by-asset/workspace-config.threats.mmd index 8f49c714..c9ad0d8b 100644 --- a/.guardlink/graph/by-asset/workspace-config.threats.mmd +++ b/.guardlink/graph/by-asset/workspace-config.threats.mmd @@ -1,7 +1,7 @@ %% GENERATED FILE — do not edit. Regenerate with: guardlink artifacts . %% artifact: by-asset/workspace-config.threats.mmd %% scope: PARTIAL — #workspace-config only — its own threats and controls -%% annotation_hash: sha256-v3:9babda79ed52b602afa4ff8920c7a1e2743162457b8a33a234491d30019b2293 +%% annotation_hash: sha256-v3:7699fd36a4f42f9233f7bb6b81030a15c52f45618f34baa20928da749b08203b %% generator: guardlink@2.1.0 %% --- %% This diagram is derived from the annotations in this repository. If diff --git a/.guardlink/graph/by-asset/workspace-link.flows.mmd b/.guardlink/graph/by-asset/workspace-link.flows.mmd index 3e7edbc0..8c9a1a23 100644 --- a/.guardlink/graph/by-asset/workspace-link.flows.mmd +++ b/.guardlink/graph/by-asset/workspace-link.flows.mmd @@ -1,7 +1,7 @@ %% GENERATED FILE — do not edit. Regenerate with: guardlink artifacts . %% artifact: by-asset/workspace-link.flows.mmd %% scope: PARTIAL — #workspace-link and its flow neighbourhood, grown until the drawing stopped fitting — 37 neighbour(s) one hop out are not shown -%% annotation_hash: sha256-v3:9babda79ed52b602afa4ff8920c7a1e2743162457b8a33a234491d30019b2293 +%% annotation_hash: sha256-v3:7699fd36a4f42f9233f7bb6b81030a15c52f45618f34baa20928da749b08203b %% generator: guardlink@2.1.0 %% --- %% This diagram is derived from the annotations in this repository. If diff --git a/.guardlink/graph/by-boundary/agent-boundary.mmd b/.guardlink/graph/by-boundary/agent-boundary.mmd index d002a967..527a5c13 100644 --- a/.guardlink/graph/by-boundary/agent-boundary.mmd +++ b/.guardlink/graph/by-boundary/agent-boundary.mmd @@ -1,7 +1,7 @@ %% GENERATED FILE — do not edit. Regenerate with: guardlink artifacts . %% artifact: by-boundary/agent-boundary.mmd %% scope: PARTIAL — the Trust boundary at process spawn between agent-launcher and agentprocess, and what flows across it -%% annotation_hash: sha256-v3:9babda79ed52b602afa4ff8920c7a1e2743162457b8a33a234491d30019b2293 +%% annotation_hash: sha256-v3:7699fd36a4f42f9233f7bb6b81030a15c52f45618f34baa20928da749b08203b %% generator: guardlink@2.1.0 %% --- %% This diagram is derived from the annotations in this repository. If diff --git a/.guardlink/graph/by-boundary/cli-input-boundary.mmd b/.guardlink/graph/by-boundary/cli-input-boundary.mmd index 0efd198c..0d90a14e 100644 --- a/.guardlink/graph/by-boundary/cli-input-boundary.mmd +++ b/.guardlink/graph/by-boundary/cli-input-boundary.mmd @@ -1,7 +1,7 @@ %% GENERATED FILE — do not edit. Regenerate with: guardlink artifacts . %% artifact: by-boundary/cli-input-boundary.mmd %% scope: PARTIAL — the Trust boundary at CLI argument parsing between cli and userinput, and what flows across it -%% annotation_hash: sha256-v3:9babda79ed52b602afa4ff8920c7a1e2743162457b8a33a234491d30019b2293 +%% annotation_hash: sha256-v3:7699fd36a4f42f9233f7bb6b81030a15c52f45618f34baa20928da749b08203b %% generator: guardlink@2.1.0 %% --- %% This diagram is derived from the annotations in this repository. If diff --git a/.guardlink/graph/by-boundary/codegraph-boundary.mmd b/.guardlink/graph/by-boundary/codegraph-boundary.mmd index 61638946..4adbf4e1 100644 --- a/.guardlink/graph/by-boundary/codegraph-boundary.mmd +++ b/.guardlink/graph/by-boundary/codegraph-boundary.mmd @@ -1,7 +1,7 @@ %% GENERATED FILE — do not edit. Regenerate with: guardlink artifacts . %% artifact: by-boundary/codegraph-boundary.mmd %% scope: PARTIAL — the Process boundary: an external binary's JSON is parsed defensively and never executed between codegraph and codegraphserver, and what flows across it -%% annotation_hash: sha256-v3:9babda79ed52b602afa4ff8920c7a1e2743162457b8a33a234491d30019b2293 +%% annotation_hash: sha256-v3:7699fd36a4f42f9233f7bb6b81030a15c52f45618f34baa20928da749b08203b %% generator: guardlink@2.1.0 %% --- %% This diagram is derived from the annotations in this repository. If diff --git a/.guardlink/graph/by-boundary/fs-boundary.mmd b/.guardlink/graph/by-boundary/fs-boundary.mmd index 20ee13e4..1384a589 100644 --- a/.guardlink/graph/by-boundary/fs-boundary.mmd +++ b/.guardlink/graph/by-boundary/fs-boundary.mmd @@ -1,7 +1,7 @@ %% GENERATED FILE — do not edit. Regenerate with: guardlink artifacts . %% artifact: by-boundary/fs-boundary.mmd %% scope: PARTIAL — the Trust boundary between parser and disk I/O between parser and filesystem, and what flows across it -%% annotation_hash: sha256-v3:9babda79ed52b602afa4ff8920c7a1e2743162457b8a33a234491d30019b2293 +%% annotation_hash: sha256-v3:7699fd36a4f42f9233f7bb6b81030a15c52f45618f34baa20928da749b08203b %% generator: guardlink@2.1.0 %% --- %% This diagram is derived from the annotations in this repository. If diff --git a/.guardlink/graph/by-boundary/git-boundary.mmd b/.guardlink/graph/by-boundary/git-boundary.mmd index 729d1ce0..efadc9b6 100644 --- a/.guardlink/graph/by-boundary/git-boundary.mmd +++ b/.guardlink/graph/by-boundary/git-boundary.mmd @@ -1,7 +1,7 @@ %% GENERATED FILE — do not edit. Regenerate with: guardlink artifacts . %% artifact: by-boundary/git-boundary.mmd %% scope: PARTIAL — the Trust boundary at git command execution between diff and gitrepo, and what flows across it -%% annotation_hash: sha256-v3:9babda79ed52b602afa4ff8920c7a1e2743162457b8a33a234491d30019b2293 +%% annotation_hash: sha256-v3:7699fd36a4f42f9233f7bb6b81030a15c52f45618f34baa20928da749b08203b %% generator: guardlink@2.1.0 %% --- %% This diagram is derived from the annotations in this repository. If diff --git a/.guardlink/graph/by-boundary/llm-api-boundary.mmd b/.guardlink/graph/by-boundary/llm-api-boundary.mmd index cf1e9725..9fc61c42 100644 --- a/.guardlink/graph/by-boundary/llm-api-boundary.mmd +++ b/.guardlink/graph/by-boundary/llm-api-boundary.mmd @@ -1,7 +1,7 @@ %% GENERATED FILE — do not edit. Regenerate with: guardlink artifacts . %% artifact: by-boundary/llm-api-boundary.mmd %% scope: PARTIAL — the Trust boundary at external API call between llm-client and llmprovider, and what flows across it -%% annotation_hash: sha256-v3:9babda79ed52b602afa4ff8920c7a1e2743162457b8a33a234491d30019b2293 +%% annotation_hash: sha256-v3:7699fd36a4f42f9233f7bb6b81030a15c52f45618f34baa20928da749b08203b %% generator: guardlink@2.1.0 %% --- %% This diagram is derived from the annotations in this repository. If diff --git a/.guardlink/graph/by-boundary/mcp-boundary.mmd b/.guardlink/graph/by-boundary/mcp-boundary.mmd index 234a4413..e7cfc322 100644 --- a/.guardlink/graph/by-boundary/mcp-boundary.mmd +++ b/.guardlink/graph/by-boundary/mcp-boundary.mmd @@ -1,7 +1,7 @@ %% GENERATED FILE — do not edit. Regenerate with: guardlink artifacts . %% artifact: by-boundary/mcp-boundary.mmd %% scope: PARTIAL — the Trust boundary at MCP protocol between mcp and mcpclient, and what flows across it -%% annotation_hash: sha256-v3:9babda79ed52b602afa4ff8920c7a1e2743162457b8a33a234491d30019b2293 +%% annotation_hash: sha256-v3:7699fd36a4f42f9233f7bb6b81030a15c52f45618f34baa20928da749b08203b %% generator: guardlink@2.1.0 %% --- %% This diagram is derived from the annotations in this repository. If diff --git a/.guardlink/graph/by-boundary/mcp-tool-boundary.mmd b/.guardlink/graph/by-boundary/mcp-tool-boundary.mmd index 589baadf..b601bb49 100644 --- a/.guardlink/graph/by-boundary/mcp-tool-boundary.mmd +++ b/.guardlink/graph/by-boundary/mcp-tool-boundary.mmd @@ -1,7 +1,7 @@ %% GENERATED FILE — do not edit. Regenerate with: guardlink artifacts . %% artifact: by-boundary/mcp-tool-boundary.mmd %% scope: PARTIAL — the Trust boundary at tool argument parsing between mcp and mcpclient, and what flows across it -%% annotation_hash: sha256-v3:9babda79ed52b602afa4ff8920c7a1e2743162457b8a33a234491d30019b2293 +%% annotation_hash: sha256-v3:7699fd36a4f42f9233f7bb6b81030a15c52f45618f34baa20928da749b08203b %% generator: guardlink@2.1.0 %% --- %% This diagram is derived from the annotations in this repository. If diff --git a/.guardlink/graph/by-boundary/nvd-api-boundary.mmd b/.guardlink/graph/by-boundary/nvd-api-boundary.mmd index 8cd8a96f..35748e19 100644 --- a/.guardlink/graph/by-boundary/nvd-api-boundary.mmd +++ b/.guardlink/graph/by-boundary/nvd-api-boundary.mmd @@ -1,7 +1,7 @@ %% GENERATED FILE — do not edit. Regenerate with: guardlink artifacts . %% artifact: by-boundary/nvd-api-boundary.mmd %% scope: PARTIAL — the Trust boundary at external API between llm-client and nvd, and what flows across it -%% annotation_hash: sha256-v3:9babda79ed52b602afa4ff8920c7a1e2743162457b8a33a234491d30019b2293 +%% annotation_hash: sha256-v3:7699fd36a4f42f9233f7bb6b81030a15c52f45618f34baa20928da749b08203b %% generator: guardlink@2.1.0 %% --- %% This diagram is derived from the annotations in this repository. If diff --git a/.guardlink/graph/by-boundary/tui-input-boundary.mmd b/.guardlink/graph/by-boundary/tui-input-boundary.mmd index 461e9164..ded0b4cb 100644 --- a/.guardlink/graph/by-boundary/tui-input-boundary.mmd +++ b/.guardlink/graph/by-boundary/tui-input-boundary.mmd @@ -1,7 +1,7 @@ %% GENERATED FILE — do not edit. Regenerate with: guardlink artifacts . %% artifact: by-boundary/tui-input-boundary.mmd %% scope: PARTIAL — the Trust boundary at interactive input between tui and userinput, and what flows across it -%% annotation_hash: sha256-v3:9babda79ed52b602afa4ff8920c7a1e2743162457b8a33a234491d30019b2293 +%% annotation_hash: sha256-v3:7699fd36a4f42f9233f7bb6b81030a15c52f45618f34baa20928da749b08203b %% generator: guardlink@2.1.0 %% --- %% This diagram is derived from the annotations in this repository. If diff --git a/.guardlink/graph/by-feature/dashboard.mmd b/.guardlink/graph/by-feature/dashboard.mmd index 2900d36e..9f2890c4 100644 --- a/.guardlink/graph/by-feature/dashboard.mmd +++ b/.guardlink/graph/by-feature/dashboard.mmd @@ -1,7 +1,7 @@ %% GENERATED FILE — do not edit. Regenerate with: guardlink artifacts . %% artifact: by-feature/dashboard.mmd %% scope: PARTIAL — @feature "Dashboard" only, not the whole model -%% annotation_hash: sha256-v3:9babda79ed52b602afa4ff8920c7a1e2743162457b8a33a234491d30019b2293 +%% annotation_hash: sha256-v3:7699fd36a4f42f9233f7bb6b81030a15c52f45618f34baa20928da749b08203b %% generator: guardlink@2.1.0 %% --- %% This diagram is derived from the annotations in this repository. If diff --git a/.guardlink/graph/by-feature/mcp-integration.mmd b/.guardlink/graph/by-feature/mcp-integration.mmd index c393c321..351a1ba7 100644 --- a/.guardlink/graph/by-feature/mcp-integration.mmd +++ b/.guardlink/graph/by-feature/mcp-integration.mmd @@ -1,7 +1,7 @@ %% GENERATED FILE — do not edit. Regenerate with: guardlink artifacts . %% artifact: by-feature/mcp-integration.mmd %% scope: PARTIAL — @feature "MCP Integration" only, not the whole model -%% annotation_hash: sha256-v3:9babda79ed52b602afa4ff8920c7a1e2743162457b8a33a234491d30019b2293 +%% annotation_hash: sha256-v3:7699fd36a4f42f9233f7bb6b81030a15c52f45618f34baa20928da749b08203b %% generator: guardlink@2.1.0 %% --- %% This diagram is derived from the annotations in this repository. If diff --git a/.guardlink/graph/dataflow.mmd b/.guardlink/graph/dataflow.mmd index 0296a3c4..39e2da72 100644 --- a/.guardlink/graph/dataflow.mmd +++ b/.guardlink/graph/dataflow.mmd @@ -1,6 +1,6 @@ %% GENERATED FILE — do not edit. Regenerate with: guardlink artifacts . %% artifact: dataflow.mmd -%% annotation_hash: sha256-v3:9babda79ed52b602afa4ff8920c7a1e2743162457b8a33a234491d30019b2293 +%% annotation_hash: sha256-v3:7699fd36a4f42f9233f7bb6b81030a15c52f45618f34baa20928da749b08203b %% generator: guardlink@2.1.0 %% --- %% This diagram is derived from the annotations in this repository. If @@ -9,7 +9,7 @@ %% When it was generated and at which commit: ask git. Those move for reasons %% unrelated to the diagram, so they are reported at emission time, not stored. %% --- -%% DRAWS, BUT IS TOO BIG TO READ. dataflow.mmd is 77 nodes / 193 edges, past the 12 / 16 legibility budget (77 nodes against 12, 193 edges against 16). +%% DRAWS, BUT IS TOO BIG TO READ. dataflow.mmd is 77 nodes / 195 edges, past the 12 / 16 legibility budget (77 nodes against 12, 195 edges against 16). %% 12 nodes and 16 edges is what fits a dashboard diagram panel at its %% label size; past it a layered layout grows taller than the panel and %% fitting it shrinks the labels below reading size. Mermaid will draw this @@ -111,8 +111,8 @@ graph LR exportfile["🧩 ExportFile"] annotations["🧩 Annotations"] annotationsource["🧩 AnnotationSource"] - featurename["🧩 FeatureName"] report["🧩 GuardLink.Report · pii"] + featurename["🧩 FeatureName"] markdown["🧩 Markdown"] agentproposal["🧩 AgentProposal"] proposalledger["🧩 ProposalLedger"] @@ -213,6 +213,7 @@ graph LR threatmodel -- "📡 generateDashboardHTML" --> dashboard gitconfig -- "🗄️ readFileSync" --> dashboard dashboard -- "📡 RepoLinks" --> dashboardhtml + threatmodel -- "📡 summarizeReach" --> dashboard threatmodel -- "📡 buildClaims" --> dashboard diagramsource -- "📡 checkRenderBudget" --> dashboard gitrepo -- "📡 parseAtRef" --> dashboard @@ -284,6 +285,7 @@ graph LR ledgerfile -- "📡 classifyClaims" --> parser cli -- "📡 applyVerification" --> ledgerfile threatmodel -- "📡 findUnmitigatedPaths" --> parser + threatmodel -- "📡 summarizeReach" --> report featurename -- "📡 filtered_by_features" --> report threatmodel -- "📡 generateReport" --> report report -- "📡 return" --> markdown diff --git a/.guardlink/graph/index.md b/.guardlink/graph/index.md index 7bf50a54..9d020082 100644 --- a/.guardlink/graph/index.md +++ b/.guardlink/graph/index.md @@ -82,7 +82,7 @@ size so you know which you are about to open. | File | What it shows | Size | |---|---|---| | [`threat-graph.mmd`](threat-graph.mmd) | Every component, the threats declared on it, and the controls that answer them. | 180 edges — **past the readable size** | -| [`dataflow.mmd`](dataflow.mmd) | Every `@flows` between components, with trust boundaries drawn as zones. | 193 edges — **past the readable size** | +| [`dataflow.mmd`](dataflow.mmd) | Every `@flows` between components, with trust boundaries drawn as zones. | 195 edges — **past the readable size** | | [`attack-surface.mmd`](attack-surface.mmd) | Exposures per component, worst first. | 0 edges | ## One feature at a time @@ -106,6 +106,6 @@ size so you know which you are about to open. --- -Generated by guardlink@2.1.0 from annotation hash `sha256-v3:9babda79ed52b602afa4ff8920c7a1e2743162457b8a33a234491d30019b2293`. If that +Generated by guardlink@2.1.0 from annotation hash `sha256-v3:7699fd36a4f42f9233f7bb6b81030a15c52f45618f34baa20928da749b08203b`. If that differs from what `guardlink status .` reports, everything here is stale — regenerate rather than trusting it. diff --git a/.guardlink/graph/threat-graph.mmd b/.guardlink/graph/threat-graph.mmd index d2a93e32..f1873502 100644 --- a/.guardlink/graph/threat-graph.mmd +++ b/.guardlink/graph/threat-graph.mmd @@ -1,6 +1,6 @@ %% GENERATED FILE — do not edit. Regenerate with: guardlink artifacts . %% artifact: threat-graph.mmd -%% annotation_hash: sha256-v3:9babda79ed52b602afa4ff8920c7a1e2743162457b8a33a234491d30019b2293 +%% annotation_hash: sha256-v3:7699fd36a4f42f9233f7bb6b81030a15c52f45618f34baa20928da749b08203b %% generator: guardlink@2.1.0 %% --- %% This diagram is derived from the annotations in this repository. If diff --git a/.guardlink/model.json b/.guardlink/model.json index 9a1ba85d..f233d623 100644 --- a/.guardlink/model.json +++ b/.guardlink/model.json @@ -1,12 +1,12 @@ { "provenance": { - "annotation_hash": "sha256-v3:9babda79ed52b602afa4ff8920c7a1e2743162457b8a33a234491d30019b2293", + "annotation_hash": "sha256-v3:7699fd36a4f42f9233f7bb6b81030a15c52f45618f34baa20928da749b08203b", "generator": "guardlink@2.1.0" }, "version": "1.2.0", "project": "guardlink", - "source_files": 165, - "annotations_parsed": 885, + "source_files": 169, + "annotations_parsed": 891, "annotated_files": [ ".guardlink/definitions.ts", "src/agents/config.ts", @@ -47,6 +47,7 @@ "src/dashboard/html.ts", "src/dashboard/index.ts", "src/dashboard/links.ts", + "src/dashboard/pages/agents.ts", "src/dashboard/pages/analytics.ts", "src/dashboard/pages/assets.ts", "src/dashboard/pages/attribution.ts", @@ -126,6 +127,9 @@ "src/playbooks/select.ts", "src/playbooks/skills.ts", "src/playbooks/types.ts", + "src/reach/index.ts", + "src/reach/summary.ts", + "src/report/agent-reach.ts", "src/report/index.ts", "src/report/mermaid.ts", "src/report/report.ts", @@ -948,7 +952,7 @@ "description": "join() constrains reads to .guardlink/", "location": { "file": "src/analyze/index.ts", - "line": 799 + "line": 839 } }, { @@ -1208,7 +1212,7 @@ "description": "Diagram labels are sanitized before emission as Mermaid source", "location": { "file": "src/dashboard/diagrams.ts", - "line": 15 + "line": 16 } }, { @@ -1228,7 +1232,7 @@ "description": "esc() HTML-encodes every interpolated value in every page module; serialized data escapes closing script tags before embedding in