agentpolicy

package module
v0.0.11 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Oct 2, 2026 License: MIT Imports: 15 Imported by: 0

README

agentpolicy

Decisions for Go agents over Open Responses: what an agent may do, and what may enter or leave its window. One rule grammar, one engine that becomes the loop's BeforeToolCall, guards that become BeforeModelCall, OutputGuard and ShouldStopAfterTurn, an answer source for the calls the engine defers, and a journal of every verdict.

It sits above agentturn and produces its hook values; the loop never learns it exists. The root package and guard never call a model or open a socket. Only classify takes an openresponses.Streamer.

go get github.com/ChristopherDavenport/agentpolicy

Rules

A rule is a tool name, or a name with a specifier in parentheses. It is the grammar of a skill's allowed-tools, and a specifier may contain spaces:

rules, err := agentpolicy.ParseRules("read bash(git status:*) bash(npm test:*)")

What a specifier means belongs to the tool. A product registers a matcher per tool that takes specifiers. GlobMatcher is the reference's pattern over one string argument, the one a settings file copied from its documentation needs: a * anywhere stands for any run of characters, so git log * main matches git log --oneline main, and a trailing :* is a wildcard at a word boundary, so ls:* matches ls -la and not lsof. PrefixMatcher is the simpler <prefix>:* or an exact value, and reads those patterns differently. A rule with a specifier for a tool without a matcher does not build.

A rule may say why it exists. Rule.Note is not part of the grammar; a product's settings parser fills it from a comment or a field beside the rule, and it follows the rule in the reason the model reads: denied by bash(curl:*): outbound network is proxied; use fetch. A note from a named source the user has not trusted is left out of the reason, since the model would read a repository's text in the harness's voice; it stays on Verdict.Rule for the record and the front.

A rule's tool name may be a glob, mcp__*, which the deny and ask lists honour: a * stands for any run of characters, and nothing folds case. A glob in the allow list does not build, since a glob names no matcher and the reference refuses one too.

Rules a product did not write are spelled with the reference's tool names, Bash, Read, Edit, and one of those names may govern several of a product's tools. An alias table says which:

agentpolicy.WithAliases(map[string][]string{
	"Bash": {"bash"},
	"Read": {"read", "grep", "glob"},
	"Edit": {"edit", "write"},
})

Build expands a rule whose name has an entry into one rule per tool, so a skill's allowed-tools line and a settings file copied out of the reference's documentation build against a product's own tools, and Engine.Policy reports the rules as they are evaluated. A name with no entry is a tool's own name and still fails closed.

A policy

policy := agentpolicy.Policy{
	Allow:   must(agentpolicy.ParseRules("read bash(git status:*) bash(npm test:*)")),
	Deny:    must(agentpolicy.ParseRules("bash(rm:*) bash(curl:*)")),
	Ask:     must(agentpolicy.ParseRules("bash(git push:*) edit")),
	Default: agentpolicy.Ask(),
}
eng, err := agentpolicy.Build(policy, map[string]agentpolicy.ToolMatcher{
	"bash": {Match: agentpolicy.PrefixMatcher("command"), Subjects: shell.Split},
	"edit": {Match: agentpolicy.PrefixMatcher("path")},
}, agentpolicy.WithObserver(record))

cfg.BeforeToolCall = eng.BeforeToolCall()

Precedence is deny, then ask, then allow, then the default, always. A deny blocks the call with denied by bash(rm:*) as its error output; an ask defers it, the run ends with the call pending, and the front answers through Agent.Resume. The default must be set: a deny list on its own never allows everything else by accident. Engine.Would gives the verdict Decide would, without the batch hold and without deciding anything, for a front that shows which rule will match before the call is made.

A deny with no specifier denies every call of its tool, so the tool is not offered at all: the model never sees it and plans nothing around it, as in the reference. A carve-out in the deny list that reaches it, read read(!/repo/README.md), lets some calls through, so the tool stays offered and the decision refuses the rest. Engine.Filter drops those tools from a list, Engine.ToolProvider is the hook value over a list that changes, and Engine.Removes names the rule for a front that shows what a policy withheld.

cfg.ToolProvider = eng.ToolProvider(mcp.Tools)

A tool list that changes needs a policy that can change with it. Engine.SetPolicy replaces the rules without rebuilding the matchers and without replacing the config the loop holds, which SetConfig refuses while a run is active, so a tool an MCP server announces mid-session is not parked by an Ask() default for an approval nobody will give. A policy that cannot be re-derived covers the tools it has not seen with a bare name or a tool-name glob in the deny or ask list.

An ask holds its batch. When the model asks for git add -A, git commit -m wip and git push --force in one turn and the policy asks about the push, the two calls it allows are deferred too, with Verdict.Held set, so nothing runs while the user reads the question. The front asks about the calls Engine.Deferred reports as not held, and Release answers the rest from the user's answer:

answers, err := eng.Release(ctx, end, agentturn.Approve(callID).WithNote("last time"))
end, err = agent.Resume(ctx, answers...)

The held calls run with the approved one, in the model's order. An answer built with agentturn.Refuse ends the turn instead, and the held calls are answered with text that says so; a plain refusal lets them run, since the policy allowed them and the model sees the one refusal. A pending call neither the front nor the engine answers is ErrUnanswered, which names it, rather than a Resume that fails with nothing to say.

A product's own before-tool-call hooks go to WithHooks, which folds their decisions into the engine's before the hold, strictest first: a hook that asks holds the batch as a rule does, and a hook that blocks a call the policy asked about leaves nothing held for it. A hook chained after Decide with agentturn.ChainBeforeToolCall is outside the hold, so the siblings of a call it defers run before anyone answers. The engine reads a call's siblings to decide the hold, so a hook is also called for a sibling before the loop hands it that sibling's own call, and must decide a call the same way each time.

eng, err := agentpolicy.Build(policy, matchers,
	agentpolicy.WithHooks(meterFetches))

One engine serves every agent: what it defers is remembered under the call's own run, so a sub-agent deciding a call while the user reads a question costs the main agent nothing. Release and Answers forget a call as they answer it, and Engine.Forget(runID) drops what an abandoned run left behind. A nested call, one a tool made with agentturn.Invoke, has a ToolCallInfo.Parent; its deferral is reported as a verdict and not remembered, since the loop settles it inline with the invoking tool's elicitor and it never ends the run pending.

When a tool's Subjects splits a call, a shell command into its subcommands say, every subject is decided and the verdicts fold: the call is denied if any subject is, asked about if any is, and allowed only when every subject is. git status && rm -rf / is denied, and the model reads denied by bash(rm:*) on "rm -rf /"; the call did not run, so it does not report the other half as having run. A subject may name another tool, so a redirect target is checked against the file tool's rules. The splitter is the product's; there is no shell parser here. Verdict.Subject is the text of the subject the verdict was made on, so a prompt about npm run build && ./scripts/deploy.sh --prod says that the deploy script is what it is asking about.

A call its tool says runs confined is not asked about by a bare ask rule naming that tool, as both references skip a bare Bash ask for a sandboxed command. The engine reads agenttool.ConfinedBy over the call's tool and arguments, allows the call with confined by landlock+seccomp, so bash does not ask, and puts what confined it on Verdict.Confined. A deny rule applies whatever the sandbox, and so does an ask rule with a specifier, which is how a policy still asks about a call that leaves it: bash(sandbox:escalated) for a shell whose escape hatch is an argument. A tool that claims no sandbox asks, since the safe mistake is to ask. WithConfinement replaces the reading or turns it off, and WithTools(set.Lookup) lets the hold see the confinement of a call later in the batch than the one being decided:

eng, err := agentpolicy.Build(agentpolicy.AutoEdit(split), matchers,
	agentpolicy.WithTools(tools.Lookup))

Confinement is one bit: a tool says a call is confined, not what its sandbox permits. Under a sandbox that permits writes, Suggest allows every confined command AutoEdit does, rm -rf src included, so a product whose sandbox permits writes passes WithConfinement(nil) with Suggest, or a reading that answers confined only for a read-only sandbox.

Where rules come from

Settings files merge rather than override:

policy, err := agentpolicy.Merge(
	agentpolicy.RuleSet{Source: managed, Deny: managedDeny},
	agentpolicy.RuleSet{Source: project, Ask: projectAsk, Allow: projectAllow},
	agentpolicy.RuleSet{Source: user, Allow: userAllow},
)
policy.Default = agentpolicy.Ask()

Every rule carries its Source. An untrusted source's allow rules are withheld while its deny and ask rules apply. A specifier beginning with ! is a carve-out, and it reaches only rules of a source it does not rank below, so a repository's read(!.env.example) cannot open a read(.env:*) an administrator denied. Engine.Sources reports the sources, with their paths and hashes, so a session can name the policy in force.

A withheld rule is kept rather than dropped: Policy.Withheld and Engine.Withheld hold the allow rules of the untrusted sources, so a front asking whether to trust a folder can show what trusting it would allow. Nothing evaluates that list; trusting a source is a merge again with Trusted set.

