Agent governance & access reviews
Shield governs agents from what they actually did, not just what they were configured to do. Because Shield sits in the runtime path, the inventory and least-privilege views are backed by real call-path activity — and review decisions apply through the same RBAC/registry that enforces tool access.
Table of contents
All endpoints are read/query operations on the control plane (plus registry
writes when a review closes). Nothing here runs on the guard path
(cap/mint, tools/call), so guarded/LLM traffic has no added latency.
Authenticate with your tenant X-API-Key. In the tenant portal this is the
Governance tab.
Where this runs (deployment)
Shield runs as two independent planes that share Redis:
| Plane | Hardware | Responsibilities |
|---|---|---|
| Data plane (guardrail server) | GPU (vLLM, ~9B guardrail model) | Pre/post-call inspection, agent & tool access control, the LLM-based checks (~250 ms) |
| Admin & tenant plane (portal) | CPU only, no GPU | Policy management, the tenant portal, and governance (inventory, used-vs-granted, reviews) |
Governance is pure control-plane logic — no model, no GPU, so it lives on the admin/tenant plane and the portal’s Governance tab calls it there. Both planes read the same Redis (registry, auth-event activity), so governance sees a consistent view of entitlements and runtime usage regardless of which plane served a request. Because nothing here touches the GPU data plane’s inference path, governance adds zero load to the guardrail server — you can scale the portal independently of the GPU fleet.
1. Agent inventory
GET /v1/governance/agents merges:
- Registered agents — entitlements from the registry (granted tools, roles,
allowed_resources, status). - Shadow agents — agent ids observed in traffic but never registered.
- Recent activity —
last_seenand recently-used tools from the runtime auth-event stream.
curl -s "$SHIELD_URL/v1/governance/agents" -H "X-API-Key: $KEY" | python3 -m json.tool
Returns registered_count, shadow_count, and a per-agent list. Shadow agents
are the blind spot worth closing first — they’re acting without a policy.
2. Used-vs-granted (least privilege)
GET /v1/governance/agents/{agent_id}/usage diffs what an agent was granted
against what it has actually exercised:
| Field | Meaning |
|---|---|
unused_grants |
granted but never used — candidates to remove |
used_not_granted |
used but not granted — drift / investigate |
used_tools / used_resources |
what the agent actually touched |
activity |
event breakdown (mint / verify / denied …) |
curl -s "$SHIELD_URL/v1/governance/agents/<agent_id>/usage" -H "X-API-Key: $KEY"
Usage is derived from the runtime auth-event buffer (recent activity, not full history) — directional for least-privilege, not a substitute for full audit. Longer-retention usage analytics is on the roadmap. Treat
unused_grantsas a review prompt, not an automatic delete.
3. Access-review campaigns
Certify each agent’s entitlements on a schedule, then apply the decisions.
| Method | Endpoint | Purpose |
|---|---|---|
POST |
/v1/governance/reviews |
Create a campaign — snapshots entitlements + recommendations |
GET |
/v1/governance/reviews |
List campaigns + progress |
GET |
/v1/governance/reviews/{id} |
Campaign detail + decisions |
POST |
/v1/governance/reviews/{id}/decisions |
Record keep/revoke per (agent, tool) |
POST |
/v1/governance/reviews/{id}/close |
Close and apply revokes (idempotent) |
Recommendations are pre-filled from used-vs-granted: no recent activity →
review (a stale-grant candidate — not a hard revoke, since the usage window is
bounded), drift (used-but-not-granted) → investigate, used → keep. The
reviewer then chooses keep or revoke per grant. A future longer-retention usage
signal will let review graduate to a confident revoke for grants unused over
a real time window.
On close, each revoke removes that tool grant from the agent in the
registry. RBAC and capability minting read the registry, so the change takes
effect on the next mint/check; any live capability tokens expire on their own
(≤60s). Close is idempotent and snapshots entitlements at creation, so a
mid-review config change can’t silently alter what’s being certified.
# create
CID=$(curl -s -X POST "$SHIELD_URL/v1/governance/reviews" -H "X-API-Key: $KEY" \
-d '{"name":"Q3 agent certification"}' | python3 -c 'import sys,json;print(json.load(sys.stdin)["campaign"]["campaign_id"])')
# decide
curl -s -X POST "$SHIELD_URL/v1/governance/reviews/$CID/decisions" -H "X-API-Key: $KEY" \
-d '{"decisions":[{"agent_id":"billing-agent","tool":"send_email","decision":"revoke"}]}'
# close + apply
curl -s -X POST "$SHIELD_URL/v1/governance/reviews/$CID/close" -H "X-API-Key: $KEY"
Every decision and the close action is retained on the campaign (reviewer, timestamp, applied actions) as the certification trail.
4. Delegation chains
When an agent spawns a sub-agent, the child’s token records which agent delegated to it. Two controls govern that link, both off by default.
Prove the parent
By default parent_agent_id is whatever the caller put in the request body.
Shield signs it, but nothing verifies that the named parent exists or delegated
anything — so the lineage in your audit is a caller-supplied string.
SHIELD_DELEGATION_PARENT_PROOF=required
The parent is then derived from a verified parent token the caller presents
as parent_agent_token, and the body field is ignored. Holding a valid parent
token already implies more authority than the child will have, so there is no
escalation in deriving from it. A parent token belonging to a different tenant
is refused with 403.
curl -X POST "$SHIELD/v1/tenant/me/agent-auth/agent-token" -H "X-API-Key: $TENANT_KEY" -H 'Content-Type: application/json' -d "{\"user_sub\":\"alice\",\"agent_id\":\"research-bot\",\"agent_instance_id\":\"inst-2\",\"build_hash\":\"b\",\"model_version\":\"m\",\"session_id\":\"s\",\"parent_agent_token\":\"$PARENT_TOKEN\"}"
Bound the depth
SHIELD_MAX_DELEGATION_DEPTH=1
A root token is depth 0, its child depth 1. With the limit at 1, that child
cannot mint a grandchild — 403 delegation depth 2 exceeds limit 1. The depth
is enforced at mint and at verification, so lowering the ceiling takes
effect immediately rather than waiting out every issued token’s lifetime.
Set the proof flag first. A depth limit without
SHIELD_DELEGATION_PARENT_PROOF=requiredbounds nothing: the depth is computed from the parent the caller named, so the caller also chooses its own depth. Shield logs a warning at boot if you configure it that way, but the limit will silently not apply.
Rollout order
- Set
SHIELD_DELEGATION_PARENT_PROOF=required, no depth limit. Chains become proven but unbounded. Nothing breaks that was not already asserting an unproven parent. - Watch
delegation_depthin the audit to learn your real maximum. This is why the claim reaches the audit before the limit is enforced. - Set
SHIELD_MAX_DELEGATION_DEPTHat or above that observed maximum. - Lower it deliberately.
Turning both on at once without step 2 is how you break a legitimate three-hop workflow.
Known limit
Revoking a parent does not revoke its children. A child token stays valid
until its own expiry, capped at 15 minutes. Cascading revocation would need a
chain registry, and a registry means a Redis read per guarded request — the
15-minute ceiling is the mitigation instead. Revoke the child’s
agent_instance_id directly if you need it gone sooner.
5. Proof-of-possession for agent tokens
By default an agent token is a bearer token: whoever holds the string can use it. A copy in a log file, a crash dump, an error report, or a proxy access log is a working credential until it expires. The 15-minute lifetime caps the window, but that is a mitigation, not a control.
Binding the token to a keypair fixes that. The agent generates a keypair, sends only the public half at mint, and proves possession of the private half on every request.
Mint a bound token
curl -X POST "$SHIELD/v1/tenant/me/agent-auth/agent-token" -H "X-API-Key: $TENANT_KEY" -H 'Content-Type: application/json' -d '{"user_sub":"alice","agent_id":"billing-bot","agent_instance_id":"inst-1","build_hash":"b","model_version":"m","session_id":"s","agent_jwk":{"kty":"OKP","crv":"Ed25519","x":"<public-key>"}}'
The token gains a cnf.jkt claim carrying the key’s thumbprint. Shield stores
no keys: the thumbprint travels in the signed token and the full public key
arrives inside each proof.
Send the public JWK only. A JWK containing private members (
d,p,q, …) is refused with400, and the key is never echoed into the response or the logs. Shield never needs your private key, and it should never leave the agent process.
Prove possession per request
Each request carries a DPoP proof in X-Agent-DPoP, signed over the method and
URI being called. Proofs are single-use for 60 seconds and expire after 30, so
one captured in flight cannot be replayed.
The header is X-Agent-DPoP, not DPoP — DPoP is already used for external
IdP token binding (SHIELD_TOKEN_BINDING), and with both enabled and different
keypairs one header cannot satisfy two thumbprints.
Roll it out in four steps
SHIELD_AGENT_TOKEN_POP=off | optional | required
SHIELD_AGENT_TOKEN_POP_ALLOW_UNBOUND=true|false
| step | setting | effect |
|---|---|---|
| 1 | off, clients start sending agent_jwk |
tokens gain cnf, nothing enforced |
| 2 | optional |
proofs verified and recorded, denied never — watch pop_verified in the audit |
| 3 | required + ALLOW_UNBOUND=true |
bound tokens must prove; unmigrated clients keep working |
| 4 | required |
once the audit shows no unbound tokens |
cnf is minted whenever a key is supplied regardless of mode, which is what
makes step 1 possible. Skipping step 3 is how you take down every client that
has not shipped a key yet.
Deploy the data plane before the admin plane: an admin plane minting cnf
tokens against a data plane that ignores them is harmless, while a data plane in
required against an admin plane that cannot mint cnf refuses everything.
What this does and does not cover
This is a direct-path control. It covers cap/mint and tools/call — the
endpoints that actually authorize an action.
Behind an LLM gateway a proof cannot exist: a proof binds to the method and URI
of the request, the agent signs for the gateway’s URL, and Shield receives its
own. There is no configuration that fixes this. The gateway authenticates
itself with the trusted-proxy secret instead, and the audit records
pop_verified: false so a vouched request is distinguishable from a proven one.
The accurate claim, and the one to make to a security team:
On paths where the agent calls Shield directly, a stolen agent token is useless without the agent’s private key. Where an LLM gateway sits in front, the gateway authenticates itself and the audit records that possession was vouched for rather than proven.
Not covered: an attacker with code execution inside the agent process has the private key. This binds the credential to a key, not to a machine or a human.
6. Ownership — who to ask
A registry entry records when an agent was created. Until you set an owner it
does not record who to ask about it, which is the first question in any
review: a reviewer looking at payments-bot with fourteen granted tools has to
either approve blind or stall.
curl -X POST "$SHIELD_ADMIN/v1/agents/registry" -H "X-API-Key: $TENANT_KEY" -H 'Content-Type: application/json' -d '{"agent_id":"payments-bot","name":"Payments bot","owner":"team-payments","owner_contact":"#payments-oncall","tools":["read_logs"]}'
Both fields are free text. An owner is as likely to be a Slack channel or a rota as a person, and a format check would only teach people to lie to it.
Find what has nobody accountable for it:
curl -s "$SHIELD_ADMIN/v1/governance/agents/unowned" -H "X-API-Key: $TENANT_KEY"
Ordered by granted-tool count, so the biggest blast radius is first. The portal shows the same as an Unowned tile and a badge in the governance table.
Ownership is metadata and never authorization. No grant, denial or capability reads it. It is free text a tenant admin types, so a permission that depended on it would depend on an unverified string. A test asserts that no authorization path references the field, so this cannot drift.
Shadow agents are not badged unowned. They are unregistered by definition, so the action is to register or block them, not to fill in a field.
7. Environment scoping
Point a staging deployment at your production Shield — a copied env file, which happens constantly — and without this every grant applies.
Two settings, and the asymmetry between them is the design:
SHIELD_ENVIRONMENT=prod # on the Shield DEPLOYMENT
{"environments": ["staging"]} // on the AGENT entry
The environment comes from the Shield process, never from the request. If a
caller could send it, it would be X-User-Role all over again: a control that
reads as enforcement and is a suggestion. Reading it from the deployment makes
it unforgeable by construction.
| deployment | agent declares | result |
|---|---|---|
| unset | anything | allowed — enforcement off |
prod |
absent or [] |
allowed — unscoped agent |
prod |
["prod"] |
allowed |
prod |
["staging"] |
denied, naming both values |
Names match exactly. Prod is not prod — case-insensitive matching lets three
spellings coexist meaning the same thing until one day they do not.
This guards against a misconfiguration, not an attacker. Anyone who can write the registry can add
prodto the list. It stops the copied-env-file mistake; it is not tenant isolation and should not be described as such.
Rolling it out
- Declare
environmentson your agents first. Nothing changes yet — the deployment is still unscoped. - Check the governance table for agents still showing no environment chip. Those will keep running anywhere.
- Set
SHIELD_ENVIRONMENTon each deployment. - Confirm a deliberately-mismatched agent is refused. A control you have not seen refuse something is a control you have not tested.
Doing 3 before 1 is safe — an agent that declares nothing runs anywhere — but it also means you get no protection while believing you do.
8. Who is acting for whom
X-On-Behalf-Of lets an agent act with a user’s authority. To see what that
produced:
curl -s "$SHIELD_ADMIN/v1/tenant/me/delegations?user_sub=alice@example.com" -H "X-API-Key: $TENANT_KEY"
{"delegations": [
{"user_sub": "alice@example.com", "agent_id": "payments-bot",
"decisions": 42, "first_seen": "…", "last_seen": "…"}],
"entries_scanned": 200, "scan_limit": 200, "truncated": true}
Aggregated per user and agent: the question at review time is which agents act for alice, not every call she made.
Two things to read carefully:
- Only verified delegations are counted. An
X-On-Behalf-Ofthat failed verification is a claim, not a delegation, and listing someone who never successfully delegated anything would be worse than listing nobody. truncated: truemeans look further back. The scan is capped, so a short result is not the same as “nobody delegated”. Raiselimitor narrowsince.
There is no delegations table, deliberately. Delegation is established per request from a verified token, so recording it would mean a store write on the guard path for every delegated call. This reads the audit trail that already holds it.
The cost of that choice: there is no standing grant to revoke. To stop a delegation you revoke the user’s token at your IdP, or the agent instance at Shield.
See also
- Agentic Integration Guide — registering agents and entitlements.
- Continuous Identity & Auto-Revoke — runtime revocation and signal exchange.
- Developer Guide — MCP & APIs — how tool access is governed at runtime.