Skip to content

GuardLink

GuardLink is a threat model you write beside the code, and a command that checks it. The whole of the idea is one line above a function:

src/routes.js
// @exposes #receipts to #idor [critical] cwe:CWE-639 -- "No ownership check"
app.get('/receipts/:id', async (req, res) => {
const receipt = await db.query('SELECT * FROM receipts WHERE id = $1', [req.params.id])
res.json(receipt)
})

A person wrote that line. Git dates it and attributes it, it appears in the pull request that changes the handler, and it moves with the function when the function moves. Until someone writes the @mitigates that closes it, every run says so:

Terminal window
npx guardlink ci . --strict
Parse diagnostics: 0
Unmitigated exposures: 1 (critical 1)
Confirmed exploits: 0
Acceptances: 0 (source: @accepts annotations in this repository)
Annotation coverage: 100% — 2 of 2 source file(s) annotated, 3 annotation(s)
Anchor drift: 0 (no anchored @source blocks to check)
Stale claims: none recorded — run `guardlink verify --all` to start tracking
⚠ 1 unmitigated exposure(s):
#receipts → #idor [critical] (src/routes.js:1)

Exit code 1. That is the gate. Without --strict the same command reports the same findings and exits 0, so a repository does not fail its build on the day the annotations land.

  • MIT licensed. Free to use, including commercially. LICENSE.
  • Installs from npm. npm install --save-dev guardlink, Node.js 18 or newer.
  • No account, no API key, no server. guardlink reads and writes files on your disk. The one command that reaches further is annotate, and it does so by handing a prompt to a coding agent you already have.
  • An open specification, not only a tool. GuardLink v1.0.0 is published under CC-BY-4.0 with four conformance levels, and this CLI is Level 4 conformant. See the open specification.
  • The grammar is not new. @mitigates, @exposes, @transfers, and @accepts come from ThreatSpec (2015 to 2020).

Four commands, run in the repository you want annotated:

Terminal window
npm install --save-dev guardlink
npx guardlink init .
npx guardlink annotate "annotate the routes" . # hands the work to your coding agent
npx guardlink ci .

Install GuardLink runs the same loop against a project you build in about three minutes, and write your first annotation finishes with a threat model, a markdown report, and a CI check that passes.

guardlink annotate is the fastest route from an unannotated repository to a populated model, and it is the one command here that does not run on its own: it builds a prompt carrying the grammar, your definitions, and your project’s rules, then hands that prompt to a coding agent you already have. --stdout prints the prompt instead, so you can read exactly what would be sent before anything is. Annotate an existing codebase covers it in full.

GuardLink: AI-Powered Continuous Threat Modeling as a Code walks through the loop end to end on a real repository.

What it found on a repository nobody had annotated

Section titled “What it found on a repository nobody had annotated”

Bugb ran guardlink with a coding agent against vuln-node.js-express.js-app, a deliberately vulnerable Express application carrying 37 documented vulnerability types. In 6 minutes, with no human intervention:

Measure Result
Annotations written 143, across 6 route files
Distinct threats identified 29, with CWE mappings
Unmitigated exposures recorded 66, each with file:line precision
Known vulnerabilities detected 27 of 37, so 73% recall, or 81% counting partial matches
Architecture recovered 8 assets, 3 data flows, a Mermaid diagram with a risk heat map
Token cost About $0.50 in Haiku tokens

That is the project’s own measurement, published in guardlink’s README, not an independent benchmark, and recall is measured against that application’s own documented vulnerability list. It is quoted here because it is the clearest statement of what the annotate loop produces unattended, and a reader deciding whether to spend an afternoon on this should be able to see it.

A threat model written as a document is accurate on the day it is signed off and decays from then on: the code moves, the document does not, and nobody finds out until an incident. Annotations decay too, but they decay visibly. When an anchored block no longer sits on the symbol it names, guardlink ci reports the drift, guardlink reanchor repairs the ones whose symbol still exists, and what is left needs a human precisely because the function it described is gone. See why annotations live in code.

Under the hood guardlink reads a small annotation language called GAL. You declare assets, threats, controls, and actors once in .guardlink/definitions.js, each with an #id, and every claim references those ids rather than repeating their names. That split is what makes the model checkable: a claim referencing #idor either resolves to a declared threat or guardlink validate fails on it.

.guardlink/definitions.js
// @asset App.Receipts (#receipts) -- "Customer payment receipts"
// @threat Insecure_Direct_Object_Reference (#idor) [critical] cwe:CWE-639 -- "A user reads another user's record by changing an id"

npx guardlink gal prints the whole language with an example per verb, which is the fastest reference while you are writing annotations. The annotation language explains how the verbs relate.

Once the annotations are in place, one model feeds several consumers.

flowchart TB
    SRC["annotations in your source<br/>@exposes, @mitigates, @confirmed"] --> P["guardlink parse"]
    P --> M["the threat model<br/>.guardlink/model.json"]
    M --> CI["guardlink ci<br/>open exposures, drifted anchors"]
    M --> EX["sarif, report, dashboard, diff"]
    M --> AG["MCP server<br/>an agent reads and extends it"]

    class M emphasis
