Guides
Policies
Write Pilothouse rules in YAML with glob matches and optional CEL conditions. Deny force-push, ask before pushing to main, keep .env files out of the context — for Claude Code and Codex.
A policy is a list of rules that Pilothouse checks before every tool call an agent makes. Each rule can allow, ask or deny. Rules live in plain YAML files you can review, version and share.
Where rules come from
| File | Scope | Notes |
|---|---|---|
~/.pilothouse/policy.yaml |
You, every repo | Created by pilothouse policy init |
<repo>/.pilothouse/policy.yaml |
One repository | Only deny and ask apply until you trust the repo |
| Built-in | Always on | pilothouse-self-protection; can’t be disabled |
All applicable rules are evaluated together. Any deny wins, then ask, then allow. If no rule matches, Pilothouse has no opinion and the agent’s own permission settings decide.
A first policy
version: 1
rules:
- name: no-force-push
description: Never force-push
match: { kind: shell, command: "git push*" }
when: 'command.matches("(--force|-f)\\b")'
action: deny
message: Force-push is blocked by policy. Open a PR instead.
- name: ask-before-push-main
match: { kind: shell, command: "git push*main*" }
action: ask
- name: no-env-files
match: { path: "**/.env*" }
action: deny
message: .env files stay out of the agent's context.
- name: tests-are-fine
match: { kind: shell, command: "{npm,pnpm} test*" }
action: allow
Run pilothouse policy init for a starter file with more commented examples, and pilothouse policy check to validate a file.
Rule fields
| Field | Required | Meaning |
|---|---|---|
name |
yes | Shown in the inbox, on your phone, in explain and in the log |
description |
no | For humans |
match |
one of match / when |
Glob patterns; all listed fields must match |
when |
one of match / when |
A CEL expression; AND-ed with match |
action |
yes | allow, ask or deny |
message |
no | Sent to the agent when the rule denies |
agents |
no | Limit the rule to some agents, e.g. [claude, codex] |
match
Fields: agent, tool, kind, command, path, cwd, repo, branch. Values are globs: *, ** (any number of directories), ?, and {a,b} alternatives. path matches if any path the tool touches matches.
match: { tool: Edit, path: "migrations/**" }
match: { kind: shell, command: "terraform apply*" }
match: { branch: "{main,release/*}", kind: shell, command: "git commit*" }
when (CEL)
When a glob isn’t enough, add a CEL condition. Available variables:
| Variable | Type | Example |
|---|---|---|
agent |
string | "claude" |
tool |
string | "Bash", "Edit", "Read" |
kind |
string | "shell" |
command |
string | "git push origin main" |
paths |
list of strings | every path the call touches |
path |
string | the first path, or "" |
input |
map | the raw tool input |
cwd, repo, branch |
string | where the agent is working |
mode |
string | the agent’s permission mode |
hour, weekday |
int | local time |
Besides CEL’s standard library and string extensions, two helpers are available: glob(s, pattern) and has_word(s, word).
# Ask before any deploy outside working hours
- name: after-hours-deploys
match: { kind: shell }
when: 'has_word(command, "deploy") && (hour < 9 || hour >= 18 || weekday == 0 || weekday == 6)'
action: ask
# Block curl | sh
- name: no-pipe-to-shell
match: { kind: shell }
when: 'command.matches("curl[^|]*\\|\\s*(ba|z)?sh")'
action: deny
message: Download the script and review it first.
Repo rules and trust
A repository can ship rules in .pilothouse/policy.yaml. Because anyone can put a file in a repo you clone, untrusted repos can only make things stricter: their deny and ask rules apply, their allow rules are ignored (and pilothouse policy status lists them as ignored).
pilothouse policy trust # trust the repo you're in
pilothouse policy trust ~/src/api # or a path
pilothouse policy untrust
Trusted repos are listed in ~/.pilothouse/trusted-repos.yaml.
Built-in protection
The built-in rule pilothouse-self-protection always forces ask when an agent:
- runs Pilothouse’s own admin commands from a shell (approving, pairing, changing presence, reloading policy…),
- edits protected paths: the Pilothouse data directory and the agents’ own settings files.
It can’t be turned off. Together with the process checks on the socket it keeps an agent from quietly approving itself.
Test before you rely on it
Check a hypothetical tool call:
pilothouse policy test --tool Bash --command "git push --force origin feat"
pilothouse policy test --tool Read --path .env.production
pilothouse policy test --agent codex --tool Bash --command "npm test" --cwd ~/src/api
Replay new rules against the tool calls your agents actually made:
pilothouse policy replay --since 7d
replay re-evaluates logged PreToolUse events and shows which outcomes would change — the easiest way to tighten a rule without surprises.
Reloading and errors
After editing a file, run pilothouse policy status to see which files are loaded, from where, and any errors. If a file has an error, Pilothouse keeps using the last good version of that file and reports the problem. It never crashes on a bad rule.
Recipes
# Never touch production credentials
- name: no-prod-creds
match: { path: "**/{prod,production}*.{env,json,yaml,yml}" }
action: deny
# Ask before recursive force deletes
- name: ask-rm-rf
match: { kind: shell, command: "rm *" }
when: 'command.matches("\\s-[a-zA-Z]*(rf|fr)")'
action: ask
# Codex only: no network installs
- name: codex-no-installs
agents: [codex]
match: { kind: shell, command: "{npm,pnpm,yarn,pip,brew} {install,add}*" }
action: ask
Found a mistake? Open an issue.