# Squidbrake rules - hot-reloaded on save, no restart needed. # # Policy: agents may LOOK at anything (read, list, search). Coding agents do everyday work on their own (edits, # tests, builds, commits), but what can't be undone waits for a person and what's catastrophic is blocked. Any other # change (sends, refunds, pays, updates, deletes, pushes) waits for a person, and so does an agent changing its own # settings, hooks or MCP servers, Squidbrake's rules, or a secrets file. # # Rules are checked top to bottom; the FIRST match wins. If none match, `default` applies. # match fields (all optional, all must match): # kind, name, source, client, session_id glob pattern or list of globs (case-insensitive), e.g. "shell*" # input_regex regex searched in the JSON of the call's input # input number checks on input fields: {field: {op: number}}, ops gt gte lt lte eq ne, # e.g. input: { amount: { gt: 100 } }; "a.b" reaches nested fields; a missing # or non-numeric field doesn't match # skip_reads: true a shell command that only looks (cat, grep, ls, jq ...) doesn't match; # a secret read, a write or a redirect into a file still does # action: allow | deny | review # # Example - let small refunds run on their own (put it above approve-refunds to use it): # - id: allow-small-refunds # action: allow # reason: Small refund - runs on its own # match: # name: ["*refund*"] # input: { amount: { gte: 0, lte: 50 } } # For allow rules use a range: `lte: 50` alone would also let through a negative amount like -5000. # Amounts are in your API's units - Stripe sends cents, so 50 dollars is 5000 there. # # review = hold the call until a person approves or rejects it (dashboard, phone, Slack). # timeout_seconds how long to wait for a person (default APPROVAL_TIMEOUT, 300) # on_timeout deny (default) | allow - what happens if nobody decides in time # approvers who may decide: key names or roles, e.g. ["role:finance", "priya"]; default = any approver # second_person true: the person whose agent asked can't approve it (maker-checker), even with # Settings > second-person approval off # A key can never approve its own request, and an agent can't reach the approval path at all (selfguard.py). default: review default_reason: This changes something, so a person has to approve it # enforce: block and hold for real. observe (or shadow): block and hold nothing, only record what would have happened # (Reports and `squidbrake report --week` show "would block" / "would hold"), the easy way to try Squidbrake on real # work. Stops, catastrophic commands (rm -rf /, wiping a drive) and an agent reaching for Squidbrake's own approval # path are enforced in observe mode too. A rule or sequence can set its own `mode: enforce | observe`, e.g. enforce # the catastrophic ones first and observe the rest. mode: enforce shadow_agents: [] # shadow mode for just these agents (source or key names, globs), e.g. ["new-bot*"] # While Squidbrake can't be reached: # block_risky what these rules let run on its own (reads, everyday coding work) still runs; anything that would # wait for a person or be blocked, and commands that can't be undone or can't be read, are blocked # block everything is blocked (the safest; agents stop working until Squidbrake is back) # allow everything runs # warn everything runs, with a loud warning on each action; when Squidbrake is back, what ran is put on # the record (protection.was_off). Opt in for a laptop that reboots; Squidbrake's keys stay out of reach # Each rule can say its own with `when_unreachable: allow | block`. Agents keep a copy of these answers, so they # follow them while it's down. What runs while it's down isn't recorded: the tradeoff is in THREAT_MODEL.md. when_unreachable: block_risky # Squidbrake checks every minute that its hooks in each agent, this file and its installed program haven't changed. # alert a change is recorded and sent wherever approvals go # fail_closed also: until a person runs `squidbrake integrity accept`, actions that would wait for a person or # can't be undone are blocked (a change to this file alone only alerts) integrity: alert # Undo copies: before an action that waits for a person, or a command that can't be undone, keep a copy of what it # changes (files and git changes on the agent's computer, the record behind an API call through /proxy, or what a # client.py snapshot function returns). required: if no copy can be made, it doesn't run. best_effort: try, run anyway. # off: don't. A rule can set its own `snapshot: required | best_effort | off`; `snapshot: required` on a rule also # blocks paths that can't make a copy at all (an MCP tool). snapshots: required # Spend limits: pause an agent once it has used this many model tokens (or dollars, `usd:`) in a day or month. # per: agent gives each matching agent its own limit; per: together shares one between them. Claude Code reports its # tokens by itself; other agents send them to POST /v1/usage (client.py: gw.usage(...)). # budgets: # - id: daily-tokens # match: { source: "*" } # per: agent # period: day # tokens: 5000000 # Proof of work: when an agent says an action is done, look in the real system. Credentials only come from the # gateway's environment. The answer (confirmed / not actually done) is recorded and a person is told on a mismatch. # proof_checks: # - id: refund-really-made # match: { name: "*refund*" } # stripe: { object: refunds, id: "{output.id}" } # STRIPE_SECRET_KEY # expect: { status: succeeded } # - id: pr-really-open # match: { name: "*create_pull_request*" } # github: { path: "repos/{input.owner}/{input.repo}/pulls/{output.number}" } # GITHUB_TOKEN # expect: { state: open } # - id: order-really-updated # match: { name: "orders.update_status" } # sql: { url_env: ORDERS_DB_URL, query: "select status from orders where id = :id", params: { id: "{input.order_id}" } } # expect: { status: "{input.status}" } # History checks look at what happened BEFORE an action, not just the action itself. # Each one: block (stop it), review (a person decides), warn (flag it for the approver), off. history_checks: company_domains: [acme.com] # your real email domains; look-alikes (acrne.com, acme-corp.com) are suspicious lookback_hours: 24 repeat_of_rejected: review # an action a person already rejected (same tool + same target) goes back to a # person, flagged with their earlier no and note (block: refuse it outright) impersonation: block # moving money right after reading a message from a look-alike of your domain payment_request_in_message: review # moving money right after reading a message that asks for a payment duplicate_change: warn # the same change on the same thing again (e.g. a 2nd refund on one charge) # Command checks read what a shell command actually DOES before it runs: they split `a && b`, look inside # `bash -c "..."`, `sudo`, `xargs` and `$(...)`, and understand rm / git / terraform / kubectl / cloud CLIs / SQL. # Each one: block, review, warn, off. They apply even when a rule below allows the tool. command_checks: tools: ["Bash", "PowerShell", "*shell*", "*run_command*", "*execute_command*", "*terminal*", "*exec_command*"] catastrophic: block # rm -rf /, rm -rf ~, wiping a drive (rmdir /s d:\), mkfs, dd onto a disk, chmod -R 777 / irreversible: review # every delete (rm, git rm, docker rm, DELETE FROM), git push --force, git reset --hard, terraform destroy, kubectl delete, cloud deletes, DROP TABLE rebuildable: warn # deletes of build output, caches or temp files only (rm -rf node_modules dist, rm app.log, # rm -rf /tmp/x): flagged for you to see; review holds them like other deletes hidden: review # code that can't be read first: eval, curl ... | sh, base64 -d | bash, powershell -EncodedCommand, # programs written inline (python -c, node -e, perl -e), a program named only when it runs rename: warn # renames or moves: mv, git mv, Rename-Item, git branch -m, aws s3 mv, ALTER TABLE ... RENAME: # flagged for you to see; review holds them secret: review # shows a secret to the agent (and its transcript): cat key.pem, cat ~/.kube/config, # gh auth token, a keychain password, kubectl get secret -o yaml written_then_run: review # a script the agent created in this conversation, then runs (python x.py): read it first edited_then_run: warn # a script the agent changed in this conversation, then runs: flagged for you to see; # review holds it (safer: an edit can hide anything, but fixing a script and running it # is everyday work) unknown: warn # a program that isn't a common developer tool: recorded so you can see what would wait; # review holds them too read_only: allow # commands that only look (ls, cat, grep, git status/log/diff) run without asking, # unless a rule below says otherwise. Set to off to approve those too. # Taint checks catch prompt injection without a model: content someone else wrote (a web page, an email, an issue) # says "send X to ", and the agent does. When an action sends something to a destination (email address, # URL, bank account, repo) that appears in that outside content but NOT in what the user asked (Claude Code prompts # are recorded) or in your own tools' results, it came from the outside content. taint_checks: untrusted_destination: review # sends to an address/URL/account found only in outside content after_untrusted: warn # sends anything out after outside content was read in this conversation after_untrusted_setup_edit: warn # changes the agent's own setup (held by approve-agent-setup-changes) after outside # content was read in the same conversation: names which content came first (#74); # warn only, it never changes the hold into an allow lookback_hours: 24 scope: identity # outside content read in one conversation still counts in the next (or: session) # untrusted: [...] tools whose results are outside content (defaults: WebFetch, *fetch*, *inbox*, *issue*, *ticket* ...) # sinks: [...] tools that send things out (defaults: *send*, *reply*, *publish*, *transfer*, *webhook*, *upload* ...) # Shell commands count as sinks when they upload (curl -d, scp, git push, ...). # Data checks look at text an action puts where other people read it: a pull request, an issue or comment, a gist, a # chat post, a page (and `gh pr|issue|gist ... create|comment|edit`). Looking things up doesn't count. Email has its # own rule above. Each one: block, review, warn, off. data_checks: customer_names: review # names a customer on your list (dashboard → Settings → Customers), e.g. in a PR description personal_data: review # a card number, a social security number, an IBAN, or a list of 3+ emails or phone numbers # where: [...] tools whose text others read (defaults: *pull_request*, *issue*, *comment*, *gist*, ...) # Sequence rules judge an action by what came BEFORE it, and say which earlier step caused it. # match this action (same fields as rules: name, source, input_regex, input, ...) # after an earlier step that went ahead, within within_hours (default 24); # scope: session (the same conversation, the default) | agent (any conversation of this agent) | # identity (any conversation of any agent working for the same person: on your own computer, all of # them; on a team gateway, the agents whose key "works for" that person) | all (any agent); # the step is remembered until within_hours runs out, across restarts (`squidbrake capabilities` lists them); # same_target: true = only if it was on the same thing (same charge, file, account ...) # count more than `more_than` earlier matching actions within within_hours; scope: session | agent | identity | all; # sum: = add up that input field (this action plus earlier ones) instead of counting actions # (missing or non-numeric values count as 0); same_target: true = only earlier actions on the same thing # action deny | review | warn | notify (runs, and tells the team, like a rule's notify) sequences: - id: destroy-after-recovery-removed action: review reason: Destroying data right after its backups or deletion protection were turned off match: input_regex: 'delete[-_ ]?db[-_ ]?(instance|cluster|snapshot)|delete[-_ ]?(bucket|volume|database|snapshot)|volumeDelete|s3\s+rb\b|terraform\s+destroy|drop\s+(table|database)' after: match: input_regex: 'backup[-_ ]?retention[-_ ]?period\W{0,4}0\b|disable[-_ ]?backup|delete[-_ ]?(db[-_ ]?)?snapshot|deletion[-_ ]?protection\W{0,4}(false|0)\b|no-deletion-protection|point[-_ ]?in[-_ ]?time[-_ ]?recovery\W{0,4}(false|disabled)' within_hours: 24 scope: all # backups switched off by any agent, in any conversation, then a delete - id: runaway-refunds action: deny reason: Too many refunds in a short time; the agent may be stuck in a loop match: { name: ["*refund*"] } count: { more_than: 10, within_hours: 1, scope: identity } # a loop split across two agents still counts - id: mass-deletes action: review reason: Many deletions in a short time match: { input_regex: '\b(delete|destroy|drop|rm\s+-|rmdir|remove-item)\b' } count: { more_than: 20, within_hours: 0.25, scope: session } - id: unusual-burst action: notify reason: Far more actions than usual from one agent; it may be stuck in a loop or doing something nobody planned match: {} count: { more_than: 300, within_hours: 0.25, scope: agent } rules: # ---- never allowed - id: block-destructive-shell action: deny reason: Destructive shell command match: name: ["shell*", "bash*", "powershell*", "exec*", "run_command*"] input_regex: 'rm\s+-(rf|fr)\s+(/\*?|~/?\*?|\*)(?=[\s"'';&|)]|$)|mkfs|dd\s+if=|:\(\)\s*\{|shutdown|reboot|Remove-Item\s.*-Recurse.*\s(C:\\\\|~|\$HOME)' - id: block-sql-drop action: deny reason: Dropping or truncating tables is not allowed match: name: ["sql*", "db*", "*query*"] input_regex: '\b(drop\s+(table|database|schema)|truncate\s+table)\b' - id: block-secret-files action: deny reason: Reading secret files is not allowed match: name: ["read*", "file*", "cat*"] input_regex: '\.env\b(?![\w.-]*(example|sample|template))|id_rsa|\.pem\b|\.p12\b|\.pfx\b|credentials|\.kube[\\/]+config\b|\.docker[\\/]+config\.json' # ---- an agent changing its own setup: its settings, hooks and MCP servers, Squidbrake's own files, # secrets, and files that run code at login or on every commit. Squidbrake would be easy to switch off without this. - id: approve-agent-setup-changes action: review reason: Changes an AI agent's own settings, hooks or MCP servers, Squidbrake itself, a secrets file, or code that runs at login match: name: ["Edit", "Write", "MultiEdit", "NotebookEdit", "bash*", "powershell*", "shell*", "run_command*"] skip_reads: true # `cat ~/.claude/settings.json` only looks; `cat .env` (a secret) and `sed -i` still wait # an agent's own notes (Antigravity's brain/, Claude Code's plans/, todos/ and memory/) are markdown it writes on # every task, not its setup: those .md files aren't held. Settings, hooks, CLAUDE.md, commands/ and agents/ still are. input_regex: '(?i)\.(claude|cursor|codex|gemini|copilot|kiro|codeium|windsurf|squidbrake)[\\/](?![\\/]*(?:antigravity[\\/]+brain|plans|todos|projects[\\/]+[^"\\/]+[\\/]+memory)[\\/]+(?:(?!\.\.)[^"])*\.md")|mcp(_config)?\.json|claude_desktop_config\.json|\.vscode[\\/]+(mcp|tasks|settings)\.json|Code( - Insiders)?[\\/]+User[\\/]+settings\.json|\.env\b(?![\w.-]*(example|sample|template))|\.ssh[\\/]|\.aws[\\/]|\.(npmrc|pypirc|netrc)\b|\.(bashrc|zshrc|bash_profile|zprofile|profile)\b|PowerShell_profile\.ps1|\.git[\\/]+hooks[\\/]|managed-settings\.json|(/etc/|ProgramData[\\/]+|Program Files[\\/]+|Application Support/)(ClaudeCode|claude-code|codex|gemini-cli|cursor|squidbrake|OpenAI)\b' # ---- looking is fine: reading, listing, searching run without asking (and are still recorded) - id: allow-reads action: allow reason: Only looks at information match: name: ["*.list*", "*_list*", "list*", "*.get*", "*_get*", "get*", "*.read*", "*_read*", "*.find*", "*_find*", "find*", "*.search*", "*_search*", "search*", "*.describe*", "*_describe*", "*.query*", "*.fetch*", "*_fetch*", "*.view*", "*_view*", "*.show*", "*_show*", "*.count*", "*_count*", "*.lookup*", "*_lookup*", # Claude Code's own read-only / housekeeping tools "Read", "Grep", "Glob", "LS", "WebSearch", "WebFetch", "NotebookRead", "ToolSearch", "TodoWrite", "Task", "Agent", "AskUserQuestion", "EnterPlanMode", "ExitPlanMode", "Skill", "BashOutput", "TaskOutput", "ListMcpResourcesTool", "ReadMcpResourceTool", # Antigravity's own read-only / housekeeping tools (its file reads, edits and commands are checked above) "read_url_content", "view_content_chunk", "read_resource", "read_terminal", "command_status", "manage_task", "task_boundary", "notify_user"] # ---- changes: a person decides (these give the approver a clearer reason than the default) - id: approve-money-out action: review reason: Bank transfers, wires and payouts need finance sign-off timeout_seconds: 900 approvers: ["role:finance", "role:admin"] match: name: ["*transfer*", "*wire*", "*payout*"] - id: approve-refunds action: review reason: Refund - money goes back to a customer timeout_seconds: 600 match: name: ["*refund*"] - id: approve-outbound-email action: review reason: Email to someone outside the company timeout_seconds: 600 match: name: ["email.send", "mail.*", "*send_email*", "*email_send*", "*send_mail*", "*gmail*send*"] input_regex: '"to"\s*:\s*"[^"]*@(?!acme\.com")' # change acme.com to your company's email domain - id: approve-writes-to-db action: review reason: Changes data in the database match: name: ["sql*", "db*", "*query*"] input_regex: '\b(delete\s+from|update\s+\S+\s+set|alter\s+table|insert\s+(or\s+\w+\s+)?into|replace\s+into)\b' # Pushing a named feature branch is everyday work (it runs); pushing main, master, a release or production branch, # tags, everything, or the current branch without naming it waits for a person. Force pushes are held by the # command checks whatever the branch. - id: approve-git-push action: review reason: Pushing to a shared branch (main, master, release, production), tags, or an unnamed branch match: name: ["bash*", "shell*", "powershell*", "run_command*"] input_regex: 'git\s+push((\s+-\S+)*(\s+[^\s"&;|-]\S*)?\s*("|&&|;|\|)|[^"&;|]*\s(\S+:)?(main|master|trunk|develop|release\S*|prod|production|HEAD)\b|[^"&;|]*\s--(all|tags|mirror)\b)' # Deploying from a terminal: the hosting CLIs' production deploys. Previews (`vercel`, `netlify deploy` without # --prod, `firebase hosting:channel:deploy`) run on their own. - id: approve-prod-deploys-from-shell action: review reason: Deploys to production from the terminal match: name: ["bash*", "shell*", "powershell*", "run_command*"] input_regex: '\bvercel\b[^"&;|]*\s--prod\b|\bnetlify\s+deploy\b[^"&;|]*\s--prod\b|\bfirebase\s+deploy\b|\bfly(ctl)?\s+deploy\b|\brailway\s+up\b|\bwrangler\s+(deploy|publish)\b(?![^"&;|]*\s--env[= ]+(dev|development|staging|preview)\b)|\beb\s+deploy\b|\b(serverless|sls)\s+deploy\b[^"&;|]*--stage[= ]+prod' - id: approve-prod-deploys action: review reason: Production deploy timeout_seconds: 900 match: name: ["deploy*", "release*"] input_regex: '\bprod(uction)?\b' # ---- coding agents: everyday work runs on its own, so nobody turns Squidbrake off out of approval fatigue. # The command checks still hold what can't be undone (git push --force, cloud deletes, DROP TABLE, curl | sh) # and block what's catastrophic (rm -rf ~/, wiping a disk); the rule above holds changes to the agents' own setup. - id: allow-coding-work action: allow reason: Everyday coding work (edits, tests, builds, installs, commits) match: name: ["Edit", "Write", "MultiEdit", "NotebookEdit", "Bash", "PowerShell"] # Upstreams for the HTTP proxy: /proxy// -> / upstreams: httpbin: https://httpbin.org