Three things are in play every time an agent reaches for a tool, and it helps to name them before talking about policy.
The pack says what the agent is and what it may do. A PromptPack is the defining artefactartifact: the prompts, the tools, the models it may use, and the governance metadata that goes with them. For each tool it can declare what calling it does: whether it reads or writes, whether the effect can be undone, whether it lands outside the organisationorganization, what classes of data it touches. It names who’s accountable. It’s reviewed and versioned, it’s hashed, and Omnia checks the hash at startup against an anchor the pod can’t edit, so what’s running is provably what was approved. We trust it. It’s the authority on what the agent is for.
The agent is what’s governed. Not the pack, not the person who wrote it. The agent, at the moment it calls a tool.
The tool policy is where the two meet. It’s the point where the platform takes the pack’s governance requirements and turns them into something it can enforce: it looks at the actual call the agent is about to make, compares it with what the pack declared and what the policy requires, and answers allow or deny. This post is about how that comparison is built, and why we rebuilt it.
What the policy has in front of it
Four things, and they don’t deserve the same trust.
Who’s calling. Verified by the login chain: a JWT, an API key, a service identity from the mesh. The one input the caller can’t forge.
Which tool, in which registry. Resolved by the runtime. Reliable, coarse.
What the pack declared about the tool. The governance metadata above. Written ahead of time, by someone other than the agent, through review. Authoritative about what the tool is for.
What the agent wrote. The arguments. Composed at call time by the thing being governed, and the only place the content of the call is visible. The tool’s schema fixes the shape and says nothing about what’s inside it. A summarisersummarizer takes a pile of text and the pile can be a customer’s whole file. A calculator takes an array of numbers and an array of operators, and the argument names tell you nothing about what the numbers are. Nothing in a call announces what it is.
The policy’s job is to hold the last of those up against the third. Did the agent’s actual call stay inside what the pack said this tool is for, and inside what the organisationorganization requires?
The version that couldn’t be read
Our first tool policy did that comparison in a single expression per rule, written in CEL. Reading the pack’s declaration, inspecting the arguments and stating the requirement all happened in one sentence:
- name: deny-raw-statement-egress
deny:
cel: >-
tool.effect == "external"
&& has(body.evidence) && body.evidence.exists(e,
e.type.matches("(?i)^(bank[ _-]?statement|transaction|account[ _-]?(history|ledger|entries))")
&& !e.type.matches("(?i)(summary|aggregate|derived|total)"))
That’s correct. It’s also unreadable to the person who owns the requirement it implements. The requirement is one line of English: raw statement data doesn’t go to an outside party. The other four lines are how you work out, for this pack’s particular argument shape, whether a payload contains raw statement data. Fold those together and the compliance officer signs off a regex. In practice they sign off nothing, and the control becomes whatever engineering says it is.
There’s a second problem underneath, and it’s the one that decided the design. In one expression, the pack’s declaration, the caller’s identity and the agent’s arguments are all just variables. A rule can grant on any of them. Add a permit form to that language, which every real policy system needs, and “the agent’s own arguments can open the gate” is a mistake a reviewer has to catch every time.
The split
A ToolPolicy now has two parts.
Producers do the inspection. Each one reads the call, or the pack’s declaration about the tool, and computes one named fact. This is the only part of the system that touches what the agent wrote.
producers:
- name: leavesTheOrg
type: cel
cel: tool.effect == "external"
- name: containsPersonalData
type: cel
cel: >-
has(body.evidence) && body.evidence.exists(e,
e.type.matches("(?i)^(bank[ _-]?statement|transaction|account[ _-]?(history|ledger|entries))")
&& !e.type.matches("(?i)(summary|aggregate|derived|total)"))
leavesTheOrg is the pack’s governance metadata, given a name the policy can use. containsPersonalData is the argument inspection, same regex as before, also given a name.
The policy states the requirement. It’s written in Cedar, an open policy language from AWS, and it can see exactly two things: the named facts, and identity the login chain verified.
permit (principal in Workspace::"finance",
action == Action::"invoke",
resource in Registry::"ledger-tools");
@id("no-personal-data-leaves")
@message("Personal data may not be sent outside the organisation. Send the derived summary instead.")
forbid (principal, action == Action::"invoke", resource)
when { context.leavesTheOrg && context.containsPersonalData };
@id("nothing-irreversible-without-approval")
forbid (principal, action == Action::"invoke", resource)
when { context.irreversible && !context.approvedChange };
Read it back. Finance may use the ledger tools. Personal data doesn’t leave the organisationorganization. Nothing irreversible runs without an approved change. That’s the pack’s governance requirement, stated in the policy, in words the person accountable for it can check. The regex is somewhere else, under a name, where an engineer maintains it.
And the second problem is gone by construction. Cedar anchors a grant on principal, and principal is built from verified identity and nothing else. The agent’s arguments reach context, and context is the only place they can reach. There’s no variable a rule could read that lets a call authoriseauthorize itself.
Producers are trusted to produce
The policy believes every fact it’s handed. That’s the whole model, and stating it plainly is what makes the rest of the design follow.
If a fact is wrong, the decision is wrong. If a fact is missing, the decision is worse, because a missing fact reads as false and false is the direction that lets calls through. So a producer that fails fails the whole decision, and the call is refused. An unexpected result type is refused rather than coerced, since coercion invents a fact. A producer can’t shadow a built-in or share a name with another. And the policy digest that every decision carries covers the producers as well as the policy text, because a producer decides what a policy’s terms mean: change one and every verdict changes without a line of Cedar moving.
It also means the producer’s type is the most important thing about it. A fact is exactly as trustworthy as whatever produced it, and there are three kinds.
Built in. The broker computes a few facts itself: argBytes, argNames, hasAttachments. Cheap, universal, and enough that a policy works with no producers declared. What the broker won’t do is expose the raw argument map. A policy reading context.args.evidence[0].type is reading the agent’s own words again.
Declared in CEL. A short expression in the policy’s YAML, run in the broker. This is where the regexes went. It’s the right place for a structural check that’s cheap and testable, and the wrong place to pretend a regex is a judgementjudgment. A pattern matching dated rows with two-decimal amounts proves a payload has the shape of a statement. It’s a weak witness. It knows nothing about what the document is or where it came from.
Remote. A producer declared with an endpoint, called once per tool call with a latency budget and fail-closed on timeout. This is the one the design exists to reach. A remote producer can be a DLP service, a classifier, a small model, or a lookup against a judgementjudgment made once, when the document entered the system, and recorded with its provenance. It can bring evidence the broker can’t compute at the gate: a signed classification, a data owner’s attestation, a change-ticket reference. The policy still sees one name. The thing behind the name got a great deal stronger.
Over time the CEL producers shrink. Each check that moves behind an endpoint is a regex that leaves the repository. Today http is declared in the CRD and rejected at compile time, because a declared producer that produces nothing would read as absent, and absent fails open.
What the platform gets
Shut by default. No statement permits the call, the call is refused. That took every agent’s tool calling away until a grant was written, and it’s the right default.
Audit mode can’t grant. A permit in an audit policy doesn’t count. If it did, switching enforce to audit would widen what’s allowed.
A refusal is a refusal wherever it sits. No first-match-wins, and one policy’s forbid beats another’s permit.
The refusal names itself and says what to do instead. The decision carries the @id of the statement that decided it. The @message goes back to the agent as the tool result, which is how an agent learns to resubmit a summary in place of the raw rows. Cedar has no message concept; a forbid is a predicate. The annotation is ours, and a control that refuses correct work without saying how to do it correctly is a control somebody switches off.
What it costs
The regexes didn’t get simpler. They moved out of the rule and into the producers, and they still run over whatever the agent sends. Cedar can prove properties of the policy. It can’t prove that the check behind containsPersonalData means what its name says. The honest description is a verified decision over a tested set of checks, and the checks are where the risk lives now.
Two languages in one object, a name to keep in step between them, and about thirty per cent more lines than the single-expression form. Taken on purpose. A requirement the owner can read was the point, and you can’t have it while the requirement and the inspection are the same sentence.
One thing still to come. Today a policy refuses on the pack’s declarations and grants on identity. Granting on a declaration waits for signed provenance, which RFC 0013 leaves to a later RFC; when a pack carries its approver, that grant is as sound as any other.
The pack says what the agent may do. The call is what it did. The policy is the one place those two are held side by side, and it’s now written so the person who owns the first can read the thing that checks the second.
Further reading
- Cedar, the cedar-go implementation this runs on, and the policy syntax guide for why one policy’s refusal beats another’s grant.
- PromptPack RFC 0013, Governance Declarations: what a pack declares about a tool, and what it defers.
ToolPolicyreference: producers, Cedar policy text, selectors, modes.- The CEL language definition and the RE2 syntax behind
matches().