docs(gateguard): document first-touch classes, search credit, and denial cap

This commit is contained in:
Jeffrey Montoya
2026-09-29 21:50:11 -04:00
parent e98a9da288
commit 92b9d7c7be
5 changed files with 364 additions and 5 deletions
+15
View File
@@ -1,5 +1,20 @@
# Changelog
## Unreleased
### Changed
- GateGuard first-touch Edit/Write/MultiEdit denials now ask questions that fit the target's class (instruction, test, prose, config, or code), classified on its project-relative path. Code targets keep the existing questions word for word.
- GateGuard credits investigation that already happened: a completed, non-error `Glob`, `Grep`, `LS`, or search-style Bash/PowerShell command in the current human turn that names the target and whose scope contains it satisfies the first-touch check with a note instead of a denial. `Read` never counts; searches in the gated call's own batch, before a compaction, with error results, or behind exclusion globs never count; and any transcript problem falls back to the denial.
- GateGuard asks once for a batch of new files: after a Write of a new file is denied, later new files of the same class in the same real directory within the same human turn pass with a note. Edits, config and instruction files, and any dotfile or dot-directory are always gated one by one.
- Add opt-in `GATEGUARD_FACT_FORCE_MAX_DENIALS`, a per-session denial cap: once that many first-touch denials have been issued, further new paths pass through (counted in `cap_allows`). Unset keeps the current behaviour, credit and sibling allows never consume it, destructive and routine shell gates are unaffected, and a malformed value is reported once on stderr and leaves the gate uncapped (#2755, thanks @SulimanAbdulrazzaq; closes #2608).
- Sensitive targets (`.env*`, keys and certificates, `credentials*`, `secrets.*`, `auth`/`security`/`payment(s)`/`billing`/`migrations` path segments, `.github/workflows/`) are always gated on first touch, judged on both the path as written and its real (symlink-resolved) location; the denial says so.
- Session state records `fact_force_credited`, `denials_by_class`, `credited_by_class`, `sibling_allows`, `dir_gates`, and `cap_allows`; older state files load unchanged. These rules only remove redundant denials: nothing previously allowed is now denied. See `skills/gateguard/SKILL.md`.
### Fixed
- GateGuard keys checked files by canonical path, resolving relative targets against the tool `cwd`, so `a.py`, `./a.py`, and the absolute path (or differently cased and separated Windows spellings) share one first touch. Keys from earlier state files are still honoured.
## 2.2.2 - 2026-09-15
### Added
+47
View File
@@ -49,6 +49,8 @@ Both agents produce code that runs and passes tests. The difference is design de
MultiEdit is handled identically — each file in the batch is gated individually.
質問内容は対象ファイルのクラスによって変わります([対象クラス別の質問](#対象クラス別の質問)を参照)。以下は **code** クラスの Edit ゲートの質問です。
```
Before editing {file_path}, present these facts:
@@ -61,6 +63,8 @@ Before editing {file_path}, present these facts:
### Write Gate (first new file creation)
以下は **code** クラスの Write ゲートの質問です。
```
Before creating {file_path}, present these facts:
@@ -71,6 +75,28 @@ Before creating {file_path}, present these facts:
4. Quote the user's current instruction verbatim
```
### 対象クラス別の質問
初回の Edit/Write ゲートは対象ファイルを分類し、最初に一致したクラスの質問を使います。分類にはプロジェクトルート(`CLAUDE_PROJECT_DIR`、次にペイロードの `cwd`、次にプロセスのディレクトリ)からの相対パスを使うため、`~/work/tests/` のような場所にあるプロジェクトでも全ファイルが test になることはありません。先頭の `.claude/worktrees/<name>/` は `<root>/.claude/worktrees/<name>/.git` が存在する場合にだけ取り除きます。Windows 形式のパスでは末尾のドット・空白と `:stream` 接尾辞を除いてから分類します(`CLAUDE.md.` や `CLAUDE.md::$DATA` は `CLAUDE.md` として扱います)。ルート外の対象は絶対パスで分類します。パスは小文字化・`/` 区切りで、次の順に判定します。
1. **instruction** — `CLAUDE.md`、`AGENTS.md`、`AGENT.md`、`GEMINI.md`、`SKILL.md`、`copilot-instructions.md`、`.cursorrules`、`.windsurfrules`、任意の `.mdc`、`.claude`、`agents`、`commands`、`skills`、`rules`、`hooks`、`.cursor`、`.codex`、`.opencode` ディレクトリ配下の `.md`/`.mdx`/`.txt`、または `.github/` 配下の `*instructions*.md`
2. **test** — `*.test.*`、`*.spec.*`、`test_*.py`、`*_test.py`、`*_test.go`、または `tests`、`test`、`__tests__` 配下
3. **prose** — `.md`、`.mdx`、`.txt`、`.rst`、`.adoc`
4. **config** — `.json`、`.jsonc`、`.yaml`、`.yml`、`.toml`、`.ini`、またはコード拡張子で終わらない `.env` / `.env.*`(`.env.local` は config、`.env.example.ts` と `.envrc` は code)
5. **code** — 上記以外(上に示した質問)
どのクラスも最後に「ユーザーの現在の指示を原文のまま引用する」項目が付きます。質問文(英語、hook の出力そのまま)は次のとおりです。*(search)* は "(search the tree — Glob/Grep, or find/grep via Bash)" の略です。
| クラス | 質問 |
|---|---|
| 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 |
メッセージの文面以外でクラスが影響するのは兄弟ファイル集約だけです(集約できるクラスと、同じクラス同士でのみ集約されること)。
### Destructive Bash Gate (every destructive command)
Triggers on: `rm -rf`, `git reset --hard`, `git push --force`, `drop table`, etc.
@@ -88,6 +114,27 @@ Triggers on: `rm -rf`, `git reset --hard`, `git push --force`, `drop table`, etc
2. What this specific command verifies or produces
```
## すでに済んでいるとみなされるもの
調査がすでに行われたことがトランスクリプトから確認できる場合、または前回の拒否と同じ回答になる場合、ゲートは拒否の代わりに注記(`additionalContext`)付きで通過させます。判断が曖昧な場合は常に従来どおり拒否します。
- **事前検索のクレジット:** 現在の人間のターン内で、`Glob`、`Grep`、`LS`、または `rg`/`grep`/`git grep`/`git ls-files`/`find`/`fd`/`ls`/`tree`/`Get-ChildItem`/`Select-String` などで始まる Bash/PowerShell コマンドが完了していれば、初回タッチを満たしたとみなします。**`Read` は対象外です。**
- **ターン:** 最新の実際のユーザーメッセージ、またはコンパクション(compact summary レコードや `compact_boundary`)から始まります。コンパクション前の検索は数えません。読むのはトランスクリプト末尾(256 KiB / 2000 行)だけで、ターン開始がその範囲より前にある場合は範囲全体を現在のターンとみなします。ターン開始がまったくないトランスクリプトではクレジットしません。
- **結果:** エラーでない `tool_result` が必要です(`is_error` が `true`/`"true"`、または内容が `<tool_use_error>` で始まるものはエラー)。同じ tool_use id が複数回現れる検索は数えません。
- **同一バッチの除外:** ゲート対象の呼び出しと同じアシスタントメッセージ内の検索は数えません。`tool_use_id` がない、またはトランスクリプトに見つからない場合は、ターン内の最新のアシスタントメッセージを除外します。message id のない検索は数えません。
- **語幹一致:** ファイル名の語幹(4 文字以上、`index`・`utils`・`config` などの汎用名は除外)が、英数字以外の文字または端で区切られた単語として検索文字列に現れる必要があります(`payment.py` は `payment` や `payment_service` の検索で一致し、`payments` では一致しません)。
- **除外は数えない:** Grep の `glob` はトップレベルのカンマで分割し、`!` で始まるものは除外です。シェルでは `rg -g`/`--glob`/`--iglob` の `!` 値、`grep --exclude`/`--exclude-dir`、`fd -E`、`ls -I`、`tree -I`、`git ls-files -x`、git の `:!x` パススペック、`-not`/`!` 付きまたは `-prune` が続く `find -name`/`-path`、PowerShell の `-Exclude` が除外です。除外は語幹の出どころになりません。除外が対象(ファイル名またはパス上のディレクトリ、または語幹)に当たる検索はその対象を一切クレジットしません(`rg -g '!widget.py' TODO .` は `widget.py` をクレジットせず、`rg -g '!*.md' widget .` はクレジットします)。`--exclude-from`/`--ignore-file` を含むセグメントはクレジットしません。`*.py`・`--include`・`find -name` などのファイル名グロブで絞った検索は、対象のファイル名に一致する場合だけ語幹一致を数えます。
- **範囲:** `Glob`/`Grep`/`LS` は検索したディレクトリ(`path`、なければツールの `cwd`、Glob はパターンのリテラル部分を加える)の内側にある対象だけをクレジットします。対象自体を読むだけのシェルセグメント(例: `ls src/a.py`)や、パス引数がなく再帰でもない・パイプで stdin を読むセグメントは検索とみなしません。`$( )`、バッククォート、`<( )`、`>( )`、ヒアドキュメントを含むコマンドは対象外です。シェルセグメントは、パス引数(なければ `cwd`)の内側にある対象だけをクレジットします(`rg payment docs` は `src/payment.py` をクレジットしません)。引数より前の入力リダイレクト(`rg payment < file`)は stdin とみなし、別の呼び出しで `cd` した後の相対パス引数は数えません。
- **ディレクトリ一致:** まだ存在しないファイルを作成する Write だけが、対象のディレクトリそのものを指す検索でも一致します(Glob の `path` とリテラル接頭辞、Grep/LS の `path`、シェルのパス引数)。シェル引数では grep 系のパターン引数や値を取るフラグの値を除き、今実在するディレクトリだけを数えます。ターン内のいずれかの Bash/PowerShell 呼び出しに `cd`/`pushd`/`Set-Location` などがあれば、そのターンのシェルのディレクトリ一致は無効です。Edit、MultiEdit、既存ファイルを上書きする Write は語幹一致のみです。
- 相対パスはツールの `cwd`、次に `CLAUDE_PROJECT_DIR`、次にプロセスのディレクトリを基準に解決します。トランスクリプトが読めない等の問題があれば拒否に戻ります。
- **同一ターンの兄弟ファイル作成の集約:** まだ存在しないファイルへの Write(code、test、prose クラスのみ)が拒否された後、**同じクラス・同じディレクトリ**への新規ファイル Write は、同じターン内であれば注記付きで許可されます。ターン ID はターン開始レコード自身の `promptId`、なければその `uuid`、なければそのハッシュです(末尾範囲にターン開始がない場合は、範囲内のユーザーレコードの `promptId` がすべて一致するときだけ使います)。120 秒ルールはトランスクリプトのパスがまったくない場合に限られます。config・instruction ファイル、`.` で始まるセグメント(ドットファイルとすべてのドットディレクトリ)や 8.3 短縮名を含むパスは集約されません。ディレクトリはシンボリックリンクを解決した実体でも同じ判定を行い、実体のディレクトリをキーにします。Edit/MultiEdit も集約されません。
- **正規化パスキー:** 相対パスはツールの `cwd` を基準に解決され、`a.py`、`./a.py`、絶対パスは同じファイルとして扱われ、初回タッチは 1 回だけです。
- **セッション内カウンター:** 状態ファイルに `fact_force_credited`、`denials_by_class`、`credited_by_class`、`sibling_allows`、`dir_gates`、`cap_allows` を記録します(クラス名のみ記録し、パスは記録しません。`dir_gates` はサニタイズ済みの最初のファイル名のみ保持、最大 50 件)。
- **拒否の上限(オプトイン):** `GATEGUARD_FACT_FORCE_MAX_DENIALS` を設定すると、セッション内の初回タッチ拒否がその回数に達した後の新しいパスは拒否されずに通過します(`cap_allows` に記録)。`GATEGUARD_FACT_FORCE_FULL_DENIALS` はメッセージの詳しさ(メッセージ予算)だけを変えますが、こちらはブロックするかどうか(拒否予算)を変えます。事前検索のクレジットと兄弟ファイル集約が先に判定され、上限を消費しません。不正な値は上限なしとして扱い、stderr に 1 回警告します。
- **機密対象:** `.env`/`.env.*`、`*.pem`・`*.key`・`*.p12`・`*.pfx`、`id_rsa*`・`id_ed25519*`・`credentials*`・`secrets.*`、パスセグメントが `auth`・`authn`・`authz`・`security`・`secrets`・`payment`・`payments`・`billing`・`migrations` のいずれかと完全一致するもの、`.github/workflows/` 配下は、クレジット・集約・上限のいずれも適用されず、初回タッチで必ず拒否されます。判定は字句上のパスとシンボリックリンクを解決した実体の両方で行い(`src/tools -> ../auth` なら `src/tools/login.py` も機密)、実体を解決できない場合は機密とみなします。判定順は exempt → subagent → checked → 機密? → クレジット → 兄弟集約 → 上限 → 拒否です。
- **互換性:** 新しい環境変数はオプトインの `GATEGUARD_FACT_FORCE_MAX_DENIALS` のみです。以前許可されていた操作が拒否されることはありません。
- **信頼の限界:** クレジットはローカルのトランスクリプトを読むため、エージェントが理論上それを書き換えられます。検証するのは行動(検索が実行され結果が返ったこと)であり、意図ではありません。
## Quick Start
### Option A: Use the ECC hook (zero install)
+11
View File
@@ -86,6 +86,17 @@ LLM 的自我评估不起作用。问"你是否违反了任何策略?"答案
2. 此特定命令验证或生成的内容
```
## 按目标类别提问与已完成调查的认定
以上编辑/写入门控的问题适用于 **code** 类目标。完整规则见英文版 `skills/gateguard/SKILL.md`,要点如下:
* **目标类别:** 首次编辑/写入按项目相对路径(去掉开头的 `.claude/worktrees/<name>/`)依次判定为 instruction(`CLAUDE.md`、`AGENTS.md`、`SKILL.md`、`.mdc`、`.cursorrules`、`.github/copilot-instructions.md` 等)、test、prose(`.md` 等)、config(`.json`、`.yaml`、`.env` / `.env.*` 等,`.env.example.ts` 仍属 code)或 code,并提出相应的问题。
* **事先搜索抵免:** 若当前人类回合内已有成功完成(非错误结果)的 `Glob`、`Grep`、`LS` 或 `rg`/`grep`/`find` 等搜索命中该文件,则以附注代替拒绝。`Read` 从不计入;与被门控调用同批次的搜索、重复的 tool_use id、压缩(compaction)之前的搜索均不计入。文件名词干须按单词边界匹配,且目标须位于所搜索目录之内(shell 片段按其路径参数或 `cwd` 判定范围;参数前的输入重定向 `<` 视为读取 stdin)。仅"创建尚不存在的文件"的写入可按目录匹配,且 shell 参数须是真实存在的目录;回合内出现 `cd` 等切换目录命令时不再按 shell 目录匹配。相对路径按工具的 `cwd` 解析。排除规则从不计入:`Grep` 的 `glob` 中以 `!` 开头的项,以及 shell 中 `rg -g '!x'`、`grep --exclude`/`--exclude-dir`、`fd -E`、`ls -I`、`tree -I`、`git ls-files -x`、git `:!x` 路径规格、带 `-not`/`!` 或后接 `-prune` 的 `find -name`/`-path`、PowerShell `-Exclude` 都不作为词干来源;若排除项覆盖目标(文件名、路径上的目录或词干),该搜索完全不能为其抵免(`rg -g '!widget.py' TODO .` 不抵免 `widget.py`,`rg -g '!*.md' widget .` 仍抵免)。含 `--exclude-from`/`--ignore-file` 的片段不抵免;用 `*.py`、`--include`、`find -name` 等文件名 glob 限定的搜索,只有在其匹配目标文件名时词干才计入。
* **同回合同级文件合并:** 同一目录、同一类别(仅 code、test、prose)的新文件在同一回合内(无回合 ID 时为 120 秒内)只拒绝第一个,其余以附注放行。config、instruction,以及任何以 `.` 开头的路径段(点文件和所有点目录)或 8.3 短文件名下的文件从不合并;目录按解析符号链接后的真实位置判定和记录;仅在完全没有 transcript 路径时才使用 120 秒规则;编辑从不合并。
* **拒绝上限(可选):** 设置 `GATEGUARD_FACT_FORCE_MAX_DENIALS` 后,会话内首次触碰的拒绝次数达到该值后,新路径直接放行(计入 `cap_allows`)。`GATEGUARD_FACT_FORCE_FULL_DENIALS` 只决定消息详略(消息预算),而此变量决定是否拦截(拒绝预算)。事先搜索抵免和同级合并先于上限判定,不消耗上限;格式错误的值视为无上限,并在 stderr 警告一次。
* **敏感目标:** `.env`/`.env.*`、`*.pem`/`*.key`/`*.p12`/`*.pfx`、`id_rsa*`/`id_ed25519*`/`credentials*`/`secrets.*`、任一路径段恰为 `auth`、`authn`、`authz`、`security`、`secrets`、`payment`、`payments`、`billing`、`migrations`,以及 `.github/workflows/` 下的文件,不适用抵免、合并和上限,首次触碰总是拒绝。按字面路径和解析符号链接后的真实位置同时判定(`src/tools -> ../auth` 时 `src/tools/login.py` 也是敏感目标),真实位置无法解析时视为敏感。判定顺序:exempt → subagent → checked → 敏感? → 抵免 → 同级合并 → 上限 → 拒绝。
* **兼容性:** 唯一新增的环境变量是可选的 `GATEGUARD_FACT_FORCE_MAX_DENIALS`;以前允许的操作不会变为拒绝,且从不返回 `permissionDecision: "allow"`。
## 快速开始
### 选项 A:使用 ECC 钩子(零安装)
File diff suppressed because one or more lines are too long
+290 -4
View File
@@ -50,6 +50,10 @@ Both agents produce code that runs and passes tests. The difference is design de
MultiEdit is handled identically — each file in the batch is gated individually.
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:
@@ -62,6 +66,8 @@ Before editing {file_path}, present these facts:
### Write Gate (first new file creation)
For **code** targets the Write gate asks:
```
Before creating {file_path}, present these facts:
@@ -72,6 +78,55 @@ Before creating {file_path}, present these facts:
4. Quote the user's current instruction verbatim
```
### 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. 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.
@@ -109,6 +164,234 @@ already have been applied. Treat it literally:
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 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.
2. **Subagent** call — allowed (the parent session was already gated).
3. **Already checked** this session — allowed.
4. **Sensitive target?** — if so, skip straight to the denial (step 8).
5. **Prior-search credit** — allowed with a note.
6. **Sibling collapse** — allowed with a note.
7. **Denial cap** (`GATEGUARD_FACT_FORCE_MAX_DENIALS`, opt-in) — passes
through once the session's denials have reached the cap.
8. **Deny** — counted in `fact_force_denials`.
Credit 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, 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` or starts with `.env.`, ends in `.pem`, `.key`,
`.p12`, or `.pfx`, or starts with `id_rsa`, `id_ed25519`, `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.
### Prior-search credit
An Edit, Write, 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.
- **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'
TODO .` never credits `widget.py`, while `rg -g '!*.md' widget .` still
does. `--exclude-from`, `--ignore-file`, and `git ls-files -X` hide names
the hook cannot see, so such a segment never credits. File-name globs that
restrict a search (Grep `glob: '*.py'`, `rg -g '*.py'`, `grep --include`,
`find -name`, `tree -P`, `-Include`/`-Filter`) must match the target's file
name for its stem to count.
- **Scope:** `Glob`, `Grep`, and `LS` only credit a target strictly inside the
directory they searched (their `path`, else the tool `cwd`, plus a Glob
pattern's literal leading directories). A Glob without glob characters is a
one-file lookup: it names no directory and credits only files directly in
its directory, never the looked-up file itself. A search whose path is the target is not a search.
- **Shell segments:** a segment without a path operand counts only when it is
recursive (`rg`, `git grep`, `git ls-files`, `find`, `fd`, and `tree` by
default; `grep -r`/`-R`/`--recursive`, `ls -R`, `Get-ChildItem -Recurse`)
and not fed by a pipe; otherwise it reads stdin, not the tree. A segment
whose path operands all resolve to the target only reads the target and
does not count (`ls src/a.py` never credits `src/a.py`). Commands containing
`$( )`, backticks, `<( )`, `>( )`, or heredocs, or longer than 8192
characters, never credit.
- **Shell scope:** a shell segment only credits a target equal to or inside
one of its path operands (resolved against the tool `cwd`; a glob operand
counts as its literal directory), or inside the `cwd` when it has none:
`rg payment docs` or `grep -rn payment /usr/share/doc` never credit
`src/payment.py`. An input redirection before any operand
(`rg payment < file`) or a `<` inside a word reads stdin and never counts.
After `cd <dir>` in the same command, relative operands resolve against that
directory; after any other directory change, or one in another call of the
turn, only absolute operands count.
- **Directory match (new files only):** a **Write that creates a file that
does not exist yet** may also match when the search named exactly the
target's directory: a Glob `path` plus the pattern's literal prefix (a
pattern that starts with a glob character and has no `path` names no
directory), a Grep or LS `path`, or a shell path operand. For shell
operands, the pattern argument of grep-style tools is skipped (unless
`-e`/`-f`/`--regexp`/`--file`/`-Pattern` supplied the pattern), values of
value-taking flags (`-g`, `--include`, `-name`, `-Filter`, …) are skipped,
trailing `/`, `/*`, and `/**` are dropped, `.`, `~`/`$` paths, and globs
name no directory, and the operand must be an existing directory at the
time of the check. If any Bash/PowerShell call in the turn changes directory
(`cd`, `pushd`, `popd`, `chdir`, `Set-Location`, `sl`, `Push-Location`,
`Pop-Location`), shell directory matching is off for the whole turn; stem
matching still applies. **Edit, MultiEdit, and a Write that overwrites an
existing file match by stem only.**
- **Paths:** relative targets and search paths resolve against the tool's
`cwd`, then `CLAUDE_PROJECT_DIR`, then the process directory. Comparison is
case-insensitive for Windows paths only. Shell commands are read with bash
quoting rules, so on Windows an operand credits when spelled `C:/x/src`;
backslash spellings (`C:\x\src`, quoted or not) and MSYS spellings
(`/c/x/src`) do not credit and the write is denied as usual.
- **Note, not denial:** the credited file is marked checked and
`fact_force_credited` is incremented; the denial count and ordinal are not
touched. MultiEdit credits per entry and denies the first uncredited entry
as before.
- **Falls back to deny:** a missing, unreadable, or non-file transcript,
garbage records, or any internal error mean no credit.
### Same-turn sibling creation collapse
When several new files are created in one directory, the answers for each
would be identical, so only the first is denied:
- Applies to a **Write of a file that does not exist yet** whose class is
`code`, `test`, or `prose`. Never collapsed: `config` and `instruction`
files, and any path with a segment starting with `.` (dotfiles and every
dot-directory: `.claude`, `.github`, `.git`, `.husky`, `.devcontainer`,
`.githooks`, `.idea`, `.vscode`, …) or an 8.3 short name (`CLAUDE~1`),
checked on the same project-relative path as the class.
- The directory is also resolved to its **real location** (symlinks
followed; missing directories are resolved from the nearest existing
parent). If that differs from the path as written, the real path must be
inside the project, keep the same class, and pass the same screen, so a
symlink such as `src/tools -> .claude/hooks` never collapses. Gates are
keyed by the real directory. Any filesystem error means no collapse.
- After such a Write is denied, later first-touch Writes of new files **of the
same class in the same directory** are allowed with the note "Sibling of
`<first>` (gated earlier at denial #`<n>` this session)". A code denial never
lets a test, README, or config file through.
- The window is the **same human turn**. The turn id is the turn-start
record's own `promptId`, else its `uuid`, else a hash of that record. When
the turn started before the transcript tail, the id is the `promptId` that
every user record in the window agrees on (Claude Code stamps it on the
prompt and on every tool result of the turn); if they disagree there is no
turn id. Only when **no transcript path is available at all** does a gate
recorded without a turn id match for **120 seconds**; a transcript that is
missing, unreadable, or yields no turn id means no collapse. A turn-scoped
gate never matches a call without a turn id, and vice versa; a gate stamped
in the future is ignored.
- Prior-search credit is checked first. **Edits and MultiEdits are never
collapsed**, and an existing file is never treated as a sibling.
### Canonical path keys
Checked state is keyed on the canonical path: a relative `file_path` resolves
against the tool's `cwd` (where the tool runs), then `CLAUDE_PROJECT_DIR`,
then the process directory, with `/` separators, lowercased on Windows only.
`a.py`, `./a.py`, and the absolute path share one first-touch. State files
written by earlier versions (raw keys) are still honoured.
`GATEGUARD_EXEMPT_GLOBS` keeps its project-relative matching (below).
### In-session counters
The per-session state file (under `GATEGUARD_STATE_DIR`) records these fields.
They record class names only, never paths (except `dir_gates`, which stores
the sanitized first file per directory):
| Field | Meaning |
|---|---|
| `fact_force_credited` | Number of first touches allowed by prior-search credit |
| `denials_by_class` | Denials per target class (`code`, `prose`, `test`, …) |
| `credited_by_class` | Credits per target class |
| `sibling_allows` | Number of Writes allowed by sibling collapse |
| `dir_gates` | Per class and directory, the denial that opened a sibling window (turn, time, first file, ordinal); capped at 50 entries |
| `cap_allows` | Number of first touches passed through by the denial cap (`GATEGUARD_FACT_FORCE_MAX_DENIALS`); these never count as denials |
Missing or malformed fields load as empty or zero, so older state files keep
working.
### Compatibility and limits
- **One new, opt-in environment variable:**
`GATEGUARD_FACT_FORCE_MAX_DENIALS` (unset means no cap). The existing
controls are unchanged.
- **Nothing previously allowed is now denied.** These rules only remove
denials, and never return `permissionDecision: "allow"`, so other hooks
and permission rules still apply.
- **Trust limit:** prior-search credit reads the local transcript file,
which the agent could in principle write to. The credit verifies observed
behaviour (a search ran and returned), not intent or how carefully the
result was read.
## Quick Start
### Option A: Use the ECC hook (zero install)
@@ -120,11 +403,13 @@ If GateGuard blocks setup or repair work, start the session with
`ECC_DISABLED_HOOKS` with the GateGuard hook ID.
In long sessions, only the first `GATEGUARD_FACT_FORCE_FULL_DENIALS`
fact-force denials (default 3) emit the full four-fact block; later
fact-force denials (default 3) emit the full fact block; later
denials are condensed to a single line carrying the denial ordinal, so
near-identical blocks cannot accumulate in the context window and
amplify model repetition loops (#2142). Retrying the same file or
command after presenting facts never re-triggers the gate.
command after presenting facts never re-triggers the gate. That is a
message budget; to stop denying new files after a number of denials, set
the opt-in denial budget `GATEGUARD_FACT_FORCE_MAX_DENIALS` (below).
#### Graduated controls
@@ -136,7 +421,8 @@ load-bearing destructive-Bash checks keep running:
|---|---|---|
| `GATEGUARD_BASH_ROUTINE_DISABLED` | unset (gate on) | Disables the **routine-Bash** gate only. The destructive-Bash gate (`rm -rf`, `git reset --hard`, `drop table`, `dd if=`, …) is unaffected. |
| `GATEGUARD_EXEMPT_GLOBS` | unset (no exemptions) | Comma-separated globs; a matching Edit/Write/MultiEdit target skips first-touch fact-forcing. Intended for low-import-value trees (tests, generated artifacts, scratch dirs) where "who imports this / what schema" carries no signal. |
| `GATEGUARD_FACT_FORCE_FULL_DENIALS` | `3` | How many denials emit the full four-fact block before later ones condense to a single line. `0` condenses from the very first denial. |
| `GATEGUARD_FACT_FORCE_FULL_DENIALS` | `3` | How many denials emit the full fact block before later ones condense to a single line. `0` condenses from the very first denial. |
| `GATEGUARD_FACT_FORCE_MAX_DENIALS` | unset (no cap) | A **denial budget**: caps how many first-touch Edit/Write/MultiEdit denials a session draws. Once that many denials have been issued, further new paths pass through instead of being denied (counted in `cap_allows`, not as denials); `0` passes from the first. Prior-search credit and sibling collapse run first and never use it up, and [sensitive targets](#sensitive-targets) are always denied. Destructive and routine Bash stay gated. The cap is best effort when hooks run concurrently: a lost count update can add a denial, but never lets a new path through early. Opt-in: unset, or any value that is not a whole non-negative integer (surrounding spaces allowed), keeps the deny-every-new-path behaviour; a malformed value is reported once on stderr (`ignoring malformed GATEGUARD_FACT_FORCE_MAX_DENIALS=…; the denial cap is not active.`). Unlike `GATEGUARD_FACT_FORCE_FULL_DENIALS`, a **message budget** that only changes how much text a denial carries, this changes whether the operation is blocked. Condensed denials name this variable. |
| `GATEGUARD_BASH_EXTRA_DESTRUCTIVE` | unset | Extra destructive-command patterns, as regex source, added to the built-in set. A malformed regex is treated as unset (built-ins still apply) and logged once to stderr. |
| `GATEGUARD_STATE_DIR` | `~/.gateguard` | Where per-session gate state is kept. If state cannot be persisted the gate allows the operation rather than looping, and names this variable in the warning. |
@@ -196,7 +482,7 @@ This adds `.gateguard.yml` for per-project configuration (custom messages, ignor
## Best Practices
- Let the gate fire naturally. Don't try to pre-answer the gate questions — the investigation itself is what improves quality.
- Let the gate fire naturally. Don't try to pre-answer the gate questions — the investigation itself is what improves quality. A real search for the target before editing is investigation, and is credited (see [What Counts as Already Done](#what-counts-as-already-done)).
- Customize gate messages for your domain. If your project has specific conventions, add them to the gate prompts.
- Use `.gateguard.yml` to ignore paths like `.venv/`, `node_modules/`, `.git/`.