PRACTICAL GUIDE / AI agent tool authorization trace grading

Grade the permission check before you trust the tool call

Test whether an AI agent was allowed to call each tool, whether enforcement preceded execution, and whether the trace clearly proves the decision in CI.

By The Testing AcademyUpdated August 7, 202623 min read
All field guides
In this guide7 sections
  1. Why a tool list is not a permission boundary
  2. Record evidence that can prove the decision
  3. Test three paths at the executor boundary
  4. Diagnose denials without confusing them with bad input
  5. Distinguish a valid denial from stale trusted context
  6. Roll the grader out without weakening policy
  7. Name the costs and know when to use a smaller test

What you will learn

  • Why a tool list is not a permission boundary
  • Record evidence that can prove the decision
  • Test three paths at the executor boundary
  • Diagnose denials without confusing them with bad input

The assistant is asked to summarize an invoice, but its trace shows a refund attempt before the summary appears. The refund was denied, so no money moved and the final answer looks harmless. A useful grade must separate three facts: the agent requested a forbidden action, the policy blocked it, and the executor never ran it.

Those facts belong to different owners. Planning quality determines whether the request should have been made. Authorization determines whether the authenticated caller may perform it. Execution determines whether a side effect occurred. Collapsing them into one “tool call failed” label hides both a model regression and a security control that worked.

Why a tool list is not a permission boundary

Showing an agent only the tools it usually needs is good design. It reduces irrelevant choices and can prevent accidental requests. It does not prove that a hidden capability is unreachable. Tool names can enter a plan through stale context, an injected instruction, a routing defect, or a direct request to the executor. The trusted boundary must reject an unauthorized invocation even when the planner behaves badly.

Put the check as close as possible to the operation. The policy input normally needs an authenticated principal, an action, a target resource, tenant or account ownership, relevant scopes or roles, and context such as environment or approval state. The model should not supply trusted identity or grant itself a scope in tool arguments. Derive those values from the session and the server-side resource lookup.

Authentication and authorization are related but different. In HTTP, MDN documents 401 as the response for missing or invalid authentication credentials, while 403 means the server understood the request and refused it, commonly because the authenticated client lacks permission. Internal tool protocols do not have to copy those status codes. They do need distinct states. “We do not know who called” and “we know the caller and deny this action” lead to different fixes.

The safest architecture has four steps:

  1. The planner proposes a tool and untrusted arguments.
  2. The executor resolves trusted context, including principal and resource ownership.
  3. A policy decision is made against the current policy version.
  4. Only an allow decision can reach the handler that performs the operation.

Record each step with a correlation value that is unique to the invocation. The grader later checks the record. It cannot retroactively protect the resource, so never move enforcement into the grader to simplify the runtime.

A common failure is authorizing only the tool name. The rule says support agents may call customer.read, so the request passes. The target customer belongs to another region or tenant, which the name-only check never considered. Tool-level scopes are useful coarse filters. Resource-level policy still has to verify the resolved target.

Another failure is trusting the requested resource attributes. If an argument says tenant_id equals the caller’s tenant, but the referenced invoice belongs elsewhere, an attacker can make the input look authorized. Resolve ownership from a trusted store, then make the policy decision. Keep the requested value for diagnostics only after redaction.

Time matters too. An agent may plan while a permission is valid and execute after it is revoked. Authorizing the plan once and replaying that decision later creates a time-of-check gap. Check again at the executor for sensitive operations. Record the policy version used for the actual decision, not the version the planner happened to see.

Record evidence that can prove the decision

A useful trace is small enough to protect and complete enough to grade. Capture a stable case ID, run ID, invocation ID, principal class, action, opaque resource ID, resource tenant, policy version, decision, safe reason code, executor state, and normalized outcome. Avoid access tokens, authorization headers, cookies, full prompts, raw document bodies, and unrestricted tool arguments.

The Authorization request header can contain credentials, as MDN’s reference explains. That makes it an input to authentication, not a debugging field to copy into an evaluation artifact. A redacted header name is rarely useful either. Record a safe authentication state such as authenticated, missing, or invalid, plus an opaque principal reference if the test needs correlation.

Transport context can join events across services. W3C Trace Context defines a propagation format for trace context, but it does not turn arbitrary application attributes into proof of authorization. A shared trace ID tells you that a policy event and an executor event may belong to one distributed operation. Your invocation ID and event schema still need to show which decision governed which action.

