---
name: gateguard
description: "PreToolUse fact-forcing gate that denies the first Edit/Write/Bash (including MultiEdit and NotebookEdit) attempt until the agent presents concrete facts (importers, data schemas, verbatim user instruction), then allows retry; A/B-tested at +2.25 quality points. Use when enabling or configuring the GateGuard hook, exempting paths via env vars, or handling first-touch denials."
metadata:
  origin: community
---

# GateGuard — Fact-Forcing Pre-Action Gate

A PreToolUse hook that forces Claude to investigate before editing. Instead of self-evaluation ("are you sure?"), it demands concrete facts. The act of investigation creates awareness that self-evaluation never did.

## When to Activate

- Working on any codebase where file edits affect multiple modules
- Projects with data files that have specific schemas or date formats
- Teams where AI-generated code must match existing patterns
- Any workflow where Claude tends to guess instead of investigating

## Core Concept

LLM self-evaluation doesn't work. Ask "did you violate any policies?" and the answer is always "no." This is verified experimentally.

But asking "list every file that imports this module" forces the LLM to run Grep and Read. The investigation itself creates context that changes the output.

**Three-stage gate:**

```
1. DENY  — block the first Edit/Write/Bash attempt
2. FORCE — tell the model exactly which facts to gather
3. ALLOW — permit retry after facts are presented
```

No competitor does all three. Most stop at deny.

## Evidence

Two independent A/B tests, identical agents, same task:

| Task | Gated | Ungated | Gap |
| --- | --- | --- | --- |
| Analytics module | 8.0/10 | 6.5/10 | +1.5 |
| Webhook validator | 10.0/10 | 7.0/10 | +3.0 |
| **Average** | **9.0** | **6.75** | **+2.25** |

Both agents produce code that runs and passes tests. The difference is design depth.

## Gate Types

### Edit / MultiEdit Gate (first edit per file)

MultiEdit is handled identically — each file in the batch is gated individually;
entries without their own `file_path` are gated as the call's `file_path`.
NotebookEdit is gated as an Edit of its `notebook_path`: same class, checked
key, credit and cap rules, the full questions (the cell change is not read),
and never a comment-only pass.