Output Command For
The model as JSON guardlink parse Anything that wants to read the model directly
A CI gate guardlink ci Failing a build on unmitigated exposures and drifted anchors
SARIF 2.1.0 guardlink sarif GitHub Advanced Security, VS Code
SARIF for a test run guardlink sarif --profile pentest A scanner: covered exposures and boundary claims as well as open ones
One answer from the model, as JSON guardlink lookup A script or a CI step asking one question, such as "unentitled reaches"
A report with a Mermaid diagram guardlink report A human reading the model
An interactive HTML dashboard guardlink dashboard Browsing the model and its diagrams
What changed since a git ref guardlink diff Reviewing a pull request
What you are doing Reach for Why
Recording what a piece of code puts at risk @exposes and @mitigates in source The statement is dated by git and attributable, and it moves with the code
Gating a build on the model guardlink ci Advisory by default, and --strict makes it fail
Finding annotations that drifted off their symbol guardlink reanchor Reports @source blocks whose file:line no longer holds the symbol they name
Feeding a code-scanning pipeline guardlink sarif Standard SARIF, so existing tooling reads it without adapters
Declaring what an embedded LLM agent can reach @agents, @effects and @gates in source guardlink lookup "unentitled reaches" lists every capability the code hands out that no @entitles approved. See the annotation language
Handing the model to a coding agent guardlink mcp An agent reads and extends the model instead of inferring one
Testing whether an exposure is real cxg pentest guardlink states the hypothesis, cxg tries to confirm it at runtime
Reading the model from your own code The library API Ten subpath exports, from the published TypeScript declarations

Three things surprise people. Each one is covered in full elsewhere.

Install and annotate one function

Install GuardLink, annotate a single function, and finish with a threat model that validates clean in CI.

Install GuardLink

Give an agent the model

Wire the MCP server into a coding agent: 28 tools and 3 resources over stdio.

Wire up the MCP server

Read the specification

Four conformance levels, a CC-BY-4.0 grammar, and a ten-year lineage.

The open specification

These pages describe GuardLink 2.2.0, released 2026-10-07 and the current release on npm. Every command shown was run against that version and its real output pasted in.

Each of the three references is produced from the installed package: its --help output, its shipped .d.ts files, and its running MCP server. None of them can claim a behaviour the release does not have.

Reference Covers
CLI reference Both executables, guardlink and guardlink-mcp
API reference Every subpath export, from the published TypeScript declarations
MCP tool reference Every tool and resource the MCP server registers
What Where Licence
The CLI and library Bugb-Technologies/guardlink MIT
The published package guardlink on npm MIT
The specification text docs/SPEC.md CC-BY-4.0

If guardlink ci, validate or sarif gates a pipeline, expect more open exposures, not fewer. A @mitigates written on one handler used to clear the same asset and threat on every sibling handler in the file. It now covers an exposure in the same file only when its handler encloses the exposure’s handler: the same function, or a class around the method. A mitigation in a module header, or in another file, covers as before.

Measured on one file with three handlers: @exposes #orders to #idor on get_order and on delete_order, and @mitigates #orders against #idor on update_order between them. Under 2.1.0, guardlink ci . prints “✓ No unmitigated exposures, no confirmed exploits, no anchor drift, every acceptance accounted for.” Under 2.2.0 it names both:

⚠ 2 unmitigated exposure(s):
#orders → #idor [high] (orders.py:4)
#orders → #idor [high] (orders.py:15)

Each of those exposures was already written down, and was being hidden.

Things to do once, in this order:

  1. Run guardlink ci without --strict before the gate does, and read what it newly reports.
  2. Regenerate committed artifacts. Every .guardlink/graph/**/*.mmd carries a generator: guardlink@<version> header, so guardlink validate . --artifacts reads them stale until guardlink artifacts . runs. The annotation hash version did not change.
  3. Upgrade every writer of a shared .guardlink/hypotheses.json together. A ledger that records a boundary outcome reads as corrupt to 2.1.0, which then refuses to write it rather than dropping the entry.

Two changes reach consumers of the exports:

  • Some SARIF results carry route_candidates instead of a route. A result used to take the first route declared in its file. It now takes the route on its own handler, else its file’s, else its asset’s inbound route, and says which in properties.route_attribution. When the model does not tie a finding to one route, the result carries route_attribution: "ambiguous" and route_candidates, and no codegraph_reachability. Read that as “test each of these”, not “no route”. The routes it replaces were guesses.
  • A GitHub code-scanning upload now files alerts with a security-severity band, and without an explicit category it files them under guardlink/threat-model/.

For library users, one TypeScript change is not purely additive. AnnotationVerb gained 'agents', 'reaches', 'effects' and 'gates', and the Annotation union gained ReachesAnnotation, EffectsAnnotation and GatesAnnotation. Code that reads verb or compares it with a literal is unaffected. An exhaustive switch with a never default stops compiling until it handles the four verbs or adds a default branch. See use GuardLink as a library.

