Permissions, Sandboxing & Least Privilege
Bounding what an agent can reach — capability models, the isolation ladder, credentials it never sees, and blast radius as a design parameter.
In this illustrative scenario, Reference Agent’s security review asked: “What is the worst thing this can do if the model is wrong?”
The honest answer at the time was that nobody knew. The agent held a service account with read access across four systems and write access to two. It ran in a container with unrestricted egress. Its tools took file paths as strings. If the model were confused — or, worse, persuaded by content it retrieved — the reachable damage was bounded only by what that service account could touch, which was a great deal more than any single task required.
Answer with a capability boundary you can describe, enforce and test.
15.1Least privilege, agent-shaped#
Least privilege gives each component the minimum authority for its function. For an agent, that authority must be scoped to the current task as its path develops.
So the unit of authority is not the agent. It is the task, and increasingly the individual action. Reference Agent as a system may need to read from six systems and write to three; this run, reconciling January for one tenant, needs to read two and write one. The gap between those is the blast radius you are choosing to accept, and it is usually far larger than anyone intended because permissions are granted at deployment and used at runtime.
Deny by default, everywhere. Paths, hosts, tools, tenants. The reference implementation’s NetworkPolicy has an empty allowlist by default, which means its URL check permits no host; only a completely mediated network boundary can enforce this for all egress. That is deliberate: a default that permits is a default that ships.
Scope authority to the task. Where possible, derive the permitted set from the request — this tenant, these paths, this date range — rather than from a static grant. This is harder than it sounds and worth the effort for the highest-risk capabilities.
Separate reads from writes, and reversible writes from irreversible ones. These are three risk tiers, not two, and the third is where humans belong (Chapter 19).
Make the boundary enforceable and testable. A permission model you cannot write a test for is a document, not a control.
Design Rule
The unit of least privilege for an agent is the action, not the agent. Ask what this run needs, not what the system needs, and derive authority from the request wherever you can. Every gap between “what the service account can do” and “what this task requires” is blast radius you have chosen to accept — usually without noticing.
15.2Treat authority as a budget#
Least privilege becomes operational when you can put a number against the excess authority. Counting permissions is not enough: one permission may read a single case while another reads every tenant. Instead, count the reachable objects and effects behind each grant, then weight them by the severity of the operation. The result is not a probability of breach. It is an exposure index for comparing two policy designs before either reaches production.
Take an illustrative Reference Agent deployment with a shared identity. The document store contains 18,000 documents for each of 120 tenants. The case system holds 240 open tickets per tenant. The billing tool can issue a credit to 60 active accounts per tenant. The operations tool can restart any of 12 services. Give reads a weight of 1, reversible writes a weight of 5 and irreversible writes a weight of 25. Those weights are local review choices, not universal constants; their value is that security and product agree on them before comparing designs.
| Authority held by the shared identity | Reachable objects or effects | Illustrative weight | Exposure points |
|---|---|---|---|
| Read all tenant documents | 120 × 18,000 = 2,160,000 | 1 | 2,160,000 |
| Update any open ticket | 120 × 240 = 28,800 | 5 | 144,000 |
| Issue a credit to any active account | 120 × 60 = 7,200 | 25 | 180,000 |
| Restart any named service | 12 | 5 | 60 |
| Deployment identity total | 2,484,060 |
Now scope one run: reconcile case INC-4471 for one tenant. It needs to read 40 documents and update six tickets. It needs no billing capability and no service restart. Its exposure index is therefore 40 × 1 + 6 × 5 = 70. The shared identity exposes 2,484,060 ÷ 70 = 35,486.6 times the authority required by that run. Put another way, 1 - 70 ÷ 2,484,060 = 99.9972% of its weighted authority is excess for the task.
The decimal places are not the insight. The more-than-four-orders-of-magnitude gap is. A review that debates whether the model’s error rate is 1% or 2% while leaving that gap untouched is optimising the wrong term. Reduce reachable authority first; then evaluate the model inside the smaller boundary.
Do the same calculation separately for confidentiality, integrity and availability. A document read affects confidentiality but not integrity. A restart affects availability but usually not confidentiality. Combining them into one score helps rank redesigns, but it can hide a catastrophic dimension behind many harmless reads. Keep the component totals beside the combined index:
| Design | Confidentiality | Integrity | Availability | Combined index |
|---|---|---|---|---|
| Shared deployment identity | 2,160,000 | 324,000 | 60 | 2,484,060 |
| Task-scoped grant | 40 | 30 | 0 | 70 |
This arithmetic also stops a common sleight of hand: calling an agent “read-only” when its reads span every tenant. Read-only reduces one kind of damage. It does not make a cross-tenant disclosure small. Likewise, reversible writes are safer than irreversible writes, but reversing 28,800 ticket changes is still an incident.
Mint, attenuate, consume, revoke#
The runtime shape that corresponds to the smaller number is authority attenuation. A deployment identity may be broad enough to request a grant, but the identity itself is not handed to the agent. A trusted broker mints a task grant containing bound values: actor, tenant, case, permitted operations and expiry. Before each side effect, it attenuates that grant again to the exact action. The enforcement proxy verifies the bound values, injects the credential, performs the operation and consumes a single-use grant where the action is irreversible.
Bind values that the model cannot reinterpret. tenant=acme is a boundary; “use the current customer’s tenant” is an instruction. service=reports is a boundary; “restart the affected service” leaves selection to the model. expires_at=14:30Z is a boundary; “valid for this incident” depends on another component deciding when the incident ended. Natural-language scope belongs in the model’s context for reasoning. Machine-checkable scope belongs in the grant for enforcement.
The grant must travel with the action, not merely with the run. Queueing, retries and hand-offs break ambient assumptions. A worker that receives restart_service(reports) should be able to validate the actor, tenant, run, approval and expiry without reconstructing them from a conversation transcript. Chapter 5’s checkpoint restores computation; it must not silently restore expired authority. Resume the task, then mint fresh authority from current policy.
Revocation completes the lifecycle. A kill switch that only changes the system prompt is not revocation: an already queued worker may never read that prompt. Revoke at the credential broker or enforcement proxy, where every effect must pass. A task grant should fail after its expiry, after its allowed count reaches zero, after its subject is disabled or after its parent grant is revoked. Those are deterministic conditions you can test.
Approval is not a bearer token#
The difference between remembering an answer and granting authority becomes important as soon as a run can move between workers. In the reference loop, an approved call adds ToolCall.signature() to PolicyContext.session_approvals. The signature is stable JSON over the tool name and exact arguments, so approval for $50 does not match $500, and argument order cannot create a false miss. On the next identical proposal, resource policies still run first; if they do not deny it, the session signature produces session.grant before ordered rules. The loop deliberately preserves that grant so it does not ask the human the same question repeatedly.
That is a clear teaching mechanism for one in-process session. It is not a production delegation format. The signature contains no actor, tenant, expiry, approval identifier, use count or policy version. Those values live elsewhere in the Python context, and the approval set is mutable process memory. If a production system serialised only that signature and treated it as a credential, issue_credit({"account":"acct-1","amount":50}) could appear identical under two tenants even though the authority is not.
A durable action grant must bind at least six fields:
- subject — the agent workload identity, not merely its display name;
- delegating actor and tenant — whose authority is being exercised and inside which boundary;
- canonical action and resource — the exact operation, arguments and resource owner;
- constraint set — maximum amount, allowed count, region or other material bounds;
- lifetime — issued-at and expiry times, plus a nonce for one-time use where required;
- policy provenance — approval, parent-grant and policy-version identifiers.
Sign the structured grant or keep it opaque and resolve it at a trusted broker. Either design makes tampering detectable. Consume it atomically with the side effect when replay would duplicate irreversible work; checking and then consuming in separate operations creates a race. For reversible actions that intentionally permit repetition, state the count explicitly rather than relying on an unbounded “session”.
Re-evaluate resource boundaries before honouring remembered approval. A grant for /work/acme/out/report.csv must fail if the path is later denied or resolves elsewhere. Approval narrows current policy; it cannot override it. Record the policy version for audit without freezing its authority.
This broker-and-grant design is an illustrative production extension, not a capability supplied by the compact Reference Agent reference implementation. The reference PolicyContext carries actor, tenant, autonomy and session approvals; PermissionEngine evaluates resource arguments and ordered rules. It does not mint signed grants, inject credentials or distribute revocation. Preserve that distinction when borrowing its policy composition model for a production authoriser.
15.3Resource policies: absolute, and evaluated first#
Two capability classes matter enough to be modelled directly rather than left to per-tool logic.
Paths#
Filesystem access is the most common agent capability and the easiest to get subtly wrong.
@dataclass
class PathPolicy:
read_roots: tuple[str, ...] = ()
write_roots: tuple[str, ...] = ()
deny_globs: tuple[str, ...] = ("**/.env", "**/.git/**", "**/*.pem",
"**/id_rsa*", "**/.aws/**")
def check(self, path: str, *, write: bool) -> Verdict:
real = os.path.realpath(os.path.expanduser(path)) # normalisation only; see race caveat below
for pattern in self.deny_globs:
if fnmatch.fnmatch(real, pattern) or fnmatch.fnmatch(
os.path.basename(real), pattern.lstrip("*/")):
return Verdict(Decision.DENY, f"path matches deny rule {pattern!r}",
"path.deny")
roots = self.write_roots if write else (self.read_roots + self.write_roots)
for root in roots:
rroot = os.path.realpath(os.path.expanduser(root))
if real == rroot or real.startswith(rroot + os.sep):
return Verdict(Decision.ALLOW, f"inside allowed root {root}", "path.allow")
return Verdict(Decision.DENY,
f"path {real} is outside every allowed {'write' if write else 'read'} root",
"path.outside")Resolve before comparing. os.path.realpath collapses .. and follows symlinks. A policy that compares the string is defeated by workspace/../../etc/passwd, and by a symlink inside the workspace pointing anywhere.
Write roots are narrower than read roots. Read scope protects confidentiality; write scope also protects integrity. Both should be task-limited. Making these separate fields rather than one list forces the distinction to be made.
Deny globs beat allow roots. A .env file inside the workspace is still a secret. Deny rules are checked first and are absolute.
startswith(root + os.sep), not startswith(root). Without the separator, /data/workspace-evil passes a check for /data/workspace. A one-character bug with a total bypass.
realpath() followed later by open() has a time-of-check/time-of-use race: a symlink or directory can change between them. Deny globs also do not identify every secret. This teaching check is not a filesystem sandbox. Production isolation must constrain actual opens, including linked paths, inherited descriptors and alternate tool implementations; use platform-specific descriptor-relative operations or an OS-enforced filesystem boundary.
Egress#
Network access is the capability that converts a confused agent into a data breach, because exfiltration needs a channel and egress is the channel.
@dataclass
class NetworkPolicy:
allow_hosts: tuple[str, ...] = () # empty = deny everything
allow_schemes: tuple[str, ...] = ("https",)Default-deny egress with an explicit host allowlist. This removes one exfiltration path. Injection can still corrupt local work, misuse permitted APIs, or disclose through an answer or shared artifact (Chapter 21).
Enforce egress at the connection layer. A redirect from an allowed host can bypass a check on the original URL. DNS and other side channels can also disclose data, including through attacker-controlled hostnames. Inspect these paths rather than treating a URL allowlist as complete prevention.
15.4Ordered evaluation, and the three-valued verdict#
With resource policies in place, the engine composes them with contextual rules. Evaluation order is the design.
The non-bypassable resource check makes the policy ordering inspectable. You can point at any executed action and name the rule that permitted it, and you can assert in a test that no rule can widen a resource denial:
def test_resource_denial_beats_a_permissive_rule(tmp_path, toolset):
engine = PermissionEngine(rules=[Rule("everything", Decision.ALLOW, tools=("*",))],
default=Decision.ALLOW)
ctx = PolicyContext(paths=PathPolicy(read_roots=(str(tmp_path / "safe"),)))
v = engine.evaluate(ToolCall("read_file", {"path": "/etc/passwd"}),
toolset.get("read_file"), ctx)
assert v.decision is Decision.DENY, "a rule must not widen a resource policy"A companion test checks the constructor’s shipped default. The constructor still accepts an explicit Decision.ALLOW, as the preceding resource-denial test demonstrates; this test does not forbid permissive configuration:
def test_default_is_never_allow():
assert PermissionEngine().default in (Decision.ASK, Decision.DENY)15.5The isolation ladder#
Policy decides what the agent may ask for. Isolation decides what it can reach if policy is wrong — a bug, an unmodelled capability, an injection that produced an action policy did not anticipate. You need both, because policy is code and code has defects.
Shipped systems land at different rungs, and their choices are informative. Hermes Agent makes the terminal backend pluggable — Docker, cloud sandbox providers, or local — and documents something worth pausing on: with the Docker backend, dangerous-command checks are skipped, “because the container itself is the security boundary.”1 That documented trade-off must be evaluated against mounts, credentials, network access and kernel exposure; a container label alone establishes none of those limits. OpenHands offers a Docker sandbox as default, a process mode with “no container isolation,” and a remote sandbox.2 Devin runs sessions in a VM built from a frozen snapshot where session changes do not persist back, and its CLI adds bubblewrap-based sandboxing on Linux that fails closed — refusing to start if sandboxing cannot initialise.34
That fail-closed behaviour is the detail to copy. A sandbox that silently degrades to no sandbox when unavailable is worse than none, because the system’s operators believe they have a boundary they do not have.
A comparison of documentation cannot prove a product has no isolation. Review the configured version, actual backend, mounts and execution path. Unattended execution of untrusted code needs a tested boundary regardless of product name.
Isolation mechanisms are not interchangeable. gVisor interposes a userspace application kernel; a microVM uses a separate guest kernel; WebAssembly confines code through its runtime and exposed host functions. All still need explicit storage and egress policy. The gVisor security documentation is a useful primary source for checking the boundary rather than inferring it from a deployment label.5
15.6Credentials the agent never sees#
The strongest available control, and the most commonly skipped: the agent should not hold credentials.
An agent that has an API key in its environment can put that key anywhere — in a file it writes, in a request body, in a log line, in its own answer. Models leak secrets not maliciously but incidentally: asked to debug an auth failure, a helpful agent prints the header.
The alternative is a credential-injecting proxy. The agent calls a local endpoint with no credential; the proxy attaches the real one, forwards, and returns the response. The agent can use the capability without receiving the credential. The proxy must also prevent secrets leaking through responses, redirects, debug output or accessible logs. Hermes documents exactly this shape — an egress proxy alongside a credential-injection proxy.1
- Credentials stay outside model context when injection, response filtering and logging are correctly enforced.
- Scope is enforced at the proxy, so a read-only token is genuinely read-only regardless of what the agent asks.
- Credentialed calls share one logging boundary, which supports the wider audit requirements in Chapter 30.
- Rotation does not touch the agent, which removes a whole category of operational coupling.
Layered on top: short-lived tokens scoped per task, and — for the highest-risk operations — a token minted only after a human approval, valid for one action.
Failure Mode
Never let a secret enter the model’s context, and treat any path by which one could as a defect. This includes: environment variables read by a tool that echoes its environment, configuration files inside a readable root, error messages that include a full request, and — the one that catches people — a .env in the workspace the agent was given read access to. Chapter 20’s secret-scanning guardrails are a backstop, not a control. The control is that the value was never there.
15.7Agent identity and delegated authority#
At enterprise scale, “the agent” needs to be a first-class identity, and the question “on whose behalf is it acting” needs a real answer.
Three models, in increasing order of defensibility.
A shared service account. One identity for the agent, with the union of all permissions any task might need. Simple, and the audit trail says “the agent did it” — which is useless for attribution and, in many regulated contexts, insufficient. This is where most systems start and too many stay.
Per-user delegation. The agent acts with the requesting user’s authority, so it can do exactly what that person can do and no more. Much better: authority is naturally scoped, revocation follows identity and token policy, including token expiry and cached grants, and the audit trail is meaningful. Harder to build, because it requires token exchange and it means the agent’s capability varies by caller — which surfaces as “the agent worked for Priya and not for me,” a support burden worth planning for.
Per-task scoped credentials. The agent requests a credential scoped to this task — these resources, this window — and holds nothing else. Strongest, and requires infrastructure most organisations do not yet have.
Two operational requirements regardless of model. The agent’s identity must be distinguishable in every downstream log, so a database audit shows “agent, on behalf of Priya, run 4471” rather than a service account. And it must be revocable in one place — a kill switch that stops all agent activity without a deploy (Chapter 20).
Govern agents as non-human identities. Their ability to initiate actions makes excessive or poorly tracked authority especially consequential (Chapter 30).
Identity answers who is acting; attenuation answers what this action may do. Figure 15.3 joins them. The deployment identity may be able to request broad authority, but the enforcement proxy accepts only the narrower task and action grants bound to the current actor, tenant and resource.
15.8The tenant field that enforced nothing#
An incident can pass every model evaluation and still be an authorisation failure. Consider this illustrative Reference Agent run. Priya asked it to summarise six support cases for tenant Acme and attach the result to case INC-4471. The trace looked clean: the model selected search_cases, retrieved six relevant records, produced a correct summary and wrote one attachment. The result was useful. It also contained two cases belonging to Northwind.
The first assumption was retrieval error. The search team checked relevance and found nothing surprising: all six records discussed the same payment gateway failure. The second assumption was prompt injection. The retrieved cases contained no instructions. The third assumption was model confusion. The tool call used the requested terms and looked reasonable.
The actual mechanism was a confused deputy. Reference Agent knew the tenant; the case service knew the tenant; the authority between them did not bind the tenant. PolicyContext.tenant was acme, but the illustrative production adapter called a broad service credential and passed only a text query. The downstream search therefore returned every semantically similar case that the service account could read. The model did not cross a boundary because no enforceable boundary existed at that layer.
This distinction matters in the actual reference code. PolicyContext includes a tenant string so an ordered rule can inspect it. PermissionEngine.evaluate() automatically checks arguments named path, file, filename, target, url, endpoint and uri; it does not automatically inject or validate a tenant. The supplied least_privilege() preset contains no tenant predicate, and the demonstration tools are not a multi-tenant data plane. The code demonstrates policy ordering, default denial and resource checks. Treating it as a complete tenant authoriser would grant a property it does not implement.
The fix was not “remind the model to include the tenant”. The team changed the boundary in four places:
- The run controller derived
tenant_id=acmefrom the authenticated request, not from model output. - The tool adapter removed
tenant_idfrom the model-visible arguments and attached the controller’s value to every request. - The case service required the tenant claim and applied it in the datastore query; a missing claim failed closed.
- The result envelope returned the tenant for every record, and the adapter rejected a mixed-tenant response before any content entered model context.
After the change, a call was not “search for gateway cases”. It was “under grant g-4471, search Acme’s case partition for gateway cases”. The model still chose the query. It did not choose the security principal or the partition. That is the correct separation of reasoning from authority.
Reconstruct the failure as a chain#
Record the incident as five links, identifying the property each safeguard should enforce:
| Link | What the team believed | What was actually enforced | Repair |
|---|---|---|---|
| Authenticated request | Priya selected Acme | Actor authenticated; tenant was UI state | Derive tenant from authorised server-side session |
| Run context | tenant=acme existed |
Context available to Python rules only | Bind tenant into the task grant |
| Tool schema | Search was “tenant aware” | Model supplied only free-text query | Inject tenant outside model-controlled arguments |
| Service call | Service would honour tenant | Shared credential could read all partitions | Require tenant claim and filter in storage query |
| Tool result | Six relevant cases implied six valid cases | Relevance checked; provenance scope was not | Validate every returned record before context admission |
The last row is defence in depth, not the primary fix. Checking returned records catches a broken downstream filter, but data from another tenant has already crossed one boundary by then. The primary control is server-side authorisation at the system that owns the data. The harness cannot compensate for a datastore that accepts a global credential and trusts a caller-provided filter.
The same confused-deputy shape appears outside tenancy. A file tool receives a safe workspace path but follows a server-side alias into a restricted share. A billing tool receives an approved account but lets the model select a broader portfolio identifier. A deployment tool validates the service name but uses credentials valid in every region. In each case, the agent carries the right context while the effectful service enforces a different, broader authority.
Test the chain at both ends. At the adapter, attempt to put a different tenant in the model’s arguments and confirm it cannot override the controller. At the service, call with a valid actor and the wrong tenant claim and expect denial. Return a deliberately mixed-tenant fixture and confirm the adapter rejects the entire response. Then omit tenant context and confirm the run cannot start. Four failing-closed tests establish more than a prompt that says “never access another customer”.
Finally, log the authority provenance needed to reconstruct the decision: authenticated actor, effective tenant, task-grant identifier, tool name, bound resource, deciding rule, downstream subject and result scope. Do not log the credential. Chapter 23 gives the trace structure, but the authorisation service must supply these fields; the model cannot reliably narrate them after the fact.
15.9Blast radius, and earning autonomy#
The security review’s question — what is the worst thing this can do — deserves a systematic answer rather than an intuition. Compute it.
For each capability, quantify reachable records, systems, money and reversibility to calculate exposure.
| Capability | Reachable scope | Reversible? | Blast radius |
|---|---|---|---|
read_file in workspace |
~4,000 files, one tenant | n/a | Read-only, tenant-scoped |
search_docs all tenants |
entire corpus | n/a | Cross-tenant read — fix the scope |
write_file in out/ |
~50 files, one tenant | ✓ via checkpoint | Low |
run_query read-only |
warehouse, all tenants | n/a | Cross-tenant read |
issue_credit |
any account, ≤$10k | ✗ | High — human approval required |
restart_service |
3 named services | ✓ minutes | Medium — approval below tier 3 |
Two things fall out of writing this table that do not fall out of discussing it. The cross-tenant rows become visible, and they are almost always there — a search tool built before multi-tenancy, still unscoped. And the genuinely irreversible row becomes obvious, which tells you exactly where Chapter 19’s approval gate belongs.
Then let autonomy be earned rather than granted, per capability rather than globally:
Level 0 — Read only. Observe and report. Ship here. Run for weeks. This is not a formality; it is where you discover what the agent actually does.
Level 1 — Propose. Draft the change and present it; a human executes. Measures proposal quality before execution; disclosure and misleading recommendations remain possible.
Level 2 — Act with approval. The agent executes after a human approves each action. The dominant mode for consequential capabilities in enterprises, and the mode Chapter 19 designs.
Level 3 — Act within bounds. Autonomous inside declared limits — under $500, these three services, business hours — with everything outside escalating. The productive steady state for well-understood capabilities.
Level 4 — Fully autonomous. Reserved for capabilities that are reversible, bounded and heavily evaluated.
The mistake is treating this as a global setting. Reference Agent runs at level 3 for restarting a service, level 2 for issuing a credit, and level 0 for anything touching the ledger — simultaneously. The reference PolicyContext.autonomy is one integer per context, so per-capability tiers require rules or separately scoped contexts, and the promotion criterion should be a number from Chapter 24’s evaluations rather than a feeling that it has been fine lately.
The capability manifest
Write down, in one reviewable file, exactly what the agent may reach. Use a versioned manifest that production code validates and loads, then test whether runtime enforcement matches it.
agent: reagent
tenant_scoped: true # every capability is tenant-filtered
identity: per_user_delegation
filesystem:
read: ["/work/{tenant}/**"]
write: ["/work/{tenant}/out/**"]
deny: ["**/.env", "**/.git/**", "**/*.pem", "**/.aws/**"]
egress:
allow_hosts: ["runbooks.internal.corp", "api.internal.corp"]
schemes: ["https"]
via: credential_proxy # the agent holds no tokens
capabilities:
- {tool: read_file, autonomy: 3}
- {tool: search_docs, autonomy: 3, scope: tenant}
- {tool: run_query, autonomy: 3, scope: tenant, role: readonly}
- {tool: restart_service, autonomy: 2, targets: [reports, etl, dash-api]}
- {tool: issue_credit, autonomy: 1, max_amount_usd: 500, approver: billing_lead}
kill_switch: config://reagent/enabled # flips without a deployThis valid YAML is a proposed production format, not a configuration file the reference harness loads. A loader must validate it, bind {tenant} from authenticated context and translate every field into an enforced policy; tenant_scoped: true alone enforces nothing.
The capability manifest is reviewable by non-engineers, loaded and checked at runtime, and diffable so permission changes are visible in review. Test for runtime drift and unenforced fields.
15.10Measure whether the boundary operates#
A permission policy can be correct on paper and unusable in operation. If it asks too often, people approve reflexively. If it denies without a repair path, the model repeats the same call. If most actions fall through to the default, your explicit rules do not describe the workload. Measure the boundary as a product surface as well as a security control.
Start with one ledger row per proposed action, including actions that never execute. Record run and call identifiers, actor, tenant, tool, resource class, mutability, reversibility, decision, deciding rule, reason, grant identifier, approval outcome and whether execution occurred. Record bound values as stable identifiers rather than raw customer data. The action proposal is the denominator; logging only executed calls makes denials and abandoned approvals disappear.
Five measures expose different defects:
Default-decision rate. actions decided by default ÷ all proposed actions. A high value means the policy’s named rules do not describe real traffic. ASK as a safe default still creates operational debt when it handles routine work.
Unchanged-denial retry rate. denial events followed by an unchanged repeat before new evidence ÷ denial events. The ToolCall.signature() in the reference harness canonically identifies the exact name-and-arguments combination, and the loop tells the model not to retry. A high rate means the denial observation is unclear, the alternative tools are invisible or the loop needs a deterministic stop.
Approval yield and abandonment. Split requests into approved, declined, expired and abandoned. A 98% approval rate is not automatically success; it can mean the gate asks questions with no decision value. Pair the rate with time-to-decision and outcome quality (Chapter 19).
Authority utilisation. weighted authority exercised ÷ weighted authority granted over a task or deployment window. Reuse the exposure weights from the earlier calculation. Low utilisation reveals standing permissions that tasks rarely need. This is a redesign signal, not a target to inflate by using more authority.
Scope-violation catch location. Record which layer rejects an attempted scope violation: harness, proxy, service or result validator. Enforce authority at the owning resource as well as earlier boundaries. A result validator can detect a leak after data already crossed a boundary.
Suppose an illustrative week contains 82,000 proposed calls. The engine allows 70,110, asks on 8,960 and denies 2,930; these sum to 70,110 + 8,960 + 2,930 = 82,000. Of the denied calls, 791 are followed by an unchanged repeat before new evidence. The per-denial-event retry rate is 791 ÷ 2,930 = 27.0%. That is not a model-quality curiosity. It is 791 wasted turns and 791 attempts against a boundary that already answered.
Now inspect the 8,960 asks. If 4,100 came from the default rather than a named rule, the default-ask rate within approvals is 4,100 ÷ 8,960 = 45.8%, while the all-action default-ask rate is 4,100 ÷ 82,000 = 5.0% (assuming no other default decisions). Both numbers matter. Five per cent sounds modest across all traffic; nearly half of the human queue being unclassified is an operability problem.
| Symptom | Likely mechanism | First repair |
|---|---|---|
| Default decides a large share of calls | Tool catalogue changed faster than policy | Add named rules from reviewed trace clusters; keep default at ASK or DENY |
| Exact denied call repeats | Denial lacks an actionable alternative | Return deciding rule, immutable constraint and permitted next action; stop repeat signatures |
| Almost every approval is granted | Gate covers routine or low-risk work | Move proven reversible bounds to level 3; retain exceptions and irreversible actions |
| Approvals expire or are abandoned | Request lacks consequence, scope or urgency | Show exact effect, resource, reversibility, expiry and fallback |
| Authority utilisation stays near zero | Standing identity is much broader than tasks | Split credentials and mint task-scoped grants |
| Scope violations reach result validation | Upstream filter is being trusted as authorisation | Enforce scope in the resource-owning service |
Do not turn these illustrative percentages into universal thresholds. Establish a baseline per capability, review the tails and set promotion criteria from your own failures. A reversible report export and an irreversible credit should not share an acceptable approval or denial profile.
Connect the measures to autonomy. Promote one bounded capability only when its evaluated success rate clears the Chapter 24 threshold, its scope-violation count is zero at the resource boundary, its rollback path has been exercised and its authority utilisation supports a narrower grant. Demote automatically after a policy-version change, a new resource class, a serious incident or sustained metric drift. Autonomy is a revocable operating state, not a badge the agent earns forever.
The reference implementation already supplies useful seeds: every Verdict carries a decision, reason and rule; a tool call has a stable signature; risk metadata distinguishes mutation and reversibility; approval events record the decision. A production telemetry ledger is still an extension. It must add durable actor, tenant, grant, resource and policy-version fields, and it must retain denied proposals even though no tool executed.
Definition
Capability — a specific authority to act on a specific resource, granted independently of other authorities.
Blast radius — the maximum damage reachable through the authority an agent currently holds, computed rather than estimated.
Credential-injecting proxy — an intermediary that attaches real credentials to outbound calls so the agent can use a capability without ever possessing the secret.
Key Takeaways
- The unit of least privilege is the action, not the agent. Every gap between what the service account can do and what this task requires is blast radius you accepted without noticing.
- Resource policies are absolute and evaluated first. Resolve paths before comparing them, keep write roots narrower than read roots, check deny globs first, and remember that
startswith(root)without a separator is a total bypass. - Authority must narrow monotonically: a rule cannot widen a resource denial, and tool risk metadata cannot widen a rule. Test this ordering and the shipped default. The reference constructor permits an explicit
ALLOWconfiguration, which requires separate review. - Policy and isolation are both required, because policy is code and code has defects. The rung on the isolation ladder is set by one question — whose code runs inside — and a sandbox that silently degrades when unavailable is worse than none. Fail closed.
- The agent should hold no credentials. A credential-injecting proxy can keep credentials outside the agent, scope is enforced outside the agent, every credentialed call is logged in one place, and rotation does not touch the agent.
- Give the agent a real identity with delegated authority, distinguishable in every downstream log and revocable in one place without a deploy.
- Compute blast radius in a table. The cross-tenant rows and the irreversible rows become visible, and they tell you where scoping and approval gates belong. Then earn autonomy per capability, promoting on evaluation numbers rather than on it having been fine lately.
- Treat authority as a measurable budget. In the illustrative Reference Agent task, task scoping cuts the weighted exposure index from 2,484,060 to 70 — a 35,486.6-fold reduction before model quality changes at all.
- Context is not enforcement. The reference
PolicyContextcarries a tenant but does not automatically authorise tenant scope; bind scope outside model-controlled arguments and enforce it again in the resource-owning service. - Instrument every proposed action, including denials and abandoned approvals. Default-decision rate, unchanged-denial retries, approval yield, authority utilisation and catch location tell you whether the boundary is both safe and operable.
Hands-on
Answer the security review’s question
1. Write the blast-radius table for every capability your agent holds today. Mark every row that can reach data outside the current tenant. Those are your highest-priority fixes and there are usually more than you expect.
2. Test your path policy adversarially: .. traversal, a symlink out of the workspace, a .env inside it, and a sibling directory whose name extends an allowed root. At least one will probably pass.
3. Set your egress allowlist to empty and run your eval suite. Every failure names a host you did not know you depended on. That list is your allowlist, and it is shorter than your current effective one.
4. Find every place a credential could reach model context — environment echoes, config files in readable roots, verbose errors. Then design the proxy that makes the question moot.
5. Write the capability manifest and make the running system load it. Take it to whoever does your security review and watch how different that conversation is from the one about prompts.
6. Calculate an illustrative exposure index for one real task and for the deployment identity that currently serves it. Keep confidentiality, integrity and availability subtotals, then remove the largest excess grant before tuning prompts.
7. Trace tenant or account scope from authenticated request to the resource-owning service. At every hand-off, ask whether scope is a bound claim or model-controlled text. Add wrong-tenant and missing-tenant tests at both the adapter and service boundary.
8. Build a one-week permission dashboard from proposed actions. Investigate default decisions, exact denial retries, near-universal approvals and scope violations caught only after results return.
-
Security — Hermes Agent user guide. Nous Research. Accessed 9 November 2026. https://hermes-agent.nousresearch.com/docs/user-guide/security ↩↩
-
Sandboxes overview — OpenHands. https://docs.openhands.dev/openhands/usage/sandboxes/overview ↩
-
Devin’s environment — Cognition. https://docs.devin.ai/onboard-devin/environment ↩
-
Devin CLI sandboxing — Cognition. https://docs.devin.ai/cli/sandbox ↩
-
gVisor project. What is gVisor? Accessed 8 September 2026. https://gvisor.dev/docs/ ↩