Skip to content

Write your first annotation

By the end of this page one command reads a claim you wrote and reports it against a real line of code:

Terminal window
npx guardlink validate .
⚠ 1 unmitigated exposure(s):
#users → #sqli [critical] (src/users.js:3)
Validation passed with 1 unmitigated exposure(s).

Three pieces produce that line: a definition naming what is at stake, an annotation claiming a risk against it, and an anchor tying the claim to src/users.js:3. This page writes all three, then closes the risk and gates a build on it.

It continues the project from Install GuardLink.

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"

Declaring ids once is what makes the model checkable rather than a pile of strings: a later claim referencing #sqli either resolves to that threat or validate fails on it.

Part Shape Notes
Name App.Users Dotted path, your choice
Id (#users) What every other annotation references
Severity [critical] One of critical, high, medium, low, or P0 to P3
External reference cwe:CWE-89 Any scheme:value, so owasp:A03:2021 and attack:T1190 work the same way
Description -- "…" Always -- "quoted text", never a colon

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-09-09T19:52:35.759Z
> 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-09-09T19:52:40.266Z
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.

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

Every command on these two pages reads files from disk and writes files to disk, with no account, no API key, and no server. That is why they make up the onboarding path.

For a repository that already exists, writing every sidecar by hand is the wrong use of your time. guardlink annotate builds a prompt carrying the grammar, your definitions, and your project’s rules, then hands it to a coding agent you already have:

Terminal window
npx guardlink annotate "annotate the database layer" . --stdout

--stdout prints that prompt and exits, so you can read exactly what would be sent before anything is. --claude-code, --codex and --gemini launch those CLIs in the foreground; --cursor, --windsurf and --clipboard put the prompt on the clipboard. guardlink itself makes no network call in any of those modes, and the agent you point it at does.