The contract
Every tool call passes through Approver.Approve(ctx, ApprovalRequest) before it executes. The interface lives in internal/agent/approver.go:
type Decision string
const (
DecisionAllow Decision = "allow"
DecisionDeny Decision = "deny"
)
type ApprovalRequest struct {
ToolName string
Input json.RawMessage
SessionID string
}
type Approver interface {
Approve(ctx context.Context, req ApprovalRequest) (Decision, string)
}
Approve is called synchronously on the hot path; implementations must return promptly or honour ctx cancellation.
A DecisionDeny with a non-empty reason surfaces the reason back to the model as a tool_result error. The model can then adapt (typically by asking the operator for clarification) instead of failing silently. This is a deliberate design decision — silent denials produce worse behaviour than annotated ones.
Three shipped modes
allow_all
Every tool call runs. This is the baseline behaviour when no approver is configured.
agent:
approver:
mode: allow_all
Use when:
- Interactive
rousseau chatwith theclaudecliprovider (Claude Code is doing its own per-call approvals). - Development smoke tests where you want to see exactly what the model would do.
deny_all
Blocks every tool call with a single reason string.
agent:
approver:
mode: deny_all
reason: "denied by policy for this deployment"
Use when:
- Smoke-testing the approver wiring.
- A first-pass inspection posture where you want to see what the model would have tried, without letting it act.
pattern
Regex allow / deny rules per tool. Deny wins over allow. Unmatched requests fall back to default (allow or deny).
agent:
approver:
mode: pattern
default: deny # safe-by-default; unlisted requests are blocked
reason: "denied by pattern policy"
allow:
- {tool: read, match: ".*"}
- {tool: grep, match: ".*"}
- {tool: edit, match: "\"path\":\"/workspace/[^\"]*\""}
deny:
- {tool: bash, match: "rm -rf|sudo|chmod|chown"}
Rule semantics
Each PatternRule has two fields:
| Field | Meaning |
|---|---|
tool |
Tool name (read, write, edit, grep, bash, or any custom tool). Empty matches every tool. |
match |
Go RE2 regex against the raw JSON input the model produced. Empty matches every input. |
Match order:
- Every deny rule is tested against the request. First match → deny.
- Every allow rule is tested. First match → allow.
- Fall back to
default. Emptydefaultis treated asdeny— safe-by-default.
Deny always wins because the safer disposition is preferred. An operator adding a broad allow block can never accidentally unlock a category they had denied.
Matching against raw JSON
The match regex runs against the raw JSON input the model emitted, not against parsed fields. This has two consequences:
- You match against the JSON shape. For a
bashcall, that looks like{"command":"ls /tmp"}. Match"command":\s*"ls\s. - You can match any field. The
edittool receives{"path":"/x","old_string":"...","new_string":"..."}; you can match onpath, onold_string, or on both.
Escape JSON-relevant characters carefully:
- Double quotes are literal in the raw JSON — match with
\"in your regex if using YAML double-quoted strings. - Backslashes require doubling in YAML:
\\in the YAML file becomes\in the compiled regex.
Worked matcher patterns
Restrict edits to a directory tree
allow:
- {tool: edit, match: "\"path\":\"/workspace/repo/[^\"]*\""}
- {tool: write, match: "\"path\":\"/workspace/repo/[^\"]*\""}
Whitelist safe shell commands
allow:
- {tool: bash, match: "^\\s*\"command\":\\s*\"(ls|cat|grep|rg|find|git status|git diff|go test) "}
Deny destructive commands regardless of allow
deny:
- {tool: bash, match: "rm\\s+-rf|sudo|:\\(\\)\\{ :\\|:& \\};:"}
Deny writes to system directories
deny:
- {tool: write, match: "\"path\":\"/(etc|root|var|usr)/"}
- {tool: edit, match: "\"path\":\"/(etc|root|var|usr)/"}
The Default field
default: deny is the safer disposition and the recommended value for any unattended daemon. default: allow inverts the model — every unlisted call runs, and deny rules become the primary lever.
When to use default: allow:
- The daemon is running inside a heavily locked-down container (Deployment) and the container is your primary boundary.
- You are experimenting and want to see the model's behaviour before deciding what to block.
Everywhere else, prefer default: deny.
The Reason field
reason is the string returned to the model on every denial (or default: deny fallback). Empty falls back to denied by pattern policy (or denied by policy for deny_all).
Setting a helpful reason improves model recovery — instead of denied by pattern policy, try denied — this deployment only allows reads inside /workspace; ask the operator to widen the scope and watch the model reply with an actionable clarification.
Interaction with claudecli
When provider: claudecli, Claude Code is running the tool calls, and its own permission-mode (bypassPermissions, plan, default) also gates every action. Effective behaviour is the intersection: both the rousseau approver and Claude Code's approver must permit the call for it to run.
Prefer to keep both aligned:
- Unattended:
bypassPermissionson Claude Code,mode: pattern+default: denyon rousseau. - Read-only inspection:
planon Claude Code,mode: patternallowing onlyread/grepon rousseau. See Guides: Read-only Mode.
Audit trail
Every approver decision is emitted through slog:
| Event | Meaning |
|---|---|
tool.execute (INFO) |
Call approved, running. |
tool.denied (WARN) |
Call blocked. Includes tool name and reason. |
tool.error (WARN) |
Call ran but failed. |
See Guides: Observability for pipeline recipes.
Custom approvers
Any type satisfying Approver works. Wire your own in when embedding the agent loop:
myApprover := agent.ApproverFunc(func(ctx context.Context, req agent.ApprovalRequest) (agent.Decision, string) {
// Consult an external policy engine, prompt the operator, ...
return agent.DecisionAllow, ""
})
ag := agent.New(provider, registry, logger, agent.Options{Approver: myApprover})
The interface is deliberately minimal (Approve is the only method) so integrating with an external policy engine (OPA, Cedar, or a bespoke rules engine) is a small adapter.
Troubleshooting
Every call denied even with a matching allow
Deny wins over allow. PatternApprover.Approve in internal/agent/approver.go line 152 iterates deny rules first. Look for the exact reason string in tool.denied logs.
Regex compile error at start
PatternApprover compiles regexes lazily on first Approve. A compile error results in DecisionDeny with reason approver: pattern compile: <err>. Test regexes at regex101.com with the Go flavour.
mode: pattern but default: is ignored
Only allow and deny are valid values for default:. Empty or unknown values fall back to DecisionDeny (safe default) and print no warning.
Allow rule matches the JSON literally
The regex runs against the raw tool-call input JSON. To match a path field, escape quotes: "\"path\":\"/workspace/".
Denied calls do not appear in logs
They do — as tool.denied at warn level. If you filter by level, ensure warn is included.
Related pages
- Guides: Audit + Approval Policies — worked example with slog audit trail.
- Guides: Read-only Mode — the inspection posture.
- User Guide: Tools — the tools the approver gates.
- Security — trust boundaries overview.
- Agent loop — where the approver is called.
Further reading
internal/agent/approver.go—PatternApprover,AllowAllApprover,DenyAllApprover.internal/agent/approver_test.go— the test matrix.internal/cli/approver.go— config → approver translation.internal/config/config.go—ApproverConfig,PatternEntry.