Always allow

granted, reason := eng.GrantOver(ctx, verdict, agentpolicy.Rule{Tool: "bash", Spec: "git push:*", Source: local})

A grant that answers a prompt the default raised is a plain allow rule. A grant that answers a prompt an ask rule raised must also keep that rule from firing, since precedence alone would let it ask again: GrantOver writes the carve-out bash(!git push:*) beside the ask rule, under the grant's own source, or removes the rule when the grant is bare. It may only when the grant's source ranks at or above the rule's, and it says when it cannot, so a front drops the "always" option and offers a one-time approval instead. A carve-out cancels a rule of any source it does not rank below, so a repository's read(!.env.example) still cannot open a read(.env:*) an administrator denied. A grant never beats a deny.

What a product persists is one source's rules:

set := eng.PolicyOf("local") // the local settings file's rules, grants and all

not Engine.Policy, which holds every merged file's rules and would copy the managed and project files into the local one.

A skill's rules

A skill's allowed-tools is a grant with no prompt behind it, so there is no verdict to grant over, and an appended allow rule loses to any ask rule naming the tool. GrantSet activates a rule set under its source instead:

granted, refused := eng.GrantSet(ctx, agentpolicy.RuleSet{Source: skill, Allow: rules})
defer eng.Revoke(ctx, skill.Name)

While the set is active, its allow rules shadow the ask rules they cover, from other sources, of equal or lower rank: the team that asks before every Bash gets the git commands of the commit skill it wrote, and nothing else. A rule the set cannot activate is in refused with the reason, as GrantOver reports one, so a front can show what the skill asked for and did not get: a bare deny for the tool, a bare ask rule from a source that outranks the set, a source the user has not trusted, whose allow rules go to Engine.Withheld.

The set is keyed by its source, so a product activates a skill when the skill tool returns it and calls Revoke at the turn boundary, rather than merging every skill's rules into the policy before the run and granting the tools of skills the model never opened. Engine.Grants reports the sets in force; Engine.Policy holds only what a product persists.

A set's bare deny, a skill's disallowed-tools, takes the tool out of the offer as the policy's does: Engine.Removes and Engine.Filter read the deny rules a decision reads, so under ToolProvider the tool leaves the request on the turn after the set is activated and comes back on the turn after Revoke.

One engine serves every agent of a product, so a set with no scope decides every call the engine sees, a sub-agent's and another conversation's included. A grant scope on the context keeps a skill to the conversation that opened it:

ctx = agentpolicy.ContextWithGrantScope(ctx, sessionID)
eng.GrantSet(ctx, agentpolicy.RuleSet{Source: skill, Allow: rules}) // decides calls under this scope only
defer eng.RevokeScope(ctx)                                           // when the conversation ends

A set is activated under the scope of its context, a call is decided against the unscoped sets and those of its own context's scope, and Revoke removes the set of its context's scope; a context with no scope is the unscoped one, as before. Run every hook of the conversation, and GrantSet and Revoke for it, under the same context. A context derived from a scoped one carries the scope, so a sub-agent run from a tool call is decided under its parent's scope unless its context is given one of its own. Engine.GrantsFor(ctx) lists the sets a call under ctx consults; Engine.Grants spans every scope.

A reviewer instead of a human

reviewer := classify.NewReviewer(model, "claude-sonnet-5", rubric, classify.WithTimeout(20*time.Second))
end, _ := agent.Prompt(ctx, prompt)
for end.Reason == agentturn.ReasonInputRequired {
	answers, err := eng.Answers(ctx, reviewer, end)
	if errors.Is(err, agentpolicy.ErrDenialBound) {
		break
	}
	end, _ = agent.Resume(ctx, answers...)
}

Answers gives the reviewer each call the policy asked about as the hook saw it, with the verdict that deferred it, and returns one answer per pending call. An approval runs the call inside the loop, with the reviewer's Note after the result when it gives one; the calls held behind it are released with it. A refusal, a timeout and a failed review each answer with a refusal the model reads, so nothing runs that was not approved, and the refusal tells the model not to pursue the same outcome by another route; a reviewer that sets By: ByPolicy is read to the model as Denied by policy. A call an abort cut off is not the reviewer's to approve, since its tool may have run. When its tool says a second run is safe, agenttool.ReplaySafe, or safe under its first run's key, ReplayKeyed, and the loop carries that key, it is decided under the policy first, since a call a seeded transcript left may have been waiting on the user: it runs again when the policy allows it, goes to the reviewer when the policy asks, and is refused when the policy denies it. When the reviewer does not approve such a call, the model reads that it was cut off and may have run; an approval with other arguments than the call would run with is refused unless the tool says the rewrite is safe, since the loop would refuse to run a keyed call again with them. A call the loop never handed over, PendingUndispatched, is approved for the loop to put to the policy on resume; one the session says never started, through WithNeverStarted, is refused as never run; one whose pending call carries the output it has where it ran off the path, PendingCall.Ran, on a branch a rebase left or in the session a fork was made from, is answered with that output, giving RanWhere as the reason; one pending as PendingRejected is answered as refused before it ran, with the reason the refusing decision gave, PendingCall.Refused, when the record has one; and otherwise it is refused with text that says it may have run, as is a call pending as PendingAnswered. A deferred call held after its dispatch is reviewed when it may run again and refused when it may not. After three consecutive refusals, or ten within the last fifty reviews, the refusals are built with agentturn.Refuse, so resuming with them appends the outputs and ends the run without a model call, and ErrDenialBound tells the front why.

Guards

input := guard.Chain{
	Guards:   []guard.Guard{guard.Limit(1 << 20), guard.Redact(), guard.Deny(injection)},
	Observer: record,
}
output := guard.Chain{Guards: []guard.Guard{guard.Redact()}, Observer: record}
cfg.BeforeModelCall = input.BeforeModelCall()
cfg.OutputGuard = output.OutputGuard()

An input guard sees the request's instructions beside its items, which is where most of what enters an agent's window is: an AGENTS.md chain read out of a checkout, a skill catalogue, a memory block the model wrote. It may block, which stops the run as a guard stop before the model is called, or rewrite, which replaces the request's input for that call, and its instructions when the verdict carries them. An output guard sees each assistant message as the stream completes it, before the transcript, the record or the front's item_end keeps it: it may rewrite the message or withhold it behind a placeholder, Withheld by deny: matched denied pattern "..." unless the chain sets its own, and the run goes on. A chain that sets Stop stops the run instead, as a guard stop with the message withheld, so neither the message nor the pattern reaches the caller. The placeholder a chain builds is handed the message as the guards before the blocking one left it, so a placeholder that keeps part of the message never reads text a guard ahead of it redacted. A guard over a finished turn, ShouldStopAfterTurn, sees the response as the model produced it, with the turn's other items, and can only stop the run, since those items are already in the transcript; it does so as a guard stop, with a BlockedError wrapping agentturn.ErrGuard on the run's end, so a policy stop is told from a failure. Its subject says whether the turn is Final, the run's answer rather than a turn of tool calls, so a guard over what the user will read skips the rest. The two output hooks see the same words, so a chain wired to both judges every message twice: a rewriting chain belongs on OutputGuard, as above, and ShouldStopAfterTurn is for a check no rewrite can answer, a secret in a function call's arguments say. Limit bounds the wire size, Deny matches patterns, Secrets blocks on a key or token and Redact replaces one before the model reads it, or before a message of the model's is kept; over a finished turn Redact reads only the items the output guard never sees, so it does not stop the run on a secret it already removed. classify.New builds a guard that asks a model with a rubric.

The record

Every decision, hold, release, grant, guard verdict and reviewer answer is a Verdict through the observer: the run, the turn, the call, the action, the rule that fired, the subject it fired on, who decided and the reason. The decision the hook returns is what the loop's recorder writes as the session format's decision entry on the call, Block as reject with the reason and Defer as hold, and the engine names itself there, so those entries read by: policy.

The answers Release and Answers build name their decider through agentturn.Answer.By, policy for the engine's own and agent or human for a reviewer's, as Review.By says, and Verdict.By says the same.

Every WithObserver adds an observer, called in the order given, so a kit that records verdicts and an observer of the product's own both see every one.

What the decision entry cannot carry, the rule that fired and its note, a guard's verdict, a grant, what confined a call, reaches the session as a custom entry under agentpolicy:verdict, which Verdict.Record writes:

agentpolicy.WithObserver(func(ctx context.Context, v agentpolicy.Verdict) {
	ns, data := v.Record()
	recorder.Annotate(ctx, ns, json.RawMessage(data))
})

Specification

RFC 0001 is the contract this module binds: the grammar and its errors, the reference matchers, the precedence as an algorithm, confinement, the batch hold, grants, the answers to a deferred call, the guard contract and the verdict's record. testdata/policy/ is its conformance corpus, JSON a second implementation runs: the grammar, the matchers, and decisions over policies, sources, grants, subjects, confinement and a batch. A parser of allowed-tools, or a front that shows which rule will match a call, runs the same files.

