Repository navigation
Writing a Policy
type Policy interface {
Evaluate(ctx context.Context, req ToolRequest, view RunView) (PolicyDecision, error)
}The policy is called for every tool request after the arguments passed schema validation and before anything executes. It receives the request, including the tool's specification and the parsed arguments, and a view of the run: the run row, every step, and every approval. The view is a copy: a policy that modifies it changes nothing the driver, the agent, or the store sees.
| Outcome | Effect |
|---|---|
allow |
execute |
deny |
a policy_denied observation with the reason; counts as a failure; the agent decides again |
abort |
the run ends FAILED with reason policy_abort
|
require_approval |
the run pauses in WAITING_FOR_APPROVAL with an approval row |
Use deny for a request the agent could reasonably correct. Use abort for a request that shows the run cannot succeed within its rules, such as crossing a hard limit. Use require_approval for actions a human must see.
Any other outcome, an empty one included, fails the run as internal_error, in the loop and on resume alike: the two share one dispatch over policy outcomes. So does an error returned from Evaluate. The policy decision is recorded on the step, with its step.policy event, in the same transaction as what it caused.
DefaultPolicy maps side-effect classes: read-only and local mutations allowed, remote mutations require approval, destructive denied. A class with no mapping is denied. It is a sensible base to wrap: answer the requests your domain knows about, and fall through to it for the rest.
A require_approval decision carries three things the runtime stores and hashes together with the exact request:
-
Kind, a short name such aspublicationorscope_expansion; -
Capability, JSON describing exactly what would be authorized, such as an artifact id and hash; -
Presentation, JSON for the human, such as the branch, base, commit, and changed files.
NeedApproval builds the decision and marshals both:
return agentrt.NeedApproval("publication", "publishing needs an operator",
map[string]any{"artifact": id, "hash": sum}, // capability
map[string]any{"branch": branch, "base": base, "files": changed}) // presentationIt is named for what a policy does rather than for the RequireApproval outcome it carries, and it returns an error for a value that will not marshal, which is better found in the policy than as a corrupt approval row. A nil capability or presentation becomes {}. Both enter the hash, so a changed presentation cannot inherit an old approval: what the reviewer was shown is part of what they granted.
A capability or presentation built by hand as json.RawMessage is held to the same boundary as every other JSON a consumer hands the runtime: one well-formed value, valid UTF-8, nested no deeper than 256 levels, with no repeated object key and no escaped surrogate outside a pair. Anything else could not be stored or hashed faithfully, so the run fails as internal_error with nothing executed. NeedApproval produces only usable JSON from Go values.
Make the capability specific. On resume, the recorded arguments are first checked against the tool's current schema, and then the policy is evaluated again; the recorded request executes only if the answer is allow or a require_approval that hashes to the granted approval, which means the same kind, capability, and presentation over the same request. Anything else pauses the run again on a new approval. A capability that names a hash means a changed artifact can never inherit an old approval.
The presentation is shown to an operator as text that may contain model-chosen content, and cmd/agentrt labels it so. Keep it to what a reviewer needs, and nothing that differs between two runs of the same action.
After a crash, a run with no reconciliation pauses an interrupted side effect on an approval of kind interrupted_side_effect (agentrt.InterruptedSideEffect), whose capability is {"tool": ..., "args": ...}. When an operator approves it and the run resumes, your policy is evaluated on that request like any other, and the request executes only if the policy allows it or asks for exactly that approval. A policy that answers require_approval with its own kind for the same call pauses the run a second time, on its own approval, which is the policy's right: ADR 6 accepts that. A policy that wants the operator's yes on the interrupted call to suffice can recognize the kind in the run view's latest approval for the step.
DecodePresentation[T] reads one back:
pres, err := agentrt.DecodePresentation[Publication](approval)It is for a front end that renders the approval and for a consumer that rechecks it before resuming — that the branch is still what it was, say. The error names the approval and what was wrong with it, including the case of an approval with no presentation at all.
The view lets a policy make stateful decisions without state of its own: count validations to enforce a budget, see whether an expansion was already granted, check which phase the run is in by looking at what has happened. Deriving such facts from the view rather than storing them elsewhere keeps the policy consistent with the log.
A policy is a pure function of the request and the view. Construct a RunView with the steps and approvals you need and assert the decision. No model, no driver, no store.
Since v0.3.1 the testkit package does the table for you, and does it through the runtime's own validation first:
testkit.CheckPolicy(t, policy, tools, []testkit.PolicyCase{
{Name: "send asks", Tool: "send", Args: `{"to":"ops"}`, Want: agentrt.RequireApproval, Kind: "remote_mutation"},
{Name: "no recipient", Tool: "send", Args: `{}`, Want: testkit.Refused},
})Each case is decided with agentrt.CompileTool and ToolSchema.Check, the same acceptance the driver applies, so a case whose arguments the schema refuses is Refused and never reaches your policy, as it would not in a run. Disagreements print as one table. testkit.Never(t, policy, tools, class, outcome, fuzz, views...) holds an outcome off a whole side-effect class over generated arguments: "a destructive tool is never allowed", checked over requests nobody wrote by hand. To check a case yourself, agentrt.CheckArgs(spec, args) gives the driver's acceptance and refusal text. See Testing a Consumer.
A tool loaded from an MCP server reaches the policy like any other, under its prefixed name, with the schema the operator restricted. The operator's side-effect class is what the policy switches on, and no server chooses its own. See MCP Tools.
Since v0.5.0 a policy is handed the request's arguments and its tool's input schema in PolicyJSON form: keys sorted, no insignificant whitespace, numbers as written, and no HTML escaping, so && stays &&. The loop, a gate, a resume, and a re-check all hand the same bytes for one request. A policy that unmarshals what it is handed is unaffected; one that matches bytes sees one form wherever it is asked. A policy that copies the arguments into its approval's capability or presentation copies them in this form, and the approval's hash is unchanged. See Upgrading to v0.5.
A policy can name itself:
func (p MyPolicy) PolicyID() string { return "my-rules v3" }A policy with that method implements IdentifiedPolicy. Its identity is recorded on each step it evaluated and in that step's step.policy event, so a re-check can say whether the policy given is the one that decided. Make it change whenever a decision could: a version, or a hash of the rules. It must be at most 256 bytes, valid UTF-8, and free of control characters, or NewDriver and NewGate refuse the policy. SideEffectPolicy is a map and does not implement the method; wrap it to give it a name, as examples/recheck does. A policy that decides from its request and view alone can be re-checked exactly. One that reads a clock, a file, or a service cannot.
The model's reasoning beyond the recorded Reason, and anything outside the run. Domain facts such as file contents or a projected diff come through an interface the consumer supplies to the policy, testable without a model.
Using it
- Getting Started
- Upgrading to v0.5
- Upgrading to v0.4
- Upgrading to v0.3
- Concepts
- Writing an Agent
- The Gate
- Writing Tools
- MCP Tools
- MCP Proxy
- Writing a Policy
- Models and Replay
- Provider Adapters
- Rendering Model Context
- Approvals and Resume
- Interruption and Reconciliation
- Run Lease
- Limits
- Testing a Consumer
- Re-checking a Run
- Measuring Agents
Inside it
- Internals
- Persistence and Inspection
- Performance
- The Operator Command
- Approval Channels
- Exporting Events
- Testing
Deciding on it
- Safety Properties
- Security Model
- Comparison with Frameworks
- Status and Roadmap
- Roadmap
- Design Decisions
- Essays
- Contributing
- Releasing
Reference