Mehdi Akiki
Published on

A denied tool call must leave no side effect

Authors
  • Mehdi Akiki avatar
    Name
    Mehdi Akiki
    Twitter

Article · Through the layers

The test asks an unauthorized user to update a record. The gateway returns a denial. The test passes.

The handler already wrote the update before checking permission.

This sounds obvious when written in that order. In a real tool wrapper, the write may be hidden in a helper called “prepare,” a default lookup that creates a record, or an adapter that sends a request while constructing its result.

Find the first effect

Follow the whole call path. Identify where it first writes storage, opens a privileged resource, or sends an external command.

Parse the generated arguments, validate their meaning, and authorize the actor and target before that point. If a preview needs reads, scope those reads too. Validation itself should not quietly trigger a write.

The tool-argument boundary article explains the different checks. This article's narrow invariant is about what remains after a rejected call.

A Rust test outline should inspect both the response and the effect:

assert!(state.effects.is_empty());assert!(state.mail.messages.is_empty());assert!(state.credential_uses.is_empty());

These fixture helpers must cover the actual storage and adapter used by the gateway. A snapshot of one in-memory map says nothing about a second adapter that already sent an email.

Separate rejection from uncertain failure

A denied call should not begin the forbidden action. An authorized call can fail after an external system has accepted it. That is a different outcome and may require lookup or repair.

Do not call a failed post-write audit step an authorization denial. The system needs to report that an authorized effect may have happened, not imply that a policy prevented it.

For database-only actions, a transaction can keep authorized related changes together. It cannot roll back an email or a remote provider request. Keep external effects behind an explicit recoverable boundary.

OWASP's authorization guidance recommends failing safely when checks fail. For a tool, the safe failure has to be visible in state, not just in prose returned to the model.

Exercise each rejected layer

Use malformed arguments, a validly shaped but impossible amount, an unauthorized actor, a wrong tenant, and an expired approval when the tool requires one.

After each case, inspect the relevant database rows, files, queue entries, and mock external calls. Also include an allowed command that produces the intended effect. A gateway that denies everything is not correct either.

Run one integration check through the real dispatch route. Unit-testing a policy function cannot prove the dispatcher calls it before invoking the adapter.

Keep policy state current enough

Permission can change between a read and a write. Use a transaction, version check, or another suitable check when that race matters. State how quickly changed permissions take effect. A check made while planning does not grant access forever.

The model's final explanation is not evidence that enforcement worked. The useful evidence is a rejected call, unchanged protected state, and no forbidden external request. That makes the security claim concrete enough to review.

Send denied commands through the actual route

The fixture does not stop at calling a permission predicate. It sends JSON through an Axum dispatch route. The route constructs a context from a fixture identity, strictly parses the arguments, and invokes the same gateway that records effects.

The refusal matrix includes a write proposed in a lookup workflow, an actor without write permission, a foreign account, a missing approval, a zero refund amount, and an injected tenant field. After each request, the test checks three independent observations: no business effect, no captured mail, and no credential use. A forbidden response code alone would be weaker evidence.

A positive control then approves one exact refund for the operator. The route returns success. Repeating that command under the same operation key still leaves one effect. A changed amount cannot borrow the earlier approval. Expired approval is refused, and deleting the target after review prevents execution.

The complete dispatch test runs against a capture-only store. It proves the order of checks and recorded effects for that adapter. It does not prove that a production payment transport has no additional initialization or network calls before the gateway is reached.

An allowed operation with a lost reply belongs to another outcome category. The gateway can retain an unresolved receipt after recording an effect; that is different from a policy denial before execution. Keep these cases separate when designing API responses and retries. Calling every error “denied” would hide uncertainty that the caller needs to recover.

For a real connector, put the same counters around the first irreversible or externally visible step. Include credential resolution when it can initiate privileged work. The useful invariant is specific: these rejected requests leave protected state and external-call counts unchanged, while the authorized control performs exactly its intended action.