Install and annotate one function
Install GuardLink, annotate a single function, and finish with a threat model that validates clean in CI.
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:
// @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:
npx guardlink ci . --strictParse diagnostics: 0Unmitigated exposures: 1 (critical 1)Confirmed exploits: 0Acceptances: 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.
npm install --save-dev guardlink, Node.js 18 or newer.annotate, and it does so by
handing a prompt to a coding agent you already have.@mitigates, @exposes, @transfers, and
@accepts come from
ThreatSpec (2015 to 2020).Four commands, run in the repository you want annotated:
npm install --save-dev guardlinknpx guardlink init .npx guardlink annotate "annotate the routes" . # hands the work to your coding agentnpx 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.
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.
// @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.
Annotate a real repository
Vocabulary first, then the seams, then a gate that catches drift.
Give an agent the model
Wire the MCP server into a coding agent: 28 tools and 3 resources over stdio.
Read the specification
Four conformance levels, a CC-BY-4.0 grammar, and a ten-year lineage.
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:
guardlink ci without --strict before the gate does, and read
what it newly reports..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..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:
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.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.
| 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:
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.ci gains --severity critical,high, --scope services/api,
and --min-coverage <percent> in this release for exactly this case.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.
| 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.
| 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 |