From 2eb5b6d5c968a52afd1c518f52afc8ba6ab9e026 Mon Sep 17 00:00:00 2001 From: Animesh Srivastava Date: Fri, 2 Oct 2026 02:02:13 +0530 Subject: [PATCH 1/2] feat(boundary): add a directed @boundary from to A @boundary was an undirected pair, so the only way to tell its untrusted side was to infer it from an endpoint no @asset declares. That says nothing when both sides are declared assets, which is the usual case for an application and its database: the SARIF export could only report the side as unknown. The new form '@boundary from to ' states it. The between, and and | forms are unchanged and stay undirected. - Parser: the directed form is read inline and from .gal sidecars. A line whose arguments open with 'from' and do not parse is malformed; 'from' and 'to' elsewhere in a sentence are not evidence, so prose stays a warning. - Model: boundaries[] records directed: true, with asset_a the outer side and asset_b the inner side. Undirected boundaries carry no new field, and their annotation hash and claim key do not move; a declared, dropped or reversed direction changes both. - SARIF: a declared side is basis 'declared', distinct from the inferred 'undeclared-endpoint' and from 'unknown', in run.graphs[0] guardlink/side (a directed edge runs outer to inner, guardlink/directed: true) and in the pentest profile's boundary-claim results, whose inner_routes now also cover a declared inner side. Exports of models with no directed boundary are unchanged; the github baselines still match byte for byte. The boundary-claim rule's help text describes the three bases, so the two pentest consumer fixtures are regenerated, and a third, boundary-direction.sarif, holds all three bases. - validate (CLI, MCP, TUI): a directed boundary whose outer or inner side is no declared asset and no @flows endpoint is an error, unresolved-boundary-side. diff reports a change of direction, and validate --code-graph uses a declared inner side. - conformance/boundaries.json pins the grammar (directed, undirected, malformed), the side the export states, and the validation result, and runs in tests/conformance-boundaries.test.ts. - SPEC 3.2, 2.12, 5.3, 6.6 and 6.8, the annotate prompt, the map playbook, the code-graph boundary suggestions and the agent instruction templates describe the directed form and ask agents to use it when the trust direction is known. --- CHANGELOG.md | 7 + CONTRIBUTING.md | 2 +- conformance/README.md | 88 +- conformance/boundaries.json | 710 ++++++++++ docs/GUARDLINK_REFERENCE.md | 7 +- docs/SPEC.md | 56 +- src/agents/prompts.ts | 14 +- src/analyzer/sarif-context.ts | 34 +- src/analyzer/sarif-pentest.ts | 14 +- src/cli/index.ts | 11 +- src/codegraph/boundary-check.ts | 18 +- src/codegraph/index.ts | 5 +- src/diff/engine.ts | 15 +- src/diff/format.ts | 2 +- src/hypothesis/boundary.ts | 4 +- src/hypothesis/format.ts | 7 +- src/init/templates.ts | 11 +- src/mcp/lookup.ts | 5 +- src/mcp/server.ts | 6 +- src/parser/annotation-hash.ts | 6 +- src/parser/claim-key.ts | 7 +- src/parser/index.ts | 2 +- src/parser/parse-line.ts | 28 + src/parser/parse-project.ts | 1 + src/parser/validate.ts | 60 + src/playbooks/annotate.ts | 2 +- src/tui/commands.ts | 9 +- src/types/index.ts | 11 + tests/boundary-check.test.ts | 16 + tests/boundary-direction.test.ts | 264 ++++ tests/conformance-boundaries.test.ts | 128 ++ .../boundary-direction/.guardlink/config.json | 15 + .../.guardlink/definitions.ts | 7 + tests/fixtures/boundary-direction/FIXTURE.md | 18 + tests/fixtures/boundary-direction/src/web.ts | 30 + tests/fixtures/sarif-pentest/README.md | 6 +- .../sarif-pentest/boundary-direction.sarif | 1204 +++++++++++++++++ .../fixtures/sarif-pentest/expense-api.sarif | 2 +- tests/fixtures/sarif-pentest/sarif-shop.sarif | 2 +- tests/sarif-pentest.test.ts | 81 +- 40 files changed, 2830 insertions(+), 85 deletions(-) create mode 100644 conformance/boundaries.json create mode 100644 tests/boundary-direction.test.ts create mode 100644 tests/conformance-boundaries.test.ts create mode 100644 tests/fixtures/boundary-direction/.guardlink/config.json create mode 100644 tests/fixtures/boundary-direction/.guardlink/definitions.ts create mode 100644 tests/fixtures/boundary-direction/FIXTURE.md create mode 100644 tests/fixtures/boundary-direction/src/web.ts create mode 100644 tests/fixtures/sarif-pentest/boundary-direction.sarif diff --git a/CHANGELOG.md b/CHANGELOG.md index 61e90166..3d8e812e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,6 +28,13 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), - **`guardlink sarif --profile pentest` exports what a test run needs.** The default profile, `github`, is the export above, byte for byte. The `pentest` profile appends, after every `github` result so no result index moves: one `guardlink/mitigated-exposure` result per covered `@exposes`, said exactly as an uncovered one would be, with `suppressions[]` naming each covering `@mitigates` or `@accepts` and where it is; and one `guardlink/boundary-claim` result per `@boundary` (`kind: "review"`, `level: "none"`), keyed by the boundary's claim key, with its sides, the outer side when exactly one side is undeclared (`basis: "unknown"` and no side otherwise), the `@flows` that cross it and the routes into its inner side. Every exposure, confirmed and boundary-claim result carries the hypothesis ledger's state as `properties["guardlink/hypothesis"]`. `--baseline ` sets each result's `baselineState` (`new`, `unchanged` or `updated`) against an earlier export, matching by claim key; a baseline result that matches nothing is counted in `properties.baseline.absent`, not exported. The MCP `guardlink_sarif` tool takes `profile` too. See SPEC §6.8. - `tests/fixtures/sarif-pentest/` holds the pentest export of two fixtures, with a ledger, for a SARIF reader to test against; the test suite regenerates and compares them, and validates every pentest export against the SARIF 2.1.0 schema. +- **`@boundary` can say which side is outside: `@boundary from to `.** A boundary was an undirected pair, so the only way to tell its untrusted side was to infer it from an endpoint no `@asset` declares — which says nothing when both sides are declared assets, the usual case for an application and its database. The directed form states it. `between`, `and` and `|` are unchanged and remain undirected. See SPEC §3.2 `@boundary`. + - **The model** records `directed: true` on such a boundary, with `asset_a` the outer side and `asset_b` the inner side. Undirected boundaries carry no new field, and their annotation hash and claim key do not move; declaring, dropping or reversing a direction changes both, so a recorded test outcome does not carry over to a boundary whose direction changed. Read from inline comments and `.gal` sidecars alike. + - **The SARIF export** states a declared side as `basis: "declared"`, distinct from the inferred `undeclared-endpoint` and from `unknown`: in each boundary edge's `guardlink/side` in `run.graphs[0]`, where a directed boundary is also `guardlink/directed: true` running outer to inner, and in the `pentest` profile's `guardlink/boundary-claim` results, whose `inner_routes` now also list routes into a declared inner side. An export of a model with no directed boundary is unchanged, byte for byte, in both profiles except for the `guardlink/boundary-claim` rule's help text, which now describes the three bases; `tests/fixtures/sarif-pentest/` is regenerated for it and gains `boundary-direction.sarif`, an export with all three. + - **`guardlink validate`** (and the MCP and TUI validate) reports a directed boundary whose outer or inner side names no declared asset and no `@flows` endpoint as an error, `unresolved-boundary-side`. Undirected boundaries are not checked. `guardlink diff` reports a change of direction, and `validate --code-graph` checks a directed boundary's declared inner side, including one between two declared assets that it used to skip. + - **The annotate prompt, the `map` playbook, the code-graph boundary suggestions and the generated agent instructions** teach agents to write the directed form when the trust direction is visible in the code, and `between` otherwise. + - [`conformance/boundaries.json`](conformance/boundaries.json) pins the grammar (directed, undirected and malformed lines), the side the SARIF export states for each, and which directed boundaries validation rejects. It ships in the npm package beside `flows.json`, and `tests/conformance-boundaries.test.ts` runs it against this parser and exporter. + ### Fixed - **The MCP `guardlink_sarif` tool reports dangling references.** It passed an empty list where `guardlink sarif` computes them, so the same model gave two different SARIF documents depending on the front end. Both now compute them the same way. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index e43d4ef3..b2123720 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -163,7 +163,7 @@ the gate is level-triggered because it describes a repository at the moment it r Changes to the annotation grammar or ThreatModel schema require discussion in an issue first. The spec is designed to be stable — breaking changes need strong justification. -`docs/SPEC.md` is the definition every reader conforms to, this parser included. Other tools read the same annotations, so a form this parser accepts and the spec does not describe is a form those tools will drop without a word. When you change what the parser accepts or produces for a verb that has a conformance corpus — today `@flows`, together with handler scope (SPEC §3.6) — change three things in the same pull request: the SPEC section, the matching case in `conformance/flows.json`, and the parser. `tests/conformance-flows.test.ts` fails if the corpus and the parser disagree. Adding a case does not need a new corpus `version`. Changing the shape of a case does, and `conformance/README.md` documents that shape. +`docs/SPEC.md` is the definition every reader conforms to, this parser included. Other tools read the same annotations, so a form this parser accepts and the spec does not describe is a form those tools will drop without a word. When you change what the parser accepts or produces for a verb that has a conformance corpus — today `@flows`, together with handler scope (SPEC §3.6), and `@boundary`, together with the sides the SARIF export states for it — change three things in the same pull request: the SPEC section, the matching case in `conformance/flows.json` or `conformance/boundaries.json`, and the parser. `tests/conformance-flows.test.ts` and `tests/conformance-boundaries.test.ts` fail if a corpus and the code disagree. Adding a case does not need a new corpus `version`. Changing the shape of a case does, and `conformance/README.md` documents that shape. ## SARIF Export Changes diff --git a/conformance/README.md b/conformance/README.md index ccd180f5..7042f670 100644 --- a/conformance/README.md +++ b/conformance/README.md @@ -9,24 +9,25 @@ annotations the way the specification says. | File | Pins | |---|---| | [`flows.json`](flows.json) | SPEC §3.2 `@flows`: endpoint forms, chains, `via` mechanisms, route channels, sidecar attribution and malformed lines. SPEC §3.6, Handler Scope: which route reaches a claim, and which exposures a mitigation covers | +| [`boundaries.json`](boundaries.json) | SPEC §3.2 `@boundary`: the directed `from to ` form and the undirected `between`, `and` and `\|` forms, sidecar attribution and malformed lines. SPEC §6.6 and §6.8: which side of a boundary is outer, declared or inferred, as the SARIF export states it. Which directed boundaries validation rejects because a side resolves to nothing | -The reference parser runs the whole corpus in `tests/conformance-flows.test.ts`, so a -change to the parser that moves a record cannot ship with a corpus that no longer -describes it. +The reference parser runs each corpus in its own test, `tests/conformance-flows.test.ts` +and `tests/conformance-boundaries.test.ts`, so a change to the parser or the exporter +that moves a record cannot ship with a corpus that no longer describes it. ## Getting it Pin a released version. Do not track `main`, because a case can change between releases. -- **npm.** The file ships in the `guardlink` package: `node_modules/guardlink/conformance/flows.json`, - or `require.resolve('guardlink/conformance/flows.json')` from Node. +- **npm.** The files ship in the `guardlink` package: `node_modules/guardlink/conformance/flows.json`, + or `require.resolve('guardlink/conformance/flows.json')` from Node, and the same for `boundaries.json`. - **Raw download.** `https://raw.githubusercontent.com/Bugb-Technologies/guardlink/v/conformance/flows.json`. Vendor the file into your own test fixtures and record the version you took it from. -`version` in the file is the corpus format version. It changes only when the shape below -changes. New cases can be added without changing it. +`version` in each file is that corpus's format version. It changes only when the shape +below changes. New cases can be added without changing it. -## Format +## `flows.json` format ```jsonc { @@ -97,3 +98,74 @@ code each annotation is attached to, which SPEC §5.2 defines as the location's reader that consumes GuardLink's exported model (`guardlink parse . -o report.json`) already has `anchor` on every location. A reader that parses source itself has to resolve the attachment by the same rule. + +## `boundaries.json` format + +```jsonc +{ + "corpus": "guardlink-boundaries-conformance", + "version": 1, + "parse": [ /* cases */ ], + "sides": [ /* cases */ ], + "validation": [ /* cases */ ] +} +``` + +Cases have the same `id`, `rule`, `about` and `files` members as in `flows.json`, and +are read the same way. + +### `parse` + +```jsonc +{ + "id": "directed/two-declared-sides", + "files": { "app/web.py": "import os\n# @boundary from #orders to #store (#data) -- \"The store trusts every query it is sent\"\n" }, + "expect": { + "boundaries": [ + { "asset_a": "#orders", "asset_b": "#store", "id": "data", "directed": true, + "description": "The store trusts every query it is sent", + "location": { "file": "app/web.py", "line": 2, "origin_file": null, "origin_line": null } } + ], + "malformed": [] + } +} +``` + +- `boundaries` is every boundary record the files declare, compared as a multiset. + `directed` is `true` for `from to `, and then `asset_a` is the outer side + and `asset_b` the inner side. It is `false` for the `between`, `and` and `|` forms, whose + side order means nothing. `id` is without its `#`. Absent values are `null`. +- `malformed` is every `{ file, line }` where a `@boundary` line must be reported as + malformed, as in `flows.json`. A `prose/` case is a line a reader must *not* report as + malformed: it is about the verb, not an attempt at one. + +### `sides` + +```jsonc +{ "expect": [ { "boundary": { "file": "app/web.py", "line": 2 }, + "side": { "basis": "declared", "outer": "#orders", "inner": "#store" } } ] } +``` + +For each `@boundary` at `boundary`, `side` is which side is outer (SPEC §6.6, §6.8). It is +one of: + +- `{ "basis": "declared", "outer", "inner" }` — the boundary is directed and says so; +- `{ "basis": "undeclared-endpoint", "outer", "inner" }` — undirected, with exactly one + side that no `@asset` declares, which is inferred to be outer; +- `{ "basis": "unknown" }` — no side is stated, and a reader must not infer one. + +`outer` and `inner` are the sides as written in the annotation. The SARIF export names +the same sides by their `run.graphs[0]` node id, in `properties.boundary` of a +`guardlink/boundary-claim` result and in the boundary edge's `guardlink/side`; map a +node id back through `properties.boundary.a`/`asset_a` and `b`/`asset_b`. + +### `validation` + +```jsonc +{ "expect": { "unresolved": [ { "file": "app/web.py", "line": 3 } ] } } +``` + +`unresolved` is every directed `@boundary` that validation must reject because its outer +or inner side resolves to nothing: a `#tag` that no `@asset` defines, or another name that +is neither a declared asset's path nor an endpoint some `@flows` names. A +repository-qualified `#repo.tag` is not checked, and an undirected boundary never is. diff --git a/conformance/boundaries.json b/conformance/boundaries.json new file mode 100644 index 00000000..ac47877f --- /dev/null +++ b/conformance/boundaries.json @@ -0,0 +1,710 @@ +{ + "corpus": "guardlink-boundaries-conformance", + "version": 1, + "spec": "docs/SPEC.md — §3.2 `@boundary`, §6.6 `run.graphs`, §6.8 `guardlink/boundary-claim`", + "format": "See conformance/README.md. Paths are repository-relative; every file in `files` is written before parsing; lines are 1-based.", + "parse": [ + { + "id": "directed/undeclared-outer", + "rule": "Direction", + "about": "from names the outer side and to the inner side; the record keeps them in that order as asset_a and asset_b.", + "files": { + "app/web.py": "import os\n# @boundary from Client to #web (#edge) -- \"Nothing before this point is trusted\"\n" + }, + "expect": { + "boundaries": [ + { + "asset_a": "Client", + "asset_b": "#web", + "id": "edge", + "directed": true, + "description": "Nothing before this point is trusted", + "location": { + "file": "app/web.py", + "line": 2, + "origin_file": null, + "origin_line": null + } + } + ], + "malformed": [] + } + }, + { + "id": "directed/two-declared-sides", + "rule": "Direction", + "about": "The case direction exists for: both sides are declared assets, so no convention can say which is outside.", + "files": { + "app/web.py": "import os\n# @boundary from #orders to #store (#data) -- \"The store trusts every query it is sent\"\n" + }, + "expect": { + "boundaries": [ + { + "asset_a": "#orders", + "asset_b": "#store", + "id": "data", + "directed": true, + "description": "The store trusts every query it is sent", + "location": { + "file": "app/web.py", + "line": 2, + "origin_file": null, + "origin_line": null + } + } + ], + "malformed": [] + } + }, + { + "id": "directed/minimal", + "rule": "Direction", + "about": "The id and the description are optional, as in the undirected forms.", + "files": { + "app/web.py": "import os\n# @boundary from #orders to #store\n" + }, + "expect": { + "boundaries": [ + { + "asset_a": "#orders", + "asset_b": "#store", + "id": null, + "directed": true, + "description": null, + "location": { + "file": "app/web.py", + "line": 2, + "origin_file": null, + "origin_line": null + } + } + ], + "malformed": [] + } + }, + { + "id": "directed/dotted-paths", + "rule": "Direction", + "files": { + "app/web.py": "import os\n# @boundary from External.Internet to Shop.Web (#perimeter)\n" + }, + "expect": { + "boundaries": [ + { + "asset_a": "External.Internet", + "asset_b": "Shop.Web", + "id": "perimeter", + "directed": true, + "description": null, + "location": { + "file": "app/web.py", + "line": 2, + "origin_file": null, + "origin_line": null + } + } + ], + "malformed": [] + } + }, + { + "id": "directed/quoted-sides", + "rule": "Direction", + "about": "Quoted sides are recorded without their quotes.", + "files": { + "app/web.py": "import os\n# @boundary from \"User Browser\" to \"Backend API\"\n" + }, + "expect": { + "boundaries": [ + { + "asset_a": "User Browser", + "asset_b": "Backend API", + "id": null, + "directed": true, + "description": null, + "location": { + "file": "app/web.py", + "line": 2, + "origin_file": null, + "origin_line": null + } + } + ], + "malformed": [] + } + }, + { + "id": "directed/qualified-tag", + "rule": "Direction", + "about": "#billing.charge is the repository-qualified tag for `charge` in repository `billing`, recorded whole.", + "files": { + "app/web.py": "import os\n# @boundary from #web to #billing.charge (#vendor)\n" + }, + "expect": { + "boundaries": [ + { + "asset_a": "#web", + "asset_b": "#billing.charge", + "id": "vendor", + "directed": true, + "description": null, + "location": { + "file": "app/web.py", + "line": 2, + "origin_file": null, + "origin_line": null + } + } + ], + "malformed": [] + } + }, + { + "id": "undirected/between", + "rule": "Undirected forms", + "about": "The between form is unchanged and records no direction.", + "files": { + "app/web.py": "import os\n# @boundary between Client and #web (#edge) -- \"Edge\"\n" + }, + "expect": { + "boundaries": [ + { + "asset_a": "Client", + "asset_b": "#web", + "id": "edge", + "directed": false, + "description": "Edge", + "location": { + "file": "app/web.py", + "line": 2, + "origin_file": null, + "origin_line": null + } + } + ], + "malformed": [] + } + }, + { + "id": "undirected/bare-and", + "rule": "Undirected forms", + "about": "between is optional before the first side.", + "files": { + "app/web.py": "import os\n# @boundary Client and #web\n" + }, + "expect": { + "boundaries": [ + { + "asset_a": "Client", + "asset_b": "#web", + "id": null, + "directed": false, + "description": null, + "location": { + "file": "app/web.py", + "line": 2, + "origin_file": null, + "origin_line": null + } + } + ], + "malformed": [] + } + }, + { + "id": "undirected/pipe", + "rule": "Undirected forms", + "about": "The pipe is sugar for between ... and; spaces around it are optional.", + "files": { + "app/web.py": "import os\n# @boundary Client | #web (#edge)\n# @boundary #orders|#store\n" + }, + "expect": { + "boundaries": [ + { + "asset_a": "Client", + "asset_b": "#web", + "id": "edge", + "directed": false, + "description": null, + "location": { + "file": "app/web.py", + "line": 2, + "origin_file": null, + "origin_line": null + } + }, + { + "asset_a": "#orders", + "asset_b": "#store", + "id": null, + "directed": false, + "description": null, + "location": { + "file": "app/web.py", + "line": 3, + "origin_file": null, + "origin_line": null + } + } + ], + "malformed": [] + } + }, + { + "id": "comment/jsdoc-block", + "rule": "Direction", + "about": "Read from a doc-block like any other annotation.", + "files": { + "src/web.ts": "/**\n * HTTP entry points.\n *\n * @boundary from Client to #web (#edge) -- \"Edge\"\n */\nexport const web = 1;\n" + }, + "expect": { + "boundaries": [ + { + "asset_a": "Client", + "asset_b": "#web", + "id": "edge", + "directed": true, + "description": "Edge", + "location": { + "file": "src/web.ts", + "line": 4, + "origin_file": null, + "origin_line": null + } + } + ], + "malformed": [] + } + }, + { + "id": "sidecar/attributed-to-source", + "rule": "Direction", + "about": "A directed boundary in a .gal sidecar is attributed to the @source line, with the sidecar as its origin.", + "files": { + "app/db.py": "def query():\n pass\n", + ".guardlink/annotations/app/db.py.gal": "@source file:app/db.py line:1 symbol:query\n@boundary from #orders to #store (#data) -- \"Service to database\"\n" + }, + "expect": { + "boundaries": [ + { + "asset_a": "#orders", + "asset_b": "#store", + "id": "data", + "directed": true, + "description": "Service to database", + "location": { + "file": "app/db.py", + "line": 1, + "origin_file": ".guardlink/annotations/app/db.py.gal", + "origin_line": 2 + } + } + ], + "malformed": [] + } + }, + { + "id": "malformed/from-without-to", + "rule": "Malformed lines", + "about": "from opens the directed form; a line with no to is a broken annotation.", + "files": { + "app/web.py": "import os\n# @boundary from #orders\n" + }, + "expect": { + "boundaries": [], + "malformed": [ + { + "file": "app/web.py", + "line": 2 + } + ] + } + }, + { + "id": "malformed/to-without-inner", + "rule": "Malformed lines", + "files": { + "app/web.py": "import os\n# @boundary from #orders to\n" + }, + "expect": { + "boundaries": [], + "malformed": [ + { + "file": "app/web.py", + "line": 2 + } + ] + } + }, + { + "id": "malformed/from-and", + "rule": "Malformed lines", + "about": "from pairs with to, and between with and; the two forms do not mix.", + "files": { + "app/web.py": "import os\n# @boundary from #orders and #store\n" + }, + "expect": { + "boundaries": [], + "malformed": [ + { + "file": "app/web.py", + "line": 2 + } + ] + } + }, + { + "id": "malformed/between-to", + "rule": "Malformed lines", + "files": { + "app/web.py": "import os\n# @boundary between #orders to #store\n" + }, + "expect": { + "boundaries": [], + "malformed": [ + { + "file": "app/web.py", + "line": 2 + } + ] + } + }, + { + "id": "malformed/arrow", + "rule": "Malformed lines", + "about": "An arrow is @flows syntax, not a boundary direction.", + "files": { + "app/web.py": "import os\n# @boundary from #orders -> #store\n" + }, + "expect": { + "boundaries": [], + "malformed": [ + { + "file": "app/web.py", + "line": 2 + } + ] + } + }, + { + "id": "malformed/to-before-from", + "rule": "Malformed lines", + "files": { + "app/web.py": "import os\n# @boundary to #store from #orders\n" + }, + "expect": { + "boundaries": [], + "malformed": [ + { + "file": "app/web.py", + "line": 2 + } + ] + } + }, + { + "id": "malformed/three-sides", + "rule": "Malformed lines", + "about": "A boundary has exactly two sides.", + "files": { + "app/web.py": "import os\n# @boundary from #web to #orders to #store\n" + }, + "expect": { + "boundaries": [], + "malformed": [ + { + "file": "app/web.py", + "line": 2 + } + ] + } + }, + { + "id": "malformed/to-without-from", + "rule": "Malformed lines", + "about": "The directed form starts with from.", + "files": { + "app/web.py": "import os\n# @boundary #orders to #store\n" + }, + "expect": { + "boundaries": [], + "malformed": [ + { + "file": "app/web.py", + "line": 2 + } + ] + } + }, + { + "id": "malformed/from-bare-names", + "rule": "Malformed lines", + "about": "A leading from is enough evidence even without a #ref or a description.", + "files": { + "app/web.py": "import os\n# @boundary from Client\n" + }, + "expect": { + "boundaries": [], + "malformed": [ + { + "file": "app/web.py", + "line": 2 + } + ] + } + }, + { + "id": "prose/from-inside-a-sentence", + "rule": "Malformed lines", + "about": "from and to are evidence only as the first word of the arguments; prose that merely contains them is not a malformed annotation.", + "files": { + "app/web.py": "import os\n# @boundary annotations come from the spec, written to mark a trust change\n" + }, + "expect": { + "boundaries": [], + "malformed": [] + } + } + ], + "sides": [ + { + "id": "declared/undeclared-outer", + "rule": "Direction; SARIF sides", + "files": { + ".guardlink/definitions.ts": "// @asset Shop.Web (#web) -- \"Public HTTP API\"\n// @asset Shop.Orders (#orders) -- \"Order service\"\n// @asset Shop.Store (#store) -- \"Order database\"\nexport {};\n", + "app/web.py": "import os\n# @flows Client -> #web via HTTPS\n# @boundary from Client to #web (#edge)\n" + }, + "expect": [ + { + "boundary": { + "file": "app/web.py", + "line": 3 + }, + "side": { + "basis": "declared", + "outer": "Client", + "inner": "#web" + } + } + ] + }, + { + "id": "declared/two-declared-sides", + "rule": "Direction; SARIF sides", + "about": "Written undirected, this pair is unknown (see unknown/two-declared-sides).", + "files": { + ".guardlink/definitions.ts": "// @asset Shop.Web (#web) -- \"Public HTTP API\"\n// @asset Shop.Orders (#orders) -- \"Order service\"\n// @asset Shop.Store (#store) -- \"Order database\"\nexport {};\n", + "app/web.py": "import os\n# @boundary from #orders to #store (#data)\n" + }, + "expect": [ + { + "boundary": { + "file": "app/web.py", + "line": 2 + }, + "side": { + "basis": "declared", + "outer": "#orders", + "inner": "#store" + } + } + ] + }, + { + "id": "declared/over-inference", + "rule": "Direction; SARIF sides", + "about": "A declared direction is the answer even where the undeclared-endpoint convention would say the opposite.", + "files": { + ".guardlink/definitions.ts": "// @asset Shop.Web (#web) -- \"Public HTTP API\"\n// @asset Shop.Orders (#orders) -- \"Order service\"\n// @asset Shop.Store (#store) -- \"Order database\"\nexport {};\n", + "app/web.py": "import os\n# @boundary from #web to Client (#egress)\n" + }, + "expect": [ + { + "boundary": { + "file": "app/web.py", + "line": 2 + }, + "side": { + "basis": "declared", + "outer": "#web", + "inner": "Client" + } + } + ] + }, + { + "id": "inferred/undeclared-endpoint", + "rule": "SARIF sides", + "about": "Undirected, with exactly one side no @asset declares: that side is outer, inferred.", + "files": { + ".guardlink/definitions.ts": "// @asset Shop.Web (#web) -- \"Public HTTP API\"\n// @asset Shop.Orders (#orders) -- \"Order service\"\n// @asset Shop.Store (#store) -- \"Order database\"\nexport {};\n", + "app/web.py": "import os\n# @boundary between #web and Client (#edge)\n" + }, + "expect": [ + { + "boundary": { + "file": "app/web.py", + "line": 2 + }, + "side": { + "basis": "undeclared-endpoint", + "outer": "Client", + "inner": "#web" + } + } + ] + }, + { + "id": "unknown/two-declared-sides", + "rule": "SARIF sides", + "about": "Undirected between two declared assets: no side is stated, and a reader must not infer one.", + "files": { + ".guardlink/definitions.ts": "// @asset Shop.Web (#web) -- \"Public HTTP API\"\n// @asset Shop.Orders (#orders) -- \"Order service\"\n// @asset Shop.Store (#store) -- \"Order database\"\nexport {};\n", + "app/web.py": "import os\n# @boundary between #orders and #store (#data)\n" + }, + "expect": [ + { + "boundary": { + "file": "app/web.py", + "line": 2 + }, + "side": { + "basis": "unknown" + } + } + ] + }, + { + "id": "unknown/two-undeclared-sides", + "rule": "SARIF sides", + "files": { + ".guardlink/definitions.ts": "// @asset Shop.Web (#web) -- \"Public HTTP API\"\n// @asset Shop.Orders (#orders) -- \"Order service\"\n// @asset Shop.Store (#store) -- \"Order database\"\nexport {};\n", + "app/web.py": "import os\n# @boundary between Client and Backup\n" + }, + "expect": [ + { + "boundary": { + "file": "app/web.py", + "line": 2 + }, + "side": { + "basis": "unknown" + } + } + ] + } + ], + "validation": [ + { + "id": "resolved/declared-ids", + "rule": "Validation", + "about": "A #tag side resolves to an @asset with that id.", + "files": { + ".guardlink/definitions.ts": "// @asset Shop.Web (#web) -- \"Public HTTP API\"\n// @asset Shop.Orders (#orders) -- \"Order service\"\n// @asset Shop.Store (#store) -- \"Order database\"\nexport {};\n", + "app/web.py": "import os\n# @boundary from #orders to #store\n" + }, + "expect": { + "unresolved": [] + } + }, + { + "id": "resolved/declared-paths", + "rule": "Validation", + "about": "A dotted side resolves to an @asset with that path.", + "files": { + ".guardlink/definitions.ts": "// @asset Shop.Web (#web) -- \"Public HTTP API\"\n// @asset Shop.Orders (#orders) -- \"Order service\"\n// @asset Shop.Store (#store) -- \"Order database\"\nexport {};\n", + "app/web.py": "import os\n# @boundary from Shop.Orders to Shop.Store\n" + }, + "expect": { + "unresolved": [] + } + }, + { + "id": "resolved/flows-endpoint", + "rule": "Validation", + "about": "An undeclared name resolves when a @flows names it: that is how the model writes the outside.", + "files": { + ".guardlink/definitions.ts": "// @asset Shop.Web (#web) -- \"Public HTTP API\"\n// @asset Shop.Orders (#orders) -- \"Order service\"\n// @asset Shop.Store (#store) -- \"Order database\"\nexport {};\n", + "app/web.py": "import os\n# @flows Client -> #web via HTTPS\n# @boundary from Client to #web\n" + }, + "expect": { + "unresolved": [] + } + }, + { + "id": "resolved/qualified-tag", + "rule": "Validation", + "about": "A repository-qualified tag belongs to another repository and is not checked here.", + "files": { + ".guardlink/definitions.ts": "// @asset Shop.Web (#web) -- \"Public HTTP API\"\n// @asset Shop.Orders (#orders) -- \"Order service\"\n// @asset Shop.Store (#store) -- \"Order database\"\nexport {};\n", + "app/web.py": "import os\n# @boundary from #web to #billing.charge\n" + }, + "expect": { + "unresolved": [] + } + }, + { + "id": "unresolved/outer-typo", + "rule": "Validation", + "about": "A misspelt outer side is an error: consumers would trust a direction pointing at nothing.", + "files": { + ".guardlink/definitions.ts": "// @asset Shop.Web (#web) -- \"Public HTTP API\"\n// @asset Shop.Orders (#orders) -- \"Order service\"\n// @asset Shop.Store (#store) -- \"Order database\"\nexport {};\n", + "app/web.py": "import os\n# @flows Client -> #web via HTTPS\n# @boundary from Clinet to #web\n" + }, + "expect": { + "unresolved": [ + { + "file": "app/web.py", + "line": 3 + } + ] + } + }, + { + "id": "unresolved/inner-undefined-id", + "rule": "Validation", + "about": "A #tag no @asset defines does not resolve, even though it is well-formed.", + "files": { + ".guardlink/definitions.ts": "// @asset Shop.Web (#web) -- \"Public HTTP API\"\n// @asset Shop.Orders (#orders) -- \"Order service\"\n// @asset Shop.Store (#store) -- \"Order database\"\nexport {};\n", + "app/web.py": "import os\n# @boundary from #web to #cache\n" + }, + "expect": { + "unresolved": [ + { + "file": "app/web.py", + "line": 2 + } + ] + } + }, + { + "id": "unresolved/both-sides", + "rule": "Validation", + "about": "One diagnostic per boundary, naming both sides.", + "files": { + ".guardlink/definitions.ts": "// @asset Shop.Web (#web) -- \"Public HTTP API\"\n// @asset Shop.Orders (#orders) -- \"Order service\"\n// @asset Shop.Store (#store) -- \"Order database\"\nexport {};\n", + "app/web.py": "import os\n# @boundary from Nowhere to Nothing\n" + }, + "expect": { + "unresolved": [ + { + "file": "app/web.py", + "line": 2 + } + ] + } + }, + { + "id": "undirected/not-checked", + "rule": "Validation", + "about": "An undirected boundary claims no side, so its sides are never checked here.", + "files": { + ".guardlink/definitions.ts": "// @asset Shop.Web (#web) -- \"Public HTTP API\"\n// @asset Shop.Orders (#orders) -- \"Order service\"\n// @asset Shop.Store (#store) -- \"Order database\"\nexport {};\n", + "app/web.py": "import os\n# @boundary between Nowhere and Nothing\n" + }, + "expect": { + "unresolved": [] + } + } + ] +} diff --git a/docs/GUARDLINK_REFERENCE.md b/docs/GUARDLINK_REFERENCE.md index 53b176a4..c54d2c99 100644 --- a/docs/GUARDLINK_REFERENCE.md +++ b/docs/GUARDLINK_REFERENCE.md @@ -23,6 +23,7 @@ RELATE @mitigates against <#threat> using <#control> -- "how" FLOW @flows -> [-> …] [via ] -- "details" @flows -> via GET./orders/ -- "an HTTP route, on its handler" + @boundary from to (#id) -- "trust boundary, direction known" @boundary | (#id) -- "trust boundary" @boundary between and (#id) -- "trust boundary" @@ -273,7 +274,11 @@ guardlink hypothesis confirm --from-scan .guardlink/pentest/.json [--wri `total` is additive exactly as `key` is, and the schema stays `/v1` for the same reason. A run that hides nothing is unchanged: no marker, no notice. - **A declared boundary is a claim too.** A `@boundary` says trust changes between two sides and, - in its description, what holds the line, which a probe can test from the outer side. The ledger + in its description, what holds the line, which a probe can test from the outer side. Write it + `@boundary from to ` when you know which side is less trusted: the export then + states the outer side as declared (`basis: "declared"`) instead of inferring it, which it cannot + do at all when both sides are declared assets. A directed side that names no declared asset and + no `@flows` endpoint fails `guardlink validate` (`unresolved-boundary-side`). The ledger records that against the boundary's claim key as `supported` (refused where the boundary says) or `contradicted` (it got through), with the same evidence rule (a contradiction is held to the confirmation's bar) and the same expiry (supported lapses to `unverified`, contradicted becomes diff --git a/docs/SPEC.md b/docs/SPEC.md index ca08c979..8a9f31f8 100644 --- a/docs/SPEC.md +++ b/docs/SPEC.md @@ -327,12 +327,16 @@ the following appear after the verb: | A `#reference` | `@exposes #api to` | | A spaced `--` description delimiter | `@audit App.API --` | | A grammar keyword **belonging to that verb** | `@exposes App.API to` | +| A keyword that is evidence only **as the first word** of that verb's arguments | `@boundary from Client` | The keyword set is **per verb**, not global. `to` is structural in `@exposes`, which is defined as `@exposes to `; it is not structural in `@feature`, whose grammar has no `to`. A global keyword list misclassifies ordinary English — `@feature still claims to describe the model` — as a broken -annotation. +annotation. For the same reason the directed `@boundary from to ` +does not make `from` or `to` keywords anywhere in the line: `@boundary is used to +mark a trust change` is prose. `from` is evidence only when it opens the +arguments, which is how the directed form starts. Verb keyword sets: @@ -344,7 +348,7 @@ Verb keyword sets: | `@accepts` | `on`, `to`, `by`, `until` | | `@transfers` | `from`, `to` | | `@flows` | `->` | -| `@boundary` | `between`, `and`, `\|` | +| `@boundary` | `between`, `and`, `\|`; `from` only as the first word of the arguments | | `@validates`, `@owns` | `for` | | all others | *(none — structure comes from a ref or a `--`)* | @@ -673,18 +677,45 @@ A reader MUST NOT shorten, re-case or resolve an endpoint when recording it: `#b #### `@boundary` — Trust Boundary ``` +@boundary from to [(#id)] [-- ""] @boundary between and [(#id)] [-- ""] @boundary | [(#id)] [-- ""] ``` -Marks a trust boundary between two security zones. All data crossing a boundary should be validated. The pipe (`|`) form is syntactic sugar for the `between ... and` form. +Marks a trust boundary between two security zones. All data crossing a boundary should be validated. The pipe (`|`) form is syntactic sugar for the `between ... and` form, and `between` itself may be omitted (`@boundary A and B`). ```yaml +# @boundary from External.Internet to Internal.DMZ (#perimeter) -- "WAF + firewall boundary" # @boundary between External.Internet and Internal.DMZ (#perimeter) -- "WAF + firewall boundary" # @boundary External.Internet | Internal.DMZ (#perimeter) -- "WAF + firewall boundary" -# @boundary between Internal.DMZ and Internal.Backend (#app-boundary) -- "Service mesh with mTLS" +# @boundary from Internal.DMZ to Internal.Backend (#app-boundary) -- "Service mesh with mTLS" ``` +Each side is an endpoint in any of the four forms `@flows` accepts (§3.2 `@flows`, Endpoints), recorded exactly as written. + +**Direction.** The `from … to …` form is **directed**: it declares which side is outside. `` is the less-trusted side, where untrusted input comes from; `` is the side the boundary protects. The `between`, `and` and `|` forms are **undirected**: they say trust changes between two sides and nothing about which is which. Write the directed form whenever the trust direction is known. It matters most when both sides are declared assets — an application and its database, say — because then no convention can infer the outer side (§6.6), and a test cannot tell which side to probe from. + +A direction is a claim like any other. Declaring one, removing one or reversing one changes the annotation hash (§8.2.2) and the boundary's claim key (§6.5), so a recorded test outcome does not carry over to a boundary whose direction changed. An undirected boundary hashes and keys exactly as it did before the directed form existed. + +**Malformed lines.** `from` pairs with `to`, and `between` with `and`; the two do not mix. `@boundary from #a and #b`, `@boundary between #a to #b`, `@boundary #a to #b`, `@boundary from #a`, `@boundary from #a -> #b` and `@boundary from #a to #b to #c` are malformed (§2.12), as is any line whose arguments begin with `from` and do not parse. Prose that merely contains `from` or `to` elsewhere is not evidence. + +**Validation.** A directed boundary whose outer or inner side resolves to nothing is an **error** (`unresolved-boundary-side`): consumers act on the sides a direction names, so a typo there would point a test at no component. A side resolves when it is a `#id` some `@asset` defines, or another endpoint that is a declared asset's path or that some `@flows` names (an undeclared endpoint such as `Client` is how the model writes the outside — §3.2 `@flows`, Endpoints). A repository-qualified `#repo.id` is resolved across the workspace, not here. Undirected boundaries are not checked: they claim no side. + +**Records.** Each boundary becomes one entry in the model's `boundaries` array (§5.1): + +```json +{ + "asset_a": "#orders", + "asset_b": "#store", + "id": "data", + "directed": true, + "description": "The store trusts every query it is sent", + "location": { "file": "app/orders.py", "line": 12 } +} +``` + +`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.3. Lifecycle Annotations These annotations support security process and governance. @@ -1038,7 +1069,7 @@ Attribution (§5.4) blames the annotation line at `origin_file:origin_line` when The threat model forms a directed graph: - **Nodes:** Assets, Threats, Controls -- **Edges:** `@mitigates` (Control → Asset, defending against Threat), `@exposes` (Threat → Asset), `@flows` (Asset → Asset), `@boundary` (bidirectional between Assets), `@transfers` (Asset → Asset, for a Threat), `@validates` (Test → Control) +- **Edges:** `@mitigates` (Control → Asset, defending against Threat), `@exposes` (Threat → Asset), `@flows` (Asset → Asset), `@boundary` (between Assets, undirected; outer → inner when declared with `from … to …`), `@transfers` (Asset → Asset, for a Threat), `@validates` (Test → Control) An asset with `@exposes` edges and no corresponding `@mitigates` edges is **unmitigated** — a candidate for remediation or explicit `@accepts`. @@ -1436,7 +1467,12 @@ Each hop is a `threadFlowLocation` at its own `@flows` line: `message.text` is ` **The graph.** `run.graphs[0]` holds the declared model as SARIF nodes and edges: - **Nodes** — every declared asset (`id` its canonical id, `label` `#id` or the dotted path, `location` its definition, `properties["guardlink/declared"]: true` and `guardlink/path`), then every `@flows` or `@boundary` endpoint no asset declares (`guardlink/declared: false`). Actors are not nodes. -- **Boundary edges** — `boundary:`, one per `@boundary`, undirected (`guardlink/directed: false`), with `guardlink/kind: "boundary"`, `guardlink/boundaryId`, `guardlink/claimKey`, `guardlink/location`, `guardlink/crossings` (the flow edges whose endpoints are its two sides) and `guardlink/side`. A boundary has no direction in the grammar, so the side is inferred only when exactly one endpoint is undeclared: `{ basis: "undeclared-endpoint", outer, inner }`. Otherwise it is `{ basis: "unknown" }`. +- **Boundary edges** — `boundary:`, one per `@boundary`, with `guardlink/kind: "boundary"`, `guardlink/directed`, `guardlink/boundaryId`, `guardlink/claimKey`, `guardlink/location`, `guardlink/crossings` (the flow edges whose endpoints are its two sides, in either direction) and `guardlink/side`. An undirected boundary is an undirected edge (`guardlink/directed: false`). A directed one (`@boundary from to `, §3.2) is `guardlink/directed: true`, with `sourceNodeId` its outer side and `targetNodeId` its inner side. `guardlink/side` says which side is outer and why: + - `{ basis: "declared", outer, inner }` — the boundary is directed, and these are its sides as declared. A declared side is used as written, even where the inference below would say otherwise. + - `{ basis: "undeclared-endpoint", outer, inner }` — the boundary is undirected and exactly one side is an endpoint no `@asset` declares; that side is inferred to be outer. + - `{ basis: "unknown" }` — otherwise. Neither side is stated, and a reader must not infer one. + + `outer` and `inner` are node ids. A reader written before `declared` existed should treat every basis other than `unknown` as naming both sides. The two kinds are kept distinct so a consumer can tell a side the author declared from one the exporter inferred. - **Flow edges** — `flow:`, one per `@flows` hop, directed, with `label` the mechanism, `guardlink/kind: "flow"`, `guardlink/claimKey`, `guardlink/location`, `guardlink/route` for a route channel, and `guardlink/crosses` when it crosses a boundary. Edge ids are positions in this export and change when annotations are added or removed; `guardlink/claimKey` is the identity that survives (§6.5). @@ -1486,7 +1522,7 @@ Only an acceptance that covers the exposure appears: one sited in its file and n | `message.text` | the boundary's description; `@boundary between {a} and {b} (#id)` when it has none | | `locations[0]` | the `@boundary` line | | `partialFingerprints["guardlink/claimKey"]`, `properties.claimKey` | the boundary's claim key (§6.5), the key `guardlink hypothesis support` and `contradict` record against | -| `properties.boundary` | `{ id, a, b, asset_a, asset_b, basis, outer?, inner? }`. `a` and `b` are the two sides as `run.graphs[0]` node ids; `asset_a` and `asset_b` as written; `id` is `#id` or `null` | +| `properties.boundary` | `{ id, a, b, asset_a, asset_b, basis, outer?, inner? }`. `a` and `b` are the two sides as `run.graphs[0]` node ids; `asset_a` and `asset_b` as written; `id` is `#id` or `null`. `basis` is `declared`, `undeclared-endpoint` or `unknown`, below | | `properties.description` | the description, `""` when there is none | | `properties.crossings` | `[{ from, to, via, edge, claimKey, file, line, http_method?, http_path? }]` — every `@flows` hop whose endpoints are the two sides, in either direction | | `properties.inner_routes` | `[{ http_method, http_path, edge, claimKey, file, line }]` — every route-channel `@flows` into the inner side. Empty when the inner side is not known | @@ -1494,7 +1530,7 @@ Only an acceptance that covers the exposure appears: one sited in its file and n | `codeFlows` | one per crossing: a single `threadFlowLocation` at the hop, `kinds: ["flow", "boundary-crossing"]`, with `webRequest` for a route channel | | `relatedLocations` | one per inner route, at its `@flows` line | -`@boundary` has no direction. `basis` is `"undeclared-endpoint"` when exactly one side is an endpoint no `@asset` declares, and that side is `outer`, the other `inner` — the convention `guardlink paths` reads entries by (§3.2). Otherwise `basis` is `"unknown"` and neither `outer` nor `inner` is written; a reader must not infer them. `run.graphs[0]` states the same inference as `guardlink/side`. +`basis` is `"declared"` when the boundary is written `from to ` (§3.2): `outer` is `a` and `inner` is `b`, as declared. An undirected boundary states no side, so `basis` is `"undeclared-endpoint"` when exactly one side is an endpoint no `@asset` declares, and that side is `outer`, the other `inner` — the convention `guardlink paths` reads entries by (§3.2). Otherwise `basis` is `"unknown"` and neither `outer` nor `inner` is written; a reader must not infer them. `inner_routes` lists routes into the inner side whenever there is one, declared or inferred. `run.graphs[0]` states the same sides as `guardlink/side`. **`properties["guardlink/hypothesis"]`** — on every `@exposes` result (uncovered or covered), every `@confirmed` result and every boundary claim, the claim's state in the hypothesis ledger (`.guardlink/hypotheses.json`): `{ state, evidence, by, at, expired, previous_outcome }`. `state` is `untested`, `confirmed`, `refuted` or `retest` for an exposure (a source `@confirmed` is always `confirmed`), and `unverified`, `supported`, `contradicted` or `retest` for a boundary. A ledger outcome holds while the code beneath the claim is unchanged; `expired: true` with `previous_outcome` says it lapsed. With no ledger every claim is `untested` or `unverified`. A corrupt ledger stamps nothing. `run.properties.hypothesis_ledger` is `present`, `absent` or `corrupt`. Parse-error and dangling-ref results carry none. @@ -2070,6 +2106,7 @@ CONNECT @accepts on -- "description" @transfers from to -- "description" @flows -> [-> …] [via ] -- "description" + @boundary from to (#id) -- "description" @boundary between and (#id) -- "description" @boundary | (#id) -- "description" @@ -2128,7 +2165,8 @@ exposes_args = component_path SP "to" SP threat_ref ; accepts_args = threat_ref SP "on" SP component_path ; transfers_args = threat_ref SP "from" SP component_path SP "to" SP component_path ; flows_args = endpoint SP "->" SP endpoint { SP "->" SP endpoint } [ SP "via" SP mechanism ] ; (* one record per "->" *) -boundary_args = [ "between" SP ] component_path SP "and" SP component_path [ SP id_def ] +boundary_args = "from" SP component_path SP "to" SP component_path [ SP id_def ] (* directed: outer, then inner *) + | [ "between" SP ] component_path SP "and" SP component_path [ SP id_def ] | component_path SP "|" SP component_path [ SP id_def ] ; validates_args = control_ref SP "for" SP component_path ; audit_args = component_path ; diff --git a/src/agents/prompts.ts b/src/agents/prompts.ts index 9afb8995..7db320be 100644 --- a/src/agents/prompts.ts +++ b/src/agents/prompts.ts @@ -343,13 +343,14 @@ Descriptions must reference the real code: function names, variable names, libra \`\`\` ### \`@boundary\` — Mark Every Trust Zone Crossing -Place @boundary annotations where trust level changes between two components: +Place @boundary annotations where trust level changes between two components. +When you know which side is less trusted, write the directed form \`@boundary from to \`: the outer side is where untrusted input comes from, the inner side is what the boundary protects. Testers probe the inner side from the outer one, and without a direction they cannot tell which is which when both sides are declared assets. Write \`between\` only when the code does not show the direction: \`\`\` // @shield:begin -- "Boundary examples, excluded from parsing" // -// @boundary between #api-gateway and External_Internet (#public-boundary) -- "TLS termination, rate limiting at edge" -// @boundary between #backend and #database (#data-boundary) -- "Application to persistence layer, connection pooling via pgBouncer" +// @boundary from External_Internet to #api-gateway (#public-boundary) -- "TLS termination, rate limiting at edge" +// @boundary from #backend to #database (#data-boundary) -- "Application to persistence layer, connection pooling via pgBouncer" // @boundary between #app and #payment-provider (#vendor-boundary) -- "PCI-DSS scope boundary, tokenized card data only" // // @shield:end @@ -503,7 +504,7 @@ Definitions go in .guardlink/definitions.{ts,js,py,rs}. Relationship annotations // @audit #auth -- "Timing attack risk — needs human review to decide if bcrypt constant-time comparison is sufficient" // @transfers #ddos from #api to #cdn -- "Cloudflare handles L7 DDoS mitigation" // @flows req.body.username -> db.query via string-concat -- "User input flows to SQL" -// @boundary between #frontend and #api (#web-boundary) -- "TLS-terminated public/private boundary" +// @boundary from #frontend to #api (#web-boundary) -- "TLS-terminated public/private boundary" // @handles pii on #auth -- "Processes email, password, session tokens" // @validates #prepared-stmts for #auth -- "Integration test sqlInjectionTest.ts confirms parameterized queries block SQLi payloads" // @audit #auth -- "Session token rotation logic needs cryptographic review" @@ -527,9 +528,10 @@ Definitions go in .guardlink/definitions.{ts,js,py,rs}. Relationship annotations ## CRITICAL SYNTAX RULES (violations cause parse errors) -1. **@boundary requires TWO assets**: \`@boundary between #A and #B\` or \`@boundary #A | #B\`. +1. **@boundary requires TWO assets**: \`@boundary from #outer to #inner\` when you know which side is less trusted, otherwise \`@boundary between #A and #B\` or \`@boundary #A | #B\`. WRONG: \`@boundary api -- "desc"\` (only one argument — will NOT parse) - RIGHT: \`@boundary between #api and #client (#api-boundary) -- "Trust boundary"\` + WRONG: \`@boundary from #client and #api\` (\`from\` pairs with \`to\`, \`between\` with \`and\`) + RIGHT: \`@boundary from Client to #api (#api-boundary) -- "Auth middleware: everything past it is authenticated"\` 2. **@flows is ONE path per line, joined by arrows**: \`@flows -> via \`. A chain \`@flows A -> B -> C via mechanism\` is two flows (A -> B, B -> C), each with the same mechanism. diff --git a/src/analyzer/sarif-context.ts b/src/analyzer/sarif-context.ts index 05b8d7d7..581de16f 100644 --- a/src/analyzer/sarif-context.ts +++ b/src/analyzer/sarif-context.ts @@ -124,10 +124,12 @@ export interface FindingClaim { } /** - * Which side of an undirected `@boundary` is outside, inferred by the - * undeclared-endpoint convention (SPEC §3.2) or not at all. + * Which side of a `@boundary` is outside. A directed boundary (`from + * to `) states it: `declared`. An undirected one leaves it to the + * undeclared-endpoint convention (SPEC §3.2), or to nobody: `unknown`. */ export type BoundarySide = + | { basis: 'declared'; outer: string; inner: string } | { basis: 'undeclared-endpoint'; outer: string; inner: string } | { basis: 'unknown' }; @@ -268,22 +270,27 @@ export function buildSarifContext(model: ThreatModel): SarifContext { const c = nodeFor(b.asset_b); const pair = pairKey(a, c); boundaryEdgeIds.set(pair, [...(boundaryEdgeIds.get(pair) ?? []), id]); - boundaryLabel.set(id, b.id ? `#${b.id}` : `${b.asset_a} and ${b.asset_b}`); + boundaryLabel.set(id, b.id ? `#${b.id}` : b.directed ? `${b.asset_a} to ${b.asset_b}` : `${b.asset_a} and ${b.asset_b}`); crossingsOf.set(id, []); - // The convention SPEC §3.2 gives `guardlink paths`: an undeclared endpoint - // is outside the system. With exactly one undeclared side, that side is - // outer; otherwise the annotation does not say, and neither do we. + // A directed boundary says which side is outside, and that is the answer + // whatever either side's declaration suggests. Otherwise, the convention + // SPEC §3.2 gives `guardlink paths`: an undeclared endpoint is outside the + // system. With exactly one undeclared side, that side is outer; otherwise + // the annotation does not say, and neither do we. const outer = [a, c].filter(n => !declared.has(n)); - const side: BoundarySide = outer.length === 1 - ? { basis: 'undeclared-endpoint', outer: outer[0], inner: outer[0] === a ? c : a } - : { basis: 'unknown' }; + const side: BoundarySide = b.directed + ? { basis: 'declared', outer: a, inner: c } + : outer.length === 1 + ? { basis: 'undeclared-endpoint', outer: outer[0], inner: outer[0] === a ? c : a } + : { basis: 'unknown' }; boundaryContexts.push({ boundary: b, edge: id, a, b: c, side, claimKey: claimKeyOf.get(b.location) ?? null, crossings: [] }); edges.push({ id, sourceNodeId: a, targetNodeId: c, label: { text: b.description || `@boundary ${claimText(['boundary', b])}` }, properties: { 'guardlink/kind': 'boundary', - 'guardlink/directed': false, + // Directed edges run outer -> inner: source is the outer side. + 'guardlink/directed': b.directed === true, 'guardlink/boundaryId': b.id ?? null, 'guardlink/claimKey': claimKeyOf.get(b.location) ?? null, 'guardlink/location': { physicalLocation: physicalLocation(b.location.file, b.location.line) }, @@ -515,8 +522,13 @@ export function buildSarifContext(model: ThreatModel): SarifContext { graph() { if (nodes.size === 0) return null; + // The text changes only when a directed boundary exists, so an export of + // a model without one is byte-identical to what it was before. + const boundaryEdges = boundaries.some(b => b.directed) + ? '@boundary edges (undirected, or outer to inner where guardlink/directed is true)' + : '@boundary edges (undirected)'; return { - description: { text: 'GuardLink declared threat model: @flows edges (directed) and @boundary edges (undirected). Edge ids are local to this export; guardlink/claimKey is the stable identity.' }, + description: { text: `GuardLink declared threat model: @flows edges (directed) and ${boundaryEdges}. Edge ids are local to this export; guardlink/claimKey is the stable identity.` }, nodes: [...nodes.values()], edges, }; diff --git a/src/analyzer/sarif-pentest.ts b/src/analyzer/sarif-pentest.ts index c97674d9..e4ac1c8a 100644 --- a/src/analyzer/sarif-pentest.ts +++ b/src/analyzer/sarif-pentest.ts @@ -33,10 +33,12 @@ * * ── The boundary's direction ──────────────────────────────────────── * - * `@boundary` is undirected. The outer side is stated only when exactly one - * side is an endpoint no `@asset` declares (the convention `guardlink paths` - * uses, SPEC §3.2); otherwise `basis` is `"unknown"` and neither `outer` nor - * `inner` is written. The same inference `run.graphs[0]` carries. + * A directed `@boundary from to ` states its sides: `basis` is + * `"declared"`. An undirected one does not, so its outer side is inferred only + * when exactly one side is an endpoint no `@asset` declares (the convention + * `guardlink paths` uses, SPEC §3.2): `"undeclared-endpoint"`. Otherwise + * `basis` is `"unknown"` and neither `outer` nor `inner` is written. The same + * sides `run.graphs[0]` carries. * * @flows ThreatModel -> #sarif via buildPentestAppendix -- "Covered exposures, boundaries and flows read from the parsed model" * @flows LedgerFile -> #sarif via HypothesesRead -- "Ledger states stamped onto results; the front end reads the file, this module does not" @@ -120,7 +122,7 @@ export const PENTEST_RULES = [ help: { text: 'A @boundary annotation says trust changes between two sides. Probe the inner side from the outer side and record the outcome with guardlink hypothesis support or contradict.', markdown: 'A `@boundary` annotation says trust changes between two sides. This result is a claim to verify, never a finding.\n\n' - + '`properties.boundary` names the sides, and which one is outer when the model says so; `crossings` and `codeFlows` hold the declared `@flows` across it; `inner_routes` the declared routes into its inner side. ' + + '`properties.boundary` names the sides and, when they are known, which one is outer: `basis` is `declared` when the annotation is written `from to `, `undeclared-endpoint` when it is inferred, `unknown` otherwise; `crossings` and `codeFlows` hold the declared `@flows` across it; `inner_routes` the declared routes into its inner side. ' + 'Record the outcome with `guardlink hypothesis support` or `contradict`.', }, properties: { tags: ['threat-model'] }, @@ -276,7 +278,7 @@ function boundaryClaim( // Routes into the inner side: route-channel @flows whose target is the // inner asset. Only when the side is known — with no outer side there is no // inner one to list. - const inner = bc.side.basis === 'undeclared-endpoint' ? bc.side.inner : null; + const inner = bc.side.basis === 'unknown' ? null : bc.side.inner; const innerRoutes = inner === null ? [] : flows.flatMap((f, i) => { const route = routeOf(i); if (!route || key(f.target) !== inner) return []; diff --git a/src/cli/index.ts b/src/cli/index.ts index b883b561..147e45d4 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, 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, 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'; @@ -358,6 +358,9 @@ program // Check for dangling refs const danglingDiags = findDanglingRefs(model); + // A directed @boundary whose outer or inner side names nothing (error) + const boundarySideDiags = findUnresolvedBoundarySides(model); + // Check for @accepts without @audit (governance concern) const acceptAuditDiags = findAcceptedWithoutAudit(model); @@ -407,7 +410,7 @@ program console.error(boundaryCheckSummaryLine(worklist, boundaryDiags)); } - const allDiags = [...diagnostics, ...danglingDiags, ...acceptAuditDiags, ...acceptanceDiags, ...actorDiags, ...inertDiags, ...impreciseDiags, ...provenanceDiags, ...galConventionDiags, ...ledgerDiags, ...boundaryDiags]; + const allDiags = [...diagnostics, ...danglingDiags, ...boundarySideDiags, ...acceptAuditDiags, ...acceptanceDiags, ...actorDiags, ...inertDiags, ...impreciseDiags, ...provenanceDiags, ...galConventionDiags, ...ledgerDiags, ...boundaryDiags]; // Check for unmitigated exposures const unmitigated = findUnmitigatedExposures(model); @@ -1930,7 +1933,7 @@ hypothesis const c = classifyBoundaryClaims(model, readHypotheses(root)); if (opts.json) { const records = (opts.state ? c.records.filter(r => r.state === opts.state) : c.records).map(r => ({ - key: r.key, claim: r.claim, id: r.id || null, asset_a: r.asset_a, asset_b: r.asset_b, description: r.description, file: r.file, line: r.line, state: r.state, expired: r.expired, + key: r.key, claim: r.claim, id: r.id || null, asset_a: r.asset_a, asset_b: r.asset_b, directed: r.directed, description: r.description, file: r.file, line: r.line, state: r.state, expired: r.expired, outcome: r.entry ? { outcome: r.entry.outcome, evidence: r.entry.evidence, by: r.entry.by, at: r.entry.at, source: r.entry.source, history: r.entry.history.length } : null, previous: r.previous ? { outcome: r.previous.outcome, by: r.previous.by, at: r.previous.at } : null, })); @@ -3671,8 +3674,10 @@ program console.log(D(' Declare a trust boundary between two assets.')); console.log(D(' Groups assets in the Data Flow Diagram.')); console.log(D(' Alternate: @boundary between A and B or @boundary A | B')); + console.log(D(' Directed: @boundary from to when you know which side is less trusted.')); console.log(EX(' // @boundary internet and api.gateway (#edge) -- "Public-facing edge"')); console.log(EX(' // @boundary api.gateway | db.users -- "Internal network boundary"')); + console.log(EX(' // @boundary from Client to #api (#edge) -- "Auth middleware: everything past it is authenticated"')); console.log(''); // ── LIFECYCLE ── diff --git a/src/codegraph/boundary-check.ts b/src/codegraph/boundary-check.ts index 122c8d02..178983e5 100644 --- a/src/codegraph/boundary-check.ts +++ b/src/codegraph/boundary-check.ts @@ -22,11 +22,14 @@ * human decides which is wrong. * * WHICH SIDE IS INSIDE - * `@boundary` is undirected. The outer side is inferred the way `guardlink paths` - * infers entries: an endpoint the model does not declare as an asset is outside - * the system, and so is a declared `External.*` asset. A boundary with exactly - * one outer side has the other as its inner side; with two declared inner-looking - * sides its direction is unknown and the access checks skip it rather than guess. + * A directed `@boundary from to ` says so, and that is used as + * written — it is the one way to give a boundary between two declared assets an + * inner side. An undirected one does not say, so its outer side is inferred the + * way `guardlink paths` infers entries: an endpoint the model does not declare + * as an asset is outside the system, and so is a declared `External.*` asset. + * A boundary with exactly one outer side has the other as its inner side; with + * two declared inner-looking sides its direction is unknown and the access + * checks skip it rather than guess. * * WHICH ROUTES ARE ON A SIDE * A route belongs to an asset when its handler's file carries an annotation that @@ -85,7 +88,7 @@ const exampleFile = (example: string): string | null => { }; function boundaryName(b: ThreatModelBoundary): string { - return b.id ? `#${b.id}` : `the boundary between ${b.asset_a} and ${b.asset_b}`; + return b.id ? `#${b.id}` : b.directed ? `the boundary from ${b.asset_a} to ${b.asset_b}` : `the boundary between ${b.asset_a} and ${b.asset_b}`; } function routeWhere(e: WorklistEntry): string { @@ -151,7 +154,8 @@ export function checkBoundaries(model: ThreatModel, w: Worklist | null | undefin for (const b of model.boundaries ?? []) { const ka = key(b.asset_a); const kb = key(b.asset_b); - const inner = isOuter(ka) && !isOuter(kb) ? kb : isOuter(kb) && !isOuter(ka) ? ka : null; + const inner = b.directed ? kb + : isOuter(ka) && !isOuter(kb) ? kb : isOuter(kb) && !isOuter(ka) ? ka : null; if (!inner) continue; const assumptions = (model.assumptions ?? []).filter(a => key(a.asset) === inner).map(a => a.description ?? ''); const stated = [b.description ?? '', ...assumptions].map(statedAccess).find(s => s !== null) ?? null; diff --git a/src/codegraph/index.ts b/src/codegraph/index.ts index 8a16e1b9..68c7ff60 100644 --- a/src/codegraph/index.ts +++ b/src/codegraph/index.ts @@ -861,8 +861,9 @@ function renderBoundarySuggestions(shown: WorklistEntry[]): string[] { '', '### Boundaries the graph suggests', 'Each row above crosses trust lines the code shows: an outside caller reaching the handler, and the handler reaching storage, a spawned process, the network or the filesystem. ' - + 'Where the model does not already declare one (guardlink_lookup "boundary for "), write a `@boundary` between the handler\'s asset and the other side — `Client` or an `External.*` asset for a caller, the store, process or vendor for a sink — ' - + 'and name what enforces the line in its description: the guard the graph cites, once you have read it in source. A suggestion is where to look, not a claim; write none you cannot see in the code.', + + 'Where the model does not already declare one (guardlink_lookup "boundary for "), write a `@boundary` between the handler\'s asset and the other side — `Client` or an `External.*` asset for a caller, the store, process or vendor for a sink. ' + + 'Write it directed, `@boundary from to `, when you know which side is less trusted: a caller is outer (`from Client to #api`), and a store is inner to the code that queries it (`from #api to #db`); write `between` when you do not. ' + + 'Name what enforces the line in its description: the guard the graph cites, once you have read it in source. A suggestion is where to look, not a claim; write none you cannot see in the code.', ...blocks, ]; } diff --git a/src/diff/engine.ts b/src/diff/engine.ts index 6ef08968..dfdede8a 100644 --- a/src/diff/engine.ts +++ b/src/diff/engine.ts @@ -109,7 +109,7 @@ export function diffModels(before: ThreatModel, after: ThreatModel, options: Dif const confirmed = diffByKey(before.confirmed || [], after.confirmed || [], (c: ThreatModelConfirmed) => `${c.asset}::${c.threat}`, (a: ThreatModelConfirmed, b: ThreatModelConfirmed) => a.severity !== b.severity || a.description !== b.description ? `severity/description changed` : null); const acceptances = diffByKey(before.acceptances, after.acceptances, acceptanceKey); const flows = diffByKey(before.flows, after.flows, flowKey, flowChanged); - const boundaries = diffByKey(before.boundaries, after.boundaries, boundaryKey); + const boundaries = diffByKey(before.boundaries, after.boundaries, boundaryKey, boundaryChanged); const transfers = diffByKey(before.transfers, after.transfers, transferKey); // Compute unmitigated exposure delta @@ -352,6 +352,19 @@ function flowChanged(a: ThreatModelFlow, b: ThreatModelFlow): string | null { return null; } +/** + * Only a change of direction: declaring one, dropping one, or swapping which + * side is outer. The boundary diff reported nothing on modification before the + * directed form existed, and an undirected pair still reports nothing. + */ +function boundaryChanged(a: ThreatModelBoundary, b: ThreatModelBoundary): string | null { + const direction = (x: ThreatModelBoundary) => (x.directed ? `from ${x.asset_a} to ${x.asset_b}` : 'undirected'); + if (!a.directed && !b.directed) return null; + const before = direction(a); + const after = direction(b); + return before === after ? null : `direction: ${before} → ${after}`; +} + // ─── Unmitigated exposure computation ──────────────────────────────── /** diff --git a/src/diff/format.ts b/src/diff/format.ts index 1edc2e4f..151c2557 100644 --- a/src/diff/format.ts +++ b/src/diff/format.ts @@ -79,7 +79,7 @@ export function formatDiff(diff: ThreatModelDiff): string { `${e.actor} entitled to ${e.capability}${e.asset ? ` on ${e.asset}` : ''}${e.threat ? ` against ${e.threat}` : ''}` + entitlementCaveat(e)); emitSection('Flows', diff.flows, lines, f => `${f.source} → ${f.target}${f.mechanism ? ` via ${f.mechanism}` : ''}`); - emitSection('Boundaries', diff.boundaries, lines, b => `${b.asset_a} ↔ ${b.asset_b}`); + 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})`); return lines.join('\n'); diff --git a/src/hypothesis/boundary.ts b/src/hypothesis/boundary.ts index 5395bb3b..e631c647 100644 --- a/src/hypothesis/boundary.ts +++ b/src/hypothesis/boundary.ts @@ -32,6 +32,8 @@ export interface BoundaryClaimRecord { id: string; asset_a: string; asset_b: string; + /** Written `from to `: `asset_a` is the outer side. */ + directed: boolean; description: string; file: string; line: number; @@ -63,7 +65,7 @@ export function classifyBoundaryClaims(model: ThreatModel, read: HypothesesRead) const b = byLocation.get(src.location); if (!b) continue; const base = { - key: src.key, claim: src.claim, id: b.id ?? '', asset_a: b.asset_a, asset_b: b.asset_b, description: b.description ?? '', + key: src.key, claim: src.claim, id: b.id ?? '', asset_a: b.asset_a, asset_b: b.asset_b, directed: b.directed === true, description: b.description ?? '', file: src.location.origin_file ?? src.location.file, line: src.location.origin_line ?? src.location.line, location: src.location, }; const entry = entries.get(src.key) ?? null; diff --git a/src/hypothesis/format.ts b/src/hypothesis/format.ts index 2a4410d1..1dbea17c 100644 --- a/src/hypothesis/format.ts +++ b/src/hypothesis/format.ts @@ -118,9 +118,10 @@ export function formatOutcome(record: HypothesisRecord, entry: HypothesisEntry, return lines.join('\n'); } -/** How a boundary reads in a heading: its #id, or its two sides. */ +/** How a boundary reads in a heading: its #id, or its two sides — outer first when it declares a direction. */ function boundaryLabel(r: BoundaryClaimRecord): string { - return r.id ? `#${r.id} (between ${r.asset_a} and ${r.asset_b})` : `between ${r.asset_a} and ${r.asset_b}`; + const sides = r.directed ? `from ${r.asset_a} to ${r.asset_b}` : `between ${r.asset_a} and ${r.asset_b}`; + return r.id ? `#${r.id} (${sides})` : sides; } /** One boundary outcome, as a block. Like `formatOutcome`, it names the claim, not a position. */ @@ -152,7 +153,7 @@ export function formatBoundaryList(c: BoundaryClaimClassification, state?: strin const outcome = r.state === 'unverified' && r.previous ? `previously ${r.previous.outcome} ${r.previous.at.slice(0, 10)} by ${r.previous.by} — code changed since` : r.state === 'retest' && e ? `contradicted ${e.at.slice(0, 10)} by ${e.by} — code changed since, retest` : e ? `${e.at.slice(0, 10)} by ${e.by}` : ''; - lines.push(` ${pad(r.state, 13)}${pad(short(r.id ? `#${r.id}` : '(no id)', 21), 22)}${pad(short(`${r.asset_a} ↔ ${r.asset_b}`, 33), 34)}${pad(short(`${r.file}:${r.line}`, 33), 34)}${outcome}`); + lines.push(` ${pad(r.state, 13)}${pad(short(r.id ? `#${r.id}` : '(no id)', 21), 22)}${pad(short(`${r.asset_a} ${r.directed ? '→' : '↔'} ${r.asset_b}`, 33), 34)}${pad(short(`${r.file}:${r.line}`, 33), 34)}${outcome}`); } if (rows.length === 0) lines.push(' (none)'); return lines.join('\n'); diff --git a/src/init/templates.ts b/src/init/templates.ts index ce660166..77d3f886 100644 --- a/src/init/templates.ts +++ b/src/init/templates.ts @@ -247,6 +247,7 @@ RELATE @mitigates against <#threat> using <#control> -- "how" ^ PROPOSED via \`guardlink entitle --propose\`, written only when a human accepts FLOW @flows -> via -- "details" + @boundary from to (#id) -- "trust boundary, direction known" @boundary | (#id) -- "trust boundary" @boundary between and (#id) -- "trust boundary" @@ -484,7 +485,7 @@ the same change.** This includes: new endpoints, authentication/authorization lo - **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. - **You know the team accountable for an asset** → \`@owns team-id for Asset\`. The owner is a bare token: \`platform-security\` parses, \`"Platform Security"\` and \`#platform-security\` do not. If you do not know the team, skip it rather than guessing. -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, and \`@handles\` when the asset touches classified data. Never write a lone \`@exposes\` without follow-up. +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. @@ -515,7 +516,7 @@ the same change.** This includes: new endpoints, authentication/authorization lo @mitigates App.API against #sqli using #prepared-stmts -- "Parameterized queries via pg" @audit App.API -- "Timing attack risk — needs human review to assess bcrypt constant-time comparison" @flows User -> App.API via HTTPS -- "Login request path" -@boundary between #api and #db (#data-boundary) -- "App → DB trust change" +@boundary from #api to #db (#data-boundary) -- "App → DB trust change; #api is the less-trusted side" @handles pii on App.API -- "Processes email and session token" @validates #prepared-stmts for App.API -- "sqlInjectionTest.ts ensures placeholders used" @assumes App.API -- "Caller has already authenticated; this function never re-checks the session" @@ -779,7 +780,7 @@ Every time you write or modify code that touches security-relevant behavior, you - 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.) - 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, and @handles when the asset touches classified data. +- 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. ## Workflow @@ -793,7 +794,7 @@ Every time you write or modify code that touches security-relevant behavior, you - @mitigates App.API against #sqli using #prepared-stmts -- "Parameterized queries via pg" - @audit App.API -- "Timing attack risk — needs human review" - @flows User -> App.API via HTTPS -- "Login request" -- @boundary between #api and #db (#data-boundary) -- "Trust change" +- @boundary from #api to #db (#data-boundary) -- "Trust change; outer side first" - @handles pii on App.API -- "Processes email, token" - @validates #prepared-stmts for App.API -- "CI test ensures placeholders" - @assumes App.API -- "Caller already authenticated; this never re-checks the session" @@ -1161,7 +1162,7 @@ Assets are referenced as \`#id\` or as a \`Dotted.Path\`; both resolve to the sa | \`@mitigates\` | \`@mitigates against using -- "how"\` | | \`@confirmed\` | \`@confirmed on [severity] cwe:CWE-89 -- "evidence"\` | | \`@flows\` | \`@flows -> via -- "what moves"\` — chains allowed: \`A -> B -> C\` | -| \`@boundary\` | \`@boundary between and (#id) -- "what changes across it"\` | +| \`@boundary\` | \`@boundary from to (#id) -- "what changes across it"\` when you know which side is less trusted; \`@boundary between and \` when you do not | | \`@transfers\` | \`@transfers from to -- "who owns it now"\` | | \`@validates\` | \`@validates for -- "the test that proves it"\` | | \`@audit\` | \`@audit -- "what a human needs to look at"\` | diff --git a/src/mcp/lookup.ts b/src/mcp/lookup.ts index a2bd89aa..970dbb09 100644 --- a/src/mcp/lookup.ts +++ b/src/mcp/lookup.ts @@ -564,7 +564,10 @@ function lookupBoundaries(model: ThreatModel, query: string, assetRef: string, r const results = model.boundaries .filter(b => keep(b.asset_a) || keep(b.asset_b)) .map(b => ({ - asset_a: b.asset_a, asset_b: b.asset_b, description: b.description, + asset_a: b.asset_a, asset_b: b.asset_b, + // Only on a directed boundary, where asset_a is the outer side. + ...(b.directed ? { directed: true, outer: b.asset_a, inner: b.asset_b } : {}), + description: b.description, file: b.location.file, line: b.location.line, })); return { query, type: 'boundaries', count: results.length, results, ...provenance(match), ...ambiguity(matched, v => v) }; diff --git a/src/mcp/server.ts b/src/mcp/server.ts index 91c03e25..f7f8b4b9 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, clearAnnotations, applyAnnotations, findAnchorDrift, applyReanchor, crossRepoTag } from '../parser/index.js'; +import { parseProject, findDanglingRefs, findUnmitigatedExposures, findUndeclaredActors, 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'; @@ -358,6 +358,8 @@ export function createServer(): McpServer { // Compute dangling refs using shared validation const danglingDiags = findDanglingRefs(model); + // A directed @boundary whose outer or inner side names nothing is an error. + const boundarySideDiags = findUnresolvedBoundarySides(model); // 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); @@ -377,7 +379,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, ...actorDiags, ...inertDiags, ...impreciseDiags, ...provenanceDiags, ...boundaryDiags]; + const allDiags = [...diagnostics, ...danglingDiags, ...boundarySideDiags, ...actorDiags, ...inertDiags, ...impreciseDiags, ...provenanceDiags, ...boundaryDiags]; const errors = allDiags.filter(d => d.level === 'error'); const warnings = allDiags.filter(d => d.level === 'warning'); diff --git a/src/parser/annotation-hash.ts b/src/parser/annotation-hash.ts index 6c9d0dc7..f2878268 100644 --- a/src/parser/annotation-hash.ts +++ b/src/parser/annotation-hash.ts @@ -134,8 +134,12 @@ export function canonicalAnnotationRecords(model: ThreatModel): string[] { for (const fl of model.flows ?? []) { out.push(record('flows', s(fl.source), s(fl.target), s(fl.mechanism), s(fl.description), f(fl.location.file))); } + // A declared direction is content: `from A to B` says which side is outside, + // and `between A and B` does not. The marker is appended only to a directed + // record, so every undirected boundary hashes exactly as it did before the + // directed form existed and no version bump re-hashes existing models. for (const b of model.boundaries ?? []) { - out.push(record('boundary', s(b.asset_a), s(b.asset_b), s(b.id), s(b.description), f(b.location.file))); + out.push(record('boundary', s(b.asset_a), s(b.asset_b), s(b.id), s(b.description), f(b.location.file), ...(b.directed ? ['directed'] : []))); } for (const v of model.validations ?? []) { out.push(record('validates', s(v.control), s(v.asset), s(v.description), f(v.location.file))); diff --git a/src/parser/claim-key.ts b/src/parser/claim-key.ts index 4ebc02de..12e2217f 100644 --- a/src/parser/claim-key.ts +++ b/src/parser/claim-key.ts @@ -127,7 +127,10 @@ function identity([verb, r]: Rec): string[] { case 'accepts': return [s(r.asset), s(r.threat), s(r.accepted_by), s(r.expires)]; case 'transfers': return [s(r.threat), s(r.source), s(r.target)]; case 'flows': return [s(r.source), s(r.target), s(r.mechanism)]; - case 'boundary': return [s(r.asset_a), s(r.asset_b), s(r.id)]; + // A declared direction is part of the claim: rewriting `between` as `from … + // to …` asserts something new, so it arrives in the ledger unverified. The + // marker is appended only when directed, so undirected keys never move. + case 'boundary': return [s(r.asset_a), s(r.asset_b), s(r.id), ...(r.directed ? ['directed'] : [])]; case 'validates': return [s(r.control), s(r.asset)]; case 'audit': return [s(r.asset)]; case 'owns': return [s(r.owner), s(r.asset)]; @@ -149,7 +152,7 @@ export function claimText(rec: Rec): string { case 'accepts': return `${r.threat} on ${r.asset}${r.accepted_by ? ` by ${r.accepted_by}` : ''}${r.expires ? ` until ${r.expires}` : ''}`; case 'transfers': return `${r.threat} from ${r.source} to ${r.target}`; case 'flows': return `${r.source} -> ${r.target}${r.mechanism ? ` via ${r.mechanism}` : ''}`; - case 'boundary': return `between ${r.asset_a} and ${r.asset_b}${r.id ? ` (#${r.id})` : ''}`; + case 'boundary': return `${r.directed ? `from ${r.asset_a} to ${r.asset_b}` : `between ${r.asset_a} and ${r.asset_b}`}${r.id ? ` (#${r.id})` : ''}`; case 'validates': return `${r.control} for ${r.asset}`; case 'audit': return r.asset; case 'owns': return `${r.owner} for ${r.asset}`; diff --git a/src/parser/index.ts b/src/parser/index.ts index 5ad37012..b7c3e25f 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 } from './validate.js'; +export { findDanglingRefs, findUnmitigatedExposures, findAcceptedWithoutAudit, findAcceptedExposures, findUndeclaredActors, 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 { diff --git a/src/parser/parse-line.ts b/src/parser/parse-line.ts index 48c870be..d858d0f6 100644 --- a/src/parser/parse-line.ts +++ b/src/parser/parse-line.ts @@ -133,6 +133,10 @@ const PATTERNS: Record = { // as the mechanism and lose the description. SPEC §3.2 `@flows`, Mechanism. flows: new RegExp(String.raw`^@flows\s+(${ASSET_REF}(?:\s+->\s+${ASSET_REF})+)(?:\s+via\s+(?!--\s*")((?:(?!\s+--\s*").)+?))?(?:\s+${DESC})?$`), boundary: new RegExp(String.raw`^@boundary\s+(?:between\s+)?(${ASSET_REF})\s+and\s+(${ASSET_REF})(?:\s+${ID_DEF})?(?:\s+${DESC})?$`), + // `from to ` states which side is outside. The undirected + // forms above stay as they were; a reader that ignores `directed` reads this + // one as the pair (outer, inner). SPEC §3.2 `@boundary`, Direction. + boundary_directed: new RegExp(String.raw`^@boundary\s+from\s+(${ASSET_REF})\s+to\s+(${ASSET_REF})(?:\s+${ID_DEF})?(?:\s+${DESC})?$`), boundary_pipe: new RegExp(String.raw`^@boundary\s+(${ASSET_REF})\s*\|\s*(${ASSET_REF})(?:\s+${ID_DEF})?(?:\s+${DESC})?$`), connects_v1: new RegExp(String.raw`^@connects\s+(${ASSET_REF})\s+to\s+(${ASSET_REF})(?:\s+${DESC})?$`), @@ -374,6 +378,14 @@ export function parseLine( }); } + // ── @boundary directed: @boundary from to ── + if ((m = trimmed.match(PATTERNS.boundary_directed))) { + return ok({ + ...base, verb: 'boundary', asset_a: resolveRef(m[1]), asset_b: resolveRef(m[2]), + id: m[3], description: desc(m[4]), directed: true, + }); + } + // ── @boundary pipe shorthand: @boundary A | B ── if ((m = trimmed.match(PATTERNS.boundary_pipe))) { return ok({ @@ -731,6 +743,19 @@ const VERB_KEYWORDS: Readonly> = { entitles: ['to', 'on', 'against'], }; +/** + * Keywords that are evidence only as the first word of the arguments. + * + * The directed `@boundary from to ` brings `from` and `to`, and + * both are too common in English to count anywhere in the line: `@boundary is + * used to mark a trust change` is prose, not a broken annotation. Opening the + * arguments with `from` is how the directed form starts, and prose about the + * verb almost never does. + */ +const LEADING_KEYWORDS: Readonly> = { + boundary: /^from\b/, +}; + /** * Why we believe a line was MEANT to be an annotation, or null if we do not. * @@ -769,6 +794,9 @@ export function structuralEvidence(verb: string, rest: string): string | null { // ("well--maybe") is not evidence. if (/\s--\s/.test(rest) || /\s--$/.test(rest)) return 'a `--` delimiter'; + const leading = LEADING_KEYWORDS[verb]; + if (leading && leading.test(rest.trim())) return `a leading \`${rest.trim().split(/\s+/)[0]}\``; + for (const keyword of VERB_KEYWORDS[verb] ?? []) { const pattern = /^[a-z]+$/.test(keyword) ? new RegExp(String.raw`\b${keyword}\b`) diff --git a/src/parser/parse-project.ts b/src/parser/parse-project.ts index 87cc1430..da910eb9 100644 --- a/src/parser/parse-project.ts +++ b/src/parser/parse-project.ts @@ -571,6 +571,7 @@ function assembleModel(annotations: Annotation[], fileCount: number, project: st const b = ann as BoundaryAnnotation; model.boundaries.push({ asset_a: b.asset_a, asset_b: b.asset_b, id: b.id, + ...(b.directed ? { directed: true as const } : {}), description: b.description, location: b.location, }); break; diff --git a/src/parser/validate.ts b/src/parser/validate.ts index 6fc0aadf..250441b4 100644 --- a/src/parser/validate.ts +++ b/src/parser/validate.ts @@ -6,6 +6,7 @@ * * @mitigates #parser against #tag-collision using #prefix-ownership -- "findDanglingRefs ensures #id refs resolve to definitions" * @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" */ @@ -17,6 +18,7 @@ import { // both to parser/coverage.ts, which this file now re-exports from (below). import type { ThreatModel, ParseDiagnostic, SourceLocation } from '../types/index.js'; import { normalizeName } from './normalize.js'; +import { canonicaliser } from './canonical-ref.js'; import { entitlementDemotionBlockers } from './parse-project.js'; /** @@ -106,6 +108,64 @@ export function findDanglingRefs(model: ThreatModel): ParseDiagnostic[] { return diagnostics; } +/** + * Find directed `@boundary from to ` annotations naming a side the + * model cannot place. + * + * A direction is only worth declaring if a reader can act on it: a test probes + * the inner side from the outer one, and the SARIF export states both as graph + * nodes. A side that names nothing — a typo, a renamed asset — still parses and + * still exports as `basis: "declared"`, so consumers would trust a direction + * pointing at no component. That is why this is an error, where a dangling ref + * on an undirected boundary is a warning: the undirected form claims no side. + * + * A side resolves when it is: + * - a `#tag` that a declared `@asset` defines as its id; + * - any other name that is a declared asset's path, or an endpoint some `@flows` + * names (an undeclared endpoint such as `Client` is how the model writes the + * outside, SPEC §3.2). + * A repository-qualified `#repo.tag` belongs to another repository and is + * resolved by `guardlink merge`, not here. Undirected boundaries are not checked. + */ +export function findUnresolvedBoundarySides(model: ThreatModel): ParseDiagnostic[] { + const key = canonicaliser(model); + const assetIds = new Set(); + const names = new Set(); + for (const a of model.assets ?? []) { + if (a.id) assetIds.add(a.id.toLowerCase()); + names.add(key(a.path.join('.'))); + } + for (const f of model.flows ?? []) { + names.add(key(f.source)); + names.add(key(f.target)); + } + const resolves = (ref: string): boolean => { + if (ref.startsWith('#')) { + const id = ref.slice(1); + return id.includes('.') || assetIds.has(id.toLowerCase()); + } + return names.has(key(ref)); + }; + + const diagnostics: ParseDiagnostic[] = []; + for (const b of model.boundaries ?? []) { + if (!b.directed) continue; + const unresolved = [['outer', b.asset_a], ['inner', b.asset_b]].filter(([, ref]) => !resolves(ref)); + if (unresolved.length === 0) continue; + diagnostics.push({ + level: 'error', + code: 'unresolved-boundary-side', + message: `@boundary from ${b.asset_a} to ${b.asset_b}${b.id ? ` (#${b.id})` : ''} declares a direction, but its ` + + `${unresolved.map(([role, ref]) => `${role} side ${ref}`).join(' and ')} ` + + `${unresolved.length === 1 ? 'resolves' : 'resolve'} to no declared @asset and no @flows endpoint. ` + + 'Declare it with @asset, name it in the @flows that crosses the boundary, or correct the reference.', + file: b.location.file, + line: b.location.line, + }); + } + return diagnostics; +} + /** * Coverage lives in `coverage.ts` — one predicate for the whole product (D36). * Re-exported here because every existing caller imports it from this module. diff --git a/src/playbooks/annotate.ts b/src/playbooks/annotate.ts index 486b3b79..606fb521 100644 --- a/src/playbooks/annotate.ts +++ b/src/playbooks/annotate.ts @@ -26,7 +26,7 @@ Read the entry points (HTTP handlers, CLI commands, consumers, cron, listeners), For every entry point, follow the request to its sinks and write the path as \`@flows\` edges, one per hop, naming the mechanism (\`via POST./login\`, \`via pg.query\`, \`via redis.set\`). Extend the flows the model already has; do not restate them. A hop you cannot see in the code is not a hop. ### Phase 3 — Mark the trust changes -Where the caller's trust level changes — internet to app, app to database, service to service, app to vendor, code to spawned process — write a \`@boundary between A and B\` with what enforces the line (TLS termination, auth middleware, connection pooling, tokenisation). +Where the caller's trust level changes — internet to app, app to database, service to service, app to vendor, code to spawned process — write a \`@boundary\` with what enforces the line (TLS termination, auth middleware, connection pooling, tokenisation). When you can see which side is less trusted, write it directed, \`@boundary from to \` — \`from Client to #api\`, \`from #api to #db\` — so a tester knows which side to probe from; write \`@boundary between A and B\` only when the direction is not visible in the code. ### Phase 4 — Classify the data Where an asset stores, logs or forwards personal, financial, health or secret data, write \`@handles on Asset\` with the fields by name. Where the code trusts something it never checks, write \`@assumes\`. Where you know the accountable team, write \`@owns\`. diff --git a/src/tui/commands.ts b/src/tui/commands.ts index 3412c429..bee6dd2f 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, clearAnnotations, listFeatures, filterByFeature, getFeatureSummaries } from '../parser/index.js'; +import { parseProject, findDanglingRefs, findUnmitigatedExposures, findAcceptedWithoutAudit, findAcceptedExposures, findUndeclaredActors, 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'; @@ -268,8 +268,10 @@ export function cmdGal(): void { console.log(D(' Declare a trust boundary between two assets.')); console.log(D(' Groups assets in the Data Flow Diagram.')); console.log(D(' Alternate: @boundary between A and B or @boundary A | B')); + console.log(D(' Directed: @boundary from to when you know which side is less trusted.')); console.log(EX(' // @boundary internet and api.gateway (#edge) -- "Public-facing edge"')); console.log(EX(' // @boundary api.gateway | db.users -- "Internal network boundary"')); + console.log(EX(' // @boundary from Client to #api (#edge) -- "Auth middleware: everything past it is authenticated"')); console.log(''); // ── LIFECYCLE ───────────────────────────────────────────────────── @@ -891,8 +893,9 @@ export async function cmdValidate(ctx: TuiContext): Promise { const { model, diagnostics } = await parseProject({ root: ctx.root, project: ctx.projectName }); ctx.model = model; - // Dangling refs + // Dangling refs, and a directed @boundary whose side names nothing const danglingDiags = findDanglingRefs(model); + const boundarySideDiags = findUnresolvedBoundarySides(model); // Check for @accepts without @audit (governance concern) const acceptAuditDiags = findAcceptedWithoutAudit(model); @@ -902,7 +905,7 @@ export async function cmdValidate(ctx: TuiContext): Promise { const inertDiags = findInertEntitlements(model); const impreciseDiags = findImpreciseEntitlements(model); - const allDiags = [...diagnostics, ...danglingDiags, ...acceptAuditDiags, ...actorDiags, ...inertDiags, ...impreciseDiags]; + const allDiags = [...diagnostics, ...danglingDiags, ...boundarySideDiags, ...acceptAuditDiags, ...actorDiags, ...inertDiags, ...impreciseDiags]; // Unmitigated exposures const unmitigated = findUnmitigatedExposures(model); diff --git a/src/types/index.ts b/src/types/index.ts index 6de6a158..e3e15e9b 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -195,6 +195,8 @@ export interface BoundaryAnnotation extends BaseAnnotation { asset_a: string; asset_b: string; id?: string; + /** Written `from to `: `asset_a` is the outer side, `asset_b` the inner. Absent for `between`. */ + directed?: true; } export interface ValidatesAnnotation extends BaseAnnotation { @@ -597,6 +599,13 @@ export interface ThreatModelBoundary { asset_a: string; asset_b: string; id?: string; + /** + * The annotation declared a direction (`@boundary from to `, + * SPEC §3.2): `asset_a` is the outer, less-trusted side and `asset_b` the + * inner side. Absent — never `false` — for the undirected `between` and `|` + * forms, whose side order carries no meaning. + */ + directed?: true; description?: string; location: SourceLocation; } @@ -745,6 +754,8 @@ export type DiagnosticCode = // ── Validation-time (src/parser/validate.ts) ── /** A `#id` reference resolves to no definition. */ | '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`. */ | 'undeclared-actor' /** `@entitles` cites no authz code, so it can never demote a finding. */ diff --git a/tests/boundary-check.test.ts b/tests/boundary-check.test.ts index 06e9fb5a..f9bf9e58 100644 --- a/tests/boundary-check.test.ts +++ b/tests/boundary-check.test.ts @@ -165,6 +165,22 @@ describe('checkBoundaries', () => { expect(d.some(x => x.message.includes('#data-boundary'))).toBe(false); }); + it('checks a directed boundary between two declared assets against its declared inner side', async () => { + // The same pair the test above skips, now written `from #db to #api`: the + // direction names the inner side, so nothing has to be inferred. + const directed: ThreatModel = { + ...model, + boundaries: model.boundaries.map(b => (b.id === 'data-boundary' + ? { ...b, asset_a: '#db', asset_b: '#api', directed: true as const, description: 'Everything past this point has been authenticated' } + : b)), + }; + const d = checkBoundaries(directed, await worklist()); + const contradicted = d.filter(x => x.code === 'boundary-access-contradicted' && x.message.startsWith('#data-boundary')); + expect(contradicted).toHaveLength(1); + expect(contradicted[0].message).toContain('#data-boundary says the #api side is authenticated'); + expect(contradicted[0].message).toContain('GET /export (api/export.py:2 export) is public (absence)'); + }); + it('every diagnostic is a warning', async () => { const d = checkBoundaries(model, await worklist()); expect(d.length).toBeGreaterThan(0); diff --git a/tests/boundary-direction.test.ts b/tests/boundary-direction.test.ts new file mode 100644 index 00000000..31cd8462 --- /dev/null +++ b/tests/boundary-direction.test.ts @@ -0,0 +1,264 @@ +/** + * The directed `@boundary from to ` (SPEC §3.2 `@boundary`, + * Direction): parsed inline and from `.gal` sidecars, recorded in the model, + * part of the claim's identity and the annotation hash, checked by validation, + * reported by diff, and taught to agents. The undirected `between` and `|` + * forms are pinned unchanged alongside it. + * + * The SARIF half lives in tests/sarif-pentest.test.ts and the published corpus + * in tests/conformance-boundaries.test.ts. + * + * @validates #input-sanitize for #parser -- "A directed boundary parses only as from to ; every mixed or partial form is reported as malformed, never read as a boundary" + */ +import { describe, it, expect, afterAll } from 'vitest'; +import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join, dirname } from 'node:path'; +import { parseLine } from '../src/parser/parse-line.js'; +import { parseProject } from '../src/parser/parse-project.js'; +import { findUnresolvedBoundarySides } from '../src/parser/validate.js'; +import { canonicalAnnotationRecords, computeAnnotationHash } from '../src/parser/annotation-hash.js'; +import { relationRecords } from '../src/parser/claim-key.js'; +import { diffModels } from '../src/diff/engine.js'; +import { formatDiff } from '../src/diff/format.js'; +import { lookup } from '../src/mcp/lookup.js'; +import { buildAnnotatePrompt } from '../src/agents/prompts.js'; +import { getPlaybook } from '../src/playbooks/index.js'; +import { agentInstructions, referenceDocContent } from '../src/init/templates.js'; +import type { ThreatModel, ThreatModelBoundary } from '../src/types/index.js'; + +const at = { file: 'app/web.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 }); }); + +async function parseFiles(files: Record) { + const root = mkdtempSync(join(tmpdir(), 'gl-boundary-direction-')); + roots.push(root); + for (const [path, body] of Object.entries(files)) { + mkdirSync(dirname(join(root, path)), { recursive: true }); + writeFileSync(join(root, path), body); + } + return parseProject({ root, project: 'boundary-direction' }); +} + +const DEFINITIONS = [ + '// @asset App.Web (#web) -- "Public HTTP API"', + '// @asset App.Db (#db) -- "Order database"', + 'export {};', +].join('\n'); + +// ─── Grammar ───────────────────────────────────────────────────────── + +describe('parsing the directed form', () => { + it('reads from to as a directed pair, outer first', () => { + const r = line('@boundary from Client to #web (#edge) -- "Nothing before this point is trusted"'); + expect(r.diagnostic).toBeNull(); + expect(r.annotation).toMatchObject({ + verb: 'boundary', asset_a: 'Client', asset_b: '#web', id: 'edge', directed: true, + description: 'Nothing before this point is trusted', + }); + }); + + it('takes every endpoint form the undirected one does, with id and description optional', () => { + expect(line('@boundary from #api to #db').annotation).toMatchObject({ asset_a: '#api', asset_b: '#db', directed: true }); + expect(line('@boundary from External.Internet to App.Api (#perimeter)').annotation) + .toMatchObject({ asset_a: 'External.Internet', asset_b: 'App.Api', id: 'perimeter', directed: true }); + expect(line('@boundary from "User Browser" to "Backend API"').annotation) + .toMatchObject({ asset_a: 'User Browser', asset_b: 'Backend API', directed: true }); + expect(line('@boundary from #web to #billing.charge -- "Cross-repo inner side"').annotation) + .toMatchObject({ asset_a: '#web', asset_b: '#billing.charge', directed: true }); + }); + + it('leaves the between and pipe forms undirected, with no directed field at all', () => { + for (const text of [ + '@boundary between Client and #web (#edge) -- "d"', + '@boundary Client and #web', + '@boundary Client | #web (#edge)', + ]) { + const a = line(text).annotation as Record; + expect(a).toMatchObject({ verb: 'boundary', asset_a: 'Client', asset_b: '#web' }); + expect('directed' in a).toBe(false); + } + }); + + it('reports every partial or mixed form as a malformed annotation', () => { + for (const text of [ + '@boundary from #api', + '@boundary from #api to', + '@boundary from #api and #db', + '@boundary between #api to #db', + '@boundary from #api -> #db', + '@boundary to #db from #api', + '@boundary from #api to #db to #cache', + '@boundary #api to #db', + '@boundary from Client', + ]) { + const r = line(text); + expect(r.annotation, text).toBeNull(); + expect(r.diagnostic, text).toMatchObject({ level: 'error', code: 'malformed-annotation' }); + } + }); + + it('keeps prose that merely contains from or to a warning, not an error', () => { + for (const text of [ + '@boundary is used to mark a trust change', + '@boundary annotations come from the spec', + ]) { + expect(line(text).diagnostic, text).toMatchObject({ level: 'warning', code: 'prose-like' }); + } + }); +}); + +describe('the model', () => { + it('records directed: true for the directed form, inline and in a .gal sidecar, and nothing for the undirected forms', async () => { + const { model, diagnostics } = await parseFiles({ + '.guardlink/definitions.ts': DEFINITIONS, + 'app/web.ts': [ + '// @boundary from Client to #web (#edge) -- "inline"', + '// @boundary between #web and #db (#data) -- "undirected"', + 'export const web = 1;', + ].join('\n'), + 'app/db.ts': 'export const db = 1;\n', + '.guardlink/annotations/app/db.ts.gal': [ + '@source file:app/db.ts line:1', + '@boundary from #web to #db (#store) -- "sidecar"', + ].join('\n'), + }); + expect(diagnostics.filter(d => d.level === 'error')).toEqual([]); + const byId = Object.fromEntries(model.boundaries.map(b => [b.id, b])); + expect(byId.edge).toMatchObject({ asset_a: 'Client', asset_b: '#web', directed: true }); + expect(byId.store).toMatchObject({ asset_a: '#web', asset_b: '#db', directed: true }); + expect(byId.store.location).toMatchObject({ file: 'app/db.ts', origin_file: '.guardlink/annotations/app/db.ts.gal' }); + expect('directed' in byId.data).toBe(false); + }); +}); + +// ─── Identity ──────────────────────────────────────────────────────── + +const boundary = (b: Partial): ThreatModel => ({ + boundaries: [{ asset_a: '#api', asset_b: '#db', id: 'data', description: 'd', location: { file: 'a.ts', line: 1 }, ...b }], +} as unknown as ThreatModel); + +describe('identity: annotation hash and claim key', () => { + it('hashes an undirected boundary exactly as before the directed form existed', () => { + const SEP = String.fromCharCode(1); + expect(canonicalAnnotationRecords(boundary({}))).toEqual([['boundary', '#api', '#db', 'data', 'd', 'a.ts'].join(SEP)]); + }); + + it('changes the hash and the claim key when a direction is declared, and when it is reversed', () => { + const undirected = boundary({}); + const directed = boundary({ directed: true }); + const reversed = boundary({ asset_a: '#db', asset_b: '#api', directed: true }); + const hashes = new Set([undirected, directed, reversed].map(m => computeAnnotationHash(m))); + expect(hashes.size).toBe(3); + const keys = new Set([undirected, directed, reversed].map(m => relationRecords(m)[0].key)); + expect(keys.size).toBe(3); + }); + + it('renders the claim in the form it was written', () => { + expect(relationRecords(boundary({})).map(r => r.claim)).toEqual(['between #api and #db (#data)']); + expect(relationRecords(boundary({ directed: true })).map(r => r.claim)).toEqual(['from #api to #db (#data)']); + }); +}); + +// ─── Validation ────────────────────────────────────────────────────── + +describe('findUnresolvedBoundarySides', () => { + it('accepts sides that are declared assets, by id or path, or @flows endpoints', async () => { + const { model } = await parseFiles({ + '.guardlink/definitions.ts': DEFINITIONS, + 'app/web.ts': [ + '// @flows Client -> #web via HTTPS', + '// @boundary from Client to #web (#edge)', + '// @boundary from App.Web to App.Db (#data)', + '// @boundary from #web to #billing.charge (#remote) -- "resolved by guardlink merge, not here"', + 'export const web = 1;', + ].join('\n'), + }); + expect(findUnresolvedBoundarySides(model)).toEqual([]); + }); + + it('errors on a directed side that is no declared asset and no @flows endpoint, naming which side', async () => { + const { model } = await parseFiles({ + '.guardlink/definitions.ts': DEFINITIONS, + 'app/web.ts': [ + '// @boundary from Clinet to #web (#edge)', + '// @boundary from #web to #dbb (#data)', + '// @boundary from Nowhere to Nothing', + 'export const web = 1;', + ].join('\n'), + }); + const d = findUnresolvedBoundarySides(model); + expect(d.map(x => [x.level, x.code, x.line])).toEqual([ + ['error', 'unresolved-boundary-side', 1], + ['error', 'unresolved-boundary-side', 2], + ['error', 'unresolved-boundary-side', 3], + ]); + expect(d[0].message).toContain('outer side Clinet resolves to no declared @asset'); + expect(d[1].message).toContain('inner side #dbb'); + expect(d[2].message).toContain('outer side Nowhere and inner side Nothing resolve'); + }); + + it('never checks an undirected boundary, whose side is the convention to infer', async () => { + const { model } = await parseFiles({ + '.guardlink/definitions.ts': DEFINITIONS, + 'app/web.ts': '// @boundary between Nowhere and Nothing\nexport const web = 1;\n', + }); + expect(findUnresolvedBoundarySides(model)).toEqual([]); + }); +}); + +// ─── Lookup ────────────────────────────────────────────────────────── + +describe('guardlink_lookup "boundary for X"', () => { + it('names the outer and inner side of a directed boundary, and adds nothing to an undirected one', async () => { + const { model } = await parseFiles({ + '.guardlink/definitions.ts': DEFINITIONS, + 'app/web.ts': [ + '// @boundary from Client to #web (#edge)', + '// @boundary between #web and #db (#data)', + 'export const web = 1;', + ].join('\n'), + }); + const rows = lookup(model, 'boundary for #web').results as Record[]; + expect(rows).toHaveLength(2); + expect(rows[0]).toMatchObject({ asset_a: 'Client', asset_b: '#web', directed: true, outer: 'Client', inner: '#web' }); + expect(Object.keys(rows[1])).toEqual(['asset_a', 'asset_b', 'description', 'file', 'line']); + }); +}); + +// ─── Diff ──────────────────────────────────────────────────────────── + +describe('diff', () => { + const model = (b: Partial): ThreatModel => ({ + ...boundary(b), + assets: [], threats: [], controls: [], mitigations: [], exposures: [], confirmed: [], acceptances: [], flows: [], transfers: [], + } as unknown as ThreatModel); + + it('reports declaring, reversing and dropping a direction as a modification', () => { + const undirected = model({}); + const directed = model({ directed: true }); + const reversed = model({ asset_a: '#db', asset_b: '#api', directed: true }); + const one = (a: ThreatModel, b: ThreatModel) => diffModels(a, b).boundaries; + expect(one(undirected, directed)).toMatchObject([{ kind: 'modified', details: 'direction: undirected → from #api to #db' }]); + expect(one(directed, reversed)).toMatchObject([{ kind: 'modified', details: 'direction: from #api to #db → from #db to #api' }]); + expect(one(directed, undirected)).toMatchObject([{ kind: 'modified', details: 'direction: from #api to #db → undirected' }]); + expect(one(undirected, undirected)).toEqual([]); + expect(formatDiff(diffModels(undirected, directed))).toContain('~ #api → #db (outer → inner) (direction: undirected → from #api to #db)'); + }); +}); + +// ─── What agents are taught ────────────────────────────────────────── + +describe('agent material teaches the directed form', () => { + it('the annotate prompt, the map playbook and the instruction templates', () => { + const prompt = buildAnnotatePrompt('annotate the api', '/nonexistent', null, 'inline'); + expect(prompt).toContain('@boundary from to '); + expect(getPlaybook('map').body).toContain('@boundary from to '); + const project = { name: 'p', language: 'typescript', definitionsPath: '.guardlink/definitions.ts' } as any; + expect(agentInstructions(project)).toContain('@boundary from #api to #db'); + expect(referenceDocContent(project)).toContain('@boundary from to '); + }); +}); diff --git a/tests/conformance-boundaries.test.ts b/tests/conformance-boundaries.test.ts new file mode 100644 index 00000000..b0e81cef --- /dev/null +++ b/tests/conformance-boundaries.test.ts @@ -0,0 +1,128 @@ +/** + * The published @boundary conformance corpus, run against this parser and exporter. + * + * `conformance/boundaries.json` is what other readers of GuardLink annotations and + * of its SARIF export test against (conformance/README.md); SPEC §3.2 `@boundary` + * and §6.6/§6.8 are what it pins. Running it here keeps the three in agreement: + * a change that moves a record, a side or a validation result fails this file + * before it can ship a corpus that no longer describes the reference. + * + * Sides are checked twice — in `run.graphs[0]` and in the pentest profile's + * boundary claims — because those are the two places a consumer reads them. + * + * @validates #input-sanitize for #parser -- "Every conforming @boundary form yields its specified record, and every malformed one yields a diagnostic rather than a dropped line" + */ +import { describe, it, expect, afterAll } from 'vitest'; +import { mkdtempSync, rmSync, writeFileSync, mkdirSync, readFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join, dirname } from 'node:path'; +import { parseProject } from '../src/parser/parse-project.js'; +import { findUnresolvedBoundarySides } from '../src/parser/validate.js'; +import { generateSarif } from '../src/analyzer/sarif.js'; +import type { ThreatModelBoundary } from '../src/types/index.js'; + +interface Site { file: string; line: number } +interface CorpusCase { id: string; rule: string; about?: string; files: Record; expect: E } +type Side = { basis: 'declared' | 'undeclared-endpoint'; outer: string; inner: string } | { basis: 'unknown' }; +interface Corpus { + corpus: string; + version: number; + parse: CorpusCase<{ boundaries: unknown[]; malformed: Site[] }>[]; + sides: CorpusCase<{ boundary: Site; side: Side }[]>[]; + validation: CorpusCase<{ unresolved: Site[] }>[]; +} + +const corpus: Corpus = JSON.parse(readFileSync(join(__dirname, '..', 'conformance', 'boundaries.json'), 'utf-8')); + +const roots: string[] = []; +afterAll(() => { for (const r of roots) rmSync(r, { recursive: true, force: true }); }); + +async function parseCase(files: Record) { + const root = mkdtempSync(join(tmpdir(), 'gl-conformance-boundary-')); + roots.push(root); + for (const [path, body] of Object.entries(files)) { + mkdirSync(dirname(join(root, path)), { recursive: true }); + writeFileSync(join(root, path), body); + } + return parseProject({ root, project: 'conformance' }); +} + +/** A boundary record in the corpus's shape: every field present, absence as null or false. */ +function record(b: ThreatModelBoundary) { + return { + asset_a: b.asset_a, + asset_b: b.asset_b, + id: b.id ?? null, + directed: b.directed === true, + description: b.description ?? null, + location: { + file: b.location.file, + line: b.location.line, + origin_file: b.location.origin_file ?? null, + origin_line: b.location.origin_line ?? null, + }, + }; +} + +const bySite = (rows: T[]) => + [...rows].sort((a, b) => a.location.file.localeCompare(b.location.file) || a.location.line - b.location.line); + +describe(`conformance corpus ${corpus.corpus} v${corpus.version}`, () => { + it('has a unique id on every case', () => { + const ids = [...corpus.parse, ...corpus.sides, ...corpus.validation].map(c => c.id); + expect(new Set(ids).size).toBe(ids.length); + }); + + it('covers directed, undirected and malformed lines', () => { + const prefixes = new Set(corpus.parse.map(c => c.id.split('/')[0])); + for (const p of ['directed', 'undirected', 'malformed']) expect(prefixes).toContain(p); + }); + + describe('parse', () => { + for (const c of corpus.parse) { + it(c.id, async () => { + const { model, diagnostics } = await parseCase(c.files); + expect(bySite(model.boundaries.map(record))).toEqual(bySite(c.expect.boundaries as ReturnType[])); + const malformed = diagnostics + .filter(d => d.code === 'malformed-annotation' && /@boundary/.test(d.message)) + .map(d => ({ file: d.file, line: d.line })) + .sort((a, b) => a.line - b.line); + expect(malformed).toEqual(c.expect.malformed); + }); + } + }); + + describe('sides, as the SARIF export states them', () => { + for (const c of corpus.sides) { + it(c.id, async () => { + const { model } = await parseCase(c.files); + const run = generateSarif(model, [], [], { profile: 'pentest' }).runs[0]; + const claims = run.results.filter(r => r.ruleId === 'guardlink/boundary-claim'); + for (const want of c.expect) { + const i = model.boundaries.findIndex(b => b.location.file === want.boundary.file && b.location.line === want.boundary.line); + expect(i, `${want.boundary.file}:${want.boundary.line}`).toBeGreaterThan(-1); + const p = claims[i].properties as Record; + // The export names sides by graph node id; the corpus by the side as written. + const written = (node: string) => (node === p.boundary.a ? p.boundary.asset_a : p.boundary.asset_b); + const side = (s: Record): Side => (s.basis === 'unknown' + ? { basis: 'unknown' } + : { basis: s.basis, outer: written(s.outer), inner: written(s.inner) }); + expect(side(p.boundary)).toEqual(want.side); + const edge = run.graphs![0].edges.find(e => e.id === p['guardlink/edge'])!; + expect(side(edge.properties['guardlink/side'])).toEqual(want.side); + expect(edge.properties['guardlink/directed']).toBe(want.side.basis === 'declared'); + } + }); + } + }); + + describe('validation', () => { + for (const c of corpus.validation) { + it(c.id, async () => { + const { model } = await parseCase(c.files); + const got = findUnresolvedBoundarySides(model).map(d => ({ file: d.file, line: d.line })); + expect(got).toEqual(c.expect.unresolved); + }); + } + }); +}); diff --git a/tests/fixtures/boundary-direction/.guardlink/config.json b/tests/fixtures/boundary-direction/.guardlink/config.json new file mode 100644 index 00000000..a3ebfd51 --- /dev/null +++ b/tests/fixtures/boundary-direction/.guardlink/config.json @@ -0,0 +1,15 @@ +{ + "version": "1.1.0", + "project": "boundary-direction", + "language": "typescript", + "annotation_mode": "inline", + "definitions": "definitions.ts", + "include": [ + "**/*.ts" + ], + "exclude": [ + "node_modules", + "dist", + ".git" + ] +} diff --git a/tests/fixtures/boundary-direction/.guardlink/definitions.ts b/tests/fixtures/boundary-direction/.guardlink/definitions.ts new file mode 100644 index 00000000..d015be88 --- /dev/null +++ b/tests/fixtures/boundary-direction/.guardlink/definitions.ts @@ -0,0 +1,7 @@ +// Definitions for the boundary-direction fixture. See ../FIXTURE.md. + +// @asset Shop.Web (#web) -- "Public HTTP API" +// @asset Shop.Orders (#orders) -- "Order service behind the API" +// @asset Shop.Store (#store) -- "Order database" + +// @threat SQL_Injection (#sqli) [critical] cwe:CWE-89 owasp:A03:2021 -- "Request text reaches a query unescaped" diff --git a/tests/fixtures/boundary-direction/FIXTURE.md b/tests/fixtures/boundary-direction/FIXTURE.md new file mode 100644 index 00000000..f1f090f2 --- /dev/null +++ b/tests/fixtures/boundary-direction/FIXTURE.md @@ -0,0 +1,18 @@ +# Fixture: `boundary-direction` + +A small inline-mode TypeScript service whose boundaries cover every way the +SARIF export states a boundary's sides (SPEC §6.6, §6.8): + +| Boundary | Written | Sides in the export | +|---|---|---| +| `#edge` | `@boundary from Client to #web` | `basis: "declared"`, outer `client`, inner `web` | +| `#data` | `@boundary from #orders to #store` | `basis: "declared"`, outer `orders`, inner `store` — both sides are declared assets, so only the direction says which is outside | +| `#svc` | `@boundary between #web and #orders` | `basis: "unknown"` — two declared assets and no direction | +| `#backup` | `@boundary between #store and Backup` | `basis: "undeclared-endpoint"`, outer `backup` — inferred, because `Backup` is no declared asset | + +Its `pentest` export is `tests/fixtures/sarif-pentest/boundary-direction.sarif`; +editing this fixture changes that export, so regenerate it with the command in +that directory's README. + +It does not perturb this repository's own model: `**/tests/**` is in the +parser's `DEFAULT_EXCLUDE`. diff --git a/tests/fixtures/boundary-direction/src/web.ts b/tests/fixtures/boundary-direction/src/web.ts new file mode 100644 index 00000000..cce9d118 --- /dev/null +++ b/tests/fixtures/boundary-direction/src/web.ts @@ -0,0 +1,30 @@ +/** + * HTTP entry points for the boundary-direction fixture. + * + * @boundary from Client to #web (#edge) -- "Internet to API: nothing before this point is trusted" + * @boundary between #web and #orders (#svc) -- "API to order service over the internal network" + */ +export const service = 'web'; + +/** + * @flows Client -> #web via POST./orders/search -- "Free-text search term in the body" + * @flows #web -> #orders via searchOrders -- "Term passed to the order service" + * @flows #orders -> #store via query -- "Term reaches SQL" + * @boundary from #orders to #store (#data) -- "Service to database: the store trusts every query it is sent" + * @exposes #store to #sqli [critical] cwe:CWE-89 owasp:A03:2021 -- "Search term concatenated into SQL" + */ +export function searchOrders(term: string): string { + return lookup(`SELECT * FROM orders WHERE note LIKE '%${term}%'`); +} + +/** + * @flows #store -> Backup via pg_dump -- "Nightly dump to off-site storage" + * @boundary between #store and Backup (#backup) -- "Database to off-site storage" + */ +export function nightlyBackup(): string { + return lookup('backup'); +} + +function lookup(text: string): string { + return text; +} diff --git a/tests/fixtures/sarif-pentest/README.md b/tests/fixtures/sarif-pentest/README.md index a61ac2c4..df1809cd 100644 --- a/tests/fixtures/sarif-pentest/README.md +++ b/tests/fixtures/sarif-pentest/README.md @@ -1,13 +1,14 @@ # `pentest`-profile SARIF fixtures The `pentest` profile of `guardlink sarif` (SPEC §6.8) as it is exported today, -for two fixtures. A tool that reads GuardLink SARIF can test its reader against +for three fixtures. A tool that reads GuardLink SARIF can test its reader against these files without installing GuardLink. | File | Export of | What it holds | |---|---|---| | `sarif-shop.sarif` | `tests/fixtures/sarif-shop`, with its hypothesis ledger | every rule GuardLink writes: both unmitigated rules, a confirmed result, a parse error, a dangling reference, a `guardlink/mitigated-exposure` with its suppression, and two `guardlink/boundary-claim` results — `#edge` with an outer side, `#data` between two declared assets and so `basis: "unknown"`. Ledger states `untested`, `confirmed`, `refuted`, `supported` and `unverified` | | `expense-api.sarif` | `tests/fixtures/expense-api`, no ledger | eleven unmitigated exposures and three boundary claims, two with an outer side and one unknown; every claim `untested` or `unverified` | +| `boundary-direction.sarif` | `tests/fixtures/boundary-direction`, no ledger | four boundary claims, one per way a side is stated: two directed (`@boundary from to `, `basis: "declared"`), including `#data` between two declared assets; one inferred (`basis: "undeclared-endpoint"`); one `basis: "unknown"` | What a reader can rely on, and `tests/sarif-pentest.test.ts` checks: @@ -16,7 +17,8 @@ What a reader can rely on, and `tests/sarif-pentest.test.ts` checks: same indexes. Every pentest result comes after them. - A boundary claim carries its claim key in `partialFingerprints["guardlink/claimKey"]` and `properties.claimKey`. `properties.boundary` holds both `outer` and `inner` - (one each of `a` and `b`), or neither, when `basis` is `"unknown"`. + (one each of `a` and `b`), or neither, when `basis` is `"unknown"`. When `basis` + is `"declared"`, `outer` is `a` and `inner` is `b`. `crossings` and `inner_routes` are always arrays. - A mitigated exposure is the uncovered exposure's result under another rule: same message, properties and identity, plus `suppressions[]`. diff --git a/tests/fixtures/sarif-pentest/boundary-direction.sarif b/tests/fixtures/sarif-pentest/boundary-direction.sarif new file mode 100644 index 00000000..a8a06da8 --- /dev/null +++ b/tests/fixtures/sarif-pentest/boundary-direction.sarif @@ -0,0 +1,1204 @@ +{ + "$schema": "https://raw.githubusercontent.com/oasis-tcs/sarif-spec/main/sarif-2.1/schema/sarif-schema-2.1.0.json", + "version": "2.1.0", + "runs": [ + { + "tool": { + "driver": { + "name": "GuardLink", + "version": "2.1.0", + "informationUri": "https://guardlink.bugb.io", + "rules": [ + { + "id": "guardlink/unmitigated-exposure", + "name": "UnmitigatedExposure", + "shortDescription": { + "text": "Asset exposed to threat with no mitigation or acceptance" + }, + "fullDescription": { + "text": "An @exposes annotation exists but no matching @mitigates or @accepts covers this (asset, threat) pair. This represents an acknowledged but unaddressed security risk." + }, + "helpUri": "https://guardlink.bugb.io/docs/exposures", + "defaultConfiguration": { + "level": "warning" + }, + "help": { + "text": "An @exposes annotation declares this risk and no @mitigates or @accepts covers it (GuardLink SPEC §3.6.1). Add the control and record it with @mitigates, or have the accountable person record an @accepts.", + "markdown": "An `@exposes` annotation declares this risk and no `@mitigates` or `@accepts` covers it (GuardLink SPEC §3.6.1 says which ones do).\n\n- Fix the weakness and record the control with `@mitigates against using `.\n- Or, if the risk is knowingly taken, have the accountable person record `@accepts`. That is a governance decision, not a code change.\n\nRelated locations show the boundaries, assumptions, data classes, transfers and audits declared on the same asset; code flows, when present, show the declared `@flows` chain into the claim." + }, + "properties": { + "tags": [ + "security", + "threat-model" + ], + "security-severity": "5.0" + } + }, + { + "id": "guardlink/unmitigated-critical", + "name": "UnmitigatedCriticalExposure", + "shortDescription": { + "text": "Critical/high severity exposure with no mitigation" + }, + "fullDescription": { + "text": "A critical or high severity exposure exists without mitigation. This should be addressed before deployment." + }, + "helpUri": "https://guardlink.bugb.io/docs/exposures", + "defaultConfiguration": { + "level": "error" + }, + "help": { + "text": "A critical or high severity @exposes has no @mitigates or @accepts covering it (GuardLink SPEC §3.6.1). Address it before deployment.", + "markdown": "A critical or high severity `@exposes` has no `@mitigates` or `@accepts` covering it (GuardLink SPEC §3.6.1). Address it before deployment.\n\nThe result's `properties.severity` holds the exact severity; this rule covers both critical and high." + }, + "properties": { + "tags": [ + "security", + "threat-model" + ], + "security-severity": "8.9" + } + }, + { + "id": "guardlink/confirmed-exploitable", + "name": "ConfirmedExploitable", + "shortDescription": { + "text": "Threat verified exploitable through testing" + }, + "fullDescription": { + "text": "A @confirmed annotation marks this threat as verified through pentest, scanning, or manual reproduction. This is not a false positive and requires immediate remediation." + }, + "helpUri": "https://guardlink.bugb.io/docs/confirmed", + "defaultConfiguration": { + "level": "error" + }, + "help": { + "text": "A @confirmed annotation records that this threat was reproduced by a pentest, scanner or manual test. It is not a false positive.", + "markdown": "A `@confirmed` annotation records that this threat was reproduced by a pentest, a scanner or a manual test. It is not a false positive and needs remediation." + }, + "properties": { + "tags": [ + "security", + "threat-model" + ], + "security-severity": "9.0" + } + }, + { + "id": "guardlink/parse-error", + "name": "AnnotationParseError", + "shortDescription": { + "text": "Malformed GuardLink annotation" + }, + "fullDescription": { + "text": "A GuardLink annotation could not be parsed. Check syntax against the specification." + }, + "helpUri": "https://guardlink.bugb.io/docs/syntax", + "defaultConfiguration": { + "level": "error" + }, + "help": { + "text": "A GuardLink annotation could not be parsed, so the claim it makes is missing from the threat model. Fix its syntax.", + "markdown": "A GuardLink annotation could not be parsed, so the claim it makes is missing from the threat model. Fix its syntax; `guardlink validate .` reports the same error locally." + }, + "properties": { + "tags": [ + "threat-model" + ] + } + }, + { + "id": "guardlink/dangling-ref", + "name": "DanglingReference", + "shortDescription": { + "text": "Reference to undefined threat, control, or asset ID" + }, + "fullDescription": { + "text": "An annotation references a #id that is not defined anywhere in the project." + }, + "helpUri": "https://guardlink.bugb.io/docs/definitions", + "defaultConfiguration": { + "level": "warning" + }, + "help": { + "text": "An annotation names a #id that no @asset, @threat or @control defines. Define it, or correct the reference.", + "markdown": "An annotation names a `#id` that no `@asset`, `@threat` or `@control` defines. Define it in the definitions file, or correct the reference." + }, + "properties": { + "tags": [ + "threat-model" + ] + } + }, + { + "id": "guardlink/mitigated-exposure", + "name": "MitigatedExposure", + "shortDescription": { + "text": "Exposure covered by a declared @mitigates or @accepts" + }, + "fullDescription": { + "text": "An @exposes annotation that a @mitigates or @accepts covers. The covering annotation is a claim, not evidence; the result is exported so a test can check that the control holds. Its suppressions name what covers it." + }, + "helpUri": "https://guardlink.bugb.io/docs/exposures", + "defaultConfiguration": { + "level": "note" + }, + "help": { + "text": "An @exposes annotation declares this risk and a @mitigates or @accepts covers it (GuardLink SPEC §3.6.1). Test that the control holds; record the outcome with guardlink hypothesis confirm or refute.", + "markdown": "An `@exposes` annotation declares this risk and a `@mitigates` or `@accepts` covers it (GuardLink SPEC §3.6.1). The result's `suppressions` name each covering annotation and where it is.\n\nA declared control is a claim. Test that it holds, and record the outcome with `guardlink hypothesis confirm` or `refute`." + }, + "properties": { + "tags": [ + "threat-model" + ] + } + }, + { + "id": "guardlink/boundary-claim", + "name": "BoundaryClaim", + "shortDescription": { + "text": "Declared trust boundary: a claim to verify" + }, + "fullDescription": { + "text": "A @boundary annotation says trust changes between two sides. It is a claim to test from the outer side, not a finding." + }, + "helpUri": "https://guardlink.bugb.io/docs/boundaries", + "defaultConfiguration": { + "level": "none" + }, + "help": { + "text": "A @boundary annotation says trust changes between two sides. Probe the inner side from the outer side and record the outcome with guardlink hypothesis support or contradict.", + "markdown": "A `@boundary` annotation says trust changes between two sides. This result is a claim to verify, never a finding.\n\n`properties.boundary` names the sides and, when they are known, which one is outer: `basis` is `declared` when the annotation is written `from to `, `undeclared-endpoint` when it is inferred, `unknown` otherwise; `crossings` and `codeFlows` hold the declared `@flows` across it; `inner_routes` the declared routes into its inner side. Record the outcome with `guardlink hypothesis support` or `contradict`." + }, + "properties": { + "tags": [ + "threat-model" + ] + } + } + ] + } + }, + "results": [ + { + "ruleId": "guardlink/unmitigated-critical", + "level": "error", + "message": { + "text": "#store is exposed to sqli: Search term concatenated into SQL" + }, + "locations": [ + { + "physicalLocation": { + "artifactLocation": { + "uri": "src/web.ts" + }, + "region": { + "startLine": 14 + } + }, + "logicalLocations": [ + { + "name": "searchOrders" + } + ] + } + ], + "partialFingerprints": { + "guardlink/threatId": "gl-55d85b3632f3", + "guardlink/claimKey": "aa7d0f55159827485580599056a256250d97d83583387f0d915cdae280fb2f99:0" + }, + "properties": { + "threatId": "gl-55d85b3632f3", + "claimKey": "aa7d0f55159827485580599056a256250d97d83583387f0d915cdae280fb2f99:0", + "severity": "critical", + "asset": "#store", + "threat": "#sqli", + "externalRefs": [ + "cwe:CWE-89", + "owasp:A03:2021" + ], + "codegraph_reachability": { + "http_method": "POST", + "http_path": "/orders/search" + }, + "route_attribution": "handler", + "guardlink/anchor": { + "scope": "symbol", + "symbol": "searchOrders", + "start_line": 16, + "end_line": 18 + }, + "guardlink/flowAttribution": "handler", + "guardlink/hypothesis": { + "state": "untested", + "evidence": null, + "by": null, + "at": null, + "expired": false, + "previous_outcome": null + } + }, + "taxa": [ + { + "id": "89", + "index": 0, + "toolComponent": { + "name": "CWE", + "index": 0 + } + }, + { + "id": "A03:2021", + "index": 0, + "toolComponent": { + "name": "OWASP", + "index": 1 + } + } + ], + "relatedLocations": [ + { + "id": 1, + "physicalLocation": { + "artifactLocation": { + "uri": "src/web.ts" + }, + "region": { + "startLine": 13 + } + }, + "message": { + "text": "@boundary from #orders to #store (#data): Service to database: the store trusts every query it is sent" + }, + "properties": { + "guardlink/verb": "boundary", + "guardlink/claimKey": "f49b04f52d63d18bed9e0da1c46661cf143037a5d732b89a0ea911d1755513ea:0", + "guardlink/edge": "boundary:2" + } + }, + { + "id": 2, + "physicalLocation": { + "artifactLocation": { + "uri": "src/web.ts" + }, + "region": { + "startLine": 22 + } + }, + "message": { + "text": "@boundary between #store and Backup (#backup): Database to off-site storage" + }, + "properties": { + "guardlink/verb": "boundary", + "guardlink/claimKey": "11ea908b631fbca15bf6faa40f21678d22a042a5bef8e89db14c3f5f21189f64:0", + "guardlink/edge": "boundary:3" + } + } + ], + "codeFlows": [ + { + "message": { + "text": "Declared @flows chain from Client to #store; the last hop is declared on the claim's handler" + }, + "threadFlows": [ + { + "locations": [ + { + "location": { + "physicalLocation": { + "artifactLocation": { + "uri": "src/web.ts" + }, + "region": { + "startLine": 10 + } + }, + "message": { + "text": "Client -> #web via POST./orders/search [crosses #edge]" + } + }, + "kinds": [ + "flow", + "boundary-crossing" + ], + "webRequest": { + "method": "POST", + "target": "/orders/search" + }, + "properties": { + "guardlink/edge": "flow:0", + "guardlink/claimKey": "26e7b8b1b775d3fca4e9962995b042ae31a86c50d12ef237a19a73153d69a066:0", + "guardlink/hopAttribution": "handler", + "guardlink/crosses": [ + "boundary:0" + ] + } + }, + { + "location": { + "physicalLocation": { + "artifactLocation": { + "uri": "src/web.ts" + }, + "region": { + "startLine": 11 + } + }, + "message": { + "text": "#web -> #orders via searchOrders [crosses #svc]" + } + }, + "kinds": [ + "flow", + "boundary-crossing" + ], + "properties": { + "guardlink/edge": "flow:1", + "guardlink/claimKey": "bd4234a68781038442527c1fdcfa360b8b5db21d5a6f83f5e06b368eee970346:0", + "guardlink/hopAttribution": "handler", + "guardlink/crosses": [ + "boundary:1" + ] + } + }, + { + "location": { + "physicalLocation": { + "artifactLocation": { + "uri": "src/web.ts" + }, + "region": { + "startLine": 12 + } + }, + "message": { + "text": "#orders -> #store via query [crosses #data]" + } + }, + "kinds": [ + "flow", + "boundary-crossing" + ], + "properties": { + "guardlink/edge": "flow:2", + "guardlink/claimKey": "6a50171e37512250443f44d24b1c73e3beff61c518bc233d84dcec89114f4c67:0", + "guardlink/hopAttribution": "handler", + "guardlink/crosses": [ + "boundary:2" + ] + } + }, + { + "location": { + "physicalLocation": { + "artifactLocation": { + "uri": "src/web.ts" + }, + "region": { + "startLine": 14 + } + }, + "message": { + "text": "@exposes #store to #sqli" + } + }, + "kinds": [ + "claim" + ], + "importance": "essential" + } + ] + } + ], + "properties": { + "guardlink/flowAttribution": "handler" + } + } + ] + }, + { + "ruleId": "guardlink/boundary-claim", + "kind": "review", + "level": "none", + "message": { + "text": "Internet to API: nothing before this point is trusted" + }, + "locations": [ + { + "physicalLocation": { + "artifactLocation": { + "uri": "src/web.ts" + }, + "region": { + "startLine": 4 + } + } + } + ], + "partialFingerprints": { + "guardlink/claimKey": "274ad18e3a2ec9f118c725b345722373938f62a181b5b8fea7992a098fbd2b91:0" + }, + "properties": { + "claimKey": "274ad18e3a2ec9f118c725b345722373938f62a181b5b8fea7992a098fbd2b91:0", + "boundary": { + "id": "#edge", + "a": "client", + "b": "web", + "asset_a": "Client", + "asset_b": "#web", + "basis": "declared", + "outer": "client", + "inner": "web" + }, + "description": "Internet to API: nothing before this point is trusted", + "crossings": [ + { + "from": "Client", + "to": "#web", + "via": "POST./orders/search", + "edge": "flow:0", + "claimKey": "26e7b8b1b775d3fca4e9962995b042ae31a86c50d12ef237a19a73153d69a066:0", + "file": "src/web.ts", + "line": 10, + "http_method": "POST", + "http_path": "/orders/search" + } + ], + "inner_routes": [ + { + "http_method": "POST", + "http_path": "/orders/search", + "edge": "flow:0", + "claimKey": "26e7b8b1b775d3fca4e9962995b042ae31a86c50d12ef237a19a73153d69a066:0", + "file": "src/web.ts", + "line": 10 + } + ], + "guardlink/edge": "boundary:0", + "guardlink/hypothesis": { + "state": "unverified", + "evidence": null, + "by": null, + "at": null, + "expired": false, + "previous_outcome": null + } + }, + "relatedLocations": [ + { + "id": 1, + "physicalLocation": { + "artifactLocation": { + "uri": "src/web.ts" + }, + "region": { + "startLine": 10 + } + }, + "message": { + "text": "POST /orders/search: Client -> #web" + }, + "properties": { + "guardlink/verb": "flows", + "guardlink/edge": "flow:0", + "claimKey": "26e7b8b1b775d3fca4e9962995b042ae31a86c50d12ef237a19a73153d69a066:0" + } + } + ], + "codeFlows": [ + { + "message": { + "text": "Declared @flows hop across #edge" + }, + "threadFlows": [ + { + "locations": [ + { + "location": { + "physicalLocation": { + "artifactLocation": { + "uri": "src/web.ts" + }, + "region": { + "startLine": 10 + } + }, + "message": { + "text": "Client -> #web via POST./orders/search" + } + }, + "kinds": [ + "flow", + "boundary-crossing" + ], + "webRequest": { + "method": "POST", + "target": "/orders/search" + }, + "properties": { + "guardlink/edge": "flow:0", + "claimKey": "26e7b8b1b775d3fca4e9962995b042ae31a86c50d12ef237a19a73153d69a066:0", + "guardlink/crosses": [ + "boundary:0" + ] + } + } + ] + } + ] + } + ] + }, + { + "ruleId": "guardlink/boundary-claim", + "kind": "review", + "level": "none", + "message": { + "text": "API to order service over the internal network" + }, + "locations": [ + { + "physicalLocation": { + "artifactLocation": { + "uri": "src/web.ts" + }, + "region": { + "startLine": 5 + } + } + } + ], + "partialFingerprints": { + "guardlink/claimKey": "18da9dcb7fcb6c32f25762a6bb20c4928a20fc2b0594f65a757a4351fbfcb12e:0" + }, + "properties": { + "claimKey": "18da9dcb7fcb6c32f25762a6bb20c4928a20fc2b0594f65a757a4351fbfcb12e:0", + "boundary": { + "id": "#svc", + "a": "web", + "b": "orders", + "asset_a": "#web", + "asset_b": "#orders", + "basis": "unknown" + }, + "description": "API to order service over the internal network", + "crossings": [ + { + "from": "#web", + "to": "#orders", + "via": "searchOrders", + "edge": "flow:1", + "claimKey": "bd4234a68781038442527c1fdcfa360b8b5db21d5a6f83f5e06b368eee970346:0", + "file": "src/web.ts", + "line": 11 + } + ], + "inner_routes": [], + "guardlink/edge": "boundary:1", + "guardlink/hypothesis": { + "state": "unverified", + "evidence": null, + "by": null, + "at": null, + "expired": false, + "previous_outcome": null + } + }, + "codeFlows": [ + { + "message": { + "text": "Declared @flows hop across #svc" + }, + "threadFlows": [ + { + "locations": [ + { + "location": { + "physicalLocation": { + "artifactLocation": { + "uri": "src/web.ts" + }, + "region": { + "startLine": 11 + } + }, + "message": { + "text": "#web -> #orders via searchOrders" + } + }, + "kinds": [ + "flow", + "boundary-crossing" + ], + "properties": { + "guardlink/edge": "flow:1", + "claimKey": "bd4234a68781038442527c1fdcfa360b8b5db21d5a6f83f5e06b368eee970346:0", + "guardlink/crosses": [ + "boundary:1" + ] + } + } + ] + } + ] + } + ] + }, + { + "ruleId": "guardlink/boundary-claim", + "kind": "review", + "level": "none", + "message": { + "text": "Service to database: the store trusts every query it is sent" + }, + "locations": [ + { + "physicalLocation": { + "artifactLocation": { + "uri": "src/web.ts" + }, + "region": { + "startLine": 13 + } + } + } + ], + "partialFingerprints": { + "guardlink/claimKey": "f49b04f52d63d18bed9e0da1c46661cf143037a5d732b89a0ea911d1755513ea:0" + }, + "properties": { + "claimKey": "f49b04f52d63d18bed9e0da1c46661cf143037a5d732b89a0ea911d1755513ea:0", + "boundary": { + "id": "#data", + "a": "orders", + "b": "store", + "asset_a": "#orders", + "asset_b": "#store", + "basis": "declared", + "outer": "orders", + "inner": "store" + }, + "description": "Service to database: the store trusts every query it is sent", + "crossings": [ + { + "from": "#orders", + "to": "#store", + "via": "query", + "edge": "flow:2", + "claimKey": "6a50171e37512250443f44d24b1c73e3beff61c518bc233d84dcec89114f4c67:0", + "file": "src/web.ts", + "line": 12 + } + ], + "inner_routes": [], + "guardlink/edge": "boundary:2", + "guardlink/hypothesis": { + "state": "unverified", + "evidence": null, + "by": null, + "at": null, + "expired": false, + "previous_outcome": null + } + }, + "codeFlows": [ + { + "message": { + "text": "Declared @flows hop across #data" + }, + "threadFlows": [ + { + "locations": [ + { + "location": { + "physicalLocation": { + "artifactLocation": { + "uri": "src/web.ts" + }, + "region": { + "startLine": 12 + } + }, + "message": { + "text": "#orders -> #store via query" + } + }, + "kinds": [ + "flow", + "boundary-crossing" + ], + "properties": { + "guardlink/edge": "flow:2", + "claimKey": "6a50171e37512250443f44d24b1c73e3beff61c518bc233d84dcec89114f4c67:0", + "guardlink/crosses": [ + "boundary:2" + ] + } + } + ] + } + ] + } + ] + }, + { + "ruleId": "guardlink/boundary-claim", + "kind": "review", + "level": "none", + "message": { + "text": "Database to off-site storage" + }, + "locations": [ + { + "physicalLocation": { + "artifactLocation": { + "uri": "src/web.ts" + }, + "region": { + "startLine": 22 + } + } + } + ], + "partialFingerprints": { + "guardlink/claimKey": "11ea908b631fbca15bf6faa40f21678d22a042a5bef8e89db14c3f5f21189f64:0" + }, + "properties": { + "claimKey": "11ea908b631fbca15bf6faa40f21678d22a042a5bef8e89db14c3f5f21189f64:0", + "boundary": { + "id": "#backup", + "a": "store", + "b": "backup", + "asset_a": "#store", + "asset_b": "Backup", + "basis": "undeclared-endpoint", + "outer": "backup", + "inner": "store" + }, + "description": "Database to off-site storage", + "crossings": [ + { + "from": "#store", + "to": "Backup", + "via": "pg_dump", + "edge": "flow:3", + "claimKey": "55dfa000e0863bb37458235e90a13f6385907112eb1931324941814e67309812:0", + "file": "src/web.ts", + "line": 21 + } + ], + "inner_routes": [], + "guardlink/edge": "boundary:3", + "guardlink/hypothesis": { + "state": "unverified", + "evidence": null, + "by": null, + "at": null, + "expired": false, + "previous_outcome": null + } + }, + "codeFlows": [ + { + "message": { + "text": "Declared @flows hop across #backup" + }, + "threadFlows": [ + { + "locations": [ + { + "location": { + "physicalLocation": { + "artifactLocation": { + "uri": "src/web.ts" + }, + "region": { + "startLine": 21 + } + }, + "message": { + "text": "#store -> Backup via pg_dump" + } + }, + "kinds": [ + "flow", + "boundary-crossing" + ], + "properties": { + "guardlink/edge": "flow:3", + "claimKey": "55dfa000e0863bb37458235e90a13f6385907112eb1931324941814e67309812:0", + "guardlink/crosses": [ + "boundary:3" + ] + } + } + ] + } + ] + } + ] + } + ], + "properties": { + "annotation_hash": "sha256-v3:ed1647c4925e2d2d4f4462a20d16024357f955cbd44174410b838812e59b01fd", + "annotation_hash_version": 3, + "generator": "guardlink@2.1.0", + "acceptance_register": "code-annotations", + "sarif_profile_version": 1, + "sarif_profile": "pentest", + "hypothesis_ledger": "absent" + }, + "automationDetails": { + "id": "guardlink/threat-model/" + }, + "taxonomies": [ + { + "name": "CWE", + "organization": "MITRE", + "informationUri": "https://cwe.mitre.org/", + "isComprehensive": false, + "taxa": [ + { + "id": "89" + } + ] + }, + { + "name": "OWASP", + "organization": "OWASP Foundation", + "informationUri": "https://owasp.org/Top10/", + "isComprehensive": false, + "taxa": [ + { + "id": "A03:2021" + } + ] + } + ], + "graphs": [ + { + "description": { + "text": "GuardLink declared threat model: @flows edges (directed) and @boundary edges (undirected, or outer to inner where guardlink/directed is true). Edge ids are local to this export; guardlink/claimKey is the stable identity." + }, + "nodes": [ + { + "id": "web", + "label": { + "text": "#web" + }, + "location": { + "physicalLocation": { + "artifactLocation": { + "uri": ".guardlink/definitions.ts" + }, + "region": { + "startLine": 3 + } + } + }, + "properties": { + "guardlink/declared": true, + "guardlink/path": "Shop.Web" + } + }, + { + "id": "orders", + "label": { + "text": "#orders" + }, + "location": { + "physicalLocation": { + "artifactLocation": { + "uri": ".guardlink/definitions.ts" + }, + "region": { + "startLine": 4 + } + } + }, + "properties": { + "guardlink/declared": true, + "guardlink/path": "Shop.Orders" + } + }, + { + "id": "store", + "label": { + "text": "#store" + }, + "location": { + "physicalLocation": { + "artifactLocation": { + "uri": ".guardlink/definitions.ts" + }, + "region": { + "startLine": 5 + } + } + }, + "properties": { + "guardlink/declared": true, + "guardlink/path": "Shop.Store" + } + }, + { + "id": "client", + "label": { + "text": "Client" + }, + "properties": { + "guardlink/declared": false + } + }, + { + "id": "backup", + "label": { + "text": "Backup" + }, + "properties": { + "guardlink/declared": false + } + } + ], + "edges": [ + { + "id": "boundary:0", + "sourceNodeId": "client", + "targetNodeId": "web", + "label": { + "text": "Internet to API: nothing before this point is trusted" + }, + "properties": { + "guardlink/kind": "boundary", + "guardlink/directed": true, + "guardlink/boundaryId": "edge", + "guardlink/claimKey": "274ad18e3a2ec9f118c725b345722373938f62a181b5b8fea7992a098fbd2b91:0", + "guardlink/location": { + "physicalLocation": { + "artifactLocation": { + "uri": "src/web.ts" + }, + "region": { + "startLine": 4 + } + } + }, + "guardlink/side": { + "basis": "declared", + "outer": "client", + "inner": "web" + }, + "guardlink/crossings": [ + "flow:0" + ] + } + }, + { + "id": "boundary:1", + "sourceNodeId": "web", + "targetNodeId": "orders", + "label": { + "text": "API to order service over the internal network" + }, + "properties": { + "guardlink/kind": "boundary", + "guardlink/directed": false, + "guardlink/boundaryId": "svc", + "guardlink/claimKey": "18da9dcb7fcb6c32f25762a6bb20c4928a20fc2b0594f65a757a4351fbfcb12e:0", + "guardlink/location": { + "physicalLocation": { + "artifactLocation": { + "uri": "src/web.ts" + }, + "region": { + "startLine": 5 + } + } + }, + "guardlink/side": { + "basis": "unknown" + }, + "guardlink/crossings": [ + "flow:1" + ] + } + }, + { + "id": "boundary:2", + "sourceNodeId": "orders", + "targetNodeId": "store", + "label": { + "text": "Service to database: the store trusts every query it is sent" + }, + "properties": { + "guardlink/kind": "boundary", + "guardlink/directed": true, + "guardlink/boundaryId": "data", + "guardlink/claimKey": "f49b04f52d63d18bed9e0da1c46661cf143037a5d732b89a0ea911d1755513ea:0", + "guardlink/location": { + "physicalLocation": { + "artifactLocation": { + "uri": "src/web.ts" + }, + "region": { + "startLine": 13 + } + } + }, + "guardlink/side": { + "basis": "declared", + "outer": "orders", + "inner": "store" + }, + "guardlink/crossings": [ + "flow:2" + ] + } + }, + { + "id": "boundary:3", + "sourceNodeId": "store", + "targetNodeId": "backup", + "label": { + "text": "Database to off-site storage" + }, + "properties": { + "guardlink/kind": "boundary", + "guardlink/directed": false, + "guardlink/boundaryId": "backup", + "guardlink/claimKey": "11ea908b631fbca15bf6faa40f21678d22a042a5bef8e89db14c3f5f21189f64:0", + "guardlink/location": { + "physicalLocation": { + "artifactLocation": { + "uri": "src/web.ts" + }, + "region": { + "startLine": 22 + } + } + }, + "guardlink/side": { + "basis": "undeclared-endpoint", + "outer": "backup", + "inner": "store" + }, + "guardlink/crossings": [ + "flow:3" + ] + } + }, + { + "id": "flow:0", + "sourceNodeId": "client", + "targetNodeId": "web", + "label": { + "text": "POST./orders/search" + }, + "properties": { + "guardlink/kind": "flow", + "guardlink/directed": true, + "guardlink/claimKey": "26e7b8b1b775d3fca4e9962995b042ae31a86c50d12ef237a19a73153d69a066:0", + "guardlink/location": { + "physicalLocation": { + "artifactLocation": { + "uri": "src/web.ts" + }, + "region": { + "startLine": 10 + } + } + }, + "guardlink/route": { + "http_method": "POST", + "http_path": "/orders/search" + }, + "guardlink/crosses": [ + "boundary:0" + ] + } + }, + { + "id": "flow:1", + "sourceNodeId": "web", + "targetNodeId": "orders", + "label": { + "text": "searchOrders" + }, + "properties": { + "guardlink/kind": "flow", + "guardlink/directed": true, + "guardlink/claimKey": "bd4234a68781038442527c1fdcfa360b8b5db21d5a6f83f5e06b368eee970346:0", + "guardlink/location": { + "physicalLocation": { + "artifactLocation": { + "uri": "src/web.ts" + }, + "region": { + "startLine": 11 + } + } + }, + "guardlink/crosses": [ + "boundary:1" + ] + } + }, + { + "id": "flow:2", + "sourceNodeId": "orders", + "targetNodeId": "store", + "label": { + "text": "query" + }, + "properties": { + "guardlink/kind": "flow", + "guardlink/directed": true, + "guardlink/claimKey": "6a50171e37512250443f44d24b1c73e3beff61c518bc233d84dcec89114f4c67:0", + "guardlink/location": { + "physicalLocation": { + "artifactLocation": { + "uri": "src/web.ts" + }, + "region": { + "startLine": 12 + } + } + }, + "guardlink/crosses": [ + "boundary:2" + ] + } + }, + { + "id": "flow:3", + "sourceNodeId": "store", + "targetNodeId": "backup", + "label": { + "text": "pg_dump" + }, + "properties": { + "guardlink/kind": "flow", + "guardlink/directed": true, + "guardlink/claimKey": "55dfa000e0863bb37458235e90a13f6385907112eb1931324941814e67309812:0", + "guardlink/location": { + "physicalLocation": { + "artifactLocation": { + "uri": "src/web.ts" + }, + "region": { + "startLine": 21 + } + } + }, + "guardlink/crosses": [ + "boundary:3" + ] + } + } + ] + } + ] + } + ] +} diff --git a/tests/fixtures/sarif-pentest/expense-api.sarif b/tests/fixtures/sarif-pentest/expense-api.sarif index 8d27f344..44a777bf 100644 --- a/tests/fixtures/sarif-pentest/expense-api.sarif +++ b/tests/fixtures/sarif-pentest/expense-api.sarif @@ -168,7 +168,7 @@ }, "help": { "text": "A @boundary annotation says trust changes between two sides. Probe the inner side from the outer side and record the outcome with guardlink hypothesis support or contradict.", - "markdown": "A `@boundary` annotation says trust changes between two sides. This result is a claim to verify, never a finding.\n\n`properties.boundary` names the sides, and which one is outer when the model says so; `crossings` and `codeFlows` hold the declared `@flows` across it; `inner_routes` the declared routes into its inner side. Record the outcome with `guardlink hypothesis support` or `contradict`." + "markdown": "A `@boundary` annotation says trust changes between two sides. This result is a claim to verify, never a finding.\n\n`properties.boundary` names the sides and, when they are known, which one is outer: `basis` is `declared` when the annotation is written `from to `, `undeclared-endpoint` when it is inferred, `unknown` otherwise; `crossings` and `codeFlows` hold the declared `@flows` across it; `inner_routes` the declared routes into its inner side. Record the outcome with `guardlink hypothesis support` or `contradict`." }, "properties": { "tags": [ diff --git a/tests/fixtures/sarif-pentest/sarif-shop.sarif b/tests/fixtures/sarif-pentest/sarif-shop.sarif index 99edf44b..0b023b63 100644 --- a/tests/fixtures/sarif-pentest/sarif-shop.sarif +++ b/tests/fixtures/sarif-pentest/sarif-shop.sarif @@ -168,7 +168,7 @@ }, "help": { "text": "A @boundary annotation says trust changes between two sides. Probe the inner side from the outer side and record the outcome with guardlink hypothesis support or contradict.", - "markdown": "A `@boundary` annotation says trust changes between two sides. This result is a claim to verify, never a finding.\n\n`properties.boundary` names the sides, and which one is outer when the model says so; `crossings` and `codeFlows` hold the declared `@flows` across it; `inner_routes` the declared routes into its inner side. Record the outcome with `guardlink hypothesis support` or `contradict`." + "markdown": "A `@boundary` annotation says trust changes between two sides. This result is a claim to verify, never a finding.\n\n`properties.boundary` names the sides and, when they are known, which one is outer: `basis` is `declared` when the annotation is written `from to `, `undeclared-endpoint` when it is inferred, `unknown` otherwise; `crossings` and `codeFlows` hold the declared `@flows` across it; `inner_routes` the declared routes into its inner side. Record the outcome with `guardlink hypothesis support` or `contradict`." }, "properties": { "tags": [ diff --git a/tests/sarif-pentest.test.ts b/tests/sarif-pentest.test.ts index 867beeef..3f40d53f 100644 --- a/tests/sarif-pentest.test.ts +++ b/tests/sarif-pentest.test.ts @@ -38,6 +38,8 @@ type Run = Sarif['runs'][number]; type Result = Run['results'][number]; const FIXTURES = ['expense-api', 'sarif-shop'] as const; +/** Pentest exports pinned for consumers: the two above, plus one written after the github baselines were cut. */ +const CONSUMER_FIXTURES = [...FIXTURES, 'boundary-direction'] as const; async function load(root: string): Promise<{ model: ThreatModel; diagnostics: Parameters[1] }> { const { model, diagnostics } = await parseProject({ root, project: 'unused' }); @@ -307,6 +309,74 @@ describe('guardlink/boundary-claim', () => { }); }); +// ─── A directed @boundary ─────────────────────────────────────────── + +describe('a directed @boundary from to ', () => { + const claimsOf = async (name: string) => { + const run = (await exportOf(fixture(name), { profile: 'pentest' })).runs[0]; + return { run, claims: byRule(run, 'guardlink/boundary-claim') }; + }; + + it('states its sides as declared, alongside the inferred and unknown sides of undirected ones', async () => { + const { run, claims } = await claimsOf('boundary-direction'); + expectSchemaValid({ ...(await exportOf(fixture('boundary-direction'), { profile: 'pentest' })), runs: [run] }); + expect(claims.map(c => [props(c).boundary.id, props(c).boundary.basis, props(c).boundary.outer ?? null, props(c).boundary.inner ?? null])).toEqual([ + ['#edge', 'declared', 'client', 'web'], + ['#svc', 'unknown', null, null], + ['#data', 'declared', 'orders', 'store'], + ['#backup', 'undeclared-endpoint', 'backup', 'store'], + ]); + }); + + it('gives a boundary between two declared assets the side inference cannot', async () => { + // The same pair written undirected in sarif-shop is `unknown`; directed, it is declared. + const shop = (await claimsOf('sarif-shop')).claims.find(c => props(c).boundary.id === '#data')!; + const directed = (await claimsOf('boundary-direction')).claims.find(c => props(c).boundary.id === '#data')!; + expect(props(shop).boundary.basis).toBe('unknown'); + expect(props(directed).boundary).toEqual({ + id: '#data', a: 'orders', b: 'store', asset_a: '#orders', asset_b: '#store', + basis: 'declared', outer: 'orders', inner: 'store', + }); + }); + + it('is a directed graph edge from outer to inner carrying the same side; undirected edges are unchanged', async () => { + const { run, claims } = await claimsOf('boundary-direction'); + for (const c of claims) { + const edge = run.graphs![0].edges.find(e => e.id === props(c)['guardlink/edge'])!; + const b = props(c).boundary; + expect(edge.properties['guardlink/directed']).toBe(b.basis === 'declared'); + expect(edge.properties['guardlink/side']).toEqual(b.basis === 'unknown' ? { basis: 'unknown' } : { basis: b.basis, outer: b.outer, inner: b.inner }); + if (b.basis === 'declared') expect([edge.sourceNodeId, edge.targetNodeId]).toEqual([b.outer, b.inner]); + } + expect(run.graphs![0].description.text).toContain('outer to inner where guardlink/directed is true'); + }); + + it('lists the routes into its declared inner side', async () => { + const { claims } = await claimsOf('boundary-direction'); + const edge = claims.find(c => props(c).boundary.id === '#edge')!; + expect(props(edge).inner_routes.map((r: any) => `${r.http_method} ${r.http_path}`)).toEqual(['POST /orders/search']); + }); + + it('is believed over the undeclared-endpoint inference when the two disagree', () => { + const sarif = generateSarif({ + assets: [{ path: ['App', 'API'], id: 'api', location: { file: 'd.ts', line: 1 } }], + exposures: [], mitigations: [], acceptances: [], confirmed: [], flows: [], + boundaries: [{ asset_a: '#api', asset_b: 'Client', directed: true, location: { file: 'a.ts', line: 1 } }], + } as unknown as ThreatModel, [], [], { profile: 'pentest' }); + const [claim] = sarif.runs[0].results; + expect(claim.message.text).toBe('@boundary from #api to Client'); + expect(props(claim).boundary).toMatchObject({ basis: 'declared', outer: 'api', inner: 'client' }); + expectSchemaValid(sarif); + }); + + it('leaves a model with no directed boundary exporting exactly what it did', async () => { + // The github baselines above pin this byte for byte; this pins the reason — + // nothing reads `directed` unless it is true. + const { model } = await load(fixture('sarif-shop')); + expect(model.boundaries.every(b => !('directed' in b))).toBe(true); + }); +}); + // ─── guardlink/hypothesis ─────────────────────────────────────────── describe('the hypothesis ledger\'s state on each claim', () => { @@ -401,7 +471,7 @@ describe('baselineState against --baseline', () => { // ─── Consumer fixtures ────────────────────────────────────────────── describe('tests/fixtures/sarif-pentest: the exports a consumer tests its reader against', () => { - for (const name of FIXTURES) { + for (const name of CONSUMER_FIXTURES) { it(`${name}.sarif is today's pentest export of tests/fixtures/${name}`, async () => { const sarif = serialise(await exportOf(fixture(name), { profile: 'pentest' })); if (process.env.GUARDLINK_REGENERATE_SARIF_FIXTURES === '1') writeFileSync(pentestFixture(name), sarif); @@ -410,21 +480,20 @@ describe('tests/fixtures/sarif-pentest: the exports a consumer tests its reader } it('holds every shape a reader branches on', () => { - const results = FIXTURES.flatMap(name => (JSON.parse(readFileSync(pentestFixture(name), 'utf-8')) as Sarif).runs[0].results); + const results = CONSUMER_FIXTURES.flatMap(name => (JSON.parse(readFileSync(pentestFixture(name), 'utf-8')) as Sarif).runs[0].results); const rules = new Set(results.map(r => r.ruleId)); for (const rule of ['guardlink/unmitigated-exposure', 'guardlink/unmitigated-critical', 'guardlink/confirmed-exploitable', 'guardlink/parse-error', 'guardlink/dangling-ref', 'guardlink/mitigated-exposure', 'guardlink/boundary-claim']) { expect(rules).toContain(rule); } const claims = results.filter(r => r.ruleId === 'guardlink/boundary-claim'); - expect(claims.some(c => props(c).boundary.basis === 'unknown')).toBe(true); - expect(claims.some(c => props(c).boundary.outer)).toBe(true); + expect(new Set(claims.map(c => props(c).boundary.basis))).toEqual(new Set(['declared', 'undeclared-endpoint', 'unknown'])); const states = new Set(results.map(r => props(r)?.['guardlink/hypothesis']?.state).filter(Boolean)); for (const s of ['untested', 'confirmed', 'refuted', 'unverified', 'supported']) expect(states).toContain(s); }); it('every boundary claim states its sides as a reader expects: both or neither, and among a and b', () => { - for (const name of FIXTURES) { + for (const name of CONSUMER_FIXTURES) { const run = (JSON.parse(readFileSync(pentestFixture(name), 'utf-8')) as Sarif).runs[0]; for (const c of byRule(run, 'guardlink/boundary-claim')) { const b = props(c).boundary; @@ -436,6 +505,8 @@ describe('tests/fixtures/sarif-pentest: the exports a consumer tests its reader } else { expect(new Set([b.outer, b.inner])).toEqual(new Set([b.a, b.b])); } + // A declared side is the annotation's own order: outer is the side written first. + if (b.basis === 'declared') expect([b.outer, b.inner]).toEqual([b.a, b.b]); } } }); From 8d16bd4912d20debf0bd2b9dc1f413a425ecbf41 Mon Sep 17 00:00:00 2001 From: Animesh Srivastava Date: Fri, 2 Oct 2026 02:02:13 +0530 Subject: [PATCH 2/2] chore(artifacts): regenerate agent instructions and threat-model artifacts --- .clinerules | 8 +- .cursor/rules/guardlink.mdc | 8 +- .gemini/GEMINI.md | 8 +- .github/copilot-instructions.md | 8 +- .guardlink/README.md | 6 +- .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 | 2 +- .../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 | 2 +- .guardlink/graph/threat-graph.mmd | 2 +- .guardlink/model.json | 173 +++++++++--------- .windsurfrules | 8 +- AGENTS.md | 8 +- CLAUDE.md | 8 +- 64 files changed, 231 insertions(+), 224 deletions(-) diff --git a/.clinerules b/.clinerules index 7aed4de1..984451cd 100644 --- a/.clinerules +++ b/.clinerules @@ -31,7 +31,7 @@ Every time you write or modify code that touches security-relevant behavior, you - 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.) - 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, and @handles when the asset touches classified data. +- 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. ## Workflow @@ -45,7 +45,7 @@ Every time you write or modify code that touches security-relevant behavior, you - @mitigates App.API against #sqli using #prepared-stmts -- "Parameterized queries via pg" - @audit App.API -- "Timing attack risk — needs human review" - @flows User -> App.API via HTTPS -- "Login request" -- @boundary between #api and #db (#data-boundary) -- "Trust change" +- @boundary from #api to #db (#data-boundary) -- "Trust change; outer side first" - @handles pii on App.API -- "Processes email, token" - @validates #prepared-stmts for App.API -- "CI test ensures placeholders" - @assumes App.API -- "Caller already authenticated; this never re-checks the session" @@ -120,11 +120,11 @@ _Full records with descriptions and locations: `guardlink_lookup("asset ")`, ### Model Stats -873 annotations, 20 assets, 16 threats, 14 controls, 127 exposures, 0 confirmed, 129 mitigations, 3 actors, 1 entitlements, 202 flows, 2 features +874 annotations, 20 assets, 16 threats, 14 controls, 127 exposures, 0 confirmed, 129 mitigations, 3 actors, 1 entitlements, 202 flows, 2 features ### Block Freshness -- `annotation_hash`: `sha256-v3:4a7d0ba07ea80e6802ab0660b05f4541d06265ffe1cc3d1d817bebb549433253` +- `annotation_hash`: `sha256-v3:95e447ec72ce4924c563d83aae0d3e594f518789e71d00ded358ac3255cf772a` 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 1bcc4acb..37de0b8e 100644 --- a/.cursor/rules/guardlink.mdc +++ b/.cursor/rules/guardlink.mdc @@ -36,7 +36,7 @@ Every time you write or modify code that touches security-relevant behavior, you - 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.) - 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, and @handles when the asset touches classified data. +- 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. ## Workflow @@ -50,7 +50,7 @@ Every time you write or modify code that touches security-relevant behavior, you - @mitigates App.API against #sqli using #prepared-stmts -- "Parameterized queries via pg" - @audit App.API -- "Timing attack risk — needs human review" - @flows User -> App.API via HTTPS -- "Login request" -- @boundary between #api and #db (#data-boundary) -- "Trust change" +- @boundary from #api to #db (#data-boundary) -- "Trust change; outer side first" - @handles pii on App.API -- "Processes email, token" - @validates #prepared-stmts for App.API -- "CI test ensures placeholders" - @assumes App.API -- "Caller already authenticated; this never re-checks the session" @@ -125,11 +125,11 @@ _Full records with descriptions and locations: `guardlink_lookup("asset ")`, ### Model Stats -873 annotations, 20 assets, 16 threats, 14 controls, 127 exposures, 0 confirmed, 129 mitigations, 3 actors, 1 entitlements, 202 flows, 2 features +874 annotations, 20 assets, 16 threats, 14 controls, 127 exposures, 0 confirmed, 129 mitigations, 3 actors, 1 entitlements, 202 flows, 2 features ### Block Freshness -- `annotation_hash`: `sha256-v3:4a7d0ba07ea80e6802ab0660b05f4541d06265ffe1cc3d1d817bebb549433253` +- `annotation_hash`: `sha256-v3:95e447ec72ce4924c563d83aae0d3e594f518789e71d00ded358ac3255cf772a` 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 dc6fb52c..6b2a99d1 100644 --- a/.gemini/GEMINI.md +++ b/.gemini/GEMINI.md @@ -57,7 +57,7 @@ the same change.** This includes: new endpoints, authentication/authorization lo - **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. - **You know the team accountable for an asset** → `@owns team-id for Asset`. The owner is a bare token: `platform-security` parses, `"Platform Security"` and `#platform-security` do not. If you do not know the team, skip it rather than guessing. -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, and `@handles` when the asset touches classified data. Never write a lone `@exposes` without follow-up. +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. @@ -88,7 +88,7 @@ the same change.** This includes: new endpoints, authentication/authorization lo @mitigates App.API against #sqli using #prepared-stmts -- "Parameterized queries via pg" @audit App.API -- "Timing attack risk — needs human review to assess bcrypt constant-time comparison" @flows User -> App.API via HTTPS -- "Login request path" -@boundary between #api and #db (#data-boundary) -- "App → DB trust change" +@boundary from #api to #db (#data-boundary) -- "App → DB trust change; #api is the less-trusted side" @handles pii on App.API -- "Processes email and session token" @validates #prepared-stmts for App.API -- "sqlInjectionTest.ts ensures placeholders used" @assumes App.API -- "Caller has already authenticated; this function never re-checks the session" @@ -174,11 +174,11 @@ _Full records with descriptions and locations: `guardlink_lookup("asset ")`, ### Model Stats -873 annotations, 20 assets, 16 threats, 14 controls, 127 exposures, 0 confirmed, 129 mitigations, 3 actors, 1 entitlements, 202 flows, 2 features +874 annotations, 20 assets, 16 threats, 14 controls, 127 exposures, 0 confirmed, 129 mitigations, 3 actors, 1 entitlements, 202 flows, 2 features ### Block Freshness -- `annotation_hash`: `sha256-v3:4a7d0ba07ea80e6802ab0660b05f4541d06265ffe1cc3d1d817bebb549433253` +- `annotation_hash`: `sha256-v3:95e447ec72ce4924c563d83aae0d3e594f518789e71d00ded358ac3255cf772a` 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 dc6fb52c..6b2a99d1 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -57,7 +57,7 @@ the same change.** This includes: new endpoints, authentication/authorization lo - **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. - **You know the team accountable for an asset** → `@owns team-id for Asset`. The owner is a bare token: `platform-security` parses, `"Platform Security"` and `#platform-security` do not. If you do not know the team, skip it rather than guessing. -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, and `@handles` when the asset touches classified data. Never write a lone `@exposes` without follow-up. +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. @@ -88,7 +88,7 @@ the same change.** This includes: new endpoints, authentication/authorization lo @mitigates App.API against #sqli using #prepared-stmts -- "Parameterized queries via pg" @audit App.API -- "Timing attack risk — needs human review to assess bcrypt constant-time comparison" @flows User -> App.API via HTTPS -- "Login request path" -@boundary between #api and #db (#data-boundary) -- "App → DB trust change" +@boundary from #api to #db (#data-boundary) -- "App → DB trust change; #api is the less-trusted side" @handles pii on App.API -- "Processes email and session token" @validates #prepared-stmts for App.API -- "sqlInjectionTest.ts ensures placeholders used" @assumes App.API -- "Caller has already authenticated; this function never re-checks the session" @@ -174,11 +174,11 @@ _Full records with descriptions and locations: `guardlink_lookup("asset ")`, ### Model Stats -873 annotations, 20 assets, 16 threats, 14 controls, 127 exposures, 0 confirmed, 129 mitigations, 3 actors, 1 entitlements, 202 flows, 2 features +874 annotations, 20 assets, 16 threats, 14 controls, 127 exposures, 0 confirmed, 129 mitigations, 3 actors, 1 entitlements, 202 flows, 2 features ### Block Freshness -- `annotation_hash`: `sha256-v3:4a7d0ba07ea80e6802ab0660b05f4541d06265ffe1cc3d1d817bebb549433253` +- `annotation_hash`: `sha256-v3:95e447ec72ce4924c563d83aae0d3e594f518789e71d00ded358ac3255cf772a` 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 92181aca..cda8529d 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: 873 annotations · 20 assets · 16 threats · 14 controls · 127 exposures · 202 flows -Content hash: `sha256-v3:4a7d0ba07ea80e6802ab0660b05f4541d06265ffe1cc3d1d817bebb549433253` — identical hash means identical model. +Current model: 874 annotations · 20 assets · 16 threats · 14 controls · 127 exposures · 202 flows +Content hash: `sha256-v3:95e447ec72ce4924c563d83aae0d3e594f518789e71d00ded358ac3255cf772a` — identical hash means identical model. --- @@ -115,7 +115,7 @@ Assets are referenced as `#id` or as a `Dotted.Path`; both resolve to the same n | `@mitigates` | `@mitigates against using -- "how"` | | `@confirmed` | `@confirmed on [severity] cwe:CWE-89 -- "evidence"` | | `@flows` | `@flows -> via -- "what moves"` — chains allowed: `A -> B -> C` | -| `@boundary` | `@boundary between and (#id) -- "what changes across it"` | +| `@boundary` | `@boundary from to (#id) -- "what changes across it"` when you know which side is less trusted; `@boundary between and ` when you do not | | `@transfers` | `@transfers from to -- "who owns it now"` | | `@validates` | `@validates for -- "the test that proves it"` | | `@audit` | `@audit -- "what a human needs to look at"` | diff --git a/.guardlink/graph/MANIFEST.json b/.guardlink/graph/MANIFEST.json index f99399c0..3aa3d74f 100644 --- a/.guardlink/graph/MANIFEST.json +++ b/.guardlink/graph/MANIFEST.json @@ -1,12 +1,12 @@ { "schema_version": 2, - "annotation_hash": "sha256-v3:4a7d0ba07ea80e6802ab0660b05f4541d06265ffe1cc3d1d817bebb549433253", + "annotation_hash": "sha256-v3:95e447ec72ce4924c563d83aae0d3e594f518789e71d00ded358ac3255cf772a", "generator": "guardlink@2.1.0", "artifacts": [ { "path": ".guardlink/graph/threat-graph.mmd", "bytes": 12296, - "annotation_hash": "sha256-v3:4a7d0ba07ea80e6802ab0660b05f4541d06265ffe1cc3d1d817bebb549433253", + "annotation_hash": "sha256-v3:95e447ec72ce4924c563d83aae0d3e594f518789e71d00ded358ac3255cf772a", "renderable": true, "render": { "text_size": 10687, @@ -16,7 +16,7 @@ { "path": ".guardlink/graph/dataflow.mmd", "bytes": 15435, - "annotation_hash": "sha256-v3:4a7d0ba07ea80e6802ab0660b05f4541d06265ffe1cc3d1d817bebb549433253", + "annotation_hash": "sha256-v3:95e447ec72ce4924c563d83aae0d3e594f518789e71d00ded358ac3255cf772a", "renderable": true, "render": { "text_size": 13347, @@ -26,7 +26,7 @@ { "path": ".guardlink/graph/attack-surface.mmd", "bytes": 6630, - "annotation_hash": "sha256-v3:4a7d0ba07ea80e6802ab0660b05f4541d06265ffe1cc3d1d817bebb549433253", + "annotation_hash": "sha256-v3:95e447ec72ce4924c563d83aae0d3e594f518789e71d00ded358ac3255cf772a", "renderable": true, "render": { "text_size": 4957, @@ -36,7 +36,7 @@ { "path": ".guardlink/graph/by-feature/dashboard.mmd", "bytes": 2301, - "annotation_hash": "sha256-v3:4a7d0ba07ea80e6802ab0660b05f4541d06265ffe1cc3d1d817bebb549433253", + "annotation_hash": "sha256-v3:95e447ec72ce4924c563d83aae0d3e594f518789e71d00ded358ac3255cf772a", "renderable": true, "render": { "text_size": 813, @@ -46,7 +46,7 @@ { "path": ".guardlink/graph/by-feature/mcp-integration.mmd", "bytes": 2749, - "annotation_hash": "sha256-v3:4a7d0ba07ea80e6802ab0660b05f4541d06265ffe1cc3d1d817bebb549433253", + "annotation_hash": "sha256-v3:95e447ec72ce4924c563d83aae0d3e594f518789e71d00ded358ac3255cf772a", "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:4a7d0ba07ea80e6802ab0660b05f4541d06265ffe1cc3d1d817bebb549433253", + "annotation_hash": "sha256-v3:95e447ec72ce4924c563d83aae0d3e594f518789e71d00ded358ac3255cf772a", "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:4a7d0ba07ea80e6802ab0660b05f4541d06265ffe1cc3d1d817bebb549433253", + "annotation_hash": "sha256-v3:95e447ec72ce4924c563d83aae0d3e594f518789e71d00ded358ac3255cf772a", "renderable": true, "render": { "text_size": 1404, @@ -76,7 +76,7 @@ { "path": ".guardlink/graph/by-asset/blame.threats.mmd", "bytes": 2211, - "annotation_hash": "sha256-v3:4a7d0ba07ea80e6802ab0660b05f4541d06265ffe1cc3d1d817bebb549433253", + "annotation_hash": "sha256-v3:95e447ec72ce4924c563d83aae0d3e594f518789e71d00ded358ac3255cf772a", "renderable": true, "render": { "text_size": 1477, @@ -86,7 +86,7 @@ { "path": ".guardlink/graph/by-asset/blame.flows.mmd", "bytes": 1621, - "annotation_hash": "sha256-v3:4a7d0ba07ea80e6802ab0660b05f4541d06265ffe1cc3d1d817bebb549433253", + "annotation_hash": "sha256-v3:95e447ec72ce4924c563d83aae0d3e594f518789e71d00ded358ac3255cf772a", "renderable": true, "render": { "text_size": 803, @@ -96,7 +96,7 @@ { "path": ".guardlink/graph/by-asset/cli.threats.mmd", "bytes": 2185, - "annotation_hash": "sha256-v3:4a7d0ba07ea80e6802ab0660b05f4541d06265ffe1cc3d1d817bebb549433253", + "annotation_hash": "sha256-v3:95e447ec72ce4924c563d83aae0d3e594f518789e71d00ded358ac3255cf772a", "renderable": true, "render": { "text_size": 1373, @@ -106,7 +106,7 @@ { "path": ".guardlink/graph/by-asset/cli.flows.mmd", "bytes": 1930, - "annotation_hash": "sha256-v3:4a7d0ba07ea80e6802ab0660b05f4541d06265ffe1cc3d1d817bebb549433253", + "annotation_hash": "sha256-v3:95e447ec72ce4924c563d83aae0d3e594f518789e71d00ded358ac3255cf772a", "renderable": true, "render": { "text_size": 1098, @@ -116,7 +116,7 @@ { "path": ".guardlink/graph/by-asset/codegraph.threats.mmd", "bytes": 1853, - "annotation_hash": "sha256-v3:4a7d0ba07ea80e6802ab0660b05f4541d06265ffe1cc3d1d817bebb549433253", + "annotation_hash": "sha256-v3:95e447ec72ce4924c563d83aae0d3e594f518789e71d00ded358ac3255cf772a", "renderable": true, "render": { "text_size": 1125, @@ -126,7 +126,7 @@ { "path": ".guardlink/graph/by-asset/codegraph.flows.mmd", "bytes": 2363, - "annotation_hash": "sha256-v3:4a7d0ba07ea80e6802ab0660b05f4541d06265ffe1cc3d1d817bebb549433253", + "annotation_hash": "sha256-v3:95e447ec72ce4924c563d83aae0d3e594f518789e71d00ded358ac3255cf772a", "renderable": true, "render": { "text_size": 1517, @@ -136,7 +136,7 @@ { "path": ".guardlink/graph/by-asset/dashboard.threats.mmd", "bytes": 2326, - "annotation_hash": "sha256-v3:4a7d0ba07ea80e6802ab0660b05f4541d06265ffe1cc3d1d817bebb549433253", + "annotation_hash": "sha256-v3:95e447ec72ce4924c563d83aae0d3e594f518789e71d00ded358ac3255cf772a", "renderable": true, "render": { "text_size": 1586, @@ -146,7 +146,7 @@ { "path": ".guardlink/graph/by-asset/dashboard.flows.mmd", "bytes": 2011, - "annotation_hash": "sha256-v3:4a7d0ba07ea80e6802ab0660b05f4541d06265ffe1cc3d1d817bebb549433253", + "annotation_hash": "sha256-v3:95e447ec72ce4924c563d83aae0d3e594f518789e71d00ded358ac3255cf772a", "renderable": true, "render": { "text_size": 1167, @@ -156,7 +156,7 @@ { "path": ".guardlink/graph/by-asset/diff.threats.mmd", "bytes": 1764, - "annotation_hash": "sha256-v3:4a7d0ba07ea80e6802ab0660b05f4541d06265ffe1cc3d1d817bebb549433253", + "annotation_hash": "sha256-v3:95e447ec72ce4924c563d83aae0d3e594f518789e71d00ded358ac3255cf772a", "renderable": true, "render": { "text_size": 1044, @@ -166,7 +166,7 @@ { "path": ".guardlink/graph/by-asset/diff.flows.mmd", "bytes": 1559, - "annotation_hash": "sha256-v3:4a7d0ba07ea80e6802ab0660b05f4541d06265ffe1cc3d1d817bebb549433253", + "annotation_hash": "sha256-v3:95e447ec72ce4924c563d83aae0d3e594f518789e71d00ded358ac3255cf772a", "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:4a7d0ba07ea80e6802ab0660b05f4541d06265ffe1cc3d1d817bebb549433253", + "annotation_hash": "sha256-v3:95e447ec72ce4924c563d83aae0d3e594f518789e71d00ded358ac3255cf772a", "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:4a7d0ba07ea80e6802ab0660b05f4541d06265ffe1cc3d1d817bebb549433253", + "annotation_hash": "sha256-v3:95e447ec72ce4924c563d83aae0d3e594f518789e71d00ded358ac3255cf772a", "renderable": true, "render": { "text_size": 1073, @@ -196,7 +196,7 @@ { "path": ".guardlink/graph/by-asset/gate.threats.mmd", "bytes": 1274, - "annotation_hash": "sha256-v3:4a7d0ba07ea80e6802ab0660b05f4541d06265ffe1cc3d1d817bebb549433253", + "annotation_hash": "sha256-v3:95e447ec72ce4924c563d83aae0d3e594f518789e71d00ded358ac3255cf772a", "renderable": true, "render": { "text_size": 566, @@ -206,7 +206,7 @@ { "path": ".guardlink/graph/by-asset/gate.flows.mmd", "bytes": 1831, - "annotation_hash": "sha256-v3:4a7d0ba07ea80e6802ab0660b05f4541d06265ffe1cc3d1d817bebb549433253", + "annotation_hash": "sha256-v3:95e447ec72ce4924c563d83aae0d3e594f518789e71d00ded358ac3255cf772a", "renderable": true, "render": { "text_size": 1013, @@ -216,7 +216,7 @@ { "path": ".guardlink/graph/by-asset/init.threats.mmd", "bytes": 1683, - "annotation_hash": "sha256-v3:4a7d0ba07ea80e6802ab0660b05f4541d06265ffe1cc3d1d817bebb549433253", + "annotation_hash": "sha256-v3:95e447ec72ce4924c563d83aae0d3e594f518789e71d00ded358ac3255cf772a", "renderable": true, "render": { "text_size": 967, @@ -226,7 +226,7 @@ { "path": ".guardlink/graph/by-asset/init.flows.mmd", "bytes": 2103, - "annotation_hash": "sha256-v3:4a7d0ba07ea80e6802ab0660b05f4541d06265ffe1cc3d1d817bebb549433253", + "annotation_hash": "sha256-v3:95e447ec72ce4924c563d83aae0d3e594f518789e71d00ded358ac3255cf772a", "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:4a7d0ba07ea80e6802ab0660b05f4541d06265ffe1cc3d1d817bebb549433253", + "annotation_hash": "sha256-v3:95e447ec72ce4924c563d83aae0d3e594f518789e71d00ded358ac3255cf772a", "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:4a7d0ba07ea80e6802ab0660b05f4541d06265ffe1cc3d1d817bebb549433253", + "annotation_hash": "sha256-v3:95e447ec72ce4924c563d83aae0d3e594f518789e71d00ded358ac3255cf772a", "renderable": true, "render": { "text_size": 1342, @@ -256,7 +256,7 @@ { "path": ".guardlink/graph/by-asset/mcp.threats.mmd", "bytes": 1807, - "annotation_hash": "sha256-v3:4a7d0ba07ea80e6802ab0660b05f4541d06265ffe1cc3d1d817bebb549433253", + "annotation_hash": "sha256-v3:95e447ec72ce4924c563d83aae0d3e594f518789e71d00ded358ac3255cf772a", "renderable": true, "render": { "text_size": 1003, @@ -266,7 +266,7 @@ { "path": ".guardlink/graph/by-asset/mcp.flows.mmd", "bytes": 1883, - "annotation_hash": "sha256-v3:4a7d0ba07ea80e6802ab0660b05f4541d06265ffe1cc3d1d817bebb549433253", + "annotation_hash": "sha256-v3:95e447ec72ce4924c563d83aae0d3e594f518789e71d00ded358ac3255cf772a", "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:4a7d0ba07ea80e6802ab0660b05f4541d06265ffe1cc3d1d817bebb549433253", + "annotation_hash": "sha256-v3:95e447ec72ce4924c563d83aae0d3e594f518789e71d00ded358ac3255cf772a", "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:4a7d0ba07ea80e6802ab0660b05f4541d06265ffe1cc3d1d817bebb549433253", + "annotation_hash": "sha256-v3:95e447ec72ce4924c563d83aae0d3e594f518789e71d00ded358ac3255cf772a", "renderable": true, "render": { "text_size": 1299, @@ -296,7 +296,7 @@ { "path": ".guardlink/graph/by-asset/parser.threats.mmd", "bytes": 1716, - "annotation_hash": "sha256-v3:4a7d0ba07ea80e6802ab0660b05f4541d06265ffe1cc3d1d817bebb549433253", + "annotation_hash": "sha256-v3:95e447ec72ce4924c563d83aae0d3e594f518789e71d00ded358ac3255cf772a", "renderable": true, "render": { "text_size": 910, @@ -306,7 +306,7 @@ { "path": ".guardlink/graph/by-asset/parser.flows.mmd", "bytes": 2142, - "annotation_hash": "sha256-v3:4a7d0ba07ea80e6802ab0660b05f4541d06265ffe1cc3d1d817bebb549433253", + "annotation_hash": "sha256-v3:95e447ec72ce4924c563d83aae0d3e594f518789e71d00ded358ac3255cf772a", "renderable": true, "render": { "text_size": 1302, @@ -316,7 +316,7 @@ { "path": ".guardlink/graph/by-asset/report.threats.mmd", "bytes": 1222, - "annotation_hash": "sha256-v3:4a7d0ba07ea80e6802ab0660b05f4541d06265ffe1cc3d1d817bebb549433253", + "annotation_hash": "sha256-v3:95e447ec72ce4924c563d83aae0d3e594f518789e71d00ded358ac3255cf772a", "renderable": true, "render": { "text_size": 510, @@ -326,7 +326,7 @@ { "path": ".guardlink/graph/by-asset/report.flows.mmd", "bytes": 1225, - "annotation_hash": "sha256-v3:4a7d0ba07ea80e6802ab0660b05f4541d06265ffe1cc3d1d817bebb549433253", + "annotation_hash": "sha256-v3:95e447ec72ce4924c563d83aae0d3e594f518789e71d00ded358ac3255cf772a", "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:4a7d0ba07ea80e6802ab0660b05f4541d06265ffe1cc3d1d817bebb549433253", + "annotation_hash": "sha256-v3:95e447ec72ce4924c563d83aae0d3e594f518789e71d00ded358ac3255cf772a", "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:4a7d0ba07ea80e6802ab0660b05f4541d06265ffe1cc3d1d817bebb549433253", + "annotation_hash": "sha256-v3:95e447ec72ce4924c563d83aae0d3e594f518789e71d00ded358ac3255cf772a", "renderable": true, "render": { "text_size": 1105, @@ -356,7 +356,7 @@ { "path": ".guardlink/graph/by-asset/sarif.threats.mmd", "bytes": 1350, - "annotation_hash": "sha256-v3:4a7d0ba07ea80e6802ab0660b05f4541d06265ffe1cc3d1d817bebb549433253", + "annotation_hash": "sha256-v3:95e447ec72ce4924c563d83aae0d3e594f518789e71d00ded358ac3255cf772a", "renderable": true, "render": { "text_size": 638, @@ -366,7 +366,7 @@ { "path": ".guardlink/graph/by-asset/sarif.flows.mmd", "bytes": 1344, - "annotation_hash": "sha256-v3:4a7d0ba07ea80e6802ab0660b05f4541d06265ffe1cc3d1d817bebb549433253", + "annotation_hash": "sha256-v3:95e447ec72ce4924c563d83aae0d3e594f518789e71d00ded358ac3255cf772a", "renderable": true, "render": { "text_size": 546, @@ -376,7 +376,7 @@ { "path": ".guardlink/graph/by-asset/suggest.threats.mmd", "bytes": 1582, - "annotation_hash": "sha256-v3:4a7d0ba07ea80e6802ab0660b05f4541d06265ffe1cc3d1d817bebb549433253", + "annotation_hash": "sha256-v3:95e447ec72ce4924c563d83aae0d3e594f518789e71d00ded358ac3255cf772a", "renderable": true, "render": { "text_size": 860, @@ -386,7 +386,7 @@ { "path": ".guardlink/graph/by-asset/suggest.flows.mmd", "bytes": 2143, - "annotation_hash": "sha256-v3:4a7d0ba07ea80e6802ab0660b05f4541d06265ffe1cc3d1d817bebb549433253", + "annotation_hash": "sha256-v3:95e447ec72ce4924c563d83aae0d3e594f518789e71d00ded358ac3255cf772a", "renderable": true, "render": { "text_size": 1299, @@ -396,7 +396,7 @@ { "path": ".guardlink/graph/by-asset/tui.threats.mmd", "bytes": 2073, - "annotation_hash": "sha256-v3:4a7d0ba07ea80e6802ab0660b05f4541d06265ffe1cc3d1d817bebb549433253", + "annotation_hash": "sha256-v3:95e447ec72ce4924c563d83aae0d3e594f518789e71d00ded358ac3255cf772a", "renderable": true, "render": { "text_size": 1349, @@ -406,7 +406,7 @@ { "path": ".guardlink/graph/by-asset/tui.flows.mmd", "bytes": 2052, - "annotation_hash": "sha256-v3:4a7d0ba07ea80e6802ab0660b05f4541d06265ffe1cc3d1d817bebb549433253", + "annotation_hash": "sha256-v3:95e447ec72ce4924c563d83aae0d3e594f518789e71d00ded358ac3255cf772a", "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:4a7d0ba07ea80e6802ab0660b05f4541d06265ffe1cc3d1d817bebb549433253", + "annotation_hash": "sha256-v3:95e447ec72ce4924c563d83aae0d3e594f518789e71d00ded358ac3255cf772a", "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:4a7d0ba07ea80e6802ab0660b05f4541d06265ffe1cc3d1d817bebb549433253", + "annotation_hash": "sha256-v3:95e447ec72ce4924c563d83aae0d3e594f518789e71d00ded358ac3255cf772a", "renderable": true, "render": { "text_size": 1122, @@ -436,7 +436,7 @@ { "path": ".guardlink/graph/by-boundary/agent-boundary.mmd", "bytes": 2227, - "annotation_hash": "sha256-v3:4a7d0ba07ea80e6802ab0660b05f4541d06265ffe1cc3d1d817bebb549433253", + "annotation_hash": "sha256-v3:95e447ec72ce4924c563d83aae0d3e594f518789e71d00ded358ac3255cf772a", "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:4a7d0ba07ea80e6802ab0660b05f4541d06265ffe1cc3d1d817bebb549433253", + "annotation_hash": "sha256-v3:95e447ec72ce4924c563d83aae0d3e594f518789e71d00ded358ac3255cf772a", "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:4a7d0ba07ea80e6802ab0660b05f4541d06265ffe1cc3d1d817bebb549433253", + "annotation_hash": "sha256-v3:95e447ec72ce4924c563d83aae0d3e594f518789e71d00ded358ac3255cf772a", "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:4a7d0ba07ea80e6802ab0660b05f4541d06265ffe1cc3d1d817bebb549433253", + "annotation_hash": "sha256-v3:95e447ec72ce4924c563d83aae0d3e594f518789e71d00ded358ac3255cf772a", "renderable": true, "render": { "text_size": 1410, @@ -476,7 +476,7 @@ { "path": ".guardlink/graph/by-boundary/codegraph-boundary.mmd", "bytes": 2397, - "annotation_hash": "sha256-v3:4a7d0ba07ea80e6802ab0660b05f4541d06265ffe1cc3d1d817bebb549433253", + "annotation_hash": "sha256-v3:95e447ec72ce4924c563d83aae0d3e594f518789e71d00ded358ac3255cf772a", "renderable": true, "render": { "text_size": 1517, @@ -486,7 +486,7 @@ { "path": ".guardlink/graph/by-boundary/git-boundary.mmd", "bytes": 2046, - "annotation_hash": "sha256-v3:4a7d0ba07ea80e6802ab0660b05f4541d06265ffe1cc3d1d817bebb549433253", + "annotation_hash": "sha256-v3:95e447ec72ce4924c563d83aae0d3e594f518789e71d00ded358ac3255cf772a", "renderable": true, "render": { "text_size": 1230, @@ -496,7 +496,7 @@ { "path": ".guardlink/graph/by-boundary/mcp-boundary.mmd", "bytes": 2227, - "annotation_hash": "sha256-v3:4a7d0ba07ea80e6802ab0660b05f4541d06265ffe1cc3d1d817bebb549433253", + "annotation_hash": "sha256-v3:95e447ec72ce4924c563d83aae0d3e594f518789e71d00ded358ac3255cf772a", "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:4a7d0ba07ea80e6802ab0660b05f4541d06265ffe1cc3d1d817bebb549433253", + "annotation_hash": "sha256-v3:95e447ec72ce4924c563d83aae0d3e594f518789e71d00ded358ac3255cf772a", "renderable": true, "render": { "text_size": 1413, @@ -516,7 +516,7 @@ { "path": ".guardlink/graph/by-boundary/fs-boundary.mmd", "bytes": 2260, - "annotation_hash": "sha256-v3:4a7d0ba07ea80e6802ab0660b05f4541d06265ffe1cc3d1d817bebb549433253", + "annotation_hash": "sha256-v3:95e447ec72ce4924c563d83aae0d3e594f518789e71d00ded358ac3255cf772a", "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:4a7d0ba07ea80e6802ab0660b05f4541d06265ffe1cc3d1d817bebb549433253", + "annotation_hash": "sha256-v3:95e447ec72ce4924c563d83aae0d3e594f518789e71d00ded358ac3255cf772a", "renderable": true, "render": { "text_size": 1212, @@ -535,8 +535,8 @@ }, { "path": ".guardlink/model.json", - "bytes": 277988, - "annotation_hash": "sha256-v3:4a7d0ba07ea80e6802ab0660b05f4541d06265ffe1cc3d1d817bebb549433253" + "bytes": 278305, + "annotation_hash": "sha256-v3:95e447ec72ce4924c563d83aae0d3e594f518789e71d00ded358ac3255cf772a" } ] } diff --git a/.guardlink/graph/attack-surface.mmd b/.guardlink/graph/attack-surface.mmd index 6fa02c8b..30e34632 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:4a7d0ba07ea80e6802ab0660b05f4541d06265ffe1cc3d1d817bebb549433253 +%% annotation_hash: sha256-v3:95e447ec72ce4924c563d83aae0d3e594f518789e71d00ded358ac3255cf772a %% 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 e0e3e45a..10e54fe6 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:4a7d0ba07ea80e6802ab0660b05f4541d06265ffe1cc3d1d817bebb549433253 +%% annotation_hash: sha256-v3:95e447ec72ce4924c563d83aae0d3e594f518789e71d00ded358ac3255cf772a %% 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 b5111a32..95ef90cb 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:4a7d0ba07ea80e6802ab0660b05f4541d06265ffe1cc3d1d817bebb549433253 +%% annotation_hash: sha256-v3:95e447ec72ce4924c563d83aae0d3e594f518789e71d00ded358ac3255cf772a %% 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 b7cc1777..3b40cbdb 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:4a7d0ba07ea80e6802ab0660b05f4541d06265ffe1cc3d1d817bebb549433253 +%% annotation_hash: sha256-v3:95e447ec72ce4924c563d83aae0d3e594f518789e71d00ded358ac3255cf772a %% 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 0cee883e..d3cf511b 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:4a7d0ba07ea80e6802ab0660b05f4541d06265ffe1cc3d1d817bebb549433253 +%% annotation_hash: sha256-v3:95e447ec72ce4924c563d83aae0d3e594f518789e71d00ded358ac3255cf772a %% 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 7e1b7fa1..a0a3a43b 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:4a7d0ba07ea80e6802ab0660b05f4541d06265ffe1cc3d1d817bebb549433253 +%% annotation_hash: sha256-v3:95e447ec72ce4924c563d83aae0d3e594f518789e71d00ded358ac3255cf772a %% 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 1e37b886..6b94b3ce 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:4a7d0ba07ea80e6802ab0660b05f4541d06265ffe1cc3d1d817bebb549433253 +%% annotation_hash: sha256-v3:95e447ec72ce4924c563d83aae0d3e594f518789e71d00ded358ac3255cf772a %% 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 71fe0058..a85b73d3 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:4a7d0ba07ea80e6802ab0660b05f4541d06265ffe1cc3d1d817bebb549433253 +%% annotation_hash: sha256-v3:95e447ec72ce4924c563d83aae0d3e594f518789e71d00ded358ac3255cf772a %% 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 5f120e3f..a660f7ed 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:4a7d0ba07ea80e6802ab0660b05f4541d06265ffe1cc3d1d817bebb549433253 +%% annotation_hash: sha256-v3:95e447ec72ce4924c563d83aae0d3e594f518789e71d00ded358ac3255cf772a %% 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 3d77b43c..d9ff012c 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:4a7d0ba07ea80e6802ab0660b05f4541d06265ffe1cc3d1d817bebb549433253 +%% annotation_hash: sha256-v3:95e447ec72ce4924c563d83aae0d3e594f518789e71d00ded358ac3255cf772a %% 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 f8af9c3c..68612f9d 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:4a7d0ba07ea80e6802ab0660b05f4541d06265ffe1cc3d1d817bebb549433253 +%% annotation_hash: sha256-v3:95e447ec72ce4924c563d83aae0d3e594f518789e71d00ded358ac3255cf772a %% 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 bbd2be12..38082490 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:4a7d0ba07ea80e6802ab0660b05f4541d06265ffe1cc3d1d817bebb549433253 +%% annotation_hash: sha256-v3:95e447ec72ce4924c563d83aae0d3e594f518789e71d00ded358ac3255cf772a %% 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 d95514c8..9ac58b0b 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:4a7d0ba07ea80e6802ab0660b05f4541d06265ffe1cc3d1d817bebb549433253 +%% annotation_hash: sha256-v3:95e447ec72ce4924c563d83aae0d3e594f518789e71d00ded358ac3255cf772a %% 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 02100382..ba1df699 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:4a7d0ba07ea80e6802ab0660b05f4541d06265ffe1cc3d1d817bebb549433253 +%% annotation_hash: sha256-v3:95e447ec72ce4924c563d83aae0d3e594f518789e71d00ded358ac3255cf772a %% 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 e59cd92d..be689ef7 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:4a7d0ba07ea80e6802ab0660b05f4541d06265ffe1cc3d1d817bebb549433253 +%% annotation_hash: sha256-v3:95e447ec72ce4924c563d83aae0d3e594f518789e71d00ded358ac3255cf772a %% 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 1ea1a629..40299def 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:4a7d0ba07ea80e6802ab0660b05f4541d06265ffe1cc3d1d817bebb549433253 +%% annotation_hash: sha256-v3:95e447ec72ce4924c563d83aae0d3e594f518789e71d00ded358ac3255cf772a %% 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 d13c5201..fbcaf086 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:4a7d0ba07ea80e6802ab0660b05f4541d06265ffe1cc3d1d817bebb549433253 +%% annotation_hash: sha256-v3:95e447ec72ce4924c563d83aae0d3e594f518789e71d00ded358ac3255cf772a %% 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 0e2f5502..575fa3d6 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:4a7d0ba07ea80e6802ab0660b05f4541d06265ffe1cc3d1d817bebb549433253 +%% annotation_hash: sha256-v3:95e447ec72ce4924c563d83aae0d3e594f518789e71d00ded358ac3255cf772a %% 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 23139d0b..bcc80dcc 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:4a7d0ba07ea80e6802ab0660b05f4541d06265ffe1cc3d1d817bebb549433253 +%% annotation_hash: sha256-v3:95e447ec72ce4924c563d83aae0d3e594f518789e71d00ded358ac3255cf772a %% 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 b6c8b845..99e7b699 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:4a7d0ba07ea80e6802ab0660b05f4541d06265ffe1cc3d1d817bebb549433253 +%% annotation_hash: sha256-v3:95e447ec72ce4924c563d83aae0d3e594f518789e71d00ded358ac3255cf772a %% 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 f51b5013..bda3e89f 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:4a7d0ba07ea80e6802ab0660b05f4541d06265ffe1cc3d1d817bebb549433253 +%% annotation_hash: sha256-v3:95e447ec72ce4924c563d83aae0d3e594f518789e71d00ded358ac3255cf772a %% 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 bc1cf858..6ab20bfb 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:4a7d0ba07ea80e6802ab0660b05f4541d06265ffe1cc3d1d817bebb549433253 +%% annotation_hash: sha256-v3:95e447ec72ce4924c563d83aae0d3e594f518789e71d00ded358ac3255cf772a %% 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 2b682e88..af35c884 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:4a7d0ba07ea80e6802ab0660b05f4541d06265ffe1cc3d1d817bebb549433253 +%% annotation_hash: sha256-v3:95e447ec72ce4924c563d83aae0d3e594f518789e71d00ded358ac3255cf772a %% 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 ce3bd1e9..34e19827 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:4a7d0ba07ea80e6802ab0660b05f4541d06265ffe1cc3d1d817bebb549433253 +%% annotation_hash: sha256-v3:95e447ec72ce4924c563d83aae0d3e594f518789e71d00ded358ac3255cf772a %% 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 314f9b26..f3ac5e45 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:4a7d0ba07ea80e6802ab0660b05f4541d06265ffe1cc3d1d817bebb549433253 +%% annotation_hash: sha256-v3:95e447ec72ce4924c563d83aae0d3e594f518789e71d00ded358ac3255cf772a %% 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 5943f1e1..2a4bc78f 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:4a7d0ba07ea80e6802ab0660b05f4541d06265ffe1cc3d1d817bebb549433253 +%% annotation_hash: sha256-v3:95e447ec72ce4924c563d83aae0d3e594f518789e71d00ded358ac3255cf772a %% 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 fb59f793..b5df1957 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:4a7d0ba07ea80e6802ab0660b05f4541d06265ffe1cc3d1d817bebb549433253 +%% annotation_hash: sha256-v3:95e447ec72ce4924c563d83aae0d3e594f518789e71d00ded358ac3255cf772a %% 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 c9afe0e4..caac8343 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:4a7d0ba07ea80e6802ab0660b05f4541d06265ffe1cc3d1d817bebb549433253 +%% annotation_hash: sha256-v3:95e447ec72ce4924c563d83aae0d3e594f518789e71d00ded358ac3255cf772a %% 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 279f22ce..02110882 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:4a7d0ba07ea80e6802ab0660b05f4541d06265ffe1cc3d1d817bebb549433253 +%% annotation_hash: sha256-v3:95e447ec72ce4924c563d83aae0d3e594f518789e71d00ded358ac3255cf772a %% 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 c6af5d96..cb9b9422 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:4a7d0ba07ea80e6802ab0660b05f4541d06265ffe1cc3d1d817bebb549433253 +%% annotation_hash: sha256-v3:95e447ec72ce4924c563d83aae0d3e594f518789e71d00ded358ac3255cf772a %% 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 a080122d..310f66da 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:4a7d0ba07ea80e6802ab0660b05f4541d06265ffe1cc3d1d817bebb549433253 +%% annotation_hash: sha256-v3:95e447ec72ce4924c563d83aae0d3e594f518789e71d00ded358ac3255cf772a %% 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 7672d013..91682003 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:4a7d0ba07ea80e6802ab0660b05f4541d06265ffe1cc3d1d817bebb549433253 +%% annotation_hash: sha256-v3:95e447ec72ce4924c563d83aae0d3e594f518789e71d00ded358ac3255cf772a %% 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 149e2b12..f0861084 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:4a7d0ba07ea80e6802ab0660b05f4541d06265ffe1cc3d1d817bebb549433253 +%% annotation_hash: sha256-v3:95e447ec72ce4924c563d83aae0d3e594f518789e71d00ded358ac3255cf772a %% 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 42db8208..de00285d 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:4a7d0ba07ea80e6802ab0660b05f4541d06265ffe1cc3d1d817bebb549433253 +%% annotation_hash: sha256-v3:95e447ec72ce4924c563d83aae0d3e594f518789e71d00ded358ac3255cf772a %% 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 a0636c51..fcbc2cbd 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:4a7d0ba07ea80e6802ab0660b05f4541d06265ffe1cc3d1d817bebb549433253 +%% annotation_hash: sha256-v3:95e447ec72ce4924c563d83aae0d3e594f518789e71d00ded358ac3255cf772a %% 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 3e1faf38..db778b9d 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:4a7d0ba07ea80e6802ab0660b05f4541d06265ffe1cc3d1d817bebb549433253 +%% annotation_hash: sha256-v3:95e447ec72ce4924c563d83aae0d3e594f518789e71d00ded358ac3255cf772a %% 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 35fb7b7c..6e87cd74 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:4a7d0ba07ea80e6802ab0660b05f4541d06265ffe1cc3d1d817bebb549433253 +%% annotation_hash: sha256-v3:95e447ec72ce4924c563d83aae0d3e594f518789e71d00ded358ac3255cf772a %% 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 0eec6215..3705b234 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:4a7d0ba07ea80e6802ab0660b05f4541d06265ffe1cc3d1d817bebb549433253 +%% annotation_hash: sha256-v3:95e447ec72ce4924c563d83aae0d3e594f518789e71d00ded358ac3255cf772a %% 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 d7e9ff74..f5d7a22e 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:4a7d0ba07ea80e6802ab0660b05f4541d06265ffe1cc3d1d817bebb549433253 +%% annotation_hash: sha256-v3:95e447ec72ce4924c563d83aae0d3e594f518789e71d00ded358ac3255cf772a %% 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 5faf0b50..5555e4dc 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:4a7d0ba07ea80e6802ab0660b05f4541d06265ffe1cc3d1d817bebb549433253 +%% annotation_hash: sha256-v3:95e447ec72ce4924c563d83aae0d3e594f518789e71d00ded358ac3255cf772a %% 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 fdec4826..7063b650 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:4a7d0ba07ea80e6802ab0660b05f4541d06265ffe1cc3d1d817bebb549433253 +%% annotation_hash: sha256-v3:95e447ec72ce4924c563d83aae0d3e594f518789e71d00ded358ac3255cf772a %% 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 54d36f4c..33ce9105 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:4a7d0ba07ea80e6802ab0660b05f4541d06265ffe1cc3d1d817bebb549433253 +%% annotation_hash: sha256-v3:95e447ec72ce4924c563d83aae0d3e594f518789e71d00ded358ac3255cf772a %% 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 50036804..c4783266 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:4a7d0ba07ea80e6802ab0660b05f4541d06265ffe1cc3d1d817bebb549433253 +%% annotation_hash: sha256-v3:95e447ec72ce4924c563d83aae0d3e594f518789e71d00ded358ac3255cf772a %% 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 b63b0aed..aa75cb7d 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:4a7d0ba07ea80e6802ab0660b05f4541d06265ffe1cc3d1d817bebb549433253 +%% annotation_hash: sha256-v3:95e447ec72ce4924c563d83aae0d3e594f518789e71d00ded358ac3255cf772a %% 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 4fc2190c..fe4930b4 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:4a7d0ba07ea80e6802ab0660b05f4541d06265ffe1cc3d1d817bebb549433253 +%% annotation_hash: sha256-v3:95e447ec72ce4924c563d83aae0d3e594f518789e71d00ded358ac3255cf772a %% 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 901cad66..82687fe0 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:4a7d0ba07ea80e6802ab0660b05f4541d06265ffe1cc3d1d817bebb549433253 +%% annotation_hash: sha256-v3:95e447ec72ce4924c563d83aae0d3e594f518789e71d00ded358ac3255cf772a %% 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 0bb1d890..2c2c096d 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:4a7d0ba07ea80e6802ab0660b05f4541d06265ffe1cc3d1d817bebb549433253 +%% annotation_hash: sha256-v3:95e447ec72ce4924c563d83aae0d3e594f518789e71d00ded358ac3255cf772a %% 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 7207d598..e376d11b 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:4a7d0ba07ea80e6802ab0660b05f4541d06265ffe1cc3d1d817bebb549433253 +%% annotation_hash: sha256-v3:95e447ec72ce4924c563d83aae0d3e594f518789e71d00ded358ac3255cf772a %% 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 946702d1..29f0cd47 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:4a7d0ba07ea80e6802ab0660b05f4541d06265ffe1cc3d1d817bebb549433253 +%% annotation_hash: sha256-v3:95e447ec72ce4924c563d83aae0d3e594f518789e71d00ded358ac3255cf772a %% 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 94ec7258..12f3a260 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:4a7d0ba07ea80e6802ab0660b05f4541d06265ffe1cc3d1d817bebb549433253 +%% annotation_hash: sha256-v3:95e447ec72ce4924c563d83aae0d3e594f518789e71d00ded358ac3255cf772a %% 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 93c180c3..9c4e8d2c 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:4a7d0ba07ea80e6802ab0660b05f4541d06265ffe1cc3d1d817bebb549433253 +%% annotation_hash: sha256-v3:95e447ec72ce4924c563d83aae0d3e594f518789e71d00ded358ac3255cf772a %% 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 41570dab..5c4572f4 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:4a7d0ba07ea80e6802ab0660b05f4541d06265ffe1cc3d1d817bebb549433253 +%% annotation_hash: sha256-v3:95e447ec72ce4924c563d83aae0d3e594f518789e71d00ded358ac3255cf772a %% 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 7a7e8a30..c575796a 100644 --- a/.guardlink/graph/index.md +++ b/.guardlink/graph/index.md @@ -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:4a7d0ba07ea80e6802ab0660b05f4541d06265ffe1cc3d1d817bebb549433253`. If that +Generated by guardlink@2.1.0 from annotation hash `sha256-v3:95e447ec72ce4924c563d83aae0d3e594f518789e71d00ded358ac3255cf772a`. 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 1a29f8e7..1a56b0d9 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:4a7d0ba07ea80e6802ab0660b05f4541d06265ffe1cc3d1d817bebb549433253 +%% annotation_hash: sha256-v3:95e447ec72ce4924c563d83aae0d3e594f518789e71d00ded358ac3255cf772a %% 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 96204300..2b711c5e 100644 --- a/.guardlink/model.json +++ b/.guardlink/model.json @@ -1,12 +1,12 @@ { "provenance": { - "annotation_hash": "sha256-v3:4a7d0ba07ea80e6802ab0660b05f4541d06265ffe1cc3d1d817bebb549433253", + "annotation_hash": "sha256-v3:95e447ec72ce4924c563d83aae0d3e594f518789e71d00ded358ac3255cf772a", "generator": "guardlink@2.1.0" }, "version": "1.2.0", "project": "guardlink", "source_files": 164, - "annotations_parsed": 873, + "annotations_parsed": 874, "annotated_files": [ ".guardlink/definitions.ts", "src/agents/config.ts", @@ -1154,7 +1154,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": 550 + "line": 553 } }, { @@ -2620,7 +2620,7 @@ "description": "The one command that writes .guardlink/verified.json", "location": { "file": "src/cli/index.ts", - "line": 549 + "line": 552 } }, { @@ -4053,7 +4053,7 @@ "description": "Covered exposures, boundaries and flows read from the parsed model", "location": { "file": "src/analyzer/sarif-pentest.ts", - "line": 41 + "line": 43 } }, { @@ -4064,7 +4064,7 @@ "description": "Ledger states stamped onto results; the front end reads the file, this module does not", "location": { "file": "src/analyzer/sarif-pentest.ts", - "line": 42 + "line": 44 } }, { @@ -4075,7 +4075,7 @@ "description": "A previous export compared result by result", "location": { "file": "src/analyzer/sarif-pentest.ts", - "line": 43 + "line": 45 } }, { @@ -4361,7 +4361,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": 394 + "line": 397 } }, { @@ -4372,7 +4372,7 @@ "description": "validate --code-graph: project root; ranked entry points with route access, or a status", "location": { "file": "src/cli/index.ts", - "line": 404 + "line": 407 } }, { @@ -4383,7 +4383,7 @@ "description": "Targets, --by and mode flags", "location": { "file": "src/cli/index.ts", - "line": 551 + "line": 554 } }, { @@ -4394,7 +4394,7 @@ "description": "Attribution read from blame, log -L and commit trailers", "location": { "file": "src/cli/index.ts", - "line": 668 + "line": 671 } }, { @@ -4405,7 +4405,7 @@ "description": "report --blame attaches attribution before the metadata spread, so the shared records carry it", "location": { "file": "src/cli/index.ts", - "line": 861 + "line": 864 } }, { @@ -4416,7 +4416,7 @@ "description": "pentest profile: each claim's tested state goes into the SARIF", "location": { "file": "src/cli/index.ts", - "line": 1260 + "line": 1263 } }, { @@ -4427,7 +4427,7 @@ "description": "Project root; the graph answers with ranked entry points or a status", "location": { "file": "src/cli/index.ts", - "line": 1620 + "line": 1623 } }, { @@ -4438,7 +4438,7 @@ "description": "The model before and after the agent", "location": { "file": "src/cli/index.ts", - "line": 1667 + "line": 1670 } }, { @@ -4449,7 +4449,7 @@ "description": "Which assets sit on an undefended path, for the ranking", "location": { "file": "src/cli/index.ts", - "line": 1802 + "line": 1805 } }, { @@ -4460,7 +4460,7 @@ "description": "cxg findings recorded as confirmations, joined to claims", "location": { "file": "src/cli/index.ts", - "line": 1822 + "line": 1825 } }, { @@ -4471,7 +4471,7 @@ "description": "Boundary outcomes read back by claim key", "location": { "file": "src/cli/index.ts", - "line": 1929 + "line": 1932 } }, { @@ -4482,7 +4482,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": 3037 + "line": 3040 } }, { @@ -4493,7 +4493,7 @@ "description": "dashboard --since : the model at the ref, diffed against this one", "location": { "file": "src/cli/index.ts", - "line": 3040 + "line": 3043 } }, { @@ -4504,7 +4504,7 @@ "description": "Boundary diagnostics from the worklist and the parsed model, for validate --code-graph and guardlink_validate", "location": { "file": "src/codegraph/boundary-check.ts", - "line": 42 + "line": 45 } }, { @@ -5164,7 +5164,7 @@ "description": "Attribution read from blame, log -L and commit trailers for the connected agent", "location": { "file": "src/mcp/server.ts", - "line": 1208 + "line": 1210 } }, { @@ -5780,7 +5780,7 @@ "description": "Attribution read from blame, log -L and commit trailers", "location": { "file": "src/tui/commands.ts", - "line": 1002 + "line": 1005 } }, { @@ -6451,7 +6451,7 @@ "description": "Ledger evidence strings are exported with the state they support", "location": { "file": "src/analyzer/sarif-pentest.ts", - "line": 44 + "line": 46 } }, { @@ -6532,7 +6532,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": 669 + "line": 672 } }, { @@ -6541,7 +6541,7 @@ "description": "Identity strings printed to the terminal", "location": { "file": "src/cli/index.ts", - "line": 3916 + "line": 3921 } }, { @@ -6658,7 +6658,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": 1209 + "line": 1211 } }, { @@ -6721,7 +6721,7 @@ "description": "Author identities printed to the terminal in the configured identity mode", "location": { "file": "src/tui/commands.ts", - "line": 1003 + "line": 1006 } }, { @@ -6805,91 +6805,91 @@ "reason": "Boundary examples, excluded from parsing", "location": { "file": "src/agents/prompts.ts", - "line": 349 + "line": 350 } }, { "location": { "file": "src/agents/prompts.ts", - "line": 355 + "line": 356 } }, { "reason": "Actor example, excluded from parsing", "location": { "file": "src/agents/prompts.ts", - "line": 416 + "line": 417 } }, { "location": { "file": "src/agents/prompts.ts", - "line": 418 + "line": 419 } }, { "reason": "Entitlement example, excluded from parsing", "location": { "file": "src/agents/prompts.ts", - "line": 435 + "line": 436 } }, { "location": { "file": "src/agents/prompts.ts", - "line": 438 + "line": 439 } }, { "reason": "@accepts alternative examples, excluded from parsing", "location": { "file": "src/agents/prompts.ts", - "line": 453 + "line": 454 } }, { "location": { "file": "src/agents/prompts.ts", - "line": 463 + "line": 464 } }, { "reason": "Definition syntax examples, excluded from parsing", "location": { "file": "src/agents/prompts.ts", - "line": 490 + "line": 491 } }, { "location": { "file": "src/agents/prompts.ts", - "line": 494 + "line": 495 } }, { "reason": "Relationship syntax examples, excluded from parsing", "location": { "file": "src/agents/prompts.ts", - "line": 499 + "line": 500 } }, { "location": { "file": "src/agents/prompts.ts", - "line": 515 + "line": 516 } }, { "reason": "Externalized relationship examples, excluded from parsing", "location": { "file": "src/agents/prompts.ts", - "line": 520 + "line": 521 } }, { "location": { "file": "src/agents/prompts.ts", - "line": 525 + "line": 526 } }, { @@ -6954,33 +6954,33 @@ { "location": { "file": "src/init/templates.ts", - "line": 323 + "line": 324 } }, { "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": 391 + "line": 392 } }, { "location": { "file": "src/init/templates.ts", - "line": 540 + "line": 541 } }, { "reason": "README example annotations, excluded from parsing — they would otherwise register as real records", "location": { "file": "src/init/templates.ts", - "line": 1032 + "line": 1033 } }, { "location": { "file": "src/init/templates.ts", - "line": 1344 + "line": 1345 } } ], @@ -7091,7 +7091,7 @@ "description": "Pure transform: no I/O. Reads nothing from @entitles or @actor (SPEC §6.1); the suppressions it writes come only from @mitigates and @accepts", "location": { "file": "src/analyzer/sarif-pentest.ts", - "line": 45 + "line": 47 } }, { @@ -7336,196 +7336,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": 395 + "line": 398 } }, { "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": 552 + "line": 555 } }, { "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": 670 + "line": 673 } }, { "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": 1245 + "line": 1248 } }, { "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": 1806 + "line": 1809 } }, { "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": 1809 + "line": 1812 } }, { "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": 1810 + "line": 1813 } }, { "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": 1817 + "line": 1820 } }, { "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": 1825 + "line": 1828 } }, { "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": 1828 + "line": 1831 } }, { "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": 1832 + "line": 1835 } }, { "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": 1833 + "line": 1836 } }, { "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": 1836 + "line": 1839 } }, { "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": 1847 + "line": 1850 } }, { "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": 1849 + "line": 1852 } }, { "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": 1852 + "line": 1855 } }, { "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": 1854 + "line": 1857 } }, { "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": 1855 + "line": 1858 } }, { "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": 1856 + "line": 1859 } }, { "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": 1868 + "line": 1871 } }, { "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": 1869 + "line": 1872 } }, { "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": 1874 + "line": 1877 } }, { "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": 1885 + "line": 1888 } }, { "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": 1912 + "line": 1915 } }, { "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": 1915 + "line": 1918 } }, { "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": 3839 + "line": 3844 } }, { "description": "Prints only what the payload already holds; no I/O here", "location": { "file": "src/cli/index.ts", - "line": 3917 + "line": 3922 } }, { "description": "Pure over a parsed model and an already-loaded worklist: no process, no file, no network. The description is matched for a stated access level and quoted back, so a reader sees the exact word the check read", "location": { "file": "src/codegraph/boundary-check.ts", - "line": 43 + "line": 46 } }, { @@ -7931,14 +7931,14 @@ "description": "One block per FINDING, deliberately — the header counts findings and two probes hitting one claim is worth seeing. So a claim several findings joined to appears more than once, each block showing the entry that finding produced. Withholding and the exit code are the opposite case: those are decisions about a CLAIM, are derived per claim in the CLI, and must not be read off a per-finding list", "location": { "file": "src/hypothesis/format.ts", - "line": 175 + "line": 176 } }, { "description": "This formatter emits NO model coordinate. It runs before --write splices anything, so every line it could name is a pre-write position, and a target handed to an operator has to name where the claim is once this run is finished — a key-verified write higher up the same file moves an ambiguous candidate as readily as it moves a withheld one. The by-hand commands for an ambiguous finding therefore live in the CLI beside the withheld ones, after the writes, through the one afterWrites calculation. The only file:line left below is the SCAN REPORT's own, which is correct as the report's claim about what it tested and is not ours to shift", "location": { "file": "src/hypothesis/format.ts", - "line": 178 + "line": 179 } }, { @@ -8029,7 +8029,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": 1210 + "line": 1212 } }, { @@ -8376,12 +8376,19 @@ } }, { - "description": "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", + "description": "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", "location": { "file": "src/parser/validate.ts", "line": 9 } }, + { + "description": "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", + "location": { + "file": "src/parser/validate.ts", + "line": 10 + } + }, { "description": "Pure function; no I/O. Demotion is a SET of claim keys handed to coverage.ts, never a rewrite of the model", "location": { @@ -8624,14 +8631,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": 1004 + "line": 1007 } }, { "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": 1077 + "line": 1080 } }, { @@ -8685,7 +8692,7 @@ } ], "coverage": { - "annotation_count": 873, + "annotation_count": 874, "coverage_percent": 86 }, "external_refs": [] diff --git a/.windsurfrules b/.windsurfrules index 7aed4de1..984451cd 100644 --- a/.windsurfrules +++ b/.windsurfrules @@ -31,7 +31,7 @@ Every time you write or modify code that touches security-relevant behavior, you - 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.) - 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, and @handles when the asset touches classified data. +- 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. ## Workflow @@ -45,7 +45,7 @@ Every time you write or modify code that touches security-relevant behavior, you - @mitigates App.API against #sqli using #prepared-stmts -- "Parameterized queries via pg" - @audit App.API -- "Timing attack risk — needs human review" - @flows User -> App.API via HTTPS -- "Login request" -- @boundary between #api and #db (#data-boundary) -- "Trust change" +- @boundary from #api to #db (#data-boundary) -- "Trust change; outer side first" - @handles pii on App.API -- "Processes email, token" - @validates #prepared-stmts for App.API -- "CI test ensures placeholders" - @assumes App.API -- "Caller already authenticated; this never re-checks the session" @@ -120,11 +120,11 @@ _Full records with descriptions and locations: `guardlink_lookup("asset ")`, ### Model Stats -873 annotations, 20 assets, 16 threats, 14 controls, 127 exposures, 0 confirmed, 129 mitigations, 3 actors, 1 entitlements, 202 flows, 2 features +874 annotations, 20 assets, 16 threats, 14 controls, 127 exposures, 0 confirmed, 129 mitigations, 3 actors, 1 entitlements, 202 flows, 2 features ### Block Freshness -- `annotation_hash`: `sha256-v3:4a7d0ba07ea80e6802ab0660b05f4541d06265ffe1cc3d1d817bebb549433253` +- `annotation_hash`: `sha256-v3:95e447ec72ce4924c563d83aae0d3e594f518789e71d00ded358ac3255cf772a` 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 dc6fb52c..6b2a99d1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -57,7 +57,7 @@ the same change.** This includes: new endpoints, authentication/authorization lo - **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. - **You know the team accountable for an asset** → `@owns team-id for Asset`. The owner is a bare token: `platform-security` parses, `"Platform Security"` and `#platform-security` do not. If you do not know the team, skip it rather than guessing. -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, and `@handles` when the asset touches classified data. Never write a lone `@exposes` without follow-up. +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. @@ -88,7 +88,7 @@ the same change.** This includes: new endpoints, authentication/authorization lo @mitigates App.API against #sqli using #prepared-stmts -- "Parameterized queries via pg" @audit App.API -- "Timing attack risk — needs human review to assess bcrypt constant-time comparison" @flows User -> App.API via HTTPS -- "Login request path" -@boundary between #api and #db (#data-boundary) -- "App → DB trust change" +@boundary from #api to #db (#data-boundary) -- "App → DB trust change; #api is the less-trusted side" @handles pii on App.API -- "Processes email and session token" @validates #prepared-stmts for App.API -- "sqlInjectionTest.ts ensures placeholders used" @assumes App.API -- "Caller has already authenticated; this function never re-checks the session" @@ -174,11 +174,11 @@ _Full records with descriptions and locations: `guardlink_lookup("asset ")`, ### Model Stats -873 annotations, 20 assets, 16 threats, 14 controls, 127 exposures, 0 confirmed, 129 mitigations, 3 actors, 1 entitlements, 202 flows, 2 features +874 annotations, 20 assets, 16 threats, 14 controls, 127 exposures, 0 confirmed, 129 mitigations, 3 actors, 1 entitlements, 202 flows, 2 features ### Block Freshness -- `annotation_hash`: `sha256-v3:4a7d0ba07ea80e6802ab0660b05f4541d06265ffe1cc3d1d817bebb549433253` +- `annotation_hash`: `sha256-v3:95e447ec72ce4924c563d83aae0d3e594f518789e71d00ded358ac3255cf772a` 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 dc6fb52c..6b2a99d1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -57,7 +57,7 @@ the same change.** This includes: new endpoints, authentication/authorization lo - **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. - **You know the team accountable for an asset** → `@owns team-id for Asset`. The owner is a bare token: `platform-security` parses, `"Platform Security"` and `#platform-security` do not. If you do not know the team, skip it rather than guessing. -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, and `@handles` when the asset touches classified data. Never write a lone `@exposes` without follow-up. +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. @@ -88,7 +88,7 @@ the same change.** This includes: new endpoints, authentication/authorization lo @mitigates App.API against #sqli using #prepared-stmts -- "Parameterized queries via pg" @audit App.API -- "Timing attack risk — needs human review to assess bcrypt constant-time comparison" @flows User -> App.API via HTTPS -- "Login request path" -@boundary between #api and #db (#data-boundary) -- "App → DB trust change" +@boundary from #api to #db (#data-boundary) -- "App → DB trust change; #api is the less-trusted side" @handles pii on App.API -- "Processes email and session token" @validates #prepared-stmts for App.API -- "sqlInjectionTest.ts ensures placeholders used" @assumes App.API -- "Caller has already authenticated; this function never re-checks the session" @@ -174,11 +174,11 @@ _Full records with descriptions and locations: `guardlink_lookup("asset ")`, ### Model Stats -873 annotations, 20 assets, 16 threats, 14 controls, 127 exposures, 0 confirmed, 129 mitigations, 3 actors, 1 entitlements, 202 flows, 2 features +874 annotations, 20 assets, 16 threats, 14 controls, 127 exposures, 0 confirmed, 129 mitigations, 3 actors, 1 entitlements, 202 flows, 2 features ### Block Freshness -- `annotation_hash`: `sha256-v3:4a7d0ba07ea80e6802ab0660b05f4541d06265ffe1cc3d1d817bebb549433253` +- `annotation_hash`: `sha256-v3:95e447ec72ce4924c563d83aae0d3e594f518789e71d00ded358ac3255cf772a` 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`.