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.2.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. 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.

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

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

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.

Terminal window
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.gal
Review complete: 1 accepted. 2 annotation line(s) written.
↻ Synced 8 agent instruction file(s)

Two lines land, and the second is the receipt:

.guardlink/annotations/src/reports.js.gal
@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.

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 --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:

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-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:

.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,
"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.

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.