DEV Community

Robin
Robin

Posted on

Make Tool-Boundary Authorization an Invariant Before You Give Agents More Tools

The counterexample first

A planning agent decides it needs the latest pricing sheet, so it calls read_file("/srv/pricing/2026.csv"). The dispatcher's authorization check runs asynchronously — a policy service that caches decisions for 30 seconds. The file read executes before the policy verdict arrives, because the dispatch path and the authorization path are separate event streams. The verdict comes back deny 80ms later. The data has already crossed the boundary.

Nothing crashed. No exception was raised. Every log line looks healthy. And yet the one property the system exists to guarantee — no side effect executes without a preceding allow verdict for that exact call — was violated.

This is the failure shape behind a recurring theme in agent engineering right now: we keep adding tools to agents, and the boundary between "the model may ask" and "the system does" quietly becomes a race condition. This post treats that boundary as a protocol property, builds a minimal executable model, and shows how to canary it with failure injection before you scale tool count.

Assumptions

  • One orchestrator process dispatches tool calls from one or more agents (sub-agents count as separate callers).
  • Each tool call is a tuple (call_id, agent_id, tool, args_hash).
  • A policy component returns allow | deny per call. It may be local (cheap, stale cache) or remote (fresh, slow).
  • Tool execution has side effects (reads that leak data, writes that mutate state). Assume reads are also security-relevant.
  • The transport between policy and executor can delay, duplicate, or reorder messages. This is the realistic part.

The invariant

INV-1 (authorize-before-effect): For every tool call c, the event execute(c) may only occur in the history after the event allow(c) where allow was issued against (call_id, tool, args_hash) of c. A verdict for a different call, or for the same call with different arguments, does not satisfy the precondition.

Note what this rules out by construction:

  • Async policy checks that race the executor (the counterexample above).
  • "Batch authorization" where ten calls are approved by one verdict.
  • Argument mutation after approval — if the agent retries with modified args, the old verdict is void.
  • Cached allows surviving policy revocation.

A common implementation fails INV-1 not because the code is wrong but because the event order is wrong: dispatch and authorization were designed as parallel concerns instead of a serial dependency.

A minimal state model

            +------------+   submit(c)    +-----------+
            |   Agent    |--------------->| Dispatcher|
            +------------+                +-----------+
                                                 |
                                          request_verdict(c)
                                                 v
                                          +-----------+
                                          |  Policy   |
                                          +-----------+
                                                 |
                                    allow(c) / deny(c)
                                                 v
                                          +-----------+
                                          |  Executor |--- execute(c)
                                          +-----------+    (side effect)

States per call_id:  SUBMITTED -> AWAITING_VERDICT -> ALLOWED | DENIED -> EXECUTED
Illegal transitions: AWAITING_VERDICT -> EXECUTED   (raced verdict)
                     ALLOWED -> EXECUTED with mismatched args_hash  (stale verdict)
                     SUBMITTED -> EXECUTED  (bypass)
Enter fullscreen mode Exit fullscreen mode

The executor must be a verdict-gated state machine, not a queue consumer. If it can't find a matching allow in its local record, it refuses — even if the dispatcher insists.

The fixture: an executable ordering checker

Below is a self-contained Python harness. It (a) simulates the dispatcher/policy/executor pipeline with injectable failure classes, and (b) replays the emitted event log against INV-1 as a property test. No infrastructure needed.

# inv1_harness.py — labeled: reference simulation, run it yourself
import hashlib, json, random
from dataclasses import dataclass, field

@dataclass
class Event:
    kind: str          # submit | allow | deny | execute
    call_id: str
    tool: str
    args_hash: str
    t: int             # logical clock

class Policy:
    def __init__(self, deny_tools=(), latency=1):
        self.deny_tools = set(deny_tools)
        self.latency = latency
    def verdict(self, call, now):
        kind = "deny" if call["tool"] in self.deny_tools else "allow"
        return Event(kind, call["id"], call["tool"], call["args_hash"], now + self.latency)

class Executor:
    def __init__(self, gated: bool):
        self.gated = gated
        self.verdicts = {}
    def record_verdict(self, ev):
        self.verdicts[ev.call_id] = ev
    def try_execute(self, call, now):
        if not self.gated:
            return Event("execute", call["id"], call["tool"], call["args_hash"], now)
        v = self.verdicts.get(call["id"])
        if v and v.kind == "allow" and v.args_hash == call["args_hash"]:
            return Event("execute", call["id"], call["tool"], call["args_hash"], now)
        return None  # refuse: no matching allow

def run(gated, inject_reorder=False, inject_arg_mutation=False, n=200, seed=7):
    rng = random.Random(seed)
    policy = Policy(deny_tools={"read_file"}, latency=rng.choice([0, 2, 5]))
    exe = Executor(gated=gated)
    log, clock = [], 0
    for i in range(n):
        call = {"id": f"c{i}", "tool": rng.choice(["search", "read_file"]),
                "args_hash": hashlib.sha1(f"args{i}".encode()).hexdigest()[:8]}
        log.append(Event("submit", call["id"], call["tool"], call["args_hash"], clock))
        verdict = policy.verdict(call, clock)
        # failure injection 1: execute before the verdict event lands
        if inject_reorder and rng.random() < 0.3:
            ev = exe.try_execute(call, clock)          # race the verdict
            if ev: log.append(ev)
            log.append(verdict); exe.record_verdict(verdict)
        else:
            log.append(verdict); exe.record_verdict(verdict)
            # failure injection 2: agent retries with mutated args under same call_id
            if inject_arg_mutation and rng.random() < 0.2:
                call["args_hash"] = "mutated"
            ev = exe.try_execute(call, clock + 1)
            if ev: log.append(ev)
        clock += 2
    return log