No command, flag, library subpath, JSON field or SARIF result was removed or renamed, and the schema ids consumers pin to are unchanged.

What else 2.2.0 added
Addition What it answers
@agents, @reaches, @effects, @gates What an embedded LLM agent, or another principal, can invoke, what the code it reaches does, and who approves it. See the annotation language
guardlink lookup The guardlink_lookup query forms from the CLI, as JSON. --fail-on-found exits 1 when the answer is not empty
reach_analysis in the model JSON Unentitled reaches and the gating of each mutating effect, computed once so consumers do not reimplement the join
@boundary from <outer> to <inner> Which side of a boundary is outside. between, and and | stay undirected
guardlink sarif --profile pentest Covered exposures, boundary claims and agent reach, appended after the default results. See feeding findings into cxg
guardlink hypothesis boundaries Each declared @boundary with its tested state, recorded with hypothesis support and hypothesis contradict
guardlink validate --code-graph Declared boundaries checked against an installed code graph. Warnings only
guardlink report --agents The Agents and LLM Reach section as a document of its own
A written definition of @flows Every form the parser accepts, with a conformance corpus shipped as guardlink/conformance/flows.json

2.1.0 can turn a green guardlink ci --strict red, and nothing in your code got worse. Two parser defects discarded annotations before they reached the model — silently, with validate printing its all-clear over them — and both are fixed. Annotations your team had already written are now read, so exposure counts and coverage both go up.

What was dropped What happens now
Doc comments. The parser stripped one comment marker and required @ next, so /// @exposes arrived as / @exposes. That covered Rust /// and //!, Doxygen, C#, Swift and Dart doc comments, the opening line of a /** */ block, and ##, ;;, %%, ---, -- | and ''' Read
Most of the languages whose comment styles the parser recognised were never opened, because the file walk listed only a few of their extensions 73 source extensions are walked. Measured here with one identical, correctly commented @exposes per extension: 2.0.0 read 34 of the 73, 2.1.0 reads all 73
An annotation on a line with no comment marker at all Reported as a warning instead of discarded in silence

Measured on identical fixtures: one unmitigated @exposes in a Rust /// doc comment. guardlink ci . --strict exits 0 under 2.0.0, printing “✓ No unmitigated exposures, no anchor drift.”; under 2.1.0 it exits 1 and names the exposure at src/users.rs:3. The source tree is byte-identical across both runs. The same annotation in a .php file gives the same pair of answers.

Three things to do once, in this order:

  1. Run guardlink ci without --strict before the gate does. The new findings are claims your own team wrote. Read them before a pipeline fails on them.
  2. Narrow the gate rather than removing it if there are more than you want to block on today. ci gains --severity critical,high, --scope services/api, and --min-coverage <percent> in this release for exactly this case.
  3. Regenerate committed artifacts once. ANNOTATION_HASH_VERSION is 3 and ARTIFACT_SCHEMA_VERSION is 2, so every committed .guardlink/ artifact and every synced agent instruction block reads stale on the first run after the upgrade. guardlink artifacts . and guardlink sync clear it; guardlink validate . --artifacts says which are outstanding.

Nothing needs migrating: no command and no flag was removed, and the schema ids consumers pin to are unchanged.

What else 2.1.0 added
Command What it answers
guardlink verify Records that the code beneath each claim was checked, so a later change to it shows up as a stale claim
guardlink lint Whether the annotations meet the evidence bar: exposures that name code and are paired, severities in band, evidence on @confirmed
guardlink hypothesis What happened when an exposure was tested — confirmed, refuted, untested, or due for a retest
guardlink paths Source-to-sink flow paths with no control on them, derived from @flows with no LLM
guardlink blame Who introduced, declared and fixed each claim, read from git
guardlink estate Estate-wide open risk, read back from what guardlink merge --json wrote

The major version is scoped to the TypeScript type surface and the threat-model JSON schema. No CLI command, flag, or output behaviour was removed, so CLI and MCP users upgrade with no migration.

What 2.0.0 changed
Change Affects
coverage reshaped to {annotation_count, coverage_percent} Anything reading total_symbols, annotated_symbols, or unannotated_critical. See the artifact page
Model schema 1.1.0 to 1.2.0, report schema 1.0.0 to 1.1.0 guardlink merge warns on a mixed-version set and normalises the old shape
UnannotatedSymbol removed from the type surface TypeScript consumers
New unknown-verb diagnostic A misspelled verb now warns instead of vanishing
Every diagnostic carries a machine-readable code Consumers classifying diagnostics. Twelve codes are defined
Version reporting corrected SARIF tool.driver.version reported 1.4.3 regardless of the installed version. It now reports the real one
guardlink-mcp --help and --version Both previously started the server and blocked
parse --no-pretty Compact single-line JSON, which had no flag before
report --format <bogus> now exits 1 Previously wrote nothing and exited 0