Development

make check

runs gofmt, go mod tidy -diff, vet, the dependency check, staticcheck, govulncheck and the race tests. Tests are table-driven and offline against the echo adapter. See CONTRIBUTING.md.

Documentation

Overview

Package agentpolicy decides what an agent may do: a rule grammar over tool calls, an engine that becomes the loop's BeforeToolCall hook, a journal of every verdict, and an answer source for the calls the engine defers. Guards over content live in the guard package and become the loop's BeforeModelCall, OutputGuard and ShouldStopAfterTurn hooks; a guard and a reviewer backed by a model live in classify.

A policy is three lists of rules and a default:

rules, _ := agentpolicy.ParseRules("bash(git status:*) bash(npm test:*)")
eng, err := agentpolicy.Build(agentpolicy.Policy{
	Allow:   rules,
	Deny:    must(agentpolicy.ParseRules("bash(rm:*)")),
	Default: agentpolicy.Ask(),
}, map[string]agentpolicy.ToolMatcher{
	"bash": {Match: agentpolicy.PrefixMatcher("command")},
})
cfg.BeforeToolCall = eng.BeforeToolCall()

Precedence is deny, then ask, then allow, then the default, always. A deny blocks the call with a reason that names the rule; an ask defers it to the caller, who answers through the loop's Resume, and holds the calls of the same batch the policy allows, which Engine.Release answers from the caller's answer. When a tool's matcher splits a call into several subjects, a shell command into its subcommands say, the call is denied if any subject is, asked about if any subject is, and allowed only when every subject is.

A deny rule with no specifier, or one whose tool name is a glob, takes the tool out of the request rather than refusing its calls: Engine.ToolProvider is the hook value that does it. One engine serves every agent of a product, since the policy is the product's and not a loop's, and it remembers what it defers under the call's own run. The rules change under it: Engine.SetPolicy for a tool list that changed, Engine.Grant and Engine.GrantOver for an "always allow", and Engine.GrantSet for a rule set that lasts as long as its source, a skill's allowed-tools, which Engine.Revoke takes back.

The engine never calls a model or opens a socket: the same policy and the same call always give the same verdict. Every verdict, every grant and every reviewer's answer reaches every observer exactly once, in the order the observers were given, so a product can record it beside the session.

Index

Constants

View Source
const (
	// ByPolicy is a rule the harness evaluated on its own, which is
	// every decision the engine makes.
	ByPolicy = "policy"
	// ByAgent is another model: a [Reviewer] backed by one, as the
	// classify package's is.
	ByAgent = "agent"
	// ByHuman is a person the front asked, which is what a [Reviewer]
	// that prompts one sets on its [Review].
	ByHuman = "human"
)

Who a decision names as its decider, in the session format's words.

View Source
const VerdictNS = "agentpolicy:verdict"

VerdictNS is the namespace a Verdict is recorded under, so a reader of a session recognises one without knowing the product that wrote it. See Verdict.Record.

Variables

View Source
var (
	// ErrNoDefault is returned when the policy's Default was left unset.
	ErrNoDefault = errors.New("agentpolicy: policy default is not set; use Allow(), Deny() or Ask()")
	// ErrNoMatcher is returned when a rule has a specifier and its tool
	// has no matcher, so the rule could never match. The error names
	// the rule.
	ErrNoMatcher = errors.New("agentpolicy: no matcher for the rule's tool")
	// ErrToolGlob is returned for a rule whose tool name is a glob that
	// the engine will not honour: one in the allow list, where the
	// reference refuses it too, and one with a specifier, which names
	// no matcher. The error names the rule.
	ErrToolGlob = errors.New("agentpolicy: tool-name glob")
)

Errors returned by Build and the grants. Every error the package produces begins with "agentpolicy:".

View Source
var DefaultDenialBound = DenialBound{Consecutive: 3, Total: 10, Window: 50}

DefaultDenialBound is Codex's: three consecutive refusals, or ten within the last fifty reviews.

View Source
var ErrDenialBound = errors.New("agentpolicy: reviewer denial bound reached")

ErrDenialBound is returned by Engine.Answers, with the answers, when the DenialBound was reached.

View Source
var ErrUnanswered = errors.New("agentpolicy: pending call has no answer")

ErrUnanswered is returned by Engine.Release, with the answers it built, when a call the run left pending has no answer: the caller did not answer it and the engine did not hold it, so resuming with the answers would fail. The error names every such call.

Functions

func ContextWithGrantScope added in v0.0.11

func ContextWithGrantScope(ctx context.Context, scope string) context.Context

ContextWithGrantScope returns ctx carrying a grant scope: the key a host gives the conversation, agent or run whose calls a grant set should decide, a session ID say. Engine.GrantSet activates a set under the scope of its context, Engine.Decide consults the sets of its context's scope beside the unscoped ones, and Engine.Revoke removes the set of its context's scope. A context with no scope is the unscoped one, where a set decides every call the engine sees, as before.

One engine serves every agent of a product, so without a scope a skill the main agent opened grants its tools to a sub-agent, or to another conversation, that never opened it. A host puts the scope on the context it runs each conversation under, the one the loop hands its hooks, and on the context it activates and revokes the skill's set with; Engine.RevokeScope takes every set of a scope back when the conversation ends.

A context derived from a scoped one carries the scope. A sub-agent run from a tool call runs under a context derived from the hook's, so it inherits its parent's scope and the parent's grants decide its calls; a host that wants a sub-agent kept out of what its parent opened gives the child's context a scope of its own. A kit that scopes skill grants to a conversation by other means puts the conversation's key here instead, and the engine keeps the scope.

func GrantScopeFromContext added in v0.0.11

func GrantScopeFromContext(ctx context.Context) string

GrantScopeFromContext returns the grant scope ctx carries, or "" for the unscoped one.

Types

type Default

type Default struct {
	// contains filtered or unexported fields
}

Default is what applies to a subject no rule matches. The zero value is unset, and Build refuses a policy whose default is unset, so a deny list written on its own never allows everything else by accident. Build one with Allow, Deny or Ask.

func Allow

func Allow() Default

Allow is the default that runs a call no rule mentions.

func Ask

func Ask() Default

Ask is the default that defers a call no rule mentions to the caller.

func Deny

func Deny() Default

Deny is the default that blocks a call no rule mentions.

func (Default) Action

func (d Default) Action() (agentturn.ToolAction, bool)

Action returns the action and whether the default was set.

func (Default) String

func (d Default) String() string

String returns "allow", "deny", "ask" or "unset".

type DenialBound

type DenialBound struct {
	Consecutive int
	Total       int
	Window      int
}

DenialBound is when Engine.Answers stops answering: after Consecutive refusals in a row, or Total refusals within the last Window reviews. A zero field means no bound on that axis. Every answer that is not an approval counts as a refusal, a timeout and a failed review included.

type Engine

type Engine struct {
	// contains filtered or unexported fields
}

Engine is the runtime form of a Policy: the policy in force, the matchers it is evaluated with, the grants made since it was built and the calls it has deferred. It is safe for concurrent use.

func Build

func Build(p Policy, matchers map[string]ToolMatcher, opts ...Option) (*Engine, error)

Build validates the policy against the matchers and returns the runtime form. It fails with ErrNoDefault when the default is unset, with ErrNoMatcher when a rule has a specifier and its tool has no matcher, and with ErrToolGlob when a rule's tool name is a glob the engine will not honour, so a rule that could never match is refused here rather than ignored at the first call. The lists are copied; the caller's slices are not retained.

func (*Engine) Answers

func (e *Engine) Answers(ctx context.Context, r Reviewer, end *agentturn.RunEnd) ([]agentturn.Answer, error)

Answers reviews the calls end left pending and returns one Answer per call for agentturn's Resume, in the order of end.Pending. An approval runs the call inside the loop, with the reviewer's arguments when it gave any, and carries the reviewer's note. A refusal, a timeout and a review that failed each answer the call with a refusal the model reads, and a refusal tells the model not to pursue the same outcome by another route; only a cancelled ctx returns an error instead of answers. Every answer is a Verdict through the observer: Allow for an approval, Block otherwise, with the reason and with Verdict.By naming who answered, the reviewer through Review.By or the policy for the answers the engine makes on its own. The answer names the same decider through agentturn.Answer.By, and a refusal, a timeout and a failed review carry the verdict's reason through agentturn.Answer.Reason, so the session's decision entry says who answered, and why, without the verdict beside it.