The following local module makes that boundary explicit. It does not represent a framework API. Save it as auth_boundary.py and call invoke from the server-side executor. Handlers are injected so tests can prove whether a denied request reached one.

Python
from dataclasses import dataclass
from typing import Any, Callable


REQUIRED_SCOPES = {
    "invoice.read": "invoice:read",
    "refund.create": "refund:create",
}


@dataclass(frozen=True)
class Principal:
    subject: str
    tenant: str
    scopes: frozenset[str]


@dataclass(frozen=True)
class ProposedCall:
    invocation_id: str
    tool: str
    resource_ref: str
    amount_minor: int | None = None


@dataclass(frozen=True)
class ResolvedResource:
    resource_id: str
    tenant: str


@dataclass(frozen=True)
class Decision:
    allowed: bool
    reason: str
    policy_version: str


ResourceResolver = Callable[[str], ResolvedResource]
Handler = Callable[[str, int | None], Any]


def authorize(
    principal: Principal,
    call: ProposedCall,
    resolve_resource: ResourceResolver,
) -> tuple[Decision, ResolvedResource | None]:
    required = REQUIRED_SCOPES.get(call.tool)
    if required is None:
        return Decision(False, "unknown_tool", "2026-08-04"), None
    if required not in principal.scopes:
        return Decision(False, "missing_scope", "2026-08-04"), None

    try:
        resource = resolve_resource(call.resource_ref)
    except LookupError:
        return Decision(False, "resource_not_found", "2026-08-04"), None

    if principal.tenant != resource.tenant:
        return Decision(False, "tenant_mismatch", "2026-08-04"), resource
    return Decision(True, "allowed", "2026-08-04"), resource


def invoke(
    principal: Principal,
    call: ProposedCall,
    resolve_resource: ResourceResolver,
    handlers: dict[str, Handler],
    trace: list[dict[str, Any]],
) -> Any:
    trace.append(
        {
            "event": "tool.request",
            "invocation_id": call.invocation_id,
            "tool": call.tool,
        }
    )
    decision, resource = authorize(principal, call, resolve_resource)
    trace.append(
        {
            "event": "authorization.decision",
            "invocation_id": call.invocation_id,
            "tool": call.tool,
            "resource_id": resource.resource_id if resource else None,
            "decision": "allow" if decision.allowed else "deny",
            "reason": decision.reason,
            "policy_version": decision.policy_version,
        }
    )

    if not decision.allowed:
        return {"status": "denied", "reason": decision.reason}

    if resource is None:
        raise RuntimeError("allowed decision has no resolved resource")

    trace.append(
        {
            "event": "executor.start",
            "invocation_id": call.invocation_id,
            "tool": call.tool,
            "resource_id": resource.resource_id,
        }
    )
    try:
        result = handlers[call.tool](resource.resource_id, call.amount_minor)
    except Exception as error:
        trace.append(
            {
                "event": "executor.finish",
                "invocation_id": call.invocation_id,
                "tool": call.tool,
                "resource_id": resource.resource_id,
                "outcome": "failed",
                "error_type": type(error).__name__,
            }
        )
        raise
    else:
        trace.append(
            {
                "event": "executor.finish",
                "invocation_id": call.invocation_id,
                "tool": call.tool,
                "resource_id": resource.resource_id,
                "outcome": "succeeded",
            }
        )
        return result

The example uses a fixed policy version so the behavior is runnable and visible. A production service should obtain the version from its policy deployment, not hard-code a date. The test should assert that a version is present and should compare it with the deployment manifest. The resolver is a trusted server dependency. It maps the untrusted resource reference to canonical ownership before policy runs, and the handler receives that canonical ID as a separate argument.

Notice what the trace omits. The principal subject, tenant, scopes, resource tenant, and proposed amount do not enter the emitted record in this minimal example. Whether your grader needs a safe representation of those fields depends on the threat you are testing. If it does, use synthetic fixtures or stable opaque references and apply field-level allowlisting. Do not serialize the full dataclass out of convenience.

Real tool adapters usually receive a JSON argument object. Parse it into a tool-specific type before this boundary. Do not pass a second caller-controlled resource ID inside a generic argument dictionary after authorizing the canonical target. The narrow ProposedCall type prevents that mismatch for the two example tools.

