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.0.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. 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 . guardlink review — 2 unmitigated exposure(s)
[1/2] #reports → #scrape [medium] File: src/reports.js:1 Exposure: "No rate limit on export"
(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]. 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.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✓ Accepted ent-analyst.reports.scrape — recorded for Ada Lovelace; 2 line(s) written to src/reports.js↻ Synced 8 agent instruction file(s)The name is substituted here. In a real run it is your git identity, which is the whole point: the decision is attributable to a person.
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-08-20 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, "status": "accepted", "decision": { "status": "accepted", "by": "Ada Lovelace", "at": "2026-08-20" }}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.

