What you build
A rousseau daemon that started out running the claudecli provider in bypassPermissions mode (the unattended default) ends up under a pattern-mode rousseau-agent approver with default: deny. Every tool call is either explicitly allowlisted or blocked; every denial produces a tool.denied slog event you can audit.
Estimated time: 30 minutes for a proper rules pass with tests.
Prerequisites
- Rousseau installed with any transport bridge running (WhatsApp, Slack, Signal — anything unattended).
- Basic Go regex familiarity — approver rules are Go RE2 regexes over the JSON tool-input.
Where the approver lives
Two independent layers can approve tool calls:
- The provider's own permission mode. The
claudecliprovider (internal/llm/claudecli/client.go) delegates toclaude --permission-mode. Values documented inClaudeCLIConfig.PermissionMode(internal/config/config.go):acceptEdits,auto,bypassPermissions,default,dontAsk,plan. Unattended daemons pinbypassPermissionsinsetUnattendedPermissionDefault. - Rousseau's own approver. Configured under
agent.approver(internal/config/config.goApproverConfig; implementation ininternal/agent/approver.go). Three modes:allow_all,deny_all,pattern. Deny wins over allow, and unmatched calls fall back todefault.
For an unattended daemon, the rousseau approver is the mitigation you configure by hand. claudecli's own mode is the seatbelt.
Step 1: baseline audit
Before writing rules, run a few realistic sessions with mode: allow_all and log.format: json. Every tool call emits tool.execute (internal/agent/agent.go):
jq -c 'select(.msg == "tool.execute") | {name, input: .input}' \
< /var/log/rousseau.jsonl \
| sort | uniq -c | sort -rn | head
You now have an empirical distribution of which tools the agent uses and against which paths. That's the seed for the allowlist.
Step 2: draft a pattern policy
agent:
approver:
mode: pattern
default: deny
reason: "denied by pattern policy — ask the operator to loosen the rules"
allow:
# Read side: unrestricted within the daemon's filesystem view.
- {tool: read, match: ".*"}
- {tool: grep, match: ".*"}
# Editing pinned to /workspace.
- {tool: edit, match: "\"path\":\"/workspace/[^\"]*\""}
- {tool: write, match: "\"path\":\"/workspace/[^\"]*\""}
# Shell: whitelist of read-only utilities plus git status/diff/log.
- {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 denies 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/"}
Deploy and watch the slog stream. The relevant events (internal/agent/agent.go):
tool.execute— the call ran. Fields:name,id.tool.denied— the approver blocked it. Fields:name,reason.tool.error— it ran and failed. Fields:name,err.
Step 3: iterate
The first day surfaces false positives: legitimate tool calls the approver blocked. Grep for them:
jq -c 'select(.msg == "tool.denied") | {name, input}' \
< /var/log/rousseau.jsonl \
| sort | uniq -c | sort -rn | head
Every recurring tool.denied deserves a decision:
- Genuinely needed — extend the allow rule. Prefer narrow (path pinned) over broad (open-ended regex).
- Not needed — leave denied. The model will pivot to a different approach.
Do not weaken default: deny. That is the property that makes an unforgotten tool safe.
Step 4: audit-log excerpt
A production run with an unfamiliar prompt looked like this:
{"time":"2026-07-13T18:00:12Z","level":"INFO", "msg":"whatsapp.incoming","from":"447900123456@s.whatsapp.net"}
{"time":"2026-07-13T18:00:14Z","level":"INFO", "msg":"tool.execute","name":"grep","id":"t_1"}
{"time":"2026-07-13T18:00:15Z","level":"INFO", "msg":"tool.execute","name":"read","id":"t_2"}
{"time":"2026-07-13T18:00:17Z","level":"WARN", "msg":"tool.denied","name":"bash","reason":"denied by pattern policy — ask the operator to loosen the rules"}
{"time":"2026-07-13T18:00:18Z","level":"INFO", "msg":"tool.execute","name":"read","id":"t_3"}
{"time":"2026-07-13T18:00:20Z","level":"INFO", "msg":"whatsapp.handler_ok","elapsed":"7.4s"}
The one tool.denied here was bash: "curl https://…". The deny rule caught it, the model degraded to read + grep, and the reply still went through.
Step 5: bake it in
Once the false-positive rate settles, freeze the config, commit it to source control (secrets excluded — see Guides: Enterprise Onboarding), and gate config changes behind a code review. internal/agent/approver_test.go in the source tree is your model for how to write tests against the ruleset — copy its shape into an internal package if you want CI to catch a broken policy.
What the policy still does not do
Even with the tightest pattern rules:
- No sandboxing. An allowed
bashcall still runs with the daemon's UID and filesystem visibility. Layer a rootless container (Deployment) under it. - No rate limiting. Ten allowed calls per second are all allowed. Wrap the tool registry if you need this.
- No outbound network audit. The approver sees the initial
bashcommandstring, not what it curls. Denycurlandwgetoutright — the sample deny rules do this.
See Guides: Audit + approval policies for the deeper discussion.
Related
- User Guide: Approval Policies — reference for every mode.
- User Guide: Tools — tool schemas, useful for writing regex.
- Guides: Observability — pipe
tool.deniedto Loki/Datadog. - Reference: Logs — every well-known slog message.