← Blog

Guardrails for vibe coders: PreToolUse rules that stop rm -rf and secret leaks

Copy-paste PreToolUse rules for Claude Code, Codex, OpenCode and Grok Build that block rm -rf, force-pushes and .env leaks. Every rule tested.

4 min readThe Pilothouse maintainers

Vibe coding works until the agent runs rm -rf on the wrong folder, prints your .env into the transcript, or force-pushes over a teammate’s branch. Most of us respond in one of two ways: approve every prompt by hand, or turn on “skip permissions” and hope.

There’s a middle option. Every major coding agent now has a PreToolUse hook: a point right before a tool call runs where something can say allow, ask or deny. Pilothouse (which I build) puts a rules engine at that hook, for Claude Code, Codex, OpenCode and Grok Build at once. This post is the rule file I’d hand a friend, with each rule tested.

Setup (2 minutes)

brew install sabr-software/tap/pilothouse   # or: curl -fsSL https://pilothouse.dev/install.sh | sh
pilothouse setup                            # installs the hooks (shows the diff first)
pilothouse policy init                      # writes a commented starter ~/.pilothouse/policy.yaml

The fast path: presets

If you only do one thing, do this:

pilothouse policy preset guarded-autopilot on
pilothouse policy preset no-secrets on

guarded-autopilot is meant as a safer alternative to --dangerously-skip-permissions. It lets routine repo work run without prompts (reading, editing inside the repo, tests, builds, linters, read-only git). It blocks destructive commands like rm -rf ~, git reset --hard, force-pushes and DROP TABLE. And it asks before pushes, deploys, secrets and sudo. no-secrets blocks .env files, private keys, cloud credentials and credential dumps such as printenv and gh auth token, while leaving .env.example alone.

The rule file I actually use

Here’s a hand-written ~/.pilothouse/policy.yaml that covers the same ground, so you can see how the rules work and adjust them:

version: 1
rules:
  # 1. Never let an agent wipe a broad path.
  - name: no-broad-rm-rf
    description: Recursive force-deletes of /, ~, $HOME, . or *
    match: { kind: shell }
    when: 'command.matches(r"\brm\s+(-[a-zA-Z]*[rR][a-zA-Z]*f[a-zA-Z]*|-[a-zA-Z]*f[a-zA-Z]*[rR][a-zA-Z]*|-r\s+-f|-f\s+-r)\s+(/|~/?|\$HOME/?|\./?|\*)(\s|;|&|\||$)")'
    action: deny
    message: Recursive delete of a broad path is blocked. Delete the specific files instead.

  # 2. No force-push, in any spelling (but --force-with-lease is fine).
  - name: no-force-push
    match: { kind: shell, command: "*git*push*" }
    when: 'has_word(command, "--force") || has_word(command, "-f")'
    action: deny
    message: Force-push is blocked. Open a PR instead.

  # 3. Pushing to main needs a person.
  - name: ask-before-pushing-main
    match: { kind: shell, command: "*git*push*" }
    when: 'has_word(command, "main") || has_word(command, "master") || branch == "main"'
    action: ask
    message: This pushes to main.

  # 4. Keep agents out of secrets (templates are fine).
  - name: no-secret-files
    match: { path: [".env", ".env.*", "*.pem", "*.key", "~/.ssh/**", "~/.aws/**"] }
    when: '!paths.all(p, p.endsWith(".example") || p.endsWith(".sample"))'
    action: deny
    message: Secrets are off limits. Use .env.example or ask me for the value.

  # 5. No dumping the environment or tokens into the transcript.
  - name: no-credential-dumps
    match: { kind: shell }
    when: 'has_word(command, "printenv") || command.matches(r"\benv\s*($|\|)") || command.contains("gh auth token")'
    action: deny
    message: Don't print credentials into the session.

  # 6. Piping a download into a shell needs a person.
  - name: ask-curl-pipe-sh
    match: { kind: shell }
    when: 'command.matches(r"\b(curl|wget)\b[^|]*\|\s*(sudo\s+)?(sh|bash|zsh)\b")'
    action: ask
    message: This runs a script straight from the internet.

  # 7. Production databases are off limits.
  - name: no-prod-db
    match: { kind: shell, command: ["*psql*prod*", "*mysql*prod*"] }
    action: deny
    message: Use the staging database.

A few things worth knowing about the format:

  • match: fields are globs, and they’re AND-ed together: kind, command, path, tool, cwd, repo, branch, agent.
  • when: is a CEL expression with command, paths, branch, repo, hour, weekday and more available. has_word() matches whole words, so --force-with-lease doesn’t trip --force.
  • A relative path pattern like .env matches at any depth. Start it with / or ~/ to anchor it.
  • If several rules match, any deny wins, then ask, then allow.
  • A file with an error isn’t used, and the last version that compiled stays in force, so a typo never silently drops your deny rules.

Test before you trust it

Run any rule against a command without an agent involved:

$ pilothouse policy check
✓ ~/.pilothouse/policy.yaml  7 rules

$ pilothouse policy test "rm -rf ~"
Decision: DENY by no-broad-rm-rf
Message:  Recursive delete of a broad path is blocked. Delete the specific files instead.

$ pilothouse policy test "rm -rf node_modules"
Decision: none — no rule matched; the agent's own permission settings apply.

$ pilothouse policy test "git push --force-with-lease origin feat"
Decision: none — no rule matched; the agent's own permission settings apply.

$ pilothouse policy test "git push origin main"
Decision: ASK by ask-before-pushing-main

$ pilothouse policy test --tool Read --path ~/app/.env.example
Decision: none — no rule matched; the agent's own permission settings apply.

Those are real results from the file above. Then see what your new rules would have done to last week’s actual tool calls:

pilothouse policy replay --since 7d

Rules without YAML

Most of my rules started as one tap on an approval card. When a request reaches your phone, the card offers rules built from the call: Always allow npm test in api-server, Always ask before git push, Never allow force-pushes. From the terminal:

pilothouse policy add ask-push --action ask --command "git push" --command "git push *"
pilothouse policy add no-prod-db --action deny --command "psql *prod*" --message "Use the staging database."
pilothouse policy rules      # everything, in the order it is checked

These “managed” rules are matched one command at a time, so allowing npm test never allows npm test && curl … | sh.

Repo rules for your team

A repo can ship its own .pilothouse/policy.yaml. Its deny and ask rules apply to anyone who works in that repo. Its allow rules only count once you run pilothouse policy trust, because anyone can publish a repo with a permissive policy in it.

Lean on deny

deny rules are enforced on Claude Code, Codex, OpenCode and Grok Build alike, which is why the rule file above leans on deny for the things you never want. Keep ask for things a person should see.

Pilothouse also has a built-in rule you can’t turn off: an agent can’t run pilothouse approve, edit your policy, or change its own hook settings without a person confirming. Agents can’t approve themselves.

Full reference: pilothouse.dev/docs and POLICY.md on GitHub.