Also notice that the denied return is an application result, not an HTTP response. An HTTP adapter could map authentication and authorization states to suitable responses, but that mapping belongs to the application. The test should assert the interface your executor actually promises.

Test three paths at the executor boundary

The first worked example covers the invoice summary that attempts a refund. The principal has invoice:read but not refund:create. The policy should deny the request, and the refund handler must remain untouched. That second assertion matters. A trace containing “deny” is not proof if buggy code calls the handler before writing the decision.

The next test uses a spy handler. It checks the returned state, the trace, and the call count. Parameterization keeps the permission cases readable without mutating shared inputs.

Python
import pytest

from auth_boundary import (
    Principal,
    ProposedCall,
    ResolvedResource,
    invoke,
)


@pytest.mark.parametrize(
    (
        "tool",
        "scopes",
        "resource_ref",
        "expected_status",
        "expected_reason",
        "expected_calls",
    ),
    [
        (
            "invoice.read",
            frozenset({"invoice:read"}),
            "invoice-a",
            "ok",
            "allowed",
            1,
        ),
        (
            "refund.create",
            frozenset({"invoice:read"}),
            "invoice-a",
            "denied",
            "missing_scope",
            0,
        ),
        (
            "invoice.read",
            frozenset({"invoice:read"}),
            "invoice-b",
            "denied",
            "tenant_mismatch",
            0,
        ),
        (
            "admin.export",
            frozenset({"admin:export"}),
            "invoice-a",
            "denied",
            "unknown_tool",
            0,
        ),
    ],
    ids=["allowed-read", "missing-scope", "wrong-tenant", "unknown-tool"],
)
def test_executor_enforces_policy(
    tool,
    scopes,
    resource_ref,
    expected_status,
    expected_reason,
    expected_calls,
):
    calls: list[tuple[str, int | None]] = []

    def handler(resource_id, amount_minor):
        calls.append((resource_id, amount_minor))
        return {"status": "ok"}

    principal = Principal("user-7", "tenant-a", scopes)
    call = ProposedCall(
        invocation_id="invocation-1",
        tool=tool,
        resource_ref=resource_ref,
        amount_minor=1250,
    )
    trace: list[dict[str, object]] = []
    resources = {
        "invoice-a": ResolvedResource("canonical-invoice-4", "tenant-a"),
        "invoice-b": ResolvedResource("canonical-invoice-9", "tenant-b"),
    }

    def resolve_resource(reference):
        return resources[reference]

    handlers = {
        "invoice.read": handler,
        "refund.create": handler,
        "admin.export": handler,
    }

    result = invoke(principal, call, resolve_resource, handlers, trace)

    assert result["status"] == expected_status
    assert len(calls) == expected_calls
    decision = next(
        event for event in trace if event["event"] == "authorization.decision"
    )
    assert decision["decision"] == ("allow" if expected_calls else "deny")
    assert decision["reason"] == expected_reason

    executor_starts = [
        event for event in trace if event["event"] == "executor.start"
    ]
    assert len(executor_starts) == expected_calls
    if expected_calls:
        assert calls == [("canonical-invoice-4", 1250)]

The amount is synthetic test data, not a measurement. The test never performs a refund because the handler is a local spy. It covers four different causes: allowed access, a missing scope, a cross-tenant target, and an unknown capability. It asserts each reason and proves the allowed handler received the canonical invoice ID, not the planner's reference. Do not merge those reason codes. They point to different defects and can have different alerting rules.

The second worked example is a same-tool, wrong-resource request. A user may read invoices in tenant A, and invoice.read is visible. Prompt injection causes the planner to reference an invoice from tenant B. A tool-list test passes because invoice.read is permitted in general. A resource-aware policy denies it because the resolved owner differs.

Specific evidence distinguishes this from a model hallucinating a nonexistent invoice. For a cross-tenant denial, the trusted resource lookup found the target and the policy recorded tenant_mismatch. For a nonexistent resource, the lookup should produce a not-found state according to your disclosure policy. Some services deliberately avoid revealing whether a protected resource exists. Test the documented behavior without demanding a reason string that leaks ownership to the caller. The internal redacted trace can retain a safe classification under tighter access.

The third example is a permission revoked between planning and execution. The planner sees refund.create in a tool registry when a finance approval is active. An administrator removes the approval before the call runs. A cached planner decision says allow, but the executor’s current policy says deny. The correct trace contains the registry or plan version as context and the executor’s newer policy decision as authority.

