← Blog

Rules before every tool call: the two-checkpoint design

Why an AI coding agent policy has to run on every tool call — not just on permission prompts — and how Pilothouse splits fast policy decisions from slow human ones.

4 min readThe Pilothouse maintainers

Here is a rule almost everyone who runs coding agents wants: never force-push.

The obvious place to enforce it is the permission prompt. When the agent asks “Run git push --force origin feat?”, a tool can look at the command and say no. Many approval tools work exactly like this.

The trouble is the question that never gets asked.

The prompt you never see

Agents have their own permission settings. After a few days of clicking “Yes, and don’t ask again”, git push is on the allow list — reasonably, because you push branches all the time. From then on the agent doesn’t ask before pushing. It just pushes. Including with --force.

A policy layer that only sees permission prompts has nothing to intercept. Your rule is written down, it’s correct, and it never runs.

So Pilothouse looks at agent actions at two moments, and gives each a different job.

Checkpoint 1: before every tool call

Claude Code and Codex both have a PreToolUse hook that fires before tool calls — shell commands, file edits and more — whether or not the agent intends to ask. Pilothouse evaluates your rules there. (Codex’s version covers its shell, patch and MCP tools, and can deny or allow but not “ask”; the support table has the details.)

version: 1
rules:
  - name: no-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

The outcome is one of four things:

  • deny — the call is blocked and the agent receives your message, so it can change course instead of retrying blindly;
  • ask — the agent must ask a person, even if its own settings would have allowed it;
  • allow — the call runs without a prompt;
  • no opinion — no rule matched, so the agent’s own settings decide, exactly as they would without Pilothouse.

Two properties make this checkpoint workable.

It never waits for a person. PreToolUse fires constantly. If it ever blocked on a human, the agent would crawl. So checkpoint 1 only ever computes an answer from rules, and the budget is tight: policy evaluation is designed to stay under 5 ms, and the whole hook round trip under 25 ms.

The precedence is boring on purpose. Any deny wins, then ask, then allow. There is no rule ordering to get wrong and no “last match wins”. If two people wrote overlapping rules, the stricter one holds.

Checkpoint 2: when the agent wants a person

The second checkpoint is the PermissionRequest hook: the agent is about to show a permission dialog. This is the slow path — a human is involved — and the question here isn’t whether to allow the call, but where to ask.

That’s where presence comes in. At your desk, Pilothouse steps aside and the native dialog appears instantly. Away, the request is held and sent to your phone; the first answer wins. If you come back, or the hold limit runs out, the dialog goes back to the terminal. (There’s a whole post about the phone side.)

ask rules connect the two checkpoints: a rule at checkpoint 1 turns a call the agent would have run silently into a question at checkpoint 2.

Grants don’t outrank denies

When you answer Allow for session on your phone, Pilothouse records a grant so you aren’t asked again for the same thing — for shell commands, the command prefix (git push), for other tools, the tool. Grants are applied at checkpoint 1 after deny and ask rules. Allowing git push for the session does not let git push --force through, because no-force-push is a deny and denies always win.

Rules from the repo, without trusting the repo

It’s useful for a project to ship its own rules — “ask before running migrations”, “never edit generated code”. It’s also an obvious attack: clone a repo with .pilothouse/policy.yaml that says allow: "*".

So repo rules need your trust. Until you run pilothouse policy trust in a repo, its deny and ask rules apply and its allow rules are ignored (and listed as ignored in pilothouse policy status). An untrusted repo can make your agents more careful, never less.

Globs first, CEL when you need it

Most rules are a glob: command: "terraform apply*", path: "**/.env*". When that isn’t enough, add a when: expression in CEL, Google’s small, non-Turing-complete expression language. It’s fast, side-effect free and can’t loop — the right properties for something that runs on every tool call.

- name: after-hours-deploys
  match: { kind: shell }
  when: 'has_word(command, "deploy") && (hour < 9 || hour >= 18)'
  action: ask

Test with your own history

The scariest part of tightening a policy is not knowing what it will break. Pilothouse logs every PreToolUse event, so it can answer that question with data:

pilothouse policy replay --since 7d

replay re-evaluates last week’s real tool calls against your current rules and shows which outcomes would change. pilothouse policy test does the same for a single hypothetical call.

What this doesn’t do

Pilothouse runs as you, and hooks are a cooperative mechanism: they run because the agent runs them. If the daemon is down, Pilothouse fails open by default (the agent behaves as if it weren’t installed); you can flip that with approvals.fail_closed: true. For hard limits that must hold even if Pilothouse isn’t running, keep a matching deny rule in the agent’s own permission settings as well — belt and braces. And if you need real isolation, run agents in a container or VM. The security model lays out the details.

Within those limits, two checkpoints get you something a prompt-only tool can’t: rules that run every time, and people who are asked only when it matters.