?

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 chat with the claudecli provider (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:

  1. Every deny rule is tested against the request. First match → deny.
  2. Every allow rule is tested. First match → allow.
  3. Fall back to default. Empty default is treated as deny — 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:

  1. You match against the JSON shape. For a bash call, that looks like {"command":"ls /tmp"}. Match "command":\s*"ls\s.
  2. You can match any field. The edit tool receives {"path":"/x","old_string":"...","new_string":"..."}; you can match on path, on old_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: bypassPermissions on Claude Code, mode: pattern + default: deny on rousseau.
  • Read-only inspection: plan on Claude Code, mode: pattern allowing only read/grep on 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

Further reading

  • internal/agent/approver.goPatternApprover, AllowAllApprover, DenyAllApprover.
  • internal/agent/approver_test.go — the test matrix.
  • internal/cli/approver.go — config → approver translation.
  • internal/config/config.goApproverConfig, PatternEntry.

Type to search 150+ pages. Ranking: BM25 with title/description boost.