DEV Community

Andrei
Andrei

Posted on

The allow-list entry that denies the call it was written for

Here is a permission from an agent registry. A support agent may read tickets:

allowed_actions:
  - read_ticket
Enter fullscreen mode Exit fullscreen mode

Here is the agent's tool, doing the thing it was built to do: reading ticket
ticket/5.

The runtime refuses it.

[DENIED] ACTION_NOT_ALLOWLISTED
Enter fullscreen mode Exit fullscreen mode

Nothing is broken. The YAML is valid, the tool works, the permission is right
there in the file, and every reviewer who looked at it read it as "this agent
can read tickets". It says something narrower. read_ticket with no wildcard
is an exact match: it permits the action with no resource attached, and nothing
else. To cover the call the tool makes, the entry has to say
read_ticket:ticket/*.

You can call that a documentation problem. I think it is more interesting than
that, because of when you find out.

The class of bug, not the bug

The specific rule is a five-minute fix once you know it. What is worth
attention is the shape: a control that is present, readable, reviewed, and does
not do what everyone reading it believed.

That shape does not show up in a test suite. There is nothing to assert
against. The tool has unit tests and they pass. The YAML has a schema and it
validates. The permission exists, so a check for "is there a permission" finds
one. The first honest signal is a denial in production, on a code path that
worked in staging because staging called the tool differently.

I have been building a governance layer for agent systems for a few months, and
this is the failure mode I keep meeting. Not "we forgot to add a rule" -- that
one announces itself. It is "we added the rule, and the rule does not cover the
thing". A permission that grants nothing. A block list entry that overlaps
nothing. A schema that describes arguments nobody validates.

The consequences differ. Some fail closed, like the one above: the agent gets
refused and someone opens a ticket. Some fail open, which is the same defect
pointing the other way, and those you find out about later or not at all.

Finding it before it runs

The thing that catches this is boring and static. Describe what your tools can
actually reach, then compare that description against the boundary you wrote:

tools:
  - id: ticket.read
    action: read_ticket
    resource_patterns: ["ticket/*"]
Enter fullscreen mode Exit fullscreen mode
hlinor-registry contract check --agent agent.yaml --tools tools.yaml
Enter fullscreen mode Exit fullscreen mode
- [STALE_ALLOW_PERMISSION] Allowed action pattern 'read_ticket' does not
  overlap any tool in the contract.
+ [UNDECLARED_TOOL_SCOPE] Tool 'ticket.read' exposes 'read_ticket:ticket/*',
  but the agent neither allows nor blocks that runtime scope.
Enter fullscreen mode Exit fullscreen mode

Two statements of the same divergence from opposite sides: a permission that
covers no tool, and a tool that no permission covers. No agent runs, no traffic
is needed, and it exits non-zero so it fails a pull request instead of printing
into a log.

The same check finds the other direction, which in practice matters more. Add a
tool and forget the registry:

+ [UNDECLARED_TOOL_SCOPE] Tool 'ticket.delete' exposes 'delete_ticket:ticket/*',
  but the agent neither allows nor blocks that runtime scope.
Enter fullscreen mode Exit fullscreen mode

Today that tool is refused by default, so nothing visibly breaks. It stays
refused right up until somebody widens a permission to clear an unrelated
denial and catches delete_ticket in the blast radius. The finding is not
"this is exploitable now". It is "nobody decided this".

What it does not do

The tool description above carries a JSON Schema for the tool's inputs. It
would be reasonable to read that as the runtime validating arguments.

It does not. Those schemas are an authoring and review artifact. The policy
checker sees an action and a resource; it never sees the arguments a tool was
called with. A tool that receives a well-formed argument pointing somewhere it
should not go is not stopped by this layer.

I would rather say that plainly than let you find out the way you would find
out about read_ticket.

Try it on yours

There is a notebook that runs the whole thing in about a minute with nothing
installed locally:

Open the demo in Colab

If you have an agent with tools already, the more useful path is to point the
check at it:

pip install hlinor-registry
hlinor-registry contract check --agent your-agent.yaml --tools your-tools.yaml
Enter fullscreen mode Exit fullscreen mode

The result I actually want to hear about is not a clean run. It is a finding
you did not expect, or a finding that turns out to be wrong -- the second is
more useful to me than the first, because a check that cries wolf is worse
than no check.

  • What the check said about your agent, including "nothing, and that surprised me": Discussions
  • A wrong finding, a crash, or a missing feature: Issues

Apache-2.0. No telemetry, no account, nothing phones home -- which also means
the only way I learn whether this is useful is if someone says so.

Top comments (0)