| BREAKING |
-
The coverage block lost two fields and renamed a third.
// before
"coverage": { "total_symbols": 0, "annotated_symbols": 105,
"coverage_percent": 100, "unannotated_critical": [] }
// after
"coverage": { "annotation_count": 105, "coverage_percent": 100 }
What you observe. Reading coverage.total_symbols or coverage.unannotated_critical from guardlink parse, guardlink report --format json, .guardlink/model.json, the guardlink://model resource, or the guardlink_parse / guardlink_status MCP tools now yields undefined. Reading coverage.annotated_symbols yields undefined; the number moved to coverage.annotation_count. In TypeScript you get TS2339: Property 'total_symbols' does not exist on type 'CoverageStats' — and you do not have to import CoverageStats to hit it: reading the field off a parseProject result fails through every one of the seven published subpaths, and hand-constructing a ThreatModel fails with TS2352.
A consumer that only calls functions — parseProject, generateReport, diffModels, generateSarif — and never touches .coverage compiles and runs unchanged.
Why they are gone rather than deprecated. total_symbols was always 0 and unannotated_critical was always []. They were constants in a schema presented as public, so nothing downstream could tell not computed from computed, and the answer is zero. Absent says the first; 0 and [] said the second, and three separate consumers believed them — one dashboard rendered "0% coverage" on a fully annotated project, and a merged workspace reported 0% when both its repos reported 89%. GuardLink does no per-symbol parsing, so these fields were never going to be filled in. annotated_symbols is now annotation_count because the old name is what invited the division in the first place.
Migration. Use annotation_count for the annotation total, and coverage_percent for coverage — noting that it counts files, not symbols and not annotations. The model version moves 1.1.0 → 1.2.0 to mark the change.
-
UnannotatedSymbol is deleted. It existed only to type coverage.unannotated_critical. What you observe: TS2305: Module '"guardlink"' has no exported member 'UnannotatedSymbol'.
-
AnnotationVerb gained 'actor' and 'entitles'. The union behind Annotation['verb'] widened from 20 members to 22, because @actor and @entitles are new verbs — see Added.
What you observe: nothing, if you read verb or compare it against a literal. If you switch over it exhaustively with an assertNever-style never default, that default no longer compiles:
error TS2322: Type '"actor" | "entitles"' is not assignable to type 'never'.
Add a default branch, or handle the two new verbs. This is the one breaking change that reaches ordinary reading code rather than only code that constructs our types: Annotation has been exported since 1.x, and an exhaustive switch over a verb union is the natural way to write a renderer or a linter over it.
-
Three returned interfaces gained required fields. InitResult gained preserved: string[]; DiffSummary gained staleEntitlements: number; ThreatModelDiff gained actors: Change<ThreatModelActor>[], entitlements: Change<ThreatModelEntitlement>[] and staleEntitlements: StaleEntitlement[] — note that the last is the list, while DiffSummary.staleEntitlements is its count.
What you observe: nothing, if you call initProject or diffModels and read the result — that is what these types are for, and reading is unaffected. If you construct one of them — a test fixture, a mock, an adapter that adapts some other tool's output into our shape — the object literal is now incomplete:
error TS2741: Property 'preserved' is missing in type '{ … }' but required in type 'InitResult'.
error TS2739: Type '{ … }' is missing the following properties from type 'ThreatModelDiff': actors, entitlements
Add the fields — [] for every list and 0 for the count are the correct empty values. They are required rather than optional because they are always populated on a real result, and an optional field would push a ?? [] into every consumer that reads them.
-
REPORT_SCHEMA_VERSION moved 1.0.0 → 1.1.0, and mixed-version merges now warn.
What you observe, as a CLI user: merging reports written by different GuardLink versions prints a line that never appeared before.
⚠ Reports use different schema versions: 1.0.0, 1.1.0. Results may be inconsistent.
It is advisory. The merge still succeeds and still exits 0. Regenerate the older reports with guardlink report to clear it.
What you observe, in TypeScript: the constant's type is the string literal, so const v: '1.0.0' = REPORT_SCHEMA_VERSION stops compiling.
The bump is not cosmetic. The mismatch check compares this value across the reports being merged; leaving it at 1.0.0 would have made the coverage reshape invisible to the one mechanism built to notice exactly that.
-
guardlink_graph replaced traversal.truncated with traversal.completeness.
What you observe: the boolean traversal.truncated is absent from the MCP response. In its place, traversal.completeness is one of complete (nothing more to find at any depth), depth_limited (correct for the depth you asked for — raise depth for more) or truncated (the depth-10 ceiling cut it short; the result is incomplete and raising depth will not help). When it is not complete, a new frontier_unexplored: { count, nodes } names what lies one hop past the boundary.
The boolean conflated "I stopped because a limit was hit" with "I stopped because there was nothing left to reach": one asset reported truncated: true at depth 1 and false at depth 2 on an identical 4 nodes and 6 edges. It was removed rather than aliased, because a faithful alias would have to keep reproducing the wrong answer, and a familiar name with changed meaning is worse than a missing one.
|
| Added |
-
guardlink ci — advisory CI checks in one command. Runs the two checks GuardLink already performs, against one parse of the model, and reports both: unmitigated exposures, and @source anchors that have drifted off the symbol they name.
guardlink ci [dir] [-p <project>] [-f text|json] [--strict]
Advisory by default: exit 0 even with findings. A repo that has just adopted GuardLink has unmitigated exposures by construction, and a gate that fails the build the day the annotations land is a gate that gets deleted the week after. --strict is the single opt-in and exits 1 if either check found anything.
--format json emits a stable document under the schema id guardlink.ci/v1:
{ "schema": "guardlink.ci/v1",
"exposures": [ /* the parser's own exposure records, unrenamed */ ],
"drift": [ /* anchor drift records */ ],
"summary": { "exposures": 15, "drift": 0, "anchors": 0,
"by_severity": { "critical": 0, "high": 3, "medium": 6, "low": 6, "unset": 0 },
"by_kind": { "moved": 0, "symbol_gone": 0, "file_gone": 0, "line_gone": 0 },
"strict": false, "exit_code": 0 } }
The exit code is a pure function of (strict, exposures, drift) and is carried in the summary, so a JSON consumer sees the same verdict the shell got. No new detection logic was written: both checks call the same predicates validate and reanchor use, so ci cannot disagree with them about the same model. It reports and never repairs — applying a re-anchor is deliberately not reachable from here.
-
@actor and @entitles — the principal, and the capability held by design. Two verbs answer the question the model had no field for: is the caller already entitled to this effect? @actor Namespace_Admin (#ns-admin) declares a principal in the authorization model — a role, not a person, and distinct from @owns, which names a responsible team. @entitles #ns-admin to configure-archival-destination on #archival-fs -- "… Authz: common/api/metadata.go:189" records that the privilege required to trigger an effect is a privilege that already grants it.
An entitlement is the only annotation whose failure mode is a silent false negative, so three constraints are enforced rather than documented:
- It never gates testing, only reporting. Unlike
@mitigates and @accepts, @entitles has no export semantics: the exposure stays unmitigated, stays in the SARIF, stays testable. A regression test asserts that SARIF for a model with entitlements is byte-identical to the same model without them.
- No citation, no effect. An entitlement whose description carries no
file:line pointer to the authorization code is inert — parsed and carried so a reviewer can see the claim, flagged by guardlink validate, and ignored by consumers. guardlink diff reports an entitlement whose cited file changed as stale rather than removed.
- It cannot answer an ownership question. For IDOR and tenant-isolation classes both peers hold the capability, so an entitlement cannot say whose object it was. Ownership stays measured and is deliberately absent from the grammar.
<capability> must be a single normalised identifier — prose there is a parse error, because it is the join key consumers match on. Surfaced through guardlink status, report, the dashboard, guardlink diff, guardlink sync, and MCP. Purely additive: a model with neither verb parses and exports exactly as before.
-
guardlink entitle and a proposal ledger. An agent proposes; a human accepts. guardlink entitle --propose --actor … --capability … --file … --line … --rationale … writes only to .guardlink/entitlement-proposals.json and never to source. Acceptance is what writes the annotation, under the name of the person who accepted. An @entitles in source with no accepted proposal behind it is a validation error. Also available to agents as guardlink_entitlement_propose / guardlink_entitlement_list; accepting is not.
-
guardlink migrate --to external|inline. Moves a project's annotations between source comments and .guardlink/annotations/*.gal sidecars. Annotation text is moved verbatim rather than re-serialised from parsed objects, and only annotation lines leave the source file, so the round trip reproduces the original file rather than an equivalent one. Every run re-parses and compares the model's content hash before and after and exits non-zero if it moved — the hash excludes exactly the fields a migration may legitimately change, so a moved hash means the threat model itself changed. --dry-run reports without writing. Nothing but this command ever moves an annotation; existing repos are never migrated implicitly.
-
guardlink reanchor, and MCP guardlink_reanchor. Finds @source blocks whose recorded file:line no longer holds the symbol they name — the drift external annotations accumulate after a refactor. Reports four distinct kinds (moved, symbol_gone, file_gone, line_gone) and proposes a corrected line only where the symbol was found elsewhere. --apply moves those; a renamed or deleted symbol is always left to a human, because there is no correct line to move it to.
-
MCP guardlink_annotate_apply. Writes a validated @source block into a file's sidecar — into .guardlink/, never into source — after re-parsing every line with the real parser. Idempotent, returns a diff, invalidates the parse cache, and refuses @accepts and @entitles: both are human governance decisions, and a tool that can write one lets an agent close a finding by declaring it acceptable.
-
A freshness envelope on every MCP tool result. Each response now carries a second content block naming the model it was computed from, so an agent can tell a cached answer from a current one without asking:
{ "guardlink": { "annotation_hash": "sha256-v2:69d3fe…", "git_sha": "95dab747…",
"generated_at": "2026-08-12T16:10:33.393Z", "mode": "inline",
"root": "/path/to/repo", "guardlink_version": "2.0.0" } }
The same hash appears in the auto-synced block of every agent instruction file, so a block that disagrees with a live tool result is provably out of date. The envelope is applied at tool registration rather than at each return statement, so it covers error branches too.
-
A codified path convention for .gal sidecars. A sidecar belongs at .guardlink/annotations/<source path>.gal — the source path mirrored, with .gal appended. guardlink validate warns about a sidecar that sits somewhere else, and about an on-convention sidecar carrying @source blocks for files other than the one it is named for, which parses fine and is a maintenance trap. Both are warnings, never refusals: an off-convention file still contributes every annotation it carries, because silently dropping a developer's work over a directory choice would be worse than the inconsistency.
-
A warning for an unknown @verb that is close to a real one. @flow and @migitates previously produced neither an annotation nor a diagnostic — the line simply vanished from the model, and the README itself shipped @flow twice. Near misses now warn and name the suggestion:
⚠ app/a.ts:2: Unknown annotation verb @flow — did you mean @flows? …
It cannot fail a build. parse, validate, validate --strict, ci and ci --strict all exit 0 on a file whose only problem is one @flow. Only error-level diagnostics reach SARIF, so it cannot surface in GitHub Advanced Security either.
Three rules keep it quiet in codebases that use @-tags for something else. A token carrying a namespace separator before the verb (@g.comment, @gl:exposes) is treated as a deliberate dialect, not a typo. A 170-entry list of JSDoc, TSDoc, Doxygen, phpDoc and Epydoc tags is excluded by name, so silence on @param is a property of the design rather than an accident of spelling distance. Repeats collapse to one diagnostic per distinct token per file, carrying an occurrence count, so a file with forty of the same typo reports once and still tells you where to start. Measured across four unrelated codebases totalling 13,609 source files: zero false positives.
Switch it off per project in .guardlink/config.json if it still does not suit you:
{ "diagnostics": { "unknown-verb": false } }
Only warnings can be switched off this way. Listing an error-level code is accepted and ignored — quieting noise is a preference, silencing a broken annotation is not.
-
DiagnosticCode is now an exported type, with twelve members. A code was carried on two diagnostic kinds while roughly seven were emitted without one, and the type itself was never exported — so a consumer wanting to treat a dangling reference differently from risk-acceptance hygiene had nothing to match on but the message text, and no name to match it against. Now every kind carries a code: unknown-verb, duplicate-id, dangling-ref, undeclared-actor, inert-entitlement, imprecise-entitlement, accepted-without-audit, off-convention-gal, stray-gal-source and entitlement-provenance join malformed-annotation and prose-like. ParseDiagnostic gains code?: DiagnosticCode.
This is additive, not breaking. Neither DiagnosticCode nor ParseDiagnostic.code appears in 1.4.5's published .d.ts, so no 1.4.5 consumer can have been switching over the type or reading the field. If you adopt the type now and switch over it exhaustively, give the switch a default branch — the set will grow again as new diagnostics are added, and a never assertion over it is a compile error waiting for the next release.
-
guardlink parse --no-pretty. --pretty defaulted to true with no counterpart, so the compact branch was unreachable and --no-pretty was rejected as an unknown option. It now emits the model on one line.
-
guardlink-mcp --help and --version. The binary previously ignored every argument and waited for JSON-RPC on stdin, so guardlink-mcp --version hung instead of answering. Both now print and exit without opening a transport; the no-flag invocation still starts the server exactly as before.
-
The published package now contains src/, and its source maps finally resolve. 1.4.5 already shipped 112 source maps that pointed at nothing, because the .ts files they name were not published — every map in the tarball was dead weight. 2.0.0 publishes the 76 source files alongside the 152 maps, so a stack trace from inside guardlink resolves to the original TypeScript and go-to-definition lands on the source rather than the generated .d.ts.
This is deliberate, and the reason is what the tool is. A security tool asks to be trusted with a threat model; shipping the source it was built from means a consumer can audit what they installed without cloning the repo or trusting a build they did not run. Provenance attestation says the tarball came from this commit — the source in it says what that commit does.
Cost: 228 → 384 files, 405.8 kB → 962.5 kB packed (1.9 MB → 4.1 MB unpacked). Nothing about the runtime changes: dist/ is what main, exports and both bin entries resolve to, exactly as before.
|
| Changed |
-
BEHAVIOUR CHANGE — coverage is decided per site, not per (asset, threat) pair. A @mitigates no longer clears an @exposes when the two are anchored to different symbols in the same file. Everything else is unchanged: a mitigation in a different file still covers the whole asset, an unanchored mitigation still covers the whole asset, and a same-symbol pair still covers.
Why. A correct mitigation on one function was answering for a live vulnerability on another. Reproduced on a Python service with a %-formatted SELECT in one function and a correctly bound INSERT twelve lines below it: the critical injection was missing from unmitigated, guardlink_context reported no open exposures for the file, and the scanner-triage path answered status: "mitigated" for the vulnerable line. A deterministic scanner asking GuardLink about a true positive was told it was handled.
What you will see. For inline projects, nothing at all — inline annotations carry no symbol anchor, so the rule cannot engage. Measured: 0 of 74 exposures change state on this repo, 0 of 61 on a second inline repo of 8,142 files, and 2 of 11 on the external repo where the defect was found. If you author externally with symbol anchors, and you have a mitigation and an exposure on the same asset and threat in one file at different symbols, that exposure will now appear in unmitigated. It was always there; it was not being reported.
How to say "this covers the whole asset". Omit symbol: from the mitigation's @source header. An unanchored statement is an asset-level statement and is never narrowed. No new syntax was added, and none is needed.
What this does not fix. A cross-file mitigation still blanket-covers its asset and threat. Knowing whether a control actually reaches a site needs a call graph, which GuardLink does not have. The rule closes the class where the model already holds the evidence — the author's own anchors — and no more.
-
guardlink report output is deterministic across processes. The report and Mermaid generators canonicalise the model at the emission boundary. Before: three report --diagram-only runs in three processes produced two distinct hashes, differing by whole node blocks, because the file walk returns completion order under concurrency. After: byte-identical. This changes report --diagram-only output — nodes and edges come out in a deterministic order rather than glob order, so a diff against a previously captured diagram shows reordering once. Nothing is added or removed. The full markdown report is unchanged apart from its two Generated: lines.
-
init --mode and init --no-root-files are separate flags, and the default annotation mode is external. --mode external previously meant two unrelated things at once — annotations live in sidecars, and init writes nothing outside .guardlink/ — so asking for the first silently cost you the root .mcp.json, every agent instruction file, and docs/, which are the things that make an agent aware GuardLink exists. --mode inline|external now decides only where annotations live; --no-root-files decides only the footprint and reproduces the old external behaviour. Existing projects keep their recorded mode — init does not rewrite an existing config.json without --force.
-
.guardlink/model.json and .guardlink/graph/ are tracked in git. A fresh clone has the threat model without running anything, and model changes appear in review. .gitattributes marks them as generated. Exports that are rebuilt on demand — threat-model.json, guardlink.sarif.json, threat-dashboard.html — remain ignored.
-
The dashboard lost two sections. The force-directed Risk Topology graph grew unreadably dense on large codebases; the three Mermaid views (Threat Graph, Data Flow, Attack Surface) remain, and the Threat Graph still auto-filters to high/critical with an All severities toggle. The Pentest Findings page and its detail drawers are also gone, and the dashboard no longer embeds raw scan JSON. Pentest ingestion itself is unchanged — scan results still flow into guardlink threat-report as context, and evidence redaction still applies at load time.
-
The package version is resolved in one place. Four separate implementations of "read package.json at runtime" had accumulated, with two different failure fallbacks. All version-reporting surfaces now agree by construction: guardlink --version, guardlink-mcp --version, the TUI header, the MCP serverInfo, the artifact manifest, SARIF tool.driver.version, and the report's metadata.
|
| Fixed |
-
guardlink merge reported "annotation_count": null for any report written by an older GuardLink. Merge reads report JSON from disk, so a 2.x binary meets a 1.4.5 repo's output there; the older field name read as undefined, arithmetic on it produced NaN, and NaN serialises to null. A workspace dashboard reported a null annotation count silently, exit 0, no warning. Reports are now normalised as they are read, and the older field name is carried across rather than replaced with a zero — an old repo keeps its real count.
-
The TUI reported v0.0.0 on any install path containing a space. One of the four version lookups resolved its own location through a URL, which percent-encodes, so the probe missed package.json and the fallback chain bottomed out. guardlink --version beside it reported the truth, which is why it went unnoticed.
-
SARIF reported 1.4.3 as the tool version, regardless of the installed version.
-
guardlink report --format <unrecognised> wrote nothing and exited 0, which is indistinguishable from success. It now names the valid values and exits 1.
-
guardlink tui --model was parsed and never read. It now applies for the session, as --provider and --api-key already did.
-
guardlink status printed unknown as the project name in every repo, including ones whose .guardlink/config.json held the name three lines away. Nothing read it, and it is the first command anyone runs after init. guardlink sync and guardlink entitle were the last two commands still not consulting it and now do.
-
guardlink-mcp did not run. The binary was declared but the module only exported its starter — no shebang, no entry guard — so piping an initialize request at it produced no response at all. The build also now preserves the executable bit on both binaries, without which a fresh install produced a guardlink-mcp that could not be executed.
-
The guardlink entitle --propose example in the generated agent files did not run. CLAUDE.md, AGENTS.md, .gemini/GEMINI.md and .github/copilot-instructions.md are read by coding agents at runtime, and all four carried a worked example missing two required flags, so an agent following it verbatim got an error instead of a proposal.
-
init wrote a .guardlink/README.md whose reference pointer did not exist. The README chose the path by annotation mode while init wrote it by footprint, and under the default those two disagree — which is every fresh repo. The sentence after the broken link is "Read it before inventing syntax", so the one dead pointer in the document was the one aimed at a reader about to guess.
-
init did not create .gitignore when a project had none, only appended to an existing one — so a fresh repo got no entry at all, and guardlink dashboard left threat-dashboard.html both untracked and unignored in exactly the repos least likely to notice.
-
init's "Next steps" contradicted the README it wrote in the same run. Step 2 said "add annotations to your source files" regardless of mode, while the README written beside it says, under the default, that annotations do not go in source files. Step 2 now follows the resolved mode.
-
The MCP write path accepted what it promised to reject. guardlink_annotate_apply documented that malformed input is rejected with a reason, then accepted an undeclared #reference and a source file that does not exist, each with ok: true and no errors — so an invented reference survived the write, the validation and CI. References are now checked against the model's declared ids, using the same rule the dangling-reference check uses.
-
The MCP envelope reported every external project as mixed. Asset, threat and control declarations are structurally inline-only, and counting them as inline evidence meant a correctly configured pure-external repo reported the alarm state. Detection now asks only the relationship verbs, which are the only ones with a genuine choice of home. mixed still fires on a genuinely mixed repo.
-
@shield regions survive migration. The markers delimit a region of source text and mean nothing outside the file whose lines they bracket, so they no longer migrate, and annotations inside a shielded region are no longer extracted. Both were caught by the hash gate: externalising the markers unshielded this repo's own documentation examples, and extracting from inside the region turned 38 examples into real records.
-
guardlink_context told agents the .gal path convention was not codified. It is. The tool now states the convention an agent can act on.
|