?

The problem

An unattended chat-transport daemon has no human at the terminal to approve tool calls in real time. If the model wants to run rm -rf /workspace/*, something has to stop it. Rousseau's pattern-mode approver is that lever.

The threat is not the model going rogue — it is a compromised or misaligned instruction reaching the daemon over the transport channel. A pattern-mode policy with a default: deny fallback makes the risk bounded and auditable.

Approver modes

Three built-in modes ship (see internal/agent/approver.go):

Mode Behaviour When to use
allow_all Every tool call runs. Interactive rousseau chat where the claudecli provider is doing its own approvals.
deny_all Every tool call is blocked. Denial reasons are surfaced to the model as tool_result errors so it can adapt. Read-only inspection posture; smoke tests.
pattern Regex allow / deny rules per tool. Deny wins over allow. Unmatched requests fall back to default. Any unattended daemon in production.

Worked config

agent:
  approver:
    mode: pattern
    default: deny
    reason: "denied by pattern policy — ask the operator"
    allow:
      # Read-side tools: no restriction inside the workspace.
      - {tool: read, match: ".*"}
      - {tool: grep, match: ".*"}

      # Edit inside /workspace only.
      - {tool: edit, match: "\"path\":\"/workspace/[^\"]*\""}

      # Write inside /workspace only.
      - {tool: write, match: "\"path\":\"/workspace/[^\"]*\""}

      # Shell commands: whitelist of safe read-side utilities plus git status/diff.
      - {tool: bash, match: "^\\s*\"command\":\\s*\"(ls|cat|head|tail|grep|rg|find|wc|stat|file|which|pwd|env|git status|git diff|git log|go test|go vet|go build)\\b"}

    deny:
      # Absolute deny rules override any allow above.
      - {tool: bash, match: "rm\\s+-rf|sudo|curl|wget|chmod|chown|nc\\s|ncat"}
      - {tool: bash, match: ":\\(\\)\\{ :\\|:& \\};:"}   # fork bomb
      - {tool: write, match: "\"path\":\"/etc/|/root/|/var/"}
      - {tool: edit, match: "\"path\":\"/etc/|/root/|/var/"}

Two important properties fall out of PatternApprover.Approve:

  1. Deny wins. Every deny rule is checked before any allow rule. This is safer than the reverse: an operator adding a broad allow can never accidentally unlock a category they thought was denied.
  2. Unmatched → deny. With default: deny, any tool call the operator forgot to enumerate is blocked. This is the safe-by-default disposition; if you want the opposite, set default: allow.

Reading the audit trail

Every tool call and every denial is emitted through the slog logger:

INFO tool.execute name=read id=t_1
INFO tool.execute name=grep id=t_2
WARN tool.denied  name=bash reason="denied by pattern policy — ask the operator"

The daemon uses slog with configurable level and format (log.level, log.format). For production, prefer format: json so downstream tools (Loki, Vector, Datadog) parse cleanly. See Guides: Observability for the pipeline recipe.

Every denial carries a stable structured key:

  • tool.denied — the tool call was blocked. Fields: name (tool identifier), reason (from PatternApprover.DenyReason or the built-in fallback).
  • tool.execute — the tool call ran. Fields: name, id (the model-emitted call ID for correlation).
  • tool.error — the tool ran but failed. Fields: name, err.

An slog filter on tool.denied gives you the "attempts blocked" audit view most compliance frameworks ask for.

Testing the policy

internal/agent/approver_test.go in the source tree exercises the PatternApprover with a broad matrix. To smoke-test your own rules:

rousseau chat
> Run `rm -rf /tmp/foo` for me.

The model will attempt the bash tool call. The daemon logs tool.denied and returns the reason string to the model, which will usually pivot ("I can't run that — could you tell me what you were trying to do?").

For the reference test matrix, see internal/agent/approver_test.go — the same rule shapes are exercised there.

Adding a manual override

Sometimes an operator wants to approve a single dangerous call manually. The simplest pattern:

  1. Set mode: allow_all in rousseau chat (interactive TUI). The claudecli provider handles its own per-call approval prompts.
  2. Keep mode: pattern in every unattended daemon.

There is no interactive per-call approval UI on the chat transports today — the safety story is entirely regex + slog.

What the policy does not do

  • Does not sandbox the tool. A bash call that survives the approver runs with the daemon's UID and its filesystem visibility. Layer a rootless container (Deployment) underneath.
  • Does not rate-limit. Ten allowed bash calls per second are allowed. If you need rate limiting, wrap the tool registry.
  • Does not audit outbound network calls. If a bash invocation curls something out, the approver won't see the URL — only the initial bash command string. Deny curl and wget outright at the pattern level.

Common patterns

Locking editing to a directory tree

allow:
  - {tool: edit,  match: "\"path\":\"/workspace/repo/[^\"]*\""}
  - {tool: write, match: "\"path\":\"/workspace/repo/[^\"]*\""}
deny:
  - {tool: edit,  match: "\"path\":\"/workspace/repo/(\\.git|node_modules|vendor)/"}

Read-only auditor

mode: pattern
default: deny
allow:
  - {tool: read, match: ".*"}
  - {tool: grep, match: ".*"}

Combined with provider.claudecli.permission_mode: plan, this yields a read-only inspection posture — see Guides: Read-only Mode.

Git-first workflows

allow:
  - {tool: bash, match: "^\\s*\"command\":\\s*\"git (status|diff|log|show|branch|stash|fetch|pull --ff-only)\\b"}
deny:
  - {tool: bash, match: "^\\s*\"command\":\\s*\"git (push|reset --hard|clean -fd|checkout --)\\b"}

Five reference rulesets

Dev laptop. Permissive by default, deny the truly dangerous. Assumes an attended terminal.

agent:
  approver:
    mode: pattern
    default: allow
    deny:
      - {tool: bash, match: "rm\\s+-rf\\s+/"}
      - {tool: bash, match: "sudo(?!\\s+-n)"}
      - {tool: bash, match: ":\\(\\)\\{ :\\|:& \\};:"}
      - {tool: write, match: "\"path\":\"/etc/|/root/"}
      - {tool: edit, match: "\"path\":\"/etc/|/root/"}

Troubleshooting

Every call is denied even though I have allow rules

Deny wins over allow. Check whether one of your deny rules matches unintentionally. Log line tool.denied name=<X> reason=<Y> includes the exact reason.

Pattern regex compile error

PatternApprover lazily compiles rules on first use. A compile error becomes a DecisionDeny with reason approver: pattern compile: <err>. Fix the regex; regex101.com with the Go flavour selected is your friend.

Regex matches JSON literally, not semantically

The match regex runs against the raw JSON input of the tool call. Escape quotes and backslashes appropriately: "\"path\":\"/workspace/" matches the path field of an edit or write call.

deny_all is not blocking anything

Confirm mode: deny_all (not mode: deny). The valid modes are allow_all, deny_all, pattern. allow and deny alone are treated as aliases for the _all variants but exact strings are safer.

Allow rule for bash never matches

The bash input is JSON like {"command":"ls -la"}. Match against that JSON literal, not just the shell command string. Use a pattern like ^\\{\"command\":\"ls.

Related pages

Further reading

  • internal/agent/approver.goPatternApprover, AllowAllApprover, DenyAllApprover.
  • internal/agent/approver_test.go — 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.