Annotate an existing codebase
You will annotate a four-file service you did not write, get validate to a
state you can defend, commit the generated artifacts, and prove the CI gate
catches a refactor that moves annotated code.
The order matters more than the volume. Annotating file by file produces a model full of near-duplicate ids that nothing joins on. This guide does vocabulary first, then seams, then coverage.
Before you start
Section titled “Before you start”- GuardLink installed. See Install GuardLink. Everything below is 2.2.0.
- git, to see the drift check do something.
- A repository you can commit to. The example builds one.
Build something to annotate
Section titled “Build something to annotate”If you have a real repository, use it and skip to Initialize, since the shape of the work is the same. Otherwise:
-
Create the project.
Terminal window mkdir legacy-apicd legacy-apigit initnpm init -ynpm install --save-dev guardlink -
Write four files: an HTTP layer, two modules with security-relevant behaviour, and a database helper.
src/routes.js import { findUserByEmail } from './users.js'import { redeemRefreshToken } from './tokens.js'export async function handleGetUser(req, res) {res.json(await findUserByEmail(req.query.email))}export async function handleRefresh(req, res) {const userId = redeemRefreshToken(req.body.token)res.json({ userId })}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/tokens.js import crypto from 'node:crypto'const REFRESH_TOKENS = new Map()export function issueRefreshToken(userId) {const token = crypto.randomBytes(32).toString('hex')REFRESH_TOKENS.set(token, { userId })return token}export function redeemRefreshToken(token) {const record = REFRESH_TOKENS.get(token)if (!record) return nullREFRESH_TOKENS.delete(token)return record.userId}src/db.js export async function query(sql) {return { sql, rows: [] }}
Initialize
Section titled “Initialize”npx guardlink init . --agent claudeFor a repository with other contributors, keep the default external mode.
Annotations land in .guardlink/annotations/ and no source file is touched, so
the first pull request is additive and reviewable on its own. Inline mode edits
every file you annotate, a defensible choice but not one to make in the same
change as the annotations themselves.
See the size of the job
Section titled “See the size of the job”npx guardlink status .GuardLink Status: legacy-api────────────────────────────────────────Files scanned: 5 Files annotated: 0 Files unannotated: 4Annotations: 0Verified claims: none recorded (run guardlink verify --all)────────────────────────────────────────Truncated after the first block. The rest is one count per verb, all 0 for
now.
Five scanned, four unannotated: the fifth is .guardlink/definitions.js, which
init wrote.
npx guardlink unannotated .⚠ 4 source file(s) with no annotations: src/db.js src/routes.js src/tokens.js src/users.jsWrite the vocabulary first
Section titled “Write the vocabulary first”Every @asset, @threat and @control goes in .guardlink/definitions.js,
each with a #id, and nothing else declares one. That split, declarations in one
place and claims referencing them, is what makes the model checkable at all. See
the annotation language. Doing this before the
relationships is what stops you from ending up with #user, #users and
#user-record describing the same thing.
Three questions, in this order:
- What is worth protecting? Those are the assets. Aim at the granularity
you would name in an incident review, so
App.Usersrather than every function. - What could go wrong to each? Those are the threats. Give each one a CWE if there is an obvious one; it is what a downstream scanner joins on.
- What already stands in the way? Those are the controls. Only ones that exist. A control you plan to build is not a control.
// @asset App.API (#api) -- "HTTP surface"// @asset App.Users (#users) -- "User records: id, email, phone"// @asset App.Tokens (#tokens) -- "Refresh tokens issued at login"
// @threat SQL_Injection (#sqli) [critical] cwe:CWE-89 -- "Unsanitized input reaches a SQL query"// @threat Token_Replay (#replay) [high] cwe:CWE-294 -- "A captured refresh token is redeemed more than once"
// @control Parameterized_Queries (#prepared-stmts) -- "Queries bind parameters instead of interpolating"// @control Single_Use_Tokens (#single-use) -- "A refresh token is deleted on redemption"Severity on the @threat is inherited by any @exposes that omits its own, so
setting it once here covers most cases.
Annotate the seams
Section titled “Annotate the seams”Not every file. The places where trust changes: input arrives, data is read or written, a credential is handled, a boundary is crossed. Four verbs carry this guide; the GAL reference has the other seventeen.
-
Start with the routes. Route annotations are the highest-value ones in the file, because a
@flowswhose mechanism is writtenMETHOD./pathis what tells a downstream scanner where to send traffic. A blank line separates one@sourceblock from the next..guardlink/annotations/src/routes.js.gal @source file:src/routes.js line:4 symbol:handleGetUser@flows #api -> #users via GET./api/users@source file:src/routes.js line:8 symbol:handleRefresh@flows #api -> #tokens via POST./api/refresh -
Then the risk you can see.
findUserByEmailinterpolates a caller-supplied string into SQL and returns two pieces of personal data..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" -
Then the risk that is already handled.
redeemRefreshTokendeletes the record before returning, so replay is answered. Write both halves: the@exposesrecords that this code path is where replay would happen, and the@mitigatesrecords what answers it..guardlink/annotations/src/tokens.js.gal @source file:src/tokens.js line:12 symbol:redeemRefreshToken@exposes #tokens to #replay@mitigates #tokens against #replay using #single-use -- "The record is deleted before the user id is returned"Recording only the mitigation loses the reason it exists, and the next person to simplify the function has nothing telling them what they are removing.
-
Leave
src/db.jsalone. It is a passthrough. An annotation on it would restate whatusers.jsalready says, and an asset that means nothing is worse than an uncovered file.
Validate, and read what it says
Section titled “Validate, and read what it says”npx guardlink validate .⚠ 1 unmitigated exposure(s): #users → #sqli [critical] (src/users.js:3)
⚠ Annotations do not leave this repository: 6 annotations are written in `.gal` sidecars and `.guardlink/report.json` does not exist. GuardLink reads those sidecars; nothing downstream of it does — a consumer with no export falls back to scraping inline source comments, and a sidecar is not one. Every surface outside this CLI currently sees zero exposures and zero acceptances for this repository. Fix: guardlink parse . -o .guardlink/report.json
Validation passed with 1 unmitigated exposure(s).Exit 0. That unmitigated exposure is the correct state of this repository: the
SQL injection is real and nothing mitigates it. validate fails on annotations
that are broken, not on risks that are open.
The second warning is the tax external mode charges. Only guardlink reads
.gal sidecars; run guardlink parse . -o .guardlink/report.json once, and in CI
alongside the report, so the dashboard and the gate see what you wrote. See
Where annotations go.
If it reports diagnostics
Section titled “If it reports diagnostics”All four kinds, from one deliberately broken sidecar:
@source file:src/db.js line:1 symbol:query@flow #api -> #users via GET./api/users@exposes@exposes #users to #nonexistent@feature still claims to describe the model⚠ .guardlink/annotations/src/db.js.gal:2: Unknown annotation verb @flow — did you mean @flows? Unrecognised verbs are discarded silently, so this line contributes nothing to the model. → @flow #api -> #users via GET./api/users✗ .guardlink/annotations/src/db.js.gal:3: Malformed @exposes annotation: could not parse arguments (looks structural — found no arguments at all) → @exposes⚠ src/db.js:1: Dangling reference: #nonexistent is never defined
Lines that look like prose, not annotations (1) — these do not fail validation: .guardlink/annotations/src/db.js.gal:5: @feature still claims to describe the model Each begins with a GuardLink verb but carries no #reference, no `--` delimiter, and none of that verb's grammar keywords, so it was read as prose and not parsed. If one IS an annotation, it is missing its arguments. If it is documentation, wrap it in @shield:begin / @shield:end.
1 error(s), 3 warning(s)
⚠ 2 unmitigated exposure(s): #users → #nonexistent [unset] (src/db.js:1) #users → #sqli [critical] (src/users.js:3)
⚠ Annotations do not leave this repository: 7 annotations are written in `.gal` sidecars and `.guardlink/report.json` does not exist. GuardLink reads those sidecars; nothing downstream of it does — a consumer with no export falls back to scraping inline source comments, and a sidecar is not one. Every surface outside this CLI currently sees zero exposures and zero acceptances for this repository. Fix: guardlink parse . -o .guardlink/report.jsonExit 1, because of the one error. The dangling @exposes still counts as an
open exposure, so a typo in an id never hides a risk.
| Diagnostic | Level | What to do |
|---|---|---|
unknown-verb |
warning | Fix the typo. The line contributes nothing until you do. |
malformed-annotation |
error | Real syntax error. It has structure, a #ref, a spaced --, or one of that verb’s own keywords, so it was meant to be an annotation. |
prose-like |
warning | Starts with a verb name but has no structure, so it was read as a sentence. Wrap deliberate examples in @shield:begin / @shield:end. |
dangling-ref |
warning | The #id has no definition. Add it, or fix the reference. |
The unknown-verb warning is new in 2.0.0. Before it, @flow produced no
annotation and no diagnostic, so a typo was indistinguishable from a line you
never wrote.
You can turn the warnings off per code in .guardlink/config.json:
{ "diagnostics": { "unknown-verb": false } }Only warnings are suppressible. Listing an error-level code is accepted and
ignored. With malformed-annotation disabled, the error still prints and
validate still exits 1.
Fix the anchors before you commit
Section titled “Fix the anchors before you commit”npx guardlink ci .Parse diagnostics: 0Unmitigated exposures: 1 (critical 1)Confirmed exploits: 0Acceptances: 0 (source: @accepts annotations in this repository)Annotation coverage: 80% — 4 of 5 source file(s) annotated, 13 annotation(s)Anchor drift: 1 (moved 1) of 4 anchor(s)Stale claims: none recorded — run `guardlink verify --all` to start tracking
⚠ 1 unmitigated exposure(s): #users → #sqli [critical] (src/users.js:3)
⚠ 1 drifted @source block(s): [moved] `.guardlink/annotations/src/tokens.js.gal` anchors to `redeemRefreshToken` at `src/tokens.js:12`, but `redeemRefreshToken` is now at line 11.
Advisory — nothing here failed the build. Run with --strict to gate on it.A line number counted by hand is usually off by one. moved means the symbol
still exists somewhere else in the file, which guardlink can repair:
npx guardlink reanchor . --apply1 drifted @source block(s):
[moved] `.guardlink/annotations/src/tokens.js.gal` anchors to `redeemRefreshToken` at `src/tokens.js:12`, but `redeemRefreshToken` is now at line 11.
✓ Re-anchored 1 file(s): .guardlink/annotations/src/tokens.js.galNow status reflects the whole job:
GuardLink Status: legacy-api────────────────────────────────────────Files scanned: 5 Files annotated: 4 Files unannotated: 1Annotations: 13Consumers read: nothing — 6 sidecar annotation(s), and .guardlink/report.json does not exist. Run: guardlink parse . -o .guardlink/report.jsonVerified claims: none recorded (run guardlink verify --all)────────────────────────────────────────Assets: 3Threats: 2Controls: 2Mitigations: 1Exposures: 2Truncated after Exposures. The remaining rows count the other verbs.
Commit the artifacts
Section titled “Commit the artifacts”npx guardlink artifacts .git add -Agit commit -m "add guardlink threat model"artifacts writes .guardlink/model.json and .guardlink/graph/: three
whole-model Mermaid diagrams, a slice per asset under by-asset/, a manifest, a
README and an index. For this project that is twelve files. They are committed on purpose: a fresh clone has
the model without running anything, and a reviewer sees model changes as a diff.
init already wrote the .gitattributes that marks them generated and tells you
to regenerate rather than hand-merge on a conflict.
threat-graph.mmd is the one worth opening first. It is plain Mermaid, so it
renders anywhere Mermaid does, including here. This is what the annotations above
produce, pasted unedited:
%%{init: {"flowchart": {"nodeSpacing": 55, "rankSpacing": 150, "curve": "monotoneX", "htmlLabels": false, "padding": 24}}}%%
graph LR
tokens["🔷 App.Tokens"]
users["🔷 App.Users [PII]"]
replay["🟠 Token_Replay (cwe:CWE-294)"]:::threat
sqli["🔴 SQL_Injection (cwe:CWE-89)"]:::threat
single_use["🛡️ Single_Use_Tokens"]:::control
tokens -. exposes .-> replay
users -. exposes .-> sqli
single_use -- mitigates --> replay
single_use -. protects .-> tokens
classDef threat fill:#3a1010,stroke:#ea1d1d,color:#f0f0f0,stroke-width:1.3px
classDef control fill:#102a24,stroke:#33d49d,color:#f0f0f0,stroke-width:1.3px
Two things are worth reading off it. App.Users carries a [PII] tag, which
came from the @handles pii line rather than from anything you told the diagram.
And SQL_Injection has nothing pointing at it, while Token_Replay has
Single_Use_Tokens pointing at it. That gap is the unmitigated exposure, drawn.
The %% header above the diagram is trimmed here for length. In the real file it
carries the annotation_hash the diagram was generated from, so
guardlink validate . --artifacts can tell you a diagram is stale rather than
letting you trust a picture of a model that has moved on.
Prove the gate does something
Section titled “Prove the gate does something”A threat model that has only ever been run against the state it was written for has not been tested. Move the annotated code and see whether anything notices.
-
Insert a line above
findUserByEmail.src/users.js import { query } from './db.js'const COLUMNS = 'id, email, phone'export async function findUserByEmail(email) {return query(`SELECT ${COLUMNS} FROM users WHERE email = '${email}'`)} -
Run the gate.
Terminal window npx guardlink ci .Parse diagnostics: 0Unmitigated exposures: 1 (critical 1)Confirmed exploits: 0Acceptances: 0 (source: @accepts annotations in this repository)Annotation coverage: 80% — 4 of 5 source file(s) annotated, 13 annotation(s)Anchor drift: 1 (moved 1) of 4 anchor(s)Stale claims: none recorded — run `guardlink verify --all` to start tracking⚠ 1 unmitigated exposure(s):#users → #sqli [critical] (src/users.js:3)⚠ 1 drifted @source block(s):[moved] `.guardlink/annotations/src/users.js.gal` anchors to `findUserByEmail` at `src/users.js:3`, but `findUserByEmail` is now at line 5.Advisory — nothing here failed the build. Run with --strict to gate on it. -
Confirm what does not notice, because this is the part worth internalising.
Terminal window npx guardlink diff HEADParsing current threat model...Parsing threat model at HEAD...No threat model changes detected.Terminal window npx guardlink validate . --artifacts✓ Artifacts are current.✓ Artifacts are drawable.The claims did not change, so the model did not change, so the hash did not move and the committed diagrams are still accurate. Only
ciandreanchorlook at anchors. Drift is a defect in where the annotation points, not in what it says, and the two checks are separate because the fixes are. -
Repair it.
Terminal window npx guardlink reanchor . --apply✓ Re-anchored 1 file(s): .guardlink/annotations/src/users.js.gal
And prove the artifact check does something
Section titled “And prove the artifact check does something”Now change a claim rather than a line. Add the mitigation you would write once the query is fixed:
@mitigates #users against #sqli using #prepared-stmts -- "Rewritten to bind the email parameter"npx guardlink validate . --artifacts⚠ Annotations do not leave this repository: 7 annotations are written in `.gal` sidecars and `.guardlink/report.json` does not exist. GuardLink reads those sidecars; nothing downstream of it does — a consumer with no export falls back to scraping inline source comments, and a sidecar is not one. Every surface outside this CLI currently sees zero exposures and zero acceptances for this repository. Fix: guardlink parse . -o .guardlink/report.json
✓ All annotations valid, no unmitigated exposures — in this repository. See above for what consumers of it see.
⚠ 10 artifact issue(s): .guardlink/graph/threat-graph.mmd — STALE built from: sha256-v3:e9fef760833ce95e93bc672951ff0f24694b243af8db9c2337173e67cd3e4929 model is: sha256-v3:17a41e0fbbb02cb53129f74437e92fb4b13914c2f8c117942e2781964e2c7afc .guardlink/graph/dataflow.mmd — STALE built from: sha256-v3:e9fef760833ce95e93bc672951ff0f24694b243af8db9c2337173e67cd3e4929 model is: sha256-v3:17a41e0fbbb02cb53129f74437e92fb4b13914c2f8c117942e2781964e2c7afc .guardlink/graph/attack-surface.mmd — STALE built from: sha256-v3:e9fef760833ce95e93bc672951ff0f24694b243af8db9c2337173e67cd3e4929 model is: sha256-v3:17a41e0fbbb02cb53129f74437e92fb4b13914c2f8c117942e2781964e2c7afcTruncated after the third of ten. The other seven are the by-asset/ slices,
model.json and MANIFEST.json, each with the same pair of hashes, and the
output ends:
Regenerate with: guardlink artifacts . Never hand-edit an artifact to silence this — the hash describes the annotations.✓ Artifacts are drawable.Exit 1. And diff now has something to report:
Threat Model Diff: 1 change(s) +1 added -0 removed ~0 modified
✓ 1 exposure(s) resolved — risk decreased
── Resolved Exposures ── ✓ #users → #sqli (src/users.js:3)
── Mitigations ── + #users ← #prepared-stmts against #sqliRun guardlink artifacts . to bring the diagrams back in line.
Wire it into CI
Section titled “Wire it into CI”Three commands, in increasing strictness. Start at the top and move down as the repository earns it.
npx guardlink validate . --artifacts # exit 1 on syntax errors, dangling refs, stale artifactsnpx guardlink ci . # always exit 0 — reports every check belownpx guardlink ci . --strict # exit 1 when any of them finds something to gate onci checks parse diagnostics, unmitigated exposures, reproduced @confirmed
exploits, unqualified acceptances, drifted @source anchors and stale claims,
and prints annotation coverage under them. --severity and --scope narrow what
it gates on; --min-coverage <percent> is the one check that gates without
--strict, because a repository with nothing written down passes every other
check by having nothing to fail.
ci is advisory by default for a reason: a repository annotated last week
has unmitigated exposures by construction, and a gate that fails the
build on the day the annotations land is a gate someone deletes the same week.
--strict is what you turn on after reaching zero, to stay there.
guardlink diff <base> --fail-on-new is the middle ground, exiting 1 only
when a change introduces an unmitigated exposure, so existing debt does not
block anything. --markdown renders the same diff for a pull-request comment.
A workflow you can copy
Section titled “A workflow you can copy”Save this as .github/workflows/guardlink.yml. It runs the three commands above
and publishes the exposures to your repository’s Security tab.
name: GuardLink
on: pull_request: branches: [main] push: branches: [main]
permissions: contents: read security-events: write
jobs: guardlink: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 with: fetch-depth: 0
- uses: actions/setup-node@v4 with: node-version: '20'
- run: npm ci
# Fails the job on syntax errors, dangling refs, or stale artifacts. - name: Validate annotations run: npx guardlink validate . --artifacts
# Advisory. Swap for `npx guardlink ci . --strict` once you reach zero. - name: Report exposures and drift run: npx guardlink ci .
- name: Export SARIF if: always() run: npx guardlink sarif . -o guardlink.sarif
- name: Upload SARIF if: always() uses: github/codeql-action/upload-sarif@v3 with: sarif_file: guardlink.sarifnpm ci assumes guardlink is a dev dependency, which is what
the install page sets up. For a
repository that does not use npm, replace it with npm install -g guardlink and
drop the npx.
Two fuller examples ship in the package’s own repository, and they are the ones to start from if you want the pull-request diff comment or a multi-repository rollup:
| Example | What it adds |
|---|---|
examples/github-action.yml |
A threat-model diff posted as a pull-request comment, plus a coverage summary |
examples/ci/ |
A per-repository report workflow and a scheduled workspace merge, for several services at once |
Link repositories into a workspace covers what the second one assembles.
Hand the bulk to a coding agent
Section titled “Hand the bulk to a coding agent”For a large repository, writing every sidecar by hand is the wrong use of your
time. guardlink annotate builds a prompt carrying the annotation grammar, your
definitions, and the project’s rules, and hands it to an agent:
npx guardlink annotate "annotate the database layer" . --stdout--stdout prints the prompt for piping. --claude-code, --codex and
--gemini launch those CLIs in the foreground; --cursor, --windsurf and
--clipboard put the prompt on the clipboard.
Review what it writes the way you would review any other change. An agent
proposes claims; validate checks they are internally consistent; only you know
whether they are true.
The MCP server is the better path for an agent that is already in your editor. It can read the model and write sidecars through a validated API instead of generating text. See Wire the MCP server into a coding agent.
What to change next
Section titled “What to change next”- Annotate the boundaries.
@boundary #api and #users (#edge)records where trust changes, and it is what makes the data-flow diagram readable. Write@boundary from Client to #api (#edge)when you know which side is outside: the SARIF export then states that side as declared rather than inferred. - Tag features.
@feature "Login"letsguardlink report . --feature Loginnarrow the report to one slice. - Record ownership.
@owns platform-team for #usersputs a name on who reviews changes to an asset. - Read the rest of the grammar. The GAL reference
lists twenty-one verbs with an example each, generated from
guardlink gal. The four reach verbs for an embedded agent are on the annotation language. - Export for a scanner.
guardlink sarif .turns the open exposures into findings other tools read. See Feeding findings into cxg.
Related
Section titled “Related”- Why annotations live in code covers the trade this page made for you, including what guardlink does not check.
- The threat model as an artifact covers what you committed, and what identifies a version of it.
guardlink ciandguardlink validatedocument every flag on the two commands this guide gates on.