The reviewer sees each deferred call as the hook saw it, with the verdict that deferred it, for the calls the engine asked about in that run; a call of a run it has forgotten is reviewed with the call alone and a verdict whose Action is Defer and whose Reason is empty. A call the engine held for an ask is not reviewed: the policy allowed it, and Engine.Release answers it from the asked calls' answers. A call an abort cut off or one found unanswered in a seeded transcript, whose tool may have run, is not reviewed either, and does not count toward the bound, unless the policy asks about running it again. When its tool says a second run is safe, agenttool.ReplaySafe, or safe under its first run's key, agenttool.ReplayKeyed, and the pending call carries that key, it is decided under the policy of the moment, as Engine.Decide decides it with its tool's confinement and the WithHooks fold but no hold: allowed, it is approved and runs again, with that key and any arguments a hook rewrote; asked about, it goes to the reviewer with that verdict, and counts toward the bound as any review does; and denied, it is refused with text that says it may have run and names the rule. A call a seeded transcript left may never have been decided, since it may have been waiting on the user when the product stopped, so a replay is never approved on the tool's word alone. The tool is the pending call's, or the one WithTools names for a call from a seeded transcript. A call the record shows ran to completion elsewhere, one the loop's PendingCall carries the output of as agentturn.PendingCall.Ran, is answered with that output before the replay rule, by policy, with PendingCall.RanWhere, the record's word on where it ran, as the reason. When the call was never handed to its tool, pending as agentturn.PendingUndispatched, nothing has decided it, and agentturn's Resume puts an approval of it to BeforeToolCall as a run would: it is approved, by policy, with the reason "not started: decided on resume", for the policy to decide there, and that answer is no verdict, since the decision on resume is. A call the record says never started, through WithNeverStarted, but that the loop lists under another reason is refused with text that says it did not run, since Resume would hold an approval of it to the replay rule and refuse it. A call pending as agentturn.PendingRejected was refused before it ran and is owed that refusal: it is answered, by policy, with text that says it was refused and did not run, and the reason the record gives for the refusal, agentturn.PendingCall.Refused, when it gives one. Otherwise it is answered with a refusal that says it may have run, so the model decides whether to ask for it again, as is a call pending as agentturn.PendingAnswered, which is owed its output and cannot run again. A deferred call held after its dispatch is reviewed when it may run again, and refused as one that may have run when it may not.

A reviewer's non-approval of a call that may have run, one the policy asked about running again, tells the model so: the text that says it was cut off and was not run again comes first, and a timeout and a failed review do not say the call did not run. An approval of such a call whose arguments differ from those of the dispatch it repeats is refused the same way, with the reason "not run again: the reviewer rewrote the arguments, and replay is X for the rewrite", unless its tool says the rewrite is agenttool.ReplaySafe, since Resume runs a keyed call again only with the arguments of the dispatch it repeats. That refusal is the engine's, not the reviewer's, and does not count toward the bound.

When the DenialBound is reached, every refusal among the answers is built with agentturn.Refuse, so Resume appends the outputs and ends the run with StopRefused instead of calling the model, the held calls are refused with it, and the answers are returned with ErrDenialBound so the front can say why. The bound counts across calls to Answers until Engine.ResetReviews.

Answers completes with Engine.Release, so a pending call it could not answer is ErrUnanswered, returned with the answers and joined with ErrDenialBound when both hold.

func (*Engine) BeforeToolCall

func (e *Engine) BeforeToolCall() func(context.Context, agentturn.ToolCallInfo) (*agentturn.ToolDecision, error)

BeforeToolCall returns the hook value for agentturn.Config.

func (*Engine) Decide

Decide is the verdict for one call. The call is split into its subjects by the tool's Subjects, one subject when it has none, and each subject is decided with the fixed precedence: deny, then ask, then allow, then the default. The verdicts fold to the most restrictive: the call is blocked if any subject is denied, deferred if any subject asks, and allowed only when every subject is. A splitter that fails blocks the call with its error as the reason.

A call the policy allows is held, deferred with Verdict.Held set, when another call of its batch asks, so nothing the model asked for in the same turn runs before the user has answered; the loop runs the hook for every call of the batch before any executes, so the engine sees the ask wherever it sits in the batch. Engine.Release answers the held calls once the asked ones are answered. A blocked call is blocked whatever its batch holds.

A call the tool says runs confined is not asked about by a bare ask rule naming its tool; WithConfinement says how that is read.

The hold covers the hooks given with WithHooks, whose decisions are folded into the policy's before it. A hook chained after Decide with agentturn.ChainBeforeToolCall is outside it: a call such a hook defers does not hold its siblings, and a call it blocks leaves the siblings Decide held waiting on a question nobody is asked.

The decision names the policy as its decider, or the hook that made it stricter. The verdict reaches the observer before Decide returns, and the same policy and the same call in the same batch always give the same verdict.

A nested call, one a tool made with agentturn.Invoke, has a Parent on its info. Its deferral is reported as a verdict, but nothing is remembered for it: the loop settles it inline, with the elicitor on the invoking tool's context or with a refusal, and it never ends the run pending, so no Engine.Answers or Engine.Release would forget it and Engine.Deferred would list it for as long as the run lives.

One engine serves every agent of a product. What it defers is remembered under the call's own run, so a decision in a sub-agent's run never forgets what the main agent is waiting on. The rule sets consulted are the policy's, the unscoped sets Engine.GrantSet activated, and those of the grant scope ctx carries, see ContextWithGrantScope, so a skill one conversation opened does not decide another's calls.

func (*Engine) Deferred added in v0.0.2

func (e *Engine) Deferred(runID, callID string) (Verdict, bool)

Deferred returns the verdict that deferred the call in the run, asked or held, so a front can show why a pending call waits and tell the calls it must answer, the asked ones, from the ones Engine.Release answers, the held ones. It reports false for a call the engine did not defer, one of another run, and one already answered: Engine.Release and Engine.Answers forget a call as they answer it, and a product that records a verdict does so from the observer.

func (*Engine) Filter added in v0.0.3

func (e *Engine) Filter(tools []agenttool.Tool) []agenttool.Tool

Filter returns the tools of the list the policy does not remove, in order. It is the Offer the round 1 study asked for: a bare-name deny, or a deny whose tool-name glob matches, withholds the tool from the model rather than refusing its calls one at a time. The rules are read once for the list, as Engine.Removes reads them: the policy's and the unscoped grant sets', since it takes no context; Engine.ToolProvider reads a scope's sets too.

The filtering is not journalled. It answers what the model is offered, once per turn, not what was decided about a call, and a front that shows the user what a policy withheld reads Engine.Removes for the rule.

func (*Engine) Forget added in v0.0.3

func (e *Engine) Forget(runID string)

Forget drops what the engine remembers of a run: the calls it deferred there and never answered. A front calls it when a run ends without its pending calls being answered, an abandoned conversation or a sub-agent that was cancelled, so the engine does not hold them for the life of the process. Engine.Release and Engine.Answers forget the calls they answer on their own, so a run answered through either needs no Forget.

func (*Engine) Grant

func (e *Engine) Grant(ctx context.Context, r Rule) error

Grant adds an allow rule for the rest of the process, as "always allow" does in an approval prompt that the default raised. The grant is journaled through the observer with a Verdict whose Action is Allow and whose Rule is the new rule. A rule with a specifier and no matcher is refused with ErrNoMatcher. A rule whose name is an alias is expanded as Build expands one, so the grant adds a rule per tool the name governs and journals each. A grant never beats a deny rule, and never outranks an ask rule; use Engine.GrantOver to answer a prompt an ask rule raised. A persisted grant is the product's: it writes the rule into its settings and rebuilds the engine at the next start.

func (*Engine) GrantOver

func (e *Engine) GrantOver(ctx context.Context, v Verdict, r Rule) (granted bool, reason string)

GrantOver answers the prompt behind v with "always allow". It adds r as an allow rule and, when an ask rule produced v, keeps that rule from firing for the calls r matches, since precedence alone would let it ask again: a grant with a specifier appends the carve-out "<tool>(!<spec>)" beside the ask rule, under the grant's own source, and a bare grant removes the ask rule. The carve-out is the grant's because the grant is, so a product that persists its own source's rules writes a personal "always allow" into its own settings and not into the file the team shares; a carve-out cancels a rule of any source that does not outrank it, which is what makes it reach the rule it answers. Engine.PolicyOf is what a product persists. The grant is journaled as Engine.Grant journals one.

It reports why it cannot, so a front drops the "always" option and offers a one-time approval instead: v did not defer the call, a deny rule produced it, the ask rule comes from a source that outranks r's, the ask rule is no longer in the policy, r does not name the ask rule's tool, r is a carve-out, or r has a specifier and no matcher. The reason is stable text a front can show.

func (*Engine) GrantSet added in v0.0.3

func (e *Engine) GrantSet(ctx context.Context, set RuleSet) (granted []Rule, refused []Refusal)

GrantSet activates a rule set under its source, as a skill's allowed-tools grants the tools the skill was written to run. It is the grant a verdict cannot answer: there is no prompt yet, so Engine.GrantOver has nothing to grant over, and an appended allow rule loses to any ask rule naming the tool, which is why a team whose settings ask before every bash was prompted for every command of the commit skill they wrote to avoid it.