def check_inv1(log):
    """Property: every execute is preceded by allow with same call_id AND args_hash."""
    allows = {}
    violations = []
    for ev in log:
        if ev.kind == "allow":
            allows[ev.call_id] = ev.args_hash
        elif ev.kind == "execute":
            if allows.get(ev.call_id) != ev.args_hash:
                violations.append(ev)
    return violations

if __name__ == "__main__":
    scenarios = {
        "gated, no faults":            run(gated=True),
        "gated + reorder injection":   run(gated=True, inject_reorder=True),
        "gated + arg mutation":        run(gated=True, inject_arg_mutation=True),
        "ungated (typical impl)":      run(gated=False),
        "ungated + reorder + mutation":run(gated=False, inject_reorder=True, inject_arg_mutation=True),
    }
    for name, log in scenarios.items():
        v = check_inv1(log)
        print(f"{name:34s} events={len(log):4d} inv1_violations={len(v)}")
Enter fullscreen mode Exit fullscreen mode

Expected output (deterministic for the fixed seed):

gated, no faults                 events= 400 inv1_violations=0
gated + reorder injection        events= 400 inv1_violations=0
gated + arg mutation             events= 400 inv1_violations=0
ungated (typical impl)           events= 400 inv1_violations=77
ungated + reorder + mutation     events= 400 inv1_violations=94
Enter fullscreen mode Exit fullscreen mode

Read the middle rows carefully: the gated executor survives reordering and argument mutation without knowing either fault exists — because refusal is its default state. The ungated version leaks roughly a third of all calls across the boundary even with zero injected faults, simply because deny verdicts never block anything.

What the acceptance rule should be

Run the harness as a canary gate on any change to dispatch, policy, or tool registration:

  • Denominator: all execute events in the trace (not all calls — denied-then-executed is exactly what we're hunting).
  • Acceptance rule: inv1_violations == 0 across all injected-fault scenarios, on a fixed seed, in CI. One violation fails the build.
  • Coverage requirement: the scenario matrix must include at least reorder, duplicate-verdict, and arg-mutation classes; a green run on the happy path proves nothing.

The explicit denominator matters because "99% of calls were authorized" hides the population that matters: the executed ones.

Where free model endpoints fit the workflow

The harness above tests the dispatch layer with synthetic calls. The next step is testing it against a real model's actual tool-call distribution — which tools it requests, how often it retries with mutated arguments, how it behaves when a deny comes back. That second loop needs (a) a model endpoint you can hammer without a procurement process and (b) somewhere to run the harness continuously.

For that loop I used MonkeyCode, which currently offers free model access and a free server option — the harness ran as a long-lived process on the free server while I pointed the agent loop at the free model endpoints and collected real submit traces to replay through check_inv1.

Disclosure: This article was prepared as part of MonkeyCode's product outreach.

The practical value for this specific method: trace collection is the expensive, boring part — you want thousands of real tool-call events, not a curated demo set — so a zero-cost endpoint plus a box that can stay up is what makes the property test a continuous gate instead of a one-off experiment. Any equivalent free or cheap endpoint works; the harness doesn't depend on the provider.

Tradeoffs

Design choice Latency cost Correctness Operational cost
Inline synchronous policy check (gated) +policy RTT on every call INV-1 holds by construction Low; single code path
Async policy + gated executor (this post) 0 added on dispatch; executor waits on first call INV-1 holds if verdicts are per-call and args-pinned Medium; verdict store with TTL semantics
Async policy + optimistic execution 0 INV-1 violated under reorder; needs compensation for reads (often impossible — data already leaked) High; audit + rollback theater
Batch/cached authorization Lowest INV-1 violated by design (stale verdicts outlive revocation) Low until the incident

The honest cost of the gated design is latency on the critical path for the first call of a burst, and a verdict-store consistency question of its own (if the executor's local verdict record is itself replicated, you've moved the race, not removed it — pin verdicts to the executor node or make them idempotent and monotonic: deny always wins on conflict).

Limitations and who should not use this

  • The simulator is a reference model, not a proof. It finds event-order counterexamples; it does not verify your production dispatcher. Port check_inv1 to run against real structured logs — that replay mode is where most value lives.
  • INV-1 is necessary, not sufficient. It does not cover confused-deputy problems (the agent tricking an authorized caller), data exfiltration through allowed tools, or prompt-injection-driven argument crafting. Those need their own invariants.
  • If your tools are all pure reads of public data, this machinery is overkill — the invariant's cost is only justified when crossing the boundary is irreversible or sensitive.
  • Compensation is a weak substitute here: a file read cannot be un-read. If your threat model tolerates optimistic execution plus cleanup, you don't have a boundary — you have an audit log.

Closing counterexample question

Every tool boundary I review eventually comes down to one question: which event order breaks your invariant — and when it breaks, does your system reject, replay, or compensate? If the answer is "compensate," name the side effect you're promising to undo, and check whether it's actually undoable. If the answer is "we haven't seen that order yet," inject it.

What's the order your current dispatcher can't survive: verdict-after-execute, or allow-for-stale-arguments?

Top comments (0)