This is Part 3 of Hardened Agentic Stack. Part 1 closed anonymous telemetry writes. Part 2 stopped parking long-lived secrets on the agent host. This essay closes the privilege hole that remains even when secrets are short-lived: the agent still holds too much authority once it can act at all.
We consolidate ClawQL’s curriculum on least-privilege Kubernetes identities, scoped session tokens, agent identity lifecycle, and Panguard ATR enforcement into one builder-facing pattern: Role-Based Agent Scoping.
The “Oh No” moment: admin “just for troubleshooting”
An on-call engineer is debugging a flaky summarizer agent in agents namespace. Loki queries fail with permission errors. Someone pastes a ClusterRoleBinding “temporarily,” or the Session JWT for the agent is minted with exec, Vault admin, and file:write across every tenant prefix “so we can finish the ticket.”
Two days later a prompt-injected document steers the same agent through a tool call that would have been blocked under a narrow ATR profile. The call succeeds. The agent now has:
- Kubernetes API verbs it never needed
- A Vault path tree it can read or renew
- Write access outside its workspace
- Possibly cloud roles assumed via a shared ServiceAccount
The compromise was not “the model went rogue.” The compromise was authority available before the bad instruction arrived. The blast radius was baked into the identity.
That is the agentic version of classic least privilege: autonomous systems amplify unused permissions because they will call tools at volume, across sessions, without a human reviewing each call.
Lesson: if “troubleshoot” means “give it admin,” you are designing incident response that starts after takeover.
| Layer | Takeaway |
|---|---|
| Problem | Agents get admin tokens “to troubleshoot,” expanding blast radius to the whole platform. |
| Infrastructure fix | Granular scope-limiting: a Loki-query agent gets log read — never DB write or host execute. |
| Architecture pattern | Role-Based Agent Scoping — map tasks (DiagnoseService, QueryMetrics, PerformRollback) to IAM / JWT. |
Learning goal for this post
By the end you should be able to:
- Separate platform identity (Kubernetes / cloud SA) from session authority (JWT + ATR claims) from call authority (tool-scoped tokens).
- Build a task → permission matrix and refuse roles that cannot name their claims.
- Enforce scopes at the MCP tool-call boundary with Panguard — not in the prompt.
- Provision agents as principals with expiry review dates, and shrink scope without ceremony.
- Detect orphaned credentials and decommission without destroying forensics.
ClawQL context: three layers of “who is this agent?”
A ClawQL agent is not one secret. It is a stack of identities that must each be least-privilege:
| Layer | What it answers | Typical artifact |
|---|---|---|
| Platform identity | Which workload is this in the cluster? | Dedicated ServiceAccount, Role/RoleBinding, IRSA/WI |
| Session identity | Which agent/tenant/session is acting? | Session JWT with ATR claim set |
| Call identity | Which tool may run right now? | Short-lived tool-scoped token + nonce |
| Runtime enforcement | Does this parameter match the claim? | Panguard + JSON Schema + path/HITL gates |
Part 2 gave you Secret-as-a-Service: tickets instead of permanent keys. Part 3 requires that those tickets be narrow. A five-minute Vault lease with cluster-admin is still a five-minute catastrophe window.
Lesson: short TTL without tight scope is a faster way to lose the same farm.
The architecture pattern: Role-Based Agent Scoping
Name it so later posts can point here.
Role-Based Agent Scoping means:
- Every agent task maps to a named role (
DiagnoseService,QueryMetrics,SummarizeWorkspace,PerformRollback). - Every role maps to an explicit ATR claim set — no implied “same as prod admin.”
- Platform RBAC and cloud IAM for that workload match the same story — no shared
agents-sa. - Session JWTs carry only that claim set; tool tokens carry a subset for one tool.
- Panguard denies anything the schema or claim forbids — before handler code runs.
- Scope expansion is a controlled security event; scope contraction is always safe and immediate.
If you cannot write the task → claim matrix on one page, the agent is not ready to provision.
Task → permission matrix
Start with jobs, not tools. Tools are how jobs happen; jobs are what you authorize.
| Task | Tools (examples) | ATR claims (examples) | Must not include |
|---|---|---|---|
QueryMetrics | metrics read, Loki query | telemetry:read:namespace:X | DB write, exec, Vault admin |
DiagnoseService | logs read, describe deploy | k8s:get:pods, telemetry:read | create/delete, secrets write |
SummarizeWorkspace | file_read, file_write | file:read:workspace, file:write:workspace | Paths under .clawql, host FS, exec |
PerformRollback | deploy rollback | k8s:patch:deployments (named) + HITL | Cluster-wide * verbs |
FetchExternalIssue | GitHub read | external:github:repo:read | Repo write, org admin |
Derive ATR claim grammar from products you already use (paths, repos, namespaces) — then make the grammar boring and reviewable.
Concrete ATR rule shape from the ClawQL curriculum:
- claim: file:write:workspace
tool: file_write
allowedPaths:
- '/workspace/'
deniedPaths:
- '/workspace/.clawql/'
Claims live in the session JWT. They are validated on every tool call. Agents cannot self-assign new claims. Rule changes require four-eyes approval; monthly reports should show ATR violations by rule ID and agent role.
Layer 1 — Platform identity: one ServiceAccount per workload
Kubernetes still surprises teams: the default ServiceAccount is shared, auto-mounted, and overused.
Hard rules from least-privilege scoped identities:
- One ServiceAccount per workload — never one SA for the whole
agentsnamespace. automountServiceAccountToken: falseunless the pod truly needs the Kubernetes API.- Prefer namespace-scoped
Role, notClusterRole, unless cluster scope is documented and reviewed. - No wildcards in production (
verbs: ["*"],resources: ["*"]). - Audit with
kubectl auth can-i --list --as=system:serviceaccount:agents:summarizer-sa. - Alert on RBAC diffs that add wildcards or
cluster-admin; review monthly against a git baseline.
Minimal pattern:
apiVersion: v1
kind: Pod
metadata:
name: summarizer
spec:
serviceAccountName: summarizer-sa
automountServiceAccountToken: false
containers:
- name: agent
image: harbor.example.com/golden/node@sha256:...
---
apiVersion: v1
kind: ServiceAccount
metadata:
name: summarizer-sa
namespace: agents
---
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
name: summarizer-read-config
namespace: agents
rules:
- apiGroups: ['']
resources: ['configmaps']
resourceNames: ['summarizer-config']
verbs: ['get']
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
name: summarizer-read-config
namespace: agents
subjects:
- kind: ServiceAccount
name: summarizer-sa
namespace: agents
roleRef:
kind: Role
name: summarizer-read-config
apiGroup: rbac.authorization.k8s.io
If the agent needs cloud credentials, bind this ServiceAccount — not a shared one — via IRSA / Workload Identity:
apiVersion: v1
kind: ServiceAccount
metadata:
name: summarizer-sa
namespace: agents
annotations:
eks.amazonaws.com/role-arn: arn:aws:iam::123456789012:role/summarizer-role
Trust policy condition must nail the subject:
{
"StringEquals": {
"oidc.eks.us-east-1.amazonaws.com/id/ABC123:sub": "system:serviceaccount:agents:summarizer-sa"
}
}
Lesson: cloud AssumeRole scoped to the wrong ServiceAccount is ClusterRole with better branding.
Layer 2 — Session JWT vs tool-scoped token
From scoped tokens: every tool call is a separate authentication decision, not a continuation of a sticky “logged in” feeling.
Session JWT (issued at session start) carries agent ID, tenant ID, session ID, and the ATR claim set for the role. It is not presented raw to tool handlers.
Tool-scoped token (gateway ↔ Vault exchange) carries:
- Audience bound to one tool
- Subset of ATR claims required for that invocation
- TTL measured in minutes (curriculum default: max 5 minutes)
- Unique nonce — no renewal, no reuse on another tool
Flow:
Session JWT
→ Gateway validates session + role
→ Vault issues tool-scoped token (narrow ATR + nonce)
→ Panguard validates claims + schema + risk
→ Tool handler runs
Illustrative session payload:
{
"sub": "agent:summarizer",
"agent_id": "summarizer",
"tenant_id": "tenant-123",
"session_id": "sess-abc123",
"atr": ["file:read:workspace", "file:write:workspace", "external:github:repo:read"],
"exp": 1760003600
}
Illustrative tool token:
{
"aud": "tool:file_write",
"tool": "file_write",
"atr": [
{
"claim": "file:write:workspace",
"allowedPaths": ["/workspace/"],
"deniedPaths": ["/workspace/.clawql/"]
}
],
"nonce": "01JABCDE9VZ8Q3...",
"exp": 1760000300
}
External OAuth/OIDC tokens follow the same spirit: mint on demand, use once, discard, short TTL. Part 2’s Secret-as-a-Service story is how you stop those minting credentials from living in .env.
Replay protection is fail-closed
Every MCP request includes a unique nonce. The gateway records it in a TTL store (Redis) that expires with the token. Duplicate nonce → 403 even if the JWT still verifies.
If the nonce store is down, fail closed. A replay window is worse than a brief tooling outage. Nonce health belongs on the gateway readiness probe and on the same paging class as the gateway itself — same posture as Seatbelt’s fail-closed launch in the macOS sandbox essay.
Audit each exchange without logging the secret:
{
"event_type": "tool_token_exchange",
"agent_id": "summarizer",
"session_id": "sess-abc123",
"tool_name": "file_write",
"token_accessor": "vault-accessor-xyz",
"atr_claims": ["file:write:workspace"],
"panguard_decision": "allow"
}
Layer 3 — Panguard: the structured trust boundary
Prompts are advice. Panguard is the choke point between the MCP protocol handler and the tool dispatcher:
Agent tool request
→ MCP protocol handler
→ Panguard
· ATR claims allow this tool + params?
· JSON Schema valid? (pattern, maxLength, additionalProperties: false)
· Path traversal / deniedPaths?
· Rate / cumulative session risk?
· DLP / classification boundary?
· HITL required?
→ tool handler (or 403)
No bypass path for “the model feels confident.” If Panguard allows the call but Seatbelt/Kata denies the syscall later in the stack, the sandbox still wins — complementary layers, not synonyms (Seatbelt essay).
Schema that earns its keep:
{
"type": "object",
"additionalProperties": false,
"required": ["path", "content"],
"properties": {
"path": {
"type": "string",
"maxLength": 4096,
"pattern": "^/workspace/(?!\\.clawql/).+"
},
"content": {
"type": "string",
"maxLength": 1048576
}
}
}
Validate outputs too. Undeclared response fields are a smuggling channel: structured junk that later turns into agent instructions.
HITL for irreversible verbs
Require human review for classes like exec, broad file_write, vault_secret_read, and external mutations. Serialize full context (tool, params, ATR, session ID) to the HITL queue. Default:
fallback: deny
timeout_seconds: 300
Timeout without approval is denial. HITL bypass without changing Panguard config (itself four-eyes) should be impossible.
Lesson: the trust boundary for agents is the structured tool call — not the natural-language preamble.
Layer 4 — Provisioning as a security principal
From agent identity lifecycle: agents are long-lived principals with certificates, Vault policies, memory paths, NATS namespaces, and ATR roles. Treat onboarding like joining a workforce — not like spinning a Deployment.
Provisioning starts as an infrastructure PR, not a Slack DM:
requested_by: [email protected]
agent_id: summarizer
atr_role: summarizer-basic
justification: 'Summarizes documents in tenant workspaces'
proposed_claims:
- file:read:workspace
- file:write:workspace
expiry_review_date: '2026-10-14'
Hard requirements:
- ATR role already exists and is documented.
exec/ admin-class claims need explicit justification.- No agent without an expiry review date. Permanent unchecked agents are forbidden.
- On approval: cert-manager cert, Vault policy for exact paths, NATS ACL namespace, empty Merkle-rooted memory store, WORM log of the approver.
Scope expansion vs contraction
Expansion is a security event:
- PR + written justification
- Owning team + security sign-off
- Red-team case that exercises the new scope
- Time-bounded observation (default ~7 days) with heightened Panguard logging
- Unexpected behavior → immediate revert
Contraction is always safe and applied immediately. Shrink first, debate later.
Orphans and decommission
Weekly reconcile Vault leases, NATS subscriptions, cert-manager Certificates, and running pods. Identity in the platform but not in running workloads → orphan flag.
Orphan handling: suspend (not destroy) leases for forensics, drain queues, read-lock memory, 7-day review, then automatic decommission if ignored.
Planned decommission drains sessions, archives memory to WORM with final Merkle root, revokes Vault/NATS/mTLS, deletes Kubernetes SA/RBAC/NetworkPolicy/Deployment, writes a signed decommission record.
Under compromise: isolate → preserve → revoke → hand off. Never delete memory or audit trail mid-investigation — Part 15 will go deep on IR; the order is already load-bearing here.
Safety check: what “trusted enough” looks like
You are in a healthier place when:
- Every production agent has a named role, claim matrix, dedicated ServiceAccount, and expiry review date.
kubectl auth can-ifor that SA shows no surprise verbs.- Session JWTs never hit tool handlers directly; tool tokens are single-tool, short TTL, nonce-bound.
- Panguard 403s show up in WORM with claim + schema reasons — and HITL denials default closed.
- Scope expands slowly and shrinks instantly.
- Orphan scanners find zero lingering Vault leases for dead agents.
You are not done when the prompt says “be careful with privileges.” That is advice. Scopes are controls.
How this connects to the rest of the series
| Part | Boundary |
|---|---|
| 1 | Who can write the feeds agents read |
| 2 | How agents obtain secrets without holding them forever |
| 3 | How little authority those secrets + sessions actually grant |
| 4–7 | What processes, syscalls, files, and sandboxes still constrain after auth succeeds |
| 8–9 | How you prove intent ↔ action when scopes are denied |
| 14 | What happens when content tries to talk the agent into using legitimate scopes badly |
Process sandboxes without scoped credentials are locks on an unlocked evidence room. Scoped credentials without sandboxes are polite RBAC that still allows rm -rf when claims say exec. You need both — and Part 3 is the credential half of that handshake.
Getting started checklist
- Inventory agents; kill shared ServiceAccounts and auto-mounted tokens you cannot justify.
- Write a one-page task → ATR matrix for the next agent you ship; refuse silent “admin for debug.”
- Wire Session JWT → tool-scoped exchange with nonce store fail-closed.
- Put Panguard in front of every MCP tool dispatch; schema
additionalProperties: false. - Require
expiry_review_datein every agent provisioning PR. - Run
kubectl auth can-i --listand a Vault/NATS/orphan reconcile this week — not after the first prompt injection.
Companion sketches: DevSecOps-boilerplate. Deep module links: K8s least privilege · Scoped tokens · Agent lifecycle · Panguard ATR.
Next up in the series: Process Containment — when a compromised agent still tries to spawn npx or curl despite your beautiful JWT. Series hub: Hardened Agentic Stack.