Skip to content

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.

  • 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.

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.

  1. Create the project and install guardlink into it.

    Terminal window
    mkdir guardlink-quickstart
    cd guardlink-quickstart
    npm init -y
    npm install --save-dev guardlink

    npm install -g guardlink also works and gives you a bare guardlink command. The rest of this page uses npx guardlink, which resolves the project-local copy.

  2. Confirm it runs.

    Terminal window
    npx guardlink --version
    2.0.0

    A version number rather than an error means both binaries are on the path: guardlink, and guardlink-mcp for the MCP server.

  3. 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: [] }
    }

    findUserByEmail interpolates 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.

Terminal window
npx guardlink init . --agent claude
Detected: javascript project "guardlink-quickstart"
Created: .guardlink/
Created: .guardlink/config.json
Created: .guardlink/definitions.js
Created: .guardlink/README.md
Created: .guardlink/prompt.md
Created: docs/GUARDLINK_REFERENCE.md
Created: .gitignore
Created: .gitattributes
Created: CLAUDE.md
Created: .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, @threat and @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/

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.

Assets, threats, and controls are declared once, in .guardlink/definitions.js, each with a #id. Everything else references those ids. Append to the file:

.guardlink/definitions.js
// @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 P0P3. 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.

Create the sidecar. The path mirrors src/users.js with .gal appended:

.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 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:

Terminal window
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.

Fix the query, then say so in the model.

  1. 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])
    }
  2. 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 @exposes stays. It is the record that this code path handles untrusted input at all, and deleting it would delete the reason the control exists.

  3. Validate again.

    Terminal window
    npx guardlink validate .
    ✓ All annotations valid, no unmitigated exposures.
  4. Run the CI gate.

    Terminal window
    npx guardlink ci . --strict
    Unmitigated exposures: 0
    Anchor drift: 0 of 1 anchor(s)
    ✓ No unmitigated exposures, no anchor drift.

    Exit code 0. Without --strict, ci reports the same findings and always exits 0, 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.

Terminal window
npx guardlink report .
✓ Wrote threat model report to threat-model.md

threat-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.0

For the machine-readable form, and for diagrams you commit:

Terminal window
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:8bed94a125e3e3ed533cd5acd7936a1e985e7e3c886fbf4a0f06c30994308278
generated_at: 2026-08-13T07:54:10.906Z
git_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 . --artifacts

Your 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 gal prints every verb.
  • Dangling reference: #x is never defined. The #id has no @asset, @threat, or @control behind it in .guardlink/definitions.js. Definitions live there in both annotation modes.
  • Malformed @exposes annotation: could not parse arguments, and validate exits 1. 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 status says 0 annotations. Check the sidecar path. .guardlink/annotations/src/users.js.gal mirrors the source path exactly, including the source file’s own extension.
  • is not at the conventional path. The .gal file is somewhere else. It is still parsed, since this is a warning rather than a refusal, but nothing will look for it there.