Skip to content

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.

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.

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.

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.

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

The policy: no unmitigated exposure may sit on an asset that handles PII.

scripts/pii-gate.mjs
#!/usr/bin/env node
import { 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:

Terminal window
node scripts/pii-gate.mjs
critical #users → #sqli src/users.js:5
1 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"
Terminal window
node scripts/pii-gate.mjs
0 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.

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.

scripts/reachable-sarif.mjs
#!/usr/bin/env node
import { 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.length
run.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.

Terminal window
node scripts/reachable-sarif.mjs
1 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.

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.