Use GuardLink as a library
You will write two Node scripts against GuardLink’s API: one that fails a build on a policy no CLI flag expresses, and one that reshapes the SARIF export before it reaches a scanner. Both run against the project from Annotate an existing codebase.
Reach for the library when the CLI answers a different question from the one
you have. guardlink ci --strict fails on any unmitigated exposure; it cannot
fail on “any unmitigated exposure against an asset that handles personal data”.
That distinction is four lines of JavaScript over a parsed model.
Before you start
Section titled “Before you start”- GuardLink installed in the project. See Install GuardLink.
- Node.js 18 or newer.
- A project with annotations to read. The annotate guide builds one.
The package is ESM only ("type": "module"). Use import, and name your
scripts .mjs unless your own package.json already declares
"type": "module". There is no CommonJS build and require('guardlink') will
not work.
The seven entry points
Section titled “The seven entry points”package.json declares seven subpath exports. That is the semver contract:
anything you reach by a deeper path is internal.
| Subpath | What is behind it | Reach for it when |
|---|---|---|
guardlink |
The root barrel: everything in /parser, plus report, diff, SARIF, CI, workspace merge, and every type |
You want one import and do not care about surface area |
guardlink/parser |
parseProject, parseFile, parseString, parseLine, the find* predicates, feature filters, hashes, .gal path helpers, the annotation writer |
You are reading or querying a model. Most work starts here |
guardlink/analyzer |
generateSarif |
You are exporting findings |
guardlink/report |
generateReport, generateMermaid, generateSequenceDiagram |
You are rendering markdown or diagrams |
guardlink/diff |
diffModels, formatDiff, formatDiffMarkdown, parseAtRef, getCurrentRef, getChangedFiles |
You are comparing two models, or a model against a git ref |
guardlink/init |
initProject, detectProject, syncAgentFiles, promptAgentSelection, resolveAgentFiles, AGENT_CHOICES |
You are scaffolding guardlink into a repository from your own tool |
guardlink/mcp |
createServer, startStdioServer, lookup, suggestAnnotations |
You are embedding the MCP server in another process |
Verified against 2.0.0 by importing each subpath and counting its runtime exports: 65, 46, 1, 3, 6, 6, and 4 symbols respectively. The API reference is generated from the published TypeScript declarations and carries the signature of every one of them.
TypeScript
Section titled “TypeScript”Types ship with the package. All seven subpaths resolve under
"moduleResolution": "nodenext", verified with TypeScript 5.9.3, tsc --noEmit
exit 0, importing every one.
Read the model
Section titled “Read the model”Everything starts with parseProject, which walks a directory and returns the
model plus the diagnostics it produced getting there:
import { parseProject } from 'guardlink/parser'
const { model, diagnostics } = await parseProject({ root: '.' })ParseProjectOptions takes root (required), and optional project, include
and exclude. The glob options are the programmatic escape hatch for the scan
set. The include and exclude keys in .guardlink/config.json are read by
nothing, so this is the only way to change which files are scanned.
The predicates are the reason to use the library rather than reading
.guardlink/model.json yourself. Each of these is the single implementation the
rest of the product uses, so a script built on them cannot disagree with
validate about the same model:
| Function | Answers |
|---|---|
findUnmitigatedExposures(model) |
Which @exposes has no @mitigates or @accepts covering its pair |
findDanglingRefs(model) |
Which #id resolves to nothing |
findAcceptedExposures(model) |
Which risks were explicitly accepted |
findAcceptedWithoutAudit(model) |
Which acceptances have no @audit behind them |
findUndeclaredActors(model) |
Which @entitles names an actor nobody declared |
findInertEntitlements(model) |
Which entitlements cite no authorization code |
findImpreciseEntitlements(model) |
Which entitlements omit on or against, so they join nothing |
findAnchorDrift(root, model) |
Which @source blocks point at code that moved |
findOffConventionGalFiles(model) |
Which .gal sidecars are not where the convention puts them |
Write a policy gate
Section titled “Write a policy gate”The policy: no unmitigated exposure may sit on an asset that handles PII.
#!/usr/bin/env nodeimport { parseProject, findUnmitigatedExposures, findDanglingRefs } from 'guardlink/parser'
const { model, diagnostics } = await parseProject({ root: '.' })
// Refuse to judge a model that did not parse cleanly. An exposure list from a// broken model is a list of the annotations that happened to survive.const blocking = [...diagnostics.filter((d) => d.level === 'error'), ...findDanglingRefs(model)]if (blocking.length) { for (const d of blocking) console.error(`${d.level}: ${d.file}:${d.line} ${d.message}`) process.exit(2)}
const piiAssets = new Set( model.data_handling.filter((h) => h.classification === 'pii').map((h) => h.asset),)
const open = findUnmitigatedExposures(model)const offenders = open.filter((e) => piiAssets.has(e.asset))
for (const e of offenders) { const { file, line } = e.location console.log(`${e.severity ?? 'unset'}\t${e.asset} → ${e.threat}\t${file}:${line}`)}
console.log(`${offenders.length} of ${open.length} open exposure(s) sit on a PII-handling asset.`)process.exit(offenders.length ? 1 : 0)Three exit codes rather than two: 2 means the model could not be judged, which
is a different failure from 1, the policy being violated. A gate that reports
“clean” because parsing fell over is the failure mode worth spending an if on.
Run it against the annotated project:
node scripts/pii-gate.mjscritical #users → #sqli src/users.js:51 of 1 open exposure(s) sit on a PII-handling asset.Exit 1. Now add the mitigation to .guardlink/annotations/src/users.js.gal:
@mitigates #users against #sqli using #prepared-stmts -- "Rewritten to bind the email parameter"node scripts/pii-gate.mjs0 of 0 open exposure(s) sit on a PII-handling asset.Exit 0. Both directions checked, because a gate that has only ever been run against a
failing model has not been tested.
Reshape the SARIF export
Section titled “Reshape the SARIF export”guardlink sarif gives you --min-severity and --no-diagnostics. If you want
a different cut, generateSarif is a pure function over the model and you can
filter what it returns.
The useful cut for a downstream scanner: only findings that carry an HTTP
route, since a finding with nothing but a file path gives a black-box scanner
no way to reach the code. That field, codegraph_reachability, is derived from
your @flows. See
Feeding findings into cxg.
#!/usr/bin/env nodeimport { mkdir, writeFile } from 'node:fs/promises'import { dirname } from 'node:path'import { parseProject, findDanglingRefs } from 'guardlink/parser'import { generateSarif } from 'guardlink/analyzer'
const OUT = 'whitebox/findings.sarif'
const { model, diagnostics } = await parseProject({ root: '.' })const sarif = generateSarif(model, diagnostics, findDanglingRefs(model), { includeDiagnostics: false, includeDanglingRefs: false,})
const run = sarif.runs[0]const before = run.results.lengthrun.results = run.results.filter((r) => r.properties?.codegraph_reachability)
await mkdir(dirname(OUT), { recursive: true })await writeFile(OUT, JSON.stringify(sarif, null, 2))
console.log(`${run.results.length} of ${before} finding(s) are HTTP-reachable → ${OUT}`)for (const r of run.results) { const { http_method, http_path } = r.properties.codegraph_reachability console.log(` ${r.properties.threatId} ${http_method} ${http_path} (${r.properties.threat})`)}generateSarif(model, diagnostics, danglingRefs, options) takes four arguments.
The two diagnostic arrays are separate because they come from different places,
the parser and the validator, and either can be excluded from the output through
options instead of by passing an empty array.
node scripts/reachable-sarif.mjs1 of 2 finding(s) are HTTP-reachable → whitebox/findings.sarif gl-5cc827267186 GET /api/users (#sqli)Two findings, one route. The dropped one is an exposure on the database asset,
which has no inbound @flows carrying a route. That is real, and not something an HTTP
probe can reach. Reporting the count you dropped, rather than silently writing a
shorter file, is what makes that visible.
Other things worth knowing
Section titled “Other things worth knowing”Diffing two models. diffModels(before, after) returns a ThreatModelDiff;
formatDiff renders it for a terminal and formatDiffMarkdown for a pull-request
comment. parseAtRef(root, ref) gives you the model as it was at a git ref, and
getChangedFiles(root, ref) the file list, so you can narrow a check to what
actually changed.
Rendering. generateReport(model) returns the whole markdown report as a
string, with no I/O, so you decide where it goes. generateMermaid and
generateSequenceDiagram return single diagrams.
Writing annotations. applyAnnotations writes a validated @source block
into a .gal sidecar, and applyReanchor repairs drifted anchors. Both re-parse
what they are about to write. Prefer them over string-concatenating a file, and
prefer the MCP server’s guardlink_annotate_apply over both if the caller is an
agent.
Hashes. computeAnnotationHash(model) gives you the same
sha256-v2: identity every artifact carries, so you can cache on it.
ANNOTATION_HASH_VERSION is the format prefix.
Related
Section titled “Related”- The threat model as an artifact
describes the shape of what
parseProjectreturns. - Wire the MCP server into a coding agent serves the same model over a protocol, for a caller that is not your code.
- API reference lists every exported symbol, with its signature and JSDoc.

