Review and accept risk
Some risks are not going to be fixed. Some callers are meant to have the access they have. Both are decisions rather than facts, and GuardLink keeps them separate from everything an agent or a scanner may write.
This page covers the two commands that record a decision: guardlink review,
which walks the open exposures, and guardlink entitle, which decides proposed
@entitles claims. Everything here was run against GuardLink 2.2.0.
Who is allowed to decide what
Section titled “Who is allowed to decide what”A tool can find an exposure. It cannot decide the exposure is acceptable, and it cannot decide a caller was always meant to have that power. Those are the two statements that close a finding or excuse one, so both are reserved.
flowchart TB
subgraph AGENT["an agent or a scanner may"]
direction LR
A1["find an exposure"] --> A2(["record it with<br/>@exposes"]) --> A3["propose an entitlement"]
end
subgraph HUMAN["only a person at a terminal may"]
direction LR
H1["accept a risk"] --> H2(["guardlink review<br/>guardlink entitle"]) --> H3["written under<br/>their own name"]
end
AGENT ~~~ HUMAN
class H3 emphasis
The MCP server enforces the first half of this directly.
guardlink_annotate_apply refuses to write @accepts or @entitles at all, and
there is deliberately no guardlink_entitlement_accept tool. The reach verbs
2.2.0 added, @agents, @reaches, @effects and @gates, are agent-writable:
they record what the code can do, not what anyone may do. A reach that no
@entitles covers shows up as an unentitled reach.
See wire the MCP server into a coding agent.
Walk the open exposures
Section titled “Walk the open exposures”guardlink review takes the unmitigated exposures and asks about each one,
worst first:
npx guardlink review . Your name — recorded with every decision. git on this machine says "Ada Lovelace". Enter to sign as that, or type another name:
guardlink review — 2 unmitigated exposure(s), deciding as Ada Lovelace Acceptances shown here are read from @accepts annotations in this repository, not from the server decision log. An annotation is a proposal signed in a commit; the signer is free text and is not verified.
[1/2] #reports → #scrape [medium] File: src/reports.js:1 Exposure: "No rate limit on export" Silences: 1 exposure(s) in this file — in the gate AND in the SARIF a pentest reads
(a) Accept — risk acknowledged and intentional (r) Remediate — mark as planned fix (s) Skip — leave open for now (q) Quit review
Choice [a/r/s/q]:The ordering is severity first, so [medium] came before [low]. It asks who
you are before it asks anything else, because the name goes into every line it
writes. Accepting asks for a justification and refuses an empty one, which is the
point: an @accepts with no reason behind it is worse than an open exposure,
because it looks settled.
With nothing open, it says so and exits:
✓ No unmitigated exposures to review.Off a terminal, decide explicitly
Section titled “Off a terminal, decide explicitly”Piping answers into review does not work, and it is an error rather than a
quiet success:
✗ guardlink review needs a terminal to prompt, and stdin is not one. Piping answers in silently accepted nothing and exited 0 — that is why this is an error. Non-interactively, decide explicitly: guardlink review . --list --format json guardlink review . --accept <id> --by "<name>" --justification "<why>" --until <YYYY-MM-DD> (all three are required — none of them is defaulted; see --help) guardlink review . --from decisions.json--list --format json gives each reviewable exposure an id to address:
{ "id": ".guardlink/annotations/src/reports.js.gal:2:src/reports.js:1:#reports:#scrape", "index": 1, "asset": "#reports", "threat": "#scrape", "severity": "medium", "file": "src/reports.js", "line": 1, "description": "No rate limit on export", "silences": 1}None of --by, --justification or --until is defaulted, and the command
refuses without all three. A name nobody supplied would be a fabricated
signature, and an omitted horizon used to hand out the 365-day ceiling.
npx guardlink review . \ --accept '.guardlink/annotations/src/reports.js.gal:2:src/reports.js:1:#reports:#scrape' \ --by "Ada Lovelace" \ --until 2026-12-31 \ --justification "Export volume is capped at the gateway; a second limit here would duplicate it." Silences: 1 exposure(s) in this file — in the gate AND in the SARIF a pentest reads✓ Accepted: #reports → #scrape [medium] — recorded for Ada Lovelace, expires 2026-12-31; 2 line(s) written to .guardlink/annotations/src/reports.js.galReview complete: 1 accepted. 2 annotation line(s) written.↻ Synced 8 agent instruction file(s)Two lines land, and the second is the receipt:
@source file:src/reports.js line:1 symbol:exportAll@exposes #reports to #scrape [medium] -- "No rate limit on export"@accepts #scrape on #reports by "Ada Lovelace" until 2026-12-31 -- "Export volume is capped at the gateway; a second limit here would duplicate it."@audit #reports -- "Accepted via guardlink review on 2026-10-08 by Ada Lovelace, expires 2026-12-31"--from decisions.json applies a batch of the same shape. guardlink ci warns
when a counting acceptance is within fourteen days of its until date, and fails
under --strict once it lapses and the exposure comes back.
Decide an entitlement
Section titled “Decide an entitlement”An entitlement answers a different question from acceptance. Not “we are living with this risk”, but “this caller was always meant to be able to do this”. That is why it is the one annotation nobody writes by hand: an over-broad entitlement closes a real privilege escalation as by-design.
The flow has two halves, and they are meant to be performed by two different parties.
An agent proposes
Section titled “An agent proposes”npx guardlink entitle --propose \ --actor "#analyst" --capability export-reports \ --asset "#reports" --threat "#scrape" \ --file src/reports.js --line 1 \ --rationale "By design: analysts export for their own account. Authz: src/reports.js:1"✓ Filed proposal ent-analyst.reports.scrape in /path/to/project/.guardlink/entitlement-proposals.json Nothing was written to source. A human accepts it with: guardlink entitle . --accept ent-analyst.reports.scrapeNothing reached the source. The proposal sits in a ledger, and the command says so and names the step that would change that.
npx guardlink entitle --list1 entitlement proposal(s):
ent-analyst.reports.scrape #analyst → export-reports on #reports [proposed]A person accepts
Section titled “A person accepts”npx guardlink entitle . --accept ent-analyst.reports.scrape --by "Ada Lovelace"✓ Accepted ent-analyst.reports.scrape — recorded for Ada Lovelace; 2 line(s) written to src/reports.js↻ Synced 8 agent instruction file(s)--by is required and is not defaulted. Leaving it off used to fall back to the
local git identity, and the command now refuses rather than sign for whoever
happens to own the checkout:
✗ Pass --by "<name>" — an entitlement carries a human's name, and this used to fall back to the local git identity, which signs a privilege grant for someone who never saw it.Two lines land next to the code:
export function exportAll(userId) {// @entitles #analyst to export-reports on #reports against #scrape -- "By design: analysts export for their own account. Authz: src/reports.js:1"// @comment -- "Entitlement accepted by Ada Lovelace on 2026-10-08 via guardlink entitle (proposal ent-analyst.reports.scrape)." return db.query('SELECT * FROM reports WHERE user = $1', [userId])}The @entitles is the claim. The @comment is the receipt: who, when, and
which proposal it came from. And the ledger keeps the decision beside the
proposal:
{ "id": "ent-analyst.reports.scrape", "actor": "#analyst", "capability": "export-reports", "canonical_capability": "export_reports", "asset": "#reports", "threat": "#scrape", "rationale": "By design: analysts export for their own account. Authz: src/reports.js:1", "citation": { "file": "src/reports.js", "line": 1, "raw": "src/reports.js:1" }, "inert": false, "target": { "file": "src/reports.js", "line": 1 }, "proposed_by": "cli", "proposed_at": "2026-10-08", "updated_at": "2026-10-08", "status": "accepted", "warnings": [], "decision": { "status": "accepted", "by": "Ada Lovelace", "at": "2026-10-08", "written_to": "src/reports.js:1" }, "history": [ { "status": "accepted", "by": "Ada Lovelace", "at": "2026-10-08", "written_to": "src/reports.js:1" } ]}One entry in the proposals array of that file; history keeps every decision
the proposal has carried, so a reversal does not erase what preceded it.
What makes an entitlement count
Section titled “What makes an entitlement count”Three things, and the model tracks each of them:
| Requirement | Checked by | If missing |
|---|---|---|
Cites the authorization code by file:line |
findInertEntitlements(model) |
inert: true. The claim is carried and visible but cannot demote a finding |
Names both on <asset> and against <threat> |
findImpreciseEntitlements(model) |
It joins nothing, so it applies to nothing |
| Names an actor somebody declared | findUndeclaredActors(model) |
A dangling reference |
An entitlement never closes a finding and never stops anything being tested. It informs the recommendation a downstream tool makes, and it is absent from the SARIF export entirely. See the annotation language.
Related
Section titled “Related”- The annotation language covers what closes a finding and what only records context.
- Wire the MCP server into a coding agent covers the tools an agent gets, and the two it deliberately does not.
guardlink reviewandguardlink entitledocument every flag.

