Weekly field note

Stop Writing Rules Your Agent Can Ignore

Published October 20, 2026

Claude Code loads instruction files as context and enforces permissions in code, so the layer you pick decides whether a rule can stop a tool call

Code on computer monitor (Unsplash)

*Image: Code on computer monitor (Unsplash) by Markus Spiske markusspiske, licensed under CC0.*

The rule is one line in CLAUDE.md: never run migrations against production. It is in context at the start of every session, which is the only place the harness puts it. A session that later runs the migration has not defeated a check, because no check existed.

Instruction files and auto memory, the Claude Code memory page says, are both loaded at the start of a conversation, and "Claude treats them as context, not enforced configuration" (Claude Code memory documentation, captured October 20, 2026). The same page points to a PreToolUse hook as the mechanism that can block an action regardless of what the model decides.

Two of the layers described below can refuse a call, while the other two only shape what the model attempts. Placement decides which one you are relying on.

The four layers and what each one is for

An instruction file is for knowledge that should shape judgment across a whole session. CLAUDE.md is read at launch. AGENTS.md is read instead only when no CLAUDE.md or CLAUDE.local.md sits in the working directory or above it, and reading it directly requires Claude Code v2.1.277 or later. The documentation's guidance on what belongs in the file is narrow: build commands, conventions, project layout, and always-do rules. Multi-step procedures belong in a skill, and rules scoped to one part of the codebase belong in a path-scoped rule (memory documentation).

A skill is a procedure that loads when it is relevant. Claude Code keeps a listing of skill names and descriptions in context, adds the SKILL.md body when the skill is invoked, and does not re-read the file on later turns (skills documentation). A skill can also carry allowed-tools, which pre-approves named tools for the turn that invokes it and clears when you send your next message. That grant is convenience inside the permission system, scoped to one turn.

A permission rule is the layer that refuses a call without asking the model anything. Rules live under permissions in a settings file, in three lists named deny, ask and allow. Claude Code evaluates them in that order, and the first match decides. Rule specificity does not reorder them: a broad Bash(aws *) deny blocks a call that also matches a narrower Bash(aws s3 ls) allow, because "an allow rule can't carve an exception out of a deny rule" (permissions documentation).

The same page states who enforces this layer. "Permission rules are enforced by Claude Code, not by the model. Instructions in your prompt or CLAUDE.md shape what Claude tries to do, but they don't change what Claude Code allows" (permissions documentation).

A hook is code that runs at a lifecycle event and can return a decision. PreToolUse hooks run before the permission prompt (permissions documentation), and their JSON output can allow, deny, ask, or defer a call (hooks reference). A hook that exits with code 2 blocks the tool call, and its stderr becomes the message the model sees.

The precedence between the last two is worth knowing precisely. A blocking hook wins over an allow rule, because the hook runs before permission rules are evaluated. A hook's allow decision does not win over a deny rule, because deny and ask rules are still evaluated regardless of what the hook returned (permissions documentation).

Where the permission layer stops

Permission rules match patterns, and the pattern language has edges that decide which rules can live there. The most consequential edge is the anchor. A Bash rule matches the command text Claude writes, everything before the first * exactly as written, so a rule can only stop invocation forms that share that prefix.

The permissions page keeps a list titled "What a Bash rule doesn't match". A Bash(rm *) rule does not match /bin/rm -rf build/ or bash -c 'rm -rf build/', and a Bash(git push *) rule does not match git -C . push origin main. The page's conclusion is blunt: a deny or ask rule "isn't a security boundary around the program", and it recommends sandboxing for filesystem and network enforcement that does not depend on the command text, plus a PreToolUse hook for inspecting the full command text with your own logic (permissions documentation).

File rules are checked against Read(path) and Edit(path) only. A rule written for Write, NotebookEdit or Glob is accepted and then never consulted, with a startup warning. A Read(./.env) deny also blocks Edit and Write on the same path, including creating a file there. A .claudeignore file has no effect, so its entries belong in Read deny rules (permissions documentation).

Bash rules also have to survive compound commands. Claude Code splits commands on &&, ||, ;, |, |&, & and newlines, and an allow rule has to match each subcommand independently. Wrappers such as timeout, time, nice, nohup and stdbuf are stripped before matching. A prefix rule cannot auto-approve an exec wrapper such as watch or a find with -exec or -delete (permissions documentation).

The pattern language refuses one shape outright. Bash(command:rm *) is ignored with a startup warning, because a rule on the command field would be bypassable by a compound command, and the supported form is Bash(rm *).

What needs a hook