Do not write a test that sleeps and hopes to hit the race. Give the policy dependency an explicit interface, then supply controlled versions to the executor test. The test can arrange an old planning snapshot and a current deny response without real time. A separate integration test can prove that policy deployments propagate to the executor. These tests answer different questions.

A fourth path often gets overlooked: the policy allows the action, then the downstream service fails. The trace should contain authorization.decision allow, executor.start, and executor.finish with a normalized failure outcome. Calling this an authorization failure sends the issue to the wrong team and can encourage a dangerous retry. The grader must keep permission and execution results separate.

Diagnose denials without confusing them with bad input

Three logs can end with the word “failed” while describing unrelated problems.

A missing or invalid identity is an authentication problem. For an HTTP interface, that often maps to 401 and an authentication challenge according to the protocol. An authenticated caller without permission is an authorization problem and often maps to 403. A malformed tool argument is validation. A timeout after an allow decision is execution or infrastructure. Preserve those states before formatting a user-facing error.

Start diagnosis at the invocation ID. Find exactly one request record and exactly one authorization decision. If there are two decisions, determine whether the service intentionally rechecked policy or accidentally executed twice. If there is no decision, stop grading the operation as allowed or denied. Mark the trace incomplete and investigate instrumentation or a bypass.

Next, locate executor.start for the same invocation. A deny decision followed by executor.start is a control failure even if executor.finish reports another denial. The forbidden boundary was crossed. An allow decision without executor.start may reflect cancellation, queue loss, or incomplete telemetry. Do not claim a side effect from absence alone.

Then compare the normalized resource reference. The planner’s argument and the trusted resource lookup may differ. That is expected when aliases resolve to canonical IDs, but it is suspicious when a client-controlled tenant field overrides server ownership. Record both only if they can be protected. Otherwise record a boolean match result and a safe reason.

A small grader can enforce event relationships without judging model quality. Save the next module as grade_authorization.py. It groups by invocation and fails closed when evidence is incomplete.

Python
from collections import defaultdict
from typing import Any


def grade_authorization(
    events: list[dict[str, Any]],
    expected_invocations: set[str],
) -> list[str]:
    if not expected_invocations:
        return ["grader requires at least one expected invocation"]

    grouped: dict[str, list[tuple[int, dict[str, Any]]]] = defaultdict(list)
    failures: list[str] = []
    for index, event in enumerate(events):
        invocation_id = event.get("invocation_id")
        if not isinstance(invocation_id, str) or not invocation_id:
            failures.append(f"event {index}: missing invocation_id")
            continue
        grouped[invocation_id].append((index, event))

    for invocation_id in sorted(expected_invocations - grouped.keys()):
        failures.append(f"{invocation_id}: no trace events")

    for invocation_id, indexed_events in sorted(grouped.items()):
        invocation = [event for _, event in indexed_events]
        positions: dict[str, int] = {}
        for index, event in indexed_events:
            event_name = event.get("event")
            if isinstance(event_name, str):
                positions.setdefault(event_name, index)
        requests = [
            event for event in invocation if event.get("event") == "tool.request"
        ]
        decisions = [
            event
            for event in invocation
            if event.get("event") == "authorization.decision"
        ]
        starts = [
            event for event in invocation if event.get("event") == "executor.start"
        ]
        finishes = [
            event for event in invocation if event.get("event") == "executor.finish"
        ]

        if len(requests) != 1:
            failures.append(
                f"{invocation_id}: expected one request, observed {len(requests)}"
            )
        if len(decisions) != 1:
            failures.append(
                f"{invocation_id}: expected one decision, observed {len(decisions)}"
            )
            continue

        decision_event = decisions[0]
        decision = decision_event.get("decision")
        if not decision_event.get("policy_version"):
            failures.append(f"{invocation_id}: decision has no policy_version")

        request_position = positions.get("tool.request")
        decision_position = positions.get("authorization.decision")
        if (
            request_position is not None
            and decision_position is not None
            and request_position >= decision_position
        ):
            failures.append(f"{invocation_id}: decision precedes request")

        if decision == "deny" and starts:
            failures.append(f"{invocation_id}: executor started after deny")
        elif decision == "deny" and finishes:
            failures.append(f"{invocation_id}: executor finished after deny")
        elif decision == "allow":
            if len(starts) != 1:
                failures.append(
                    f"{invocation_id}: allow must correlate to one executor start"
                )
            if len(finishes) != 1:
                failures.append(
                    f"{invocation_id}: allow must correlate to one executor finish"
                )
            start_position = positions.get("executor.start")
            finish_position = positions.get("executor.finish")
            if (
                decision_position is not None
                and start_position is not None
                and decision_position >= start_position
            ):
                failures.append(f"{invocation_id}: executor started before allow")
            if (
                start_position is not None
                and finish_position is not None
                and start_position >= finish_position
            ):
                failures.append(f"{invocation_id}: executor finish is out of order")
            if finishes and finishes[0].get("outcome") not in {"succeeded", "failed"}:
                failures.append(f"{invocation_id}: unknown executor outcome")
        elif decision not in {"allow", "deny"}:
            failures.append(f"{invocation_id}: unknown decision {decision!r}")

    return failures

