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 ten 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, SARIF_PROFILES, isSarifProfile, and the pentest profile’s rule ids You are exporting findings
guardlink/report generateReport, generateAgentReachReport, emitAgentReach, 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
guardlink/structure parseStructure, languageForExtension, GRAMMARS, EXTENSION_LANGUAGE, ANCHOR_HASH_VERSION You are resolving a @source anchor to a symbol yourself
guardlink/blame computeBlame, attachBlame, buildBlamePayload, formatBlameText, attributeCommit, and the rules behind them You are asking git who introduced, declared or fixed a claim
guardlink/hypothesis readHypotheses, recordOutcome, recordBoundaryOutcome, classifyHypotheses, classifyBoundaryClaims, rankUntested, importScan, and the renderers You are recording or reading what happened when an exposure or a boundary was tested

Verified against 2.2.0 by importing each subpath and counting its runtime exports: 136, 104, 6, 5, 6, 6, 4, 6, 20 and 26 symbols respectively, in the table’s order. 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 ten subpaths resolve under "moduleResolution": "nodenext", verified against 2.2.0 with TypeScript 5.9.3, tsc --noEmit, importing every one.

Keep skipLibCheck: true, which is the default a scaffolded tsconfig.json gives you. With it off, the same project fails on a transitive dependency’s own declarations rather than on anything guardlink ships:

node_modules/web-tree-sitter/web-tree-sitter.d.ts(160,39): error TS2304: Cannot find name 'EmscriptenModule'.

AnnotationVerb gained 'agents', 'reaches', 'effects' and 'gates', and the Annotation union gained ReachesAnnotation, EffectsAnnotation and GatesAnnotation. Code that reads verb, or compares it with a literal, is unaffected. An exhaustive switch that lists every 2.1.0 verb and ends in a never default stops compiling:

verbs.ts(29,13): error TS2322: Type '"agents" | "reaches" | "effects" | "gates"' is not assignable to type 'never'.
Type '"agents"' is not assignable to type 'never'.

Handle the four verbs, or add a default branch that does not assert never. DiagnosticCode gained members too, among them agent-reach-conflict and unresolved-boundary-side.

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
findUnentitledReaches(model) Which @agents or @reaches no cited @entitles covers
findUngatedEffects(model) Which mutating @effects no @gates covers
findUnresolvedBoundarySides(model) Which directed @boundary names a side that resolves to nothing

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 1 finding(s) are HTTP-reachable → whitebox/findings.sarif
gl-5cc827267186 GET /api/users (#sqli)

The one open finding in this project carries a route, so nothing is dropped. A finding on an asset that no @flows reaches by a route would count in the second number and not the first. Reporting the count you dropped, rather than writing a shorter file in silence, is what makes that visible.

Since 2.2.0 a finding the model does not tie to one route carries route_candidates and no codegraph_reachability, so this filter drops it too. If you want those tested, keep results where r.properties?.route_candidates is set as well. generateSarif also takes profile: 'pentest' in its options, the same export as guardlink sarif --profile pentest.

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-v3: identity every artifact carries, so you can cache on it. ANNOTATION_HASH_VERSION is the format prefix.