Originally published at pilothouse.dev/blog/guardrails-for-vibe-coders.
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.
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
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.
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:
- 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.
- 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.
- 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.
- 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.
- 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.
- 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.
- 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 paths, hour, weekday and more available. has_word() matches whole words, so --force-with-lease doesn't trip --force..env matches at any depth. Start it with / or ~/ to anchor it.deny winsask, then allow.
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
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.
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.
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.