If an event name repeats, the ordering checks evaluate its first occurrence.

This contract assumes each allow maps to one executor start. If your architecture queues, resumes, or fans out an invocation, define a different relationship and test it. Do not copy this cardinality into a workflow that legitimately starts several workers. The invariant you need is that every privileged execution is governed by an applicable decision, not that every system looks sequential.

The following output is illustrative, not captured from a real run. It shows the diagnostic shape a failing test can produce without printing credentials or raw arguments.

Shell
$ python -m pytest tests/authorization -q
..F
E   AssertionError: invocation-19: executor started after deny
E   decision=deny reason=tenant_mismatch policy_version=2026-08-04
1 failed, 2 passed

The assertion surfaces only the safe fields needed to route the failure. It does not pretend that a repository-specific diagnostic command exists.

Near-miss failures deserve explicit fixtures. An arguments-schema rejection occurs before business execution but may happen either before or after policy, depending on your threat model. Validating enough structure to identify the action can be safe. Resolving a sensitive resource before authorization can leak its existence or consume privileged data. Write down the intended order and assert the events that prove it.

Another near-miss is an agent that never requests the forbidden tool because it could not see any tools at all. A denial-path scenario may appear safe, but the control was never exercised. Require the expected request in a dedicated security test. In an ordinary task, a missing request could be a functional failure instead. The same trace can receive different grades under different scenario contracts, and that is correct.

Distinguish a valid denial from stale trusted context

Two invocations can finish with the same visible denial and the same internal reason, yet expose different failures. Imagine tenant_mismatch for a read of a synthetic invoice. One possibility is the intended test path: the authenticated principal belongs to tenant A, the canonical invoice belongs to tenant B, and policy correctly denies it. Another is a stale or misrouted resource projection: the invoice now belongs to tenant A in the authoritative fixture, but the resolver used an older ownership record or queried the wrong test environment. Retrying the prompt cannot fix the second case, and relaxing the tenant rule would turn a data problem into an access-control weakness.

The authorization row alone cannot separate them. Follow its invocation to the trusted resolution evidence and the fixture manifest used by that run. A healthy denial shows the expected principal class, the canonical resource reference, the resource ownership recorded by the controlled fixture, a current policy version, decision=deny, and no executor start. A broken authorization boundary shows a valid cross-tenant input followed by allow or execution. The misleading case still shows deny and no execution, but the resolver’s ownership fact disagrees with the pinned fixture or comes from a different fixture revision. The caller-facing 403-like response is deliberately unhelpful here because several internal causes may map to the same safe response.

Read the output fields in that order. The invocation ID answers whether the records belong together. The policy version answers which rule set ran, but not whether its inputs were fresh. The reason answers which predicate decided the result, not whether the predicate received the right resource. The executor state answers whether the boundary was crossed. A value such as outcome=denied is therefore healthy only when the correlated principal and resource facts match the case. It is misleading when those facts were defaulted, cached beyond the accepted revocation behavior, or resolved from an unpinned fixture.

Ownership should follow the first contradicted fact. Identity or session engineering owns an incorrect principal projection. The resource service owns incorrect canonical ownership. The policy team owns a wrong decision given correct inputs. The executor team owns any start after a deny. The agent team owns the unnecessary request only after the boundary evidence has been validated. A useful handoff includes the case and fixture revisions, invocation ID, safe principal class, requested and canonical resource references, resolution source revision, policy version, decision and reason, executor events, and the spy-handler count. It should explicitly say which fact is expected and which recorded fact contradicts it.

