Agent Safety24 min read

Scoped Credentials: The Least-Privilege Agent

Admin tokens for troubleshooting turn a minor compromise into a takeover. Map agent tasks to tight JWT scopes, Kubernetes identities, and ATR claims enforced at every tool call.

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.

LayerTakeaway
ProblemAgents get admin tokens “to troubleshoot,” expanding blast radius to the whole platform.
Infrastructure fixGranular scope-limiting: a Loki-query agent gets log read — never DB write or host execute.
Architecture patternRole-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:

LayerWhat it answersTypical artifact
Platform identityWhich workload is this in the cluster?Dedicated ServiceAccount, Role/RoleBinding, IRSA/WI
Session identityWhich agent/tenant/session is acting?Session JWT with ATR claim set
Call identityWhich tool may run right now?Short-lived tool-scoped token + nonce
Runtime enforcementDoes 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:

  1. Every agent task maps to a named role (DiagnoseService, QueryMetrics, SummarizeWorkspace, PerformRollback).
  2. Every role maps to an explicit ATR claim set — no implied “same as prod admin.”
  3. Platform RBAC and cloud IAM for that workload match the same story — no shared agents-sa.
  4. Session JWTs carry only that claim set; tool tokens carry a subset for one tool.
  5. Panguard denies anything the schema or claim forbids — before handler code runs.
  6. 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.

TaskTools (examples)ATR claims (examples)Must not include
QueryMetricsmetrics read, Loki querytelemetry:read:namespace:XDB write, exec, Vault admin
DiagnoseServicelogs read, describe deployk8s:get:pods, telemetry:readcreate/delete, secrets write
SummarizeWorkspacefile_read, file_writefile:read:workspace, file:write:workspacePaths under .clawql, host FS, exec
PerformRollbackdeploy rollbackk8s:patch:deployments (named) + HITLCluster-wide * verbs
FetchExternalIssueGitHub readexternal:github:repo:readRepo 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 agents namespace.
  • automountServiceAccountToken: false unless the pod truly needs the Kubernetes API.
  • Prefer namespace-scoped Role, not ClusterRole, 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:

  1. PR + written justification
  2. Owning team + security sign-off
  3. Red-team case that exercises the new scope
  4. Time-bounded observation (default ~7 days) with heightened Panguard logging
  5. 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-i for 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

PartBoundary
1Who can write the feeds agents read
2How agents obtain secrets without holding them forever
3How little authority those secrets + sessions actually grant
4–7What processes, syscalls, files, and sandboxes still constrain after auth succeeds
8–9How you prove intent ↔ action when scopes are denied
14What 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

  1. Inventory agents; kill shared ServiceAccounts and auto-mounted tokens you cannot justify.
  2. Write a one-page task → ATR matrix for the next agent you ship; refuse silent “admin for debug.”
  3. Wire Session JWT → tool-scoped exchange with nonce store fail-closed.
  4. Put Panguard in front of every MCP tool dispatch; schema additionalProperties: false.
  5. Require expiry_review_date in every agent provisioning PR.
  6. Run kubectl auth can-i --list and 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.

About the author

Daniel Smith builds ClawQL, an agent operating system for token-efficient discovery and execution over APIs — with observability, hardened tool boundaries, and production routing for LLM workloads. He writes here about the systems problems behind shipping agents.