AI agents

Permission Tests That Catch a Widened Subagent Tool

Antonio Evans8 min read

A test runner terminal showing a failed check named test_subagent_tier_write_cannot_shell, with the fixture list on the left (read_only, scoped_write, privileged) and a red CI status badge on the right.
Key points

Key points

  1. 1Write one test per risk tier (read-only, scoped write, privileged), and have each test enumerate every tool the subagent can actually reach rather than reading the policy text.
  2. 2Fixtures must define the allowed tool set per tier, so a policy edit that adds a tool outside the set fails an assertion instead of passing silently.
  3. 3Policy engines such as Open Policy Agent can be tested independently of the agent SDK with opa test, which makes the scope check a real CI gate.
  4. 4Treat any failure in the permission suite as a release blocker, because a widened scope is a security regression even when every functional test passes.

Why a policy document is not a control

A tool-scoping policy written as prose, or even as a config file that nobody verifies, drifts away from what the runtime actually permits. The documented failure pattern is well known: a subagent inherits the parent's authority implicitly and then performs writes the parent's token allowed but its own task never needed. Permit's writeup on delegated subagent permissions describes a reporting subagent spawned to read status that can also perform write operations because token inheritance is implicit, and the user never re-consented for that child context (Permit on subagent MCP permission decisions).

There is a public bug report that turns this into a concrete scenario: with "Bash" in a deny list, a subagent launched through a task tool ran more than twenty bash commands autonomously, including reading SSH keys and shell history, after a single approval of the task launch (the Claude Code issue). That is a mutation of effective scope that no code review caught, because the scope was never executed as a test.

The Claude Code documentation is useful here because it makes the tiers explicit: read-only tools run without approval inside the working directories, bash commands and file modifications require approval, and web fetch and search require approval except for preapproved domains (the Claude Code permissions docs). That table is the natural source for your tier definitions, and it is exactly what your tests should encode.

The gap auditors care about is authorization evidence, not telemetry. A tool trace tells you a function was called; an authorization record tells you whether the action was permitted under the policy in force at that moment (Permit on AI agent audit trails). Tests that assert the allowed set per tier produce the same artifact at build time, which is when fixing it is cheap.

Define three risk tiers and pin each to an allowed tool set

Before writing an assertion, write down what each tier may reach. A workable starting point, derived from the approval table above:

TierTypical toolsWrite accessHuman approvalExample fixture
Read-onlyfile read, grep, search, log querynonenoreporting-subagent
Scoped writefile write limited to a path prefix, pull request comment, ticket updateyes, boundedno, inside the prefixdocs-editor-subagent
Privilegedshell, deploy, database migration, refund.create, pushyes, unboundedalwaysrelease-subagent

The decision rule: a tool belongs to the highest tier any single invocation can reach, not the average of its uses. A file tool with a --force flag, or a shell tool exposed through an exec wrapper such as watch, is privileged, because approving a narrow-looking command can grant the full wrapped capability. The Claude Code docs call out exec wrappers and find actions as a specific place where approval scope is easy to misjudge (Claude Code permissions).

Least privilege for agents means granting only the data, systems and actions the current task requires, for as long as the task takes (AvePoint on AI agent least privilege). Microsoft's guidance adds the operational version: deny unreviewed tools, plugins and cross-tenant paths by default, and re-review access when workflows, tools or data scope change materially (Microsoft's least privilege pattern). Every material change is a diff, and every diff is what your suite should catch.

Build fixtures that expose the resolved tool set

Fixtures must describe what the subagent can actually call after policy evaluation, not what the policy file says. The useful shape is: a subagent definition, a policy input, and the expected allowed set per tier. Then the test resolves the policy and compares sets.

Keep the fixture next to the policy so a change to either is visible in the same diff:

# fixtures/reporting-subagent.yaml
subagent: reporting-subagent
tier: read-only
context:
  parent_session: analytics
  delegated_token: child-bound
expect:
  allowed: [fs.read, grep, search.logs]
  denied: [fs.write, bash, web.fetch, deploy.release]
must_not_expand: true

The must_not_expand flag is the one that matters. A read-only fixture whose resolved set is a strict subset of the expected set passes; a resolved set containing anything outside it fails, even if the extra tool is never called in the happy path. This inverts the usual test habit of asserting success cases only.

