Skip to content

feat(reach): declare what an embedded agent can reach with @agents, @reaches, @effects and @gates - #53

Merged
Animesh-Sri-bugb merged 2 commits into
mainfrom
fm/gl-agent-reach
Oct 2, 2026
Merged

Animesh-Sri-bugb merged 2 commits into
mainfrom
fm/gl-agent-reach

Conversation

@Animesh-Sri-bugb

Copy link
Copy Markdown
Contributor

What

An application that embeds an LLM agent hands it capabilities through its harness: tool definitions, MCP servers, file, shell and database access, internal methods. Until now none of that could be declared. A reviewer or scanner could not see an agent's blast radius, and guardlink diff did not move when someone registered a run_sql tool.

This PR adds four relation verbs. Each one is written where its fact lives in the code (new SPEC §3.2.1):

@agents  <agent actor> to <capability> [on <asset>] [as <identity>]          # on the tool registration
@reaches <actor> to <capability> [on <asset>] [as <identity>]                # same, non-agent principal
@effects <read|write|delete|execute|spend|notify> on <asset> [as <identity>] # on the code that acts
@gates   <asset> by <approver actor> [for <capability>]                      # on the approval step

The capability is the same token @entitles uses, so "can minus may" is a join. guardlink_lookup("unentitled reaches") returns the capabilities the code hands out that no human approved, which for an agent is the OWASP LLM06 Excessive Agency list.

Two rules that follow from splitting agents from other principals

Please review these two specifically:

  1. Writing @agents about an actor is what marks it as an agent. There is no definition-level flag. An @actor becomes an agent when some @agents names it. lookup actors now reports agent: true|false for each actor.
  2. Naming the same actor under both @agents and @reaches is a validation error, agent-reach-conflict. Any check that applies only to agents would otherwise answer two ways for that actor. Actor spellings are resolved first, so #support-agent and Support_Agent count as the same actor. The error is reported on each @reaches line and names the @agents line it conflicts with.

The can-minus-may join (src/parser/reach.ts)

An entitlement covers a reach when all of these hold:

  • it names the same actor;
  • it has the same normalised capability;
  • it is cited (an inert entitlement covers nothing, for the same reason it cannot demote a finding);
  • if the reach names an asset, the entitlement names the same asset.

An entitlement that names no asset does not cover a reach that names one. Every rule errs toward reporting the reach. When an entitlement for the same actor and capability exists but does not count, the row carries it in near_misses with the reason: uncited, no-asset or other-asset. lookup and diff both use this one function.

Whether an @entitles in source has an accepted proposal is not checked here. That needs the proposal ledger on disk, and validate already reports such an entitlement as an error.

Changes

  • Parser/model: adds reaches[] (both verbs, with agent: boolean), effects[] and gates[]. The new verbs are also wired into claim keys, annotation-hash records, workspace merge, the feature filter, guardlink_graph kinds, guardlink_context and the dashboard's annotation list. A model that uses none of the verbs hashes and exports exactly as before, so no hash version bump is needed.
  • Prose-vs-malformed tiering (SPEC §2.12): to counts as a keyword for @agents/@reaches. For @effects, an effect word is a keyword only as the first word of the arguments. @gates has no keyword, for the same reason by is not one for @accepts.
  • validate (CLI, MCP, TUI):
    • undeclared-actor now also covers the actor on @agents/@reaches and the approver on @gates.
    • An undefined asset or identity #id on any of the verbs is a dangling-ref.
    • agent-reach-conflict is new.
  • lookup:
    • New forms reaches, agents, unentitled reaches, effects and gates, each with an optional for <actor|asset>. effects for and gates for resolve the asset the way asset X does.
    • actors rows gain agent and reaches.
    • New CLI command guardlink lookup <query...> answers the same forms as JSON (there was no CLI path to lookup before). --fail-on-found makes it usable as a CI check.
  • diff: reports changes to reaches, effects and gates. A new New Unentitled Reaches section, in both text and markdown, lists any reach that has become uncovered, either because it was just added or because its entitlement was withdrawn.
  • Agent writes: all four verbs can be written by agents. guardlink_annotate_apply and the annotate gate accept them, and still refuse @entitles and @accepts. The gate warns (description-vague) on a @gates that does not say what the approval step is.
  • Docs and prompts: SPEC §2.12, §3.1, §3.2.1, §5.1, §6.1 and §7.1, docs/GUARDLINK_REFERENCE.md, the annotate prompt and the agent instruction templates now describe the verbs. The second commit regenerates the synced instruction files and graph artifacts.
  • SARIF is unchanged. None of the verbs is exported (SPEC §6.1 says why). The github and pentest baselines still match.