A set's allow rules therefore shadow the ask rules they cover: while the grant is active, an ask rule from another source whose rank is at or below the set's is not consulted for a subject the set allows. A skill from a trusted source is a grant the user made by installing it; an untrusted source's allow rules are withheld, as Merge withholds them, and reported by Engine.Withheld. The set's deny and ask rules apply at once, trusted or not, since they only restrict. A grant never beats a deny.

A rule is refused, and not activated, when it could never fire (no matcher, a carve-out, a tool-name glob in an allow list), when its source is untrusted, when a deny rule with no specifier names its tool, and when an ask rule with no specifier from a source that outranks the set names its tool, which is the rank check Engine.GrantOver makes. An ask rule with a specifier is left to the decision, where the rank test is made against the subject.

Every rule is stamped with the set's source, as Merge stamps one, so the verdict that fires names where the permission came from.

The set is keyed by the grant scope of ctx, see ContextWithGrantScope, and its source name: activating a second set under the same name and scope replaces the first, and Engine.Revoke under the same scope removes it, which is what a product calls at the turn boundary for a grant that lasts one turn. A set activated under a scope decides only the calls whose context carries that scope; one activated under no scope decides every call the engine sees. Rules a name governs are expanded as Build expands them. Every grant and every refusal is a Verdict through the observer.

A product with several skills activates each as its own source rather than merging them all into the policy before the run, so the tools of a skill the model never opened are never granted and Merge never has to be given two sources with one name.

func (*Engine) Grants added in v0.0.3

func (e *Engine) Grants() []RuleSet

Grants returns the rule sets in force beside the policy under every grant scope, as Engine.GrantSet kept them: the unscoped sets first, then the scoped ones, each in the order they were activated. The rules it refused are not here, and an untrusted source's allow rules are on Engine.Withheld instead. A set's scope is not on the RuleSet; Engine.GrantsFor returns the sets one decision consults.

func (*Engine) GrantsFor added in v0.0.11

func (e *Engine) GrantsFor(ctx context.Context) []RuleSet

GrantsFor returns the rule sets a decision under ctx consults, in the order they are consulted: the unscoped sets, then the sets of the grant scope ctx carries, each in activation order. Under a context with no scope they are the unscoped sets alone.

func (*Engine) Policy

func (e *Engine) Policy() Policy

Policy returns a copy of the policy to persist: the one built, with every grant Engine.Grant and Engine.GrantOver made applied. A grant appends an allow rule; a grant over an ask rule also appends a carve-out beside that rule, or removes it, so what a product writes back into its settings is all here.

The scoped rule sets Engine.GrantSet activated are not here, and Engine.Grants reports those: they last as long as the source that carries them, a skill in use rather than a settings file, and an engine rebuilt from this policy decides as this one does once they are revoked.

func (*Engine) PolicyOf added in v0.0.3

func (e *Engine) PolicyOf(source string) RuleSet

PolicyOf returns the rules of one source, as a RuleSet carrying that source, which is what a product writes back into that source's settings file. The whole policy is not: it holds the rules of every file that was merged, so a product that persisted it would copy the managed and project files into its own local settings and freeze a snapshot of somebody else's.

The rules are the ones in force, so a grant this source made is here, carve-out and all, and a rule another source contributed is not. A source with no rules gives an empty set whose Source is named from Engine.Sources when the merge listed it. The scoped sets Engine.GrantSet activated are not here: they are not a product's to persist.

func (*Engine) Release added in v0.0.2

func (e *Engine) Release(ctx context.Context, end *agentturn.RunEnd, answers ...agentturn.Answer) ([]agentturn.Answer, error)

Release answers the calls end left pending that the engine held for an ask, given the answers to the calls it asked about, and returns every answer for agentturn's Resume in the order of end.Pending, the model's order, which is the order a sequential batch runs in. The policy allowed a held call, so it is approved and runs with the batch, with the arguments and the note a hook given to WithHooks gave it, unless an answer ends the run, one built with agentturn.Refuse, in which case the held call is answered with a refusal the model reads, since the turn is over. A held call the given answers already cover keeps its answer, and an answer for a call that is not pending is returned after the rest. Every release is a Verdict through the observer with Held set: Allow with the rule that allowed the call, or Block when the turn was stopped. Every answer the engine builds names the policy as its decider, through agentturn.Answer.By, and a refusal carries the verdict's reason, "not released: the turn was stopped", through agentturn.Answer.Reason; the caller's answers are returned as given.

A pending call neither the caller nor the engine answers is ErrUnanswered, returned with the answers and naming the call, since Resume would otherwise fail with the loop's own error and nothing would say which call was missed. It is what a front sees when it answers the wrong run, when another hook deferred a call the engine never saw, or when it releases before every asked call is answered. The answers returned with the error are a preview: nothing is forgotten and nothing reaches the observer, so the front answers the missing call and releases again with every answer.

The calls a release answers are forgotten: the run's entry is dropped as its last deferred call is answered, so an engine shared by several agents does not grow with the runs they finish.

func (*Engine) Removes added in v0.0.3

func (e *Engine) Removes(tool string) (Rule, bool)

Removes reports the deny rule that takes the tool out of the request, and whether one does. A deny rule with no specifier names the tool itself and so denies every call of it, which both references answer by never offering the tool: the model does not see it, plans nothing around it and spends no tokens being refused. A deny rule with a specifier denies some calls and leaves the tool offered, and so does a deny rule with none that a carve-out of the list reaches, since the carve-out lets some calls through.

It reads the deny rules a decision under no grant scope reads: the policy's, and those of every rule set Engine.GrantSet activated under no scope, so a grant set's bare deny, a skill's disallowed-tools, removes the tool as the policy's does for as long as the set is active, and Engine.Revoke gives it back. It takes no context, so a set activated under a scope, see ContextWithGrantScope, is not read here; Engine.ToolProvider reads the scope of the context the loop consults it with. It is the engine's own tool-name test, so a front that lists what a policy withholds and the list the model is offered cannot disagree.

func (*Engine) ResetReviews

func (e *Engine) ResetReviews()

ResetReviews forgets the refusals the denial bound counts, as Codex does at each new user message. A front calls it before the prompt that starts a new turn.

func (*Engine) Revoke added in v0.0.3

func (e *Engine) Revoke(ctx context.Context, source string) int

Revoke removes the rules a source granted under the grant scope of ctx and returns how many rules it removed, so a product ends a turn-scoped grant at the turn boundary, as a skill's allowed-tools "clears when you send your next message". It touches neither the policy Build was given nor the rules Engine.Grant and Engine.GrantOver added, which are the always-allow journal and outlive a turn, nor the source's set under any other scope: an unscoped Revoke never removes a scoped set, and a scoped one never removes the unscoped set. The revocation is journaled as a Verdict, Block with "revoked the rules granted by N", when it removed a rule.

func (*Engine) RevokeScope added in v0.0.11

func (e *Engine) RevokeScope(ctx context.Context) int

RevokeScope removes every rule set activated under the grant scope of ctx, with the allow rules withheld from its untrusted ones, and returns how many rules it removed. A host calls it when a conversation ends, as it calls Engine.Forget for a run that ended another way, so nothing a conversation opened outlives it. Under a context with no scope it removes every unscoped set, and no scoped one. The revocation is journaled as a Verdict, Block with "revoked the rules granted under S", S the scope, or "revoked the rules granted without a scope" for the unscoped one, when it removed a rule.

func (*Engine) Runs added in v0.0.3

func (e *Engine) Runs() []string

Runs returns the runs the engine is holding deferred calls for, so a product can see what it has not answered.

func (*Engine) SetPolicy added in v0.0.3

func (e *Engine) SetPolicy(p Policy) error

SetPolicy replaces the policy the engine decides with, validated as Build validates one and expanded through the same aliases. The matchers are not rebuilt, since they are the expensive half and they do not change, and the hook values the loop holds keep working, so a product re-derives its rules without replacing its config, which agentturn refuses while a run is active.

It is what a tool list that changes needs. An engine is built from a snapshot of that list, so a tool an MCP server announces mid-session is a tool the policy never heard of: an Ask() default defers every call to it and an unattended run parks on an approval nobody will give. A product that cannot re-derive a policy writes rules that cover the tools it has not seen instead: a bare name, or a tool-name glob in the deny or ask list, "mcp__*", governs a tool that appears later, where an allow rule needs the tool's own name.

The policy in force changes for the next call decided, not for the calls already decided: a run that ended on an ask is answered under the rules that deferred it, and a batch being decided as the policy changes can see both, so a product that must not straddle one replaces the policy between turns. The scoped grants Engine.GrantSet activated, the deferred calls and the review log are untouched. The replacement is not journalled; a product records it where it derives the policy.

func (*Engine) Sources

func (e *Engine) Sources() []Source

Sources returns the sources the policy was merged from, as Merge listed them, so the session can name the policy in force.

