Skip to content

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.

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.

guardlink review takes the unmitigated exposures and asks about each one, worst first:

Terminal window
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.

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.

Terminal window
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.scrape

Nothing reached the source. The proposal sits in a ledger, and the command says so and names the step that would change that.

Terminal window
npx guardlink entitle --list
1 entitlement proposal(s):
ent-analyst.reports.scrape #analyst → export-reports on #reports [proposed]
Terminal window
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:

src/reports.js
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:

.guardlink/entitlement-proposals.json
{
"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" }
}

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.