The questions depend on the target's class (see
[Questions by target class](#questions-by-target-class)). For **code**
targets the Edit gate asks:

```
Before editing {file_path}, present these facts:

1. List ALL files that import/require this file (search the tree — Glob/Grep, or find/grep via Bash)
2. List the public functions/classes affected by this change
3. If this file reads/writes data files, show field names, structure,
   and date format (use redacted or synthetic values, not raw production data)
4. Quote the user's current instruction verbatim
```

These four are the full set, asked whenever the change cannot be read with
confidence. When it can (see [Questions fit the change](#questions-fit-the-change)),
an edit that touches no public declaration asks instead for the call sites in
the file or its module that rely on the changed behaviour, and item 3 is
dropped when the change handles no data.

### Write Gate (first new file creation)

For **code** targets the Write gate asks:

```
Before creating {file_path}, present these facts:

1. Name the file(s) and line(s) that will call this new file
2. Confirm no existing file serves the same purpose (search the tree — Glob/Grep, or find/grep via Bash)
3. If this file reads/writes data files, show field names, structure,
   and date format (use redacted or synthetic values, not raw production data)
4. Quote the user's current instruction verbatim
```

Item 3 is dropped when the new content handles no data.

### Questions fit the change

For **code** targets the gate reads the change it is about to allow (Edit
`old_string`/`new_string`, every MultiEdit entry for the file, Write
`content`) and decides three things:

- **Public surface:** a line on either side declares or exposes a public name.
  JS/TS: `export`, `exports`, `public`, `declare` (`.d.ts` files always); Python:
  `def`/`class` not starting with `_`, dunders, `__all__`, a module-level public
  assignment such as `BASE_URL = ...` (`__init__.py` always); Go: capitalised
  `func`/`type`/`var`/`const` names, `package`, lines starting with a capital;
  Rust: `pub`, `impl`, `trait`, `extern`, `#[macro_export]`, `#[derive]`,
  `#[repr]`, `#[no_mangle]`; shell: function definitions,
  `export`, `declare -x`; PowerShell: `function`, `filter`, `param`,
  `Export-ModuleMember`, `[CmdletBinding`. A member of an exported container
  counts too, judged from the file: lines inside an exported interface, enum,
  type, object or export list, member declarations of an exported class,
  `__all__` entries, public class attributes and dataclass fields, and members
  of a `pub` enum, struct, trait or `use` list. Java, Kotlin, C#, C, C++ and batch
  files, and every Write, always count as touching it. Touching it keeps questions 1 and 2; otherwise both are
  replaced by "List the call sites in this file or its module that rely on the
  changed behaviour".
- **Data:** words for file I/O, formats (`json`, `csv`, `yaml`, …),
  serialisation, encodings, schemas, SQL, data stores, browser storage, dates
  and clock reads on either side; in shell scripts also
  redirection to a file, `curl`, `wget`, `jq` and PowerShell file and web
  cmdlets. Without them the data
  question is dropped. With `GATEGUARD_EXTERNAL_CONTRACT_QUESTION` on, a
  change that keeps the data question is also asked what outside the
  repository decides its format or semantics.
- **Trivial:** see [Comment and whitespace-only edits](#comment-and-whitespace-only-edits).

Supported extensions: `.js .mjs .cjs .jsx .ts .tsx .mts .cts .py .pyi .go .rs
.java .kt .kts .cs .c .h .cc .cpp .cxx .hpp .hh .hxx .sh .bash .zsh .ps1 .psm1
.bat .cmd`. Any other extension, a
missing or non-string field, more than 64 MultiEdit entries, a side over
64 KiB, or 256 KiB in total means the full questions, word for word: the
original four (importers, public API, data schemas, the user's instruction),
or five with `GATEGUARD_EXTERNAL_CONTRACT_QUESTION` on. Sensitive
and hard-linked targets and NotebookEdit calls always get the full questions. Other classes keep their class
questions. Each question has a stable id (`importers`, `public-api`,
`local-callers`, `callers`, `no-duplicate`, `data-schema`,
`external-contract` when opted in, `quote-instruction`, and one per class
question), listed in `docs/gateguard/change-profile.md`.

### Questions by target class

The code questions carry no signal for a `SKILL.md`, a README, a test, or a
YAML file, so the first-touch gate classifies the target first.

Classification uses the target's path relative to the project root
(`CLAUDE_PROJECT_DIR`, then the payload `cwd`, then the process directory),
so a project that lives under, say, `~/work/tests/` does not turn every file
into a test. A leading `.claude/worktrees/<name>/` is stripped, since a
worktree mirrors the project, but only when
`<root>/.claude/worktrees/<name>/.git` exists; otherwise the path stays under
`.claude`. On Windows-style paths (and any segment containing `::$`), trailing
dots/spaces and a `:stream` suffix are dropped first, so `CLAUDE.md.` and
`CLAUDE.md::$DATA` classify as `CLAUDE.md`. Targets outside the root are
classified by their absolute path. The path is lowercased and `/`-separated,
and the first match wins:

1. **instruction** — basename `CLAUDE.md`, `AGENTS.md`, `AGENT.md`,
   `GEMINI.md`, `SKILL.md`, `copilot-instructions.md`, `.cursorrules`, or
   `.windsurfrules`; any `.mdc` file; a `.md`/`.mdx`/`.txt` file under a
   `.claude`, `agents`, `commands`, `skills`, `rules`, `hooks`, `.cursor`,
   `.codex`, or `.opencode` directory; or a `*instructions*.md` file under
   `.github/`
2. **test** — `*.test.*`, `*.spec.*`, `test_*.py`, `*_test.py`, `*_test.go`,
   or anything under a `tests`, `test`, or `__tests__` directory
3. **prose** — `.md`, `.mdx`, `.txt`, `.rst`, `.adoc`
4. **config** — `.json`, `.jsonc`, `.yaml`, `.yml`, `.toml`, `.ini`, or a
   `.env` / `.env.*` file that does not end in a code extension (`.env.local`
   is config; `.env.example.ts` and `.envrc` are code)
5. **code** — everything else (a `.js` under `hooks/` or `skills/` is code)

Every class ends with "Quote the user's current instruction verbatim", keeps
the batch-sibling warning, and uses the same questions for Edit, MultiEdit,
and Write unless noted. Items marked *(search)* carry the suffix
"(search the tree — Glob/Grep, or find/grep via Bash)".

| Class | Questions |
|---|---|
| instruction | Name the harness/loader that reads this file (Claude Code, Codex, Cursor, OpenCode, …) and when it loads it · Describe what agent behaviour changes as a result · Confirm no existing instruction, skill, or agent file already covers this *(search)* |
| test | Name what behaviour is under test and which module/function it exercises · Name the existing test file(s) covering this module, or confirm none exist *(search)* |
| prose (Write) | Name any existing doc this supersedes or duplicates *(search)* · State where it will be linked or referenced from · Explain why a new file rather than editing an existing one |
| prose (Edit) | List other docs or code that reference the section being changed *(search)* · State what the change corrects or adds |
| config | Name which process/tool reads this file and when · Describe the effect of the change · Confirm no secrets or credentials are being written in plain text |

Condensed denials (after `GATEGUARD_FACT_FORCE_FULL_DENIALS`) carry a
one-line hint for the same class. For code targets the hint names the same
questions as the full denial, one short phrase each; an Edit whose change
cannot be read keeps the fixed hint (importers/callers, affected API, data
schemas if any) when the external-contract question is off. Opting in adds
its external-contract phrase. Besides the message text, the class only
decides [sibling collapse](#same-turn-sibling-creation-collapse): which files
may collapse, and that a sibling must share the first file's class.

### Destructive Bash Gate (every destructive command)

Triggers on: `rm -rf`, `git reset --hard`, `git push --force`, `drop table`, etc.

```
1. List all files/data this command will modify or delete
2. Write a one-line rollback procedure
3. Quote the user's current instruction verbatim
```

### Routine Bash Gate (once per session)

```
1. The current user request in one sentence
2. What this specific command verifies or produces
```

Read-only introspection does not use this gate up: until it fires, a command
whose every `;`, `|`, `&&` or `||` segment is `ls`, `pwd`, `cat`, `head`,
`tail`, `wc`, `grep`, `rg`, `find`, `fd`, `tree`, or `git status`/`log`/
`diff`/`show`/`ls-files`/`rev-parse`/`branch` with read-only options (in
PowerShell: `Get-ChildItem`, `Get-Content`, `Select-String`, `Get-Location`,
their aliases, `rg`, `git`) passes and is counted in
`routine_readonly_passes`. The first other command is still gated. Any
redirection, `tee`, substitution, backtick, variable, environment
assignment, wrapper (`env`, `sudo`, `xargs`, `sh -c`), unknown command,
`find -exec`/`-delete`, `fd -x`, `rg --pre`/`-z`, `tree -o`, `git` global option or
write/execute option, or PowerShell `$`, `@`, `(`, `{` or `--%` makes the
command ordinary. Destructive detection runs first, unchanged.

Session state expires after 8 idle hours when it is keyed by a session id or
transcript path, and after 30 idle minutes when it falls back to the project
directory.

## Parallel Batches and Partial Application

The first-touch gate evaluates each tool call independently. When several
edits to a file that has not been touched yet are sent in one parallel
batch, the first call is denied and the denial marks the file as checked,
so the sibling edits in that batch are applied. Nothing is rolled back:
the file can end up holding the sibling edits without the denied one.

The denial message names the file and warns that batch siblings may
already have been applied. Treat it literally:

- Send dependent edits to a not-yet-touched file sequentially, not in a
  parallel batch. A definition and its first use, or an import and its
  call site, must not ride in the same batch.
- After a first-touch denial, present the facts, retry the denied edit,
  and re-read the file before building on anything else from the batch.

A batch-wide lock is not possible: hooks see tool calls one at a time, so
the gate cannot know which calls arrived together.

## What Counts as Already Done

A first-touch denial is meant to force investigation. When the transcript
shows the investigation already happened, or the answers would repeat the
previous denial word for word, the gate lets the call through with a note
instead of a denial. Each rule below falls back to the normal denial when
anything is ambiguous.

### Order of checks

For each Edit/Write/NotebookEdit target (and each MultiEdit entry) the gate
decides in this order; the first rule that applies wins:

1. **Exempt** (`GATEGUARD_EXEMPT_GLOBS`, Claude settings files) — allowed.
   Operator exemptions are authoritative: they win over every later rule,
   including the sensitive and hard-link rules, so a glob that covers
   `src/auth/**` or a hard-linked file turns its check off. Exempt only
   paths that may be edited without the check.
2. **Subagent** call — allowed (the parent session was already gated),
   except a [sensitive](#sensitive-targets) or
   [hard-linked](#hard-linked-targets) target: denied once per path
   unless the parent already gated it. A subagent's retry does not unlock
   the path for the parent.
3. **Already checked** this session — allowed.
4. **Sensitive or hard-linked target?** — if so, skip straight to the
   denial (step 9).
5. **Prior-search credit** — allowed with a note.
6. **Comment or whitespace-only Edit** — allowed with a note, not marked
   checked.
7. **Sibling collapse** — allowed with a note.
8. **Denial cap** (`GATEGUARD_FACT_FORCE_MAX_DENIALS`, opt-in) — passes
   through once the session's denials have reached the cap.
9. **Deny** — counted in `fact_force_denials`.

Credit, trivial and sibling allows never consume the denial cap.

### Sensitive targets

Some files are too costly to change without the full check, so prior-search
credit, the comment or whitespace-only pass, the change profile, sibling
collapse, and the denial cap never apply to them: they are
denied on first touch exactly as without these rules, and the denial counts
as usual. The denial carries one extra line: "Sensitive target: prior-search
credit, sibling collapse, and the denial cap do not apply." A target is
sensitive, judged on the same lowercased project-relative path as its class
**and** on its real (symlink-resolved) location, when either matches:

- its file name is `.env`, `.netrc`, or `.pgpass`, starts with `.env.`, ends
  in `.pem`, `.key`, `.p12`, or `.pfx`, or starts with `id_rsa`, `id_ed25519`,
  `id_ecdsa`, `id_dsa`, `credentials`, or `secrets.`;
- any path segment is exactly `auth`, `authn`, `authz`, `security`,
  `secrets`, `payment`, `payments`, `billing`, or `migrations` (whole
  segments only: `src/author.py`, `docs/authoring.md`, and
  `lib/paymentutils.py` are ordinary);
- or it lies under `.github/workflows/`.

The real location is the file's own realpath when it exists, else the
realpath of its nearest existing directory plus the rest of the path, judged
relative to the realpath of the project root, so `src/tools -> ../auth` makes
`src/tools/login.py` sensitive. A real location outside the project is judged
on its absolute path. Any error while deciding (a dangling symlink, a file
where a directory should be, a permission error) counts as sensitive.
Destructive and routine shell gates are unaffected.

### Hard-linked targets

A file with more than one hard link (`nlink > 1`, or a symlink to such a file)
is also reachable under another name, which may be sensitive or in another
class, so the path being edited says little about what changes. It gets the
same treatment as a sensitive target — no prior-search credit, change profile,
comment-only pass, sibling collapse, denial cap, or subagent bypass — and its
denial carries "Hard-linked target: prior-search credit, sibling collapse, and
the denial cap do not apply." instead of the sensitive line (a target that is
both says "Sensitive target"). A stat error other than a missing file counts as
hard-linked; a new file that does not exist yet is not.

### Prior-search credit

An Edit, Write, NotebookEdit, or MultiEdit entry is allowed, with an
`additionalContext` note naming the search, when a qualifying search in the
**current human turn** already covered the file:

- **Qualifying tools:** `Glob`, `Grep`, `LS`, and Bash/PowerShell command
  segments led by `rg`, `grep`, `egrep`, `fgrep`, `git grep`,
  `git ls-files`, `find`, `fd`, `ls`, `tree`, `Get-ChildItem`, `gci`,
  `Select-String`, or `sls`. **`Read` never counts** — Claude Code already
  requires a Read before an Edit, so it proves nothing extra.
- **Current human turn:** the turn starts at the latest real user message or
  at a compaction (a compact-summary record or a `compact_boundary` marker),
  so searches from before a compaction never count. Tool results, meta, and
  sidechain records do not start a turn. Only the transcript tail is read
  (last 256 KiB / 2000 lines); when the turn started before that window, the
  whole window is the current turn, provided every `promptId` in it agrees.
  Two or more `promptId`s there mean a turn started through a record the scan
  does not recognise, so that window gives no credit. A transcript with no
  turn start at all gives no credit.
- **Completed, unambiguous result:** the search needs a `tool_result` in the
  turn that is not an error (`is_error` of `true` or `"true"`, or content
  starting with `<tool_use_error>`). A tool-use id that appears more than once
  never counts.
- **Not the same batch:** searches sent in the gated call's own assistant
  message never count, since their results were not seen when the edit was
  decided. If the payload's `tool_use_id` is missing or not in the
  transcript, the newest assistant message in the turn is excluded instead. A
  search without a message id never counts.
- **Stem match:** the target's stem (basename minus its last extension,
  lowercased, at least 4 characters) must appear as a whole word — bounded by
  non-alphanumeric characters or the ends of the text — in the Glob pattern,
  the Grep pattern or glob, or the shell segment. `payment.py` is credited by
  a search for `payment` or `payment_service`, but not by one for
  `payments`. Exclusions are never a stem source. Generic stems (`index`, `main`, `init`, `__init__`,
  `utils`, `util`, `types`, `readme`, `test`, `tests`, `config`, `mod`,
  `lib`, `setup`, `app`, `spec`, `helpers`, `common`) never match. For a test
  target, one test affix is stripped first (`.test`, `.spec`, and for Python
  and Go `test_` or `_test`), so `rg tokenizer src tests` credits
  `tests/tokenizer.test.js`; `index.test.js` still reduces to the generic
  `index`.
- **Exclusions never credit:** a Grep `glob` is split on top-level commas;
  entries starting with `!` are exclusions. In shell segments, `rg -g`/
  `--glob`/`--iglob` values starting with `!`, `grep --exclude`/
  `--exclude-dir`, `fd -E`/`--exclude`, `ls -I`/`--ignore`/`--hide`,
  `tree -I`, `git ls-files -x`, git `:!x`/`:^x`/`:(exclude)x` pathspecs,
  `find -name`/`-path`/… under `-not` or `!` or followed by `-prune`, and
  PowerShell `-Exclude` are exclusions. If an exclusion covers the target
  (its glob matches the file name or any directory on its path, or it names
  the stem), that search cannot credit the target at all: `rg -g '!widget.py'
  