func (*Engine) ToolProvider added in v0.0.3

func (e *Engine) ToolProvider(base func(context.Context) []agenttool.Tool) func(context.Context) []agenttool.Tool

ToolProvider returns the hook value for agentturn.Config. ToolProvider: base's tools with the ones the policy removes taken out. The loop consults it once per turn, before the model call, so a deny added by Engine.SetPolicy or Engine.GrantSet takes the tool away on the next turn, Engine.Revoke gives it back on the next turn, and a tool a server announces mid-session is filtered as it appears.

The deny rules read are those a decision under the context the loop consults it with reads: the policy's, the unscoped grant sets', and those of the sets activated under the grant scope that context carries, see ContextWithGrantScope, so a scoped set's bare deny takes the tool out of the offer for the runs under its scope and no other. Engine.Filter and Engine.Removes take no context and read the unscoped sets alone.

base is a provider rather than a list because the list is what changes; a product whose list is fixed passes one that returns it, or filters it once with Engine.Filter and sets Config.Tools.

func (*Engine) Withheld added in v0.0.3

func (e *Engine) Withheld() []Rule

Withheld returns the allow rules withheld from the untrusted sources: those the merge withheld, and those of the rule sets Engine.GrantSet activated from a source the user has not trusted, under every grant scope, in the order Engine.Grants lists the sets. The engine never consults them: they are what a front shows when it asks whether to trust a folder or a skill, so the user reads what trusting it would allow rather than agreeing blind. Trusting a source means merging or granting again with Source.Trusted set.

func (*Engine) Would added in v0.0.11

func (e *Engine) Would(ctx context.Context, info agentturn.ToolCallInfo) (Verdict, error)

Would reports what Engine.Decide would decide for the call on its own, as a verdict, without deciding it: the policy of the moment and the grant sets ctx's scope consults, the call's confinement, and the WithHooks fold, but no batch hold, since the call's siblings are not read, and nothing remembered, so no deferral waits on an answer and nothing reaches the observer. It is the question a front asks to show which rule will match a call before the call is made, and a host asks to tell a call the policy allows on its own from one only a grant allowed, without reimplementing the precedence over Engine.Policy and Engine.Grants. The hooks are called as they are for a sibling, so a hook must decide a call the same way however often it is asked; a hook's error is returned as Decide returns it.

The verdict is the one Decide would report before the hold, down to its rule, reason, subject and confinement; its Held is never set. What the hooks add to a decision besides its action, a rewrite of the arguments, a note and Terminate, is not returned, though a rewrite's rule, reason and confinement are the verdict's. Deciding the call afterwards may differ when the rules changed in between, or when another call of its batch asks.

type Matcher

type Matcher func(spec string, args json.RawMessage) bool

Matcher decides whether a specifier matches one subject's arguments. A product registers one per tool that takes specifiers; a rule with a specifier for a tool without a matcher is an error at Build, not a silent non-match.

func GlobMatcher added in v0.0.5

func GlobMatcher(field string) Matcher

GlobMatcher is the reference's pattern over one string field of the arguments, the one a settings file copied from its documentation is written in. A "*" stands for any run of characters, spaces included, at any position: "git log * main" matches "git log --oneline main" and "* --version" matches "node --version". The space is part of the pattern, so "ls *" does not match "lsof" while "ls*" does. A trailing ":*" is a wildcard at a word boundary, which is a space: "ls:*" matches "ls" and "ls -la", and not "lsof" or "ls" and a tab. ":*" alone matches everything, as "*" does. A pattern with no wildcard matches the whole value exactly. Nothing folds case. A missing field, or one that is not a string, matches nothing.

A pattern sees the one string it is given: a compound command is split into its subcommands by the tool's Subjects, which is what keeps "git status:*" from allowing "git status && rm -rf /".

func PrefixMatcher

func PrefixMatcher(field string) Matcher

PrefixMatcher is the simplest case: the specifier is "<prefix>:*", matched as a prefix of one string field of the arguments, or any other value, matched exactly. A missing field, or one that is not a string, matches nothing.

It is not the reference's grammar: "ls:*" matches "lsof" here, and a "*" anywhere but the end is literal, so "git log * main" matches nothing. A settings file copied from the reference's documentation needs GlobMatcher.

type Option

type Option func(*Engine)

Option configures an Engine.

func WithAliases added in v0.0.3

func WithAliases(aliases map[string][]string) Option

WithAliases names the tools a rule name governs, for the rules a product does not write itself: a settings file copied out of the reference's documentation, and a skill's allowed-tools, are spelled with the reference's tool names, "Bash", "Read", "Edit", and a product's tools are named whatever the product named them. The reference also has one rule name govern several tools, Read reaching its search tools as well as its file one, which nothing else here can express.

agentpolicy.WithAliases(map[string][]string{
	"Bash": {"bash"},
	"Read": {"read", "grep", "glob"},
	"Edit": {"edit", "write"},
})

Build expands a rule whose name has an entry into one rule per tool it names, keeping the specifier, the source and the note, so the expansion happens once, where the matchers already are, and Engine.Policy reports the rules as they are evaluated. A name with no entry is a tool's own name and still fails closed: a rule with a specifier for a tool with no matcher does not build. Expansion is one level: a tool an alias names is not itself expanded. Nothing folds case, and an entry that names no tool does not build.

func WithConfinement added in v0.0.5

func WithConfinement(fn func(ctx context.Context, tool agenttool.Tool, args json.RawMessage) (bool, string)) Option

WithConfinement replaces how the engine reads whether a call runs confined, which is agenttool.ConfinedBy over the call's tool and arguments by default. A call that runs confined is not asked about by an ask rule with no specifier that names the called tool, as both references skip a bare Bash ask for a sandboxed command: it is allowed, with a reason naming what confined it and the rule it skipped. A deny rule applies whatever the confinement, and so does an ask rule with a specifier, which is how a policy still asks about a call that leaves the sandbox, bash(sandbox:escalated) for a tool whose escape hatch is an argument. The default applies as it does to any call no rule names: a confined call to a tool no rule names still asks under an Ask() default.

A tool that does not implement agenttool.Confined reads as unconfined, and so does a call whose tool the loop could not resolve, since the safe mistake is to ask. The annotations are never read this way: a server's hints are not the tool's own claim. nil turns the reading off, so every ask rule asks.

func WithDenialBound

func WithDenialBound(b DenialBound) Option

WithDenialBound replaces DefaultDenialBound.

func WithHooks added in v0.0.7

WithHooks folds these hooks' decisions into the engine's, before the batch hold, so a hook's ask holds the rest of the batch as a rule's does, and a hook's block leaves nothing held for it. A product's own before-tool-call hooks belong here rather than chained after the engine with agentturn.ChainBeforeToolCall: a chained hook is outside the hold, so a call it defers lets its siblings run before anyone answers, and a call it blocks strands the siblings the engine held for it.

The fold is agentturn.ChainBeforeToolCall's, with the engine's decision first: the strictest action wins, Block over Defer over Allow, a block ends the fold, and a hook that makes the action stricter brings its reason and its decider, By, with ByPolicy for an empty one; the verdict then has no Rule and no Subject. The first note stands, Terminate is set when any hook sets it, and arguments a hook rewrites are passed to the hooks after it and are what the call runs with, a held call's included when Engine.Release releases it. Rewritten arguments are decided again, their confinement read from them: the stricter of the two actions stands, a verdict the policy still owns takes the rewrite's rule and reason, and Verdict.Confined is the rewrite's, so a hook that takes a call out of its sandbox is asked about and the verdict does not say it ran confined. The first error fails the call's decision, and the turn with it.

The engine reads a call's siblings to decide whether to hold it, so a hook is called for a sibling of the call being decided, before the loop hands the hook that sibling's own call, with the sibling's batch position and the tool WithTools names. A hook must therefore decide a call the same way however often it is asked, and one that fails for a sibling reads as asking, so the call is held. Each WithHooks adds its hooks after those already given; a nil hook adds nothing.

func WithNeverStarted added in v0.0.7

func WithNeverStarted(fn func(ctx context.Context, runID, callID string) bool) Option

WithNeverStarted tells Engine.Answers which cut-off calls the session's record says never started: no dispatch and no output, in a file that records dispatches, agentsession's CallNeverStarted. The loop reports such a call from a seeded transcript as unknown, and without this Answers tells the model it may have run. fn is called with the run and the call and reports true only for a call the record shows never started. Such a call is refused as one that did not run, not approved for the loop to decide as a call pending as agentturn.PendingUndispatched is: the loop lists it as unknown and would hold an approval of it to the replay rule, and refuse it.

func WithObserver

func WithObserver(fn func(context.Context, Verdict)) Option

WithObserver registers fn to receive every verdict the engine produces: each decision, each grant and each reviewer answer, exactly once, outside the engine's lock. A hook cannot append to the transcript, so this is how a verdict reaches the session.

