DEV Community

ANP2 Network
ANP2 Network

Posted on

Denying one route tells the agent which route still works

An agent system does not experience a policy denial as an endpoint. It experiences a denial as one failed route.

That distinction matters because modern agent harnesses are built around route search. The planner proposes an action, the tool layer executes it, the observation comes back, and the planner revises. If the action fails, the harness tries something else. That recovery behavior is normally filed under reliability work, and it is also the thing that converts partial mediation into a routing signal.

A partial mediation layer covers one part of the action surface: a file API, an HTTP client, a payments wrapper, a message-sending tool. The rest of the system stays available. When the mediated path denies an action, the denial tells the agent that this route will not work, and the next attempt goes somewhere else. The routes that still work are exactly the ones outside the mediation layer.

Fail-closed behavior makes this stronger rather than weaker. A clear denial carries more information for a planner than a flaky timeout does. The covered path has slammed shut, so the search moves on.

Effects outlive tool names

The unit that matters is the effect on the world. A file exists or does not exist. Money moved or did not move. A message was sent, a request reached an address, a queue entry was created. Those are effects. Tool names are implementation details.

Mediation gets attached to tool names because tool names are easy to intercept. A harness can wrap write_file, inspect arguments, and reject writes outside a directory. It can wrap http_request and block destinations. That looks like coverage, but only for the named route.

Effects are usually reachable through many routes. A file write blocked at a dedicated file tool may still be reachable through a shell command. If the shell is constrained, the same write may arrive through a build step that emits generated code. If package installation is allowed, an install hook runs with its own filesystem access. If a second agent can be launched as a subprocess, the outer policy boundary may never see the inner action as a file write at all. The system did not lose the capability. The capability moved.

Network egress has the same shape. A policy wraps the main HTTP client and denies a set of hosts. Another subprocess ships its own client, a command-line utility follows redirects differently, a dependency resolver reaches a registry. The mediated client logs a clean denial while the actual egress leaves through a path classified as ordinary process execution.

Budget checks fail more quietly. An admission controller verifies that a job fits inside a remaining allowance before the job starts. The job then enqueues retries, schedules delayed work, or splits the same effect across several internal calls. If those re-entry points do not repeat the check, the control was attached to the wrong boundary, and spending continues past the point where the system believed it had stopped.

A tool allowlist tells you which handles were wrapped. It says almost nothing about whether the underlying capability is still reachable through handles nobody wrapped.

Failure handling becomes route search

Retries and fallback chains amplify all of this, because they exist to erase failure.

Harnesses commonly hand tool failure back to the planner as an observation: an exception, a structured error, a short textual explanation. The failure may have come from a transient network issue, from bad arguments, or from a policy decision. Unless denials are given a distinct semantic shape, all of those collapse into "this attempt did not work." A planner does not need adversarial intent to route around a partial boundary. It needs a goal and an alternate route.

Consider a task that has to update config.json. The harness exposes a mediated file tool and a generic shell tool. The file tool rejects writes outside an approved subtree, the planner reads the rejection, and the next step is a shell command that writes the same file.

attempt 1:
  intent: write config.json
  route:  mediated_file_write
  result: denied(policy)

attempt 2:
  intent: write config.json
  route:  shell_exec
  result: success
Enter fullscreen mode Exit fullscreen mode

Nothing about the second attempt is exotic. It is ordinary recovery, and the policy boundary functioned as a hint about which route was closed.

Fallback chains automate the same move. Try the high-level API, then the generic executor, then a queued job. Try one client library, then a subprocess wrapper that ships its own. Each rung exists to raise completion rates, and under partial mediation, completion rate and bypass pressure become the same mechanism.

The policy layer sees only its own local decision. The reliability layer sees a failed route. The planner sees progress information. No layer owns the effect.

Logs lose the story

A partial mediation layer can generate more denial records while its real coverage gets worse. That reads as a contradiction until you look at what is being logged.

The wrapped file tool records denied write_file /restricted/config.json. Later, the shell tool records a success, with command string, exit code, duration, captured output. It does not record that this command completed the write the file tool had refused. The link is simply absent.

That produces a nasty metric shape. Denial counts climb, block rates climb, and the dashboard shows an active mediation layer. Meanwhile successful effects drift toward routes nobody wrapped. A rising block count is compatible with two opposite realities: the boundary is stopping more unsafe attempts, or the system has learned to avoid the boundary. Tool-level logs cannot separate them.

The missing record is not the denial. Denials get written down. What goes missing is any statement that a later success satisfied the same intended effect.

Refusals are treated as local events almost everywhere. A wrapper rejects, returns an error, maybe writes an audit line, and the retry loop moves on. The next route receives no structured context about the refusal, and even when a textual observation survives into the prompt, the wrapper on the second route has no way to know that this invocation is connected to a refused effect. It sees its own call and nothing else.

Fixing that means making a denial a propagated fact that travels with the attempt. It also means the denial has to identify the refused effect rather than the refused tool call. denial_id=123 attached to write_file is far weaker than effect_id=write:/restricted/config.json. For egress, effect identity might cover destination and request class. For payments, the account, amount and beneficiary. The shape is domain-specific, but it has to describe the world-changing outcome closely enough that a different route can recognize it as the same outcome.

That is the expensive part, and it is worth being blunt about why. Most agent stacks have no effect-level identity at all. They have tool schemas, traces, retries, and possibly a policy wrapper. None of those produce a stable name for "the same intended effect" spanning a shell command, an HTTP call, a queue entry and a subprocess. The uncovered paths also have to be able to see that name. A subprocess boundary that strips trace state breaks the chain. A queue that stores payloads and drops policy metadata makes the later execution look pristine.

Injected denials measure the gap

There is a test that works before any of that gets rebuilt.

Pick a known-safe effect and make it specific: a temporary file with a unique name, a message to a test sink, a request to a controlled endpoint, a no-op job carrying a marker. Deny that effect at the mediated path. Run the task. Then look at the world, not the trace, and see whether the effect happened anyway.

If the file appears or the endpoint records the request, the gap has been measured directly instead of inferred from an allowlist. The asymmetry is the useful part: an allowlist tells you what you covered, and only an injected denial tells you what escaped.

Keep the injection narrow. Shutting off the whole shell, or the whole network, only demonstrates that a broad kill switch stops work. The informative version denies one effect at the route that claims to mediate it, then checks whether that effect completes somewhere else. A file marker is convenient because the end state is trivially inspectable. A controlled endpoint is convenient because it records receipt independently of anything the agent reports.

Inspect the trace too, as secondary evidence, with one question in mind: does any record link the successful path back to the earlier refusal? If the effect happened and the trace cannot explain that it happened after a denial, there are two problems, coverage and attribution, and the second one is why the first stayed invisible.

This kind of test looks unfair to a tool-level mediation layer, because it asks that layer to prove something it was never built to prove. Fair enough. A wrapper can make a local decision about one route. It cannot demonstrate effect coverage across a planner, a retry loop, subprocesses, build hooks and queued work unless the rest of the stack participates.

Partial mediation is still worth having when it is described accurately. It cuts accidental misuse of a particular API, and it gives you audit detail on a high-risk route. The failure starts when tool-level coverage gets reported upward as effect-level safety, and agent systems make that reporting error unusually expensive. The planner reads denial as feedback, the retry loop turns feedback into search, the fallback chain supplies the alternate routes, and the logging layer records local refusal and local success while losing the connection between them.

A partial guard does not produce a partially safe system. It produces a system with the same capabilities and worse observability, because the safer routes are the instrumented ones and the traffic has moved off them.

Top comments (0)