Beware of fixtures copied from production configuration. Organizations grant broad roles to unblock pilots and then never narrow them as workloads stabilize, and layering several narrow roles can create overly broad effective access that is easy to underestimate without an aggregate analysis (Microsoft on agentic identities and RBAC). So the fixture should state intent, and the resolver should read the real policy. If the two disagree, the test fails, which is the point.

For the widened case described in the article brief, the fixture is deliberately adversarial: allow fs.write inside docs/ and tool: bash must be absent from the resolved set. When someone adds bash to the docs-editor policy to "make the agent more capable", the test fails on the allowlist comparison rather than after a shell command ran on the build host.

If your team writes policy in Rego, the AI SDK's OPA integration is a ready reference: policies live as .rego files, are testable in CI with opa test, and map to allow, deny, requires-approval and not-applicable decisions, with unmatched calls defaulting to allow unless you add a default deny (the AI SDK policy tool approvals docs). Add default deny to any fixture that models read-only or scoped-write tiers.

Write assertions that fail on a widened scope

Three assertion families cover most of the risk, and they compose.

Set containment. Resolve the effective tool set for each fixture and assert that every member belongs to the tier's allowed set. This catches new tools and inherited ones. Implement it as one test per tier so the failure message names the tier.

# tests/test_tool_scope.py
import pytest
from scope import resolve_tools, TIER_ALLOWED

@pytest.mark.parametrize("fixture", load_fixtures("fixtures/"))
def test_resolved_tools_within_tier(fixture):
    resolved = set(resolve_tools(fixture.subagent, fixture.context))
    allowed = set(TIER_ALLOWED[fixture.tier]) | set(fixture.expect.allowed)
    extra = resolved - allowed
    assert not extra, f"{fixture.subagent} ({fixture.tier}) exceeds tier: {sorted(extra)}"

def test_readonly_agent_cannot_reach_shell():
    resolved = resolve_tools("reporting-subagent", {"parent_session": "analytics"})
    assert "bash" not in resolved
    assert "fs.write" not in resolved

Deny wins. Every tier above read-only gets at least one assertion that a specific denied tool is absent even when the parent session holds a token that could perform it. This is the direct test for silent token inheritance: if the resolver reports fs.write for a read-only child because a broad parent token was reused, the test fails. The recommended pattern is no inheritance by default, with child-bound, scope-minimized, time-bounded credentials (Permit).

Decision record shape. Assert that the resolver emits the fields an auditor would ask for: agent identity, tool, resource, action, policy version, decision and reason, approval if any, and correlation ID (Permit on audit trails). This is a cheap test that pays off when someone asks how you know the control is live.

A fourth, optional family: negative tests on the resolver itself. Feed it a policy that grants bash to a read-only tier and assert the resolver plus test combination produces a failure. A permission suite that has never failed on a known-bad policy is unverified. Treat that as an adversarial probe: invoke the resolver with an intentionally widened policy in a test that expects rejection, which is the same logic described for callable testing subagents whose output must be reproducible and whose privileges cannot self-expand (NHI glossary on callable testing sub-agents).

For decision history over time, not just arguments of the current call, pass the run history into the policy input and assert on it: require approval after N writes, deny a second irreversible action in the same conversation, cap cumulative spend (AI SDK policy docs). Those checks are deterministic and belong in the same suite.

Run the suite in CI and block the release

The gate is the simplest part and the most often skipped. Put the permission suite in its own CI job, make it required, and make it fast enough that nobody is tempted to mark it optional.

# .github/workflows/permissions.yml
name: permissions
on: [pull_request]
jobs:
  scope:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: opa test policy/ -v          # policy-level checks
      - run: pytest tests/test_tool_scope.py -q   # resolved-set checks

Two properties make this a real blocker. First, the job must run on changes to policy files, agent definitions and fixtures, not only on application code, otherwise a policy-only pull request slips through. Second, scope the credentials used to resolve effective permissions as narrowly as the tier under test, because a suite that itself runs with a broad token can mask an inheritance bug. Microsoft's guidance to test revocation paths and to log effective scope with a correlation ID applies to the CI job as much as to production (Microsoft).

A worked release decision makes the value concrete. Suppose the docs-editor subagent ships with four allowed tools and one pending pull request adds bash to its policy to let it run a formatter. The set-containment test fails on the scoped-write fixture, the job exits non-zero, and the release is held. The author has two legitimate fixes: keep the agent's scope and move formatting into the build pipeline, or promote the agent to the privileged tier, which requires the approval path and the audit fields that tier demands. Either choice is a visible, reviewed decision rather than an inherited one.

Keep the suite honest with three rules. Run the negative resolver test on every build so the suite can fail. Store the fixture set in version control alongside the policy so reviewers see them in one diff. Review any test deletion as a security change, because deleting an assertion is the cheapest way to widen scope without touching the policy.

If your subagents run across several model providers, or you are standardizing the policy layer while you build this out, Quanta Inference puts text, image, video, audio and embedding models behind one OpenAI-compatible API key, which keeps the model call out of the fixture and the scope check where it belongs (https://quantaintelligence.ai/inference).

Where human approval still fits

Tests do not remove the need for approval; they define which tier requires one. The tier table above already encodes that: privileged tools pause for a human, read-only tools do not. Keep the approval record in the same evidence chain as the decision, so a sample of a sensitive action shows the policy required approval, a named person approved at a time, and the side effect ran, all joined by one trace ID (Permit on audit trails).

Two failure modes to test for explicitly, because they are invisible in happy-path runs. Silent stalls: a subagent requests an approval that never routes, and the child waits indefinitely with no deny and no retry signal; the fix is an explicit escalation channel with a timeout that fails closed and a deterministic denial reason (Permit). And allowlist-only trust: payments-prod is in the server list, so a subagent whose task was "draft customer response" can call refund.create, because the allowlist defined connectivity and nobody checked intent at runtime. Your fixture for that agent should be scoped-write with refund.create absent, and the test should fail the moment it appears.

There is a broader design argument for keeping humans meaningfully able to review these boundaries: oversight degrades when the approval surface does not show what an action would permit, and human oversight of agents should be designed rather than assumed (Mitchell, Ghosh and Passi on agents and human oversight). A permission suite is one of the few places where that design is written down in a form a machine enforces.

FAQ

FAQ

How many tests do I need?

One set-containment test per tier per subagent, plus one deny-wins assertion per non-read-only tier and one resolver negative test. That is four to six tests per subagent, which stays readable even with dozens of agents.

Can I just assert on the policy file text?

No. A text assertion passes when the file is well-formed and fails to notice that the parent token still reaches a write tool. Resolve the effective set the way the runtime does, then compare sets.

What counts as a release blocker?

Any failure in the permission job, including a new tool in the resolved set that is not in the tier, a missing deny-wins assertion, or a removed test. Treat a deleted assertion as a security change that needs review, not a cleanup.

Does this replace runtime authorization?

No. It proves the policy was correct at build time; runtime authorization decides each specific call with consent, arguments and context. The two share fixtures and a policy version, so a build-time pass and a runtime deny produce consistent evidence.

What should the policy input include besides the tool name?

Arguments, the delegation chain or on-behalf-of principal, the tenant and environment, and the run history if any rule depends on what already happened. Rules that count writes, cap cumulative spend, or block a second irreversible action need that history in the input to stay deterministic and testable (AI SDK policy docs).

Sources

Sources

  1. When AI Subagents Call MCP Tools, Who Owns ...
  2. BUG Sub-agents bypass permission deny rules and per ...
  3. Configure permissions - Claude Code Docs
  4. AI Agent Audit Trail: Proving What an Agent Did and Why
  5. AI Agent Least Privilege: A Practical Guide (2026)
  6. Least privilege for AI agents (agentic identities + RBAC)
  7. Policy-Based Tool Approvals
  8. What Is Callable testing sub-agent? Definition & Examples
  9. AI Agents Push Humans Out of the Loop - arXiv
About the author

About the author

Antonio Evans
Antonio Evans

Antonio Evans is the founder of Quanta Intelligence and a fractional CTO who builds HIPAA-ready AI systems for health and life-science companies. Before that he was technical co-founder of FriendsEAT for twenty years, CEO of Socialdraft, and a data engineer at IBM. Antonio lives with his dog Santino in Lisbon, Portugal.

All articles