Writing policies
Policies are written in Cedar, the language AWS uses for AgentCore and Verified Permissions. There is no home-grown policy language here. Every example on this page is executed against the real evaluator in the test suite.
How a tool call becomes a Cedar request
For an intercepted call to tool T by the agent named in the grant:
| Cedar slot | value |
|---|---|
principal | Agent::"<agent id from the grant>" |
action | Action::"<T>" |
resource | Tool::"<T>" |
context.args | the call's arguments, exactly as the agent sent them. Provenance: claimed. |
context.facts | results of the gateway's own upstream lookups configured in facts. Provenance: observed. |
context.grant | { principal, scopes } from the signed delegation. Provenance: attested. |
No entity hierarchy or schema is loaded yet, so policies reason about context and the three identifiers above.
Two things happen before Cedar runs and cannot be overridden by policy:
- If
Tis not in the grant's scopes, the call is denied. No facts are fetched. - Each configured fact for
Tis fetched from upstream. If any lookup fails, the call is denied.
The rules Cedar applies
- Default deny. With no matching
permit, the decision is deny. forbidwins. A matchingforbidoverrides everypermit.- Errors deny. If evaluating any policy raises an error, such as a missing attribute or a float, the gateway denies regardless of what other policies said. The errors are recorded in the receipt.
- The policy is pinned. The receipt carries the sha256 of the policy file, so a verifier knows which text produced the decision.
Examples
Each of these is a complete, valid policy file.
Permit a read to any agent holding the scope.
permit(principal, action == Action::"customer.lookup", resource);Cap an amount. Amounts are integer minor units. 100000 pence is £1,000.
permit(principal, action == Action::"stripe.refund", resource)
when { context.args.amount <= 100000 };A call without amount is an error, hence a deny. That is the behaviour you want from a payments policy.
Require a fact the gateway fetched, not something the agent asserted. This is the pattern the whole project exists for.
permit(principal, action == Action::"stripe.refund", resource)
when {
context.args.amount <= 100000 &&
context.facts has customer &&
context.facts.customer.verified == true
};With the matching facts entry in the gateway config, context.facts.customer is whatever customer.lookup returned for the customer id in the call. An agent that sends { "verified": true } in its arguments changes nothing, because the policy never reads context.args.verified.
Restrict to a named agent.
permit(principal == Agent::"support-agent", action == Action::"stripe.refund", resource);Several tools in one rule.
permit(principal, action in [Action::"customer.lookup", Action::"customer.search"], resource);A hard block that no permit can override.
permit(principal, action == Action::"stripe.refund", resource)
when { context.args.amount <= 100000 };
forbid(principal, action == Action::"stripe.refund", resource)
when { context.facts has customer && context.facts.customer.flagged == true };Pattern-match a string argument.
permit(principal, action == Action::"github.merge", resource)
when { context.args.repo like "acme/*" && context.args.base == "main" };unless reads better for exceptions.
permit(principal, action == Action::"stripe.refund", resource)
unless { context.args.currency != "GBP" };Reach the grant itself.
permit(principal, action, resource)
when { context.grant.principal == "user_456" };Policies for memory
The memory server in @agent-custody/state is an upstream like any other, so its tools are governed by the same policy file with the same request shape. What differs is what is in the context:
| for | context.args carries | context.facts can carry |
|---|---|---|
memory.write | subject, predicate, value, space, and optionally supersedes and evidence | target, the fact being superseded, when the gateway is configured to look it up with memory.get |
memory.read | the query, and includeClaimed or requireVerified when the caller asks for them | |
memory.retract, memory.forget, memory.hold, memory.release | factId and reason | target, the fact being changed |
memory.sweep | before, space, reason | |
memory.confirm | factId |
A looked-up target has space, actor, provenance (claimed, attested, or verified), subject, predicate, value, and retracted. Fields that would be null are absent, so test with has. The lookup config that makes target available is in the state package README.
Confine an agent to its team's space. Reads anywhere, writes only to one space.
permit(principal, action == Action::"memory.read", resource);
permit(principal, action == Action::"memory.write", resource)
when { context.args.space == "team:support" };Keep quarantine closed. Only a named reviewer may read claimed facts or lift them out of quarantine.
permit(principal, action == Action::"memory.read", resource)
unless { context.args has includeClaimed && context.args.includeClaimed == true && principal != Agent::"reviewer" };
permit(principal == Agent::"reviewer", action == Action::"memory.confirm", resource);Require evidence for org memory. A write to the org space must cite a fact the gateway fetched itself; the memory server then checks the value against it and writes it as verified, or refuses.
permit(principal, action == Action::"memory.write", resource)
when { context.args.space != "org" || context.args has evidence };Protect attested org facts from being displaced or retracted. Needs the target lookup. A self-reported org note can be replaced; an attested one cannot.
permit(principal, action in [Action::"memory.write", Action::"memory.retract"], resource);
forbid(principal, action in [Action::"memory.write", Action::"memory.retract"], resource)
when { context.facts has target && context.facts.target.space == "org" && context.facts.target.provenance == "attested" };Erasure and holds belong to named roles. Everyone else is denied by default.
permit(principal == Agent::"privacy-officer", action in [Action::"memory.forget", Action::"memory.sweep"], resource);
permit(principal == Agent::"legal", action in [Action::"memory.hold", Action::"memory.release"], resource);A complete policy for a support agent. The pieces above, together: read anywhere but not into quarantine, write team memory freely, write org memory only with evidence, retract only claimed facts, and no erasure or holds at all.
permit(principal, action == Action::"memory.read", resource)
unless { context.args has includeClaimed && context.args.includeClaimed == true };
permit(principal, action == Action::"memory.write", resource)
when { context.args.space == "team:support" || (context.args.space == "org" && context.args has evidence) };
permit(principal, action == Action::"memory.retract", resource)
when { context.facts has target && context.facts.target.provenance == "claimed" };Gotchas
- Integers only.
12.50is not a Cedar value. Send1250. - Test
hasbefore reading an optional attribute.context.facts.customer.verifiederrors if there is nocustomerfact, and an error is a deny. Sometimes that is what you want. When it is not, guard withcontext.facts has customer. - No schema means no typo protection. A policy that reads
context.args.ammountnever matches and every refund is denied. Fail-closed hides typos as denials, so test your policies. - Scope is enforced outside Cedar. You cannot use a policy to grant a tool the delegation did not include.
Testing a policy
The evaluator is a pure function, so a policy test is a unit test. See test/policy.test.ts.
import { evaluate } from "../src/policy.ts";
import { readFileSync } from "node:fs";
const policy = readFileSync("policy.cedar", "utf8");
const d = evaluate(policy, {
agentId: "support-agent",
tool: "stripe.refund",
context: {
args: { customer_id: "cust_123", amount: 50000 },
facts: { customer: { verified: true } },
grant: { principal: "user_456", scopes: ["customer.lookup", "stripe.refund"] },
},
});
// d.decision "allow" | "deny"
// d.reasons ids of the policies that decided it
// d.errors evaluation errors, non-empty always means deny
// d.policyDigest sha256 of the policy text, the same value a receipt will carryWrite one case per branch of every when clause, plus one for the missing-attribute path.