Skip to content

The annotation language

GAL is a small language with twenty-five verbs. This page is about how they fit together: which ones declare things, which ones make a claim about code, and what has to be true before a finding appears or goes away. For the syntax of each verb, the GAL reference is generated from guardlink gal. In 2.2.0 that quick reference lists twenty-one of them: it does not yet print the four reach verbs, @agents, @reaches, @effects and @gates, which What an agent can reach covers below.

Nouns are declared once, claims reference them

Section titled “Nouns are declared once, claims reference them”

Every verb is one of two kinds.

Declarations name a thing and give it an #id. @asset, @threat, @control, and @actor live together in .guardlink/definitions.js, and they are the vocabulary the rest of the model draws on.

.guardlink/definitions.js
// @asset App.Users (#users)
// @threat SQL_Injection (#sqli) [critical] cwe:CWE-89
// @control Parameterized_Queries (#prepared-stmts)

Claims say something about a specific place in the code, and they reference those ids rather than repeating their names.

.guardlink/annotations/src/users.js.gal
@source file:src/users.js line:3 symbol:findUserByEmail
@exposes #users to #sqli [critical] -- "email reaches the SQL string"

The split is what makes the model checkable. A claim that references #sqli either resolves to a declared threat or it does not, and guardlink validate fails on the second case with a dangling-reference error. Names alone could not do that: two spellings of “SQL injection” would silently be two threats.

@exposes is the verb that creates work. Everything else either closes that work, escalates it, or records a fact beside it.

flowchart TB
    D["@asset, @threat, @control<br/>declared once, each with an #id"] --> E["@exposes<br/>opens a finding"]
    E --> Q{"what is claimed next?"}
    Q -->|"@mitigates"| M["closed, and a control is named"]
    Q -->|"@accepts"| A["closed, and a person owns the decision"]
    Q -->|"@confirmed"| C["still open, now verified exploitable"]
    Q -->|"nothing"| O["still open, and ci --strict fails"]

    class M,A emphasis

Only two verbs close a finding, and they close it for different reasons. @mitigates says a control handles it and names which one. @accepts says a person decided to carry the risk. Both remove it from the open list, and the model keeps the distinction, so “we fixed it” and “we decided not to” never collapse into the same silence.

The @exposes stays in both cases. Deleting it would delete the reason the control exists, and a later reader would have no way to tell a path that was never risky from one that was made safe.

The remaining verbs do not open or close anything. They attach facts that a reader, a report, or a downstream tool needs.

What you are recording Verbs
How data moves, and where trust changes @flows, @boundary
What an embedded agent or another principal can invoke, what the code does, and who approves it @agents, @reaches, @effects, @gates
What an asset holds and who answers for it @handles, @owns, @audit
What you are taking on faith, and what checks a control @assumes, @validates
Who is legitimately allowed to do what @actor, @entitles
Responsibility moved elsewhere @transfers
Grouping, notes, and sensitive code @feature, @comment, @shield

@boundary takes two forms. @boundary between A and B (also A and B, or A | B) is an undirected pair. @boundary from <outer> to <inner> states which side is less trusted, which matters when both sides are declared assets, the usual case for an application and its database. validate fails a directed boundary whose side resolves to no declared asset and no @flows endpoint.

@flows has a written definition in the specification: four endpoint forms, chains such as A -> B -> C read as one record per hop, the optional via, and route channels written via GET./orders/<id>. The same definition ships as test data, guardlink/conformance/flows.json, so a reader of the model built outside GuardLink can check itself against the parser.

@entitles is worth singling out. It answers “is this caller already allowed to do this by design?”, and it deliberately has no power to suppress anything: it never closes a finding, never gates testing, and does not appear in the SARIF export at all. It only informs the recommendation a downstream tool makes. It also cannot be written by hand or by an agent, because an over-grant would close a real privilege escalation as by-design, so a proposal goes through guardlink entitle and a human accepts it.

An application that embeds an LLM agent hands it capabilities through its harness: tool definitions, MCP servers, file and shell access, database clients. @flows describes traffic the authors expect, not what a tool makes possible. Four verbs declare that, each where its fact lives in the code:

Verb Written on Says
@agents <agent> to <capability> [on <asset>] [as <identity>] the tool registration an LLM agent can invoke this capability. Writing it is what marks the actor as an agent
@reaches <actor> to <capability> [on <asset>] [as <identity>] the credentials a job runs with the same, for a principal that is not an LLM agent, such as a CI runner
@effects <read|write|delete|execute|spend|notify> on <asset> [as <identity>] the code that acts what that code does to the asset. The set is closed, like @handles
@gates <asset> by <approver> [for <capability>] an approval step that waits who decides before an effect lands. by is required
src/tools.js
/**
* @agents #support-agent to run-sql on #tool-surface -- "Debug tool, still registered in production"
* @effects write on #users-db -- "Runs model-written SQL verbatim via db.raw"
* @exposes #users-db to #excessive-agency [high] cwe:CWE-862 -- "Reachable from the agent's tool surface, nothing approves the write"
*/
tools.register('run_sql', ({ sql }) => db.raw(sql))
/**
* @agents #support-agent to issue-refund on #tool-surface -- "Refunds a customer's order"
* @effects spend on #payments -- "Moves money back to the card"
*/
tools.register('issue_refund', (refund) => requireApproval(refund))
/**
* @gates #payments by #support-human for issue-refund -- "requireApproval() parks the refund until a human approves it"
*/
async function requireApproval(refund) {
return queue.park(refund)
}

With #support-agent, #support-human and the three assets declared in .guardlink/definitions.js, that validates clean. The capability is the same token @entitles uses, which gives the model a question it can answer: what can this agent do that nobody approved? A reach that no cited @entitles covers is an unentitled reach:

Terminal window
npx guardlink lookup "unentitled reaches"
{
"query": "unentitled reaches",
"type": "unentitled_reaches",
"count": 2,
"results": [
{
"actor": "#support-agent",
"agent": true,
"capability": "run_sql",
"asset": "#tool-surface",
"description": "Debug tool, still registered in production",
"file": "src/tools.js",
"line": 4,
"claim_key": "b18c175e474ec153b2d3b16218c5c0bb3d6f34bb806376f47dba0ef83bd325e2:0",
"near_misses": []
},

Truncated after the first result; the second is issue_refund. --fail-on-found makes the same command exit 1 when the list is not empty, which turns it into a CI step. npx guardlink lookup "ungated effects" answers the other half: here it lists the write on #users-db, because the only gate in the file covers issue_refund on #payments.

None of the four verbs opens or closes a finding, and a gate suppresses nothing. The model JSON carries both answers as reach_analysis; guardlink report adds an Agents and LLM Reach section mapped to the OWASP Top 10 for LLM Applications; and the SARIF pentest profile lists every reach and every mutating effect, entitled or gated or not, as a target to test.

GAL describes claims about code, and it stops at the edge of the repository.

It has no verb for a deployment topology, a vendor relationship, or a process, because none of those has a file:line to anchor to. A threat model that needs those still needs prose somewhere, and guardlink does not pretend otherwise.

More importantly, none of these verbs is checked against what the code actually does. @mitigates ... using #prepared-stmts is a statement that a person wrote down. GuardLink parses it, resolves its references, and reports it. It never reads the query to see whether the parameter is really bound.