Authorization trace grading still cannot prove that the policy itself expresses the right business rule. A perfectly correlated allow can be unsafe because an approved policy grants a role too much access. It also cannot detect a handler that receives an allowed invoice and then returns unrelated protected data. Policy review, resource-level output assertions, and adversarial cases must cover those failures.

Roll the grader out without weakening policy

Begin with the executors, not with a dashboard. Inventory every route that can reach a privileged handler: direct tool calls, queued jobs, retries, administrative paths, scheduled tasks, and legacy endpoints. Wrapping only the fashionable agent entry point leaves bypasses.

Add one enforcement adapter that derives trusted context and calls policy. Keep the planner-facing tool description outside that adapter. The executor should behave safely when invoked directly in a unit test with a forged argument. Deny unknown tools by default. Make the handling of policy-service outages explicit rather than silently allowing work.

For a suite that already exercises handlers directly, land the spy and invocation correlation before changing expected authorization outcomes. Those tests often break first because legacy calls have no authenticated principal, queued work cannot reconstruct the current one, or fixtures rely on resource IDs without ownership. Add trusted fixture ownership next, then run the decision events beside the old assertions. Enforce at every privileged entry path before allowing the trace grader to block CI. Otherwise a green grader on the wrapped agent route can coexist with an unwrapped legacy bypass. The change is working when direct, queued, retry, and agent-driven conformance cases all reach the same boundary, denied spies remain untouched, and every allowed start cites the applicable decision evidence.

Rechecking queued work has a visible product cost. A job that was permitted when submitted can be denied when a worker later resolves a revoked principal or changed resource owner. Existing retry tests that assumed eventual success will fail first, and the application needs a recoverable denied state instead of an endless retry. Preserving the old allow decision would make the queue simpler, but it would also preserve access after the trusted facts changed. Land the terminal denial and user recovery behavior before enabling strict reauthorization for those jobs.

Introduce structured decision events in shadow mode. Compare them with existing application outcomes, but restrict artifact access and retention from the start. A temporary trace store has a habit of becoming permanent. Run redaction tests against canary values that resemble tokens, email addresses, account numbers, and customer text.

Build a deterministic conformance pack before a model-driven eval. Include allowed access, missing identity, missing scope, wrong tenant, unknown tool, stale approval, policy unavailable, denied request followed by attempted execution, allowed request with downstream failure, and duplicate invocation. Each fixture should assert the policy result and the spy handler count.

Then add agent scenarios. One should tempt the planner to request an action outside its task. Another should provide a cross-tenant resource alias. A third should change approval state between planning and execution through controlled dependencies. Preserve the full classification: planning passed or failed, enforcement passed or failed, execution outcome, and trace completeness.

Run the deterministic pack on every policy or executor change. Pytest can generate JUnit XML for CI, and its log options can preserve a controlled diagnostic file. Keep model-driven repetitions in a lane whose cost and retry policy are visible. The shell wiring below invokes only documented pytest options.

Shell
set -u
mkdir -p artifacts

python -m pytest tests/authorization   --junitxml=artifacts/authorization.xml   --log-file=artifacts/authorization.log   --log-file-level=INFO
status=$?

exit $status

Start the trace grader as advisory. Triage missing decisions as observability defects and deny-then-start sequences as control defects. Do not let a high rate of missing telemetry normalize an “unknown means pass” rule. Unknown means the evidence cannot support the claim.

Move deterministic executor invariants to blocking after the conformance pack is stable. Keep planner-quality thresholds separate. A security test can report “planner failed, enforcement passed” when the model asked for a refund and the policy stopped it. That is much more useful than one red score. It tells the security owner that the boundary worked and the agent owner that the request pattern regressed.

Policy changes need code-review-quality evidence. Show added and removed actions, scope changes, resource predicates, default behavior, and policy version. When a baseline starts allowing something new, require an owner to state why. Never regenerate expected trace decisions from the same candidate policy and call that independent testing.

Name the costs and know when to use a smaller test

An executor check adds latency. A local rule may add very little, while a remote policy lookup adds network dependence and can become a bottleneck. Caching reduces cost but creates staleness. For high-consequence actions, rechecking current policy may be worth the delay. For low-risk reads, a short-lived decision cache may be acceptable if revocation requirements permit it. That is a security decision, not a test optimization.

