Install GuardLink
Install GuardLink, annotate one function in a project you build here, and finish
with a parsed threat model, a markdown report, and a CI check that exits 0
because the risk you recorded has a control against it.
Everything on this page was run against GuardLink 2.0.0.
Before you start
Section titled “Before you start”- Node.js 18 or newer. The transcripts below are from Node 24.14.0 with npm 11.9.0.
- A directory you can delete afterwards. The worked example builds its own project, so nothing here touches code you care about.
You do not need a git repository, an API key, or a coding agent. Every command on
this page reads files from disk and writes files to disk. The commands that do
call out to a network, namely guardlink threat-report, ask, and annotate,
are the AI ones, and none of them appears here.
Install, into a project to annotate
Section titled “Install, into a project to annotate”Two source files: one that reads user records out of a database, and the query
helper it calls. Install guardlink as a dev dependency of that project, so the
version is recorded in package.json and CI gets the same one you did.
-
Create the project and install guardlink into it.
Terminal window mkdir guardlink-quickstartcd guardlink-quickstartnpm init -ynpm install --save-dev guardlinknpm install -g guardlinkalso works and gives you a bareguardlinkcommand. The rest of this page usesnpx guardlink, which resolves the project-local copy. -
Confirm it runs.
Terminal window npx guardlink --version2.0.0A version number rather than an error means both binaries are on the path:
guardlink, andguardlink-mcpfor the MCP server. -
Write the code.
src/users.js import { query } from './db.js'export async function findUserByEmail(email) {return query(`SELECT id, email, phone FROM users WHERE email = '${email}'`)}src/db.js export async function query(sql) {return { sql, rows: [] }}findUserByEmailinterpolates a caller-supplied string into SQL and returns two pieces of personal data. Both of those are facts about the code that no type signature records, and both are what you are about to write down.
Initialize
Section titled “Initialize”npx guardlink init . --agent claudeDetected: javascript project "guardlink-quickstart"
Created: .guardlink/Created: .guardlink/config.jsonCreated: .guardlink/definitions.jsCreated: .guardlink/README.mdCreated: .guardlink/prompt.mdCreated: docs/GUARDLINK_REFERENCE.mdCreated: .gitignoreCreated: .gitattributesCreated: CLAUDE.mdCreated: .mcp.json
✓ GuardLink initialized. Next steps: 1. Review .guardlink/definitions.js — remove threats/controls not relevant to your project 2. Add annotations in .guardlink/annotations/<source path>.gal sidecars — NOT in source files (or ask your coding agent to do it) 3. Run: guardlink validate .--agent decides which coding-agent instruction files get written and kept in
sync. It takes a comma-separated list of claude, cursor, codex, copilot,
windsurf, cline, or none. Substitute whichever you use. Without the flag,
an interactive terminal shows a picker.
The project name comes from the directory unless package.json carries a
non-placeholder name.
Directoryguardlink-quickstart/
Directory.guardlink/
- config.json project name, language, annotation mode
- definitions.js every
@asset,@threatand@control - README.md what this directory is, regenerated by
guardlink sync - prompt.md project description, feeds the report
Directorydocs/
- GUARDLINK_REFERENCE.md
- CLAUDE.md live threat-model context for a coding agent
- .mcp.json MCP server registration
Directorysrc/
- …
Where annotations go
Section titled “Where annotations go”init defaults to external mode: annotations live in .gal sidecar files
under .guardlink/annotations/, mirroring the source path, not in the source
files themselves. config.json records that choice as "annotation_mode": "external".
| Source file | Its annotations |
|---|---|
src/users.js |
.guardlink/annotations/src/users.js.gal |
internal/db/query.go |
.guardlink/annotations/internal/db/query.go.gal |
guardlink init . --mode inline puts them in source comments instead. Both
modes parse to the same model, and they differ in what survives a refactor.
Why annotations live in code
covers the trade.
Define the vocabulary
Section titled “Define the vocabulary”Assets, threats, and controls are declared once, in .guardlink/definitions.js,
each with a #id. Everything else references those ids. Append to the file:
// @asset App.Users (#users) -- "User records: id, email, phone"
// @threat SQL_Injection (#sqli) [critical] cwe:CWE-89 -- "Unsanitized input reaches a SQL query"
// @control Parameterized_Queries (#prepared-stmts) -- "Queries bind parameters instead of interpolating"Severity is bracketed and is one of critical, high, medium, low, or
P0–P3. cwe:CWE-89 is an external reference; the scheme:value shape is all
that is required, so owasp:A03:2021 and attack:T1190 work the same way.
Write the first annotation
Section titled “Write the first annotation”Create the sidecar. The path mirrors src/users.js with .gal appended:
@source file:src/users.js line:3 symbol:findUserByEmail@handles pii on #users -- "Returns email and phone"@exposes #users to #sqli [critical] -- "email is interpolated into the SQL string".gal files hold raw annotation lines, with no // prefix. The @source header
anchors every line below it to a real code location: findUserByEmail is on
line 3 of src/users.js. symbol: is optional, and it is what lets guardlink
find the block again after a refactor moves the function.
Check it:
npx guardlink validate .⚠ 1 unmitigated exposure(s): #users → #sqli [critical] (src/users.js:3)
Validation passed with 1 unmitigated exposure(s).Exit code 0. An unmitigated exposure is not an error. Recording a risk before
its control exists is the intended order of work. What validate fails on is a
malformed annotation or a #id that resolves to nothing.
The location it reports is src/users.js:3, not the sidecar. Every consumer of
the model sees the code position, which is why the @source line has to be
right.
Close the loop
Section titled “Close the loop”Fix the query, then say so in the model.
-
Bind the parameter instead of interpolating it.
src/users.js import { query } from './db.js'export async function findUserByEmail(email) {return query('SELECT id, email, phone FROM users WHERE email = $1', [email])} -
Record the control against the threat. Replace the sidecar with:
.guardlink/annotations/src/users.js.gal @source file:src/users.js line:3 symbol:findUserByEmail@handles pii on #users -- "Returns email and phone"@exposes #users to #sqli [critical] -- "email reaches the SQL string"@mitigates #users against #sqli using #prepared-stmts -- "Bound parameter, not interpolation"The
@exposesstays. It is the record that this code path handles untrusted input at all, and deleting it would delete the reason the control exists. -
Validate again.
Terminal window npx guardlink validate .✓ All annotations valid, no unmitigated exposures. -
Run the CI gate.
Terminal window npx guardlink ci . --strictUnmitigated exposures: 0Anchor drift: 0 of 1 anchor(s)✓ No unmitigated exposures, no anchor drift.Exit code
0. Without--strict,cireports the same findings and always exits0, because it is advisory by default so a first-run repository does not fail its build on the day the annotations land.0 of 1 anchor(s)is the line worth reading. Zero drift out of zero anchors is a different statement from zero drift out of one, and only the second means anything was checked.
Take the artifacts
Section titled “Take the artifacts”npx guardlink report .✓ Wrote threat model report to threat-model.mdthreat-model.md is a full report covering scope, architecture, a Mermaid threat
diagram, data inventory, active mitigations, and data classification, built from the
six annotations you wrote. Sections with nothing behind them say so rather than
being omitted, so the gaps are visible. Its header records the version that
produced it:
# Threat Model Report — guardlink-quickstart
> Generated: 2026-08-13T07:54:10.465Z> Files scanned: 3 | Annotations: 6> GuardLink version: 2.0.0For the machine-readable form, and for diagrams you commit:
npx guardlink artifacts .Wrote 6 artifact(s): .guardlink/graph/threat-graph.mmd .guardlink/graph/dataflow.mmd .guardlink/graph/attack-surface.mmd .guardlink/model.json .guardlink/graph/MANIFEST.json .guardlink/graph/README.md
annotation_hash: sha256-v2:8bed94a125e3e3ed533cd5acd7936a1e985e7e3c886fbf4a0f06c30994308278generated_at: 2026-08-13T07:54:10.906Zgit_sha: not a git checkout(the last two are reported here, not written into the files — they would otherwise churn every commit; the files are tracked.)Every .mmd carries that hash in a %% header. Check with: guardlink validate . --artifactsYour annotation_hash will match this one if your annotations match; it is a
hash of the model, not of the run. generated_at and git_sha differ every
time, which is exactly why they are printed rather than written into the files.
.guardlink/model.json is the whole model as JSON, canonically ordered so the
diff is readable:
{ "version": "1.2.0", "project": "guardlink-quickstart", "source_files": 3, "annotations_parsed": 6, "annotated_files": [ ".guardlink/definitions.js", "src/users.js" ], "unannotated_files": [ "src/db.js" ],Truncated after unannotated_files. The file continues with one array per verb
and ends with coverage and external_refs. The @exposes line you wrote is
one entry in the exposures array:
{ "asset": "#users", "threat": "#sqli", "severity": "critical", "external_refs": [], "description": "email reaches the SQL string", "location": { "file": "src/users.js", "line": 3, "parent_symbol": "findUserByEmail", "origin_file": ".guardlink/annotations/src/users.js.gal", "origin_line": 3 }}That is the whole mapping: a verb becomes an array, and each annotation becomes
one object in it. file and line are the code position, origin_file and
origin_line are the sidecar the claim was written in, and parent_symbol is
what lets reanchor find the block again after a refactor moves the function.
External mode records both positions so a consumer can point a developer at the
annotation to edit while pointing a scanner at the code.
The threat model as an artifact
reads the rest of it.
If validate reports something you did not expect
Section titled “If validate reports something you did not expect”In order of likelihood.
Unknown annotation verb @flow, did you mean @flows?The verb is not in the grammar, so the line contributes nothing. guardlink 2.0.0 warns when a bad verb is close to a real one; earlier versions dropped it in silence.npx guardlink galprints every verb.Dangling reference: #x is never defined. The#idhas no@asset,@threat, or@controlbehind it in.guardlink/definitions.js. Definitions live there in both annotation modes.Malformed @exposes annotation: could not parse arguments, andvalidateexits1. The line has structure, a#ref, a--delimiter, or one of that verb’s own keywords, so it was meant to be an annotation and is treated as broken rather than as prose. Descriptions use-- "quoted text", never a colon.- Nothing is reported at all, and
statussays 0 annotations. Check the sidecar path..guardlink/annotations/src/users.js.galmirrors the source path exactly, including the source file’s own extension. is not at the conventional path. The.galfile is somewhere else. It is still parsed, since this is a warning rather than a refusal, but nothing will look for it there.
- Annotate an existing codebase runs the same loop against code you did not write, starting from coverage gaps.
- Wire the MCP server into a coding agent gives an agent 24 tools to read and extend the model instead of guessing.
- Why annotations live in code covers what this buys over a threat model in a document, and what it costs.
- CLI reference has every command, argument, and
option, generated from the installed package.
npx guardlink galprints the annotation grammar.

