Spec: agent ownership, environment scoping, delegation visibility
Shield can say what an agent may do and prove what it did. It cannot say who owns it, cannot stop a staging agent acting against production, and cannot answer “what has alice delegated?” without reading raw audit records.
Table of contents
1. Problem & outcome
Three gaps, found by checking Shield against how the agent-identity category is being described publicly. Each is real; none is large; one has a trap in it.
G1 — no owner. A registry entry carries agent_id, name, description,
tools, role_permissions, agent_permissions, allowed_resources,
require_resource_scope, status, created_at, updated_at
(routes_agents_registry.py:687). It records
when an agent was created and never by whom or who to ask about it.
This is the first question in every access review. /v1/governance/* surfaces
used-vs-granted and drives revocation campaigns, and a reviewer looking at
payments-bot with 14 tools has nobody to ask whether it still needs them. The
review then either rubber-stamps or stalls, and both outcomes make the campaign
worthless.
G2 — no environment. grep for an environment or stage concept across
core/ and api/ returns nothing outside os.environ reads. An agent
registered for staging is, to Shield, indistinguishable from one registered for
production. Point a staging deployment at the production Shield — a mistake
people make constantly, usually via a copied env file — and every grant applies.
G3 — delegation is invisible. resolve_identity() produces acting_for and
delegation_verified in audit_fields()
(identity_resolution.py:427), and those fields
are persisted on the tool path only
(routes_tool.py:299 and :602). They are not
recorded from /guardrails/* or cap/mint, and there is no way to query them.
So “what has alice delegated, and to which agents?” is answerable by grepping
audit JSON, which means in practice it is not answerable.
Outcome
- A reviewer opening any agent sees an owner and can contact them.
- A staging agent presented to a production Shield is refused, and the refusal cannot be bypassed by anything the caller sends.
- “What has this user delegated?” is one API call.
Non-goals
- Ownership is not an authorization input. It is a human-facing label. The moment a grant depends on it, it needs to be verified, and verifying free-text team names is a project. §5 states this as an invariant rather than a preference.
- Not a
delegationstable. §3 explains why persisting a row per delegated request is the wrong shape — it is a write on the guard path, it grows without bound, and it duplicates an audit trail that already exists. - Not OPA or Cedar. A policy-engine adapter is a real request from enterprises standardised on one, and it is a different spec. Do not pre-build it.
- Not
public_keyon the agent entry. That is proof-of-possession, already covered by spec-agent-token-pop. - Not per-environment separate registries. One registry, agents declare where they may run. Multiple registries is a deployment topology question.
2. Plane & latency contract
| Gap | Plane | Guard path? |
|---|---|---|
| G1 ownership | admin (CPU) | no |
| G2 environment | admin write, data-plane enforcement | yes |
| G3 delegation visibility | admin read + data-plane emit | marginally |
G1 touches nothing on the hot path. It is a field on a record read by
/v1/agents/registry and /v1/governance/*, both admin.
G2 does touch the guard path, and this is the item to scrutinise.
Enforcement belongs beside the existing role_permissions read in
rbac_guard.py:92, which already has the
agent entry in hand. The added work is:
- one cached read of
SHIELD_ENVIRONMENT(module-level, not per request) - one list membership test against
entry.get("environments")
No Redis, no network, no crypto. Budget: under 5 µs, and zero when an agent declares no environments — the check short-circuits on absence, which is also what makes it backward compatible.
G3’s emit half is on the guard path but adds no work. audit_fields() is
already computed on every request that calls resolve_identity(); the change is
that two more call sites include the dict they already have in a record they
already write. The query half is admin-only.
Contract: no new I/O on /guardrails/*, cap/mint or tools/call. Nothing
in this spec adds a store read or a network call to a guarded request.
3. Data model
G1 + G2 — two fields on the existing entry
Key agents:{tenant_id} is unchanged in shape. The entry gains:
{
"agent_id": "payments-bot",
"owner": "team-payments",
"owner_contact": "payments-oncall@example.com",
"environments": ["prod", "staging"]
}
owner— free text, ≤128 chars. A team name, a group, a person. Shield does not resolve it against anything, by design (§5).owner_contact— free text, ≤256 chars. Deliberately not validated as an email: it is as likely to be a Slack channel or a PagerDuty rotation, and a format check would only teach people to lie to it.environments— list of ≤16 short identifiers. Absent or empty means “any environment”, which is what makes every existing entry keep working.
All three are optional. An entry written before this change is valid after it.
G3 — no new key, and that is the point
The instinct is a delegations table: user_id, agent_id, scopes, expires_at.
It is wrong here for three reasons:
- It is a write on the guard path. Delegation is established per request
from a token. Recording it means a store write on
/guardrails/*— the one thing this repo’s invariants exist to prevent. - It grows without bound. One row per delegated request, at agent traffic volumes, with no natural expiry that is not just the audit’s retention.
- It already exists. The decision audit records
acting_foranddelegation_verifiedon the tool path today. A second store would be a partial, less trustworthy copy of a tamper-evident one.
So G3 is: emit the fields on the paths that currently omit them, and add a read endpoint over the audit that already holds them.
Stateless delegation is a genuine design choice, not an oversight. Its cost is that there is no “standing grant” to revoke — you revoke the user’s token at the IdP, or the agent instance at Shield. §10 keeps that visible as a decision rather than burying it.
Tenant scoping
Inherited entirely. agents:{tenant_id} is already per-tenant and every
endpoint resolves the tenant from X-API-Key with no tenant parameter. The
delegation query reads the tenant’s own audit scope. No new isolation surface,
and §8 asserts it.
4. API / interface
G1 — ownership
POST /v1/agents/registry and PUT /v1/agents/registry/{agent_id} accept
owner and owner_contact. Both go through _sanitize_string and the length
limits above, alongside the existing _validate_agent_body checks
(routes_agents_registry.py:125).
GET /v1/governance/agents adds both to its projection
(routes_governance.py:95), so a review campaign
shows who to ask without a second lookup.
New: GET /v1/governance/agents/unowned — every registered agent with no
owner. One call to answer “what in our estate has nobody accountable for it”,
which is the question that actually drives adoption of the field.
G2 — environment
Deployment declares its own environment; agents declare where they may run:
SHIELD_ENVIRONMENT=prod # unset means "unscoped", enforcement off
{"environments": ["staging"]} # on the agent entry
Enforcement in rbac_guard: if SHIELD_ENVIRONMENT is set and the agent
declares a non-empty environments list and the deployment’s environment is
not in it, the tool is denied with:
agent 'x' is registered for [staging], not 'prod'
The environment is a property of the deployment, never of the request. This
is the crux. If it came from a header or the request body, a caller would set
it, and it would be X-User-Role all over again — a control that reads as
enforcement and is a suggestion. Reading it from the Shield process makes it
unforgeable by construction, and costs a cached env read.
G3 — delegation visibility
Two changes:
-
Emit.
api/routes_classify.pyand thecap/mintpath include_resolved.audit_fields()in the record they already write, matching whatroutes_tool.pydoes today. No new computation; the dict is already built. -
Read.
GET /v1/tenant/me/delegations?user_sub=…&since=…&limit=…on the admin plane, reading the existing decision audit and returning:
{"user_sub": "alice@example.com",
"agents": [{"agent_id": "payments-bot",
"delegation_verified": true,
"first_seen": "…", "last_seen": "…", "decisions": 42}]}
Aggregated per agent rather than one row per request: the reviewer’s question is “which agents act for alice”, not “list every call”.
5. Security & backward compatibility
Default behaviour: unchanged. All three fields are optional, and G2’s
enforcement requires SHIELD_ENVIRONMENT to be set — unset is today’s
behaviour exactly. An existing deployment upgrading sees no difference.
Ownership is metadata, and must stay metadata. No grant, denial, capability
or policy may read owner. The reason is concrete: owner is free text, so if
it ever gates access, an authorization decision depends on an unverified string
a tenant admin types. §8 test 9 asserts no authorization path reads it, because
this is the kind of invariant that erodes one convenient exception at a time.
Environment fails open on absence, closed on mismatch.
| deployment | agent declares | result |
|---|---|---|
| unset | anything | allowed — enforcement off |
prod |
absent/empty | allowed — unscoped agent |
prod |
["prod"] |
allowed |
prod |
["staging"] |
denied |
Open-on-absence is what keeps it non-breaking. It also means an operator who
sets SHIELD_ENVIRONMENT and expects instant protection gets none until agents
declare — so GET /v1/governance/agents/unowned gains a sibling count of
agents with no environments, and the runbook says to check it.
Environment is not a security boundary on its own. It stops a
misconfiguration, not an attacker: anyone who can write the registry can add
prod to the list. It is a guardrail against the copied-env-file mistake, and
the docs must say so rather than implying tenant isolation.
Delegation visibility exposes no new data. Everything the endpoint returns is already in the tenant’s audit. It is a read path over existing records with the existing tenant scoping.
6. Packaging & deploy
- New module: none. All three land in files that already exist:
api/routes_agents_registry.py,api/routes_governance.py,api/routes_tenant_self.py,guardrails/agentic/rbac_guard.py,api/routes_classify.py. Dockerfile.admin: no change. Every file above is already in the allowlist.tests/test_admin_dockerfile_imports.pyandtests/test_admin_image_transitive_imports.pymust stay green as the evidence, not the assumption.- New pip dependency: none.
- Env flags: one new,
SHIELD_ENVIRONMENT, unset by default. - Images to rebuild: both. The admin plane writes the fields, the data
plane enforces
environments. Order does not matter: an admin plane writingenvironmentsagainst an older data plane is inert (the field is ignored), and a newer data plane withSHIELD_ENVIRONMENTunset enforces nothing. - Rollback: unset
SHIELD_ENVIRONMENT. Stored fields become inert metadata; nothing to migrate or clean up.
7. Failure modes & edge cases
| condition | behaviour | posture |
|---|---|---|
owner absent |
allowed; listed by /agents/unowned |
open, visible |
owner over 128 chars |
400 at write | closed |
owner containing markup |
sanitised by the existing _sanitize_string |
closed |
environments absent or [] |
agent runs anywhere | open by design |
environments set, SHIELD_ENVIRONMENT unset |
no enforcement; counted in the unscoped report | open, visible |
environments mismatch |
tool denied with the environment named | closed |
environments not a list |
400 at write | closed |
environments over 16 entries |
400 at write | closed |
SHIELD_ENVIRONMENT set to whitespace |
treated as unset | open |
| environment names differing in case | exact match only, Prod ≠ prod |
closed, and documented |
| agent entry unreadable / Redis down | existing rbac_guard behaviour, unchanged |
unchanged |
delegation query with no user_sub |
400 | closed |
| delegation query, no audit records | {"agents": []}, not a 404 |
open |
| delegation query over a huge window | limit capped at 1000, oldest dropped, count reported |
bounded |
Exact-match on environment names is deliberate. Case-insensitive matching
invites Prod/prod/PROD to coexist and mean the same thing until one day
they do not. Refusing the mismatch loudly is better than papering over it,
provided the error names both values — which it does.
8. Test plan (Definition of Done)
G1 ownership
ownerandowner_contactround-trip through POST and PUT.- Absent owner is valid; the entry writes and reads back.
- Over-length owner → 400; over-length contact → 400.
- Markup in
owneris sanitised, matching existing field behaviour. GET /v1/governance/agentsincludes both fields.GET /v1/governance/agents/unownedlists exactly the agents with no owner.- PUT with no
ownerkey does not clear an existing owner — partial updates must not silently delete metadata.
G2 environment
- Matrix from §5, all four rows.
- No authorization path reads
owner— asserted by spying on the entry access inrbac_guardand the cap-mint decision. The invariant test. environmentsnot a list → 400; over 16 → 400.- Case mismatch denies, and the message names both values.
SHIELD_ENVIRONMENTwhitespace-only behaves as unset.- An entry written before this change (no
environmentskey) is allowed under anySHIELD_ENVIRONMENT. The backward-compatibility guard. - Zero store reads added: spy on the Redis client across a guarded request
with and without
SHIELD_ENVIRONMENTset, and assert the counts match.
G3 delegation
audit_fields()appears in the record written byroutes_classify.- Same for the
cap/mintpath. - Query aggregates per agent with correct counts and first/last seen.
- Query without
user_sub→ 400. - Query with no matching records → empty list, 200.
limitis capped and the cap is reported in the response.- One tenant’s query never returns another tenant’s delegations.
Regression
- Existing registry tests pass unmodified. If any needs editing, the change was not additive.
tests/test_admin_dockerfile_imports.pyandtests/test_admin_image_transitive_imports.pypass.
Gate
python -m pytest tests -qgreen in a clean venv.- CI
pytestgate green.
9. Task breakdown (one PR each)
PR 1 — ownership. Fields, validation, governance projection, the unowned report, tests 1 to 7. Independently useful: it makes access-review campaigns actionable, and nothing else depends on it.
PR 2 — environment. SHIELD_ENVIRONMENT, the environments field,
enforcement in rbac_guard, tests 8 to 14. Test 14 (zero added store reads) is
the one to write first — it is the guard-path claim, and it is easier to keep
true than to restore.
PR 3 — delegation emit. audit_fields() from routes_classify and the
cap-mint path, tests 15 and 16. Small, and useful alone: the data starts
accumulating before the endpoint that reads it exists, so the endpoint has
something to return on day one.
PR 4 — delegation query. The endpoint, aggregation, limits, tests 17 to 21.
PR 5 — docs. Registry fields in the agent-governance guide; the environment rollout in the runbook, including that it is a misconfiguration guardrail and not tenant isolation.
PR 3 should land a week or two before PR 4 in calendar terms if possible, for the reason above.
10. Open decisions
- Should
ownerbe free text or a structured reference? Free text ships in a day and is honest about being metadata. A reference to a group in the tenant’s IdP is more useful and drags in directory integration. Proposed: free text now, and if it is ever wanted as a real reference, that is a different field rather than a reinterpretation of this one. - Should stateless delegation stay stateless? §3 argues yes. The cost is no standing grant to revoke. If a customer needs “revoke alice’s delegation to payments-bot” as a discrete action, that is a genuine feature and a separate spec — not a widening of this one.
- Should
environmentsdefault to["prod"]for new agents? Safer, and it breaks every registration script that does not know the field yet. Proposed: no default. Revisit once the unscoped-agent count is something operators actually look at.