Detailed authorization traces create privacy and security work. Policy reason codes can reveal tenant relationships, role names, or protected resource existence. Even opaque IDs become identifying when correlated widely. Limit fields, access, and retention. Test redaction before exporting artifacts to general CI storage.

Resource-aware rules also increase fixture complexity. Synthetic data needs ownership relationships, approval states, and policy versions. That complexity is useful when the production rule depends on those facts. It is waste when a pure formatting tool has no protected resource and cannot cause an external effect.

False denials cost user trust and support time. A strict control with unclear feedback can make an agent retry the same prohibited action. Return a safe, structured denial that lets the planner choose an allowed alternative without exposing policy internals. Grade whether the agent handles the denial, but do not weaken the decision to make the demo flow smoothly.

Do not use post-run grading as a substitute for pre-execution enforcement. The grader is excellent at proving observed relationships, catching bypasses, and comparing planner behavior. It cannot recover a sent email, reverse disclosed data, or guarantee behavior that was not exercised.

Do not require full authorization traces for every local computation. A deterministic calculator or formatter operating only on data already in memory may need input validation and ordinary unit tests, not a distributed permission record. Add the boundary when the operation reaches a protected resource, changes state, crosses tenants, spends money, communicates externally, or delegates privilege.

Avoid asserting one universal event order when the platform legitimately performs validation, authentication, and coarse policy checks in a different safe sequence. Assert the security properties: untrusted fields never become trusted identity, protected lookup does not leak data, a current applicable decision precedes execution, and denied work cannot reach the handler.

Finally, do not claim the system is secure because the assigned scenarios passed. The evidence supports narrower statements: these policy fixtures produced the expected decisions, these denied invocations did not execute, these allowed invocations were correlated with a policy version, and these agent runs stayed within their contracts. Keep that scope visible. Honest limits make the grade useful.

// FIELD DISPATCH

Get the QA Field Notes

Weekly QA battles, AI testing guides, and interview drills. Free on Substack.

// LIVE COURSE / THE TESTING ACADEMY

AI Tester Blueprint

Master GenAI, AI Agents, MCP, RAG, CrewAI. Build 23+ real AI projects.

From the instructor behind this guide.

AI testing roles are up 180% and pay 12-22 LPA. 12+ weeks / 65+ live hrs / Sat-Sun 8:30 AM IST.

Code PROMODE / 10% offJoin the batch

The Testing Academy editorial desk

Practical QA guidance built around test evidence, production tradeoffs, and interview-ready explanations.

Published July 25, 2026 / Reviewed August 7, 2026

PRIMARY REFERENCES

Verify the details at the source

QABattle guides are practical explanations. Product behavior, standards, and APIs can change, so use these primary references for the canonical details.

  1. 01
    Official developer.mozilla.org reference

    developer.mozilla.org

    Primary documentation selected and verified for the claims in this guide.

  2. 02
    Official developer.mozilla.org reference

    developer.mozilla.org

    Primary documentation selected and verified for the claims in this guide.

  3. 03
    Official developer.mozilla.org reference

    developer.mozilla.org

    Primary documentation selected and verified for the claims in this guide.

  4. 04
    Official w3.org reference

    w3.org

    Primary documentation selected and verified for the claims in this guide.

FAQ / QUICK ANSWERS

Questions testers ask

Is hiding a tool from the model enough to secure it?

No. A smaller tool list reduces accidental requests and can improve selection, but it is not an enforcement boundary. The executor must authorize the authenticated principal, action, resource, and current context before it performs the operation.

What evidence proves that a denied tool call never ran?

Look for a correlated request, an explicit deny decision, and no executor-start or side-effect-complete event for that decision. A tool error string by itself is ambiguous because validation, network, and application failures can look similar.

Should a grader fail when the agent asks for a forbidden tool?

That depends on the scenario contract. A denial-path security test should pass when the request is blocked and recorded, while an ordinary task may treat the same request as a planning regression even though enforcement worked.

How do I test resource-level permissions without real customer data?

Use synthetic tenants and opaque resource identifiers with the same ownership relationships as production. Assert the policy input and decision, then use a spy executor to prove that denied requests never reached the handler.

Can a successful tool result prove authorization happened?

Success proves only that some execution path returned a result. Require a separate decision record tied to the invocation and policy version. Missing evidence should be classified as an observability failure, not silently treated as an allow.