Each WithObserver adds an observer rather than replacing one, and the observers are called in the order they were given, each with every verdict. A kit that records verdicts and a product that passes an observer of its own therefore both see everything, whichever option comes last. A nil fn adds nothing.

func WithTools added in v0.0.5

func WithTools(lookup func(name string) (agenttool.Tool, bool)) Option

WithTools names the tools the engine reads confinement from for the other calls of a batch. The hook is handed its own call's tool, not its siblings', and whether a call is held depends on whether a sibling asks, which depends on whether the sibling runs confined. agenttool.Set's Lookup is the usual value.

Without it the engine knows a sibling's tool once the loop has handed it that sibling's own call, which it does in the model's order: a sibling decided earlier in the batch is read as it was decided, and one later in the batch reads as unconfined, so a call before a confined command that a bare ask rule names is held for it, and Engine.Release with no answers releases it.

The lookup must resolve a name to the tool the loop runs for it; a sibling read as confined through a tool the loop does not run is read as not asking until its own call is decided. Engine.Answers reads it too, for the tool of a cut-off call found in a seeded transcript, which the loop does not name, to ask whether the call may run again.

The lookup is given the tool's name alone, so one engine shared by runs whose tool lists differ reads one list for all of them; WithToolsFor gives it the decision's context as well.

func WithToolsFor added in v0.0.11

func WithToolsFor(lookup func(ctx context.Context, name string) (agenttool.Tool, bool)) Option

WithToolsFor is WithTools with the context of the decision, so a lookup answers for the run whose batch is decided: the context is the hook's for a sibling, which carries the run's ID (agentturn.RunIDFromContext), and Engine.Answers' for a cut-off call, carrying the ended run's ID when the caller's context carries none. An engine shared by concurrent runs whose tool lists differ answers per run through it; a lookup that ignores the context is WithTools.

type Outcome

type Outcome int

Outcome is a reviewer's answer to a deferred call. The zero value is Refused, so a Review left unset refuses the call.

const (
	// Refused answers the call with a refusal the model reads.
	Refused Outcome = iota
	// Approved runs the call, with Review.Args in place of the model's
	// arguments when set.
	Approved
	// TimedOut means the reviewer gave no answer in time. The call does
	// not run, and the record says why, distinct from a refusal.
	TimedOut
)

func (Outcome) String

func (o Outcome) String() string

String returns "refused", "approved" or "timed_out".

type Policy

type Policy struct {
	Allow []Rule
	Deny  []Rule
	Ask   []Rule
	// Default applies to a subject no rule matches. It must be set.
	Default Default
	// Sources lists where the rules came from, as [Merge] fills it,
	// so the session can name the policy in force. A policy built by
	// hand may leave it nil.
	Sources []Source
	// Withheld are the allow rules of the untrusted sources, stamped
	// with their source, in the order [Merge] saw them. They are not
	// evaluated: nothing in the engine reads this list, and a rebuild
	// of the policy with the source trusted is what applies them. A
	// front reads them to tell the user what trusting a folder would
	// allow, which is the reference's whole workflow around trust.
	Withheld []Rule
}

Policy is the rule set: what is allowed, what is denied, what needs approval, and what happens otherwise. Precedence between the lists is fixed: deny, then ask, then allow, then Default.

func AutoEdit

func AutoEdit(t Tools) Policy

AutoEdit approximates Codex's middle mode: reads and edits run, commands ask unless their tool says they run confined, and a tool the split does not name asks. The reference confines the edits to the workspace, which is the product's to arrange. A confined command is allowed whatever its sandbox permits; see Suggest. A host whose tools are discovered at run time replaces the default with Policy.WithDefault, or names the discovered tools in the split and follows its tool list with Engine.SetPolicy.

func FullAuto

func FullAuto(t Tools) Policy

FullAuto approximates Codex's unattended mode: every tool the split names runs without asking, and the confinement is the sandbox's, which is the product's to arrange. A tool the split does not name still asks, so a tool added later by a plugin does not run unseen; a host that wants such a tool to run replaces the default with Policy.WithDefault, knowing what it gives up, or names the discovered tools in the split and follows its tool list with Engine.SetPolicy.

func Merge

func Merge(sets ...RuleSet) (Policy, error)

Merge unions the lists of several sources into one Policy, as the settings files of an organisation, a repository and a user combine. The sets are ordered by Rank, highest first, so the most authoritative rule is the one a verdict names; every rule is stamped with its set's Source, which is what scopes a carve-out; and the allow rules of an untrusted source are withheld, since they grant capability, while its deny and ask rules apply at once, since they only restrict. Precedence between the lists does not depend on the source. Default is left unset for the product to choose, and every source is listed on the policy, trusted or not.

A withheld allow rule is kept on Policy.Withheld rather than dropped, so a front can tell the user what trusting a folder would allow. Nothing evaluates that list.

Each source must be named, and no two may share a name, since the name is what scopes a grant and names the file a product persists its own rules into. A product merging several skills names each one, "skill:commit", and the error says so; a skill's rules more often belong in Engine.GrantSet, which keys them by source and takes them back at the turn boundary, than in the policy every run is built from.

func Suggest

func Suggest(t Tools) Policy

Suggest approximates Codex's most conservative mode: reads run, everything that changes the world asks unless its tool says the call runs confined, and a tool the split does not name asks. The reference also confines the run to a read-only sandbox, which is the product's to arrange.

Confinement is one bit: a tool says a call is confined and what confines it, not what that sandbox permits. Under a sandbox that permits writes, workspace-write, Suggest therefore allows every confined command AutoEdit allows, "rm -rf src" included. A product whose sandbox permits writes passes WithConfinement(nil) with Suggest, or a reading that answers confined only for a read-only sandbox, so its commands ask.

A host whose tools are discovered at run time replaces the default with Policy.WithDefault, or names the discovered tools in the split and follows its tool list with Engine.SetPolicy.

func (Policy) WithDefault added in v0.0.11

func (p Policy) WithDefault(d Default) Policy

WithDefault returns p with its default replaced. A preset's default is Ask because a tool it has never heard of should not run unseen; a host whose tool set is discovered at run time, from an MCP server or a plugin, says here what such a tool gets, in one documented place rather than by assigning the field, and knows what it gives up: Allow() runs every tool the split does not name, those added later included. A host that would rather keep the guard names the discovered tools in the split and replaces the policy with Engine.SetPolicy when its tool list changes, which Engine.ToolProvider already follows per turn.

type Refusal added in v0.0.3

type Refusal struct {
	Rule   Rule
	Reason string
}

Refusal is one rule a grant could not activate, with the stable text that says why, so a front can show the user what a skill asked for and what it did not get, as it drops the "always allow" option when Engine.GrantOver refuses.

type Review

type Review struct {
	Outcome Outcome
	// Reason is shown to the model on a refusal and recorded on every
	// outcome.
	Reason string
	// Args, for an approval, replaces the arguments the tool receives.
	// nil keeps the call's own.
	Args json.RawMessage
	// Note is what the reviewer tells the model with the result, on an
	// approval or a refusal: the loop appends it after the outputs as a
	// user message, so the model reads the result and the note
	// together, as a user's note on an approval reaches it.
	Note string
	// By names who answered, for the record: [ByAgent] for a model,
	// which is what the classify package's reviewer sets, [ByHuman]
	// for a person a reviewer asked on the front's behalf. Empty is
	// read as ByAgent, since a Reviewer answers where a human would.
	// [Engine.Answers] puts it on the verdict and on the answer, as
	// agentturn.Answer.By, which the session recorder writes as the
	// decision's decider; the engine's own fail-closed answers, a
	// timeout and a review that failed, are [ByPolicy].
	By string
}

Review is a reviewer's answer.

type Reviewer

type Reviewer interface {
	Review(ctx context.Context, info agentturn.ToolCallInfo, v Verdict) (Review, error)
}

Reviewer answers a deferred call without a human: Codex's auto-review, a rule of the product's, a front that asks a person, or a test double. It receives the call as the hook saw it and the verdict that deferred it, and says who answered through Review.By. A model-backed one lives in the classify package; the engine never calls a model itself.

type ReviewerFunc

type ReviewerFunc func(context.Context, agentturn.ToolCallInfo, Verdict) (Review, error)

ReviewerFunc adapts a function to Reviewer.

func (ReviewerFunc) Review

Review calls f.

type Rule

type Rule struct {
	Tool string
	// Spec is the text inside the parentheses, "" for a bare name.
	Spec string
	// Source is where the rule came from. The zero Source is a rule
	// the product built itself.
	Source Source
	// Note says why the rule exists, "outbound network is proxied; use
	// fetch", in the words of whoever wrote it. It is not part of the
	// grammar: [ParseRules] leaves it empty, and a product's settings
	// parser fills it from a comment or a field beside the rule. When
	// set it follows the rule in the reason a verdict gives, which is
	// what the model reads for a denied call and what a prompt shows
	// for an asked one: "denied by bash(curl:*): outbound network is
	// proxied; use fetch". A note from a named source the user has not
	// trusted is left out of the reason, since the model would read a
	// repository's text in the harness's voice; it stays on
	// Verdict.Rule for the record and the front. Two rules that differ
	// only in their notes are the same rule to [Engine.GrantOver].
	Note string
}

