If a rule matters, it should not live only in the system prompt. Put it in a policy engine that sits between the agent and its tools, and let the prompt do what prompts are good at: helping the model behave.
An agent decides which tools to call. Say its prompt contains "never email customer data outside the company." That line is text the model reads, next to every other piece of text it reads: user messages, tool results, web pages. A prompt injection in a support ticket can argue it away. So can a plain mistake. The rule is a suggestion.
I think Cedar is a good fit for the enforcement half.
01 What Cedar is
Cedar is an open-source policy language and authorization engine from AWS, licensed Apache-2.0. A request has four parts: a principal (who), an action (what), a resource (on what), and a context record (anything else about the request). Policies are permit or forbid rules over those four parts.
Three rules from the authorization docs do most of the work:
- Default deny. No request is allowed unless some
permitmatches. - Forbid overrides permit. If any
forbidmatches, the answer is Deny, whatever the permits say. - Errors skip the policy. A policy whose evaluation errors is ignored. This one matters for safety, and I come back to it below.
Evaluation is deterministic code. There is no model in the loop, so the same request always gets the same answer. And policies can be analyzed before they run. The Cedar paper describes a sound and complete logical encoding, for example to check that a refactor does not change what is allowed, and the authors proved properties of the design in Lean. The repo ships a cedar-policy-symcc crate for verifying properties with concrete counterexamples.
02 A worked example
A user in Slack asks the agent to send this week's report to a partner. Earlier in the same session, the agent queried the customer database. The report it drafted may now contain customer PII.
The gateway tracks that. When a tool returns data from a sensitive source, the gateway adds a label to the session's taint set. The model does not set it and cannot clear it.
Three policies:
// Engineers may query the analytics database.
permit (
principal in Role::"engineer",
action == Action::"postgres.query",
resource == Server::"analytics-db"
);
// Engineers may send mail from the reports mailbox.
permit (
principal in Role::"engineer",
action == Action::"gmail.send",
resource == Mailbox::"reports"
);
// Once customer PII is in the session, mail may only go inside acme.com.
forbid (principal, action == Action::"gmail.send", resource)
when { context has taint && context.taint.contains("customer_pii") }
unless {
context has args && context.args has to &&
context.args.to like "*@acme.com"
};
Two details. Cedar has no endsWith. The operators page gives like, where * matches any run of characters, so a suffix check is like "*@acme.com". And every optional attribute is guarded with has. Because an erroring policy is skipped, an unguarded forbid that hits a missing field would quietly stop forbidding. Written this way, a missing recipient makes the unless false and the forbid still applies. It fails closed.
The model proposes gmail.send to partner@example.com. The gateway builds this request:
{
"principal": { "type": "User", "id": "priya" },
"action": { "type": "Action", "id": "gmail.send" },
"resource": { "type": "Mailbox", "id": "reports" },
"context": {
"session_id": "s-8812",
"taint": ["customer_pii"],
"args": { "to": "partner@example.com", "subject": "Weekly report" }
}
}
With User::"priya" in Role::"engineer" in the entity data, the second permit matches. The forbid matches too: the taint set contains customer_pii and the recipient is not on acme.com. Forbid wins. Decision: Deny, with the forbid's policy ID in the diagnostics. Send the same report to lead@acme.com and the answer is Allow.
The gateway should normalize arguments before building the request: one request per recipient, lowercased, and reject malformed addresses. A pattern check is only as good as the string you hand it.
03 Where it runs
In a gateway between the agent and its tools. The model proposes a call, the gateway asks Cedar, and only then does the call execute. The policies live in the gateway's config. The model never sees them and cannot edit them.
With cedar-go, the check is a few lines:
ps, err := cedar.NewPolicySetFromBytes("agent.cedar", policyBytes)
if err != nil { return err }
req := cedar.Request{
Principal: cedar.NewEntityUID("User", "priya"),
Action: cedar.NewEntityUID("Action", "gmail.send"),
Resource: cedar.NewEntityUID("Mailbox", "reports"),
Context: cedar.NewRecord(cedar.RecordMap{
"taint": cedar.NewSet(cedar.String("customer_pii")),
"args": cedar.NewRecord(cedar.RecordMap{
"to": cedar.String("partner@example.com"),
}),
}),
}
decision, diag := cedar.Authorize(ps, entities, req)
if decision != cedar.Allow {
audit.Deny(req, diag.Reasons) // policy IDs that matched
return errDenied // the model gets a short refusal, not the policy
}
return tool.Call(ctx, call)
04 System prompt vs policy
| System prompt | Cedar policy | |
|---|---|---|
| Who reads it | The model | The gateway |
| Can it be bypassed | Yes: injection, mistakes, long context | Not by the model; it never touches it |
| Testable | Statistically, with evals | Exactly, with unit tests and analysis |
| Auditable | Hard to say why a call happened | Every decision names the policies behind it |
| Speed | Part of every model call | Local evaluation, no model call |
Keep both. The prompt tells the agent the rules so it plans well and wastes fewer calls on things that will be denied. The policy enforces them when the prompt fails.
05 Running it
Policies are files, so they go in git, get reviewed, and get unit-tested in CI: a table of requests and the decision each should get, including the attacks you worry about. Validate them against a schema so a typo in an attribute name is a build error, not a silently skipped rule. Every deny goes to an audit log with the request and the matching policy IDs.
Some calls should not be a flat yes or no. "Ask a human first" is useful for things like large refunds. Cedar has no such decision, so model it as a design choice: a separate class of rules, for example forbids tagged with an @escalate annotation, which the gateway treats as "pause and ask in Slack" instead of "refuse." Cedar still just says Deny. The gateway decides what a deny from that class means.
Treat the model as an untrusted insider: it can ask for anything, and the policy engine decides what it gets.