agentpolicy

package module
v0.0.1 Latest Latest
Warning

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

Go to latest
Published: Sep 20, 2026 License: MIT Imports: 10 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 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; PrefixMatcher covers the common case, <prefix>:* or an exact value over one string argument. A rule with a specifier for a tool without a matcher does not build.

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.

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. 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.

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 from its own source, 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.

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 rule'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. Engine.Policy holds everything a grant changed, so a product persists it by writing what the policy now holds. A grant never beats a deny.

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 deferred call as the hook saw it, with the verdict that deferred it, and returns one answer per call. An approval runs the call inside the loop. 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. After three consecutive refusals, or ten within the last fifty reviews, ErrDenialBound lets the front stop the run.

Guards

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

An input guard may block, which fails the model call, or rewrite, which replaces the request's input for that call. An output guard can only stop the run, since the assistant's items are already in the transcript. Limit bounds the wire size, Deny matches patterns, Secrets blocks on a key or token and Redact replaces one before the model reads it. classify.New builds a guard that asks a model with a rubric.

The record

Every decision, grant, guard verdict and reviewer answer is a Verdict through the observer: the run, the turn, the call, the action, the rule that fired and the reason. A hook cannot append to the transcript, so a product's recorder writes it beside the session. A verdict on a call is the session format's decision entry on that call: Block is reject with the reason, Defer is hold, Allow is proceed, and by is policy for the engine or agent for a reviewer. A guard's verdict and a grant are not a call's fate, so they are custom entries under agentpolicy.

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 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. 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.

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 the observer exactly once, so a product can record it beside the session.

Index

Constants

This section is empty.

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")
)

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.

Functions

This section is empty.

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 and with ErrNoMatcher when a rule has a specifier and its tool has no matcher, 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, in the order of end.Pending, for agentturn's Resume. An approval runs the call inside the loop, with the reviewer's arguments when it gave any. 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.

The reviewer sees each call as the hook saw it, with the verdict that deferred it, for the calls the engine deferred in the run in progress; a call it did not defer, one an abort cut off or one from a run it has forgotten, is reviewed with the call alone and a verdict whose Action is Defer and whose Reason is empty.

When the DenialBound is reached the answers are returned with ErrDenialBound, so the front can end the run instead of resuming it. The bound counts across calls to Answers until Engine.ResetReviews.

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. The verdict reaches the observer before Decide returns, and the same policy and the same call always give the same verdict.

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 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 ask rule's own source, and a bare grant removes the ask rule. Both changes are in Engine.Policy, so a product persists a grant by writing what the policy now holds. 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) Policy

func (e *Engine) Policy() Policy

Policy returns a copy of the policy in force: the one built, with every grant since 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 must persist is all here.

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) 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.

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 PrefixMatcher

func PrefixMatcher(field string) Matcher

PrefixMatcher is the common 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.

type Option

type Option func(*Engine)

Option configures an Engine.

func WithDenialBound

func WithDenialBound(b DenialBound) Option

WithDenialBound replaces DefaultDenialBound.

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.

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
}

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 lets reads and edits run and asks for commands, and for a tool the split does not name.

func FullAuto

func FullAuto(t Tools) Policy

FullAuto runs every tool the split names without asking; the confinement is the sandbox's. A tool the split does not name still asks, so a tool added later by a plugin does not run unseen.

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.

Each source must be named, and no two may share a name.

func Suggest

func Suggest(t Tools) Policy

Suggest is Codex's most conservative mode: reads run, everything that changes the world asks, and so does a tool the split does not name.

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
}

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, or a test double. It receives the call as the hook saw it and the verdict that deferred it. 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
}

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 the same source, when its pattern matches the subject. The pattern is the tool's to interpret, as any specifier is.

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 is 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 and scopes a carve-out to the rules of the same source. 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. 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. 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
}

Verdict is what was decided and why, for recording: the engine's decision on a call, a grant, a guard's verdict on content, or a reviewer's answer to a deferred call. A product writes a verdict on a call into the session's decision entry for that call, and a grant or a guard's verdict as a custom entry under agentpolicy.

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 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 and ShouldStopAfterTurn over a finished turn.

Jump to

Keyboard shortcuts

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