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
  1. Where this runs (deployment)
  2. 1. Agent inventory
  3. 2. Used-vs-granted (least privilege)
  4. 3. Access-review campaigns
  5. 4. Delegation chains
    1. Prove the parent
    2. Bound the depth
    3. Rollout order
    4. Known limit
  6. 5. Proof-of-possession for agent tokens
    1. Mint a bound token
    2. Prove possession per request
    3. Roll it out in four steps
    4. What this does and does not cover
  7. 6. Ownership — who to ask
  8. 7. Environment scoping
    1. Rolling it out
  9. 8. Who is acting for whom
  10. See also

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 activitylast_seen and 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_grants as 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=required bounds 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

  1. Set SHIELD_DELEGATION_PARENT_PROOF=required, no depth limit. Chains become proven but unbounded. Nothing breaks that was not already asserting an unproven parent.
  2. Watch delegation_depth in the audit to learn your real maximum. This is why the claim reaches the audit before the limit is enforced.
  3. Set SHIELD_MAX_DELEGATION_DEPTH at or above that observed maximum.
  4. 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 with 400, 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 DPoPDPoP 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 prod to the list. It stops the copied-env-file mistake; it is not tenant isolation and should not be described as such.

Rolling it out

  1. Declare environments on your agents first. Nothing changes yet — the deployment is still unscoped.
  2. Check the governance table for agents still showing no environment chip. Those will keep running anywhere.
  3. Set SHIELD_ENVIRONMENT on each deployment.
  4. 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-Of that failed verification is a claim, not a delegation, and listing someone who never successfully delegated anything would be worse than listing nobody.
  • truncated: true means look further back. The scan is capped, so a short result is not the same as “nobody delegated”. Raise limit or narrow since.

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