Rule is one token of the grammar: a tool name, or a name with a specifier in parentheses, "Bash(git:*)". It is the grammar of a skill's allowed-tools, so agentskill.ToolRule maps onto it field for field without either module importing the other. What a specifier means belongs to the tool's Matcher; the grammar knows one thing about it, that a specifier beginning with "!" is a carve-out.

func ParseRules

func ParseRules(s string) ([]Rule, error)

ParseRules parses the grammar: tokens separated by whitespace, each a tool name or a name followed by a specifier in parentheses. Only whitespace outside parentheses separates tokens, so a specifier may contain spaces, "Bash(git status:*)", and parentheses inside a specifier are literal as long as they balance, "Edit(./Finance (2024)/**)". An empty string yields no rules and no error. The rules have no Source.

func (Rule) Bare

func (r Rule) Bare() bool

Bare reports whether the rule names a tool without a specifier, so it matches every call of the tool.

func (Rule) CarveOut

func (r Rule) CarveOut() (pattern string, ok bool)

CarveOut returns the pattern of a carve-out, a specifier beginning with "!", and whether the rule is one. A carve-out never matches on its own; it cancels a match of another rule in the same list, for the same tool, from a source it does not rank below, when its pattern matches the subject. The pattern is the tool's to interpret, as any specifier is.

func (Rule) Glob added in v0.0.3

func (r Rule) Glob() bool

Glob reports whether the rule's tool name is a glob over tool names rather than one tool's own name, "mcp__*" for every tool of every MCP server. A glob is honoured in the deny and ask lists, where both references honour one, and Build refuses it in the allow list and refuses it with a specifier, since a glob names no matcher.

func (Rule) MatchesTool added in v0.0.3

func (r Rule) MatchesTool(tool string) bool

MatchesTool reports whether the rule's tool name names the tool: the same name, or a glob that matches it, where "*" stands for any run of characters at any position. Nothing folds case: the reference documents case sensitivity for almost nothing, so guessing here would replace a loud failure with a quiet one. It is the engine's own test, exported so a product that offers the model a tool list does not write a second one that can disagree.

func (Rule) String

func (r Rule) String() string

String returns the token as it is written: the name, or the name with the specifier in parentheses. The source and the note are not rendered.

type RuleSet

type RuleSet struct {
	Source Source
	Allow  []Rule
	Deny   []Rule
	Ask    []Rule
}

RuleSet is the lists of one source, the input to Merge.

type Source

type Source struct {
	Name    string
	Path    string
	Hash    string
	Trusted bool
	Rank    int
}

Source is where a set of rules came from: a settings file, a skill, the session. Name identifies the source, keys a scoped grant and names the file a product persists its own rules into. Path and Hash let a session name the policy in force. Trusted false withholds the source's allow rules in Merge, as a repository's settings wait for the user to trust the folder while its deny and ask rules apply at once; a Source built by hand is untrusted until it says otherwise. Rank orders sources by authority, higher first: a grant may answer an ask rule only from a source of equal or lower rank, a carve-out cancels a rule only of a source it does not rank below, and a grant set's allow rules shadow only the ask rules it does not rank below. Rank never affects precedence between lists; deny before ask before allow holds whatever the sources.

type Subject

type Subject struct {
	// Args is the subject as the matchers see it.
	Args json.RawMessage
	// Tool, when set, evaluates the subject against another tool's
	// rules, as a shell redirect target is checked against the file
	// tool's rules. Empty means the tool that was called.
	Tool string
	// Text is what a prompt shows for this subject.
	Text string
}

Subject is one thing the policy is evaluated against. A call is one subject, its own arguments, unless the tool's Subjects splits it.

type Subjects

type Subjects func(args json.RawMessage) ([]Subject, error)

Subjects splits one call into its subjects, after normalisation. A shell tool splits a command on its operators and strips wrappers; a file tool resolves its path; most tools are one subject, the call itself, and register no splitter. The splitter is the product's; the root package holds no shell parser. An error fails closed: the call is blocked with the error as the reason.

type ToolMatcher

type ToolMatcher struct {
	Match Matcher
	// Subjects is nil for a tool that is one subject per call.
	Subjects Subjects
}

ToolMatcher is what a product registers for a tool: how a specifier is matched and, when one call is several subjects, how it splits.

type Tools

type Tools struct {
	Read    []string
	Edit    []string
	Execute []string
}

Tools is a tool set split the way the presets need it: the tools that read, the tools that edit, and the tools that execute. The split is the product's; the presets only name the tools.

type Verdict

type Verdict struct {
	RunID string
	Turn  int
	// CallID and Tool name the call decided. They are empty for a
	// content verdict; a grant names the rule's tool.
	CallID string
	Tool   string
	// Guard names the guard that decided content; empty otherwise.
	Guard string
	// Action is Allow, Block or Defer.
	Action agentturn.ToolAction
	// Rule is the rule that fired, or the rule granted. On a call
	// allowed because it runs confined it is the ask rule the
	// confinement skipped. It is nil when the default applied and for a
	// guard's or a reviewer's verdict.
	Rule *Rule
	// Reason is the stable text behind the action, the same text the
	// model reads when the action blocks a call.
	Reason string
	// By names who decided, in the session format's words: [ByPolicy]
	// for a rule the engine evaluated on its own, [ByAgent] for a
	// reviewer that is a model, [ByHuman] for a person a front asked.
	// It is what a product writes as the decision's decider when it
	// records the verdict, and it is empty for a guard's verdict,
	// whose decider is the guard that Guard names.
	By string
	// Held is set on a Defer for a call the policy allowed but holds
	// because another call of its batch asks, and on the verdict that
	// releases or refuses it once the ask is answered.
	Held bool
	// Subject is the Text of the subject whose verdict the fold kept,
	// the half of a compound command that raised the question, as the
	// tool's Subjects splitter wrote it: a prompt says which part of a
	// command line it is asking about with it, and the reason names
	// the rule, the two read together. It is empty for a call no
	// splitter split, since the splitter is what writes the text, and
	// for a splitter that writes none.
	Subject string
	// Confined names what confines the call, as the tool's
	// agenttool.Confined reported it, "landlock+seccomp",
	// "container:agent-sandbox", when the tool says the call runs
	// confined, whatever the action: a prompt shows it beside the
	// question, and on an Allow it says why nobody was asked. It is
	// empty for a call that is not confined and for a tool that says it
	// is and names nothing.
	Confined string
}

Verdict is what was decided and why, for recording: the engine's decision on a call, a hold and its release, a grant, a guard's verdict on content, or a reviewer's answer to a deferred call. The loop's recorder writes the decision the hook returned as the call's decision entry; the verdict carries what that entry cannot, the rule above all, and a product writes it beside the decision as a custom entry under agentpolicy.

func (Verdict) Record added in v0.0.5

func (v Verdict) Record() (ns string, data []byte)

Record returns the namespace and the bytes of the custom entry a product writes for the verdict, beside the decision the loop's recorder writes for the call, so every product on this stack records a verdict under one name in one shape:

ns, data := v.Record()
_, err := recorder.Annotate(ctx, ns, json.RawMessage(data))

The bytes are JSON already, which is why they are handed over as a json.RawMessage: a recorder that encodes what it is given would write a []byte as a base64 string.

The entry is a JSON object: run_id, turn, call_id, tool and guard as the verdict names them; action as "allow", "block" or "defer"; rule as its token, "bash(git push:*)", with source naming the file it came from, source_hash the Source.Hash of that file or frontmatter, so a review of the session can say which version of a skill or a settings file a grant or a decision was built from, and note saying why it exists; then reason, by, held, subject and confined. A member the verdict leaves empty is left out, except action. A verdict is strings, numbers and a flag, so encoding it cannot fail; a caller that wants an error of its own marshals the value itself.

Directories

Path Synopsis
Package classify backs a guard and a reviewer with a model: the subject, content or a deferred tool call, is sent to the model with a rubric, and the model's structured answer becomes the verdict.
Package classify backs a guard and a reviewer with a model: the subject, content or a deferred tool call, is sent to the model with a rubric, and the model's structured answer becomes the verdict.
Package guard decides what may enter or leave an agent's window: a contract for a check over content, deterministic checks that ship here, and the hook values that run them, BeforeModelCall over the request's input, OutputGuard over each assistant message as the stream completes it and ShouldStopAfterTurn over a finished turn.
Package guard decides what may enter or leave an agent's window: a contract for a check over content, deterministic checks that ship here, and the hook values that run them, BeforeModelCall over the request's input, OutputGuard over each assistant message as the stream completes it and ShouldStopAfterTurn over a finished turn.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL