From 6a36564d4e886b86ef1e4633c9430c004c6b394c Mon Sep 17 00:00:00 2001 From: Animesh Srivastava Date: Fri, 2 Oct 2026 04:15:06 +0530 Subject: [PATCH 1/2] feat(reach): declare what an embedded agent can reach with @agents, @reaches, @effects and @gates An application that embeds an LLM agent hands it capabilities through its harness: tool definitions, MCP servers, file, shell and database access, internal methods. None of that could be declared, so a reviewer or scanner could not see an agent's blast radius, and `guardlink diff` did not move when a `run_sql` tool was registered. Four relation verbs, each written where its fact lives (SPEC 3.2.1): - `@agents to [on ] [as ]` on the tool registration. `@reaches` has the same shape for a principal that is not an LLM agent, such as a CI runner. Writing `@agents` about an actor is what marks it as an agent, and naming one actor under both verbs is a validation error (`agent-reach-conflict`), because every agent-only check would otherwise answer two ways. - `@effects on [as ]` on the code that acts. The set is closed, like `@handles`; egress stays on `@flows ... -> External.X`. - `@gates by [for ]` on an approval step. `by` is required and there is no advisory/blocking qualifier. The capability is the token `@entitles` uses, so "can minus may" is a join: `findUnentitledReaches` (src/parser/reach.ts) returns every reach that no cited entitlement for the same actor and capability covers, and, when the reach names an asset, for the same asset. An uncited entitlement or one that names no asset never covers a reach that names one; each such near miss is reported with its reason. lookup and diff both answer from it. - Parser and model: `reaches[]` (both verbs, with `agent: true|false`), `effects[]`, `gates[]`; claim keys, annotation-hash records, workspace merge, feature filter, graph subgraph kinds, file context and the dashboard's annotation list. A model without the verbs hashes and exports exactly as before. - validate (CLI, MCP, TUI): `undeclared-actor` now covers the actor on `@agents`/`@reaches` and the approver on `@gates`; undefined assets and identities are `dangling-ref`; `agent-reach-conflict` is new. - lookup: `reaches`, `agents`, `unentitled reaches`, `effects`, `gates`, each with an optional `for `; `actors` rows gain `agent` and `reaches`. `guardlink lookup ` answers the same forms from the CLI as JSON, with `--fail-on-found` for CI. - diff: reach, effect and gate changes, and a New Unentitled Reaches section for a reach that is newly uncovered, whether added or because its entitlement was withdrawn. - Agent writes: all four verbs are accepted by `guardlink_annotate_apply` and the annotate gate; `@entitles` and `@accepts` stay refused. The gate warns on a `@gates` that does not say what the approval step is. - SPEC 2.12, 3.1, 3.2.1, 5.1, 6.1 and 7.1, the reference doc, the annotate prompt and the agent instruction templates describe the verbs. None of the verbs changes the SARIF export. --- CHANGELOG.md | 8 + docs/GUARDLINK_REFERENCE.md | 14 +- docs/SPEC.md | 84 +++++- src/agents/prompts.ts | 19 ++ src/cli/index.ts | 26 +- src/dashboard/annotations.ts | 3 + src/diff/engine.ts | 69 ++++- src/diff/format.ts | 42 +++ src/gate/lint.ts | 2 + src/init/templates.ts | 24 +- src/mcp/context.ts | 8 + src/mcp/lookup.ts | 110 ++++++++ src/mcp/server.ts | 10 +- src/mcp/subgraph.ts | 25 +- src/parser/annotation-hash.ts | 11 + src/parser/apply-annotations.ts | 4 + src/parser/claim-key.ts | 18 +- src/parser/clear.ts | 1 + src/parser/feature-filter.ts | 11 + src/parser/index.ts | 3 +- src/parser/parse-line.ts | 67 ++++- src/parser/parse-project.ts | 50 +++- src/parser/reach.ts | 119 ++++++++ src/parser/validate.ts | 72 ++++- src/tui/commands.ts | 5 +- src/types/index.ts | 119 +++++++- src/workspace/merge.ts | 10 + tests/agent-reach.test.ts | 484 ++++++++++++++++++++++++++++++++ tests/diagnostic-codes.test.ts | 6 +- tests/relation-coverage.test.ts | 9 + 30 files changed, 1395 insertions(+), 38 deletions(-) create mode 100644 src/parser/reach.ts create mode 100644 tests/agent-reach.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 3d8e812e..d348698b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,14 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), ### Added +- **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`. + - `@gates by [for ]` on an approval step. `by` is required; there is no advisory/blocking qualifier. + - **Can minus may.** The capability is the token `@entitles` uses, so `guardlink_lookup("unentitled reaches")` lists every reach no cited entitlement for the same actor, capability and (when the reach names one) asset covers, with `near_misses` saying why an entitlement that almost matched does not count. New lookup forms: `reaches`, `agents`, `unentitled reaches`, `effects`, `gates`, each with an optional `for `; `actors` rows gain `agent` and `reaches`. `guardlink diff` reports reach, effect and gate changes and a **New Unentitled Reaches** section. + - **`guardlink lookup `** answers the same forms from the CLI as JSON; `--fail-on-found` exits 1 when the answer is non-empty. + - All four verbs are agent-writable: `guardlink_annotate_apply` and the annotate gate accept them, and `@entitles` and `@accepts` stay refused. `validate` reports an undeclared actor or approver (`undeclared-actor`) and undefined assets (`dangling-ref`). None of them changes the SARIF export. Models without them hash and export exactly as before. + - **An optional code graph tells the annotating agent where to start.** `guardlink annotate` told the agent to find the entry points and trace each one to its sinks, and the `coverage` playbook asked for "entry points first" — but nothing in GuardLink could find an entry point: everything the prompt carried about the repository was derived from annotations already written. When a code graph is installed (`codegraph-mcp` on `PATH`, `$GUARDLINK_CODEGRAPH_MCP`, or `bravos graph`) and the repository is indexed, the prompt now carries a bounded **code-graph worklist**: route handlers ranked by the sink classes they reach through the call graph, each with its `METHOD path`, `file:line`, an example sink, and whether the model already covers it (`none` / `file` / `handler`), unannotated handlers first. The `coverage` playbook takes it as its "entry points first" order. - **Two new MCP tools** give an agent annotating in its own editor the same answers: `guardlink_worklist` (the ranked list, filterable by file) and `guardlink_reach(symbol)` (the sink classes one function reaches with an example path each, and which route reaches it). `guardlink_annotate` gains `code_graph: false`. - **The graph is strictly optional.** GuardLink never installs or builds one. With no tooling, no graph built, tooling too old to answer, a failed query, `--no-code-graph` or `GUARDLINK_CODEGRAPH=off`, the annotate prompt is byte-identical to the one this version builds without the feature; `annotate` says on stderr why no graph was used, unless it was switched off. diff --git a/docs/GUARDLINK_REFERENCE.md b/docs/GUARDLINK_REFERENCE.md index c54d2c99..8271791c 100644 --- a/docs/GUARDLINK_REFERENCE.md +++ b/docs/GUARDLINK_REFERENCE.md @@ -21,6 +21,12 @@ RELATE @mitigates against <#threat> using <#control> -- "how" @entitles <#actor> to on against <#threat> -- "by design + authz file:line" ^ PROPOSED via `guardlink entitle --propose`, written only when a human accepts +REACH @agents <#agent-actor> to on as -- "on the tool registration" + @reaches <#actor> to on as -- "same, for a principal that is not an LLM agent" + @effects on as -- "on the code that acts" + @gates by <#approver-actor> for -- "on the approval step" + ^ all four are agent-writable; `on`, `as` and `for` are optional + FLOW @flows -> [-> …] [via ] -- "details" @flows -> via GET./orders/ -- "an HTTP route, on its handler" @boundary from to (#id) -- "trust boundary, direction known" @@ -58,6 +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). ### Standalone `.gal` Files @@ -106,6 +113,8 @@ with three different fixes, and each is a warning rather than a silent zero. A s | Writing new endpoint/handler | `@exposes` + `@mitigates` (or `@audit`) + `@flows` + `@comment` — tell the complete story | | New service/component | `@asset` in definitions, then reference in source | | New role / permission tier | `@actor` in definitions, then `guardlink entitle --propose` for each capability it holds by design | +| Code hands an LLM agent a tool / MCP server / file, shell or DB access | `@agents #agent to on Asset` on the registration + `@effects on Asset` where the code acts + `@gates Asset by #approver` on any approval step | +| A CI runner or service account holds a capability | `@reaches #actor to on Asset` | | Finding needs privilege X, and X is allowed to do that | Propose an entitlement (`guardlink entitle --propose … --rationale "by design + authz file:line"`) — do not write `@entitles` yourself | | Security gap exists | `@exposes Asset to #threat` + `@audit Asset` | | Threat verified exploitable | `@confirmed #threat on Asset [severity] -- "pentest/scan evidence"` | @@ -125,6 +134,8 @@ guardlink parse [dir] # Parse annotations → ThreatModel JSON guardlink status [dir] # Risk grade + coverage summary guardlink validate [dir] [--strict] # Syntax errors, dangling refs, unmitigated exposures guardlink validate [dir] --code-graph # Also check each @boundary against an optional code graph (warnings only) +guardlink lookup [-d dir] # The guardlink_lookup forms as JSON, e.g. lookup unentitled reaches + # (--fail-on-found exits 1 when the query returns anything) guardlink verify [dir] [targets...] # Lock claims to the code beneath them → .guardlink/verified.json guardlink ci [dir] [--strict] # The gate: parse errors, unmitigated exposures, confirmed # exploits, unqualified acceptances, anchor drift, stale claims @@ -478,12 +489,13 @@ Run `guardlink tui` for the interactive terminal interface: 9. **@comment always needs -- and quotes**: `@comment -- "your note here"`. 10. **One annotation per comment line.** Do NOT put two @verbs on the same line. 11. **@entitles capability is ONE identifier, not prose**: `configure-archival-destination`, not `"can configure archival"`. It is the join key, and prose would not join. +12. **@agents / @reaches use the same capability token as @entitles**, and **@effects takes one of six effects**: `read`, `write`, `delete`, `execute`, `spend`, `notify`. Egress is `@flows … -> External.X`, not an effect. `@gates` needs `by `. ## MCP Tools When connected via `.mcp.json`, use: - `guardlink_parse` — parse annotations, return threat model -- `guardlink_lookup` — query threats, controls, exposures by ID (try `unmitigated`, `confirmed`, `actors`, `entitlements`) +- `guardlink_lookup` — query threats, controls, exposures by ID (try `unmitigated`, `confirmed`, `actors`, `entitlements`, `agents`, `unentitled reaches`, `effects for #db`, `gates`) - `guardlink_suggest` — get annotation suggestions for a file - `guardlink_validate` — check for syntax errors - `guardlink_status` — coverage stats diff --git a/docs/SPEC.md b/docs/SPEC.md index 8a9f31f8..f934db10 100644 --- a/docs/SPEC.md +++ b/docs/SPEC.md @@ -350,6 +350,8 @@ Verb keyword sets: | `@flows` | `->` | | `@boundary` | `between`, `and`, `\|`; `from` only as the first word of the arguments | | `@validates`, `@owns` | `for` | +| `@agents`, `@reaches` | `to` | +| `@effects` | an effect class (`read`, `write`, `delete`, `execute`, `spend`, `notify`) only as the first word of the arguments | | all others | *(none — structure comes from a ref or a `--`)* | **The two tiers.** @@ -438,7 +440,7 @@ Names a security control, defense mechanism, or mitigation strategy that is impl @actor [(#id)] [-- ""] ``` -Names a principal in the system's authorization model — a **role**, not a person. Actors are the subjects of `@entitles` (§3.2), which is what makes the question *"is the caller already entitled to this effect?"* answerable from the model instead of by inference. +Names a principal in the system's authorization model — a **role**, not a person. Actors are the subjects of `@entitles` (§3.2), which is what makes the question *"is the caller already entitled to this effect?"* answerable from the model instead of by inference. They are also the subjects of `@agents` and `@reaches` and the approvers named by `@gates` (§3.2.1). There is no separate declaration for an LLM agent: an actor is one when some `@agents` names it. ```go // @actor Namespace_Admin (#ns-admin) -- "Administers one namespace's configuration" @@ -716,6 +718,51 @@ A direction is a claim like any other. Declaring one, removing one or reversing `asset_a` and `asset_b` are the two sides in the order written. `directed` is `true` only for the `from … to …` form, and then `asset_a` is the outer side and `asset_b` the inner side. For the undirected forms it is absent, and the order of `asset_a` and `asset_b` carries no meaning; a reader MUST NOT infer a direction from it. `id` (without its `#`) and `description` are absent when not declared. The conformance corpus [`conformance/boundaries.json`](../conformance/README.md) pins each rule here to annotation text in and expected records, sides and validation results out. +#### 3.2.1. Reach: `@agents`, `@reaches`, `@effects`, `@gates` + +An application that embeds an LLM agent hands it capabilities through its harness: tool definitions, MCP servers, file and shell access, database clients, internal methods. `@flows` describes traffic the authors expect; it does not describe what a tool makes *possible*. These four verbs declare it, each where its fact lives in the code, so a reviewer and a scanner can see what an agent's blast radius is. + +``` +@agents to [on ] [as ] [-- ""] +@reaches to [on ] [as ] [-- ""] +@effects on [as ] [-- ""] +@gates by [for ] [-- ""] +``` + +```ts +// .guardlink/definitions.ts +// @actor Support_Agent (#support-agent) -- "LLM agent; acts for the signed-in customer" +// @actor Support_Human (#support-human) -- "Approves refunds in the review queue" + +/** + * @agents #support-agent to run-sql on #tool-surface -- "Debug tool, still registered in production" + * @effects write on #users-db as #db-service -- "Runs model-written SQL verbatim via db.raw" + * @exposes #users-db to #excessive-agency [high] cwe:CWE-862 -- "Reachable from the agent's tool surface, nothing approves the write" + * @audit #users-db -- "Should run_sql exist outside development?" + */ +tools.register('run_sql', ({ sql }) => db.raw(sql)); + +/** + * @gates #payments by #support-human for issue-refund -- "requireApproval() parks the refund until a human approves it" + */ +async function requireApproval(refund: Refund) { … } +``` + +**`@agents` and `@reaches` — can invoke.** Written on the code that hands a capability out: a tool registration, an MCP server mount, the credentials a job runs with. Both verbs have one shape. `@agents` names an LLM agent; `@reaches` names every other principal (a CI runner, a service account). Two rules follow from having two verbs: + +1. **Writing `@agents` about an actor is what marks it an agent.** No definition-level flag exists, so an agent is defined by its reach, and checks that apply only to agents — prompt-injection-to-tool routes among them — key on `@agents`. +2. **One actor under both verbs is a validation error** (`agent-reach-conflict`). An actor is an agent or it is not; a model that says both answers every agent-only question two ways. + +`` is the same single-token identifier `@entitles` uses (§2.10), in the same position. `on ` is the surface the capability is exposed on. `as ` is the identity the caller presents — an asset reference such as a scoped session token. + +**`@effects` — does.** Written on the code that acts on an asset. The effect is a closed set, like `@handles` classifications: `read`, `write`, `delete`, `execute`, `spend` (money or quota) and `notify` (an outbound message). `delete`, `spend` and `notify` are kept apart from `write` because they are the irreversible actions a reviewer looks for first. `as ` is whose credentials the effect runs under; when that differs from the caller's identity with no gate between them, the code is a confused deputy. Egress has no effect class: `@flows … -> External.X` already says it, and a second spelling would leave the model ambiguous about which one is authoritative. + +**`@gates` — who decides.** Written on an approval step that blocks until the named actor decides. `by ` is required: a gate with no approver would let "something happens here" read as "someone decides here", which is the one thing the annotation exists to tell apart. `for ` narrows the gate to one capability; without it the gate stands in front of every effect on the asset. A gate suppresses nothing — it is a node on a path, not a control keyed on `(asset, threat)`, which is why it is not a `@mitigates`. This version has no advisory/blocking qualifier: write `@gates` only where the code waits. + +**Can minus may.** `@entitles` (§3.2) says an actor *may* hold a capability, and only a human writes it. `@agents`/`@reaches` say an actor *can*, and are observations an agent may write. A reach that no entitlement covers is an **unentitled reach**: a capability the code hands out that nobody approved — for an LLM agent, the Excessive Agency list. An entitlement covers a reach when it names the same actor (resolving `#id` and declared name to one identity) and the same normalised capability, it is cited (an inert entitlement covers nothing), and, when the reach names an asset, it names the same asset. An entitlement naming no asset does not cover a reach that names one. Every rule errs toward reporting the reach. `guardlink_lookup("unentitled reaches")` answers it (§8.2), and `guardlink diff` reports a reach that becomes unentitled (§7.1). + +All four verbs are agent-writable. None of them has export semantics: they add no SARIF result, remove none, and never close a finding (§6.1). `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`. + ### 3.3. Lifecycle Annotations These annotations support security process and governance. @@ -1016,6 +1063,39 @@ Parsing all annotations in a codebase produces a **ThreatModel** — a typed dat } ], + "reaches": [ + { + "actor": "#support-agent", + "agent": true, + "capability": "run-sql", + "canonical_capability": "run_sql", + "asset": "#tool-surface", + "description": "Debug tool, still registered in production", + "location": { "file": "src/agent/tools.ts", "line": 12, "parent_symbol": null } + } + ], + + "effects": [ + { + "effect": "write", + "asset": "#users-db", + "identity": "#db-service", + "description": "Runs model-written SQL verbatim via db.raw", + "location": { "file": "src/agent/tools.ts", "line": 13, "parent_symbol": null } + } + ], + + "gates": [ + { + "asset": "#payments", + "approver": "#support-human", + "capability": "issue-refund", + "canonical_capability": "issue_refund", + "description": "requireApproval() parks the refund until a human approves it", + "location": { "file": "src/agent/approval.ts", "line": 4, "parent_symbol": null } + } + ], + "acceptances": [], "transfers": [], "flows": [], @@ -1111,6 +1191,7 @@ Every other annotation is **not** a result: | `@transfers` | `relatedLocations` on results for the same asset and threat (§6.6). A transfer does not cover an exposure. | Context, not findings. | | `@validates`, `@owns`, `@feature`, `@comment`, `@shield` | Not exported | — | | `@actor` / `@entitles` | **Not exported.** No result, no suppression, no property on any result, and nothing in the declared context | See below. | +| `@agents`, `@reaches`, `@effects`, `@gates` | Not exported in this version | Context for reviewers and query surfaces. A reach result would change the result index every consumer keys on, so if one is added it belongs in the `pentest` profile (§6.8). | New result kinds — covered exposures with their suppressions, `@boundary` claims, review items for `@assumes` and `@audit` — would change the result index every existing consumer keys on and open a GitHub alert per annotation. The first two exist only in the `pentest` profile, appended after every result above (§6.8); none of them is ever added to the `github` profile. @@ -1557,6 +1638,7 @@ When comparing threat models between two git refs (e.g., a feature branch vs. `m | `ACCEPTED` | New `@accepts` acknowledging a known risk | **Pass** (with note) | | `INFO` | Changes to `@flows`, `@boundary`, `@owns`, `@handles`, `@assumes`, `@actor` | **Pass** | | `STALE_ENTITLEMENT` | An `@entitles` is unchanged, but the authorization code its description cites changed | **Warn** | +| `NEW_UNENTITLED_REACH` | An `@agents`/`@reaches` claim no cited `@entitles` covers (§3.2.1) that was covered, or absent, before | **Warn** | An entitlement whose cited file changed is reported as **stale**, not as removed: the claim still stands, but the basis a reviewer accepted it on has moved, so it needs a fresh look. Because the entitlement itself did not change, this is reported even when the delta is otherwise empty. An uncited (inert) entitlement has no basis and cannot go stale. diff --git a/src/agents/prompts.ts b/src/agents/prompts.ts index 7db320be..bb8ffc49 100644 --- a/src/agents/prompts.ts +++ b/src/agents/prompts.ts @@ -283,6 +283,7 @@ At each boundary crossing and data transformation, ask: - Does this need human security review? (@audit) - Is this risk handled by someone else? (@transfers) - Is the privilege this requires already meant to grant this effect? (@entitles — cite the authz code, and never for an ownership question) +- Does this hand an LLM agent a tool, an MCP server, or file, shell or database access? (@agents on the registration, @effects on the code that acts, @gates on any approval step) ### Step 4: Write Coupled Annotation Blocks NEVER write a single annotation in isolation. Every annotated location should tell a complete story. @@ -439,6 +440,24 @@ Report what you proposed at the end of your run, and tell the user to review it // @shield:end \`\`\` +### \`@agents\`, \`@reaches\`, \`@effects\`, \`@gates\` — What an Embedded Agent Can Reach +When the code embeds an LLM agent, its harness hands the agent capabilities: tool definitions, MCP servers, file and shell access, database clients, internal methods. Declare them, so a reviewer can see the agent's blast radius: + +- \`@agents <#agent> to on [as ]\` — on the tool registration or server mount. Writing it is what marks the actor as an LLM agent; declare the actor with \`@actor\` in the definitions file first. +- \`@reaches\` — the same shape for a principal that is not an LLM agent (a CI runner, a service account). Never name one actor under both verbs: that is a validation error. +- \`@effects on [as ]\` — on the code that acts. \`as\` is whose credentials it runs under. Egress needs no new verb: write \`@flows\` to an \`External.*\` asset. +- \`@gates by <#approver> [for ]\` — on an approval step that blocks until the named principal decides. A gate suppresses nothing; only write one where the code really waits. + +You may write all four: they record what the code *can* do. Use the capability token an \`@entitles\` would use (\`run-sql\`, \`issue-refund\`), because "can minus may" — \`guardlink_lookup("unentitled reaches")\` — joins on it. For a capability with no entitlement, propose one only if the code shows it is meant to be there; a debug tool left registered is an \`@exposes\` + \`@audit\`, not an entitlement. + +\`\`\` +// @shield:begin -- "Agent reach example, excluded from parsing" +// @agents #support-agent to run-sql on #tool-surface -- "run_sql registered in tools.ts, still on in production" +// @effects write on #users-db as #db-service -- "db.raw(sql) runs the model-written SQL verbatim" +// @gates #payments by #support-human for issue-refund -- "requireApproval() parks the refund until a human approves" +// @shield:end +\`\`\` + ### \`@accepts\` — NEVER USE (Human-Only Decision) @accepts marks a risk as intentionally unmitigated. This is a **human-only governance decision** — it requires conscious risk ownership by a person or team. As an AI agent, you MUST NEVER write @accepts annotations. You cannot accept risk on behalf of humans. diff --git a/src/cli/index.ts b/src/cli/index.ts index 147e45d4..e0cf79b3 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -47,7 +47,7 @@ import { Command } from 'commander'; import { resolve, basename, join, isAbsolute, relative } from 'node:path'; import { readFileSync, writeFileSync, existsSync, mkdirSync, statSync } from 'node:fs'; -import { parseProject, findDanglingRefs, findUnmitigatedExposures, findAcceptedWithoutAudit, findAcceptedExposures, findUndeclaredActors, findInertEntitlements, findImpreciseEntitlements, findOffConventionGalFiles, findUnresolvedBoundarySides, checkHandoff, HANDOFF_COMMAND, findAnchorDrift, applyReanchor, migrateAnnotationMode, computeAnnotationHash, computeAnchorHash, canonicalAnchorRecords, countAnchors, lostAnchors, clearAnnotations, listFeatures, filterByFeature, getFeatureSummaries, readAcceptancePolicy, findAcceptanceDefects, acceptanceBlastRadius, formatBlastRadius, DEFAULT_ACCEPTANCE_POLICY, MAX_ACCEPTANCE_WARN_DAYS, ACCEPTANCE_REGISTER_NOTE, ACCEPTANCE_REGISTER_SHORT, readLedger, writeLedger, classifyClaims, planVerification, applyVerification, defaultVerifier, headCommit, nowIso, LEDGER_FILE } from '../parser/index.js'; +import { parseProject, findDanglingRefs, findUnmitigatedExposures, findAcceptedWithoutAudit, findAcceptedExposures, findUndeclaredActors, findAgentReachConflicts, findInertEntitlements, findImpreciseEntitlements, findOffConventionGalFiles, findUnresolvedBoundarySides, checkHandoff, HANDOFF_COMMAND, findAnchorDrift, applyReanchor, migrateAnnotationMode, computeAnnotationHash, computeAnchorHash, canonicalAnchorRecords, countAnchors, lostAnchors, clearAnnotations, listFeatures, filterByFeature, getFeatureSummaries, readAcceptancePolicy, findAcceptanceDefects, acceptanceBlastRadius, formatBlastRadius, DEFAULT_ACCEPTANCE_POLICY, MAX_ACCEPTANCE_WARN_DAYS, ACCEPTANCE_REGISTER_NOTE, ACCEPTANCE_REGISTER_SHORT, readLedger, writeLedger, classifyClaims, planVerification, applyVerification, defaultVerifier, headCommit, nowIso, LEDGER_FILE } from '../parser/index.js'; import { diagnosticIcon } from '../parser/format.js'; import { runCiChecks, formatCiReport, ESTATE_ROUTE } from '../ci/index.js'; import type { CiWorkspaceScope } from '../ci/index.js'; @@ -70,6 +70,7 @@ import { checkBoundaries, boundaryCheckSummaryLine } from '../codegraph/boundary import { selectAnnotatePlaybook, selectReportShape, ANNOTATE_PLAYBOOKS, REPORT_SHAPES } from '../playbooks/index.js'; import { lintAnnotations, runGate, formatGateReport, buildGateFollowUp, stripViolations, RULE_FIX } from '../gate/index.js'; import { relationRecords, CLAIM_KEY_NAMES, CLAIM_KEY_SURFACES } from '../parser/claim-key.js'; +import { lookup } from '../mcp/lookup.js'; import { parseFindingsBlock, validateFindings } from '../analyze/findings.js'; import { readHypotheses, classifyHypotheses, attachHypotheses, rankUntested, recordOutcome, importScan, confirmedLine, writeConfirmedLine, formatHypothesisList, formatQueue, formatIntake, formatOutcome, formatImport, HYPOTHESES_FILE, classifyBoundaryClaims, recordBoundaryOutcome, formatBoundaryOutcome, formatBoundaryList, BOUNDARY_CLAIM_KEY_NAMES } from '../hypothesis/index.js'; import type { HypothesisClassification } from '../hypothesis/index.js'; @@ -383,6 +384,8 @@ program const actorDiags = findUndeclaredActors(model); const inertDiags = findInertEntitlements(model); const impreciseDiags = findImpreciseEntitlements(model); + // One actor under both @agents and @reaches (error) + const agentReachDiags = findAgentReachConflicts(model); // §3.6: an entitlement in source with no accepted proposal behind it. Skipped // in projects with no proposal ledger, so this only bites where the flow is used. @@ -410,7 +413,7 @@ program console.error(boundaryCheckSummaryLine(worklist, boundaryDiags)); } - const allDiags = [...diagnostics, ...danglingDiags, ...boundarySideDiags, ...acceptAuditDiags, ...acceptanceDiags, ...actorDiags, ...inertDiags, ...impreciseDiags, ...provenanceDiags, ...galConventionDiags, ...ledgerDiags, ...boundaryDiags]; + const allDiags = [...diagnostics, ...danglingDiags, ...boundarySideDiags, ...acceptAuditDiags, ...acceptanceDiags, ...actorDiags, ...agentReachDiags, ...inertDiags, ...impreciseDiags, ...provenanceDiags, ...galConventionDiags, ...ledgerDiags, ...boundaryDiags]; // Check for unmitigated exposures const unmitigated = findUnmitigatedExposures(model); @@ -1222,6 +1225,25 @@ program if (opts.failOnFound && findings.length > 0) process.exit(1); }); +// ─── lookup ────────────────────────────────────────────────────────── + +// @comment -- "guardlink lookup is read-only: it parses the project as parse and validate do and answers with the same pure lookup() the guardlink_lookup MCP tool uses, so it adds no input surface beyond the query text, which lookup's anchored patterns already bound" +program + .command('lookup') + .description('Query the threat model with the guardlink_lookup forms, e.g. "unentitled reaches", "agents", "effects for #db", "cwe:CWE-89". Prints JSON; an unrecognised form lists the supported ones') + .argument('', 'Query form') + .option('-d, --dir ', 'Project directory', '.') + .option('-p, --project ', 'Project name (default: the name in .guardlink/config.json)') + .option('--fail-on-found', 'Exit 1 if the query returns any result (CI mode, e.g. no unentitled reaches)') + .action(async (queryParts: string[], opts: { dir: string; project?: string; failOnFound?: boolean }) => { + const root = resolve(opts.dir); + const { model } = await parseProject({ root, project: opts.project ?? readConfiguredProject(root) ?? undefined }); + const result = lookup(model, queryParts.join(' ')); + console.log(JSON.stringify(result, null, 2)); + if (result.type === 'no_match') process.exit(2); + if (opts.failOnFound && result.count > 0) process.exit(1); + }); + // ─── sarif ─────────────────────────────────────────────────────────── program diff --git a/src/dashboard/annotations.ts b/src/dashboard/annotations.ts index e8fd8846..6faf2bfa 100644 --- a/src/dashboard/annotations.ts +++ b/src/dashboard/annotations.ts @@ -126,6 +126,9 @@ export function buildFileAnnotations(model: ThreatModel, root?: string, join: An for (const a of model.assumptions) addEntry('assumes', as(a), `Assumes: ${a.asset}`); for (const ac of model.actors || []) addEntry('actor', as(ac), `Actor: ${ac.name}`); for (const en of model.entitlements || []) addEntry('entitles', as(en), `${en.actor} entitled to ${en.capability}${en.inert ? ' (inert)' : ''}`); + for (const r of model.reaches || []) addEntry(r.agent ? 'agents' : 'reaches', as(r), `${r.actor} can ${r.capability}${r.asset ? ` on ${r.asset}` : ''}`); + for (const ef of model.effects || []) addEntry('effects', as(ef), `${ef.effect} on ${ef.asset}${ef.identity ? ` as ${ef.identity}` : ''}`); + for (const g of model.gates || []) addEntry('gates', as(g), `${g.asset} gated by ${g.approver}`); for (const s of model.shields) addEntry('shield', as(s), s.reason || 'Shielded region'); for (const c of model.comments) addEntry('comment', as(c), c.description || 'Developer note'); diff --git a/src/diff/engine.ts b/src/diff/engine.ts index dfdede8a..d198e3d5 100644 --- a/src/diff/engine.ts +++ b/src/diff/engine.ts @@ -14,17 +14,19 @@ * * @comment -- "Pure model-vs-model comparator; no I/O. Entitlement staleness is the one thing it cannot derive from the two models, so the changed-file list is passed in by the caller (see getChangedFiles in git.ts)" * @flows ThreatModel -> #diff via diffModels -- "Before/after models compared into a structured delta" + * @comment -- "A reach that becomes unentitled is reported on its own, like a new unmitigated exposure: newUnentitledReaches comes from findUnentitledReaches, the join lookup answers with" */ import type { ThreatModel, ThreatModelAsset, ThreatModelThreat, ThreatModelControl, ThreatModelActor, ThreatModelMitigation, ThreatModelExposure, ThreatModelConfirmed, ThreatModelAcceptance, - ThreatModelEntitlement, + ThreatModelEntitlement, ThreatModelReach, ThreatModelEffect, ThreatModelGate, ThreatModelFlow, ThreatModelBoundary, ThreatModelTransfer, } from '../types/index.js'; import { findUnmitigatedExposures, normalizeRef } from '../parser/coverage.js'; import { citationMatchesFile } from '../parser/citation.js'; +import { findUnentitledReaches, reachKey as reachKeyIn } from '../parser/reach.js'; // ─── Delta types ───────────────────────────────────────────────────── @@ -51,6 +53,10 @@ export interface ThreatModelDiff { confirmed: Change[]; acceptances: Change[]; entitlements: Change[]; + /** Reach claims: @agents and @reaches */ + reaches: Change[]; + effects: Change[]; + gates: Change[]; flows: Change[]; boundaries: Change[]; transfers: Change[]; @@ -61,6 +67,13 @@ export interface ThreatModelDiff { /** Risk-relevant: previously unmitigated exposures now resolved */ resolvedExposures: ThreatModelExposure[]; + /** + * Risk-relevant: reaches no cited @entitles covers after the change that were + * not uncovered before — a new capability nobody approved, or an approval + * withdrawn from an existing one. + */ + newUnentitledReaches: ThreatModelReach[]; + /** * Entitlements whose cited authorization code changed in this delta (§3.7). * Reported as *stale* — the claim survives, but the code it was reviewed @@ -94,6 +107,8 @@ export interface DiffSummary { riskDelta: 'increased' | 'decreased' | 'unchanged'; /** Entitlements whose cited authorization code changed */ staleEntitlements: number; + /** Reaches newly without a covering entitlement */ + newUnentitledReaches: number; } // ─── Diff computation ──────────────────────────────────────────────── @@ -111,6 +126,17 @@ export function diffModels(before: ThreatModel, after: ThreatModel, options: Dif const flows = diffByKey(before.flows, after.flows, flowKey, flowChanged); const boundaries = diffByKey(before.boundaries, after.boundaries, boundaryKey, boundaryChanged); const transfers = diffByKey(before.transfers, after.transfers, transferKey); + // Identity per model: each resolves actor and asset spellings against its own + // declarations, so renaming `#db` to its dotted path is not a remove plus an add. + const reaches = diffByKey(before.reaches || [], after.reaches || [], reachKeyIn(before), reachChanged, reachKeyIn(after)); + const effects = diffByKey(before.effects || [], after.effects || [], effectKey, effectChanged); + const gates = diffByKey(before.gates || [], after.gates || [], gateKey, gateChanged); + + const beforeUnentitled = new Set(findUnentitledReaches(before).map(u => reachKeyIn(before)(u.reach))); + const afterReachKey = reachKeyIn(after); + const newUnentitledReaches = findUnentitledReaches(after) + .map(u => u.reach) + .filter(r => !beforeUnentitled.has(afterReachKey(r))); // Compute unmitigated exposure delta const beforeUnmitigated = computeUnmitigated(before); @@ -130,7 +156,7 @@ export function diffModels(before: ThreatModel, after: ThreatModel, options: Dif const staleEntitlements = findStaleEntitlements(after, options.changedFiles); - const allChanges = [assets, threats, controls, actors, mitigations, exposures, confirmed, acceptances, entitlements, flows, boundaries, transfers]; + const allChanges = [assets, threats, controls, actors, mitigations, exposures, confirmed, acceptances, entitlements, reaches, effects, gates, flows, boundaries, transfers]; const totalChanges = allChanges.reduce((sum, c) => sum + c.length, 0); const added = allChanges.reduce((sum, c) => sum + c.filter(x => x.kind === 'added').length, 0); const removed = allChanges.reduce((sum, c) => sum + c.filter(x => x.kind === 'removed').length, 0); @@ -147,10 +173,12 @@ export function diffModels(before: ThreatModel, after: ThreatModel, options: Dif resolvedUnmitigated: resolvedExposures.length, riskDelta, staleEntitlements: staleEntitlements.length, + newUnentitledReaches: newUnentitledReaches.length, }, - assets, threats, controls, actors, mitigations, exposures, confirmed, acceptances, entitlements, flows, boundaries, transfers, + assets, threats, controls, actors, mitigations, exposures, confirmed, acceptances, entitlements, reaches, effects, gates, flows, boundaries, transfers, newUnmitigatedExposures, resolvedExposures, + newUnentitledReaches, staleEntitlements, }; } @@ -185,13 +213,14 @@ function diffByKey( after: T[], keyFn: (item: T) => string, changedFn?: (a: T, b: T) => string | null, + afterKeyFn: (item: T) => string = keyFn, ): Change[] { const changes: Change[] = []; const beforeMap = new Map(); const afterMap = new Map(); for (const item of before) beforeMap.set(keyFn(item), item); - for (const item of after) afterMap.set(keyFn(item), item); + for (const item of after) afterMap.set(afterKeyFn(item), item); // Removed: in before but not in after for (const [key, item] of beforeMap) { @@ -262,6 +291,16 @@ function normalizeActorRef(ref: string): string { return ref.startsWith('#') ? ref.slice(1) : ref; } +/** A side effect is identified by what it does to what, and as whom. */ +function effectKey(e: ThreatModelEffect): string { + return `${e.effect}::${normalizeRef(e.asset)}::${normalizeRef(e.identity ?? '')}`; +} + +/** A gate is identified by what it guards, who decides, and for which capability. */ +function gateKey(g: ThreatModelGate): string { + return `${normalizeRef(g.asset)}::${normalizeRef(g.approver)}::${g.canonical_capability ?? ''}`; +} + function mitigationKey(m: ThreatModelMitigation): string { return `${m.asset}::${m.threat}::${m.control || ''}`; } @@ -339,6 +378,28 @@ function entitlementChanged(a: ThreatModelEntitlement, b: ThreatModelEntitlement return changes.length > 0 ? changes.join('; ') : null; } +/** + * Actor, capability and asset are the key. What can change under it is the + * verb — the actor stopped or started being declared an agent — the caller + * identity, the capability as written, and the description. + */ +function reachChanged(a: ThreatModelReach, b: ThreatModelReach): string | null { + const changes: string[] = []; + if (a.agent !== b.agent) changes.push(`verb: ${a.agent ? '@agents' : '@reaches'} → ${b.agent ? '@agents' : '@reaches'}`); + if (a.identity !== b.identity) changes.push(`identity: ${a.identity || 'none'} → ${b.identity || 'none'}`); + if (a.capability !== b.capability) changes.push(`capability: ${a.capability} → ${b.capability}`); + if (a.description !== b.description) changes.push('description changed'); + return changes.length > 0 ? changes.join('; ') : null; +} + +function effectChanged(a: ThreatModelEffect, b: ThreatModelEffect): string | null { + return a.description !== b.description ? 'description changed' : null; +} + +function gateChanged(a: ThreatModelGate, b: ThreatModelGate): string | null { + return a.description !== b.description ? 'description changed' : null; +} + function exposureChanged(a: ThreatModelExposure, b: ThreatModelExposure): string | null { const changes: string[] = []; if (a.severity !== b.severity) changes.push(`severity: ${a.severity || 'unset'} → ${b.severity || 'unset'}`); diff --git a/src/diff/format.ts b/src/diff/format.ts index 151c2557..dbf1b28e 100644 --- a/src/diff/format.ts +++ b/src/diff/format.ts @@ -55,6 +55,17 @@ export function formatDiff(diff: ThreatModelDiff): string { lines.push(''); } + // ── New unentitled reaches ── + // Next to new unmitigated exposures for the same reason: a capability the + // harness hands out that no human approved is the reach a reviewer must see. + if (diff.newUnentitledReaches.length > 0) { + lines.push('── New Unentitled Reaches (no cited @entitles covers them) ──'); + for (const r of diff.newUnentitledReaches) { + lines.push(` + ${describeReach(r)} (${r.location.file}:${r.location.line})`); + } + lines.push(''); + } + // ── Stale entitlements ── // Not a "change" in the model — the claim is unchanged; the authz code it was // reviewed against is not. Surfaced so a human re-checks the citation (§3.7). @@ -78,6 +89,9 @@ export function formatDiff(diff: ThreatModelDiff): string { emitSection('Entitlements', diff.entitlements, lines, e => `${e.actor} entitled to ${e.capability}${e.asset ? ` on ${e.asset}` : ''}${e.threat ? ` against ${e.threat}` : ''}` + entitlementCaveat(e)); + emitSection('Reaches', diff.reaches, lines, describeReach); + emitSection('Effects', diff.effects, lines, e => `${e.effect} on ${e.asset}${e.identity ? ` as ${e.identity}` : ''}`); + emitSection('Gates', diff.gates, lines, g => `${g.asset} by ${g.approver}${g.capability ? ` for ${g.capability}` : ''}`); emitSection('Flows', diff.flows, lines, f => `${f.source} → ${f.target}${f.mechanism ? ` via ${f.mechanism}` : ''}`); emitSection('Boundaries', diff.boundaries, lines, b => (b.directed ? `${b.asset_a} → ${b.asset_b} (outer → inner)` : `${b.asset_a} ↔ ${b.asset_b}`)); emitSection('Transfers', diff.transfers, lines, t => `${t.source} → ${t.target} (${t.threat})`); @@ -85,6 +99,11 @@ export function formatDiff(diff: ThreatModelDiff): string { return lines.join('\n'); } +/** A reach in its own words: `@agents #bot to run-sql on #api as #svc`. */ +function describeReach(r: { agent: boolean; actor: string; capability: string; asset?: string; identity?: string }): string { + return `${r.agent ? '@agents' : '@reaches'} ${r.actor} to ${r.capability}${r.asset ? ` on ${r.asset}` : ''}${r.identity ? ` as ${r.identity}` : ''}`; +} + /** Why a claim cannot demote, or '' when it can. Both halves of the join are * named (§9.3): two claims differing only by threat must not read identically. */ function entitlementCaveat(e: { inert: boolean; asset?: string; threat?: string }): string { @@ -179,6 +198,29 @@ export function formatDiffMarkdown(diff: ThreatModelDiff): string { lines.push(''); } + if (diff.newUnentitledReaches.length > 0) { + lines.push('#### 🧰 New Unentitled Reaches'); + lines.push(''); + lines.push('No cited `@entitles` covers these: a capability the code hands out that nobody approved.'); + lines.push(''); + lines.push('| Verb | Actor | Capability | Asset | Location |'); + lines.push('|------|-------|------------|-------|----------|'); + for (const r of diff.newUnentitledReaches) { + lines.push(`| ${r.agent ? '@agents' : '@reaches'} | ${r.actor} | ${r.capability} | ${r.asset || '—'} | \`${r.location.file}:${r.location.line}\` |`); + } + lines.push(''); + } + + if (diff.reaches.length + diff.effects.length + diff.gates.length > 0) { + lines.push('#### Reach, effects and gates'); + lines.push(''); + const mark = (k: string) => (k === 'added' ? '+' : k === 'removed' ? '-' : '~'); + for (const c of diff.reaches) lines.push(`- ${mark(c.kind)} ${describeReach(c.item)}${c.details ? ` (${c.details})` : ''}`); + for (const c of diff.effects) lines.push(`- ${mark(c.kind)} @effects ${c.item.effect} on ${c.item.asset}${c.item.identity ? ` as ${c.item.identity}` : ''}`); + for (const c of diff.gates) lines.push(`- ${mark(c.kind)} @gates ${c.item.asset} by ${c.item.approver}${c.item.capability ? ` for ${c.item.capability}` : ''}`); + lines.push(''); + } + emitStaleEntitlementsMarkdown(diff, lines); return lines.join('\n'); diff --git a/src/gate/lint.ts b/src/gate/lint.ts index 830799ab..2faae030 100644 --- a/src/gate/lint.ts +++ b/src/gate/lint.ts @@ -7,6 +7,7 @@ * * @flows ThreatModel -> #gate via lintAnnotations -- "Claims and their descriptions" * @comment -- "Errors are what the gate rejects; warnings are what it reports. Governance verbs (@accepts, @entitles) are errors only for claims under check, because a human may legitimately have written the others" + * @comment -- "@agents, @reaches, @effects and @gates are observations an agent may write, so no rule rejects them. A @gates that does not say what the approval step is gets the vague-description warning, because a gate claim is what a reviewer reads to decide a route is held" */ import type { ThreatModel, ThreatModelExposure, ThreatModelConfirmed } from '../types/index.js'; import { relationRecords } from '../parser/claim-key.js'; @@ -170,6 +171,7 @@ export function lintAnnotations(model: ThreatModel, opts: LintOptions = {}): Vio for (const c of model.comments) if (under(c.location) && isVague(c.description)) push('description-vague', 'warn', 'comment', c, `"${c.description ?? ''}" says nothing a reader can use`); for (const t of model.transfers) if (under(t.location) && isVague(t.description)) push('description-vague', 'warn', 'transfers', t, `"${t.description ?? ''}" does not say who holds the risk or how`); for (const a of model.assumptions) if (under(a.location) && isVague(a.description)) push('description-vague', 'warn', 'assumes', a, `"${a.description ?? ''}" does not say what must hold`); + for (const g of model.gates || []) if (under(g.location) && isVague(g.description)) push('description-vague', 'warn', 'gates', g, `"${g.description ?? ''}" does not say what the approval step is or how it blocks`); return out.sort((x, y) => (x.file < y.file ? -1 : x.file > y.file ? 1 : x.line - y.line || x.rule.localeCompare(y.rule))); } diff --git a/src/init/templates.ts b/src/init/templates.ts index 77d3f886..e16a9aa2 100644 --- a/src/init/templates.ts +++ b/src/init/templates.ts @@ -246,6 +246,11 @@ RELATE @mitigates against <#threat> using <#control> -- "how" @entitles <#actor> to on against <#threat> -- "by design + authz file:line" ^ PROPOSED via \`guardlink entitle --propose\`, written only when a human accepts +REACH @agents <#agent-actor> to on as -- "on the tool registration" + @reaches <#actor> to on as -- "same, for a principal that is not an LLM agent" + @effects on as -- "on the code that acts" + @gates by <#approver-actor> for -- "on the approval step" + FLOW @flows -> via -- "details" @boundary from to (#id) -- "trust boundary, direction known" @boundary | (#id) -- "trust boundary" @@ -282,6 +287,7 @@ Append after severity: \`cwe:CWE-89\`, \`owasp:A03:2021\`, \`capec:CAPEC-66\`, \ 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.** An over-grant closes a real privilege escalation as by-design, so an agent files a proposal (\`guardlink entitle --propose\` / \`guardlink_entitlement_propose\`) and a human accepts it — acceptance is what writes the annotation, under their name. An \`@entitles\` in source with no accepted proposal is a validation error. The rationale must cite the authz code as \`file:line\`, or the claim is **inert** — parsed but ignored. 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). +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; one actor under both verbs is a validation error. Agents may write all four. The capability is the token an \`@entitles\` would use, so \`guardlink lookup unentitled reaches\` lists what no human approved. ## When Writing Code @@ -480,7 +486,7 @@ the same change.** This includes: new endpoints, authentication/authorization lo 3. **Use \`@confirmed\` for verified exploits.** When a pentest, CXG scan, or manual reproduction proves a threat is exploitable, mark it with \`@confirmed #threat on Asset [severity] -- "evidence"\`. This is distinct from \`@exposes\` (theoretical) — \`@confirmed\` means real, verified, not a false positive. Include severity based on actual observed impact. **Without that evidence in hand, \`@exposes\` stands and you do not promote it** — reading the code is not reproduction. To get evidence, \`bugb intake ""\` turns a description into a test plan and prints a PLAN_ID; an operator approves it, never you. 4. Do not delete or mangle existing annotations. Treat them as part of the code. Edit only when intentionally changing the threat model. 5. Definitions (\`@asset\`, \`@threat\`, \`@control\` with \`(#id)\`) live in \`.guardlink/definitions${project.definitionsExt}\`. Reuse existing \`#id\`s — never redefine. If you need a new asset or threat, add the definition there first, then reference it in source files. -6. Source files use relationship verbs only: \`@mitigates\`, \`@exposes\`, \`@confirmed\`, \`@flows\`, \`@handles\`, \`@boundary\`, \`@comment\`, \`@validates\`, \`@audit\`, \`@owns\`, \`@assumes\`, \`@transfers\`, \`@feature\`. (\`@actor\` is a definition — it belongs in the definitions file with \`@asset\`/\`@threat\`/\`@control\`. \`@entitles\` is proposed, not written — see rule 9.) Four of those are permitted everywhere and written almost nowhere, because nothing tells you *when*. Their triggers: +6. Source files use relationship verbs only: \`@mitigates\`, \`@exposes\`, \`@confirmed\`, \`@flows\`, \`@handles\`, \`@boundary\`, \`@comment\`, \`@validates\`, \`@audit\`, \`@owns\`, \`@assumes\`, \`@transfers\`, \`@feature\`, and the reach verbs \`@agents\`, \`@reaches\`, \`@effects\`, \`@gates\` (rule 10). (\`@actor\` is a definition — it belongs in the definitions file with \`@asset\`/\`@threat\`/\`@control\`. \`@entitles\` is proposed, not written — see rule 9.) Four of those are permitted everywhere and written almost nowhere, because nothing tells you *when*. Their triggers: - **You wrote or changed a test that pins a control** → \`@validates #control for Asset -- "what the test proves"\`, on the test. Control first, asset second, joined by \`for\`. - **The code trusts a caller, library, or platform to hold a property it never checks itself** → \`@assumes Asset -- "what must hold, and what breaks if it does not"\`. One asset, and the whole assumption lives in the description. - **Responsibility for a threat lands on a vendor, an upstream service, or another team's component** → \`@transfers #threat from Source to Target\`. Both ends must be assets that already exist in the definitions file — a vendor needs an \`External.*\` asset declared first, and a bare company name will not parse. @@ -488,6 +494,7 @@ the same change.** This includes: new endpoints, authentication/authorization lo 7. Write coupled annotation blocks that tell a complete story: risk + control (or audit) + data flow + context note — plus \`@boundary\` when that flow crosses a trust change (written \`from to \` when you know which side is less trusted), and \`@handles\` when the asset touches classified data. Never write a lone \`@exposes\` without follow-up. 8. Avoid \`@shield\` unless a human explicitly asks to hide code from AI — it creates blind spots. 9. **NEVER write \`@entitles\` into source — propose it.** \`@entitles\` says a privilege is *supposed* to have this effect, so an over-grant closes a real privilege escalation as by-design. That makes it the second claim you may not make on a human's behalf, alongside \`@accepts\`. File it with \`guardlink entitle --propose\` (or \`guardlink_entitlement_propose\`) and a human's acceptance is what writes the annotation, under their name; an \`@entitles\` in source with no accepted proposal is a validation error. The rationale must cite the authz code as \`file:line\` or the claim is inert — parsed and then ignored. It never suppresses 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 it cannot say whose object it was. When unsure which role the code actually requires, write \`@comment\` describing what you saw instead: under-granting costs noise, over-granting hides a real bug. +10. **Declare what an embedded LLM agent can reach.** When code hands an agent a tool, an MCP server, file, shell or database access, or an internal method: \`@agents #agent to on Asset\` on the registration (declare the agent with \`@actor\` first), \`@effects on Asset\` on the code that acts, and \`@gates Asset by #approver\` on any approval step. \`@reaches\` is the same claim for a principal that is not an LLM agent (a CI runner, a service account); one actor under both verbs is a validation error. You may write all four — they say what the code *can* do. Use the capability token an \`@entitles\` would use: \`unentitled reaches\` then lists the capabilities no human approved, and for those you propose an entitlement (rule 9), never write one. ### Workflow (while coding) @@ -505,7 +512,8 @@ the same change.** This includes: new endpoints, authentication/authorization lo guessing** — send it a bad query to get the list. Beyond \`asset\`/\`threat\`/\`control\`, it reaches every relation the model holds: \`owner of X\`, \`handles pii\`, \`assumptions for X\`, \`audits for X\`, \`validations for X\`, \`acceptances\`, \`transfers\`, \`comments for X\`, \`shields\`, \`cross-repo refs\`, - and \`cwe:CWE-89\` / \`owasp:A03\` for scanner findings. + \`agents\`, \`unentitled reaches\`, \`effects for X\`, \`gates for X\`, + and \`cwe:CWE-89\` / \`owasp:A03\` for scanner findings. Without MCP, \`guardlink lookup "
"\` answers the same forms. - Reference matches report \`matched_via: exact | alias | substring\`. A substring match is a suggestion, not an identification; \`ambiguous\` with \`candidates\` means several records tied. @@ -524,6 +532,9 @@ the same change.** This includes: new endpoints, authentication/authorization lo @confirmed #sqli on App.API [critical] cwe:CWE-89 -- "Pentest verified: raw SQL injection via email param" @feature "SSO Login" -- "Single sign-on authentication flow" @owns security-team for App.API -- "Team responsible for reviews" +@agents #support-agent to run-sql on #tool-surface as #agent-session -- "run_sql tool registered in tools.ts" +@effects write on #users-db as #db-service -- "Executes the model-written SQL via db.raw" +@gates #payments by #support-human for issue-refund -- "requireApproval() blocks until a human approves" @actor Namespace_Admin (#ns-admin) -- "Administers one namespace's configuration" (definitions file) @comment -- "Rate limit: 100 req/15min via express-rate-limit" \`\`\` @@ -778,7 +789,8 @@ Every time you write or modify code that touches security-relevant behavior, you - Use @confirmed for verified exploits. When pentest/scanning/manual reproduction proves a threat is exploitable: @confirmed #threat on Asset [severity] -- "evidence". Distinct from @exposes (theoretical) — @confirmed means real, verified, no false positives. Without evidence in hand, @exposes stands and you do not promote it — reading the code is not reproduction. To get evidence: \`bugb intake ""\` builds a plan an operator approves, never you. - Preserve existing annotations — do not delete or mangle them. - Definitions (@asset, @threat, @control with (#id)) live in .guardlink/definitions${project.definitionsExt}. Reuse IDs — never redefine. Add new definitions there first, then reference in source files. -- Source files use relationship verbs: @mitigates, @exposes, @confirmed, @flows, @handles, @boundary, @comment, @validates, @audit, @owns, @assumes, @transfers, @feature. (@actor is a definition — it lives with @asset/@threat/@control. @entitles is proposed, not written.) +- Source files use relationship verbs: @mitigates, @exposes, @confirmed, @flows, @handles, @boundary, @comment, @validates, @audit, @owns, @assumes, @transfers, @feature, @agents, @reaches, @effects, @gates. (@actor is a definition — it lives with @asset/@threat/@control. @entitles is proposed, not written.) +- Code hands an LLM agent a tool, MCP server, file/shell/DB access → @agents #agent to on Asset on the registration, @effects on Asset on the code that acts, @gates Asset by #approver on an approval step. @reaches for a principal that is not an agent; never both verbs for one actor. - Triggers for the four verbs everyone forgets: wrote a test that pins a control → @validates #control for Asset. Code trusts a caller/library/platform to hold a property it never checks → @assumes Asset -- "what must hold". A threat becomes a vendor's or another team's responsibility → @transfers #threat from Source to Target (both ends must be declared assets; a vendor needs an External.* asset first). You know the accountable team → @owns team-id for Asset (bare token — no quotes, no #). - Write coupled annotation blocks: risk + control (or audit) + data flow + context note — plus @boundary when that flow crosses a trust change (from to when you know which side is less trusted), and @handles when the asset touches classified data. - Avoid @shield unless a human explicitly asks to hide code from AI. @@ -802,6 +814,8 @@ Every time you write or modify code that touches security-relevant behavior, you - @confirmed #sqli on App.API [critical] cwe:CWE-89 -- "Pentest verified: raw SQL injection via email param" - @feature "SSO Login" -- "Single sign-on authentication flow" - @owns security-team for App.API -- "Team responsible" +- @agents #support-agent to run-sql on #tool-surface -- "run_sql tool registered in tools.ts" +- @effects write on #users-db -- "Executes the model-written SQL via db.raw" - @comment -- "Rate limit: 100 req/15min" `.trimStart(); } @@ -1169,6 +1183,10 @@ Assets are referenced as \`#id\` or as a \`Dotted.Path\`; both resolve to the sa | \`@owns\` | \`@owns for -- "who reviews changes here"\` | | \`@handles\` | \`@handles on -- "what data"\` | | \`@assumes\` | \`@assumes -- "what must hold for this to be safe"\` | +| \`@agents\` | \`@agents to on as -- "the tool registration"\` — an LLM agent can invoke this | +| \`@reaches\` | \`@reaches to on as -- "where it is granted"\` — the same, for a principal that is not an agent | +| \`@effects\` | \`@effects on as -- "what this code does"\` | +| \`@gates\` | \`@gates by for -- "the approval step"\` | | \`@feature\` | \`@feature "Name" -- "what it groups"\` | | \`@comment\` | \`@comment -- "context that fits no other verb"\` | | \`@accepts\` | \`@accepts on by "" until -- "why"\` — **human only, never write this** | diff --git a/src/mcp/context.ts b/src/mcp/context.ts index 3c2f8a74..3a9e9732 100644 --- a/src/mcp/context.ts +++ b/src/mcp/context.ts @@ -130,6 +130,10 @@ function allRecords(model: ThreatModel): { verb: string; location: SourceLocatio add('accepts', model.acceptances); add('actor', model.actors); add('entitles', model.entitlements); + add('agents', (model.reaches || []).filter(r => r.agent)); + add('reaches', (model.reaches || []).filter(r => !r.agent)); + add('effects', model.effects); + add('gates', model.gates); add('transfers', model.transfers); add('flows', model.flows); add('boundary', model.boundaries); @@ -163,6 +167,10 @@ function assetRefsOf(verb: string, row: any): string[] { // names one only through its optional `on ` clause. case 'actor': return []; case 'entitles': return row.asset ? [row.asset] : []; + case 'agents': + case 'reaches': return [row.asset, row.identity].filter(Boolean); + case 'effects': return [row.asset, row.identity].filter(Boolean); + case 'gates': return [row.asset]; case 'exposes': case 'mitigates': case 'confirmed': diff --git a/src/mcp/lookup.ts b/src/mcp/lookup.ts index 970dbb09..197920b2 100644 --- a/src/mcp/lookup.ts +++ b/src/mcp/lookup.ts @@ -13,6 +13,10 @@ * - "confirmed" → @confirmed verified exploitable findings * - "actors" → declared principals (@actor) with the capabilities each is entitled to * - "entitlements" / "entitlements for #actor" → @entitles claims, with citation and inert flag + * - "reaches [for #actor]" / "agents [for #actor]" → @agents/@reaches claims: what a principal can invoke + * - "unentitled reaches [for #actor]" → reaches no cited @entitles covers (can minus may) + * - "effects [for #asset]" → @effects claims: what code does to an asset, as whom + * - "gates [for #asset]" → @gates claims: who approves before effects on an asset land * - "boundary #config" → boundaries involving asset * - Free text → fuzzy match across assets, threats, controls * @@ -20,10 +24,12 @@ * @mitigates #mcp against #redos using #regex-anchoring -- "Patterns are simple and bounded" * @flows QueryString -> #mcp via lookup -- "Query input path" * @comment -- "Pure function; no I/O; operates on in-memory ThreatModel" + * @comment -- "The reach forms project the parsed model and add no input surface. `unentitled reaches` answers from findUnentitledReaches, the same join diff uses, so the two cannot disagree about can minus may" */ import { buildCoverageIndex, findUnmitigatedExposures } from '../parser/coverage.js'; import { ACCEPTANCE_REGISTER_ID, ACCEPTANCE_REGISTER_NOTE } from '../parser/acceptance.js'; +import { actorResolver, findUnentitledReaches } from '../parser/reach.js'; import type { ThreatModel, ThreatModelAsset, ThreatModelThreat, ThreatModelControl, ThreatModelTransfer, ThreatModelAcceptance, @@ -92,6 +98,11 @@ export const SUPPORTED_QUERY_FORMS = [ 'transfers [for ]', 'comments [for ]', 'shields [for ]', + 'reaches [for ] (@agents and @reaches: capabilities a principal can invoke)', + 'agents [for ] (the @agents claims only: LLM agents and their capabilities)', + 'unentitled reaches [for ] (capabilities reached with no cited @entitles covering them: can minus may)', + 'effects [for ] (what code does to an asset: read, write, delete, execute, spend, notify)', + 'gates [for ] (approval steps: who decides before effects on an asset land)', 'cross-repo refs [for ] (sibling-repo tags from workspace.yaml — NOT cwe:/owasp: — accepts `external refs` as an alias)', 'cwe:CWE-89 | owasp:A03 | CWE-89 (external identifiers declared on threats — the scanner bridge)', ' (bare identifier, fuzzy match across all categories)', @@ -186,6 +197,46 @@ export function lookup(model: ThreatModel, query: string): LookupResult { return lookupEntitlements(model, query, entitlementsQ[1]?.trim()); } + // ── "unentitled reaches [for ]" — can minus may. Before the plain + // reach forms, which would otherwise never see the qualifier. ── + const unentitledQ = q.match(/^unentitled\s+(?:reach(?:es)?|agents?)(?:\s+(?:for|of|by)\s+(.+))?$/); + if (unentitledQ) { + return lookupUnentitledReaches(model, query, unentitledQ[1]?.trim()); + } + + // ── "reaches [for ]" / "agents [for ]" ── + const reachesQ = q.match(/^(reach(?:es)?|agents?)(?:\s+(?:for|of|by)\s+(.+))?$/); + if (reachesQ) { + return lookupReaches(model, query, reachesQ[1].startsWith('agent'), reachesQ[2]?.trim()); + } + + // ── "effects [for ]" ── + const effectsQ = q.match(/^effects?(?:\s+(?:for|on)\s+(.+))?$/); + if (effectsQ) { + const project = (e: NonNullable[number]) => ({ + effect: e.effect, asset: e.asset, execution_identity: e.identity, + // Reads are reversible; every other effect is not, and that split is the + // one a reviewer scans for first. + mutating: e.effect !== 'read', + description: e.description, ...loc(e.location), + }); + const effects = model.effects || []; + if (!effectsQ[1]) return { query, type: 'effects', count: effects.length, results: effects.map(project) }; + return lookupAssetRelation(model, query, 'effects', effectsQ[1].trim(), resolve, effects, e => e.asset, project); + } + + // ── "gates [for ]" ── + const gatesQ = q.match(/^gates?(?:\s+(?:for|on)\s+(.+))?$/); + if (gatesQ) { + const project = (g: NonNullable[number]) => ({ + asset: g.asset, approver: g.approver, capability: g.canonical_capability, + description: g.description, ...loc(g.location), + }); + const gates = model.gates || []; + if (!gatesQ[1]) return { query, type: 'gates', count: gates.length, results: gates.map(project) }; + return lookupAssetRelation(model, query, 'gates', gatesQ[1].trim(), resolve, gates, g => g.asset, project); + } + // ── "features" ── if (/^features?(\s|$)/.test(q)) { const byName = new Map; description?: string }>(); @@ -415,6 +466,7 @@ function lookupUnmitigated(model: ThreatModel, query: string): LookupResult { * by design? */ function lookupActors(model: ThreatModel, query: string): LookupResult { + const actorKey = actorResolver(model); const results = (model.actors || []).map(ac => { // Both sides go through bareRef so the join is case-insensitive, the way // every other ref match in this module is. Comparing a raw `ac.id` against a @@ -422,6 +474,7 @@ function lookupActors(model: ThreatModel, query: string): LookupResult { // carried an uppercase letter. const key = bareRef(ac.id || ac.canonical_name); const held = (model.entitlements || []).filter(en => bareRef(en.actor) === key); + const reaches = (model.reaches || []).filter(r => actorKey(r.actor) === key); return { name: ac.name, id: ac.id, @@ -435,6 +488,9 @@ function lookupActors(model: ThreatModel, query: string): LookupResult { citation: en.citation?.raw, inert: en.inert, })), + // Writing @agents about an actor is what marks it as an LLM agent. + agent: reaches.some(r => r.agent), + reaches: reaches.map(r => ({ capability: r.canonical_capability, asset: r.asset })), }; }); return { query, type: 'actors', count: results.length, results }; @@ -470,6 +526,58 @@ function lookupEntitlements(model: ThreatModel, query: string, actorRef?: string return { query, type: 'entitlements', count: results.length, results }; } +/** + * `@agents` / `@reaches` claims: what a principal can invoke. Mirrors + * `lookupEntitlements`, because the pair is the point: a row here with no row + * there is a capability nobody approved. `agentsOnly` narrows to `@agents`. + */ +function lookupReaches(model: ThreatModel, query: string, agentsOnly: boolean, actorRef?: string): LookupResult { + const actorKey = actorResolver(model); + const wanted = actorRef ? actorKey(actorRef) : undefined; + const results = (model.reaches || []) + .filter(r => (!agentsOnly || r.agent) && (!wanted || actorKey(r.actor) === wanted)) + .map(r => ({ + actor: r.actor, + agent: r.agent, + capability: r.canonical_capability, + capability_as_written: r.capability, + asset: r.asset, + caller_identity: r.identity, + description: r.description, + ...loc(r.location), + })); + return { query, type: agentsOnly ? 'agents' : 'reaches', count: results.length, results }; +} + +/** + * Can minus may: reaches no cited `@entitles` covers, from the one join in + * parser/reach.ts. Each row says why any entitlement for the same actor and + * capability still does not count, so a reviewer can tell "nobody approved + * this" from "an approval exists but is uncited or on another asset". + */ +function lookupUnentitledReaches(model: ThreatModel, query: string, actorRef?: string): LookupResult { + const actorKey = actorResolver(model); + const wanted = actorRef ? actorKey(actorRef) : undefined; + const results = findUnentitledReaches(model) + .filter(u => !wanted || actorKey(u.reach.actor) === wanted) + .map(({ reach: r, near_misses }) => ({ + actor: r.actor, + agent: r.agent, + capability: r.canonical_capability, + asset: r.asset, + caller_identity: r.identity, + description: r.description, + ...loc(r.location), + near_misses: near_misses.map(n => ({ + blocker: n.blocker, + asset: n.entitlement.asset, + file: n.entitlement.location.file, + line: n.entitlement.location.line, + })), + })); + return { query, type: 'unentitled_reaches', count: results.length, results }; +} + function lookupConfirmed(model: ThreatModel, query: string): LookupResult { const results = (model.confirmed || []).map(c => ({ asset: c.asset, @@ -599,6 +707,8 @@ function assetRelationRefs(model: ThreatModel): string[] { ...model.assumptions.map(a => a.asset), ...model.validations.map(v => v.asset), ...model.transfers.flatMap(t => [t.source, t.target]), + ...(model.effects || []).map(e => e.asset), + ...(model.gates || []).map(g => g.asset), ]; } diff --git a/src/mcp/server.ts b/src/mcp/server.ts index f7f8b4b9..7b728426 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -64,7 +64,7 @@ import { z } from 'zod'; // MERGE: main added the entitlement validators and the proposal module; ours // kept `crossRepoTag` (D19). Union — main's list had dropped crossRepoTag only // because it branched before D19 landed. -import { parseProject, findDanglingRefs, findUnmitigatedExposures, findUndeclaredActors, findInertEntitlements, findImpreciseEntitlements, findUnresolvedBoundarySides, clearAnnotations, applyAnnotations, findAnchorDrift, applyReanchor, crossRepoTag } from '../parser/index.js'; +import { parseProject, findDanglingRefs, findUnmitigatedExposures, findUndeclaredActors, findAgentReachConflicts, findInertEntitlements, findImpreciseEntitlements, findUnresolvedBoundarySides, clearAnnotations, applyAnnotations, findAnchorDrift, applyReanchor, crossRepoTag } from '../parser/index.js'; import { fingerprintProject } from '../parser/fingerprint.js'; import { readAcceptancePolicy, acceptanceBlastRadius, formatBlastRadius, ACCEPTANCE_REGISTER_ID, ACCEPTANCE_REGISTER_NOTE } from '../parser/acceptance.js'; import { buildEnvelope, degradedEnvelope, envelopeBlock } from './freshness.js'; @@ -363,6 +363,8 @@ export function createServer(): McpServer { // Entitlement checks: undeclared actor is an error, an uncited (inert) // entitlement is a warning — it parses but can never demote a finding. const actorDiags = findUndeclaredActors(model); + // One actor under both @agents and @reaches is an error. + const agentReachDiags = findAgentReachConflicts(model); const inertDiags = findInertEntitlements(model); const impreciseDiags = findImpreciseEntitlements(model); // §3.6: an @entitles no human accepted. Only checked where the project has @@ -379,7 +381,7 @@ export function createServer(): McpServer { warnings: boundaryDiags.map(d => ({ file: d.file, line: d.line, code: d.code, message: d.message })), }; } - const allDiags = [...diagnostics, ...danglingDiags, ...boundarySideDiags, ...actorDiags, ...inertDiags, ...impreciseDiags, ...provenanceDiags, ...boundaryDiags]; + const allDiags = [...diagnostics, ...danglingDiags, ...boundarySideDiags, ...actorDiags, ...agentReachDiags, ...inertDiags, ...impreciseDiags, ...provenanceDiags, ...boundaryDiags]; const errors = allDiags.filter(d => d.level === 'error'); const warnings = allDiags.filter(d => d.level === 'warning'); @@ -473,10 +475,10 @@ export function createServer(): McpServer { registerTool( server, cache, 'guardlink_lookup', - 'Query the threat model graph. Reaches every relation type the model carries: assets, threats, controls, mitigations, exposures, confirmed, acceptances, transfers, flows, boundaries, validations, audits, ownership, data classification, assumptions, actors, entitlements, shields, features, comments and cross-repo refs. Examples: "threats for #auth", "owner of #api", "handles pii", "assumptions for #api", "flows into Scanner", "unmitigated", "actors", "entitlements for #ns-admin". A query that is not one of the supported forms returns no_match listing them — it is never answered by guesswork.', + 'Query the threat model graph. Reaches every relation type the model carries: assets, threats, controls, mitigations, exposures, confirmed, acceptances, transfers, flows, boundaries, validations, audits, ownership, data classification, assumptions, actors, entitlements, reaches (@agents/@reaches), effects, gates, shields, features, comments and cross-repo refs. Examples: "threats for #auth", "owner of #api", "handles pii", "assumptions for #api", "flows into Scanner", "unmitigated", "actors", "entitlements for #ns-admin", "unentitled reaches", "effects for #db". A query that is not one of the supported forms returns no_match listing them — it is never answered by guesswork.', { root: z.string().describe('Project root directory').default('.'), - query: z.string().describe('A supported query form: "unmitigated", "confirmed", "features", "asset ", "threat ", "control ", "threats for ", "controls for ", "exposures for ", "mitigations for ", "flows into ", "flows from ", "boundary for ", "owner of ", "handles ", "handles for ", "assumptions for ", "audits [for ]", "validations for ", "acceptances [for ]", "transfers [for ]", "actors", "entitlements [for ]", "comments [for ]", "shields [for ]", or a bare identifier. TWO DIFFERENT REF QUERIES, do not confuse them: "cwe:CWE-89" / "CWE-89" / "owasp:A03" asks about external identifiers declared on threats — the scanner bridge, and returns external_id.declared so you can tell \'never heard of this weakness\' from \'declared, nothing exposed\'; "cross-repo refs" asks about sibling-repo tags from workspace.yaml and is unrelated. Every entitlements row carries inert: an uncited claim is carried and visible but cannot demote a finding (actor-entitlement design §3.4). @comment and @shield record no asset, so scoping them by an asset joins by co-location (same file) and the result says so. Free-form questions are not parsed.'), + query: z.string().describe('A supported query form: "unmitigated", "confirmed", "features", "asset ", "threat ", "control ", "threats for ", "controls for ", "exposures for ", "mitigations for ", "flows into ", "flows from ", "boundary for ", "owner of ", "handles ", "handles for ", "assumptions for ", "audits [for ]", "validations for ", "acceptances [for ]", "transfers [for ]", "actors", "entitlements [for ]", "reaches [for ]", "agents [for ]", "unentitled reaches [for ]", "effects [for ]", "gates [for ]", "comments [for ]", "shields [for ]", or a bare identifier. TWO DIFFERENT REF QUERIES, do not confuse them: "cwe:CWE-89" / "CWE-89" / "owasp:A03" asks about external identifiers declared on threats — the scanner bridge, and returns external_id.declared so you can tell \'never heard of this weakness\' from \'declared, nothing exposed\'; "cross-repo refs" asks about sibling-repo tags from workspace.yaml and is unrelated. Every entitlements row carries inert: an uncited claim is carried and visible but cannot demote a finding (actor-entitlement design §3.4). "unentitled reaches" is can minus may: each @agents/@reaches capability no cited @entitles for the same actor and capability (and the same asset, when the reach names one) covers, with near_misses saying why an entitlement that almost matched does not count. @comment and @shield record no asset, so scoping them by an asset joins by co-location (same file) and the result says so. Free-form questions are not parsed.'), }, async ({ root, query }) => { const { model } = await getModel(root); diff --git a/src/mcp/subgraph.ts b/src/mcp/subgraph.ts index 22e34822..44cd9ada 100644 --- a/src/mcp/subgraph.ts +++ b/src/mcp/subgraph.ts @@ -61,7 +61,8 @@ export type Direction = 'in' | 'out' | 'both'; export const SELECTABLE_KINDS = [ 'exposures', 'mitigations', 'confirmed', 'acceptances', 'transfers', 'flows', 'boundaries', 'validations', 'audits', 'ownership', - 'data_handling', 'assumptions', 'entitlements', 'comments', 'shields', 'features', + 'data_handling', 'assumptions', 'entitlements', 'reaches', 'effects', 'gates', + 'comments', 'shields', 'features', ] as const; export type SelectableKind = typeof SELECTABLE_KINDS[number]; @@ -382,6 +383,7 @@ export function selectSubgraph(model: ThreatModel, options: SubgraphOptions = {} audits: inFile(base.audits), ownership: inFile(base.ownership), data_handling: inFile(base.data_handling), assumptions: inFile(base.assumptions), actors: inFile(base.actors || []), entitlements: inFile(base.entitlements || []), + reaches: inFile(base.reaches || []), effects: inFile(base.effects || []), gates: inFile(base.gates || []), shields: inFile(base.shields), features: inFile(base.features), comments: inFile(base.comments), }; } @@ -417,6 +419,11 @@ export function selectSubgraph(model: ThreatModel, options: SubgraphOptions = {} // subgraph and is kept only when no selection narrows the graph. const entitlements = pick('entitlements', (base.entitlements || []) .filter(en => (en.asset ? inSet(en.asset) : selected === null))); + // A reach scopes by its `on ` clause exactly as an entitlement does. + const reaches = pick('reaches', (base.reaches || []) + .filter(r => (r.asset ? inSet(r.asset) : selected === null))); + const effects = pick('effects', (base.effects || []).filter(e => inSet(e.asset))); + const gates = pick('gates', (base.gates || []).filter(g => inSet(g.asset))); // Node definitions are always kept: an edge whose endpoint has no declaration // renders as a bare id and reads as missing data rather than as a filter. @@ -437,7 +444,8 @@ export function selectSubgraph(model: ThreatModel, options: SubgraphOptions = {} const files = new Set(); for (const row of [...assets, ...threats, ...controls, ...exposures, ...mitigations, ...confirmed, ...acceptances, ...transfers, ...flows, ...boundaries, ...validations, - ...audits, ...ownership, ...dataHandling, ...assumptions, ...entitlements]) files.add(row.location.file); + ...audits, ...ownership, ...dataHandling, ...assumptions, ...entitlements, + ...reaches, ...effects, ...gates]) files.add(row.location.file); const byFile = (rows: T[]) => rows.filter(r => files.has(r.location.file)); const comments = pick('comments', byFile(base.comments)); @@ -447,12 +455,16 @@ export function selectSubgraph(model: ThreatModel, options: SubgraphOptions = {} // An actor is a definition, so it follows the same always-kept rule as // @asset/@threat/@control: dropping it would leave entitlements naming a // principal with no declaration, which reads as missing data, not as a filter. - const actors = (base.actors || []).filter(ac => - entitlements.some(en => bare(en.actor) === bare(ac.id || ac.canonical_name))); + const actors = (base.actors || []).filter(ac => { + const id = bare(ac.id || ac.canonical_name); + return entitlements.some(en => bare(en.actor) === id) + || reaches.some(r => bare(r.actor) === id) + || gates.some(g => bare(g.approver) === id); + }); const arrays = [assets, threats, controls, actors, mitigations, exposures, confirmed, acceptances, transfers, flows, boundaries, validations, audits, ownership, - dataHandling, assumptions, entitlements, shields, features, comments]; + dataHandling, assumptions, entitlements, reaches, effects, gates, shields, features, comments]; return { ...base, @@ -472,7 +484,8 @@ export function selectSubgraph(model: ThreatModel, options: SubgraphOptions = {} unannotated_files: base.unannotated_files.filter(f => files.has(f)), assets, threats, controls, actors, mitigations, exposures, confirmed, acceptances, transfers, flows, boundaries, validations, audits, ownership, - data_handling: dataHandling, assumptions, entitlements, shields, features, comments, + data_handling: dataHandling, assumptions, entitlements, reaches, effects, gates, + shields, features, comments, }; } diff --git a/src/parser/annotation-hash.ts b/src/parser/annotation-hash.ts index f2878268..20bd4ca6 100644 --- a/src/parser/annotation-hash.ts +++ b/src/parser/annotation-hash.ts @@ -162,6 +162,17 @@ export function canonicalAnnotationRecords(model: ThreatModel): string[] { for (const en of model.entitlements ?? []) { out.push(record('entitles', s(en.actor), s(en.canonical_capability), s(en.asset), s(en.threat), s(en.description), f(en.location.file))); } + // Reach, effect and gate records exist only where those verbs are written, so + // a model without them hashes exactly as it did before they existed. + for (const r of model.reaches ?? []) { + out.push(record(r.agent ? 'agents' : 'reaches', s(r.actor), s(r.canonical_capability), s(r.asset), s(r.identity), s(r.description), f(r.location.file))); + } + for (const ef of model.effects ?? []) { + out.push(record('effects', s(ef.effect), s(ef.asset), s(ef.identity), s(ef.description), f(ef.location.file))); + } + for (const g of model.gates ?? []) { + out.push(record('gates', s(g.asset), s(g.approver), s(g.canonical_capability), s(g.description), f(g.location.file))); + } for (const a of model.assumptions ?? []) { out.push(record('assumes', s(a.asset), s(a.description), f(a.location.file))); } diff --git a/src/parser/apply-annotations.ts b/src/parser/apply-annotations.ts index fa8f042b..fa16a1c5 100644 --- a/src/parser/apply-annotations.ts +++ b/src/parser/apply-annotations.ts @@ -14,6 +14,10 @@ * bypass the propose/accept ledger in src/review/entitlements.ts entirely, which * is the gate that makes an entitlement carry a human's name (design §3.6). * + * `@agents`, `@reaches`, `@effects` and `@gates` are written like any other + * relation. They record what the code can do, not what anyone decided it may, + * so an agent reading a tool registration is exactly who should write them. + * * @exposes #parser to #arbitrary-write [high] cwe:CWE-73 -- "Writes annotation sidecars from tool input" * @mitigates #parser against #arbitrary-write using #path-validation -- "Target is always resolveGalPath(root, file); the caller cannot choose the path, and a file escaping root is rejected" * @exposes #parser to #insecure-deser [low] cwe:CWE-20 -- "Annotation lines arrive as caller-supplied text" diff --git a/src/parser/claim-key.ts b/src/parser/claim-key.ts index 12e2217f..25f14cd7 100644 --- a/src/parser/claim-key.ts +++ b/src/parser/claim-key.ts @@ -26,11 +26,13 @@ import type { ThreatModelTransfer, ThreatModelFlow, ThreatModelBoundary, ThreatModelValidation, ThreatModelAudit, ThreatModelOwnership, ThreatModelDataHandling, ThreatModelAssumption, ThreatModelFeature, ThreatModelComment, ThreatModelEntitlement, + ThreatModelReach, ThreatModelEffect, ThreatModelGate, } from '../types/index.js'; export type ClaimVerb = | 'mitigates' | 'exposes' | 'confirmed' | 'accepts' | 'transfers' | 'flows' | 'boundary' - | 'validates' | 'audit' | 'owns' | 'handles' | 'assumes' | 'feature' | 'comment' | 'entitles'; + | 'validates' | 'audit' | 'owns' | 'handles' | 'assumes' | 'feature' | 'comment' | 'entitles' + | 'agents' | 'reaches' | 'effects' | 'gates'; /** Verbs whose staleness can hide an exposure: the two that remove one from the export. */ export const DEMOTABLE_VERBS: ReadonlySet = new Set(['mitigates', 'accepts']); @@ -112,7 +114,8 @@ type Rec = | ['accepts', ThreatModelAcceptance] | ['transfers', ThreatModelTransfer] | ['flows', ThreatModelFlow] | ['boundary', ThreatModelBoundary] | ['validates', ThreatModelValidation] | ['audit', ThreatModelAudit] | ['owns', ThreatModelOwnership] | ['handles', ThreatModelDataHandling] | ['assumes', ThreatModelAssumption] - | ['feature', ThreatModelFeature] | ['comment', ThreatModelComment] | ['entitles', ThreatModelEntitlement]; + | ['feature', ThreatModelFeature] | ['comment', ThreatModelComment] | ['entitles', ThreatModelEntitlement] + | ['agents', ThreatModelReach] | ['reaches', ThreatModelReach] | ['effects', ThreatModelEffect] | ['gates', ThreatModelGate]; /** Identity fields per verb, in a fixed order. Description last, file after that. */ function identity([verb, r]: Rec): string[] { @@ -139,6 +142,10 @@ function identity([verb, r]: Rec): string[] { case 'feature': return [s(r.feature)]; case 'comment': return []; case 'entitles': return [s(r.actor), s(r.capability), s(r.asset), s(r.threat)]; + case 'agents': + case 'reaches': return [s(r.actor), s(r.capability), s(r.asset), s(r.identity)]; + case 'effects': return [s(r.effect), s(r.asset), s(r.identity)]; + case 'gates': return [s(r.asset), s(r.approver), s(r.capability)]; } } @@ -161,6 +168,10 @@ export function claimText(rec: Rec): string { case 'feature': return `"${r.feature}"`; case 'comment': return `"${(r.description ?? '').slice(0, 60)}"`; case 'entitles': return `${r.actor} to ${r.capability}${r.asset ? ` on ${r.asset}` : ''}${r.threat ? ` against ${r.threat}` : ''}`; + case 'agents': + case 'reaches': return `${r.actor} to ${r.capability}${r.asset ? ` on ${r.asset}` : ''}${r.identity ? ` as ${r.identity}` : ''}`; + case 'effects': return `${r.effect} on ${r.asset}${r.identity ? ` as ${r.identity}` : ''}`; + case 'gates': return `${r.asset} by ${r.approver}${r.capability ? ` for ${r.capability}` : ''}`; } } @@ -192,6 +203,9 @@ function allRecords(model: ThreatModel): Rec[] { for (const r of model.features ?? []) out.push(['feature', r]); for (const r of model.comments ?? []) out.push(['comment', r]); for (const r of model.entitlements ?? []) out.push(['entitles', r]); + for (const r of model.reaches ?? []) out.push([r.agent ? 'agents' : 'reaches', r]); + for (const r of model.effects ?? []) out.push(['effects', r]); + for (const r of model.gates ?? []) out.push(['gates', r]); return out; } diff --git a/src/parser/clear.ts b/src/parser/clear.ts index 97fc6751..bdb18895 100644 --- a/src/parser/clear.ts +++ b/src/parser/clear.ts @@ -28,6 +28,7 @@ const GUARDLINK_VERBS = new Set([ 'asset', 'threat', 'control', 'actor', 'mitigates', 'exposes', 'confirmed', 'accepts', 'entitles', 'transfers', 'flows', 'boundary', 'validates', 'audit', 'owns', 'handles', 'assumes', + 'agents', 'reaches', 'effects', 'gates', 'comment', 'source', 'shield', 'shield:begin', 'shield:end', // v1 compat 'review', 'connects', diff --git a/src/parser/feature-filter.ts b/src/parser/feature-filter.ts index c60c3cee..483e37b0 100644 --- a/src/parser/feature-filter.ts +++ b/src/parser/feature-filter.ts @@ -132,6 +132,9 @@ export function filterByFeature(model: ThreatModel, featureNames: string[]): Thr const dataHandling = inFeature(model.data_handling); const assumptions = inFeature(model.assumptions); const entitlements = inFeature(model.entitlements || []); + const reaches = inFeature(model.reaches || []); + const effects = inFeature(model.effects || []); + const gates = inFeature(model.gates || []); // ── Definitions: resolved from what the kept relations reference ── // @@ -154,6 +157,9 @@ export function filterByFeature(model: ThreatModel, featureNames: string[]): Thr for (const t of transfers) { assetRefs.add(bare(t.source)); assetRefs.add(bare(t.target)); } for (const b of boundaries) { assetRefs.add(bare(b.asset_a)); assetRefs.add(bare(b.asset_b)); } for (const en of entitlements) if (en.asset) assetRefs.add(bare(en.asset)); + for (const r of reaches) for (const ref of [r.asset, r.identity]) if (ref) assetRefs.add(bare(ref)); + for (const ef of effects) for (const ref of [ef.asset, ef.identity]) if (ref) assetRefs.add(bare(ref)); + for (const g of gates) assetRefs.add(bare(g.asset)); const threatRefs = new Set(); for (const r of [...exposures, ...mitigations, ...confirmed, ...acceptances, ...transfers]) threatRefs.add(bare(r.threat)); @@ -165,6 +171,8 @@ export function filterByFeature(model: ThreatModel, featureNames: string[]): Thr const actorRefs = new Set(); for (const en of entitlements) actorRefs.add(bare(en.actor)); + for (const r of reaches) actorRefs.add(bare(r.actor)); + for (const g of gates) actorRefs.add(bare(g.approver)); const assets = model.assets.filter(a => assetRefs.has(bare(a.id || '')) || assetRefs.has(bare(a.path.join('.')))); @@ -232,6 +240,9 @@ export function filterByFeature(model: ThreatModel, featureNames: string[]): Thr controls, actors, entitlements, + reaches, + effects, + gates, mitigations, exposures, confirmed, diff --git a/src/parser/index.ts b/src/parser/index.ts index b7c3e25f..7337a187 100644 --- a/src/parser/index.ts +++ b/src/parser/index.ts @@ -14,7 +14,7 @@ export { canEntitlementDemote, entitlementDemotionBlockers } from './parse-proje export { parseLine, crossRepoTag, CROSS_REPO_TAG_PATTERN } from './parse-line.js'; export { normalizeName, resolveSeverity, unescapeDescription } from './normalize.js'; export { stripCommentPrefix, commentStyleForExt } from './comment-strip.js'; -export { findDanglingRefs, findUnmitigatedExposures, findAcceptedWithoutAudit, findAcceptedExposures, findUndeclaredActors, findInertEntitlements, findImpreciseEntitlements, findOffConventionGalFiles, findUnresolvedBoundarySides } from './validate.js'; +export { findDanglingRefs, findUnmitigatedExposures, findAcceptedWithoutAudit, findAcceptedExposures, findUndeclaredActors, findAgentReachConflicts, findInertEntitlements, findImpreciseEntitlements, findOffConventionGalFiles, findUnresolvedBoundarySides } from './validate.js'; // What an acceptance has to be before it counts as one. One implementation, // shared by the gate that re-checks acceptances and the writer that creates them. export { @@ -53,6 +53,7 @@ export { migrateAnnotationMode, readGalBlocks } from './migrate-mode.js'; export type { MigrateOptions, MigrateResult, TargetMode } from './migrate-mode.js'; // Stale claim detection (docs/superpowers/specs/2026-09-03-stale-claim-detection-design.md). export { relationRecords, claimText, DEMOTABLE_VERBS } from './claim-key.js'; +export { findUnentitledReaches, actorResolver, reachKey, type UnentitledReach, type ReachCoverBlocker } from './reach.js'; export type { ClaimSource, ClaimVerb } from './claim-key.js'; export { readLedger, writeLedger, serializeLedger, emptyLedger, LEDGER_FILE, LEDGER_SCHEMA } from './ledger.js'; export type { Ledger, LedgerEntry, LedgerRead, LedgerStatus } from './ledger.js'; diff --git a/src/parser/parse-line.ts b/src/parser/parse-line.ts index d858d0f6..07d22919 100644 --- a/src/parser/parse-line.ts +++ b/src/parser/parse-line.ts @@ -7,10 +7,12 @@ * @comment -- "Regex patterns designed with bounded quantifiers and explicit structure" * @comment -- "@entitles capability is a single-token identifier by grammar, so prose in that position is a parse error rather than a label nothing can group or compare (actor-entitlement design §3.1)" * @comment -- "@entitles takes optional `on ` and `against ` clauses because the join is the (actor, asset, threat) triple, not the capability — a capability-keyed join would demote every threat on the asset including one discovered later (actor-entitlement design §9.3)" + * @mitigates #parser against #redos using #regex-anchoring -- "@agents, @reaches, @effects and @gates reuse the anchored ASSET_REF, THREAT_REF, CAPABILITY and DESC fragments and add only a closed alternation for the effect class" + * @comment -- "@effects takes a closed effect set, like @handles takes a closed classification set: an open string cannot be compared across repositories or drive a query" */ import type { - Annotation, DataClassification, + Annotation, DataClassification, EffectClass, ParseDiagnostic, SourceLocation, } from '../types/index.js'; import { normalizeName, resolveSeverity, unescapeDescription } from './normalize.js'; @@ -80,6 +82,12 @@ const THREAT_REF = String.raw`(?:${TAG_REF}|${QUOTED_REF}|[A-Za-z]\w*(?:[_\- ][A // Keeping it single-token is still what makes it citable and comparable rather // than a sentence nobody can match on. const CAPABILITY = String.raw`[A-Za-z][A-Za-z0-9_.\-]*`; +/** + * The closed `@effects` set (SPEC §3.2). Closed like the `@handles` + * classifications, and for the same reason. Egress is deliberately absent: + * `@flows … -> External.X` already says it. + */ +const EFFECT = String.raw`(read|write|delete|execute|spend|notify)`; /** * The name in `@accepts … by ` — a person, not prose. * @@ -128,6 +136,17 @@ const PATTERNS: Record = { // harmless because it demotes nothing, whereas making it a parse error would // reject a claim a reviewer should get to read. entitles: new RegExp(String.raw`^@entitles\s+(${THREAT_REF})\s+to\s+(${CAPABILITY})(?:\s+on\s+(${ASSET_REF}))?(?:\s+against\s+(${THREAT_REF}))?(?:\s+${DESC})?$`), + // `@agents` and `@reaches` share @entitles' actor and capability positions, so + // a reach and an entitlement for one capability are written with one token + // and join on it. `as ` is the identity the caller presents. + agents: new RegExp(String.raw`^@agents\s+(${THREAT_REF})\s+to\s+(${CAPABILITY})(?:\s+on\s+(${ASSET_REF}))?(?:\s+as\s+(${ASSET_REF}))?(?:\s+${DESC})?$`), + reaches: new RegExp(String.raw`^@reaches\s+(${THREAT_REF})\s+to\s+(${CAPABILITY})(?:\s+on\s+(${ASSET_REF}))?(?:\s+as\s+(${ASSET_REF}))?(?:\s+${DESC})?$`), + // `as ` here is the identity the effect executes under. + effects: new RegExp(String.raw`^@effects\s+${EFFECT}\s+on\s+(${ASSET_REF})(?:\s+as\s+(${ASSET_REF}))?(?:\s+${DESC})?$`), + // `by ` is required: a gate with no named approver would let "something + // happens here" read as "someone decides here", which is the one thing the + // annotation exists to tell apart. + gates: new RegExp(String.raw`^@gates\s+(${ASSET_REF})\s+by\s+(${THREAT_REF})(?:\s+for\s+(${CAPABILITY}))?(?:\s+${DESC})?$`), transfers: new RegExp(String.raw`^@transfers\s+(${THREAT_REF})\s+from\s+(${ASSET_REF})\s+to\s+(${ASSET_REF})(?:\s+${DESC})?$`), // `via` must be followed by a mechanism: `via -- "d"` used to record `-- "d"` // as the mechanism and lose the description. SPEC §3.2 `@flows`, Mechanism. @@ -338,6 +357,42 @@ export function parseLine( }); } + // ── @agents / @reaches ── + // One shape, two verbs: which one was written is what says whether the actor + // is an LLM agent. Refs resolve like @entitles' so the two join on the same + // spelling of actor and capability. + if ((m = trimmed.match(PATTERNS.agents)) || (m = trimmed.match(PATTERNS.reaches))) { + const capability = m[2]; + return ok({ + ...base, verb: trimmed.startsWith('@agents') ? 'agents' : 'reaches', + actor: resolveRef(m[1]), + capability, canonical_capability: normalizeName(capability), + asset: m[3] ? resolveRef(m[3]) : undefined, + identity: m[4] ? resolveRef(m[4]) : undefined, + description: desc(m[5]), + }); + } + + // ── @effects ── + if ((m = trimmed.match(PATTERNS.effects))) { + return ok({ + ...base, verb: 'effects', effect: m[1] as EffectClass, + asset: resolveRef(m[2]), + identity: m[3] ? resolveRef(m[3]) : undefined, + description: desc(m[4]), + }); + } + + // ── @gates ── + if ((m = trimmed.match(PATTERNS.gates))) { + return ok({ + ...base, verb: 'gates', asset: resolveRef(m[1]), approver: resolveRef(m[2]), + capability: m[3] || undefined, + canonical_capability: m[3] ? normalizeName(m[3]) : undefined, + description: desc(m[4]), + }); + } + // ── @transfers ── if ((m = trimmed.match(PATTERNS.transfers))) { return ok({ @@ -554,6 +609,7 @@ function ok(annotation: Annotation): ParseLineResult { const KNOWN_VERBS: ReadonlySet = new Set([ 'asset', 'threat', 'control', 'actor', 'mitigates', 'exposes', 'confirmed', 'accepts', 'entitles', 'transfers', 'flows', 'boundary', 'validates', 'audit', 'owns', + 'agents', 'reaches', 'effects', 'gates', 'handles', 'assumes', 'feature', 'source', 'comment', 'shield', 'shield:begin', 'shield:end', // v1 compat 'review', 'connects', @@ -741,6 +797,12 @@ const VERB_KEYWORDS: Readonly> = { // other verb errors — D29's split has to cover the whole verb table or it // silently weakens for whichever verbs were added last. entitles: ['to', 'on', 'against'], + // `on` and `as` are common English, so the reach verbs count only `to`, the + // keyword their grammar cannot do without. A malformed reach almost always + // carries a `#ref` too. `@gates` has no entry for the reason `by` is absent + // from `@accepts`: its one keyword is among the commonest words in English. + agents: ['to'], + reaches: ['to'], }; /** @@ -754,6 +816,9 @@ const VERB_KEYWORDS: Readonly> = { */ const LEADING_KEYWORDS: Readonly> = { boundary: /^from\b/, + // `@effects` opens with one of six effect words, which prose about the verb + // ("@effects on the model are…") does not. + effects: /^(?:read|write|delete|execute|spend|notify)\b/, }; /** diff --git a/src/parser/parse-project.ts b/src/parser/parse-project.ts index da910eb9..38c0120b 100644 --- a/src/parser/parse-project.ts +++ b/src/parser/parse-project.ts @@ -22,7 +22,8 @@ import type { Annotation, ThreatModel, ParseDiagnostic, AssetAnnotation, ThreatAnnotation, ControlAnnotation, ActorAnnotation, MitigatesAnnotation, ExposesAnnotation, ConfirmedAnnotation, AcceptsAnnotation, - EntitlesAnnotation, TransfersAnnotation, FlowsAnnotation, BoundaryAnnotation, + EntitlesAnnotation, ReachesAnnotation, EffectsAnnotation, GatesAnnotation, + TransfersAnnotation, FlowsAnnotation, BoundaryAnnotation, ValidatesAnnotation, AuditAnnotation, OwnsAnnotation, HandlesAnnotation, AssumesAnnotation, ShieldAnnotation, FeatureAnnotation, CommentAnnotation, @@ -421,6 +422,9 @@ function assembleModel(annotations: Annotation[], fileCount: number, project: st controls: [], actors: [], entitlements: [], + reaches: [], + effects: [], + gates: [], mitigations: [], exposures: [], confirmed: [], @@ -515,6 +519,36 @@ function assembleModel(annotations: Annotation[], fileCount: number, project: st }); break; } + // Both reach verbs land in one collection: they are one claim about + // two kinds of principal, and the unentitled-reach join spans both. + case 'agents': + case 'reaches': { + const r = ann as ReachesAnnotation; + model.reaches!.push({ + actor: r.actor, agent: r.verb === 'agents', + capability: r.capability, canonical_capability: r.canonical_capability, + asset: r.asset, identity: r.identity, + description: r.description, location: r.location, + }); + break; + } + case 'effects': { + const ef = ann as EffectsAnnotation; + model.effects!.push({ + effect: ef.effect, asset: ef.asset, identity: ef.identity, + description: ef.description, location: ef.location, + }); + break; + } + case 'gates': { + const g = ann as GatesAnnotation; + model.gates!.push({ + asset: g.asset, approver: g.approver, + capability: g.capability, canonical_capability: g.canonical_capability, + description: g.description, location: g.location, + }); + break; + } case 'mitigates': { const m = ann as MitigatesAnnotation; model.mitigations.push({ @@ -819,6 +853,20 @@ function detectExternalRefs(model: ThreatModel, root: string): ExternalRef[] { // the silent miss §9 is about. if (en.threat) checkTag(en.threat, 'entitles', en.location); } + for (const r of model.reaches || []) { + const verb = r.agent ? 'agents' : 'reaches'; + checkTag(r.actor, verb, r.location); + if (r.asset) checkTag(r.asset, verb, r.location); + if (r.identity) checkTag(r.identity, verb, r.location); + } + for (const ef of model.effects || []) { + checkTag(ef.asset, 'effects', ef.location); + if (ef.identity) checkTag(ef.identity, 'effects', ef.location); + } + for (const g of model.gates || []) { + checkTag(g.asset, 'gates', g.location); + checkTag(g.approver, 'gates', g.location); + } return refs; } diff --git a/src/parser/reach.ts b/src/parser/reach.ts new file mode 100644 index 00000000..20ba39d5 --- /dev/null +++ b/src/parser/reach.ts @@ -0,0 +1,119 @@ +/** + * GuardLink — what a principal can reach, against what it may. + * + * `@agents` and `@reaches` say an actor CAN invoke a capability; `@entitles` + * says a human decided it MAY. A reach that no entitlement covers is a + * capability the harness hands out and nobody approved: for an LLM agent that + * is the Excessive Agency list (OWASP LLM06). This module is the one place + * that join is written, so `lookup` and `diff` cannot disagree about it. + * + * ── The join ───────────────────────────────────────────────────────── + * + * An entitlement covers a reach when all of these hold: + * + * - the same actor, after resolving `#id` and declared name to one identity; + * - the same capability, compared on the §2.10-normalised token both verbs + * write in the same position; + * - the entitlement is cited. An uncited (inert) entitlement cannot demote a + * finding (§3.4), so it must not close a reach either; + * - when the reach names an asset, the entitlement names the same asset. A + * grant on one surface says nothing about the same capability on another, + * and an entitlement that names no asset is not read as covering all of + * them. When the reach names no asset, any asset on the entitlement will do. + * + * Every rule errs toward reporting a reach. A reach reported in error costs a + * reviewer a look; a reach hidden in error is the over-grant this exists to find. + * + * Whether an `@entitles` in source has an accepted proposal behind it is not + * checked here: that needs the proposal ledger on disk, this module is pure, + * and `validate` already reports such an entitlement as an error. + * + * @comment -- "Pure functions over an assembled ThreatModel; no I/O. Uncited entitlements never cover a reach, so the can-minus-may answer errs toward showing a capability rather than hiding one" + */ +import type { ThreatModel, ThreatModelReach, ThreatModelEntitlement } from '../types/index.js'; +import { canonicaliser } from './canonical-ref.js'; +import { normalizeName } from './normalize.js'; +import { normalizeRef } from './coverage.js'; + +/** Why an entitlement for the same actor and capability does not cover a reach. */ +export type ReachCoverBlocker = 'uncited' | 'no-asset' | 'other-asset'; + +export interface UnentitledReach { + reach: ThreatModelReach; + /** + * Entitlements naming this actor and capability that still do not cover the + * reach, each with the reason. Empty when no entitlement names the pair at all. + */ + near_misses: { entitlement: ThreatModelEntitlement; blocker: ReachCoverBlocker }[]; +} + +/** + * One identity per actor reference: `#support-agent`, `support-agent` and the + * declared name `Support_Agent` all resolve to the declared actor's id. An + * undeclared ref resolves to its bare lowercase form, which `validate` reports. + */ +export function actorResolver(model: ThreatModel): (ref: string) => string { + const canon = new Map(); + for (const ac of model.actors ?? []) { + const identity = (ac.id ?? ac.canonical_name).toLowerCase(); + if (ac.id) canon.set(ac.id.toLowerCase(), identity); + canon.set(ac.canonical_name.toLowerCase(), identity); + } + return (ref: string) => { + const bare = normalizeRef(ref); + return canon.get(bare) ?? canon.get(normalizeName(bare)) ?? bare; + }; +} + +/** + * Why `entitlement` does not cover `reach`, or null when it does. Callers have + * already matched actor and capability. + */ +function coverBlocker( + reach: ThreatModelReach, + entitlement: ThreatModelEntitlement, + assetKey: (ref: string) => string, +): ReachCoverBlocker | null { + if (entitlement.inert) return 'uncited'; + if (!reach.asset) return null; + if (!entitlement.asset) return 'no-asset'; + return assetKey(entitlement.asset) === assetKey(reach.asset) ? null : 'other-asset'; +} + +/** + * Every `@agents` / `@reaches` claim no `@entitles` covers, in model order. + * See the module note for the join. + */ +export function findUnentitledReaches(model: ThreatModel): UnentitledReach[] { + const actorKey = actorResolver(model); + const assetKey = canonicaliser(model); + const byPair = new Map(); + for (const en of model.entitlements ?? []) { + const pair = `${actorKey(en.actor)}::${en.canonical_capability}`; + byPair.set(pair, [...(byPair.get(pair) ?? []), en]); + } + + const out: UnentitledReach[] = []; + for (const reach of model.reaches ?? []) { + const candidates = byPair.get(`${actorKey(reach.actor)}::${reach.canonical_capability}`) ?? []; + const near_misses: UnentitledReach['near_misses'] = []; + let covered = false; + for (const entitlement of candidates) { + const blocker = coverBlocker(reach, entitlement, assetKey); + if (!blocker) { covered = true; break; } + near_misses.push({ entitlement, blocker }); + } + if (!covered) out.push({ reach, near_misses }); + } + return out; +} + +/** + * Identity of a reach for comparing two models: actor, capability and asset. + * Used by `diff` to tell a newly unentitled reach from one that already was. + */ +export function reachKey(model: ThreatModel): (reach: ThreatModelReach) => string { + const actorKey = actorResolver(model); + const assetKey = canonicaliser(model); + return (r: ThreatModelReach) => `${actorKey(r.actor)}::${r.canonical_capability}::${r.asset ? assetKey(r.asset) : ''}`; +} diff --git a/src/parser/validate.ts b/src/parser/validate.ts index 250441b4..29bbea03 100644 --- a/src/parser/validate.ts +++ b/src/parser/validate.ts @@ -8,6 +8,7 @@ * @comment -- "@confirmed refs validated same as @exposes for asset and threat" * @comment -- "findUnresolvedBoundarySides errors on a directed @boundary whose outer or inner side names no declared asset and no @flows endpoint, so a declared direction cannot point consumers at nothing" * @comment -- "findUndeclaredActors / findInertEntitlements implement the two mechanical @entitles checks from docs/prd/actor-entitlement-design.md §3.7 — both typo-class; entitlement intent is not machine-checkable" + * @comment -- "findUndeclaredActors also covers the actor on @agents/@reaches and the approver on @gates; findAgentReachConflicts errors when one actor is written under both @agents and @reaches, because which verb names an actor is what says whether it is an agent" */ import { @@ -20,6 +21,7 @@ import type { ThreatModel, ParseDiagnostic, SourceLocation } from '../types/inde import { normalizeName } from './normalize.js'; import { canonicaliser } from './canonical-ref.js'; import { entitlementDemotionBlockers } from './parse-project.js'; +import { actorResolver } from './reach.js'; /** * Find all dangling #id references in the threat model. @@ -95,6 +97,18 @@ export function findDanglingRefs(model: ThreatModel): ParseDiagnostic[] { if (en.threat) checkRef(en.threat, en.location); } + // Reach, effect and gate. Actors (the reaching actor, the approver) are + // checked by findUndeclaredActors, as on @entitles; assets and identities here. + for (const r of model.reaches || []) { + if (r.asset) checkRef(r.asset, r.location); + if (r.identity) checkRef(r.identity, r.location); + } + for (const ef of model.effects || []) { + checkRef(ef.asset, ef.location); + if (ef.identity) checkRef(ef.identity, ef.location); + } + for (const g of model.gates || []) checkRef(g.asset, g.location); + // Lifecycle annotations — check asset refs for (const v of model.validations) { checkRef(v.control, v.location); @@ -174,7 +188,8 @@ export { findUnmitigatedExposures, findAcceptedExposures, normalizeRef } from '. import { normalizeRef } from './coverage.js'; /** - * Find @entitles annotations naming an actor that was never declared with @actor. + * Find annotations naming an actor that was never declared with @actor: the + * actor on `@entitles`, `@agents` and `@reaches`, and the approver on `@gates`. * * This is one of the two mechanical checks §3.7 of the actor/entitlement design * calls for — both are typo-class. Neither can verify *intent*: whether an actor @@ -182,7 +197,9 @@ import { normalizeRef } from './coverage.js'; * can derive from the code. It is an error rather than a warning because an * entitlement pointing at a non-existent principal can never be joined * downstream, so it is silently inoperative — the failure mode this design is - * built to avoid. + * built to avoid. The same holds for a reach: one naming a misspelt actor joins + * no entitlement and reads as unentitled for a principal that does not exist, + * and a gate whose approver is nobody is not a gate. */ export function findUndeclaredActors(model: ThreatModel): ParseDiagnostic[] { const diagnostics: ParseDiagnostic[] = []; @@ -193,18 +210,57 @@ export function findUndeclaredActors(model: ThreatModel): ParseDiagnostic[] { declared.add(ac.canonical_name); } - for (const en of model.entitlements || []) { - const bare = normalizeRef(en.actor); - if (declared.has(bare) || declared.has(normalizeName(bare))) continue; + const check = (actor: string, claim: string, location: SourceLocation) => { + const bare = normalizeRef(actor); + if (declared.has(bare) || declared.has(normalizeName(bare))) return; diagnostics.push({ level: 'error', code: 'undeclared-actor', - message: `@entitles names actor ${en.actor} which is never declared with @actor`, - file: en.location.file, - line: en.location.line, + message: `${claim} names actor ${actor} which is never declared with @actor`, + file: location.file, + line: location.line, }); + }; + + for (const en of model.entitlements || []) check(en.actor, '@entitles', en.location); + for (const r of model.reaches || []) check(r.actor, r.agent ? '@agents' : '@reaches', r.location); + for (const g of model.gates || []) check(g.approver, '@gates', g.location); + + return diagnostics; +} + +/** + * Find actors named under both `@agents` and `@reaches`. + * + * Writing `@agents` about an actor is what marks it as an LLM agent; `@reaches` + * is the same claim for every other principal. An actor under both verbs is + * therefore both an agent and not one, and every check that keys on agents + * (the injection-to-tool routes among them) would answer differently depending + * on which line it read. It is an error, reported on each `@reaches` line, + * because the fix is to choose one verb for the actor. + */ +export function findAgentReachConflicts(model: ThreatModel): ParseDiagnostic[] { + const actorKey = actorResolver(model); + const agentLines = new Map(); + for (const r of model.reaches || []) { + const key = actorKey(r.actor); + if (r.agent && !agentLines.has(key)) agentLines.set(key, r.location); } + const diagnostics: ParseDiagnostic[] = []; + for (const r of model.reaches || []) { + if (r.agent) continue; + const agentAt = agentLines.get(actorKey(r.actor)); + if (!agentAt) continue; + diagnostics.push({ + level: 'error', + code: 'agent-reach-conflict', + message: `@reaches names ${r.actor}, which @agents marks as an agent at ${agentAt.file}:${agentAt.line}. ` + + 'An actor is an agent or it is not: write @agents for an LLM agent and @reaches for every other principal.', + file: r.location.file, + line: r.location.line, + }); + } return diagnostics; } diff --git a/src/tui/commands.ts b/src/tui/commands.ts index bee6dd2f..55d3c093 100644 --- a/src/tui/commands.ts +++ b/src/tui/commands.ts @@ -23,7 +23,7 @@ import { resolve, basename } from 'node:path'; import { readFileSync, existsSync, writeFileSync, mkdirSync } from 'node:fs'; -import { parseProject, findDanglingRefs, findUnmitigatedExposures, findAcceptedWithoutAudit, findAcceptedExposures, findUndeclaredActors, findInertEntitlements, findImpreciseEntitlements, findUnresolvedBoundarySides, clearAnnotations, listFeatures, filterByFeature, getFeatureSummaries } from '../parser/index.js'; +import { parseProject, findDanglingRefs, findUnmitigatedExposures, findAcceptedWithoutAudit, findAcceptedExposures, findUndeclaredActors, findAgentReachConflicts, findInertEntitlements, findImpreciseEntitlements, findUnresolvedBoundarySides, clearAnnotations, listFeatures, filterByFeature, getFeatureSummaries } from '../parser/index.js'; import { initProject, detectProject, promptAgentSelection, syncAgentFiles } from '../init/index.js'; import { generateReport } from '../report/index.js'; import { generateDashboardHTML } from '../dashboard/index.js'; @@ -902,10 +902,11 @@ export async function cmdValidate(ctx: TuiContext): Promise { // Entitlement checks: undeclared actor (error) and uncited/inert claim (warning) const actorDiags = findUndeclaredActors(model); + const agentReachDiags = findAgentReachConflicts(model); const inertDiags = findInertEntitlements(model); const impreciseDiags = findImpreciseEntitlements(model); - const allDiags = [...diagnostics, ...danglingDiags, ...boundarySideDiags, ...acceptAuditDiags, ...actorDiags, ...inertDiags, ...impreciseDiags]; + const allDiags = [...diagnostics, ...danglingDiags, ...boundarySideDiags, ...acceptAuditDiags, ...actorDiags, ...agentReachDiags, ...inertDiags, ...impreciseDiags]; // Unmitigated exposures const unmitigated = findUnmitigatedExposures(model); diff --git a/src/types/index.ts b/src/types/index.ts index e3e15e9b..40924d34 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -16,6 +16,8 @@ export type AnnotationVerb = | 'asset' | 'threat' | 'control' | 'actor' // Relationship | 'mitigates' | 'exposes' | 'accepts' | 'transfers' | 'flows' | 'boundary' | 'entitles' + // Reach — what a principal can invoke, what code does, and who approves it + | 'agents' | 'reaches' | 'effects' | 'gates' // Evidence | 'confirmed' // Lifecycle @@ -176,6 +178,76 @@ export interface EntitlesAnnotation extends BaseAnnotation { threat?: string; } +/** + * The closed set of things code can do to an asset, for `@effects`. + * + * Closed for the reason `DataClassification` is: an open string cannot be + * compared across repositories or drive a mechanical query. `delete`, `spend` + * (money or quota) and `notify` (an outbound message) are kept apart from + * `write` because they are the irreversible actions a reviewer looks for first. + * Egress is not here: `@flows X -> External.Y` already says it. + */ +export type EffectClass = 'read' | 'write' | 'delete' | 'execute' | 'spend' | 'notify'; + +/** + * "This principal can invoke this capability." Written where the capability is + * handed out — a tool registration, an MCP server mount, a job's credentials. + * + * Two verbs share this shape. `@agents` names an LLM agent, and writing it is + * what marks the actor as one; `@reaches` names any other principal (a CI + * runner, a service account). One actor under both is a validation error. + * + * The factual twin of `@entitles`: that says an actor *may* (a human's + * decision), this says it *can* (an observation an agent may write). The + * capability id is shared, so "can minus may" — a reach no entitlement covers — + * is a join. Like `@entitles` it has no export semantics: it removes nothing + * from SARIF and closes no finding. + */ +export interface ReachesAnnotation extends BaseAnnotation { + verb: 'agents' | 'reaches'; + /** Actor ref — `#id` or a declared actor name. */ + actor: string; + /** Capability as written: one identifier, the same token `@entitles` uses. */ + capability: string; + /** §2.10-normalised capability: the label a reach and an entitlement join on. */ + canonical_capability: string; + /** `on ` — the surface the capability is exposed on. */ + asset?: string; + /** `as ` — the identity the caller presents. An asset ref. */ + identity?: string; +} + +/** + * "Running this code does to ." Written on the code that acts. + * `as ` is whose credentials the effect runs under; when that differs + * from the caller's `@agents … as ` with no `@gates` between them, the + * code is a confused deputy. + */ +export interface EffectsAnnotation extends BaseAnnotation { + verb: 'effects'; + effect: EffectClass; + asset: string; + /** `as ` — the execution identity. An asset ref. */ + identity?: string; +} + +/** + * "A named principal decides here before effects on land." Written on + * the approval step. It suppresses nothing: a gate is a node on a path, not a + * control keyed on (asset, threat), so it is not a `@mitigates`. + */ +export interface GatesAnnotation extends BaseAnnotation { + verb: 'gates'; + /** The asset whose effects this gate stands in front of. */ + asset: string; + /** `by ` — whose decision is required. */ + approver: string; + /** `for ` — narrows the gate to one capability. Absent: every route to the asset. */ + capability?: string; + /** §2.10-normalised capability, when present. */ + canonical_capability?: string; +} + export interface TransfersAnnotation extends BaseAnnotation { verb: 'transfers'; threat: string; @@ -250,6 +322,9 @@ export type Annotation = | ConfirmedAnnotation | AcceptsAnnotation | EntitlesAnnotation + | ReachesAnnotation + | EffectsAnnotation + | GatesAnnotation | TransfersAnnotation | FlowsAnnotation | BoundaryAnnotation @@ -392,6 +467,12 @@ export interface ThreatModel { actors?: ThreatModelActor[]; /** Entitlement claims (@entitles). Optional for the same reason as `actors`. */ entitlements?: ThreatModelEntitlement[]; + /** Capability grants (@agents and @reaches). Optional for the same reason as `actors`. */ + reaches?: ThreatModelReach[]; + /** What code does to an asset (@effects). */ + effects?: ThreatModelEffect[]; + /** Approval steps (@gates). */ + gates?: ThreatModelGate[]; mitigations: ThreatModelMitigation[]; exposures: ThreatModelExposure[]; confirmed: ThreatModelConfirmed[]; @@ -444,6 +525,40 @@ export interface ThreatModelActor { location: SourceLocation; } +/** One `@agents` or `@reaches` claim: this actor can invoke this capability. */ +export interface ThreatModelReach { + actor: string; + /** True when written `@agents`: the actor is an LLM agent. False for `@reaches`. */ + agent: boolean; + capability: string; + canonical_capability: string; + asset?: string; + /** Caller identity the actor presents. */ + identity?: string; + description?: string; + location: SourceLocation; +} + +/** One `@effects` claim: running this code does to . */ +export interface ThreatModelEffect { + effect: EffectClass; + asset: string; + /** Execution identity: whose credentials the effect runs under. */ + identity?: string; + description?: string; + location: SourceLocation; +} + +/** One `@gates` claim: decides before effects on land. */ +export interface ThreatModelGate { + asset: string; + approver: string; + capability?: string; + canonical_capability?: string; + description?: string; + location: SourceLocation; +} + /** * A file:line pointer, extracted from an @entitles description, at the * authorization code that grants the entitlement. §3.4: no citation, no effect. @@ -756,8 +871,10 @@ export type DiagnosticCode = | 'dangling-ref' /** A directed `@boundary from to ` names a side that is no declared asset and no `@flows` endpoint. */ | 'unresolved-boundary-side' - /** `@entitles` names an actor never declared with `@actor`. */ + /** `@entitles`, `@agents`, `@reaches` or `@gates` names an actor never declared with `@actor`. */ | 'undeclared-actor' + /** One actor is named under both `@agents` and `@reaches`: it is an agent or it is not. */ + | 'agent-reach-conflict' /** `@entitles` cites no authz code, so it can never demote a finding. */ | 'inert-entitlement' /** `@entitles` cites authz code too imprecisely to be checked. */ diff --git a/src/workspace/merge.ts b/src/workspace/merge.ts index 5e85734d..a34ef612 100644 --- a/src/workspace/merge.ts +++ b/src/workspace/merge.ts @@ -598,6 +598,9 @@ export function combineModels(reports: LoadedReport[]): ThreatModel { controls: [], actors: [], entitlements: [], + reaches: [], + effects: [], + gates: [], mitigations: [], exposures: [], confirmed: [], @@ -651,6 +654,12 @@ export function combineModels(reports: LoadedReport[]): ThreatModel { combined.confirmed.push(...prefixAll(m.confirmed || [], repo)); combined.acceptances.push(...prefixAll(m.acceptances, repo)); combined.entitlements!.push(...prefixAll(m.entitlements || [], repo)); + // Like every relation, the location is prefixed and the refs are not: one + // agent across services is one actor only if every repository spells it with + // the same tag. + combined.reaches!.push(...prefixAll(m.reaches || [], repo)); + combined.effects!.push(...prefixAll(m.effects || [], repo)); + combined.gates!.push(...prefixAll(m.gates || [], repo)); combined.transfers.push(...prefixAll(m.transfers, repo)); combined.flows.push(...prefixAll(m.flows, repo)); combined.boundaries.push(...prefixAll(m.boundaries, repo)); @@ -1093,6 +1102,7 @@ function emptyMergedReport(workspace: string, statuses: RepoStatus[]): MergedRep generated_at: new Date().toISOString(), source_files: 0, annotations_parsed: 0, annotated_files: [], unannotated_files: [], assets: [], threats: [], controls: [], actors: [], entitlements: [], + reaches: [], effects: [], gates: [], mitigations: [], exposures: [], confirmed: [], acceptances: [], transfers: [], flows: [], boundaries: [], validations: [], audits: [], ownership: [], data_handling: [], diff --git a/tests/agent-reach.test.ts b/tests/agent-reach.test.ts new file mode 100644 index 00000000..c5fa25ca --- /dev/null +++ b/tests/agent-reach.test.ts @@ -0,0 +1,484 @@ +/** + * Reach: `@agents`, `@reaches`, `@effects`, `@gates` (SPEC §3.2.1). + * + * The grammar of each verb, the two rules that follow from splitting reach into + * an agent verb and a non-agent verb (`@agents` is what marks an actor an + * agent; one actor under both verbs is an error), validation of the actors and + * assets they name, and "can minus may" — `unentitled reaches` — answered by + * lookup and by diff over a small support-desk app that embeds an agent. + * + * @validates #input-sanitize for #parser -- "Each reach verb parses only in its documented shape; an effect outside the closed set, a gate with no approver, or a reach with no `to` is malformed, never read as a claim" + */ +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join, dirname, resolve } from 'node:path'; +import { execFileSync } from 'node:child_process'; +import { parseLine } from '../src/parser/parse-line.js'; +import { parseProject } from '../src/parser/parse-project.js'; +import { + findAgentReachConflicts, findDanglingRefs, findUndeclaredActors, +} from '../src/parser/validate.js'; +import { findUnentitledReaches } from '../src/parser/reach.js'; +import { computeAnnotationHash } from '../src/parser/annotation-hash.js'; +import { relationRecords } from '../src/parser/claim-key.js'; +import { applyAnnotations } from '../src/parser/apply-annotations.js'; +import { runGate } from '../src/gate/gate.js'; +import { diffModels } from '../src/diff/engine.js'; +import { formatDiff, formatDiffMarkdown } from '../src/diff/format.js'; +import { lookup, SUPPORTED_QUERY_FORMS } from '../src/mcp/lookup.js'; +import { agentInstructions, referenceDocContent } from '../src/init/templates.js'; +import type { ThreatModel } from '../src/types/index.js'; + +const at = { file: 'src/agent/tools.ts', line: 3 }; +const line = (text: string) => parseLine(text, at); + +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-agent-reach-')); + 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; +} + +async function parseFiles(files: Record) { + return parseProject({ root: writeProject(files), project: 'agent-reach' }); +} + +// ─── Grammar ───────────────────────────────────────────────────────── + +describe('@agents and @reaches', () => { + it('parse every clause, and record which verb was written', () => { + const full = line('@agents #support-agent to run-sql on #tool-surface as #agent-session -- "Debug tool, still registered"'); + expect(full.diagnostic).toBeNull(); + expect(full.annotation).toMatchObject({ + verb: 'agents', actor: '#support-agent', capability: 'run-sql', canonical_capability: 'run_sql', + asset: '#tool-surface', identity: '#agent-session', description: 'Debug tool, still registered', + }); + + const ci = line('@reaches #ci-runner to publish-package on Registry.Npm as #npm-token'); + expect(ci.annotation).toMatchObject({ + verb: 'reaches', actor: '#ci-runner', capability: 'publish-package', asset: 'Registry.Npm', identity: '#npm-token', + }); + }); + + it('take on, as and the description as optional, and a declared name as the actor', () => { + expect(line('@agents #bot to search-kb').annotation) + .toMatchObject({ verb: 'agents', actor: '#bot', capability: 'search-kb', asset: undefined, identity: undefined }); + expect(line('@agents Support_Agent to search-kb as #session').annotation) + .toMatchObject({ actor: 'Support_Agent', asset: undefined, identity: '#session' }); + expect(line('@reaches #ci to deploy on #prod -- "d"').annotation) + .toMatchObject({ verb: 'reaches', asset: '#prod', identity: undefined, description: 'd' }); + }); + + it('refuse a capability written as prose, and a missing `to`', () => { + for (const text of [ + '@agents #bot to "run any sql" on #db', + '@agents #bot run-sql on #db', + '@reaches #ci on #prod', + ]) { + const r = line(text); + expect(r.annotation, text).toBeNull(); + expect(r.diagnostic?.code, text).toBe('malformed-annotation'); + } + }); +}); + +describe('@effects', () => { + it('parses each of the six effects, with an optional execution identity', () => { + for (const effect of ['read', 'write', 'delete', 'execute', 'spend', 'notify']) { + expect(line(`@effects ${effect} on #orders-db`).annotation, effect) + .toMatchObject({ verb: 'effects', effect, asset: '#orders-db', identity: undefined }); + } + expect(line('@effects spend on #payments as #billing-sa -- "postRefund calls the processor"').annotation) + .toMatchObject({ effect: 'spend', asset: '#payments', identity: '#billing-sa', description: 'postRefund calls the processor' }); + }); + + it('refuses an effect outside the closed set, so egress cannot be spelled twice', () => { + for (const text of ['@effects send on #mail', '@effects egress on #internet', '@effects write #db']) { + const r = line(text); + expect(r.annotation, text).toBeNull(); + expect(r.diagnostic?.code, text).toBe('malformed-annotation'); + } + // With no #ref and no effect word there is no evidence of an attempt, so it + // is reported as prose — still never read as a claim. + const r = line('@effects egress on External.Internet'); + expect(r.annotation).toBeNull(); + expect(r.diagnostic?.code).toBe('prose-like'); + }); + + it('reads prose about the verb as prose, not as a broken annotation', () => { + const r = line('@effects on the model are described in the spec'); + expect(r.annotation).toBeNull(); + expect(r.diagnostic?.code).toBe('prose-like'); + expect(r.diagnostic?.level).toBe('warning'); + // An effect word in the first position is how the verb starts, so it is evidence. + expect(line('@effects write the users table').diagnostic?.code).toBe('malformed-annotation'); + }); +}); + +describe('@gates', () => { + it('requires `by ` and takes `for ` as optional', () => { + expect(line('@gates #payments by #support-human for issue-refund -- "requireApproval() blocks"').annotation) + .toMatchObject({ + verb: 'gates', asset: '#payments', approver: '#support-human', + capability: 'issue-refund', canonical_capability: 'issue_refund', description: 'requireApproval() blocks', + }); + expect(line('@gates #payments by Support_Human').annotation) + .toMatchObject({ approver: 'Support_Human', capability: undefined, canonical_capability: undefined }); + + const noApprover = line('@gates #payments -- "a human looks at it"'); + expect(noApprover.annotation).toBeNull(); + expect(noApprover.diagnostic?.code).toBe('malformed-annotation'); + }); + + it('carries no advisory or blocking qualifier', () => { + expect(line('@gates #payments by #support-human [blocking]').annotation).toBeNull(); + }); +}); + +// ─── The fixture agent app ─────────────────────────────────────────── + +const DEFINITIONS = `/** + * @asset Agent.ToolSurface (#tool-surface) -- "Tools registered with the support agent" + * @asset Shop.OrdersDb (#orders-db) -- "Orders table" + * @asset Shop.UsersDb (#users-db) -- "Users table" + * @asset Shop.Payments (#payments) -- "Refunds through the payment processor" + * @asset Shop.Kb (#kb) -- "Help-centre articles" + * @asset Host.Fs (#host-fs) -- "The host filesystem" + * @asset Identity.AgentSession (#agent-session) -- "Customer-scoped token the agent presents" + * @asset Identity.BillingSa (#billing-sa) -- "Service account that can move money" + * @threat Excessive_Agency (#excessive-agency) [high] -- "Agent holds a capability nobody approved" + * @actor Support_Agent (#support-agent) -- "LLM agent; acts for the signed-in customer" + * @actor Support_Human (#support-human) -- "Approves refunds in the review queue" + * @actor CI_Runner (#ci-runner) -- "Release pipeline" + */ +export {}; +`; + +const TOOLS = `/** + * @agents #support-agent to lookup-order on #tool-surface as #agent-session + * @agents #support-agent to run-sql on #tool-surface -- "Debug tool, still registered" + * @agents #support-agent to issue-refund on #tool-surface + * @agents #support-agent to search-kb on #tool-surface + * @agents #support-agent to fetch-url on #tool-surface + * @agents #support-agent to mcp-files on #tool-surface -- "Filesystem MCP server rooted at /" + * @entitles #support-agent to lookup-order on #tool-surface against #excessive-agency -- "By design. Authz: src/agent/policy.ts:10" + * @entitles #support-agent to issue-refund on #tool-surface against #excessive-agency -- "By design. Authz: src/agent/policy.ts:14" + * @entitles #support-agent to search-kb on #kb against #excessive-agency -- "By design. Authz: src/agent/policy.ts:18" + * @entitles #support-agent to fetch-url -- "Uncited, so it covers nothing" + */ +export function registerTools(): void {} +`; + +const ACTIONS = `/** + * @effects read on #orders-db -- "findOrder" + * @effects delete on #orders-db -- "runRaw executes the model's SQL" + * @effects write on #users-db -- "runRaw executes the model's SQL" + * @effects spend on #payments as #billing-sa -- "postRefund" + * @effects read on #kb -- "search" + * @effects write on #host-fs -- "mountFiles" + * @gates #payments by #support-human for issue-refund -- "requireApproval() parks the refund until a human approves" + */ +export function act(): void {} +`; + +const CI = `/** + * @reaches #ci-runner to publish-package on #tool-surface -- "npm publish in release.yml" + */ +export function release(): void {} +`; + +const APP = { + '.guardlink/definitions.ts': DEFINITIONS, + 'src/agent/tools.ts': TOOLS, + 'src/agent/actions.ts': ACTIONS, + 'src/ci/release.ts': CI, +}; + +describe('the model', () => { + let model: ThreatModel; + beforeAll(async () => { ({ model } = await parseFiles(APP)); }); + + it('collects @agents and @reaches in one array, flagging which rows are agents', () => { + expect(model.reaches).toHaveLength(7); + expect(model.reaches!.filter(r => r.agent).map(r => r.capability)) + .toEqual(['lookup-order', 'run-sql', 'issue-refund', 'search-kb', 'fetch-url', 'mcp-files']); + expect(model.reaches!.filter(r => !r.agent)).toMatchObject([{ actor: '#ci-runner', capability: 'publish-package' }]); + expect(model.effects).toHaveLength(6); + expect(model.gates).toMatchObject([{ asset: '#payments', approver: '#support-human', capability: 'issue-refund' }]); + }); + + it('gives each claim a key, and moves the annotation hash when one is added', async () => { + const verbs = relationRecords(model).map(r => r.verb); + expect(verbs.filter(v => v === 'agents')).toHaveLength(6); + expect(verbs.filter(v => v === 'reaches')).toHaveLength(1); + expect(verbs.filter(v => v === 'effects')).toHaveLength(6); + expect(verbs.filter(v => v === 'gates')).toHaveLength(1); + + const { model: more } = await parseFiles({ + ...APP, 'src/ci/release.ts': CI.replace(' */', ' * @reaches #ci-runner to tag-release on #tool-surface\n */'), + }); + expect(computeAnnotationHash(more)).not.toBe(computeAnnotationHash(model)); + }); + + it('validates clean', () => { + expect(findUndeclaredActors(model)).toEqual([]); + expect(findAgentReachConflicts(model)).toEqual([]); + expect(findDanglingRefs(model)).toEqual([]); + }); +}); + +// ─── Validation ────────────────────────────────────────────────────── + +describe('validation', () => { + it('errors when one actor is named under both @agents and @reaches, in any spelling', async () => { + const { model } = await parseFiles({ + ...APP, + 'src/ci/release.ts': CI.replace(' */', ' * @reaches Support_Agent to tag-release on #tool-surface\n */'), + }); + const diags = findAgentReachConflicts(model); + expect(diags).toHaveLength(1); + expect(diags[0]).toMatchObject({ level: 'error', code: 'agent-reach-conflict', file: 'src/ci/release.ts', line: 3 }); + expect(diags[0].message).toContain('src/agent/tools.ts:2'); + }); + + it('reports an undeclared actor on @agents, @reaches and as a @gates approver', async () => { + const { model } = await parseFiles({ + '.guardlink/definitions.ts': '// @asset App.Db (#db) -- "d"\nexport {};\n', + 'src/a.ts': [ + '// @agents #ghost-agent to run-sql on #db', + '// @reaches #ghost-runner to deploy on #db', + '// @gates #db by #nobody -- "approve()"', + 'export {};', + ].join('\n'), + }); + const diags = findUndeclaredActors(model); + expect(diags.map(d => [d.code, d.level, d.line])).toEqual([ + ['undeclared-actor', 'error', 1], ['undeclared-actor', 'error', 2], ['undeclared-actor', 'error', 3], + ]); + expect(diags.map(d => d.message.split(' names')[0])).toEqual(['@agents', '@reaches', '@gates']); + }); + + it('reports an undefined asset or identity as a dangling ref', async () => { + const { model } = await parseFiles({ + '.guardlink/definitions.ts': '// @actor Bot (#bot) -- "agent"\nexport {};\n', + 'src/a.ts': [ + '// @agents #bot to run-sql on #no-surface as #no-session', + '// @effects write on #no-db as #no-sa', + '// @gates #no-asset by #bot', + 'export {};', + ].join('\n'), + }); + const dangling = findDanglingRefs(model).map(d => d.message.match(/#[\w-]+/)![0]); + expect(dangling).toEqual(['#no-surface', '#no-session', '#no-db', '#no-sa', '#no-asset']); + }); +}); + +// ─── Can minus may ─────────────────────────────────────────────────── + +describe('unentitled reaches', () => { + let model: ThreatModel; + beforeAll(async () => { ({ model } = await parseFiles(APP)); }); + + it('lists the reaches no cited entitlement covers, with why a near miss does not count', () => { + const r = lookup(model, 'unentitled reaches'); + expect(r.type).toBe('unentitled_reaches'); + expect(r.results.map(x => x.capability)).toEqual(['run_sql', 'search_kb', 'fetch_url', 'mcp_files', 'publish_package']); + const byCap = Object.fromEntries(r.results.map(x => [x.capability, x])); + expect(byCap.run_sql.near_misses).toEqual([]); + // The search-kb entitlement names another asset; the fetch-url one is uncited. + expect(byCap.search_kb.near_misses).toMatchObject([{ blocker: 'other-asset', asset: '#kb' }]); + expect(byCap.fetch_url.near_misses).toMatchObject([{ blocker: 'uncited' }]); + expect(byCap.run_sql).toMatchObject({ actor: '#support-agent', agent: true, file: 'src/agent/tools.ts', line: 3 }); + }); + + it('narrows to one actor, by id or declared name', () => { + expect(lookup(model, 'unentitled reaches for #ci-runner').results.map(x => x.capability)).toEqual(['publish_package']); + expect(lookup(model, 'unentitled reaches for CI_Runner').count).toBe(1); + }); + + it('does not let an entitlement naming no asset cover a reach that names one', async () => { + const { model: m } = await parseFiles({ + ...APP, + 'src/agent/tools.ts': TOOLS.replace(' */', ' * @entitles #support-agent to run-sql against #excessive-agency -- "Authz: src/agent/policy.ts:30"\n */'), + }); + const runSql = findUnentitledReaches(m).find(u => u.reach.capability === 'run-sql')!; + expect(runSql.near_misses.map(n => n.blocker)).toEqual(['no-asset']); + }); + + it('is closed by a cited entitlement on the same actor, capability and asset', async () => { + const { model: m } = await parseFiles({ + ...APP, + 'src/agent/tools.ts': TOOLS.replace(' */', ' * @entitles Support_Agent to run_sql on Agent.ToolSurface against #excessive-agency -- "Authz: src/agent/policy.ts:30"\n */'), + }); + expect(findUnentitledReaches(m).map(u => u.reach.capability)).not.toContain('run-sql'); + }); +}); + +describe('lookup forms', () => { + let model: ThreatModel; + beforeAll(async () => { ({ model } = await parseFiles(APP)); }); + + it('lists every reach, or only the agents, by actor', () => { + expect(lookup(model, 'reaches').count).toBe(7); + expect(lookup(model, 'agents')).toMatchObject({ type: 'agents', count: 6 }); + expect(lookup(model, 'reaches for #ci-runner').results).toMatchObject([{ agent: false, capability: 'publish_package' }]); + expect(lookup(model, 'agents for #ci-runner').count).toBe(0); + }); + + it('scopes effects and gates by asset, resolving the ref as `asset X` does', () => { + const effects = lookup(model, 'effects for Shop.OrdersDb'); + expect(effects.results.map(e => [e.effect, e.mutating])).toEqual([['read', false], ['delete', true]]); + expect(effects.matched_via).toBe(lookup(model, 'asset Shop.OrdersDb').matched_via); + expect(lookup(model, 'effects').count).toBe(6); + expect(lookup(model, 'gates for #payments').results).toMatchObject([{ approver: '#support-human', capability: 'issue_refund' }]); + }); + + it('marks an actor named by @agents as an agent', () => { + const actors = Object.fromEntries(lookup(model, 'actors').results.map(a => [a.id, a])); + expect(actors['support-agent'].agent).toBe(true); + expect(actors['support-agent'].reaches).toHaveLength(6); + expect(actors['ci-runner'].agent).toBe(false); + expect(actors['support-human']).toMatchObject({ agent: false, reaches: [] }); + }); + + it('lists the forms when a query is not one', () => { + expect(SUPPORTED_QUERY_FORMS.some(f => f.startsWith('unentitled reaches'))).toBe(true); + }); +}); + +// ─── Diff ──────────────────────────────────────────────────────────── + +describe('diff', () => { + it('reports a new reach, and a reach that is newly unentitled', async () => { + const { model: before } = await parseFiles(APP); + const { model: after } = await parseFiles({ + ...APP, + 'src/agent/tools.ts': TOOLS.replace(' */', ' * @agents #support-agent to run-shell on #host-fs -- "exec tool"\n */'), + }); + const diff = diffModels(before, after); + expect(diff.reaches).toMatchObject([{ kind: 'added', item: { capability: 'run-shell', agent: true } }]); + expect(diff.newUnentitledReaches.map(r => r.capability)).toEqual(['run-shell']); + expect(diff.summary.newUnentitledReaches).toBe(1); + + const text = formatDiff(diff); + expect(text).toContain('── New Unentitled Reaches'); + expect(text).toContain('+ @agents #support-agent to run-shell on #host-fs'); + expect(formatDiffMarkdown(diff)).toContain('New Unentitled Reaches'); + }); + + it('reports a reach that was already unentitled as no new risk', async () => { + const { model: before } = await parseFiles(APP); + const { model: after } = await parseFiles({ + ...APP, 'src/agent/tools.ts': TOOLS.replace('"Debug tool, still registered"', '"Debug tool"'), + }); + const diff = diffModels(before, after); + expect(diff.reaches).toMatchObject([{ kind: 'modified', details: 'description changed' }]); + expect(diff.newUnentitledReaches).toEqual([]); + }); + + it('reports withdrawing an entitlement as a newly unentitled reach', async () => { + const { model: before } = await parseFiles(APP); + const { model: after } = await parseFiles({ + ...APP, 'src/agent/tools.ts': TOOLS.replace(/^ \* @entitles #support-agent to lookup-order.*\n/m, ''), + }); + expect(diffModels(before, after).newUnentitledReaches.map(r => r.capability)).toEqual(['lookup-order']); + }); + + it('reports effects and gates', async () => { + const { model: before } = await parseFiles(APP); + const { model: after } = await parseFiles({ + ...APP, 'src/agent/actions.ts': ACTIONS.replace(/^ \* @gates.*\n/m, ''), + }); + const diff = diffModels(before, after); + expect(diff.gates).toMatchObject([{ kind: 'removed', item: { asset: '#payments' } }]); + expect(formatDiff(diff)).toContain('── Gates ──'); + }); +}); + +// ─── The annotate gate ─────────────────────────────────────────────── + +describe('what an agent may write', () => { + it('applies the four reach verbs and still refuses @entitles and @accepts', () => { + const root = writeProject({ 'src/agent/tools.ts': 'export function registerTools() {}\n' }); + const ok = applyAnnotations({ + root, file: 'src/agent/tools.ts', line: 1, dryRun: true, + annotations: [ + '@agents #support-agent to run-sql on #tool-surface', + '@reaches #ci-runner to publish-package', + '@effects write on #users-db', + '@gates #payments by #support-human -- "requireApproval()"', + ], + }); + expect(ok.errors).toEqual([]); + expect(ok.ok).toBe(true); + + for (const governance of [ + '@entitles #support-agent to run-sql on #tool-surface -- "Authz: a.ts:1"', + '@accepts #excessive-agency on #tool-surface -- "fine"', + ]) { + const refused = applyAnnotations({ root, file: 'src/agent/tools.ts', line: 1, dryRun: true, annotations: [governance] }); + expect(refused.status, governance).toBe('rejected'); + } + }); + + it('lets the gate accept added reach claims, warning only on a gate that says nothing', async () => { + const { model: before } = await parseFiles({ '.guardlink/definitions.ts': DEFINITIONS }); + const { model: after } = await parseFiles({ + '.guardlink/definitions.ts': DEFINITIONS, + 'src/agent/tools.ts': [ + '// @agents #support-agent to run-sql on #tool-surface', + '// @effects write on #users-db -- "runRaw executes the SQL"', + '// @gates #payments by #support-human', + 'export {};', + ].join('\n'), + }); + const report = runGate(before, after); + expect(report.added.map(a => a.verb)).toEqual(['agents', 'effects', 'gates']); + expect(report.errors).toBe(0); + expect(report.violations).toMatchObject([{ rule: 'description-vague', level: 'warn', verb: 'gates' }]); + }); +}); + +// ─── What agents are taught ────────────────────────────────────────── + +describe('instruction templates', () => { + const project = { name: 'app', language: 'typescript', definitionsExt: '.ts' } as Parameters[0]; + + it('list the reach verbs as writable, and every example they show parses', () => { + const texts = [referenceDocContent(project), agentInstructions(project, 'inline')]; + for (const text of texts) { + for (const verb of ['@agents', '@reaches', '@effects', '@gates']) expect(text).toContain(verb); + } + const examples = agentInstructions(project, 'inline').split('\n') + .filter(l => /^@(agents|reaches|effects|gates) /.test(l)); + expect(examples.length).toBeGreaterThan(0); + for (const example of examples) expect(line(example).annotation, example).not.toBeNull(); + }); +}); + +// ─── CLI ───────────────────────────────────────────────────────────── + +describe('guardlink lookup (CLI)', () => { + const cli = resolve(__dirname, '..', 'dist', 'cli', 'index.js'); + + it('prints the lookup answer as JSON and fails on request when it finds something', () => { + const root = writeProject(APP); + const out = JSON.parse(execFileSync('node', [cli, 'lookup', 'unentitled', 'reaches', '-d', root], { encoding: 'utf8' })); + expect(out).toMatchObject({ type: 'unentitled_reaches', count: 5 }); + + let status = 0; + try { + execFileSync('node', [cli, 'lookup', 'unentitled reaches', '-d', root, '--fail-on-found'], { stdio: 'pipe' }); + } catch (e) { + status = (e as { status: number }).status; + } + expect(status).toBe(1); + }); +}); diff --git a/tests/diagnostic-codes.test.ts b/tests/diagnostic-codes.test.ts index 03102d49..be9bf471 100644 --- a/tests/diagnostic-codes.test.ts +++ b/tests/diagnostic-codes.test.ts @@ -16,7 +16,7 @@ import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { parseProject } from '../src/parser/parse-project.js'; import { - findDanglingRefs, findUndeclaredActors, findInertEntitlements, + findDanglingRefs, findUndeclaredActors, findAgentReachConflicts, findInertEntitlements, findImpreciseEntitlements, findAcceptedWithoutAudit, findOffConventionGalFiles, } from '../src/parser/validate.js'; import { checkEntitlementProvenance } from '../src/review/entitlements.js'; @@ -45,6 +45,8 @@ beforeAll(async () => { + ' * @accepts #sqli on #api -- "accepted with no audit"\n' + ' * @entitles #ghost to doathing -- "no citation, no asset, no threat"\n' + ' * @entitles #admin to configure-thing on #api against #sqli -- "By design. Authz: app/a.ts:2"\n' + + ' * @agents #admin to configure-thing on #api\n' + + ' * @reaches #admin to configure-thing on #api -- "the same actor under both verbs"\n' + ' */\n' + 'export function f() {}\n'); @@ -69,6 +71,7 @@ describe('every diagnostic kind is machine-classifiable', () => { ['duplicate-id', 'parse', () => parseDiagnostics], ['dangling-ref', 'validate', () => findDanglingRefs(model)], ['undeclared-actor', 'validate', () => findUndeclaredActors(model)], + ['agent-reach-conflict', 'validate', () => findAgentReachConflicts(model)], ['inert-entitlement', 'validate', () => findInertEntitlements(model)], ['imprecise-entitlement', 'validate', () => findImpreciseEntitlements(model)], ['accepted-without-audit', 'validate', () => findAcceptedWithoutAudit(model)], @@ -88,6 +91,7 @@ describe('every diagnostic kind is machine-classifiable', () => { ...parseDiagnostics, ...findDanglingRefs(model), ...findUndeclaredActors(model), + ...findAgentReachConflicts(model), ...findInertEntitlements(model), ...findImpreciseEntitlements(model), ...findAcceptedWithoutAudit(model), diff --git a/tests/relation-coverage.test.ts b/tests/relation-coverage.test.ts index 2aa137cc..2b542951 100644 --- a/tests/relation-coverage.test.ts +++ b/tests/relation-coverage.test.ts @@ -48,6 +48,9 @@ const QUERY_FOR: Record = { comments: 'comments', actors: 'actors', entitlements: 'entitlements', + reaches: 'reaches', + effects: 'effects for #db', + gates: 'gates for #db', external_refs: 'external refs', }; @@ -58,6 +61,9 @@ const DEFINITIONS = `/** * @threat Auth_Bypass (#auth-bypass) [high] -- "Authentication sidestepped" * @control Prepared_Statements (#prepared-stmts) -- "Parameterized queries" * @actor Namespace_Admin (#ns-admin) -- "Administers one namespace's configuration" + * @actor Support_Agent (#support-agent) -- "LLM agent with a tool surface" + * @actor On_Call (#oncall) -- "Human who answers approval requests" + * @asset Identity.AgentSession (#agent-session) -- "Scoped token the agent presents" */ export {}; `; @@ -81,6 +87,9 @@ const SOURCE = `/** * @feature "Checkout" -- "Cart and order placement" * @comment -- "Rate limit: 100 req/15min" * @entitles #ns-admin to configure-archival-destination on #api against #sqli -- "By design: this is namespace configuration. Authz: src/authz.ts:12" + * @agents #support-agent to query-users on #api as #agent-session -- "Registered as a tool" + * @effects write on #db as #agent-session -- "UPDATE under the caller's own token" + * @gates #db by #oncall for query-users -- "Blocking approval before the write lands" * @flows "#sibling-lib.tokens" -> #api via header -- "Cross-repo token handoff (quoted: the unquoted form does not parse — see report)" */ export function handler(): void {} From 7f52f71ffcb104cb46a2d466df89254c47502e5d Mon Sep 17 00:00:00 2001 From: Animesh Srivastava Date: Fri, 2 Oct 2026 04:15:06 +0530 Subject: [PATCH 2/2] chore(artifacts): regenerate agent instructions and threat-model artifacts --- .clinerules | 9 +- .cursor/rules/guardlink.mdc | 9 +- .gemini/GEMINI.md | 13 +- .github/copilot-instructions.md | 13 +- .guardlink/README.md | 8 +- .guardlink/graph/MANIFEST.json | 112 +++---- .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 | 4 +- .../graph/by-asset/report-metadata.flows.mmd | 2 +- .../by-asset/report-metadata.threats.mmd | 2 +- .guardlink/graph/by-asset/report.flows.mmd | 2 +- .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 | 2 +- .guardlink/graph/index.md | 4 +- .guardlink/graph/threat-graph.mmd | 2 +- .guardlink/model.json | 274 +++++++++++------- .windsurfrules | 9 +- AGENTS.md | 13 +- CLAUDE.md | 13 +- 64 files changed, 347 insertions(+), 238 deletions(-) diff --git a/.clinerules b/.clinerules index 984451cd..c8e35b70 100644 --- a/.clinerules +++ b/.clinerules @@ -29,7 +29,8 @@ Every time you write or modify code that touches security-relevant behavior, you - Use @confirmed for verified exploits. When pentest/scanning/manual reproduction proves a threat is exploitable: @confirmed #threat on Asset [severity] -- "evidence". Distinct from @exposes (theoretical) — @confirmed means real, verified, no false positives. Without evidence in hand, @exposes stands and you do not promote it — reading the code is not reproduction. To get evidence: `bugb intake ""` builds a plan an operator approves, never you. - Preserve existing annotations — do not delete or mangle them. - Definitions (@asset, @threat, @control with (#id)) live in .guardlink/definitions.ts. Reuse IDs — never redefine. Add new definitions there first, then reference in source files. -- Source files use relationship verbs: @mitigates, @exposes, @confirmed, @flows, @handles, @boundary, @comment, @validates, @audit, @owns, @assumes, @transfers, @feature. (@actor is a definition — it lives with @asset/@threat/@control. @entitles is proposed, not written.) +- Source files use relationship verbs: @mitigates, @exposes, @confirmed, @flows, @handles, @boundary, @comment, @validates, @audit, @owns, @assumes, @transfers, @feature, @agents, @reaches, @effects, @gates. (@actor is a definition — it lives with @asset/@threat/@control. @entitles is proposed, not written.) +- Code hands an LLM agent a tool, MCP server, file/shell/DB access → @agents #agent to on Asset on the registration, @effects on Asset on the code that acts, @gates Asset by #approver on an approval step. @reaches for a principal that is not an agent; never both verbs for one actor. - Triggers for the four verbs everyone forgets: wrote a test that pins a control → @validates #control for Asset. Code trusts a caller/library/platform to hold a property it never checks → @assumes Asset -- "what must hold". A threat becomes a vendor's or another team's responsibility → @transfers #threat from Source to Target (both ends must be declared assets; a vendor needs an External.* asset first). You know the accountable team → @owns team-id for Asset (bare token — no quotes, no #). - Write coupled annotation blocks: risk + control (or audit) + data flow + context note — plus @boundary when that flow crosses a trust change (from to when you know which side is less trusted), and @handles when the asset touches classified data. - Avoid @shield unless a human explicitly asks to hide code from AI. @@ -53,6 +54,8 @@ Every time you write or modify code that touches security-relevant behavior, you - @confirmed #sqli on App.API [critical] cwe:CWE-89 -- "Pentest verified: raw SQL injection via email param" - @feature "SSO Login" -- "Single sign-on authentication flow" - @owns security-team for App.API -- "Team responsible" +- @agents #support-agent to run-sql on #tool-surface -- "run_sql tool registered in tools.ts" +- @effects write on #users-db -- "Executes the model-written SQL via db.raw" - @comment -- "Rate limit: 100 req/15min" ## Live Threat Model Context (auto-synced by `guardlink sync`) @@ -120,11 +123,11 @@ _Full records with descriptions and locations: `guardlink_lookup("asset ")`, ### Model Stats -874 annotations, 20 assets, 16 threats, 14 controls, 127 exposures, 0 confirmed, 129 mitigations, 3 actors, 1 entitlements, 202 flows, 2 features +884 annotations, 20 assets, 16 threats, 14 controls, 127 exposures, 0 confirmed, 130 mitigations, 3 actors, 1 entitlements, 202 flows, 2 features ### Block Freshness -- `annotation_hash`: `sha256-v3:95e447ec72ce4924c563d83aae0d3e594f518789e71d00ded358ac3255cf772a` +- `annotation_hash`: `sha256-v3:58a5b22940f27108f0890a80cc509fe5e68f0e97f88f9812332529f9e00b729a` 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 37de0b8e..bdd1b532 100644 --- a/.cursor/rules/guardlink.mdc +++ b/.cursor/rules/guardlink.mdc @@ -34,7 +34,8 @@ Every time you write or modify code that touches security-relevant behavior, you - Use @confirmed for verified exploits. When pentest/scanning/manual reproduction proves a threat is exploitable: @confirmed #threat on Asset [severity] -- "evidence". Distinct from @exposes (theoretical) — @confirmed means real, verified, no false positives. Without evidence in hand, @exposes stands and you do not promote it — reading the code is not reproduction. To get evidence: `bugb intake ""` builds a plan an operator approves, never you. - Preserve existing annotations — do not delete or mangle them. - Definitions (@asset, @threat, @control with (#id)) live in .guardlink/definitions.ts. Reuse IDs — never redefine. Add new definitions there first, then reference in source files. -- Source files use relationship verbs: @mitigates, @exposes, @confirmed, @flows, @handles, @boundary, @comment, @validates, @audit, @owns, @assumes, @transfers, @feature. (@actor is a definition — it lives with @asset/@threat/@control. @entitles is proposed, not written.) +- Source files use relationship verbs: @mitigates, @exposes, @confirmed, @flows, @handles, @boundary, @comment, @validates, @audit, @owns, @assumes, @transfers, @feature, @agents, @reaches, @effects, @gates. (@actor is a definition — it lives with @asset/@threat/@control. @entitles is proposed, not written.) +- Code hands an LLM agent a tool, MCP server, file/shell/DB access → @agents #agent to on Asset on the registration, @effects on Asset on the code that acts, @gates Asset by #approver on an approval step. @reaches for a principal that is not an agent; never both verbs for one actor. - Triggers for the four verbs everyone forgets: wrote a test that pins a control → @validates #control for Asset. Code trusts a caller/library/platform to hold a property it never checks → @assumes Asset -- "what must hold". A threat becomes a vendor's or another team's responsibility → @transfers #threat from Source to Target (both ends must be declared assets; a vendor needs an External.* asset first). You know the accountable team → @owns team-id for Asset (bare token — no quotes, no #). - Write coupled annotation blocks: risk + control (or audit) + data flow + context note — plus @boundary when that flow crosses a trust change (from to when you know which side is less trusted), and @handles when the asset touches classified data. - Avoid @shield unless a human explicitly asks to hide code from AI. @@ -58,6 +59,8 @@ Every time you write or modify code that touches security-relevant behavior, you - @confirmed #sqli on App.API [critical] cwe:CWE-89 -- "Pentest verified: raw SQL injection via email param" - @feature "SSO Login" -- "Single sign-on authentication flow" - @owns security-team for App.API -- "Team responsible" +- @agents #support-agent to run-sql on #tool-surface -- "run_sql tool registered in tools.ts" +- @effects write on #users-db -- "Executes the model-written SQL via db.raw" - @comment -- "Rate limit: 100 req/15min" ## Live Threat Model Context (auto-synced by `guardlink sync`) @@ -125,11 +128,11 @@ _Full records with descriptions and locations: `guardlink_lookup("asset ")`, ### Model Stats -874 annotations, 20 assets, 16 threats, 14 controls, 127 exposures, 0 confirmed, 129 mitigations, 3 actors, 1 entitlements, 202 flows, 2 features +884 annotations, 20 assets, 16 threats, 14 controls, 127 exposures, 0 confirmed, 130 mitigations, 3 actors, 1 entitlements, 202 flows, 2 features ### Block Freshness -- `annotation_hash`: `sha256-v3:95e447ec72ce4924c563d83aae0d3e594f518789e71d00ded358ac3255cf772a` +- `annotation_hash`: `sha256-v3:58a5b22940f27108f0890a80cc509fe5e68f0e97f88f9812332529f9e00b729a` 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 6b2a99d1..babcd784 100644 --- a/.gemini/GEMINI.md +++ b/.gemini/GEMINI.md @@ -52,7 +52,7 @@ the same change.** This includes: new endpoints, authentication/authorization lo 3. **Use `@confirmed` for verified exploits.** When a pentest, CXG scan, or manual reproduction proves a threat is exploitable, mark it with `@confirmed #threat on Asset [severity] -- "evidence"`. This is distinct from `@exposes` (theoretical) — `@confirmed` means real, verified, not a false positive. Include severity based on actual observed impact. **Without that evidence in hand, `@exposes` stands and you do not promote it** — reading the code is not reproduction. To get evidence, `bugb intake ""` turns a description into a test plan and prints a PLAN_ID; an operator approves it, never you. 4. Do not delete or mangle existing annotations. Treat them as part of the code. Edit only when intentionally changing the threat model. 5. Definitions (`@asset`, `@threat`, `@control` with `(#id)`) live in `.guardlink/definitions.ts`. Reuse existing `#id`s — never redefine. If you need a new asset or threat, add the definition there first, then reference it in source files. -6. Source files use relationship verbs only: `@mitigates`, `@exposes`, `@confirmed`, `@flows`, `@handles`, `@boundary`, `@comment`, `@validates`, `@audit`, `@owns`, `@assumes`, `@transfers`, `@feature`. (`@actor` is a definition — it belongs in the definitions file with `@asset`/`@threat`/`@control`. `@entitles` is proposed, not written — see rule 9.) Four of those are permitted everywhere and written almost nowhere, because nothing tells you *when*. Their triggers: +6. Source files use relationship verbs only: `@mitigates`, `@exposes`, `@confirmed`, `@flows`, `@handles`, `@boundary`, `@comment`, `@validates`, `@audit`, `@owns`, `@assumes`, `@transfers`, `@feature`, and the reach verbs `@agents`, `@reaches`, `@effects`, `@gates` (rule 10). (`@actor` is a definition — it belongs in the definitions file with `@asset`/`@threat`/`@control`. `@entitles` is proposed, not written — see rule 9.) Four of those are permitted everywhere and written almost nowhere, because nothing tells you *when*. Their triggers: - **You wrote or changed a test that pins a control** → `@validates #control for Asset -- "what the test proves"`, on the test. Control first, asset second, joined by `for`. - **The code trusts a caller, library, or platform to hold a property it never checks itself** → `@assumes Asset -- "what must hold, and what breaks if it does not"`. One asset, and the whole assumption lives in the description. - **Responsibility for a threat lands on a vendor, an upstream service, or another team's component** → `@transfers #threat from Source to Target`. Both ends must be assets that already exist in the definitions file — a vendor needs an `External.*` asset declared first, and a bare company name will not parse. @@ -60,6 +60,7 @@ the same change.** This includes: new endpoints, authentication/authorization lo 7. Write coupled annotation blocks that tell a complete story: risk + control (or audit) + data flow + context note — plus `@boundary` when that flow crosses a trust change (written `from to ` when you know which side is less trusted), and `@handles` when the asset touches classified data. Never write a lone `@exposes` without follow-up. 8. Avoid `@shield` unless a human explicitly asks to hide code from AI — it creates blind spots. 9. **NEVER write `@entitles` into source — propose it.** `@entitles` says a privilege is *supposed* to have this effect, so an over-grant closes a real privilege escalation as by-design. That makes it the second claim you may not make on a human's behalf, alongside `@accepts`. File it with `guardlink entitle --propose` (or `guardlink_entitlement_propose`) and a human's acceptance is what writes the annotation, under their name; an `@entitles` in source with no accepted proposal is a validation error. The rationale must cite the authz code as `file:line` or the claim is inert — parsed and then ignored. It never suppresses 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 it cannot say whose object it was. When unsure which role the code actually requires, write `@comment` describing what you saw instead: under-granting costs noise, over-granting hides a real bug. +10. **Declare what an embedded LLM agent can reach.** When code hands an agent a tool, an MCP server, file, shell or database access, or an internal method: `@agents #agent to on Asset` on the registration (declare the agent with `@actor` first), `@effects on Asset` on the code that acts, and `@gates Asset by #approver` on any approval step. `@reaches` is the same claim for a principal that is not an LLM agent (a CI runner, a service account); one actor under both verbs is a validation error. You may write all four — they say what the code *can* do. Use the capability token an `@entitles` would use: `unentitled reaches` then lists the capabilities no human approved, and for those you propose an entitlement (rule 9), never write one. ### Workflow (while coding) @@ -77,7 +78,8 @@ the same change.** This includes: new endpoints, authentication/authorization lo guessing** — send it a bad query to get the list. Beyond `asset`/`threat`/`control`, it reaches every relation the model holds: `owner of X`, `handles pii`, `assumptions for X`, `audits for X`, `validations for X`, `acceptances`, `transfers`, `comments for X`, `shields`, `cross-repo refs`, - and `cwe:CWE-89` / `owasp:A03` for scanner findings. + `agents`, `unentitled reaches`, `effects for X`, `gates for X`, + and `cwe:CWE-89` / `owasp:A03` for scanner findings. Without MCP, `guardlink lookup ""` answers the same forms. - Reference matches report `matched_via: exact | alias | substring`. A substring match is a suggestion, not an identification; `ambiguous` with `candidates` means several records tied. @@ -96,6 +98,9 @@ the same change.** This includes: new endpoints, authentication/authorization lo @confirmed #sqli on App.API [critical] cwe:CWE-89 -- "Pentest verified: raw SQL injection via email param" @feature "SSO Login" -- "Single sign-on authentication flow" @owns security-team for App.API -- "Team responsible for reviews" +@agents #support-agent to run-sql on #tool-surface as #agent-session -- "run_sql tool registered in tools.ts" +@effects write on #users-db as #db-service -- "Executes the model-written SQL via db.raw" +@gates #payments by #support-human for issue-refund -- "requireApproval() blocks until a human approves" @actor Namespace_Admin (#ns-admin) -- "Administers one namespace's configuration" (definitions file) @comment -- "Rate limit: 100 req/15min via express-rate-limit" ``` @@ -174,11 +179,11 @@ _Full records with descriptions and locations: `guardlink_lookup("asset ")`, ### Model Stats -874 annotations, 20 assets, 16 threats, 14 controls, 127 exposures, 0 confirmed, 129 mitigations, 3 actors, 1 entitlements, 202 flows, 2 features +884 annotations, 20 assets, 16 threats, 14 controls, 127 exposures, 0 confirmed, 130 mitigations, 3 actors, 1 entitlements, 202 flows, 2 features ### Block Freshness -- `annotation_hash`: `sha256-v3:95e447ec72ce4924c563d83aae0d3e594f518789e71d00ded358ac3255cf772a` +- `annotation_hash`: `sha256-v3:58a5b22940f27108f0890a80cc509fe5e68f0e97f88f9812332529f9e00b729a` 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 6b2a99d1..babcd784 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -52,7 +52,7 @@ the same change.** This includes: new endpoints, authentication/authorization lo 3. **Use `@confirmed` for verified exploits.** When a pentest, CXG scan, or manual reproduction proves a threat is exploitable, mark it with `@confirmed #threat on Asset [severity] -- "evidence"`. This is distinct from `@exposes` (theoretical) — `@confirmed` means real, verified, not a false positive. Include severity based on actual observed impact. **Without that evidence in hand, `@exposes` stands and you do not promote it** — reading the code is not reproduction. To get evidence, `bugb intake ""` turns a description into a test plan and prints a PLAN_ID; an operator approves it, never you. 4. Do not delete or mangle existing annotations. Treat them as part of the code. Edit only when intentionally changing the threat model. 5. Definitions (`@asset`, `@threat`, `@control` with `(#id)`) live in `.guardlink/definitions.ts`. Reuse existing `#id`s — never redefine. If you need a new asset or threat, add the definition there first, then reference it in source files. -6. Source files use relationship verbs only: `@mitigates`, `@exposes`, `@confirmed`, `@flows`, `@handles`, `@boundary`, `@comment`, `@validates`, `@audit`, `@owns`, `@assumes`, `@transfers`, `@feature`. (`@actor` is a definition — it belongs in the definitions file with `@asset`/`@threat`/`@control`. `@entitles` is proposed, not written — see rule 9.) Four of those are permitted everywhere and written almost nowhere, because nothing tells you *when*. Their triggers: +6. Source files use relationship verbs only: `@mitigates`, `@exposes`, `@confirmed`, `@flows`, `@handles`, `@boundary`, `@comment`, `@validates`, `@audit`, `@owns`, `@assumes`, `@transfers`, `@feature`, and the reach verbs `@agents`, `@reaches`, `@effects`, `@gates` (rule 10). (`@actor` is a definition — it belongs in the definitions file with `@asset`/`@threat`/`@control`. `@entitles` is proposed, not written — see rule 9.) Four of those are permitted everywhere and written almost nowhere, because nothing tells you *when*. Their triggers: - **You wrote or changed a test that pins a control** → `@validates #control for Asset -- "what the test proves"`, on the test. Control first, asset second, joined by `for`. - **The code trusts a caller, library, or platform to hold a property it never checks itself** → `@assumes Asset -- "what must hold, and what breaks if it does not"`. One asset, and the whole assumption lives in the description. - **Responsibility for a threat lands on a vendor, an upstream service, or another team's component** → `@transfers #threat from Source to Target`. Both ends must be assets that already exist in the definitions file — a vendor needs an `External.*` asset declared first, and a bare company name will not parse. @@ -60,6 +60,7 @@ the same change.** This includes: new endpoints, authentication/authorization lo 7. Write coupled annotation blocks that tell a complete story: risk + control (or audit) + data flow + context note — plus `@boundary` when that flow crosses a trust change (written `from to ` when you know which side is less trusted), and `@handles` when the asset touches classified data. Never write a lone `@exposes` without follow-up. 8. Avoid `@shield` unless a human explicitly asks to hide code from AI — it creates blind spots. 9. **NEVER write `@entitles` into source — propose it.** `@entitles` says a privilege is *supposed* to have this effect, so an over-grant closes a real privilege escalation as by-design. That makes it the second claim you may not make on a human's behalf, alongside `@accepts`. File it with `guardlink entitle --propose` (or `guardlink_entitlement_propose`) and a human's acceptance is what writes the annotation, under their name; an `@entitles` in source with no accepted proposal is a validation error. The rationale must cite the authz code as `file:line` or the claim is inert — parsed and then ignored. It never suppresses 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 it cannot say whose object it was. When unsure which role the code actually requires, write `@comment` describing what you saw instead: under-granting costs noise, over-granting hides a real bug. +10. **Declare what an embedded LLM agent can reach.** When code hands an agent a tool, an MCP server, file, shell or database access, or an internal method: `@agents #agent to on Asset` on the registration (declare the agent with `@actor` first), `@effects on Asset` on the code that acts, and `@gates Asset by #approver` on any approval step. `@reaches` is the same claim for a principal that is not an LLM agent (a CI runner, a service account); one actor under both verbs is a validation error. You may write all four — they say what the code *can* do. Use the capability token an `@entitles` would use: `unentitled reaches` then lists the capabilities no human approved, and for those you propose an entitlement (rule 9), never write one. ### Workflow (while coding) @@ -77,7 +78,8 @@ the same change.** This includes: new endpoints, authentication/authorization lo guessing** — send it a bad query to get the list. Beyond `asset`/`threat`/`control`, it reaches every relation the model holds: `owner of X`, `handles pii`, `assumptions for X`, `audits for X`, `validations for X`, `acceptances`, `transfers`, `comments for X`, `shields`, `cross-repo refs`, - and `cwe:CWE-89` / `owasp:A03` for scanner findings. + `agents`, `unentitled reaches`, `effects for X`, `gates for X`, + and `cwe:CWE-89` / `owasp:A03` for scanner findings. Without MCP, `guardlink lookup ""` answers the same forms. - Reference matches report `matched_via: exact | alias | substring`. A substring match is a suggestion, not an identification; `ambiguous` with `candidates` means several records tied. @@ -96,6 +98,9 @@ the same change.** This includes: new endpoints, authentication/authorization lo @confirmed #sqli on App.API [critical] cwe:CWE-89 -- "Pentest verified: raw SQL injection via email param" @feature "SSO Login" -- "Single sign-on authentication flow" @owns security-team for App.API -- "Team responsible for reviews" +@agents #support-agent to run-sql on #tool-surface as #agent-session -- "run_sql tool registered in tools.ts" +@effects write on #users-db as #db-service -- "Executes the model-written SQL via db.raw" +@gates #payments by #support-human for issue-refund -- "requireApproval() blocks until a human approves" @actor Namespace_Admin (#ns-admin) -- "Administers one namespace's configuration" (definitions file) @comment -- "Rate limit: 100 req/15min via express-rate-limit" ``` @@ -174,11 +179,11 @@ _Full records with descriptions and locations: `guardlink_lookup("asset ")`, ### Model Stats -874 annotations, 20 assets, 16 threats, 14 controls, 127 exposures, 0 confirmed, 129 mitigations, 3 actors, 1 entitlements, 202 flows, 2 features +884 annotations, 20 assets, 16 threats, 14 controls, 127 exposures, 0 confirmed, 130 mitigations, 3 actors, 1 entitlements, 202 flows, 2 features ### Block Freshness -- `annotation_hash`: `sha256-v3:95e447ec72ce4924c563d83aae0d3e594f518789e71d00ded358ac3255cf772a` +- `annotation_hash`: `sha256-v3:58a5b22940f27108f0890a80cc509fe5e68f0e97f88f9812332529f9e00b729a` 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 cda8529d..55933646 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: 874 annotations · 20 assets · 16 threats · 14 controls · 127 exposures · 202 flows -Content hash: `sha256-v3:95e447ec72ce4924c563d83aae0d3e594f518789e71d00ded358ac3255cf772a` — identical hash means identical model. +Current model: 884 annotations · 20 assets · 16 threats · 14 controls · 127 exposures · 202 flows +Content hash: `sha256-v3:58a5b22940f27108f0890a80cc509fe5e68f0e97f88f9812332529f9e00b729a` — identical hash means identical model. --- @@ -122,6 +122,10 @@ Assets are referenced as `#id` or as a `Dotted.Path`; both resolve to the same n | `@owns` | `@owns for -- "who reviews changes here"` | | `@handles` | `@handles on -- "what data"` | | `@assumes` | `@assumes -- "what must hold for this to be safe"` | +| `@agents` | `@agents to on as -- "the tool registration"` — an LLM agent can invoke this | +| `@reaches` | `@reaches to on as -- "where it is granted"` — the same, for a principal that is not an agent | +| `@effects` | `@effects on as -- "what this code does"` | +| `@gates` | `@gates by for -- "the approval step"` | | `@feature` | `@feature "Name" -- "what it groups"` | | `@comment` | `@comment -- "context that fits no other verb"` | | `@accepts` | `@accepts on by "" until -- "why"` — **human only, never write this** | diff --git a/.guardlink/graph/MANIFEST.json b/.guardlink/graph/MANIFEST.json index 3aa3d74f..a528c852 100644 --- a/.guardlink/graph/MANIFEST.json +++ b/.guardlink/graph/MANIFEST.json @@ -1,12 +1,12 @@ { "schema_version": 2, - "annotation_hash": "sha256-v3:95e447ec72ce4924c563d83aae0d3e594f518789e71d00ded358ac3255cf772a", + "annotation_hash": "sha256-v3:58a5b22940f27108f0890a80cc509fe5e68f0e97f88f9812332529f9e00b729a", "generator": "guardlink@2.1.0", "artifacts": [ { "path": ".guardlink/graph/threat-graph.mmd", "bytes": 12296, - "annotation_hash": "sha256-v3:95e447ec72ce4924c563d83aae0d3e594f518789e71d00ded358ac3255cf772a", + "annotation_hash": "sha256-v3:58a5b22940f27108f0890a80cc509fe5e68f0e97f88f9812332529f9e00b729a", "renderable": true, "render": { "text_size": 10687, @@ -16,7 +16,7 @@ { "path": ".guardlink/graph/dataflow.mmd", "bytes": 15435, - "annotation_hash": "sha256-v3:95e447ec72ce4924c563d83aae0d3e594f518789e71d00ded358ac3255cf772a", + "annotation_hash": "sha256-v3:58a5b22940f27108f0890a80cc509fe5e68f0e97f88f9812332529f9e00b729a", "renderable": true, "render": { "text_size": 13347, @@ -26,7 +26,7 @@ { "path": ".guardlink/graph/attack-surface.mmd", "bytes": 6630, - "annotation_hash": "sha256-v3:95e447ec72ce4924c563d83aae0d3e594f518789e71d00ded358ac3255cf772a", + "annotation_hash": "sha256-v3:58a5b22940f27108f0890a80cc509fe5e68f0e97f88f9812332529f9e00b729a", "renderable": true, "render": { "text_size": 4957, @@ -36,7 +36,7 @@ { "path": ".guardlink/graph/by-feature/dashboard.mmd", "bytes": 2301, - "annotation_hash": "sha256-v3:95e447ec72ce4924c563d83aae0d3e594f518789e71d00ded358ac3255cf772a", + "annotation_hash": "sha256-v3:58a5b22940f27108f0890a80cc509fe5e68f0e97f88f9812332529f9e00b729a", "renderable": true, "render": { "text_size": 813, @@ -46,7 +46,7 @@ { "path": ".guardlink/graph/by-feature/mcp-integration.mmd", "bytes": 2749, - "annotation_hash": "sha256-v3:95e447ec72ce4924c563d83aae0d3e594f518789e71d00ded358ac3255cf772a", + "annotation_hash": "sha256-v3:58a5b22940f27108f0890a80cc509fe5e68f0e97f88f9812332529f9e00b729a", "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:95e447ec72ce4924c563d83aae0d3e594f518789e71d00ded358ac3255cf772a", + "annotation_hash": "sha256-v3:58a5b22940f27108f0890a80cc509fe5e68f0e97f88f9812332529f9e00b729a", "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:95e447ec72ce4924c563d83aae0d3e594f518789e71d00ded358ac3255cf772a", + "annotation_hash": "sha256-v3:58a5b22940f27108f0890a80cc509fe5e68f0e97f88f9812332529f9e00b729a", "renderable": true, "render": { "text_size": 1404, @@ -76,7 +76,7 @@ { "path": ".guardlink/graph/by-asset/blame.threats.mmd", "bytes": 2211, - "annotation_hash": "sha256-v3:95e447ec72ce4924c563d83aae0d3e594f518789e71d00ded358ac3255cf772a", + "annotation_hash": "sha256-v3:58a5b22940f27108f0890a80cc509fe5e68f0e97f88f9812332529f9e00b729a", "renderable": true, "render": { "text_size": 1477, @@ -86,7 +86,7 @@ { "path": ".guardlink/graph/by-asset/blame.flows.mmd", "bytes": 1621, - "annotation_hash": "sha256-v3:95e447ec72ce4924c563d83aae0d3e594f518789e71d00ded358ac3255cf772a", + "annotation_hash": "sha256-v3:58a5b22940f27108f0890a80cc509fe5e68f0e97f88f9812332529f9e00b729a", "renderable": true, "render": { "text_size": 803, @@ -96,7 +96,7 @@ { "path": ".guardlink/graph/by-asset/cli.threats.mmd", "bytes": 2185, - "annotation_hash": "sha256-v3:95e447ec72ce4924c563d83aae0d3e594f518789e71d00ded358ac3255cf772a", + "annotation_hash": "sha256-v3:58a5b22940f27108f0890a80cc509fe5e68f0e97f88f9812332529f9e00b729a", "renderable": true, "render": { "text_size": 1373, @@ -106,7 +106,7 @@ { "path": ".guardlink/graph/by-asset/cli.flows.mmd", "bytes": 1930, - "annotation_hash": "sha256-v3:95e447ec72ce4924c563d83aae0d3e594f518789e71d00ded358ac3255cf772a", + "annotation_hash": "sha256-v3:58a5b22940f27108f0890a80cc509fe5e68f0e97f88f9812332529f9e00b729a", "renderable": true, "render": { "text_size": 1098, @@ -116,7 +116,7 @@ { "path": ".guardlink/graph/by-asset/codegraph.threats.mmd", "bytes": 1853, - "annotation_hash": "sha256-v3:95e447ec72ce4924c563d83aae0d3e594f518789e71d00ded358ac3255cf772a", + "annotation_hash": "sha256-v3:58a5b22940f27108f0890a80cc509fe5e68f0e97f88f9812332529f9e00b729a", "renderable": true, "render": { "text_size": 1125, @@ -126,7 +126,7 @@ { "path": ".guardlink/graph/by-asset/codegraph.flows.mmd", "bytes": 2363, - "annotation_hash": "sha256-v3:95e447ec72ce4924c563d83aae0d3e594f518789e71d00ded358ac3255cf772a", + "annotation_hash": "sha256-v3:58a5b22940f27108f0890a80cc509fe5e68f0e97f88f9812332529f9e00b729a", "renderable": true, "render": { "text_size": 1517, @@ -136,7 +136,7 @@ { "path": ".guardlink/graph/by-asset/dashboard.threats.mmd", "bytes": 2326, - "annotation_hash": "sha256-v3:95e447ec72ce4924c563d83aae0d3e594f518789e71d00ded358ac3255cf772a", + "annotation_hash": "sha256-v3:58a5b22940f27108f0890a80cc509fe5e68f0e97f88f9812332529f9e00b729a", "renderable": true, "render": { "text_size": 1586, @@ -146,7 +146,7 @@ { "path": ".guardlink/graph/by-asset/dashboard.flows.mmd", "bytes": 2011, - "annotation_hash": "sha256-v3:95e447ec72ce4924c563d83aae0d3e594f518789e71d00ded358ac3255cf772a", + "annotation_hash": "sha256-v3:58a5b22940f27108f0890a80cc509fe5e68f0e97f88f9812332529f9e00b729a", "renderable": true, "render": { "text_size": 1167, @@ -156,7 +156,7 @@ { "path": ".guardlink/graph/by-asset/diff.threats.mmd", "bytes": 1764, - "annotation_hash": "sha256-v3:95e447ec72ce4924c563d83aae0d3e594f518789e71d00ded358ac3255cf772a", + "annotation_hash": "sha256-v3:58a5b22940f27108f0890a80cc509fe5e68f0e97f88f9812332529f9e00b729a", "renderable": true, "render": { "text_size": 1044, @@ -166,7 +166,7 @@ { "path": ".guardlink/graph/by-asset/diff.flows.mmd", "bytes": 1559, - "annotation_hash": "sha256-v3:95e447ec72ce4924c563d83aae0d3e594f518789e71d00ded358ac3255cf772a", + "annotation_hash": "sha256-v3:58a5b22940f27108f0890a80cc509fe5e68f0e97f88f9812332529f9e00b729a", "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:95e447ec72ce4924c563d83aae0d3e594f518789e71d00ded358ac3255cf772a", + "annotation_hash": "sha256-v3:58a5b22940f27108f0890a80cc509fe5e68f0e97f88f9812332529f9e00b729a", "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:95e447ec72ce4924c563d83aae0d3e594f518789e71d00ded358ac3255cf772a", + "annotation_hash": "sha256-v3:58a5b22940f27108f0890a80cc509fe5e68f0e97f88f9812332529f9e00b729a", "renderable": true, "render": { "text_size": 1073, @@ -196,7 +196,7 @@ { "path": ".guardlink/graph/by-asset/gate.threats.mmd", "bytes": 1274, - "annotation_hash": "sha256-v3:95e447ec72ce4924c563d83aae0d3e594f518789e71d00ded358ac3255cf772a", + "annotation_hash": "sha256-v3:58a5b22940f27108f0890a80cc509fe5e68f0e97f88f9812332529f9e00b729a", "renderable": true, "render": { "text_size": 566, @@ -206,7 +206,7 @@ { "path": ".guardlink/graph/by-asset/gate.flows.mmd", "bytes": 1831, - "annotation_hash": "sha256-v3:95e447ec72ce4924c563d83aae0d3e594f518789e71d00ded358ac3255cf772a", + "annotation_hash": "sha256-v3:58a5b22940f27108f0890a80cc509fe5e68f0e97f88f9812332529f9e00b729a", "renderable": true, "render": { "text_size": 1013, @@ -216,7 +216,7 @@ { "path": ".guardlink/graph/by-asset/init.threats.mmd", "bytes": 1683, - "annotation_hash": "sha256-v3:95e447ec72ce4924c563d83aae0d3e594f518789e71d00ded358ac3255cf772a", + "annotation_hash": "sha256-v3:58a5b22940f27108f0890a80cc509fe5e68f0e97f88f9812332529f9e00b729a", "renderable": true, "render": { "text_size": 967, @@ -226,7 +226,7 @@ { "path": ".guardlink/graph/by-asset/init.flows.mmd", "bytes": 2103, - "annotation_hash": "sha256-v3:95e447ec72ce4924c563d83aae0d3e594f518789e71d00ded358ac3255cf772a", + "annotation_hash": "sha256-v3:58a5b22940f27108f0890a80cc509fe5e68f0e97f88f9812332529f9e00b729a", "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:95e447ec72ce4924c563d83aae0d3e594f518789e71d00ded358ac3255cf772a", + "annotation_hash": "sha256-v3:58a5b22940f27108f0890a80cc509fe5e68f0e97f88f9812332529f9e00b729a", "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:95e447ec72ce4924c563d83aae0d3e594f518789e71d00ded358ac3255cf772a", + "annotation_hash": "sha256-v3:58a5b22940f27108f0890a80cc509fe5e68f0e97f88f9812332529f9e00b729a", "renderable": true, "render": { "text_size": 1342, @@ -256,7 +256,7 @@ { "path": ".guardlink/graph/by-asset/mcp.threats.mmd", "bytes": 1807, - "annotation_hash": "sha256-v3:95e447ec72ce4924c563d83aae0d3e594f518789e71d00ded358ac3255cf772a", + "annotation_hash": "sha256-v3:58a5b22940f27108f0890a80cc509fe5e68f0e97f88f9812332529f9e00b729a", "renderable": true, "render": { "text_size": 1003, @@ -266,7 +266,7 @@ { "path": ".guardlink/graph/by-asset/mcp.flows.mmd", "bytes": 1883, - "annotation_hash": "sha256-v3:95e447ec72ce4924c563d83aae0d3e594f518789e71d00ded358ac3255cf772a", + "annotation_hash": "sha256-v3:58a5b22940f27108f0890a80cc509fe5e68f0e97f88f9812332529f9e00b729a", "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:95e447ec72ce4924c563d83aae0d3e594f518789e71d00ded358ac3255cf772a", + "annotation_hash": "sha256-v3:58a5b22940f27108f0890a80cc509fe5e68f0e97f88f9812332529f9e00b729a", "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:95e447ec72ce4924c563d83aae0d3e594f518789e71d00ded358ac3255cf772a", + "annotation_hash": "sha256-v3:58a5b22940f27108f0890a80cc509fe5e68f0e97f88f9812332529f9e00b729a", "renderable": true, "render": { "text_size": 1299, @@ -296,7 +296,7 @@ { "path": ".guardlink/graph/by-asset/parser.threats.mmd", "bytes": 1716, - "annotation_hash": "sha256-v3:95e447ec72ce4924c563d83aae0d3e594f518789e71d00ded358ac3255cf772a", + "annotation_hash": "sha256-v3:58a5b22940f27108f0890a80cc509fe5e68f0e97f88f9812332529f9e00b729a", "renderable": true, "render": { "text_size": 910, @@ -306,7 +306,7 @@ { "path": ".guardlink/graph/by-asset/parser.flows.mmd", "bytes": 2142, - "annotation_hash": "sha256-v3:95e447ec72ce4924c563d83aae0d3e594f518789e71d00ded358ac3255cf772a", + "annotation_hash": "sha256-v3:58a5b22940f27108f0890a80cc509fe5e68f0e97f88f9812332529f9e00b729a", "renderable": true, "render": { "text_size": 1302, @@ -316,7 +316,7 @@ { "path": ".guardlink/graph/by-asset/report.threats.mmd", "bytes": 1222, - "annotation_hash": "sha256-v3:95e447ec72ce4924c563d83aae0d3e594f518789e71d00ded358ac3255cf772a", + "annotation_hash": "sha256-v3:58a5b22940f27108f0890a80cc509fe5e68f0e97f88f9812332529f9e00b729a", "renderable": true, "render": { "text_size": 510, @@ -326,7 +326,7 @@ { "path": ".guardlink/graph/by-asset/report.flows.mmd", "bytes": 1225, - "annotation_hash": "sha256-v3:95e447ec72ce4924c563d83aae0d3e594f518789e71d00ded358ac3255cf772a", + "annotation_hash": "sha256-v3:58a5b22940f27108f0890a80cc509fe5e68f0e97f88f9812332529f9e00b729a", "renderable": true, "render": { "text_size": 431, @@ -336,7 +336,7 @@ { "path": ".guardlink/graph/by-asset/report-metadata.threats.mmd", "bytes": 1323, - "annotation_hash": "sha256-v3:95e447ec72ce4924c563d83aae0d3e594f518789e71d00ded358ac3255cf772a", + "annotation_hash": "sha256-v3:58a5b22940f27108f0890a80cc509fe5e68f0e97f88f9812332529f9e00b729a", "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:95e447ec72ce4924c563d83aae0d3e594f518789e71d00ded358ac3255cf772a", + "annotation_hash": "sha256-v3:58a5b22940f27108f0890a80cc509fe5e68f0e97f88f9812332529f9e00b729a", "renderable": true, "render": { "text_size": 1105, @@ -356,7 +356,7 @@ { "path": ".guardlink/graph/by-asset/sarif.threats.mmd", "bytes": 1350, - "annotation_hash": "sha256-v3:95e447ec72ce4924c563d83aae0d3e594f518789e71d00ded358ac3255cf772a", + "annotation_hash": "sha256-v3:58a5b22940f27108f0890a80cc509fe5e68f0e97f88f9812332529f9e00b729a", "renderable": true, "render": { "text_size": 638, @@ -366,7 +366,7 @@ { "path": ".guardlink/graph/by-asset/sarif.flows.mmd", "bytes": 1344, - "annotation_hash": "sha256-v3:95e447ec72ce4924c563d83aae0d3e594f518789e71d00ded358ac3255cf772a", + "annotation_hash": "sha256-v3:58a5b22940f27108f0890a80cc509fe5e68f0e97f88f9812332529f9e00b729a", "renderable": true, "render": { "text_size": 546, @@ -376,7 +376,7 @@ { "path": ".guardlink/graph/by-asset/suggest.threats.mmd", "bytes": 1582, - "annotation_hash": "sha256-v3:95e447ec72ce4924c563d83aae0d3e594f518789e71d00ded358ac3255cf772a", + "annotation_hash": "sha256-v3:58a5b22940f27108f0890a80cc509fe5e68f0e97f88f9812332529f9e00b729a", "renderable": true, "render": { "text_size": 860, @@ -386,7 +386,7 @@ { "path": ".guardlink/graph/by-asset/suggest.flows.mmd", "bytes": 2143, - "annotation_hash": "sha256-v3:95e447ec72ce4924c563d83aae0d3e594f518789e71d00ded358ac3255cf772a", + "annotation_hash": "sha256-v3:58a5b22940f27108f0890a80cc509fe5e68f0e97f88f9812332529f9e00b729a", "renderable": true, "render": { "text_size": 1299, @@ -396,7 +396,7 @@ { "path": ".guardlink/graph/by-asset/tui.threats.mmd", "bytes": 2073, - "annotation_hash": "sha256-v3:95e447ec72ce4924c563d83aae0d3e594f518789e71d00ded358ac3255cf772a", + "annotation_hash": "sha256-v3:58a5b22940f27108f0890a80cc509fe5e68f0e97f88f9812332529f9e00b729a", "renderable": true, "render": { "text_size": 1349, @@ -406,7 +406,7 @@ { "path": ".guardlink/graph/by-asset/tui.flows.mmd", "bytes": 2052, - "annotation_hash": "sha256-v3:95e447ec72ce4924c563d83aae0d3e594f518789e71d00ded358ac3255cf772a", + "annotation_hash": "sha256-v3:58a5b22940f27108f0890a80cc509fe5e68f0e97f88f9812332529f9e00b729a", "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:95e447ec72ce4924c563d83aae0d3e594f518789e71d00ded358ac3255cf772a", + "annotation_hash": "sha256-v3:58a5b22940f27108f0890a80cc509fe5e68f0e97f88f9812332529f9e00b729a", "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:95e447ec72ce4924c563d83aae0d3e594f518789e71d00ded358ac3255cf772a", + "annotation_hash": "sha256-v3:58a5b22940f27108f0890a80cc509fe5e68f0e97f88f9812332529f9e00b729a", "renderable": true, "render": { "text_size": 1122, @@ -436,7 +436,7 @@ { "path": ".guardlink/graph/by-boundary/agent-boundary.mmd", "bytes": 2227, - "annotation_hash": "sha256-v3:95e447ec72ce4924c563d83aae0d3e594f518789e71d00ded358ac3255cf772a", + "annotation_hash": "sha256-v3:58a5b22940f27108f0890a80cc509fe5e68f0e97f88f9812332529f9e00b729a", "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:95e447ec72ce4924c563d83aae0d3e594f518789e71d00ded358ac3255cf772a", + "annotation_hash": "sha256-v3:58a5b22940f27108f0890a80cc509fe5e68f0e97f88f9812332529f9e00b729a", "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:95e447ec72ce4924c563d83aae0d3e594f518789e71d00ded358ac3255cf772a", + "annotation_hash": "sha256-v3:58a5b22940f27108f0890a80cc509fe5e68f0e97f88f9812332529f9e00b729a", "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:95e447ec72ce4924c563d83aae0d3e594f518789e71d00ded358ac3255cf772a", + "annotation_hash": "sha256-v3:58a5b22940f27108f0890a80cc509fe5e68f0e97f88f9812332529f9e00b729a", "renderable": true, "render": { "text_size": 1410, @@ -476,7 +476,7 @@ { "path": ".guardlink/graph/by-boundary/codegraph-boundary.mmd", "bytes": 2397, - "annotation_hash": "sha256-v3:95e447ec72ce4924c563d83aae0d3e594f518789e71d00ded358ac3255cf772a", + "annotation_hash": "sha256-v3:58a5b22940f27108f0890a80cc509fe5e68f0e97f88f9812332529f9e00b729a", "renderable": true, "render": { "text_size": 1517, @@ -486,7 +486,7 @@ { "path": ".guardlink/graph/by-boundary/git-boundary.mmd", "bytes": 2046, - "annotation_hash": "sha256-v3:95e447ec72ce4924c563d83aae0d3e594f518789e71d00ded358ac3255cf772a", + "annotation_hash": "sha256-v3:58a5b22940f27108f0890a80cc509fe5e68f0e97f88f9812332529f9e00b729a", "renderable": true, "render": { "text_size": 1230, @@ -496,7 +496,7 @@ { "path": ".guardlink/graph/by-boundary/mcp-boundary.mmd", "bytes": 2227, - "annotation_hash": "sha256-v3:95e447ec72ce4924c563d83aae0d3e594f518789e71d00ded358ac3255cf772a", + "annotation_hash": "sha256-v3:58a5b22940f27108f0890a80cc509fe5e68f0e97f88f9812332529f9e00b729a", "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:95e447ec72ce4924c563d83aae0d3e594f518789e71d00ded358ac3255cf772a", + "annotation_hash": "sha256-v3:58a5b22940f27108f0890a80cc509fe5e68f0e97f88f9812332529f9e00b729a", "renderable": true, "render": { "text_size": 1413, @@ -516,7 +516,7 @@ { "path": ".guardlink/graph/by-boundary/fs-boundary.mmd", "bytes": 2260, - "annotation_hash": "sha256-v3:95e447ec72ce4924c563d83aae0d3e594f518789e71d00ded358ac3255cf772a", + "annotation_hash": "sha256-v3:58a5b22940f27108f0890a80cc509fe5e68f0e97f88f9812332529f9e00b729a", "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:95e447ec72ce4924c563d83aae0d3e594f518789e71d00ded358ac3255cf772a", + "annotation_hash": "sha256-v3:58a5b22940f27108f0890a80cc509fe5e68f0e97f88f9812332529f9e00b729a", "renderable": true, "render": { "text_size": 1212, @@ -535,8 +535,8 @@ }, { "path": ".guardlink/model.json", - "bytes": 278305, - "annotation_hash": "sha256-v3:95e447ec72ce4924c563d83aae0d3e594f518789e71d00ded358ac3255cf772a" + "bytes": 281362, + "annotation_hash": "sha256-v3:58a5b22940f27108f0890a80cc509fe5e68f0e97f88f9812332529f9e00b729a" } ] } diff --git a/.guardlink/graph/attack-surface.mmd b/.guardlink/graph/attack-surface.mmd index 30e34632..5b277b79 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:95e447ec72ce4924c563d83aae0d3e594f518789e71d00ded358ac3255cf772a +%% annotation_hash: sha256-v3:58a5b22940f27108f0890a80cc509fe5e68f0e97f88f9812332529f9e00b729a %% 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 10e54fe6..22da115f 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:95e447ec72ce4924c563d83aae0d3e594f518789e71d00ded358ac3255cf772a +%% annotation_hash: sha256-v3:58a5b22940f27108f0890a80cc509fe5e68f0e97f88f9812332529f9e00b729a %% 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 95ef90cb..d78331ea 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:95e447ec72ce4924c563d83aae0d3e594f518789e71d00ded358ac3255cf772a +%% annotation_hash: sha256-v3:58a5b22940f27108f0890a80cc509fe5e68f0e97f88f9812332529f9e00b729a %% 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 3b40cbdb..27ec93b8 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:95e447ec72ce4924c563d83aae0d3e594f518789e71d00ded358ac3255cf772a +%% annotation_hash: sha256-v3:58a5b22940f27108f0890a80cc509fe5e68f0e97f88f9812332529f9e00b729a %% 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 d3cf511b..db6cb7b4 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:95e447ec72ce4924c563d83aae0d3e594f518789e71d00ded358ac3255cf772a +%% annotation_hash: sha256-v3:58a5b22940f27108f0890a80cc509fe5e68f0e97f88f9812332529f9e00b729a %% 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 a0a3a43b..85e6a4b9 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:95e447ec72ce4924c563d83aae0d3e594f518789e71d00ded358ac3255cf772a +%% annotation_hash: sha256-v3:58a5b22940f27108f0890a80cc509fe5e68f0e97f88f9812332529f9e00b729a %% 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 6b94b3ce..5b379c24 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:95e447ec72ce4924c563d83aae0d3e594f518789e71d00ded358ac3255cf772a +%% annotation_hash: sha256-v3:58a5b22940f27108f0890a80cc509fe5e68f0e97f88f9812332529f9e00b729a %% 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 a85b73d3..5d6ec4d7 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:95e447ec72ce4924c563d83aae0d3e594f518789e71d00ded358ac3255cf772a +%% annotation_hash: sha256-v3:58a5b22940f27108f0890a80cc509fe5e68f0e97f88f9812332529f9e00b729a %% 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 a660f7ed..4a8e8368 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:95e447ec72ce4924c563d83aae0d3e594f518789e71d00ded358ac3255cf772a +%% annotation_hash: sha256-v3:58a5b22940f27108f0890a80cc509fe5e68f0e97f88f9812332529f9e00b729a %% 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 d9ff012c..5e2e850f 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:95e447ec72ce4924c563d83aae0d3e594f518789e71d00ded358ac3255cf772a +%% annotation_hash: sha256-v3:58a5b22940f27108f0890a80cc509fe5e68f0e97f88f9812332529f9e00b729a %% 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 68612f9d..e7b22731 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:95e447ec72ce4924c563d83aae0d3e594f518789e71d00ded358ac3255cf772a +%% annotation_hash: sha256-v3:58a5b22940f27108f0890a80cc509fe5e68f0e97f88f9812332529f9e00b729a %% 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 38082490..3cfe0478 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:95e447ec72ce4924c563d83aae0d3e594f518789e71d00ded358ac3255cf772a +%% annotation_hash: sha256-v3:58a5b22940f27108f0890a80cc509fe5e68f0e97f88f9812332529f9e00b729a %% 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 9ac58b0b..1817e287 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:95e447ec72ce4924c563d83aae0d3e594f518789e71d00ded358ac3255cf772a +%% annotation_hash: sha256-v3:58a5b22940f27108f0890a80cc509fe5e68f0e97f88f9812332529f9e00b729a %% 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 ba1df699..68fc1b35 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:95e447ec72ce4924c563d83aae0d3e594f518789e71d00ded358ac3255cf772a +%% annotation_hash: sha256-v3:58a5b22940f27108f0890a80cc509fe5e68f0e97f88f9812332529f9e00b729a %% 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 be689ef7..c6ac4a9d 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:95e447ec72ce4924c563d83aae0d3e594f518789e71d00ded358ac3255cf772a +%% annotation_hash: sha256-v3:58a5b22940f27108f0890a80cc509fe5e68f0e97f88f9812332529f9e00b729a %% 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 40299def..1da23c56 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:95e447ec72ce4924c563d83aae0d3e594f518789e71d00ded358ac3255cf772a +%% annotation_hash: sha256-v3:58a5b22940f27108f0890a80cc509fe5e68f0e97f88f9812332529f9e00b729a %% 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 fbcaf086..3329f9d7 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:95e447ec72ce4924c563d83aae0d3e594f518789e71d00ded358ac3255cf772a +%% annotation_hash: sha256-v3:58a5b22940f27108f0890a80cc509fe5e68f0e97f88f9812332529f9e00b729a %% 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 575fa3d6..9485386b 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:95e447ec72ce4924c563d83aae0d3e594f518789e71d00ded358ac3255cf772a +%% annotation_hash: sha256-v3:58a5b22940f27108f0890a80cc509fe5e68f0e97f88f9812332529f9e00b729a %% 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 bcc80dcc..5aa1acfb 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:95e447ec72ce4924c563d83aae0d3e594f518789e71d00ded358ac3255cf772a +%% annotation_hash: sha256-v3:58a5b22940f27108f0890a80cc509fe5e68f0e97f88f9812332529f9e00b729a %% 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 99e7b699..b48cff6d 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:95e447ec72ce4924c563d83aae0d3e594f518789e71d00ded358ac3255cf772a +%% annotation_hash: sha256-v3:58a5b22940f27108f0890a80cc509fe5e68f0e97f88f9812332529f9e00b729a %% 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 bda3e89f..b03c5d76 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:95e447ec72ce4924c563d83aae0d3e594f518789e71d00ded358ac3255cf772a +%% annotation_hash: sha256-v3:58a5b22940f27108f0890a80cc509fe5e68f0e97f88f9812332529f9e00b729a %% 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 6ab20bfb..376071a0 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:95e447ec72ce4924c563d83aae0d3e594f518789e71d00ded358ac3255cf772a +%% annotation_hash: sha256-v3:58a5b22940f27108f0890a80cc509fe5e68f0e97f88f9812332529f9e00b729a %% 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 af35c884..944f7423 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:95e447ec72ce4924c563d83aae0d3e594f518789e71d00ded358ac3255cf772a +%% annotation_hash: sha256-v3:58a5b22940f27108f0890a80cc509fe5e68f0e97f88f9812332529f9e00b729a %% 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 34e19827..4c724f83 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:95e447ec72ce4924c563d83aae0d3e594f518789e71d00ded358ac3255cf772a +%% annotation_hash: sha256-v3:58a5b22940f27108f0890a80cc509fe5e68f0e97f88f9812332529f9e00b729a %% 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 f3ac5e45..59b9721a 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:95e447ec72ce4924c563d83aae0d3e594f518789e71d00ded358ac3255cf772a +%% annotation_hash: sha256-v3:58a5b22940f27108f0890a80cc509fe5e68f0e97f88f9812332529f9e00b729a %% 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 2a4bc78f..813ee0c0 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:95e447ec72ce4924c563d83aae0d3e594f518789e71d00ded358ac3255cf772a +%% annotation_hash: sha256-v3:58a5b22940f27108f0890a80cc509fe5e68f0e97f88f9812332529f9e00b729a %% 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 b5df1957..b595f65d 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 (30 lower-severity claim(s) omitted to keep it readable) -%% annotation_hash: sha256-v3:95e447ec72ce4924c563d83aae0d3e594f518789e71d00ded358ac3255cf772a +%% 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:58a5b22940f27108f0890a80cc509fe5e68f0e97f88f9812332529f9e00b729a %% 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 caac8343..e19405d3 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:95e447ec72ce4924c563d83aae0d3e594f518789e71d00ded358ac3255cf772a +%% annotation_hash: sha256-v3:58a5b22940f27108f0890a80cc509fe5e68f0e97f88f9812332529f9e00b729a %% 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 02110882..20b34e02 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:95e447ec72ce4924c563d83aae0d3e594f518789e71d00ded358ac3255cf772a +%% annotation_hash: sha256-v3:58a5b22940f27108f0890a80cc509fe5e68f0e97f88f9812332529f9e00b729a %% 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 cb9b9422..160d7ec3 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:95e447ec72ce4924c563d83aae0d3e594f518789e71d00ded358ac3255cf772a +%% annotation_hash: sha256-v3:58a5b22940f27108f0890a80cc509fe5e68f0e97f88f9812332529f9e00b729a %% generator: guardlink@2.1.0 %% --- %% This diagram is derived from the annotations in this repository. If diff --git a/.guardlink/graph/by-asset/report.threats.mmd b/.guardlink/graph/by-asset/report.threats.mmd index 310f66da..a3c80678 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:95e447ec72ce4924c563d83aae0d3e594f518789e71d00ded358ac3255cf772a +%% annotation_hash: sha256-v3:58a5b22940f27108f0890a80cc509fe5e68f0e97f88f9812332529f9e00b729a %% 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 91682003..4257d4b5 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:95e447ec72ce4924c563d83aae0d3e594f518789e71d00ded358ac3255cf772a +%% annotation_hash: sha256-v3:58a5b22940f27108f0890a80cc509fe5e68f0e97f88f9812332529f9e00b729a %% 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 f0861084..2d5775ad 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:95e447ec72ce4924c563d83aae0d3e594f518789e71d00ded358ac3255cf772a +%% annotation_hash: sha256-v3:58a5b22940f27108f0890a80cc509fe5e68f0e97f88f9812332529f9e00b729a %% 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 de00285d..a4540ee4 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:95e447ec72ce4924c563d83aae0d3e594f518789e71d00ded358ac3255cf772a +%% annotation_hash: sha256-v3:58a5b22940f27108f0890a80cc509fe5e68f0e97f88f9812332529f9e00b729a %% 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 fcbc2cbd..8bd1ee79 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:95e447ec72ce4924c563d83aae0d3e594f518789e71d00ded358ac3255cf772a +%% annotation_hash: sha256-v3:58a5b22940f27108f0890a80cc509fe5e68f0e97f88f9812332529f9e00b729a %% 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 db778b9d..2a4e42a4 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:95e447ec72ce4924c563d83aae0d3e594f518789e71d00ded358ac3255cf772a +%% annotation_hash: sha256-v3:58a5b22940f27108f0890a80cc509fe5e68f0e97f88f9812332529f9e00b729a %% 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 6e87cd74..69cb6f28 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:95e447ec72ce4924c563d83aae0d3e594f518789e71d00ded358ac3255cf772a +%% annotation_hash: sha256-v3:58a5b22940f27108f0890a80cc509fe5e68f0e97f88f9812332529f9e00b729a %% 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 3705b234..697a03ab 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:95e447ec72ce4924c563d83aae0d3e594f518789e71d00ded358ac3255cf772a +%% annotation_hash: sha256-v3:58a5b22940f27108f0890a80cc509fe5e68f0e97f88f9812332529f9e00b729a %% 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 f5d7a22e..9ca979c2 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:95e447ec72ce4924c563d83aae0d3e594f518789e71d00ded358ac3255cf772a +%% annotation_hash: sha256-v3:58a5b22940f27108f0890a80cc509fe5e68f0e97f88f9812332529f9e00b729a %% 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 5555e4dc..f7ec4dbe 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:95e447ec72ce4924c563d83aae0d3e594f518789e71d00ded358ac3255cf772a +%% annotation_hash: sha256-v3:58a5b22940f27108f0890a80cc509fe5e68f0e97f88f9812332529f9e00b729a %% 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 7063b650..d502d8a3 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:95e447ec72ce4924c563d83aae0d3e594f518789e71d00ded358ac3255cf772a +%% annotation_hash: sha256-v3:58a5b22940f27108f0890a80cc509fe5e68f0e97f88f9812332529f9e00b729a %% 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 33ce9105..8c2ace56 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:95e447ec72ce4924c563d83aae0d3e594f518789e71d00ded358ac3255cf772a +%% annotation_hash: sha256-v3:58a5b22940f27108f0890a80cc509fe5e68f0e97f88f9812332529f9e00b729a %% 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 c4783266..990cbce6 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:95e447ec72ce4924c563d83aae0d3e594f518789e71d00ded358ac3255cf772a +%% annotation_hash: sha256-v3:58a5b22940f27108f0890a80cc509fe5e68f0e97f88f9812332529f9e00b729a %% 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 aa75cb7d..9673fbcf 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:95e447ec72ce4924c563d83aae0d3e594f518789e71d00ded358ac3255cf772a +%% annotation_hash: sha256-v3:58a5b22940f27108f0890a80cc509fe5e68f0e97f88f9812332529f9e00b729a %% 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 fe4930b4..42299697 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:95e447ec72ce4924c563d83aae0d3e594f518789e71d00ded358ac3255cf772a +%% annotation_hash: sha256-v3:58a5b22940f27108f0890a80cc509fe5e68f0e97f88f9812332529f9e00b729a %% 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 82687fe0..af9e9224 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:95e447ec72ce4924c563d83aae0d3e594f518789e71d00ded358ac3255cf772a +%% annotation_hash: sha256-v3:58a5b22940f27108f0890a80cc509fe5e68f0e97f88f9812332529f9e00b729a %% 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 2c2c096d..c6eaaeb0 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:95e447ec72ce4924c563d83aae0d3e594f518789e71d00ded358ac3255cf772a +%% annotation_hash: sha256-v3:58a5b22940f27108f0890a80cc509fe5e68f0e97f88f9812332529f9e00b729a %% 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 e376d11b..48bd06c1 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:95e447ec72ce4924c563d83aae0d3e594f518789e71d00ded358ac3255cf772a +%% annotation_hash: sha256-v3:58a5b22940f27108f0890a80cc509fe5e68f0e97f88f9812332529f9e00b729a %% 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 29f0cd47..6a8da321 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:95e447ec72ce4924c563d83aae0d3e594f518789e71d00ded358ac3255cf772a +%% annotation_hash: sha256-v3:58a5b22940f27108f0890a80cc509fe5e68f0e97f88f9812332529f9e00b729a %% 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 12f3a260..6717b03e 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:95e447ec72ce4924c563d83aae0d3e594f518789e71d00ded358ac3255cf772a +%% annotation_hash: sha256-v3:58a5b22940f27108f0890a80cc509fe5e68f0e97f88f9812332529f9e00b729a %% 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 9c4e8d2c..88c06c5a 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:95e447ec72ce4924c563d83aae0d3e594f518789e71d00ded358ac3255cf772a +%% annotation_hash: sha256-v3:58a5b22940f27108f0890a80cc509fe5e68f0e97f88f9812332529f9e00b729a %% 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 5c4572f4..09086bb4 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:95e447ec72ce4924c563d83aae0d3e594f518789e71d00ded358ac3255cf772a +%% annotation_hash: sha256-v3:58a5b22940f27108f0890a80cc509fe5e68f0e97f88f9812332529f9e00b729a %% generator: guardlink@2.1.0 %% --- %% This diagram is derived from the annotations in this repository. If diff --git a/.guardlink/graph/index.md b/.guardlink/graph/index.md index c575796a..c34c2bf5 100644 --- a/.guardlink/graph/index.md +++ b/.guardlink/graph/index.md @@ -43,7 +43,7 @@ a small neighbourhood into a hairball. | [`mcp.flows.mmd`](by-asset/mcp.flows.mmd) | #mcp and its flow neighbourhood, grown until the drawing stopped fitting — 20 neighbour(s) one hop out are not shown | | [`merge-engine.threats.mmd`](by-asset/merge-engine.threats.mmd) | #merge-engine only — its own threats and controls | | [`merge-engine.flows.mmd`](by-asset/merge-engine.flows.mmd) | #merge-engine and its flow neighbourhood, grown until the drawing stopped fitting — 34 neighbour(s) one hop out are not shown | -| [`parser.threats.mmd`](by-asset/parser.threats.mmd) | #parser only — its own threats and controls, narrowed to high and critical (30 lower-severity claim(s) omitted to keep it readable) | +| [`parser.threats.mmd`](by-asset/parser.threats.mmd) | #parser only — its own threats and controls, narrowed to high and critical (31 lower-severity claim(s) omitted to keep it readable) | | [`parser.flows.mmd`](by-asset/parser.flows.mmd) | #parser and its flow neighbourhood, grown until the drawing stopped fitting — 13 neighbour(s) one hop out are not shown | | [`report.threats.mmd`](by-asset/report.threats.mmd) | #report only — its own threats and controls | | [`report.flows.mmd`](by-asset/report.flows.mmd) | #report and its flow neighbourhood, grown until the drawing stopped fitting — 11 neighbour(s) one hop out are not shown | @@ -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:95e447ec72ce4924c563d83aae0d3e594f518789e71d00ded358ac3255cf772a`. If that +Generated by guardlink@2.1.0 from annotation hash `sha256-v3:58a5b22940f27108f0890a80cc509fe5e68f0e97f88f9812332529f9e00b729a`. 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 1a56b0d9..581c3585 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:95e447ec72ce4924c563d83aae0d3e594f518789e71d00ded358ac3255cf772a +%% annotation_hash: sha256-v3:58a5b22940f27108f0890a80cc509fe5e68f0e97f88f9812332529f9e00b729a %% 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 2b711c5e..d9b4e8ac 100644 --- a/.guardlink/model.json +++ b/.guardlink/model.json @@ -1,12 +1,12 @@ { "provenance": { - "annotation_hash": "sha256-v3:95e447ec72ce4924c563d83aae0d3e594f518789e71d00ded358ac3255cf772a", + "annotation_hash": "sha256-v3:58a5b22940f27108f0890a80cc509fe5e68f0e97f88f9812332529f9e00b729a", "generator": "guardlink@2.1.0" }, "version": "1.2.0", "project": "guardlink", - "source_files": 164, - "annotations_parsed": 874, + "source_files": 165, + "annotations_parsed": 884, "annotated_files": [ ".guardlink/definitions.ts", "src/agents/config.ts", @@ -111,6 +111,7 @@ "src/parser/parse-file.ts", "src/parser/parse-line.ts", "src/parser/parse-project.ts", + "src/parser/reach.ts", "src/parser/reanchor.ts", "src/parser/route.ts", "src/parser/validate.ts", @@ -836,6 +837,9 @@ } } ], + "reaches": [], + "effects": [], + "gates": [], "mitigations": [ { "asset": "#agent-launcher", @@ -1154,7 +1158,7 @@ "description": "Path is the constant LEDGER_FILE under a root that must already carry .guardlink/; targets only select claims, they are never written to", "location": { "file": "src/cli/index.ts", - "line": 553 + "line": 556 } }, { @@ -1584,7 +1588,7 @@ "description": "Patterns are simple and bounded", "location": { "file": "src/mcp/lookup.ts", - "line": 20 + "line": 24 } }, { @@ -1694,7 +1698,7 @@ "description": "Target is always resolveGalPath(root, file); the caller cannot choose the path, and a file escaping root is rejected", "location": { "file": "src/parser/apply-annotations.ts", - "line": 18 + "line": 22 } }, { @@ -1704,7 +1708,7 @@ "description": "Every line is re-parsed with parseLine before write; anything that does not parse, or parses as @accepts, is rejected", "location": { "file": "src/parser/apply-annotations.ts", - "line": 20 + "line": 24 } }, { @@ -1807,6 +1811,16 @@ "line": 6 } }, + { + "asset": "#parser", + "threat": "#redos", + "control": "#regex-anchoring", + "description": "@agents, @reaches, @effects and @gates reuse the anchored ASSET_REF, THREAT_REF, CAPABILITY and DESC fragments and add only a closed alternation for the effect class", + "location": { + "file": "src/parser/parse-line.ts", + "line": 10 + } + }, { "asset": "#parser", "threat": "#path-traversal", @@ -1834,7 +1848,7 @@ "description": "Existence only: no read, no write, and the result reaches the model as a warning string that echoes the path the author already wrote", "location": { "file": "src/parser/parse-project.ts", - "line": 143 + "line": 144 } }, { @@ -2620,7 +2634,7 @@ "description": "The one command that writes .guardlink/verified.json", "location": { "file": "src/cli/index.ts", - "line": 552 + "line": 555 } }, { @@ -3060,7 +3074,7 @@ "description": "Regex patterns applied to query strings", "location": { "file": "src/mcp/lookup.ts", - "line": 19 + "line": 23 } }, { @@ -3242,7 +3256,7 @@ "description": "Writes annotation sidecars from tool input", "location": { "file": "src/parser/apply-annotations.ts", - "line": 17 + "line": 21 } }, { @@ -3255,7 +3269,7 @@ "description": "Annotation lines arrive as caller-supplied text", "location": { "file": "src/parser/apply-annotations.ts", - "line": 19 + "line": 23 } }, { @@ -3448,7 +3462,7 @@ "description": "`@source file:` is author-controlled text and is joined onto the project root to test existence", "location": { "file": "src/parser/parse-project.ts", - "line": 142 + "line": 143 } }, { @@ -4317,7 +4331,7 @@ "description": "Opt-in attribution; without --blame the JSON is byte-identical to before", "location": { "file": "src/cli/index.ts", - "line": 273 + "line": 274 } }, { @@ -4328,7 +4342,7 @@ "description": "status reports verified/stale/unverified claim counts alongside annotation coverage", "location": { "file": "src/cli/index.ts", - "line": 323 + "line": 324 } }, { @@ -4339,7 +4353,7 @@ "description": "status reports how many exposures were tested, and with what outcome", "location": { "file": "src/cli/index.ts", - "line": 324 + "line": 325 } }, { @@ -4350,7 +4364,7 @@ "description": "status --blame reads attribution without touching the model", "location": { "file": "src/cli/index.ts", - "line": 326 + "line": 327 } }, { @@ -4361,7 +4375,7 @@ "description": "Read-only; a corrupt ledger becomes a validate error (ledger-corrupt) instead of being silently treated as absent", "location": { "file": "src/cli/index.ts", - "line": 397 + "line": 400 } }, { @@ -4372,7 +4386,7 @@ "description": "validate --code-graph: project root; ranked entry points with route access, or a status", "location": { "file": "src/cli/index.ts", - "line": 407 + "line": 410 } }, { @@ -4383,7 +4397,7 @@ "description": "Targets, --by and mode flags", "location": { "file": "src/cli/index.ts", - "line": 554 + "line": 557 } }, { @@ -4394,7 +4408,7 @@ "description": "Attribution read from blame, log -L and commit trailers", "location": { "file": "src/cli/index.ts", - "line": 671 + "line": 674 } }, { @@ -4405,7 +4419,7 @@ "description": "report --blame attaches attribution before the metadata spread, so the shared records carry it", "location": { "file": "src/cli/index.ts", - "line": 864 + "line": 867 } }, { @@ -4416,7 +4430,7 @@ "description": "pentest profile: each claim's tested state goes into the SARIF", "location": { "file": "src/cli/index.ts", - "line": 1263 + "line": 1285 } }, { @@ -4427,7 +4441,7 @@ "description": "Project root; the graph answers with ranked entry points or a status", "location": { "file": "src/cli/index.ts", - "line": 1623 + "line": 1645 } }, { @@ -4438,7 +4452,7 @@ "description": "The model before and after the agent", "location": { "file": "src/cli/index.ts", - "line": 1670 + "line": 1692 } }, { @@ -4449,7 +4463,7 @@ "description": "Which assets sit on an undefended path, for the ranking", "location": { "file": "src/cli/index.ts", - "line": 1805 + "line": 1827 } }, { @@ -4460,7 +4474,7 @@ "description": "cxg findings recorded as confirmations, joined to claims", "location": { "file": "src/cli/index.ts", - "line": 1825 + "line": 1847 } }, { @@ -4471,7 +4485,7 @@ "description": "Boundary outcomes read back by claim key", "location": { "file": "src/cli/index.ts", - "line": 1932 + "line": 1954 } }, { @@ -4482,7 +4496,7 @@ "description": "dashboard --blame; identities land in the page, so blame.identity=hash is the setting for a shared dashboard", "location": { "file": "src/cli/index.ts", - "line": 3040 + "line": 3062 } }, { @@ -4493,7 +4507,7 @@ "description": "dashboard --since : the model at the ref, diffed against this one", "location": { "file": "src/cli/index.ts", - "line": 3043 + "line": 3065 } }, { @@ -5087,7 +5101,7 @@ "description": "Query input path", "location": { "file": "src/mcp/lookup.ts", - "line": 21 + "line": 25 } }, { @@ -5164,7 +5178,7 @@ "description": "Attribution read from blame, log -L and commit trailers for the connected agent", "location": { "file": "src/mcp/server.ts", - "line": 1210 + "line": 1212 } }, { @@ -5263,7 +5277,7 @@ "description": "Structured annotation write path", "location": { "file": "src/parser/apply-annotations.ts", - "line": 21 + "line": 25 } }, { @@ -5274,7 +5288,7 @@ "description": "Sidecar append", "location": { "file": "src/parser/apply-annotations.ts", - "line": 22 + "line": 26 } }, { @@ -5472,7 +5486,7 @@ "description": "Declared @source paths probed for existence", "location": { "file": "src/parser/parse-project.ts", - "line": 144 + "line": 145 } }, { @@ -5780,7 +5794,7 @@ "description": "Attribution read from blame, log -L and commit trailers", "location": { "file": "src/tui/commands.ts", - "line": 1005 + "line": 1006 } }, { @@ -6532,7 +6546,7 @@ "description": "Author identities printed or emitted as JSON; --identity hash (or blame.identity in config.json) redacts emails", "location": { "file": "src/cli/index.ts", - "line": 672 + "line": 675 } }, { @@ -6541,7 +6555,7 @@ "description": "Identity strings printed to the terminal", "location": { "file": "src/cli/index.ts", - "line": 3921 + "line": 3943 } }, { @@ -6658,7 +6672,7 @@ "description": "Author identities, in the configured identity mode (blame.identity in config.json), reach the MCP client", "location": { "file": "src/mcp/server.ts", - "line": 1211 + "line": 1213 } }, { @@ -6721,7 +6735,7 @@ "description": "Author identities printed to the terminal in the configured identity mode", "location": { "file": "src/tui/commands.ts", - "line": 1006 + "line": 1007 } }, { @@ -6766,82 +6780,82 @@ "reason": "Example annotation block for reference, excluded from parsing", "location": { "file": "src/agents/prompts.ts", - "line": 296 + "line": 297 } }, { "location": { "file": "src/agents/prompts.ts", - "line": 309 + "line": 310 } }, { "reason": "Description examples, excluded from parsing", "location": { "file": "src/agents/prompts.ts", - "line": 316 + "line": 317 } }, { "location": { "file": "src/agents/prompts.ts", - "line": 326 + "line": 327 } }, { "reason": "Flow examples, excluded from parsing", "location": { "file": "src/agents/prompts.ts", - "line": 333 + "line": 334 } }, { "location": { "file": "src/agents/prompts.ts", - "line": 342 + "line": 343 } }, { "reason": "Boundary examples, excluded from parsing", "location": { "file": "src/agents/prompts.ts", - "line": 350 + "line": 351 } }, { "location": { "file": "src/agents/prompts.ts", - "line": 356 + "line": 357 } }, { "reason": "Actor example, excluded from parsing", "location": { "file": "src/agents/prompts.ts", - "line": 417 + "line": 418 } }, { "location": { "file": "src/agents/prompts.ts", - "line": 419 + "line": 420 } }, { "reason": "Entitlement example, excluded from parsing", "location": { "file": "src/agents/prompts.ts", - "line": 436 + "line": 437 } }, { "location": { "file": "src/agents/prompts.ts", - "line": 439 + "line": 440 } }, { - "reason": "@accepts alternative examples, excluded from parsing", + "reason": "Agent reach example, excluded from parsing", "location": { "file": "src/agents/prompts.ts", "line": 454 @@ -6850,46 +6864,59 @@ { "location": { "file": "src/agents/prompts.ts", - "line": 464 + "line": 458 + } + }, + { + "reason": "@accepts alternative examples, excluded from parsing", + "location": { + "file": "src/agents/prompts.ts", + "line": 473 + } + }, + { + "location": { + "file": "src/agents/prompts.ts", + "line": 483 } }, { "reason": "Definition syntax examples, excluded from parsing", "location": { "file": "src/agents/prompts.ts", - "line": 491 + "line": 510 } }, { "location": { "file": "src/agents/prompts.ts", - "line": 495 + "line": 514 } }, { "reason": "Relationship syntax examples, excluded from parsing", "location": { "file": "src/agents/prompts.ts", - "line": 500 + "line": 519 } }, { "location": { "file": "src/agents/prompts.ts", - "line": 516 + "line": 535 } }, { "reason": "Externalized relationship examples, excluded from parsing", "location": { "file": "src/agents/prompts.ts", - "line": 521 + "line": 540 } }, { "location": { "file": "src/agents/prompts.ts", - "line": 526 + "line": 545 } }, { @@ -6954,33 +6981,33 @@ { "location": { "file": "src/init/templates.ts", - "line": 324 + "line": 330 } }, { "reason": "agentInstructions renders the Quick Syntax block: verb lines at column 0 that document the grammar rather than claim anything", "location": { "file": "src/init/templates.ts", - "line": 392 + "line": 398 } }, { "location": { "file": "src/init/templates.ts", - "line": 541 + "line": 552 } }, { "reason": "README example annotations, excluded from parsing — they would otherwise register as real records", "location": { "file": "src/init/templates.ts", - "line": 1033 + "line": 1047 } }, { "location": { "file": "src/init/templates.ts", - "line": 1345 + "line": 1363 } } ], @@ -7336,189 +7363,196 @@ "description": "Closes the same blind spot ci's third check already closed (src/ci/index.ts) for the validate/CI-gate surface", "location": { "file": "src/cli/index.ts", - "line": 398 + "line": 401 } }, { "description": "Re-locking a stale claim is an assertion that the control still holds, so the default form never does it: --stale, --all or a named target is required, and each says so in its output", "location": { "file": "src/cli/index.ts", - "line": 555 + "line": 558 } }, { "description": "Always exits 0 once the model parsed: outside a git checkout every claim reads as unattributed, which is an answer, not a failure. Only an unknown --identity is an error", "location": { "file": "src/cli/index.ts", - "line": 673 + "line": 676 + } + }, + { + "description": "guardlink lookup is read-only: it parses the project as parse and validate do and answers with the same pure lookup() the guardlink_lookup MCP tool uses, so it adds no input surface beyond the query text, which lookup's anchored patterns already bound", + "location": { + "file": "src/cli/index.ts", + "line": 1230 } }, { "description": "--baseline reads a file the caller names, relative to the cwd like any CLI path argument; it is parsed as JSON and only compared, never written or executed", "location": { "file": "src/cli/index.ts", - "line": 1248 + "line": 1270 } }, { "description": "ONE bound, applied before any renderer runs. `--intake` used to take the whole queue while --json and the table took -n, so the renderer built for the downstream consumer was the one that could not be asked for a bounded brief — `-n 3 --intake` printed all 142 on temporal. A per-renderer slice is the shape that drifts; a renderer added later inherits this one", "location": { "file": "src/cli/index.ts", - "line": 1809 + "line": 1831 } }, { "description": "`key` is the claim key, the same one hypotheses-list/v1 carries and `hypothesis confirm`/`--from-scan` join on, so a consumer can address a queued claim instead of joining back to `hypothesis list` on (asset, threat, file, line). That join is positional and GAP-58 proved it unsafe: this population collides at the coarser (asset, threat, file) tier. The schema stays at v1 deliberately — the field is purely additive, nothing v1 carried is renamed or removed, and a consumer tells whether a build has it by the field's presence; bumping would strand consumers pinned to the v1 string for a change that breaks none of them", "location": { "file": "src/cli/index.ts", - "line": 1812 + "line": 1834 } }, { "description": "`total` is the queue length before the bound, carried into all three renderers because a bounded queue that does not say it is bounded reads as the whole of what there is to test — the failure this guards is an operator handing bugb a 10-item plan off a 142-claim queue believing nothing else is outstanding. It is additive exactly as `key` is, so the schema stays at v1: nothing v1 carried is renamed or removed, and a consumer holding a page tells it apart from a whole queue by the field's presence", "location": { "file": "src/cli/index.ts", - "line": 1813 + "line": 1835 } }, { "description": "`defaultVerifier` already returns `human:` (src/parser/verify.ts:172), so prefixing it here recorded `human:human:` — the form the ledger schema and this command's own --by help both say it is not. Do NOT 'repair' older entries that carry the doubled form: `by` is display and provenance metadata, never a join key — upsert and every reader match on the claim key — so pre-fix and post-fix entries coexist in one ledger and nothing stops matching. An explicit --by is passed through verbatim on this path, which is how `--by cxg:login-sqli` names its scheme", "location": { "file": "src/cli/index.ts", - "line": 1820 + "line": 1842 } }, { "description": "Withheld is a fact about a CLAIM, not about a finding. Several findings can join to one claim — a stamped one beside an unstamped one during a cxg stamp rollout, or two templates probing the same exposure — so a per-finding list printed one claim's remediation command twice and, worse, could report a claim as skipped in the very run that wrote it. Keyed by record.key on the same rule `writes` uses, last occurrence kept because that is the entry the ledger ends up holding", "location": { "file": "src/cli/index.ts", - "line": 1828 + "line": 1850 } }, { "description": "THE one calculation of where a line sits once the writes are done, used by every coordinate this command hands a user: the confirmations that landed, the ones already present, and the by-hand commands for both the withheld and the ambiguous. Each done.line is a PRE-write coordinate — descending order guarantees nothing at or above a target had been spliced when it was written — so an insertion at or at a lower number than a pre-write position pushes that position down by one. Only SUCCESSFUL writes are in `done`, so a write that threw shifts nothing, and with no --write it is empty and this is the identity. Two sites computing this independently is the shape that disagrees silently, and this command has already produced that defect twice", "location": { "file": "src/cli/index.ts", - "line": 1831 + "line": 1853 } }, { "description": "Writes are applied per file in DESCENDING line order. Every record.line was resolved from the PRE-write model and writeConfirmedLine splices at line + 1, so an ascending pass shifts each later target in that file down by one: with consecutive @exposes lines the second write lands beneath the wrong claim's @exposes and its guards all pass, writing a confirmation against a claim the probe never tested. Inserting below a line never moves a line above it, so descending order cannot shift a target it has not written yet", "location": { "file": "src/cli/index.ts", - "line": 1835 + "line": 1857 } }, { "description": "ONE CLAIM, ONE WRITE. Two findings can carry the same claim key — the ordinary shape when two cxg templates probe one exposure — and both resolve to the same record and fold into ONE ledger entry, so writing per finding attempted the same insertion twice and the second was refused as a failure. Keyed by record.key, keeping the LAST occurrence: upsert replaces the entry object on every call, so only the final one is the entry the ledger actually holds", "location": { "file": "src/cli/index.ts", - "line": 1836 + "line": 1858 } }, { "description": "An exit code is a claim about what happened, and it has to be true in BOTH directions: honest about an under-write and honest about a success. A confirmation already in the source is the state we were asked to reach — from an earlier run, or a re-import of the same report — so it is separated from a real failure by the TYPE writeConfirmedLine returns, never by matching the wording of an error. Every remaining throw is a failure and still exits non-zero", "location": { "file": "src/cli/index.ts", - "line": 1839 + "line": 1861 } }, { "description": "Reconciled against what the writes actually did. A claim whose confirmation IS in the source — inserted just now or already there — was not withheld, whatever else joined to it coarsely, so it is neither told to the operator as skipped nor counted toward the exit status. The rule it serves is the one the exit check states: the code is a claim about what happened and has to be true in BOTH directions, and reporting a claim as withheld in the run that wrote it is the half that was still false", "location": { "file": "src/cli/index.ts", - "line": 1850 + "line": 1872 } }, { "description": "An inserted @confirmed comes to rest directly above the line it displaced, so it is that line's new position less one — derived from afterWrites rather than counted a second way. Printed in reading order, not in the order the writes went in", "location": { "file": "src/cli/index.ts", - "line": 1852 + "line": 1874 } }, { "description": "Said in terms of what is true rather than as an error: the confirmation is there. The line an already-present @confirmed was found at is a pre-write coordinate like any other — the scan that found it stops at the next @exposes, and descending order means every insertion so far sits below that — so it goes through the same afterWrites", "location": { "file": "src/cli/index.ts", - "line": 1855 + "line": 1877 } }, { "description": "--write only inserts an UNCONTESTED key-verified confirmation. An @confirmed in source is a claim in this repository that the exposure was tested and proven — later scans, reviewers and the SARIF export all read it, and unlike a ledger entry it never expires. A location- or CWE-joined confirmation may be about a different exposure than the one probed (GAP-58), and a contested one came from a report that named two different claims at once, so identity is in doubt in both cases. Losing that throughput is the point: those are exactly the joins that can be wrong", "location": { "file": "src/cli/index.ts", - "line": 1857 + "line": 1879 } }, { "description": "Reported AFTER the writes, at the line the withheld claim occupies once they have landed. Printing pre-write lines here handed the operator a command naming a line that this same run then moved: with consecutive @exposes the shifted line holds a DIFFERENT claim, resolveTarget finds exactly one @exposes there, and the operator writes a confirmation against an exposure the probe never tested — GAP-58 one layer out, in a human's fingers instead of ours. Documenting the hazard one block down was not guarding against it", "location": { "file": "src/cli/index.ts", - "line": 1858 + "line": 1880 } }, { "description": "GAP-77: `hypothesis confirm` addresses a claim only as file:line, so every command printed here is position-dependent and is correct AT THE MOMENT OF PRINTING and no longer — a later write, or an edit, moves the target again. Tracked there, not widened here", "location": { "file": "src/cli/index.ts", - "line": 1859 + "line": 1881 } }, { "description": "The candidates for an ambiguous finding are by-hand targets like the withheld ones, so they are emitted here rather than inside formatImport: that formatter runs BEFORE the writes, and a key-verified write higher up the same file moves an ambiguous candidate exactly as it moves a withheld claim. With consecutive @exposes the shifted line holds a DIFFERENT claim, resolveTarget finds exactly one @exposes there, and the operator records a confirmation against an exposure the probe never tested — GAP-58 through a human's fingers, on our own instruction. Stays on stdout, where it was", "location": { "file": "src/cli/index.ts", - "line": 1871 + "line": 1893 } }, { "description": "GAP-77: `hypothesis confirm` addresses a claim only as file:line, so these are correct AT THE MOMENT OF PRINTING and no longer. That residue is the addressing scheme and is tracked there; a command this run itself invalidated is a different problem and is fixed here", "location": { "file": "src/cli/index.ts", - "line": 1872 + "line": 1894 } }, { "description": "A scan whose findings were not all recorded exits non-zero: ambiguous, stale (the stamped claim is not in the model any more), malformed (a stamp arrived that is not a claim key) and unmatched each need a human, and a silent 0 would read as 'all imported'. A --write that withheld any confirmation is the same fact about the same run, so it exits non-zero too — PARTIALLY written included, not only the all-withheld case. The reader of this exit code is bravos, which orchestrates this loop and never sees the `! skipped` lines on stderr: give 0 two meanings and a half-written run is indistinguishable from a complete one, and the orchestrator proceeds believing the corpus carries confirmations that are not in it", "location": { "file": "src/cli/index.ts", - "line": 1877 + "line": 1899 } }, { "description": "The by-hand path reads the same typed outcome as --from-scan. A claim whose source already carries this confirmation is in the state that was asked for, so it is reported and exits 0; it used to throw into the ✗ handler and exit non-zero on a run that changed nothing and needed to change nothing", "location": { "file": "src/cli/index.ts", - "line": 1888 + "line": 1910 } }, { "description": "The accepted names and containers are INTERPOLATED from the one shared definition, not written out here. A hand-written copy drifts benignly when the definition widens (the help under-advertises) and harmfully when it narrows: the help then advertises a name the reader refuses, which is the one thing this definition exists to make impossible. The no-stamp banner in format.ts interpolates for the same reason, and both are pinned by tests that take the names out of the text and require the reader to accept them", "location": { "file": "src/cli/index.ts", - "line": 1915 + "line": 1937 } }, { "description": "ONE string stating BOTH behaviours, because --write sits on `confirm` and genuinely serves both paths. An author writing against this flag reads --help, not our runtime output: 'inserts the @confirmed line' alone over-promises on the one surface that writes into someone else's repository, and an operator who believes every joined confirmation landed stops checking the ones that did not — which is exactly the knowledge the withholding rule exists to give them. Two per-path wordings were rejected for the reason this branch has already corrected on names, containers, grammars and positions: two descriptions of one thing can disagree, and one string cannot contradict itself", "location": { "file": "src/cli/index.ts", - "line": 1918 + "line": 1940 } }, { "description": "Displays ledger state (absent, corrupt, or counts) computed by the caller; this function performs no I/O of its own", "location": { "file": "src/cli/index.ts", - "line": 3844 + "line": 3866 } }, { "description": "Prints only what the payload already holds; no I/O here", "location": { "file": "src/cli/index.ts", - "line": 3922 + "line": 3944 } }, { @@ -7780,6 +7814,13 @@ "line": 15 } }, + { + "description": "A reach that becomes unentitled is reported on its own, like a new unmitigated exposure: newUnentitledReaches comes from findUnentitledReaches, the join lookup answers with", + "location": { + "file": "src/diff/engine.ts", + "line": 17 + } + }, { "description": "Pure string formatting; no I/O. Stale entitlements print even when the delta is otherwise empty — a claim whose cited authz code moved is exactly the thing that must not disappear quietly (actor-entitlement design §3.3)", "location": { @@ -7808,6 +7849,13 @@ "line": 9 } }, + { + "description": "@agents, @reaches, @effects and @gates are observations an agent may write, so no rule rejects them. A @gates that does not say what the approval step is gets the vague-description warning, because a gate claim is what a reviewer reads to decide a route is held", + "location": { + "file": "src/gate/lint.ts", + "line": 10 + } + }, { "description": "The numbers are measured against a specific panel and font size, both cited above — deriving rather than guessing is the same discipline render-budget.ts applies to Mermaid's own limits", "location": { @@ -8008,7 +8056,14 @@ "description": "Pure function; no I/O; operates on in-memory ThreatModel", "location": { "file": "src/mcp/lookup.ts", - "line": 22 + "line": 26 + } + }, + { + "description": "The reach forms project the parsed model and add no input surface. `unentitled reaches` answers from findUnentitledReaches, the same join diff uses, so the two cannot disagree about can minus may", + "location": { + "file": "src/mcp/lookup.ts", + "line": 27 } }, { @@ -8029,7 +8084,7 @@ "description": "Non-mutating: computeBlame leaves the cached model untouched, so a later guardlink_parse in the same session never carries blame unasked. Nothing is written to the repository", "location": { "file": "src/mcp/server.ts", - "line": 1212 + "line": 1214 } }, { @@ -8134,7 +8189,7 @@ "description": "Idempotent by construction: an identical @source block is detected and skipped rather than duplicated", "location": { "file": "src/parser/apply-annotations.ts", - "line": 23 + "line": 27 } }, { @@ -8326,6 +8381,13 @@ "line": 9 } }, + { + "description": "@effects takes a closed effect set, like @handles takes a closed classification set: an open string cannot be compared across repositories or drive a query", + "location": { + "file": "src/parser/parse-line.ts", + "line": 11 + } + }, { "description": "Scans standalone .gal files in addition to comment-based source annotations", "location": { @@ -8351,7 +8413,14 @@ "description": "A traversing path is already a broken annotation and this is the diagnostic that says so; refusing to stat it would return the silence this function exists to remove", "location": { "file": "src/parser/parse-project.ts", - "line": 145 + "line": 146 + } + }, + { + "description": "Pure functions over an assembled ThreatModel; no I/O. Uncited entitlements never cover a reach, so the can-minus-may answer errs toward showing a capability rather than hiding one", + "location": { + "file": "src/parser/reach.ts", + "line": 31 } }, { @@ -8389,6 +8458,13 @@ "line": 10 } }, + { + "description": "findUndeclaredActors also covers the actor on @agents/@reaches and the approver on @gates; findAgentReachConflicts errors when one actor is written under both @agents and @reaches, because which verb names an actor is what says whether it is an agent", + "location": { + "file": "src/parser/validate.ts", + "line": 11 + } + }, { "description": "Pure function; no I/O. Demotion is a SET of claim keys handed to coverage.ts, never a rewrite of the model", "location": { @@ -8631,14 +8707,14 @@ "description": "Non-mutating on purpose: ctx.model is reused by every later command, and /blame must not make /report carry attribution unasked", "location": { "file": "src/tui/commands.ts", - "line": 1007 + "line": 1008 } }, { "description": "Derived query over @flows and @mitigates; every hop it prints is an existing annotation location, so a finding here cannot cite a line that was never written", "location": { "file": "src/tui/commands.ts", - "line": 1080 + "line": 1081 } }, { @@ -8692,7 +8768,7 @@ } ], "coverage": { - "annotation_count": 874, + "annotation_count": 884, "coverage_percent": 86 }, "external_refs": [] diff --git a/.windsurfrules b/.windsurfrules index 984451cd..c8e35b70 100644 --- a/.windsurfrules +++ b/.windsurfrules @@ -29,7 +29,8 @@ Every time you write or modify code that touches security-relevant behavior, you - Use @confirmed for verified exploits. When pentest/scanning/manual reproduction proves a threat is exploitable: @confirmed #threat on Asset [severity] -- "evidence". Distinct from @exposes (theoretical) — @confirmed means real, verified, no false positives. Without evidence in hand, @exposes stands and you do not promote it — reading the code is not reproduction. To get evidence: `bugb intake ""` builds a plan an operator approves, never you. - Preserve existing annotations — do not delete or mangle them. - Definitions (@asset, @threat, @control with (#id)) live in .guardlink/definitions.ts. Reuse IDs — never redefine. Add new definitions there first, then reference in source files. -- Source files use relationship verbs: @mitigates, @exposes, @confirmed, @flows, @handles, @boundary, @comment, @validates, @audit, @owns, @assumes, @transfers, @feature. (@actor is a definition — it lives with @asset/@threat/@control. @entitles is proposed, not written.) +- Source files use relationship verbs: @mitigates, @exposes, @confirmed, @flows, @handles, @boundary, @comment, @validates, @audit, @owns, @assumes, @transfers, @feature, @agents, @reaches, @effects, @gates. (@actor is a definition — it lives with @asset/@threat/@control. @entitles is proposed, not written.) +- Code hands an LLM agent a tool, MCP server, file/shell/DB access → @agents #agent to on Asset on the registration, @effects on Asset on the code that acts, @gates Asset by #approver on an approval step. @reaches for a principal that is not an agent; never both verbs for one actor. - Triggers for the four verbs everyone forgets: wrote a test that pins a control → @validates #control for Asset. Code trusts a caller/library/platform to hold a property it never checks → @assumes Asset -- "what must hold". A threat becomes a vendor's or another team's responsibility → @transfers #threat from Source to Target (both ends must be declared assets; a vendor needs an External.* asset first). You know the accountable team → @owns team-id for Asset (bare token — no quotes, no #). - Write coupled annotation blocks: risk + control (or audit) + data flow + context note — plus @boundary when that flow crosses a trust change (from to when you know which side is less trusted), and @handles when the asset touches classified data. - Avoid @shield unless a human explicitly asks to hide code from AI. @@ -53,6 +54,8 @@ Every time you write or modify code that touches security-relevant behavior, you - @confirmed #sqli on App.API [critical] cwe:CWE-89 -- "Pentest verified: raw SQL injection via email param" - @feature "SSO Login" -- "Single sign-on authentication flow" - @owns security-team for App.API -- "Team responsible" +- @agents #support-agent to run-sql on #tool-surface -- "run_sql tool registered in tools.ts" +- @effects write on #users-db -- "Executes the model-written SQL via db.raw" - @comment -- "Rate limit: 100 req/15min" ## Live Threat Model Context (auto-synced by `guardlink sync`) @@ -120,11 +123,11 @@ _Full records with descriptions and locations: `guardlink_lookup("asset ")`, ### Model Stats -874 annotations, 20 assets, 16 threats, 14 controls, 127 exposures, 0 confirmed, 129 mitigations, 3 actors, 1 entitlements, 202 flows, 2 features +884 annotations, 20 assets, 16 threats, 14 controls, 127 exposures, 0 confirmed, 130 mitigations, 3 actors, 1 entitlements, 202 flows, 2 features ### Block Freshness -- `annotation_hash`: `sha256-v3:95e447ec72ce4924c563d83aae0d3e594f518789e71d00ded358ac3255cf772a` +- `annotation_hash`: `sha256-v3:58a5b22940f27108f0890a80cc509fe5e68f0e97f88f9812332529f9e00b729a` 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/AGENTS.md b/AGENTS.md index 6b2a99d1..babcd784 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -52,7 +52,7 @@ the same change.** This includes: new endpoints, authentication/authorization lo 3. **Use `@confirmed` for verified exploits.** When a pentest, CXG scan, or manual reproduction proves a threat is exploitable, mark it with `@confirmed #threat on Asset [severity] -- "evidence"`. This is distinct from `@exposes` (theoretical) — `@confirmed` means real, verified, not a false positive. Include severity based on actual observed impact. **Without that evidence in hand, `@exposes` stands and you do not promote it** — reading the code is not reproduction. To get evidence, `bugb intake ""` turns a description into a test plan and prints a PLAN_ID; an operator approves it, never you. 4. Do not delete or mangle existing annotations. Treat them as part of the code. Edit only when intentionally changing the threat model. 5. Definitions (`@asset`, `@threat`, `@control` with `(#id)`) live in `.guardlink/definitions.ts`. Reuse existing `#id`s — never redefine. If you need a new asset or threat, add the definition there first, then reference it in source files. -6. Source files use relationship verbs only: `@mitigates`, `@exposes`, `@confirmed`, `@flows`, `@handles`, `@boundary`, `@comment`, `@validates`, `@audit`, `@owns`, `@assumes`, `@transfers`, `@feature`. (`@actor` is a definition — it belongs in the definitions file with `@asset`/`@threat`/`@control`. `@entitles` is proposed, not written — see rule 9.) Four of those are permitted everywhere and written almost nowhere, because nothing tells you *when*. Their triggers: +6. Source files use relationship verbs only: `@mitigates`, `@exposes`, `@confirmed`, `@flows`, `@handles`, `@boundary`, `@comment`, `@validates`, `@audit`, `@owns`, `@assumes`, `@transfers`, `@feature`, and the reach verbs `@agents`, `@reaches`, `@effects`, `@gates` (rule 10). (`@actor` is a definition — it belongs in the definitions file with `@asset`/`@threat`/`@control`. `@entitles` is proposed, not written — see rule 9.) Four of those are permitted everywhere and written almost nowhere, because nothing tells you *when*. Their triggers: - **You wrote or changed a test that pins a control** → `@validates #control for Asset -- "what the test proves"`, on the test. Control first, asset second, joined by `for`. - **The code trusts a caller, library, or platform to hold a property it never checks itself** → `@assumes Asset -- "what must hold, and what breaks if it does not"`. One asset, and the whole assumption lives in the description. - **Responsibility for a threat lands on a vendor, an upstream service, or another team's component** → `@transfers #threat from Source to Target`. Both ends must be assets that already exist in the definitions file — a vendor needs an `External.*` asset declared first, and a bare company name will not parse. @@ -60,6 +60,7 @@ the same change.** This includes: new endpoints, authentication/authorization lo 7. Write coupled annotation blocks that tell a complete story: risk + control (or audit) + data flow + context note — plus `@boundary` when that flow crosses a trust change (written `from to ` when you know which side is less trusted), and `@handles` when the asset touches classified data. Never write a lone `@exposes` without follow-up. 8. Avoid `@shield` unless a human explicitly asks to hide code from AI — it creates blind spots. 9. **NEVER write `@entitles` into source — propose it.** `@entitles` says a privilege is *supposed* to have this effect, so an over-grant closes a real privilege escalation as by-design. That makes it the second claim you may not make on a human's behalf, alongside `@accepts`. File it with `guardlink entitle --propose` (or `guardlink_entitlement_propose`) and a human's acceptance is what writes the annotation, under their name; an `@entitles` in source with no accepted proposal is a validation error. The rationale must cite the authz code as `file:line` or the claim is inert — parsed and then ignored. It never suppresses 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 it cannot say whose object it was. When unsure which role the code actually requires, write `@comment` describing what you saw instead: under-granting costs noise, over-granting hides a real bug. +10. **Declare what an embedded LLM agent can reach.** When code hands an agent a tool, an MCP server, file, shell or database access, or an internal method: `@agents #agent to on Asset` on the registration (declare the agent with `@actor` first), `@effects on Asset` on the code that acts, and `@gates Asset by #approver` on any approval step. `@reaches` is the same claim for a principal that is not an LLM agent (a CI runner, a service account); one actor under both verbs is a validation error. You may write all four — they say what the code *can* do. Use the capability token an `@entitles` would use: `unentitled reaches` then lists the capabilities no human approved, and for those you propose an entitlement (rule 9), never write one. ### Workflow (while coding) @@ -77,7 +78,8 @@ the same change.** This includes: new endpoints, authentication/authorization lo guessing** — send it a bad query to get the list. Beyond `asset`/`threat`/`control`, it reaches every relation the model holds: `owner of X`, `handles pii`, `assumptions for X`, `audits for X`, `validations for X`, `acceptances`, `transfers`, `comments for X`, `shields`, `cross-repo refs`, - and `cwe:CWE-89` / `owasp:A03` for scanner findings. + `agents`, `unentitled reaches`, `effects for X`, `gates for X`, + and `cwe:CWE-89` / `owasp:A03` for scanner findings. Without MCP, `guardlink lookup ""` answers the same forms. - Reference matches report `matched_via: exact | alias | substring`. A substring match is a suggestion, not an identification; `ambiguous` with `candidates` means several records tied. @@ -96,6 +98,9 @@ the same change.** This includes: new endpoints, authentication/authorization lo @confirmed #sqli on App.API [critical] cwe:CWE-89 -- "Pentest verified: raw SQL injection via email param" @feature "SSO Login" -- "Single sign-on authentication flow" @owns security-team for App.API -- "Team responsible for reviews" +@agents #support-agent to run-sql on #tool-surface as #agent-session -- "run_sql tool registered in tools.ts" +@effects write on #users-db as #db-service -- "Executes the model-written SQL via db.raw" +@gates #payments by #support-human for issue-refund -- "requireApproval() blocks until a human approves" @actor Namespace_Admin (#ns-admin) -- "Administers one namespace's configuration" (definitions file) @comment -- "Rate limit: 100 req/15min via express-rate-limit" ``` @@ -174,11 +179,11 @@ _Full records with descriptions and locations: `guardlink_lookup("asset ")`, ### Model Stats -874 annotations, 20 assets, 16 threats, 14 controls, 127 exposures, 0 confirmed, 129 mitigations, 3 actors, 1 entitlements, 202 flows, 2 features +884 annotations, 20 assets, 16 threats, 14 controls, 127 exposures, 0 confirmed, 130 mitigations, 3 actors, 1 entitlements, 202 flows, 2 features ### Block Freshness -- `annotation_hash`: `sha256-v3:95e447ec72ce4924c563d83aae0d3e594f518789e71d00ded358ac3255cf772a` +- `annotation_hash`: `sha256-v3:58a5b22940f27108f0890a80cc509fe5e68f0e97f88f9812332529f9e00b729a` 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/CLAUDE.md b/CLAUDE.md index 6b2a99d1..babcd784 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -52,7 +52,7 @@ the same change.** This includes: new endpoints, authentication/authorization lo 3. **Use `@confirmed` for verified exploits.** When a pentest, CXG scan, or manual reproduction proves a threat is exploitable, mark it with `@confirmed #threat on Asset [severity] -- "evidence"`. This is distinct from `@exposes` (theoretical) — `@confirmed` means real, verified, not a false positive. Include severity based on actual observed impact. **Without that evidence in hand, `@exposes` stands and you do not promote it** — reading the code is not reproduction. To get evidence, `bugb intake ""` turns a description into a test plan and prints a PLAN_ID; an operator approves it, never you. 4. Do not delete or mangle existing annotations. Treat them as part of the code. Edit only when intentionally changing the threat model. 5. Definitions (`@asset`, `@threat`, `@control` with `(#id)`) live in `.guardlink/definitions.ts`. Reuse existing `#id`s — never redefine. If you need a new asset or threat, add the definition there first, then reference it in source files. -6. Source files use relationship verbs only: `@mitigates`, `@exposes`, `@confirmed`, `@flows`, `@handles`, `@boundary`, `@comment`, `@validates`, `@audit`, `@owns`, `@assumes`, `@transfers`, `@feature`. (`@actor` is a definition — it belongs in the definitions file with `@asset`/`@threat`/`@control`. `@entitles` is proposed, not written — see rule 9.) Four of those are permitted everywhere and written almost nowhere, because nothing tells you *when*. Their triggers: +6. Source files use relationship verbs only: `@mitigates`, `@exposes`, `@confirmed`, `@flows`, `@handles`, `@boundary`, `@comment`, `@validates`, `@audit`, `@owns`, `@assumes`, `@transfers`, `@feature`, and the reach verbs `@agents`, `@reaches`, `@effects`, `@gates` (rule 10). (`@actor` is a definition — it belongs in the definitions file with `@asset`/`@threat`/`@control`. `@entitles` is proposed, not written — see rule 9.) Four of those are permitted everywhere and written almost nowhere, because nothing tells you *when*. Their triggers: - **You wrote or changed a test that pins a control** → `@validates #control for Asset -- "what the test proves"`, on the test. Control first, asset second, joined by `for`. - **The code trusts a caller, library, or platform to hold a property it never checks itself** → `@assumes Asset -- "what must hold, and what breaks if it does not"`. One asset, and the whole assumption lives in the description. - **Responsibility for a threat lands on a vendor, an upstream service, or another team's component** → `@transfers #threat from Source to Target`. Both ends must be assets that already exist in the definitions file — a vendor needs an `External.*` asset declared first, and a bare company name will not parse. @@ -60,6 +60,7 @@ the same change.** This includes: new endpoints, authentication/authorization lo 7. Write coupled annotation blocks that tell a complete story: risk + control (or audit) + data flow + context note — plus `@boundary` when that flow crosses a trust change (written `from to ` when you know which side is less trusted), and `@handles` when the asset touches classified data. Never write a lone `@exposes` without follow-up. 8. Avoid `@shield` unless a human explicitly asks to hide code from AI — it creates blind spots. 9. **NEVER write `@entitles` into source — propose it.** `@entitles` says a privilege is *supposed* to have this effect, so an over-grant closes a real privilege escalation as by-design. That makes it the second claim you may not make on a human's behalf, alongside `@accepts`. File it with `guardlink entitle --propose` (or `guardlink_entitlement_propose`) and a human's acceptance is what writes the annotation, under their name; an `@entitles` in source with no accepted proposal is a validation error. The rationale must cite the authz code as `file:line` or the claim is inert — parsed and then ignored. It never suppresses 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 it cannot say whose object it was. When unsure which role the code actually requires, write `@comment` describing what you saw instead: under-granting costs noise, over-granting hides a real bug. +10. **Declare what an embedded LLM agent can reach.** When code hands an agent a tool, an MCP server, file, shell or database access, or an internal method: `@agents #agent to on Asset` on the registration (declare the agent with `@actor` first), `@effects on Asset` on the code that acts, and `@gates Asset by #approver` on any approval step. `@reaches` is the same claim for a principal that is not an LLM agent (a CI runner, a service account); one actor under both verbs is a validation error. You may write all four — they say what the code *can* do. Use the capability token an `@entitles` would use: `unentitled reaches` then lists the capabilities no human approved, and for those you propose an entitlement (rule 9), never write one. ### Workflow (while coding) @@ -77,7 +78,8 @@ the same change.** This includes: new endpoints, authentication/authorization lo guessing** — send it a bad query to get the list. Beyond `asset`/`threat`/`control`, it reaches every relation the model holds: `owner of X`, `handles pii`, `assumptions for X`, `audits for X`, `validations for X`, `acceptances`, `transfers`, `comments for X`, `shields`, `cross-repo refs`, - and `cwe:CWE-89` / `owasp:A03` for scanner findings. + `agents`, `unentitled reaches`, `effects for X`, `gates for X`, + and `cwe:CWE-89` / `owasp:A03` for scanner findings. Without MCP, `guardlink lookup ""` answers the same forms. - Reference matches report `matched_via: exact | alias | substring`. A substring match is a suggestion, not an identification; `ambiguous` with `candidates` means several records tied. @@ -96,6 +98,9 @@ the same change.** This includes: new endpoints, authentication/authorization lo @confirmed #sqli on App.API [critical] cwe:CWE-89 -- "Pentest verified: raw SQL injection via email param" @feature "SSO Login" -- "Single sign-on authentication flow" @owns security-team for App.API -- "Team responsible for reviews" +@agents #support-agent to run-sql on #tool-surface as #agent-session -- "run_sql tool registered in tools.ts" +@effects write on #users-db as #db-service -- "Executes the model-written SQL via db.raw" +@gates #payments by #support-human for issue-refund -- "requireApproval() blocks until a human approves" @actor Namespace_Admin (#ns-admin) -- "Administers one namespace's configuration" (definitions file) @comment -- "Rate limit: 100 req/15min via express-rate-limit" ``` @@ -174,11 +179,11 @@ _Full records with descriptions and locations: `guardlink_lookup("asset ")`, ### Model Stats -874 annotations, 20 assets, 16 threats, 14 controls, 127 exposures, 0 confirmed, 129 mitigations, 3 actors, 1 entitlements, 202 flows, 2 features +884 annotations, 20 assets, 16 threats, 14 controls, 127 exposures, 0 confirmed, 130 mitigations, 3 actors, 1 entitlements, 202 flows, 2 features ### Block Freshness -- `annotation_hash`: `sha256-v3:95e447ec72ce4924c563d83aae0d3e594f518789e71d00ded358ac3255cf772a` +- `annotation_hash`: `sha256-v3:58a5b22940f27108f0890a80cc509fe5e68f0e97f88f9812332529f9e00b729a` 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`.