A permission rule can refuse a tool by name, a path, or a command prefix. It cannot refuse every SQL statement that drops a table, because that decision depends on the content of an argument. The content-dependent case needs a PreToolUse hook: read the JSON on stdin, inspect tool_input.command, and exit 2 with a reason when the text matches.

Two documented limits keep a hook honest. A command hook that reaches its timeout does not block the call unless you set "onFailure": "block", and the reference warns not to count on a stalled hook as a gate; that field requires Claude Code v2.1.295 or later. The if field on a hook takes exactly one permission rule, with no && or || combining, and its matching on Bash commands is best effort, so the reference says to use the permission system for a hard allow or deny (hooks reference).

Hooks also run with your full user permissions. A command hook executes shell commands as your account and can modify, delete, or read anything you can. Keep the script small, read only the JSON it needs, and test it on a scratch directory before it guards anything.

The placement test

Run each rule through four questions in order.

The skills documentation applies the same test from the other direction. When a rule must hold every time, it says to move the rule into a hook, because Claude Code runs the hook on every matching event whether or not the model is following the skill (skills documentation).

A configuration that uses three layers

The following settings file puts shared team rules in .claude/settings.json, which the settings documentation says to commit so teammates get the same permissions and hooks. The exact rules are examples; replace the commands and paths with yours.

{
  "permissions": {
    "deny": [
      "Read(./.env)",
      "Read(./secrets/**)",
      "Edit(./migrations/**)",
      "mcp__prod-db__*"
    ],
    "ask": [
      "Bash(git push *)",
      "Bash(gh pr merge *)"
    ]
  },
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash",
        "hooks": [
          {
            "type": "command",
            "command": "bash",
            "args": ["${CLAUDE_PROJECT_DIR}/.claude/hooks/block-drop-table.sh"],
            "onFailure": "block"
          }
        ]
      }
    ]
  }
}

Bash(git push *) prompts for git push origin main and for git push --force origin main, since both begin with the matched prefix. It does not prompt for git -C . push origin main, which is one of the forms the permissions page lists as unmatched. The hook covers the case a glob cannot express, and onFailure makes a crash or timeout block the call.

#!/usr/bin/env bash
# .claude/hooks/block-drop-table.sh
input=$(cat)
command=$(printf '%s' "$input" | jq -r '.tool_input.command // ""')

if printf '%s' "$command" | grep -qiE 'drop[[:space:]]+table'; then
  echo "Refusing DROP TABLE through the agent. Run it through the migration review." >&2
  exit 2
fi

exit 0

The third layer is the instruction file, and its job shrinks once the gates exist. Put the facts a new teammate would need in CLAUDE.md: how to run the build, which test command matters, where the generated code lives. Put the path and server rules in the settings file, where Claude Code checks them when Claude attempts a matching call and refuses it. The production-migration sentence needs the hook as well, because whether a command targets production is content rather than a prefix, and the settings file above cannot express it.

Test each rule once

An enforced rule is a claim about behavior, and the documentation supplies the commands for checking it.

Run /permissions and read the dialog, which lists the rules and the settings file each one comes from. A rule in the wrong file, such as a personal rule in settings.local.json that teammates never receive, shows up there.

Run a session with --verbose to see the exact parameter names and values each tool call sends, which is what a parameter rule matches against. This matters for rules such as Agent(model:*), where a parameter the model omits is never matched.

Attempt the blocked action once in a scratch checkout. A blocked read names the rule that stopped it; a hook denial shows your script's stderr, so the message should be a sentence you wrote.

Check the failure mode of every hook. With onFailure unset, a crashed or timed-out hook lets the action continue through the normal permission flow, which turns your gate into a suggestion at the moment the script is broken. On Windows without Git Bash, a shell-form PreToolUse hook that fails can instead block the call, so the same script can behave differently on that platform.

Check the deny-first order when rules overlap. Adding an allow rule for a subcommand of a denied prefix changes nothing, and a rule counts as tested only after the overlapping call has been attempted.

What the instruction file is still for

Enforcement is a narrow, mechanical job, and most of what makes an agent useful does not fit it. CLAUDE.md is the right place for the sentence that explains why the migrations directory is append-only, and the skill is the right place for the twelve-step release procedure. Neither of those can refuse a tool call, and neither needs to.

Keep the distinction in one habit: when you catch yourself adding a rule that starts with "never", ask which tool call would violate it. If you can name the call, write it in the settings file. If the violation is a pattern across calls, write a hook. If no single call would show the mistake, keep the rule in prose and give the model the context to apply it. The memory page notes that specific, concise instructions are followed more consistently, which is the most a prose rule can offer.