Verification

  • npm run build && npm test: 118 files, 2170 passed, 1 skipped.
  • npm run lint is clean.
  • node dist/cli/index.js validate . --artifacts passes: artifacts are current and drawable, with 0 errors and the same 16 unmitigated exposures as main.
  • tests/agent-reach.test.ts (30 tests) covers:
    • the grammar of every verb, including malformed and prose-like cases;
    • both rules above;
    • undeclared-actor and dangling-ref on each verb;
    • lookup and diff on a small support-desk app with six agent tools, one CI reach, six effects and one gate: run-sql, fetch-url (uncited entitlement), search-kb (entitlement on another asset), mcp-files and the CI runner's publish come back unentitled;
    • what the annotate gate accepts and refuses;
    • the template examples, which must parse;
    • the CLI command.
  • tests/relation-coverage.test.ts now covers the three new relation arrays. tests/diagnostic-codes.test.ts covers agent-reach-conflict.

Follow-ups (not in this PR)

  • Annotate guardlink's own MCP server, whose 28 registered tools are an agent harness, as the first real fixture.
  • Add an agent-reach review result to the SARIF pentest profile.
  • Infer draft @agents from tool registrations and mcp.json, then verify them.
  • Have a code graph check the route from each @agents to each @effects, and whether a @gates sits on it.
  • Attest the runtime tool list, so drift from the declared reach is flagged.
  • Teach downstream consumers (scanners, runtime permission gates) to read unentitled reaches.

…reaches, @effects and @gates

An application that embeds an LLM agent hands it capabilities through its
harness: tool definitions, MCP servers, file, shell and database access,
internal methods. None of that could be declared, so a reviewer or scanner
could not see an agent's blast radius, and `guardlink diff` did not move when
a `run_sql` tool was registered.

Four relation verbs, each written where its fact lives (SPEC 3.2.1):

- `@agents <agent actor> to <capability> [on <asset>] [as <identity>]` on the
  tool registration. `@reaches` has the same shape for a principal that is
  not an LLM agent, such as a CI runner. Writing `@agents` about an actor is
  what marks it as an agent, and naming one actor under both verbs is a
  validation error (`agent-reach-conflict`), because every agent-only check
  would otherwise answer two ways.
- `@effects <read|write|delete|execute|spend|notify> on <asset> [as <identity>]`
  on the code that acts. The set is closed, like `@handles`; egress stays on
  `@flows ... -> External.X`.
- `@gates <asset> by <approver actor> [for <capability>]` on an approval step.
  `by` is required and there is no advisory/blocking qualifier.

The capability is the token `@entitles` uses, so "can minus may" is a join:
`findUnentitledReaches` (src/parser/reach.ts) returns every reach that no
cited entitlement for the same actor and capability covers, and, when the
reach names an asset, for the same asset. An uncited entitlement or one that
names no asset never covers a reach that names one; each such near miss is
reported with its reason. lookup and diff both answer from it.

- Parser and model: `reaches[]` (both verbs, with `agent: true|false`),
  `effects[]`, `gates[]`; claim keys, annotation-hash records, workspace
  merge, feature filter, graph subgraph kinds, file context and the
  dashboard's annotation list. A model without the verbs hashes and exports
  exactly as before.
- validate (CLI, MCP, TUI): `undeclared-actor` now covers the actor on
  `@agents`/`@reaches` and the approver on `@gates`; undefined assets and
  identities are `dangling-ref`; `agent-reach-conflict` is new.
- lookup: `reaches`, `agents`, `unentitled reaches`, `effects`, `gates`, each
  with an optional `for <actor|asset>`; `actors` rows gain `agent` and
  `reaches`. `guardlink lookup <query...>` answers the same forms from the
  CLI as JSON, with `--fail-on-found` for CI.
- diff: reach, effect and gate changes, and a New Unentitled Reaches section
  for a reach that is newly uncovered, whether added or because its
  entitlement was withdrawn.
- Agent writes: all four verbs are accepted by `guardlink_annotate_apply` and
  the annotate gate; `@entitles` and `@accepts` stay refused. The gate warns
  on a `@gates` that does not say what the approval step is.
- SPEC 2.12, 3.1, 3.2.1, 5.1, 6.1 and 7.1, the reference doc, the annotate
  prompt and the agent instruction templates describe the verbs.

None of the verbs changes the SARIF export.
@Animesh-Sri-bugb
Animesh-Sri-bugb merged commit 9afd8f7 into main Oct 2, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant