AI agents

A Tool Gateway That Refuses on Tier, Not on Argument

Antonio Evans10 min read

A horizontal request path diagram with five labeled boxes: Client, Gateway, Tier Check, Policy Cache, Tool.
Key points

Key points

  1. 1Put the tier check before argument parsing and before any tool dispatch, so the gateway refuses on identity and entitlement rather than payload shape.
  2. 2Return a 403 with a stable reason code, and log the caller identity, tool, required tier and caller tier as an authorization record, not a debug line.
  3. 3Resolve the effective tier as the weaker of the agent grant and the human entitlement, and never let a subagent inherit its parent's tier implicitly.
  4. 4Write fixtures with a spy adapter that assert the tool was never invoked, because the passing case alone proves nothing.

Where the tier check belongs in the request path

A tool gateway has one job on the critical path: decide whether this caller, right now, may cause this side effect. Argument validation, payload shaping and response normalization all happen only after that decision is a yes.

The argument for putting the tier check first is not aesthetic. If the gateway parses arguments before checking entitlement, a caller with the wrong tier still gets their payload inspected by your code, and any expensive validation or partial side effect that occurs during parsing runs for a caller who should never have reached that code. Subagent permission work makes the same point: a delegated worker with the parent's scope is "privilege carryover, not least privilege," and the fix is a runtime decision before each tool execution rather than static inheritance the subagent MCP analysis.

Order matters because tool calls are where intent becomes a side effect. Logging authorization at the gateway rather than inside the model is recommended precisely because that is "the moment intent becomes a possible side effect" the agent audit trail guide. A tier check that fires after argument parsing is still an authorization check, but it has already spent CPU and trust on a caller it is about to reject.

The sequence, from arrival to dispatch:

  1. Authenticate the caller and resolve the principal, including any agent acting on a human's behalf.
  2. Resolve the requested tool identifier to a registered descriptor carrying the required tier.
  3. Compare the caller's tier against the required tier.
  4. On failure, log the refusal, return the 403 and stop. Nothing downstream runs.
  5. On a pass, parse and validate arguments.
  6. Apply remaining policy, such as approval requirements for destructive calls.
  7. Dispatch to the tool adapter and record the outcome.

Step 3 is the decision point. Steps 5 through 7 are what your fixtures must prove never ran.

One counterargument deserves attention: some teams want argument shape inside the authorization decision so a low-tier caller can read but not write on the same tool. That is legitimate, but it is a different decision than tier enforcement. Fold argument inspection into the tier check and you have quietly moved the parser back in front of the refusal. Keep tier a property of the tool, and use a separate, later policy step for argument-sensitive rules. Policy engines support this split: Open Policy Agent backed tool approvals express scope, role and tenant checks and can consult conversation history for thresholds, while the guidance is clear that deterministic checks over structured fields are the strong fit and semantic judgment belongs in a classifier step the AI SDK policy tool approvals documentation.

Placement of tier checkParser runs for denied callersTool reachable before denialFixture can prove no dispatch
Before argument parsingNoNoYes
After parsing, before dispatchYesNoYes, but parser ran
Inside the tool adapterYesYesNo, adapter invoked
Only as a prompt instructionYesYesNo

The refusal contract: status, reason and log fields

A refusal is an interface. If you do not define it, every caller interprets your denials differently and every auditor asks a question your logs cannot answer.

The minimal contract has three parts. The status is 403, signalling an authenticated caller who is not permitted. The body carries a stable machine-readable reason code, the requested tool, the required tier and the caller's tier. The gateway writes an authorization record with the same fields plus identifiers that let an investigator join the refusal to a trace.

Auditors ask specific questions about a sensitive action: who initiated it, whether approval was required, which policy allowed it, and whether denies are logged at all the agent audit trail guide. That last point is the one teams skip. A log of successful tool calls cannot show the control is active. The recommended evidence envelope includes agent identity, the human principal or delegation chain, tool and parameters, policy ID and version, decision plus reason, approval record, timestamp, request and trace IDs, tenant and environment, and the outcome of the side effect.

A workable refusal record:

{
  "event": "tool.gateway.refusal",
  "decision": "deny",
  "reason_code": "TIER_INSUFFICIENT",
  "caller_id": "agent:reporting-worker-17",
  "principal_id": "user:8f2c1a",
  "tool": "refund.create",
  "required_tier": "payments-write",
  "caller_tier": "payments-read",
  "policy_id": "tool-gateway-tiers",
  "policy_version": "14",
  "request_id": "req_01J9",
  "trace_id": "4bf92f3577b34da6",
  "tenant": "prod-eu",
  "side_effect": "none"
}

Three fields deserve attention. reason_code must be an enumerated value, not prose, because you will alert on it. policy_version lets you reconstruct which rules were in force, which is what makes replay meaningful: replaying reconstructs the decision from the same principal, tool, resource, policy version and attributes rather than re-running a model the agent audit trail guide. And side_effect: "none" is a claim, not decoration. Your fixtures prove it.

Keep the refusal log separate from prompt text. Prompts belong under a different privacy and retention model than authorization evidence, and mixing them weakens both.

Reading the tier: lookup, delegation and the parent trap

The tier check is only as good as the lookup behind it. Two failure patterns account for most broken implementations.

The first is implicit inheritance. A subagent is a new execution principal with its own instructions and often a narrower task, and treating it as identical to the parent collapses the trust boundary the subagent MCP analysis. Real reports show the consequence: an issue filed against Claude Code describes subagents spawned through the Task tool running shell commands without inheriting the user's configured deny rules the subagent bypass report. Whatever a given product does about it, the lesson is that the tier must be resolved for the calling principal, never copied from a parent by default.

The second is treating an allowlist as authorization. A configured list of permitted tools answers what could ever be called. The gateway answers what is allowed right now for this caller, this action, these arguments and this consent the subagent MCP analysis. Microsoft's guidance draws the same line: deny unreviewed tools by default, and log agent identity, role, effective scope, action, resource, correlation ID and the on-behalf-of user Microsoft's least privilege guidance.

A lookup that respects both:

TIER_RANK = {"anonymous": 0, "basic": 10, "payments-read": 20,
             "payments-write": 30, "admin": 40}

def resolve_caller_tier(principal, agent):
    # Agent tier is bound at issuance. No fallback to the parent's tier.
    record = tier_store.get(agent.id)
    if record is None or record.revoked:
        return None
    human = tier_store.get(principal.id)
    if human is None:
        return None
    # Effective tier is the weaker of the agent grant and human entitlement.
    return min(record.tier, human.tier, key=lambda t: TIER_RANK[t])

The min is the decision rule worth arguing for. An agent acting for a user should never hold more than either its own grant or the user's entitlement. This matters when an agent is provisioned with broad credentials during a pilot and never narrowed, the permission creep pattern Microsoft names as a common cause of excessive effective access Microsoft's least privilege guidance. The broader evidence supports treating this as ordinary access control: among organizations that experienced an AI-related breach, 97 percent lacked proper AI access controls, according to IBM's Cost of a Data Breach Report as cited by AvePoint the AvePoint least privilege guide.

Fail closed on a missing or stale tier record. A lookup that errors and returns a default tier is worse than one that refuses, because the default is almost always the permissive one.

Fixtures that fail if a widened scope reaches the tool

A policy test that passes proves your rule matched a happy case. It does not prove the gateway refuses, and it certainly does not prove the tool was untouched. That is what runtime fixtures add.

The technique is a spy adapter. Wrap every tool in a test double that records invocations, and assert the count is zero on denied paths. Without that assertion, a gateway that returns 403 after silently calling the tool passes your status-code test.

def test_widened_scope_never_reaches_tool():
    adapter = SpyAdapter("refund.create")
    gw = Gateway(tools={"refund.create": adapter},
                 required_tier={"refund.create": "payments-write"})

    resp = gw.handle(caller=caller(tier="payments-read"),
                     tool="refund.create",
                     args={"amount": 10, "order": "o_1"})

    assert resp.status == 403
    assert resp.body["reason_code"] == "TIER_INSUFFICIENT"
    assert adapter.calls == 0
    assert adapter.any_side_effect() is False

Add a parser spy if your gateway has a distinct validation stage, and assert it was never entered for a tier denial. That is the ordering property your design claims.

A second fixture class covers delegation. Spawn a child agent with a narrower tier than its parent, request a tool the parent could use, and assert the child is refused with a reason code naming the tier mismatch rather than a tool-not-found error. That fixture catches inheritance regressions before they reach production.

FixtureSetupStatusReason codeTool calls
Allowed at exact tiercaller equals required200none1
Denied, one tier belowcaller one rank lower403TIER_INSUFFICIENT0
Denied, child narrower than parentchild below required, parent above403TIER_INSUFFICIENT0
Unknown tooltool absent from registry403TOOL_UNKNOWN0
Missing tier recordtier store returns null403TIER_UNRESOLVED0
Revoked grantagent grant revoked403TIER_REVOKED0
Passed tier, bad argumentstier sufficient, args invalid400ARGUMENT_INVALID0

Note the ordering evidence in the last two rows. A caller who passes the tier check but sends bad arguments gets a 400 with no tool calls, and a misconfigured tier record never falls through to an argument error. If your gateway returns 400 for an unresolved tier, the ordering is backwards and the fixtures say so.

Run these in CI alongside the policy tests. Policy engines are built for this: rules as a separate reviewable artifact, testable in CI with the engine's own runner, independent of the SDK the AI SDK policy tool approvals documentation. The gateway fixtures then prove the tested policy is the one on the request path.

Human approvals and the tier you cannot automate

Tier enforcement and human approval solve different problems. A tier is a standing property of an identity. An approval is a one-time decision about one action under one set of conditions. Conflating them produces a gateway that either over-blocks or under-blocks.

Where an action is both high tier and high consequence, the gateway should return a third outcome. Policy engines model it directly: allow runs the tool, deny returns a result the model can reason about, and requires-approval pauses the run until a human responds the AI SDK policy tool approvals documentation. Watch the default. In that SDK an unmatched call normalizes to a status treated as allow, so the documentation recommends adding a default-deny rule the AI SDK policy tool approvals documentation. A gateway without a default deny is one configuration mistake from open.

Approvals must produce evidence, not just a pause. Record the approval ID, who approved or rejected, when, and which request it applied to, linked to the same trace ID as the tool call the agent audit trail guide. Otherwise you can show a human clicked something but not that the control decided correctly.

The oversight literature explains why approvals alone are not a safety story. A position paper argues that current agent design impedes effective oversight and that extended use degrades the cognitive capacity needed for it, so oversight has to be designed for at the interface and organizational level rather than assumed the arXiv paper on agents and human oversight. A gateway that refuses on tier does the opposite of assuming oversight: it keeps the decision out of the human's queue unless the action genuinely needs a person.

The same reasoning applies to escalations that silently stall. When a subagent requests a tool needing consent and no escalation event is routed, the child waits indefinitely and operators cannot tell whether it was denied or never reviewed the subagent MCP analysis. Give approvals a timeout with fail-closed behavior and a deterministic denial reason.

Test cases for allowed, denied and misconfigured scopes

Beyond the runtime fixtures, cover the scope logic itself.

Allowed. The caller holds exactly the required tier and the tool has no other constraints. The caller holds a higher tier. A child agent holds a tier equal to the required tier and its parent holds the same. An approval was granted within the timeout and the tool is dispatched exactly once.

Denied. The caller holds one tier below required. The agent grant is above required but the human entitlement is lower, and effective tier resolves to the lower. The child's tier is below required though the parent's is above. The policy in force raised the required tier for this tool after the grant was issued. A harness attempts a call after its grant is revoked.

Misconfigured. The tool is not registered. The tool is registered without a required tier. The tier store returns null or errors. Tier names in policy and caller record differ by case or whitespace. The default deny rule is missing from the bundle. The gateway runs an older bundle than the tests exercised.

That last case is not hypothetical. The split between gateway enforcement and service-level policy is a known source of drift: gateways standardize coverage while service-level rules add precision, and the two must be monitored for gaps and bypasses the analysis of gateway versus service-level enforcement. A gateway on a stale bundle is one where tested and enforced policy differ.

Two habits close the loop. Emit the policy version on every decision so drift is visible in logs, and alert on refusals during rollout: a spike in TIER_INSUFFICIENT after a policy change is either a legitimate tightening or a misconfiguration, and both deserve a look.

When to reach for a managed boundary

Build the gateway yourself if your tool surface is small and your policy logic is a few dozen rules. The design above is a few hundred lines plus fixtures. What gets expensive is breadth: every tool, every provider, every model, every tenant, plus an audit requirement that each of those has a decision record with a policy version.

That breadth is where a managed boundary earns its keep, and where one entry point for many models simplifies the gateway's own job. When every model call and tool call already traverses one governed path, the tier check has one home rather than one per provider integration. Quanta Inference puts text, image, video, audio and embedding models behind one OpenAI-compatible API, so the model side of your gateway stops multiplying Quanta Inference.

If your team is moving from a proven policy test to a production gateway and wants the surrounding work built with you, that is the kind of scoped build Quanta takes on: describe the gateway, the refusal contract and the fixtures, and a specialist works it through with you Quanta.

FAQ

FAQ

Should the tier check run before or after authentication?

After authentication, before everything else. You need a resolved principal to look up a tier, so authentication comes first, but the comparison precedes argument parsing, payload-inspecting approval checks and dispatch. If a request with an insufficient tier can make your argument parser execute, the check is too late.

What status code should a tier refusal return?

403 Forbidden, with a body carrying a stable reason code, the requested tool, the required tier and the caller's tier. Reserve 401 for unauthenticated callers and 400 for malformed arguments, and make sure an unresolved tier record gets 403 with its own reason code rather than falling through to a 400. Returning 404 to hide the tool is a defensible variant only if the reason code stays in the logs.

How do I prove the tool was never called on a denied request?

Use a spy adapter that records every invocation and any side effect, then assert zero alongside the 403 and the reason code. Add a parser spy if your gateway has a distinct validation stage. A status-code assertion alone cannot distinguish a gateway that refused before dispatch from one that called the tool and then returned an error.

What happens when a subagent has a higher tier than its parent?

That should not be possible if you resolve the effective tier as the weaker of agent grant and human entitlement and refuse implicit inheritance. If a child genuinely needs more authority for a task, treat it as a delegated grant with its own scope and time bound. A fixture that spawns a narrow child and asserts refusal catches the regression.

Do I still need human approvals if every tool is tier gated?

Yes, for actions where a single legitimate call is still consequential, such as irreversible deletes or payments above a threshold. Tier gating answers who may use a tool at all; approval answers whether this particular call, with these arguments, should proceed now. Model the third outcome explicitly, give it a timeout, and record the approval as evidence tied to the same trace ID.

Sources

Sources

  1. When AI Subagents Call MCP Tools, Who Owns ...
  2. AI Agent Audit Trail: Proving What an Agent Did and Why
  3. Policy-Based Tool Approvals
  4. BUG Sub-agents bypass permission deny rules and per ...
  5. Least privilege for AI agents (agentic identities + RBAC)
  6. AI Agent Least Privilege: A Practical Guide (2026)
  7. AI Agents Push Humans Out of the Loop - arXiv
  8. What is the difference between API gateway enforcement ...
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