feat(gateguard): integrate evidence-aware first-touch questions

This commit is contained in:
haelyra
2026-10-10 17:46:23 -04:00
73 changed files with 29239 additions and 165 deletions
+309
View File
@@ -0,0 +1,309 @@
# GateGuard change profile notes
Rationale for how GateGuard reads the change itself: which questions a
denial asks and which edits pass as trivial
(`scripts/lib/gateguard-change-profile.js`, `gateguard-code-lexer.js` and
`gateguard-file-context.js`). Code comments point here as
`docs/gateguard/change-profile.md#<anchor>`; keep headings stable, since they
are the anchors. The other decisions are in [design-notes.md](design-notes.md).
## Change profile
`scripts/lib/gateguard-change-profile.js` reads the change the hook already
receives (Edit `old_string`/`new_string`, each MultiEdit entry for the target,
Write `content`) and derives three booleans: `touchesPublicSurface`,
`touchesData` and `trivial`. It is string analysis of those texts and, for
`trivial`, of the current file text the hook passes in (see
[File context](#file-context)): no reads of its own, no `RegExp` built from
input, and every loop is a single linear pass.
The profile only ever removes questions, so anything uncertain yields the
unknown profile (every question asked, never trivial): an unsupported
extension, a non-string side, no entries or more than 64, a side over 64 KiB
(UTF-8), more than 256 KiB in total, or any exception. Supported languages, by
extension: JS/TS (`.js .mjs .cjs .jsx .ts .tsx .mts .cts`), Python
(`.py .pyi`), Go, Rust, Java, Kotlin (`.kt .kts`), C# and C/C++ (`.c .h .cc
.cpp .cxx .hpp .hh .hxx`), and shell scripts: POSIX shells (`.sh .bash .zsh`),
PowerShell (`.ps1 .psm1`) and batch (`.bat .cmd`). Ruby, Makefiles, Swift,
fish, PowerShell data files and everything else are unknown.
## Public surface
A Write always touches the public surface (a new or rewritten module's
surface is all new). For an Edit, any line on either side that declares or
exposes a public name counts, whether it changed or is only context: context
around a public declaration means the edit is inside that symbol.
- JS/TS: a line starting with the word `export`, `public` or `declare`, or
containing the word `exports` (`module.exports`, `exports.x`). `.d.ts`,
`.d.mts` and `.d.cts` files are all surface.
- Python: `def`, `async def` or `class` whose name does not start with `_`
(dunders such as `__init__` are public), any `__all__`, and a column-0
assignment or annotation of a public name (a module constant or variable
that importers can read). The first line of a snippet may start mid-line
(an Edit's `old_string` can begin after the indentation), so there only an
all-uppercase name counts, the constant convention that function locals
rarely use. `__init__.py` is all surface.
- Go: `func` (after an optional receiver), `type`, `var` or `const` with a
capitalised name, a `package` line, and any line that starts with a
capital letter (exported fields, interface methods, grouped declarations).
- Rust: lines starting with `pub` (any `pub(...)`), `impl`, `trait`,
`extern`, or an ABI or trait attribute (`#[macro_export`, `#[derive`,
`#[repr`, `#[no_mangle`, `#[export_name`, and their `#[unsafe(...)]`
forms).
- POSIX shells: a function definition (`name() {`, `function name`), an
`export`, or `declare -x`/`typeset -x`, at any indentation. A sourced
script's functions and exported variables are what other scripts use.
- PowerShell (case-insensitive): lines starting with `function`, `filter`,
`workflow`, `class`, `enum`, `param` (a script's parameters are its
interface), `Export-ModuleMember`, `[CmdletBinding` or `$global:`.
- Java, Kotlin, C#, C, C++ and batch files always touch the surface:
package-private Java, Kotlin's public default, C# partial classes, C linkage
and batch labels reachable by `call :label` make a lexical answer
unreliable.
An Edit that changes a function body without its declaration line in the
snippet reads as not touching the surface; the local question still asks for
the call sites that rely on the behaviour.
A member line can be public surface even though its container's declaration
is not in the snippet: a property of an exported interface, a variant of a
public enum, a name in an export list. So, when the hook passes the file text,
the nearest line above the first occurrence that starts at column 0 (skipping
blank lines, comments and decorators; a closing `}`, `]` or `)` there means
the edit is at top level) is read as the enclosing opener:
- JS/TS: `module.exports`/`exports` assigned an object, array or call, and
`export` (or `export default`) of an interface, enum, type, namespace,
module, `declare`, `{ ... }` list, or `const`/`let`/`var` that is not a
function or arrow: every line inside is surface. An exported class: lines
at the class's member indentation (the first non-blank line after the
opener) are surface; method bodies are not. An exported `function` or
`async function` is a body, as before.
- Python: `__all__` makes every line surface; a public `class` makes an
assignment or annotation of a public name at member indentation (class
attributes, dataclass fields) surface.
- Rust: a `pub` enum, struct, union, trait or `use` makes lines at member
indentation surface.
The upward scan stops after 4 MiB in total and then counts as surface. The
opener is found by indentation, not by parsing, so it can over-match (a method
body inside an exported object literal counts), which only keeps the importer
question.
## Data handling
`touchesData` looks at every side (both sides of an Edit) as words: identifiers
are split on non-alphanumerics, `_` and camelCase boundaries and lowercased,
so `validate` and `updated` never match `date`. It is true for a data word
(formats such as `json`, `csv`, `yaml`, `parquet`; serialisation; `schema`;
SQL and database words; date/time words such as `date`, `datetime`,
`timestamp`, `strftime`, `utc`, `chrono`, `zoneinfo`; encodings such as
`base64`, `encoding`, `msgpack`; browser storage and cookies; data-store
clients such as `redis`, `prisma`, `sqlalchemy`; file I/O such as `fs`,
`fopen`, `pathlib`), for `open(`, for adjacent pairs such as `read file`,
`time now`, `time parse`, `system time`,
`write text`, `read to`, and for SQL keyword pairs anywhere in the text
(`select`+`from`, `insert`+`into`, `create`+`table`, ...). Over-matching only
keeps the data question.
Shell scripts also touch data with a file redirection (`>`, `>>`, `>|`, `&>`
or `<` followed by a target other than `/dev/null`, `/dev/stdout`,
`/dev/stderr`, `$null` or `nul`; descriptor duplication such as `2>&1` and
`>&2`, heredocs and process substitution do not count), an HTTP or document
tool (`curl`, `wget`, `jq`, `yq`, `xmllint`, `psql`, `tee`, `iwr`, `irm`),
or a PowerShell file or web cmdlet (`Out-File`, `Get-Content`,
`Set-Content`, `Add-Content`, `Invoke-WebRequest`, `Invoke-RestMethod`,
`*-Clixml`; `Import-Csv` matches `csv`). Redirection is found without
reading quotes, so a `>` inside a string also counts.
## Trivial edits
An Edit is trivial when, for every entry, old and new have the same code once
comments are removed, blank and comment-only lines are dropped, and runs of
whitespace between tokens are folded to one space. Line structure, whitespace
inside strings, and the presence of whitespace between tokens stay code
(`a+b` to `a + b` is not trivial). For Python, leading whitespace of each code
line is code; comment-only lines carry no indentation.
Comments are `//` and non-nesting `/* */` in the C family and `#` in Python;
C preprocessor lines are code. Strings are single-line `"..."` and `'...'`
(Rust: `"..."` only, with `'x'` char literals told apart from lifetimes).
Anything the lexer cannot read with confidence makes the entry non-trivial:
- any multi-line string form: JS/Go backticks, Python/Java/Kotlin/C# triple
quotes, Rust raw strings (`r"`, `r#"`), C++ raw strings (`R"`), C# verbatim
and interpolated strings (`@"`, `$"`), Kotlin strings containing `$`, Python
f-strings, and any string that runs to the end of a line;
- in JS/TS, any `/` outside a comment (division and regex literals cannot be
told apart), JSX-like `<x`, `</`, `<>`, `<!`, and `-->`;
- a line comment ending in `\` (it continues onto the next line in C and
Make), the trigraph `??/`, a `/*` inside a block comment, and an
unterminated block comment or string;
- a code line ending in a line continuation `\` (trailing blanks included):
a C macro, a Python explicit continuation or a string continued onto the
next line joins that line, so adding or removing a comment-only line after
it changes code.
## Directive comments
Some comments are read by the language, the build or a tool, so their text is
code. A directive comment is kept in the compared code verbatim: changing,
adding or removing one makes the entry non-trivial, while an unchanged
directive next to an edited ordinary comment does not.
- A comment whose text starts with `!` (shebangs, Rust inner docs, `/*!`),
`/` (`///` doc comments and TypeScript triple-slash references), `go:`,
`export` or `extern` followed by a blank (cgo), or `line` followed by a
blank (Go line directives), with no blank after the comment marker.
- A comment whose first word, after blanks, `*` and `/`, starts with `@`, `#`,
`<`, `+`, `!`, `type:` or `requires`, or is `global`, `globals` or
`exported` followed by a blank
(JSDoc and TypeScript pragmas, `//# sourceMappingURL`, `// +build`, Python
type comments, ESLint globals, PowerShell `#Requires`).
- A comment containing a tool or encoding marker such as `lint`, `ts-`,
`noqa`, `nosec`, `pragma`, `coding:`, `fmt:`, `istanbul`, `prettier-`,
`webpack`, `__PURE__`, `fallthrough`, `shellcheck`, `suppress`, `sonar`,
`gitleaks`, `allowlist`, `vim:`, `-*-`, `DO NOT EDIT`, `#compdef` or Go's
example `// Output:`, or a JSDoc type
(`@` together with `{`). The list is in `DIRECTIVE_WORDS`; matching is
case-insensitive and substring-based, so it over-matches prose that happens
to contain a marker, which only keeps an edit gated.
- Every Rust doc comment (`///`, `//!`, `/**`, `/*!`): doc tests compile and
run.
Suppression markers (`nosec`, `NOSONAR`, `gitleaks:allow`,
`pragma: allowlist secret`, `eslint-disable`) matter most: removing a finding
from a security scanner is not a comment edit.
## Shell scripts
Shell comments are lexed by their own rules, and the lexer gives up (the entry
is not trivial) wherever a comment could be code:
- POSIX shells: `#` starts a comment only at the start of a line or after a
space, tab or `;`; after a letter, `$`, `{`, `=` and the like it is a word
character (`a#b`, `$#`, `${#x}`, `${x#y}`). After `(`, `)`, `|`, `&`, `<` or
`>` the entry is not trivial (zsh glob flags such as `(#i)`). Single quotes
are raw and must close on the line; `$'...'` is not trivial; double quotes
may hold `\` escapes and a `${...}` without quotes, but `$(` inside them,
any backtick, any `<<` (heredocs, here-strings, shifts), a
backslash before a line break, and a lone carriage return (bash reads it
as a word character) make the entry not trivial. Only spaces and tabs
between words are folded; `\` escapes stay code.
- PowerShell: `#` and `<# ... #>` are comments only at the start of a line or
after a space, tab or `;`; elsewhere the entry is not trivial. `#Requires`
and `#!` are [directive comments](#directive-comments), as in every
language. Here-strings (`@"`, `@'`), typographic
quotes (PowerShell accepts them as quote marks), `$(` inside a double-quoted
string, a backtick before a line break, and a nested `<#` are not trivial.
- Batch: only `REM` lines (after optional blanks and `@`, followed by a blank
or the end of the line) are comments; `::` labels are code, since they
change the parser's behaviour inside blocks. A `REM` line holding `%`, `^`,
`&`, `|`, `<`, `>`, `(` or `)`, and any line ending in `^`, are not trivial.
Code lines are compared exactly: `echo` keeps its spacing.
Write is never trivial. In the hook, a trivial Edit (or a MultiEdit whose
entries for that file are all trivial) of an unchecked code, test or prose
target passes with an `additionalContext` note after prior-search credit and
before sibling collapse and the denial cap. It is not marked checked and does
not touch the denial count or ordinal, so the next change that alters code is
gated as a first touch; `trivial_allows` counts the passes (merged by maximum
like the other counters). The trivial pass never marks the target checked and
never applies to sensitive, hard-linked, instruction or config targets or to
a NotebookEdit, so the next
non-trivial change still meets the full gate.
## File context
A snippet alone cannot show whether a comment-looking line is a comment: it
may sit inside a template literal, a docstring, a raw string or a heredoc
that opens above it, follow a line that continues onto it, or lose its line
break so the next line joins it. So an Edit is trivial only when checked
against the file it applies to.
- The hook reads the current target (never a sensitive or hard-linked one) itself: the path
is resolved like every other target, opened read-only and non-blocking,
and used only if `fstat` says it is a regular file of at most 1 MiB; CRLF
is folded to LF, as the Edit tool does. A missing, unreadable, larger or
special file (directory, FIFO, device) gives no file text and the edit is
not trivial. The text is never logged, stored or put in a message.
- Each entry is applied in order, as the tool applies it, to the evolving
text. `old_string` must be non-empty and occur exactly once, or at least
once with `replace_all`; otherwise the tool would fail and the entry is not
trivial. Over 1 MiB of text after an edit, more than 8 MiB of scanning for
one call, or an edit window over 128 KiB is not trivial. A `new_string`
holding a `String.prototype.replace` pattern (`$$`, `$&`, `` $` ``, `$'`,
`$<`, `$` and a digit) is not trivial, in case the tool expands it.
- The edit window runs from the start of the line before the first
occurrence to the end of the line holding the last occurrence's end
(through the next line when `old_string` ends in a line break), so a line
that loses or gains a break is compared joined. The window before and
after the replacement is compared with the lexer rules above.
- The text before the window must end in plain code: not inside a comment,
string, template literal (including `${...}`), text block, raw string,
heredoc or here-string, and not after a line continuation (`\` in C-family,
Python and POSIX shells, a backtick in PowerShell, `^` in batch). A
per-language scanner decides; anything it cannot follow makes the edit not
trivial: a JS `/` after `}` with another `/` or quote later on the line
(regex or division), JSX in `.js`/`.jsx`/`.tsx` (`.ts`, `.mts` and `.cts`
allow `<` generics), `<!--` and `-->`, a Python f-string whose
replacement field holds its own quote, a comment or a line break, C#
raw strings and interpolation holes with quotes or braces, Kotlin `${`
inside strings, shell command substitution holding `case`, a heredoc or
a comment, a heredoc whose terminator never comes, and nesting deeper than
eight levels.
- Go files that import `"C"` are never trivial: the comment before the import
is C code.
- The window comparison runs before the prefix scan. Both must pass, so the
order changes no result, but most first-touch edits change code and fail
the window comparison, which spares them a scan of everything above the
edit.
The scanner was checked against real tokenizers over this repository: every
line start it calls plain code in 801 JS files (espree) and 146 Python files
(`tokenize`) is outside strings, templates, regex literals and comments and
not a backslash continuation.
## When a change is profiled
The profile is computed only when its result is used: for classes whose
questions depend on it (code), for Edit and MultiEdit calls on the classes
that can pass as trivial (code, test, prose), and for every call when
decision metrics are on, so the metrics lines keep their profile fields.
Instruction and config targets, and new test or prose files, skip it and the
lexer modules it needs.
## Questions from the change profile
Every question has a stable id, and `questionIdsFor(class, isWrite, profile)`
returns the ids in the order asked, so later measurement can record which
questions were asked without storing text.
| Target | Profile | Question ids |
|---|---|---|
| code Edit/MultiEdit | unknown | `importers`, `public-api`, `data-schema`, `quote-instruction` |
| code Edit/MultiEdit | public surface | `importers`, `public-api`, [`data-schema`], `quote-instruction` |
| code Edit/MultiEdit | no public surface | `local-callers`, [`data-schema`], `quote-instruction` |
| code Write | unknown | `callers`, `no-duplicate`, `data-schema`, `quote-instruction` |
| code Write | known | `callers`, `no-duplicate`, [`data-schema`], `quote-instruction` |
| instruction | any | `loader`, `behaviour-change`, `no-duplicate-instruction`, `quote-instruction` |
| test | any | `under-test`, `existing-tests`, `quote-instruction` |
| prose Write | any | `supersedes`, `linked-from`, `why-new-file`, `quote-instruction` |
| prose Edit | any | `references`, `corrects-or-adds`, `quote-instruction` |
| config | any | `config-reader`, `config-effect`, `no-plaintext-secrets`, `quote-instruction` |
`[data-schema]` is asked only when `touchesData`. With an unknown profile the
code text is byte-identical to the fixed four questions. Sensitive and
hard-linked targets and NotebookEdit calls are never profiled, so they always
get the full code questions. Instruction and
config questions carry no change-dependent item, and the test and prose items
do not depend on what the change touches, so the profile leaves those classes
alone. For a MultiEdit, the denied file's profile covers every entry for that
file (same canonical key); entries for other files do not count.
A condensed code denial names the same ids as a full one, one short phrase per
id (`condensedQuestionPhrase`), so the data and duplicate checks follow the
profile there too. The one exception is an Edit with an unknown profile, which
keeps the original hint byte for byte ("importers/callers, affected API, data
schemas if any"); a Write with an unknown profile, sensitive Writes included,
names `callers`, `no-duplicate` and `data-schema` like its full denial.
+580
View File
@@ -0,0 +1,580 @@
# GateGuard design notes
Rationale for the decisions in the GateGuard fact-forcing gate
(`scripts/hooks/gateguard-fact-force.js` and `scripts/lib/gateguard-*.js`).
Code comments point here as `docs/gateguard/design-notes.md#<anchor>`; keep
headings stable, since they are the anchors. How a change is read (questions
and trivial edits) is in [change-profile.md](change-profile.md).
## Index
- Structure: [Module layout](#module-layout), [Fail to deny](#fail-to-deny).
- Which targets are gated and how strictly:
[Exempt globs](#exempt-globs), [NotebookEdit](#notebookedit),
[MultiEdit paths](#multiedit-paths), [Sensitive targets](#sensitive-targets),
[Hard-linked targets](#hard-linked-targets),
[Target resolution](#target-resolution), [Target classes](#target-classes),
[Worktree prefix](#worktree-prefix),
[Windows name normalization](#windows-name-normalization).
- Right question: [Change profile](change-profile.md#change-profile),
[Public surface](change-profile.md#public-surface), [Data handling](change-profile.md#data-handling),
[Shell scripts](change-profile.md#shell-scripts), [File context](change-profile.md#file-context),
[Questions from the change profile](change-profile.md#questions-from-the-change-profile),
[Trivial edits](change-profile.md#trivial-edits), [Directive comments](change-profile.md#directive-comments),
[Closest search that did not count](#closest-search-that-did-not-count).
- Right time, prior-search credit: [Read is not evidence](#read-is-not-evidence),
[Turn boundaries](#turn-boundaries), [Turn identity](#turn-identity),
[Tool result pairing](#tool-result-pairing),
[Same-batch searches](#same-batch-searches),
[Ambiguous shell is not evidence](#ambiguous-shell-is-not-evidence),
[Directory changes](#directory-changes),
[Stdin is not a tree search](#stdin-is-not-a-tree-search),
[Search scope](#search-scope), [Stem matching](#stem-matching),
[Test stems](#test-stems), [Search filters](#search-filters),
[Include filters](#include-filters),
[Glob matching without RegExp](#glob-matching-without-regexp),
[Flag parsing](#flag-parsing),
[PowerShell parameter binding](#powershell-parameter-binding).
- Right time, other allowances: [New-file detection](#new-file-detection),
[Sibling collapse](#sibling-collapse), [Denial cap](#denial-cap),
[Read-only first shell command](#read-only-first-shell-command),
[Idle window](#idle-window), [Subagents](#subagents).
- State and measurement: [State file is untrusted](#state-file-is-untrusted),
[Metrics](#metrics).
## Module layout
- `scripts/hooks/gateguard-fact-force.js`: the hook. Owns the shell parser
(`quoteAwareSegments`, `commandBasename`, `SHELL_SEGMENT_SEPARATORS`) that the
destructive Bash/PowerShell detector uses, the state file I/O, the gate
messages, and `run()`. It stays one file above the 800-line guideline: the
destructive detector and the shell parser that the search evidence and the
read-only shell check are built from must not drift apart, and the gate
messages share the hook's state and deny helpers. Logic that needs neither
lives in `scripts/lib/`.
- `scripts/lib/gateguard-target-class.js`: target identity and classification
for first-touch Edit/Write/MultiEdit/NotebookEdit targets: canonical path
keys, target classes and their questions, sensitive-target and hard-link
detection and sibling-collapse eligibility. Stateless; filesystem access is
limited to worktree `.git` checks, realpath/lstat of the target's parent
chain, and lstat/stat of the target for its link count.
- `scripts/lib/file-tail.js`: the bounded tail read shared with the
strategic-compact hook's `transcript-context.js`.
- `scripts/lib/gateguard-turn-scan.js`: one bounded tail read of the Claude Code
JSONL transcript, walked back to the start of the current turn. Yields the
turn id, the turn's completed non-error search calls (newest first), their
batch ids, the turn's shell commands and its `Read` calls (used only to
explain a denial, never as evidence).
- `scripts/lib/gateguard-search-evidence.js`: decides whether a search in the
current turn covers a target (prior-search credit). Its shell-dependent part
is built by `createSearchEvidence()` from the hook's shell parser, so the
parser the destructive detector relies on is shared, not copied or moved.
- `scripts/lib/gateguard-search-filters.js`: the include and exclusion
filters of a search (glob, `--include`/`--exclude`, `find` name tests,
PowerShell `-Filter`/`-Include`/`-Exclude`) and whether they admit a target.
Pure; used by the search evidence.
- `scripts/lib/gateguard-state.js`: pure helpers for the session-state fields
(counters, per-class counts, sibling dir gates). Reading and writing the
state file, and the one-write event helpers built on it, stay in the hook.
- `scripts/lib/gateguard-change-profile.js`: pure analysis of the change text
of an Edit/Write/MultiEdit into a bounded change profile (see
[Change profile](change-profile.md#change-profile)). It uses
`gateguard-code-lexer.js`, which reduces a snippet to its code lines, and
`gateguard-file-context.js`, which checks that an edit's window of the file
starts outside any string, comment or heredoc.
- `scripts/lib/gateguard-readonly-shell.js`: decides whether a Bash or
PowerShell command is allowlisted read-only introspection (see
[Read-only first shell command](#read-only-first-shell-command)). Built by
`createReadOnlyShell()` from the hook's `quoteAwareSegments`, like the
search evidence.
- `scripts/lib/gateguard-metrics.js`: builds, validates and appends the opt-in
decision metrics lines (see [Metrics](#metrics)). `scripts/gateguard-report.js`
reads them back.
## Lazy loading
Most hook calls are shell commands and edits of files already checked in the
session; neither needs the target classification beyond the path key, the
transcript scan, the search matcher or the change profile. The hook requires
those modules on first use, so a shell command loads only the shell parser and
the state helpers, as on `main`. Every Node process runs one hook call, so a
module that is loaded is also compiled on that call: the cost of a
first-touch edit follows how much code it runs, not how much the files hold.
`module.exports` lists `run` first because `run-with-flags.js` recognises a
`run()` hook by a lexical match on the export object.
## Fail to deny
Every allowance other than a retry of an already-gated target (prior-search
credit, the trivial-edit pass, sibling collapse, the denial cap, the subagent
bypass, the read-only first shell command) is an exception to the first-touch
or routine denial, so each one falls back to the denial on any doubt: an
unreadable transcript, a parse failure, an unresolvable path, a stat error on
the target, or an exception. The lib
exports that feed those decisions never throw (they catch and return a
fallback that denies, or no scan at all), so an error cannot escape into credit or
sibling logic. Allowances are never an `allow` permission decision: credit,
trivial and sibling passes return `additionalContext`, and the cap pass returns
the input unchanged.
## Read is not evidence
`Read` never earns prior-search credit. Claude Code already requires a Read of
a file before an Edit of it, so crediting Read would switch the Edit gate off.
Only `Glob`, `Grep`, `LS` and shell search commands count.
## Sensitive targets
Secrets, keys, auth/payment code, migrations and CI workflows always draw the
first-touch denial with the full questions: prior-search credit, the change
profile, the trivial-edit pass, sibling collapse and the denial cap do not
apply to them, and the denial says so (its note names the three older
exceptions).
- Basename, extension and segment rules are exact, never substrings.
- `.github/workflows/` is also matched below an ancestor (an unverified
worktree, or a target outside the project root).
- A target is sensitive on its lexical path or on its real (symlink-resolved)
location, so `src/tools -> ../auth` cannot launder `src/tools/login.py`.
- A real location that cannot be resolved (dangling symlink, `ENOTDIR`,
`EACCES`, loops), a non-string path, or any error counts as sensitive.
## Hard-linked targets
A file with more than one hard link can be changed through any of its names,
and the other names may be sensitive (`vendor/token.js` linked to
`src/auth/token.js`), belong to another class, or live outside the project.
Neither the lexical path nor `realpath` reveals them, and finding every name
would mean walking the filesystem. So the allowances trust no name of such a
file: a target whose `lstat` (or, for a symlink, `stat`) reports
`nlink > 1` gets no prior-search credit, change profile, trivial-edit pass,
sibling collapse, denial cap or subagent bypass, exactly as a sensitive target.
- Directories are skipped (their link count counts subdirectories).
- A missing file (`ENOENT`, `ENOTDIR`) is a new file with one name, so a Write
that creates one keeps its allowances; any other stat error, an unresolvable
target, or a non-string path counts as hard-linked. The stat is only used for
this allowance decision; it never turns an allowed retry into a denial.
- A Windows-style path on a POSIX host (or the reverse) has no file to stat and
is judged by its path alone, as for the real-path checks.
- The denial names the rule ("Hard-linked target: …") rather than calling the
file sensitive; a target that is both gets the sensitive note. Metrics record
the reason `hard-linked` (or `subagent-hard-linked`) with `sensitive: false`.
- The check runs at decision time; a link created after the first touch is not
seen, which is the same limit as for sensitive real paths.
## Exempt globs
`GATEGUARD_EXEMPT_GLOBS` is an operator decision and stays authoritative: an
exempt target passes before the subagent, checked, sensitive and hard-link
rules are consulted, so a glob that covers `src/auth/**` or a hard-linked file
turns its check off. The alternative (sensitive rules overriding exemptions)
would make a documented control silently ineffective for the paths an
operator is most likely to be deliberate about, and would change behaviour
for existing configurations. The precedence is documented in the skill's
order of checks, and the table row warns that exemptions apply to sensitive
and hard-linked targets too.
## NotebookEdit
NotebookEdit writes a `.ipynb` file named by `tool_input.notebook_path` (not
`file_path`), so it is routed to the edit-write gate by adding it to that
entry's matcher only; the other PreToolUse entries are unchanged. The hook
treats it as an Edit of `notebook_path`: the same canonical checked key (an
Edit and a NotebookEdit of one notebook share a first touch), class by path
(`.ipynb` falls through to code; under `tests/` it is a test), and the same
sensitive, hard-link, credit, cap, subagent and exempt rules. A `file_path`
field on the call is ignored. The change profile only understands source text,
not a cell edit, so a NotebookEdit always gets the full questions and is never
a trivial pass. It is never a new-file Write, so it never opens or joins a
sibling gate.
## Target resolution
Relative targets resolve against the tool's `cwd` first, then
`CLAUDE_PROJECT_DIR`, then the process cwd. This deliberately differs from
`GATEGUARD_EXEMPT_GLOBS`, whose globs are project-relative (`CLAUDE_PROJECT_DIR`
first). The canonical checked-state key folds `a.py`, `./a.py` and the absolute
path to one gate; Windows-style paths also fold separators and case.
A target is classified by its project-relative path, so an ancestor such as
`/home/me/tests/proj` never leaks into the class; targets outside the root keep
their absolute path. A Windows-style path has no real location on a POSIX host
(and vice versa), so real-path checks are skipped for it.
## Target classes
The first matching class wins: instruction, test, prose, config, code (the
fallback). Cursor `.mdc` rule files are instructions wherever they live. A
basename starting with `.env` is config unless it has a code extension
(`.env.example.ts` is source). Code targets ask the original four questions,
narrowed by the change profile (see
[Questions from the change profile](change-profile.md#questions-from-the-change-profile)); other
classes get class-specific questions and a class-specific condensed hint.
## Worktree prefix
A `.claude/worktrees/<name>/` prefix is stripped only when that worktree is real
(its `.git` exists). Otherwise the path stays under `.claude`, which is an
instruction directory and blocks sibling collapse.
## Windows name normalization
Windows ignores trailing dots and spaces in a name and treats `name:stream` as
`name`, so segments are normalized the same way before classification:
`CLAUDE.md.` and `CLAUDE.md::$DATA` both name `CLAUDE.md`.
## Turn boundaries
The scan walks the transcript tail backwards to the latest human message or
compaction. Only a user record whose content is a non-empty array of
`tool_result` blocks is a tool result; any other user record is a turn
boundary, which can only shrink the credit window. A compaction summary or
`compact_boundary` record discards the earlier turn and starts a new one. The
tail read is bounded (bytes and lines) to keep the hook fast.
## Turn identity
Claude Code stamps every user record of a turn with the same `promptId`, so it
identifies the turn even after the boundary has scrolled out of the tail
window; without one, the boundary record's `uuid` (or a hash of its line) is
used. When no boundary is found, a clipped window is the current turn only if
its `promptId`s agree; two or more mean an unrecognised turn start, and an
unclipped file has no turn at all.
## Tool result pairing
A search counts only when it completed without error. A tool-use id that occurs
more than once in the window cannot be tied to one result, so it never counts.
## Same-batch searches
Searches in the pending call's own assistant message never count: their results
were not seen when the edit was decided. The batch is located by the pending
`tool_use_id`, or else taken to be the newest assistant message.
## Ambiguous shell is not evidence
Command substitution, process substitution, heredocs and PowerShell backtick
escapes make the searched text ambiguous, so a shell command containing any of
them yields no evidence. Commands over 8192 characters yield none either.
## Directory changes
A `cd` (or its PowerShell equivalents) in any other shell call of the turn
leaves the cwd of every shell search unknown: shell searches then give no
directory evidence, and their relative operands scope nothing. Within one command, a plain literal `cd <dir>` rebases
later relative operands; any other directory change makes them unresolvable.
Operands that are variables, `~`, or relative to an unknown cwd scope nothing.
## Stdin is not a tree search
A search segment that reads stdin (input redirection, a pipe into it, or no
path operand without recursion) searched piped text, not the tree, so it gives
no evidence. A quoted `<` cannot be told from a redirection and is treated as
one. `||` also marks the next segment as piped, which only ever removes
evidence.
## Search scope
Each search offers stem texts, candidate directories, the scope the target must
lie in, and path operands. The target must lie inside the scope. A search whose
operands are all the target itself read the target rather than searched for it,
and gives nothing. A `Glob` without glob characters is a single-file lookup: it
names no directory. A `Glob` with an empty literal prefix and no explicit path
names only the implicit cwd, so it gives no directory credit. Directory
evidence from shell operands must name a directory that exists now.
PowerShell `-Path`/`-LiteralPath` values count as operands but never as
directory evidence.
## Search prefilter
Before any search is parsed, the turn's searches are checked for the target's
stem as plain text, with quotes and backslashes removed so that quoting
cannot split the stem. A search whose input does not contain the stem cannot
credit the target (credit needs the stem as a word in the parsed search), so
it is skipped; when no search passes, the search-evidence modules are not
loaded at all. The hook-side check strips test prefixes and suffixes
(`test_`, `_test`, `.test`, `.spec`) so it never skips a search the full
match would accept. New-file creations, which can also be credited by a
search that names their directory, bypass the prefilter. Parsed evidence is
cached per search, so the credit check and the closest-miss note share one
parse.
## Stem matching
A target's stem (basename without extension) credits it only when it has at
least four characters and is not generic (`index`, `utils`, `config`, ...).
Matching is a word-boundary `indexOf` scan; a `RegExp` is never built from
transcript text.
## Test stems
A test is named after the module it exercises, and a search for that module
(`rg tokenizer src tests`) is the search that finds its tests. So for a target
of the test class, one test affix is stripped before the stem rules apply:
a `.test` or `.spec` suffix, a `test_` prefix on `.py`, and a `_test` suffix
on `.py` and `.go`, the same affixes that make a file a test. The result
still has to be at least four characters, not generic (`index.test.js` gives
`index`, which never credits), a whole word in the search, and in the
search's scope; exclusions that mention the stripped stem cover the target.
Only one affix is stripped, and other classes keep the full stem (a
`tokenizer.test.md` under `skills/` is an instruction file). The full stem
(`tokenizer.test`) still matches as a word, since `.` is a word boundary.
## Search filters
A search that excluded the target never saw it:
- exclusion values, and the flags that introduce them, are never a stem source;
- an exclusion that names the stem, or matches the file or any directory above
it, blocks credit;
- unreadable exclusion lists (`--exclude-from`, `--ignore-file`,
`git ls-files -X`) and more than 32 exclusions block credit;
- an exclusion glob past the matching bounds, or malformed, counts as covering
the target.
find: a negated or pruned name test is an exclusion; a plain `-name`/`-iname` is
an include. `--ignore`/`--hide` exclude names only for `ls` (rg's `--ignore` is
a switch).
## Include filters
Include globs (`--include`, `-g`, `--glob`, `-name`, `tree -P`, PowerShell
`-Filter`/`-Include`, the `Grep` tool's `glob`) that all miss the target stop
its stem from crediting. A basename glob is matched against the target's file
name. A glob with a directory part is matched against the target's path
relative to the search root only for ripgrep and the `Grep` tool, where a
leading `**/` lets it start at any directory; the other tools match names only,
so such a glob admits nothing. An include glob that is malformed, past the
matching bounds, or one of more than 32 filters admits nothing.
## Glob matching without RegExp
Filter globs are matched by dynamic programming over glob tokens, never by a
`RegExp`, so a hostile glob cannot cause regex backtracking. Globs are bounded
to 256 characters and 32 brace alternatives. Bracket classes support members,
ranges, `!`/`^` negation and a leading literal `]`; an unclosed class is
malformed. An unbalanced `{` is literal.
## Flag parsing
Only path operands can scope a search:
- flags that consume a value are skipped with it; single-dash clusters
(`-rne PAT`) are read letter by letter, and a value flag ends the cluster;
- for grep-family tools, `rg`, `git grep`, `Select-String` and `fd`, the first
positional is the pattern unless `-e`/`-f` (grep family, `rg`, `git grep`
only; fd's `-e` is an extension) supplied it;
- fd's `-E/--exclude` takes a value; rg's `-r/--replace` takes a value.
## PowerShell parameter binding
PowerShell binds a parameter by any unambiguous prefix, by `-Name:value`, and by
comma lists across arguments, so `-Exclude` has many spellings. A parameter that
cannot be resolved may be an exclusion in disguise: neither it nor a value it
may carry is a stem source.
## New-file detection
A Write target is new only when `lstat` reports `ENOENT`; any other error counts
as an existing file. Only a new file may earn directory credit or sibling
collapse.
## Sibling collapse
A new file whose directory had a sibling gated moments ago passes with a note
instead of a repeat denial.
- Only code, test and prose targets collapse. Harness and tooling directories
are dot-directories, so any dot segment blocks collapse, as do 8.3 short names
(`CLAUDE~1`), which can alias any directory.
- The gate is keyed by the real directory. A symlinked directory must keep the
class and pass the same screen, and must not be sensitive, so
`src/tools -> .claude/hooks` never collapses.
- A gate opens for the same turn id; without a turn id on either side, for 120 s.
The 120 s rule applies only when there is no transcript at all; an unusable
transcript never collapses.
- A gate stamped in the future, or not created by a denial, is ignored.
## Denial cap
`GATEGUARD_FACT_FORCE_MAX_DENIALS` is validated whole (digits only, a safe
integer), not with `parseInt`: a prefix parse would turn `3oops` into a cap. A
malformed value leaves the gate uncapped and says so once on stderr. Once the
session's denials reach the cap, a first touch passes with the input unchanged
and is counted in `cap_allows`. The cap check reads the state the same write
persists, so a lost concurrent update can add a denial but never passes a
target early. Sensitive and hard-linked targets are never capped.
## State file is untrusted
The session state file can be edited or corrupted, so every field is validated
on read: counters clamp to non-negative integers, maps are null-prototype and
reject `__proto__`, `constructor` and `prototype` keys, and malformed or
old-shape entries are dropped. `dir_gates` maps `<class>\u0000<canonicalDir>` to
`{ turn, at, first, ordinal }` and keeps the 50 newest entries. Concurrent
writers are merged on save (checked keys by union, counts by maximum, gates by
newest `at`). Marking a target checked and recording its event happen in one
state write, so an event is never half-recorded.
## MultiEdit paths
A MultiEdit call names its file once, in `tool_input.file_path`, and its
`edits` entries carry only `old_string`, `new_string` and `replace_all`. An
entry without a `file_path` of its own is gated as the call's `file_path`
(at top level and in subagents), so the call meets the same first-touch,
sensitive-target and trivial-edit rules as an Edit of that file. Entries that
name their own path keep it. Non-object entries are skipped; a non-array
`edits` is treated as empty.
## Closest search that did not count
When an ordinary (not sensitive or hard-linked) first touch is denied and the
turn holds a search (or a
`Read`, or a non-search shell command) that mentions the target but did not
credit it, the denial adds one line naming the closest one, so the agent does
the missing step instead of restating facts:
`Closest search this turn did not count (<tool> <detail>): <why>.` It reuses
the single cached turn scan; no transcript is read again, and nothing is
computed on the credit or allow paths.
A search mentions the target when its stem matches (or, for a generic or short
stem, the bare name of at least three characters appears as a word) or, for a
new-file Write, when it names the target's directory. Reason codes, from
closest to farthest (the closest wins, then the newest):
| Code | Meaning |
|---|---|
| `same-batch` | it would have credited, but was sent in the pending call's own batch |
| `excluded` | an exclusion covers the target, or include globs miss it |
| `out-of-scope` | its path or operands do not contain the target |
| `stdin-only` | a search segment that read piped input or had no tree operand |
| `not-a-search` | a `Read` of the target, or a non-search shell segment naming it |
| `generic-stem` | the target's name is too generic or short to match any search |
`previous-turn` is not reported: the scan stops at the turn boundary, and
reading past it would cost a second, larger scan. Ambiguous shell commands
(substitutions, heredocs, over 8192 characters) give no line. The tool name
comes from a fixed set; the detail is the tool input, sanitized like a path,
whitespace-folded and cut to 60 characters. Sensitive and hard-linked targets
never get the line, since no search could have credited them. In a condensed denial the line
follows the batch warning.
## Read-only first shell command
The routine shell gate asks for the user's request once per session, before
the first Bash or PowerShell command. Sessions usually open with `ls`,
`git status` or a search, which the question adds nothing to, so a command
that is only read-only introspection passes without using the gate up. The
next command that is not read-only still draws the routine denial, so the
question still comes before the first command that can change something.
- Destructive detection runs first and is unchanged; the check sits after it
and after `GATEGUARD_BASH_ROUTINE_DISABLED`, inside the routine gate, and is
skipped once the gate is checked. A pass returns the input unchanged,
never marks the routine gate checked, and adds one to
`routine_readonly_passes`.
- Character screen, quote-aware: any non-ASCII or control character (other
than tab), an unterminated quote, or a lone `&` rejects. Bash rejects
backslashes anywhere, `` ` `` and `$` outside single quotes, and unquoted
`<`, `>`, `(`, `)`, `{`, `}`, so redirection, here-documents, command,
process and parameter substitution never pass. PowerShell rejects `` ` ``
and `$` outside single quotes and unquoted `<`, `>`, `(`, `)`, `{`, `}`,
`@`, `[`, `]`. Non-ASCII is rejected because PowerShell treats typographic
quotes as quotes, which the segmenter does not.
- The screened command is split by the hook's quote-aware segmenter on `;`,
`|`, `&&` and `||`. PowerShell backslashes are doubled first, since they are
literal there. Every segment must pass.
- Each segment's first word must be an exact allowlisted name with no `=`,
path separator or glob character, so assignments, wrappers (`env`, `sudo`,
`sh -c`, `xargs`), paths and unknown programs reject. Bash: `ls`, `pwd`,
`cat`, `head`, `tail`, `wc`, `grep`, `rg`, `find`, `fd`, `tree`, `git`.
PowerShell (case-insensitive): `Get-ChildItem`, `Get-Content`,
`Select-String`, `Get-Location` and their aliases, `rg`, `git`; `--%`
rejects.
- Options that execute or write reject: `find -exec*`/`-ok*`/`-delete`/
`-fprint*`/`-fls`, `fd -x`/`-X`/`--exec*` (also inside a short cluster),
`rg --pre*`/`--hostname-bin`/`--search-zip`/`-z` (`-z` runs decompressors
found on `PATH`, which older ripgrep on Windows also looked up in the
working directory), `tree -o`/`-R` (also inside a short cluster).
- `git` takes no global options. The subcommand must be `status`, `log`,
`diff`, `show`, `ls-files`, `rev-parse` or `branch`, and every option must
be in that subcommand's allowlist, so `--output` (and its abbreviations),
`--ext-diff` and `--textconv` reject. `branch` takes no positional
argument, so it only lists.
- Over 4096 characters, a non-string command, or any exception rejects.
The earlier read-only git allowlist, which runs before destructive detection,
is unchanged.
## Idle window
Session state expires after an idle window. A state key from a real session
id (`session_id`, `CLAUDE_SESSION_ID`, `ECC_SESSION_ID`) or a transcript path
names one conversation, so its window is 8 hours: a break in a long
conversation does not re-ask the checks it already answered. The project
fingerprint fallback (`proj-` keys) can be shared by unrelated sessions in the
same directory, so it keeps 30 minutes.
Module-load pruning removes a state file idle for twice its key's window,
judged by file name: `state-proj-*` uses 30 minutes, any other key 8 hours.
A session id that itself starts with `proj-` gets the short window for both
expiry and pruning, which can only expire state sooner. Temporary files from interrupted writes
are never loaded and always use the short window.
## Subagents
File gates skip subagent calls (a non-empty string `agent_id`, `agentId`,
`parent_tool_use_id` or `parentToolUseId`): the parent was already gated on
the work it delegated, and a subagent writing many files would otherwise draw
one denial per file. Shell gates were never skipped, so a subagent can
already receive a denial, present facts and retry.
Sensitive and hard-linked targets are the exception. A subagent Edit, Write,
NotebookEdit or MultiEdit entry on such a target is denied once per path with
the full denial and its note, unless the parent already gated that path. Prior-search credit, the
change profile, sibling collapse and the denial cap never apply, as at top
level. The subagent's denial marks a separate subagent key, not the file's
key, so a subagent's retry never unlocks the path for the parent, whose own
first touch is still gated. The keys share the parent's state file when the
subagent's call carries the parent's session id. Exempt globs and Claude
settings files are skipped first, as at top level.
## Metrics
Decision metrics exist to show whether the gate asks the right question at the
right time: how often it denies, how often prior-search credit, sibling
collapse, trivial edits and the cap remove a denial, which questions it asks,
and how often a denial followed a search that nearly counted.
- **Opt-in.** Nothing is written unless `GATEGUARD_METRICS` is one of the
truthy spellings the other GateGuard switches accept. The variable is read
on every call, so it can be turned on for part of a session.
- **Codes, never content.** A line holds the schema version, time, a 12-hex
sha256 prefix of the resolved session key (not the key itself), the tool,
the target class, the decision, a reason code, question ids, the sensitive
flag and the change-profile flags. Class, reason, question ids and language
must match a short lowercase code pattern or are written as `null`, so a
path, command, file content or transcript text cannot reach the file even
if a caller passed one by mistake. The session digest is one-way but not
secret: anyone holding the session id can link it to its lines.
- **One write per call.** Decisions are collected while `run()` decides and
appended in one `appendFileSync` after it returns, including when it throws.
A MultiEdit call writes one line per distinct file it decided. Lazily
computed class and sensitivity for passes are only computed when metrics
are on.
- **Never changes a decision.** Every metrics error (unwritable directory,
a directory in place of the file, a failed rotation) is ignored, and the
gate's result is returned unchanged.
- **No symlinks.** The file is checked with `lstat` and opened with
`O_NOFOLLOW`; a symlink or other non-regular file in its place is left
alone and nothing is written, so a shared state directory cannot redirect
the append or the rotation onto another file.
- **Bounded.** When an append would take `metrics.jsonl` past 1 MiB it is
renamed to `metrics.jsonl.1`, replacing the previous rotation, so the two
files together stay near 2 MiB. Concurrent hooks may interleave lines or
lose one rotation's worth of lines; the report skips anything malformed.
- **Shell denials carry no question ids.** The destructive and routine shell
gates ask fixed questions that have no ids, so their lines have
`questions: null`, as do all passes.
+338
View File
@@ -0,0 +1,338 @@
# GateGuard evaluation
How to tell whether a change to the fact-forcing gate
(`scripts/hooks/gateguard-fact-force.js`) improves it: ask the right question
at the right time, deny less where a denial repeats work already done, and
never let a security-critical target through.
`scripts/dev/gateguard-eval.js` replays a corpus of realistic sessions against
the working-tree hook and against the hook at one or more git refs, and
compares the decisions. `tests/hooks/gateguard-scenarios.test.js` replays the
same corpus against the working tree only and fails if any step's decision
changes.
## What is measured
Every step of a scenario is one hook call. The corpus author labels each step
with the decision the gate should make and why it matters:
| Label | Meaning |
|---|---|
| `expect` | `deny` or `allow` (allow covers prior-search credit, sibling collapse, trivial edits, read-only shell commands and plain passes) |
| `mustDeny` | Security-critical: sensitive targets, bypass attempts, mutating or destructive first commands. Any allow is a bypass. |
| `redundant` | A denial here would repeat investigation the session already did (a scoped search that names the file, a same-turn sibling, a comment-only edit, a read-only first command). |
| `relevantQuestions` | For code targets, the question ids the change genuinely warrants (see the question table in [design-notes.md](change-profile.md#questions-from-the-change-profile)). |
Metrics per hook:
- **Denials**: every denial costs the agent a round trip and context.
- **Redundant denials**: denials on steps labelled `redundant`.
- **Must-deny bypasses**: allows on steps labelled `mustDeny`. Must be 0 for
the working tree.
- **Expectation mismatches**: decisions that differ from `expect`. Must be 0
for the working tree; for a baseline they are the behaviour the branch
changes.
- **Irrelevant questions asked**: for denials of code targets, asked question
ids that are not in `relevantQuestions`. Questions are read from the
numbered list of a full denial, or from the phrases of a condensed one-line
denial (issued after the first three denials of a session): the working
tree's per-question phrases (`condensedQuestionPhrase`) and the fixed hint
older hooks use. The condensed rows count the ones asked in condensed
denials only.
- **Warranted questions not asked**: `relevantQuestions` ids a code denial did
not ask. Asking fewer questions must not drop the ones that matter.
- **Estimated denial tokens**: characters of all denial reasons divided by 4.
- **Allows with a note**: how the working tree avoided a denial.
- **Hook latency, fresh process**: `require()` of the hook plus `run()`, timed
inside a new Node process per step, which is how Claude Code runs a hook.
Node start-up itself is excluded; it is the same for every hook. See
[Latency](#latency).
- **run() latency, warm**: wall time of `run()` in the scenario's worker, after
the hook module is loaded. Modules the hook loads lazily count on the step
that first needs them.
The latency rows are the only non-deterministic metrics.
Each scenario runs in its own temp project (files, symlinks and hard links
created from the fixture), with its own transcript, `GATEGUARD_STATE_DIR`, `HOME` and a
clean environment (`PATH`, `CLAUDE_PROJECT_DIR`, plus the scenario's `env`),
in a fresh worker thread, so module state and state files never leak between
scenarios or hooks. A baseline hook is materialised from `git show
<ref>:scripts/hooks/gateguard-fact-force.js` plus every file it requires
relatively at that ref, into a temp tree that mirrors the repo layout.
## Results
Working tree = branch `gateguard-full`; `upstream/main` = `c70874fa`;
`4b02f669` = the pull request head before this round. Node 22, Linux.
```text
Corpus: 20 scenarios, 184 steps.
```
| Metric | working tree | upstream/main | 4b02f669 |
| --- | ---: | ---: | ---: |
| Steps | 184 | 184 | 184 |
| Denials | 132 | 157 | 132 |
| Redundant denials | 0 | 38 | 0 |
| Must-deny bypasses | 0 | 8 | 0 |
| Expectation mismatches | 0 | 55 | 0 |
| Irrelevant questions asked | 2 | 187 | 2 |
| Irrelevant questions in condensed denials | 2 | 111 | 2 |
| Warranted questions not asked | 1 | 57 | 1 |
| Estimated denial tokens | 25633 | 27796 | 25633 |
| Allows with a credit note | 12 | 0 | 12 |
| Allows with a sibling note | 11 | 0 | 11 |
| Allows with a trivial-edit note | 5 | 0 | 5 |
| Hook latency p50, fresh process (ms) | 23.68 | 11.58 | 27.05 |
| Hook latency p90, fresh process (ms) | 30.94 | 14.29 | 33.61 |
| Hook latency p95, fresh process (ms) | 33.16 | 16.57 | 38.57 |
| run() latency p50, warm (ms) | 1.72 | 0.90 | 2.02 |
| run() latency p90, warm (ms) | 6.62 | 2.33 | 6.93 |
| run() latency p95, warm (ms) | 11.84 | 2.63 | 8.98 |
| Scenario | Steps | Denials: working tree | Denials: upstream/main | Denials: 4b02f669 |
| --- | ---: | ---: | ---: | ---: |
| docs-heavy-session | 14 | 6 | 12 | 6 |
| scaffold-module | 10 | 3 | 10 | 3 |
| bugfix-after-scoped-search | 6 | 1 | 3 | 1 |
| cold-writes | 5 | 3 | 4 | 3 |
| exported-api-edits | 6 | 6 | 6 | 6 |
| internal-only-edits | 5 | 4 | 4 | 4 |
| data-handling-edits | 5 | 5 | 5 | 5 |
| comment-only-edits | 10 | 6 | 9 | 6 |
| sensitive-targets | 8 | 8 | 8 | 8 |
| subagent-edits | 6 | 3 | 1 | 3 |
| first-shell-commands | 20 | 11 | 17 | 11 |
| windows-paths | 8 | 5 | 7 | 5 |
| bypass-search-filters | 19 | 15 | 19 | 15 |
| bypass-turn-and-batch | 7 | 7 | 7 | 7 |
| bypass-siblings | 11 | 11 | 11 | 11 |
| cap-with-sensitive | 6 | 4 | 6 | 4 |
| bypass-comment-context | 22 | 21 | 19 | 21 |
| exported-members | 6 | 6 | 6 | 6 |
| notebook-edits | 6 | 4 | 0 | 4 |
| hard-linked-targets | 4 | 3 | 3 | 3 |
### Reading the results
Against `upstream/main`:
- 16% fewer denials (157 to 132) and 8% fewer denial tokens, with every
redundant denial in the corpus gone (38 to 0).
- All eight must-deny bypasses closed: `upstream/main` lets a subagent edit
`src/auth/oauth.js`, `config/secrets.yaml` and a hard-linked file without a
question, never gates a MultiEdit call that names its file in
`tool_input.file_path` (the tool's own shape), so `.env`,
`config/.env.local` from a subagent and a first-touch code file all pass,
and never gates NotebookEdit, so notebooks under `auth/` and `payments/`
pass from the parent and from a subagent.
- Irrelevant questions drop from 187 to 2 and warranted-but-unasked from 57
to 1: edits without a public-surface line ask for local call sites instead
of importers, members of exported interfaces, enums, export lists,
dataclasses and `pub` enums keep the importer questions, the data-schema
question is asked only when the change touches data, and condensed denials
name the same questions as full ones.
- Denials rise only where they should: the subagent's first touch of a
sensitive or hard-linked file, MultiEdit calls, NotebookEdit calls, the first
mutating shell command after a read-only one (`upstream/main` spends its
once-per-session routine gate on `ls`), and the first code-changing edit
after a comment-only one (the comment edit no longer spends the file's first
touch).
Against `4b02f669` (the pull request head before this round): the same
decisions, questions and notes on every step; only latency changes (see
[Latency](#latency)). `bypass-search-filters` includes a search whose
directory-qualified include (`rg -g 'src/**' sweep .`) keeps the target in
`lib/` out of the search; it must be denied, and the control that searches
the target's own directory is credited.
Four scenarios come from security reviews of this change.
`bypass-comment-context` holds 21 must-deny steps: comment-looking edits that
change code in their file (a comment line after a continued C macro, a line
break dropped so the next line joins a comment, lines inside a template
literal, a docstring and a heredoc, a snippet that starts inside a string,
`replace_all` reaching a string), directive comments (shebang, encoding
cookie, `# type:`, `# nosec`, `@ts-expect-error`, `eslint-disable`,
`//go:build`, `//go:embed`, a cgo preamble, a Rust doc test), MultiEdit calls
naming their file once, and `rg -z` as a first shell command; plus one
comment edit below closed templates and regexes that should still pass.
Before the fixes the branch let 20 of those 21 through (every edit and
`rg -z` as a trivial or read-only pass, and the three MultiEdit calls);
`upstream/main` lets the three MultiEdit calls through.
`exported-members` holds six member edits whose container declaration sits
outside the snippet; before the fixes the branch asked for local call sites
on the five public ones (5 irrelevant questions, 10 warranted ones not asked).
`notebook-edits` covers a cold NotebookEdit, its retry, a credited test
notebook, a comment-only cell and sensitive notebooks in the parent and a
subagent; every earlier hook allows all six calls. `hard-linked-targets`
edits a second name of a file after a search that names it, makes a
comment-only change to another linked file and edits a linked file from a
subagent, with a single-link control; before the fix the branch allowed all
three (credit, trivial pass, subagent bypass).
What the working tree still gets wrong, by the corpus's own labels:
- Sensitive targets are never profiled, so a sensitive code Write always asks
the data-schema question (`symlinked-auth-first`, `symlinked-auth-sibling`:
the 2 irrelevant questions).
- NotebookEdit and hard-linked targets always get the full questions; the
corpus labels all four as warranted for them, so this costs nothing in the
table, but a notebook cell that handles no data still gets the data-schema
question.
- The C edit that turns a declaration into a comment continuation
(`c-comment-continuation`) has no data words, so the condensed hint no
longer mentions data schemas; the corpus labels that question as warranted
(the 1 unasked one).
- Latency: a first touch costs more than on `upstream/main` because it reads
the transcript, matches the searches that name the target, reads the target
file and profiles the change (fresh-process p50 23.7 ms against 11.6 ms,
p90 30.9 ms against 14.3 ms on this corpus, where every step is a first
touch or a gate). The warm p95 is higher than `4b02f669`'s because modules
now load on the first step that needs them instead of before the timer
starts; the fresh-process rows count both. See [Latency](#latency) for
each call type with confidence intervals.
## Reproduce
```bash
git fetch upstream main
node scripts/dev/gateguard-eval.js --markdown
node scripts/dev/gateguard-eval.js --markdown --baseline upstream/main --baseline 4b02f669
node scripts/dev/gateguard-eval.js --json > gateguard-eval.json
node scripts/dev/gateguard-eval.js --baseline upstream/main --sarif gateguard-eval.sarif
node scripts/dev/gateguard-latency.js --baseline upstream/main --runs 40
node tests/hooks/gateguard-scenarios.test.js
```
`--baseline <ref>` takes any ref and can be repeated (default
`upstream/main`); pass the pull request head (`4b02f669` above) to compare
against it. `--corpus <dir>` points at another
scenario directory. The script exits non-zero when the working tree has a
mismatch, a must-deny bypass, an explicit `allow` decision or a thrown error.
`--sarif <file>` also writes those working-tree failures as SARIF 2.1.0
(bypasses, explicit allows and errors as `error`, other mismatches as
`warning`) with each hook's totals in the run properties. Timing is left out,
so the file is identical across reruns of the same tree and corpus.
## Latency
The fresh-process pass replays every scenario again, with a new state
directory and transcript, and runs each step with
`node -e <probe> <hook> <payload>`. Its decisions must match the worker's;
a step decided differently fails the run. `--no-cold` skips the pass.
Per call type, `scripts/dev/gateguard-latency.js` runs each hook in a new
Node process per sample, interleaving the hooks in a shuffled order every
round so machine load affects them alike, and reports p50 and p90 with 95%
percentile-bootstrap intervals for the difference from each baseline. A
change counts as faster or slower only when its interval excludes zero. The
fixture is a 20-search turn, a 50-function file, a 406 KiB file and a
`CLAUDE.md`; Node 22 on Linux, 2 cores.
40 fresh processes per call type and hook, interleaved; require() + run() in ms; 95% bootstrap intervals of the working tree minus the baseline.
| Call | working tree p50 / p90 | 4b02f669 p50 / p90 | upstream/main p50 / p90 |
| --- | ---: | ---: | ---: |
| Shell command, first of session | 16.9 / 19.9 | 25.7 / 33.4 | 14.5 / 18.0 |
| Shell command, routine gate passed | 15.6 / 19.7 | 24.3 / 27.0 | 13.8 / 17.4 |
| Edit of a file already checked | 14.1 / 19.2 | 22.9 / 28.8 | 11.6 / 15.3 |
| First edit, code, denied | 25.7 / 32.0 | 37.3 / 44.3 | 12.4 / 13.9 |
| First edit, comment-only pass | 29.5 / 35.2 | 42.7 / 47.9 | 12.5 / 14.5 |
| First edit, CLAUDE.md, denied | 21.4 / 24.4 | 35.1 / 41.8 | 12.3 / 16.0 |
| First edit, 406 KiB file, denied | 27.9 / 34.5 | 59.0 / 67.5 | 12.2 / 16.6 |
| Call | p50 change vs 4b02f669 | p90 change vs 4b02f669 |
| --- | --- | --- |
| Shell command, first of session | [-10.5, -7.8] faster | [-16.0, -6.2] faster |
| Shell command, routine gate passed | [-9.6, -7.7] faster | [-11.5, -5.3] faster |
| Edit of a file already checked | [-10.7, -7.0] faster | [-16.1, -6.5] faster |
| First edit, code, denied | [-13.6, -10.0] faster | [-19.1, -6.5] faster |
| First edit, comment-only pass | [-15.4, -10.9] faster | [-19.7, -4.6] faster |
| First edit, CLAUDE.md, denied | [-16.4, -11.6] faster | [-25.3, -14.4] faster |
| First edit, 406 KiB file, denied | [-32.5, -29.3] faster | [-41.7, -25.9] faster |
| Call | p50 change vs upstream/main | p90 change vs upstream/main |
| --- | --- | --- |
| Shell command, first of session | [2.0, 3.0] slower | [-0.6, 7.4] no measurable change |
| Shell command, routine gate passed | [1.3, 2.2] slower | [-2.1, 4.7] no measurable change |
| Edit of a file already checked | [2.0, 3.0] slower | [-0.6, 6.8] no measurable change |
| First edit, code, denied | [12.2, 14.7] slower | [14.3, 21.5] slower |
| First edit, comment-only pass | [15.5, 17.9] slower | [16.0, 28.6] slower |
| First edit, CLAUDE.md, denied | [8.3, 10.2] slower | [6.0, 11.0] slower |
| First edit, 406 KiB file, denied | [14.5, 16.4] slower | [13.7, 21.3] slower |
Against `4b02f669` every call type is faster at p50 and p90. Against `main`,
shell commands and repeat edits are 1.3 to 3 ms slower at p50 with no
measurable p90 difference; a first touch costs 8 to 18 ms more because it
reads the transcript, parses the turn's searches that name the target, reads
the target file and profiles the change, which `main` does not do. Each hook
call is a new Node process, so the code a call runs is also compiled on that
call, and that compilation is most of the remaining difference. A V8 code
cache written after each run was tried and measured end to end through
`run-with-flags.js`: setting it up and loading the cached code cost about as
much as the compilation it saved.
Each commit of this round was measured the same way (40 samples per call
type and version); no commit is slower at p50 or p90 than the one before it
on any call type:
| Commit | Change in ms, point estimate (95% interval) |
| --- | --- |
| Load modules and parse searches only when needed | every call type faster: p50 -8.4 to -14.1, p90 -9.1 to -14.5 |
| Skip plain character runs when lexing | comment-only pass p50 -3.3 (-5.3 to -2.2); no other measurable change |
| Read the transcript tail through a small module | no measurable change |
| Profile a change only when used, window first | 406 KiB file p50 -19.9 (-21.5 to -18.3), p90 -19.8 (-37.8 to -13.9); `CLAUDE.md` p50 -4.3 (-5.6 to -2.9); no other measurable change |
## Corpus format
One JSON file per scenario in `tests/fixtures/gateguard-scenarios/`:
- `name`, `description`;
- `files` (project-relative path to content), optional `dirs`, `symlinks`
(link path to target, relative to the link) and `hardlinks` (link path to
an existing project file, both project-relative), created in a temp project;
- optional `root` (a fixed project path, used for Windows-style paths, where
nothing is created on disk) and `env` (extra environment variables);
- `steps`, in order. Each step has an `id`, an optional `note`, the
`transcript` records appended to the session transcript before the call
(human prompts with `promptId`, assistant `tool_use` records with
`message.id`, `tool_result` records, compaction boundaries, and the pending
call's own `tool_use` record), the hook `payload`, and the labels above.
`{{root}}` and `{{transcript}}` in any string are replaced with the temp
project and transcript paths. Transcripts are the same for every hook: a
step's call is assumed to succeed eventually, so the next step's records start
with its `tool_result`.
A scenario with `symlinks` or `hardlinks` is skipped (and reported) where the
links cannot be created, such as Windows without the symlink privilege or a
file system without hard links.
## Limits
- The corpus is synthetic. Sessions were written to resemble real ones, not
sampled from them, so the denial counts show the direction and size of a
change, not rates to expect in the field. Opt-in metrics
(`GATEGUARD_METRICS=1` and `scripts/gateguard-report.js`) measure real
sessions.
- The labels are the corpus author's judgement, made with the design in mind.
`redundant` and `relevantQuestions` in particular are opinions: a different
author could call the README or CHANGELOG edits after a docs-wide search
redundant, or leave out the data-schema question for
`c-comment-continuation`.
Zero redundant denials means the working tree handles the cases this author
judged redundant, not that it never repeats a question.
- Question relevance is read from denial text. Condensed denials are mapped by
phrase, and a hedged phrase ("data schemas if any") counts as asked.
- A baseline sees the same transcript as the working tree even where it would
have denied an earlier call; retries are not modelled, and a denied call's
retry is assumed to pass.
- The routine shell gate is once per session, so a baseline that spent it on
a read-only command shows an allow on a later mutating command. That counts
as a mismatch, not a bypass (`mustDeny` is set only where the baseline's own
gate should also deny).
- Windows-style paths run lexically on non-Windows hosts; realpath and symlink
behaviour for them is not exercised.
- Latency is measured in process for `run()` only (no Node start-up), on the
machine that ran the evaluation.
+57
View File
@@ -49,6 +49,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.
NotebookEdit は `notebook_path` への Edit として扱われます(同じクラス・チェック済みキー・クレジット・上限の規則。セル内容は読まないため常に完全な質問になり、コメントのみの通過はありません)。
質問内容は対象ファイルのクラスによって変わります([対象クラス別の質問](#対象クラス別の質問)を参照)。以下は **code** クラスの Edit ゲートの質問です。
```
Before editing {file_path}, present these facts:
@@ -61,6 +65,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 +77,30 @@ 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 |
code クラスでは変更内容も読み、公開宣言に触れない Edit には importer/公開 API の代わりに「このファイルまたはモジュール内で変更された振る舞いに依存する呼び出し箇所」を尋ね、データを扱わない変更ではデータ形式の質問を省きます(未対応の拡張子、64 KiB 超などの判定不能な変更と機密対象では従来の 4 問のままです)。
メッセージの文面以外でクラスが影響するのは兄弟ファイル集約だけです(集約できるクラスと、同じクラス同士でのみ集約されること)。
### Destructive Bash Gate (every destructive command)
Triggers on: `rm -rf`, `git reset --hard`, `git push --force`, `drop table`, etc.
@@ -88,6 +118,33 @@ Triggers on: `rm -rf`, `git reset --hard`, `git push --force`, `drop table`, etc
2. What this specific command verifies or produces
```
このゲートが発火するまでは、読み取り専用のコマンド(`ls`、`cat`、`rg`、`git status`/`log`/`diff`、`Get-ChildItem` など。リダイレクト、置換、`tee`、`xargs`、未知のコマンドを含まないもの)はゲートを消費せずに通過し `routine_readonly_passes` に数えます。セッション ID またはトランスクリプトパスで識別される状態は 8 時間、プロジェクトディレクトリによるフォールバックは 30 分の無操作で失効し、サブエージェントでも機密対象はパスごとに 1 回拒否されます(親のゲートは解除されません)。
## すでに済んでいるとみなされるもの
調査がすでに行われたことがトランスクリプトから確認できる場合、または前回の拒否と同じ回答になる場合、ゲートは拒否の代わりに注記(`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` では一致しません)。テスト対象では先にテストの接辞を 1 つ外します(`.test`、`.spec`、Python と Go の `test_`/`_test`)。`rg tokenizer src tests` は `tests/tokenizer.test.js` をクレジットしますが、`index.test.js` は汎用名 `index` になるため一致しません。
- **除外は数えない:** 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`、次にプロセスのディレクトリを基準に解決します。トランスクリプトが読めない等の問題があれば拒否に戻ります。
- 機密でない対象が拒否され、ターン内にそのファイルに触れたがクレジットにならなかった呼び出しがある場合、拒否に「Closest search this turn did not count (...)」の 1 行を加え、最も近いものとその理由(同一バッチ、除外、範囲外、stdin、検索ではない `Read` 等、汎用すぎる名前)を示します(詳細はサニタイズして 60 文字まで)。
- **同一ターンの兄弟ファイル作成の集約:** まだ存在しないファイルへの Write(code、test、prose クラスのみ)が拒否された後、**同じクラス・同じディレクトリ**への新規ファイル Write は、同じターン内であれば注記付きで許可されます。ターン ID はターン開始レコード自身の `promptId`、なければその `uuid`、なければそのハッシュです(末尾範囲にターン開始がない場合は、範囲内のユーザーレコードの `promptId` がすべて一致するときだけ使います)。120 秒ルールはトランスクリプトのパスがまったくない場合に限られ、トランスクリプトが存在しない・読めない・ターン ID が得られない場合は集約されません。config・instruction ファイル、`.` で始まるセグメント(ドットファイルとすべてのドットディレクトリ)や 8.3 短縮名を含むパスは集約されません。ディレクトリはシンボリックリンクを解決した実体でも同じ判定を行い、実体のディレクトリをキーにします。Edit/MultiEdit も集約されません。
- **コメント・空白のみの Edit:** 対応言語の機密でない code/test/prose ファイルに対し、コメントと空白だけが変わる Edit(MultiEdit はそのファイルの全エントリ)は注記付きで通過し、`trivial_allows` に数えます。チェック済みにはしないため、コードを変える次の変更は通常どおりゲートされます(Write、Python のインデント変更、複数行文字列などを含む変更は対象外)。
- **正規化パスキー:** 相対パスはツールの `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*`・`id_ecdsa*`・`id_dsa*`・`.netrc`・`.pgpass`・`credentials*`・`secrets.*`、パスセグメントが `auth`・`authn`・`authz`・`security`・`secrets`・`payment`・`payments`・`billing`・`migrations` のいずれかと完全一致するもの、`.github/workflows/` 配下は、クレジット・集約・上限のいずれも適用されず、初回タッチで必ず拒否されます。判定は字句上のパスとシンボリックリンクを解決した実体の両方で行い(`src/tools -> ../auth` なら `src/tools/login.py` も機密)、実体を解決できない場合は機密とみなします。判定順は exempt → subagent → checked → 機密またはハードリンク? → クレジット → 兄弟集約 → 上限 → 拒否です。`GATEGUARD_EXEMPT_GLOBS` による除外は最優先で、機密対象やハードリンク対象にも適用されます。
- **ハードリンク対象:** リンク数が 2 以上のファイル(またはそれを指すシンボリックリンク)は別名から到達できるため、機密対象と同じくクレジット・変更プロファイル・コメントのみの通過・兄弟集約・上限・サブエージェントの通過が適用されません。拒否には機密の行の代わりに「Hard-linked target: ...」の行が付きます。存在しない新規ファイル以外の stat エラーはハードリンクとみなします。
- **判定メトリクス(オプトイン):** `GATEGUARD_METRICS=1` で各判定を `<GATEGUARD_STATE_DIR>/metrics.jsonl` に 1 行ずつ記録します(パス・コマンド・内容は記録せず、1 MiB で `.1` にローテーション)。集計は `node scripts/gateguard-report.js [--dir <path>] [--json]` です。
- **互換性:** 新しい環境変数はオプトインの `GATEGUARD_FACT_FORCE_MAX_DENIALS` と `GATEGUARD_METRICS` のみです。サブエージェントによる機密・ハードリンク対象への初回タッチと、各ノートブックへの最初の NotebookEdit(以前はゲート対象外)を除き、以前許可されていた操作が拒否されることはありません。
- **信頼の限界:** クレジットはローカルのトランスクリプトを読むため、エージェントが理論上それを書き換えられます。検証するのは行動(検索が実行され結果が返ったこと)であり、意図ではありません。
## Quick Start
### Option A: Use the ECC hook (zero install)
@@ -0,0 +1,73 @@
# GateGuard Fact-Force — AgentShield Evidence
Generated on 2026-09-30 with `ecc-agentshield@1.6.0` (npm integrity
`sha512-lpeHG96DtEn7ofq7Iiyvq29piQOwParaiZOdDB206i4ZeCL4yjwT6xN5BSGI4yhCEAN0eO0f9F9hxHLBuRqf/g==`),
installed with `--ignore-scripts`. Only static analysis was run: no prompt
injection testing, sandbox execution, taint or LLM analysis, and no auto-fix.
## Scope
AgentShield discovers a hooks config at `hooks/hooks.json` and the hook scripts
its commands reference. The scan root is `hooks/` and `scripts/` from the tree
under review, extracted into an empty directory so that no other part of the
repository is scanned:
```bash
git archive HEAD hooks scripts | tar -x -C <scan-root>
npx --yes ecc-agentshield@1.6.0 scan --path <scan-root> \
--format json --evidence-pack <temporary-directory>
```
The same root was built from `main` at `c70874fa`, saved with
`--save-baseline`, and compared with `--baseline <file> --gate`.
`gateguard-fact-force-agentshield.sarif` is the redacted
`agentshield-results.sarif` from the evidence pack of this branch. AgentShield
replaced the scan root with `<target-path>`; it contains no local path,
username, credential or token.
## Result
| | this branch | `main` (`c70874fa`) |
| --- | ---: | ---: |
| Exit code | 0 | 0 |
| Files scanned | 28 | 28 |
| Score / grade | 83 / B | 83 / B |
| Critical / high | 0 / 0 | 0 / 0 |
| Medium / low / info | 29 / 1 / 7 | 29 / 1 / 7 |
Baseline comparison against `main`:
- Score 83 → 83; no new critical, high, medium or low finding.
- The gate reports one new and one resolved `info` finding. It is the same
finding, "Hook code reads Claude transcript input" in
`scripts/hooks/gateguard-fact-force.js`, on the same unchanged source line,
which moved from line 1356 to 1418. AgentShield puts the line number in the
finding id and the id in its baseline fingerprint, so a moved line counts
as new. Because the gate allows no new findings, it exits `3` on this line
shift alone.
## Pre-existing findings
All 37 findings are present on `main` in the same number:
- 24 medium: `hooks/hooks.json` hook commands run inline bootstrap payloads
longer than 1,000 characters. This is the plugin's existing
`CLAUDE_PLUGIN_ROOT` resolver, which this change does not touch.
- 3 medium and 1 low on `hooks/codex-hooks.json`, which this change does not
touch.
- 2 medium on `hooks/hooks.metadata.json` ("No permissions block", "No
PreToolUse security hooks"). The metadata file holds ids and descriptions
only; permissions belong in Claude Code `settings.json`. The ECC Tools
config audit reports the same two findings on every pull request that
touches this file.
- 7 info: hook scripts that read the transcript or add context. The
fact-force hook reads the transcript on `main` as well.
## Coverage limits
AgentShield scans the hook entry script but not the `scripts/lib/gateguard-*.js`
modules it requires, and it does not read `skills/gateguard/SKILL.md` below
the top level of `skills/`. The scenario gate SARIF in
[gateguard-fact-force.md](gateguard-fact-force.md) and the unit suites cover
those modules.
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,124 @@
# GateGuard Fact-Force — Security Evidence
Evidence for the GateGuard fact-force changes, compared with `main` at
`c70874fa`. Generated on 2026-09-30 with Node.js 22 on Linux; the gate suites
also pass on Windows 11.
## Changed security-sensitive surface
- `hooks/hooks.json`: the fact-force matcher adds `NotebookEdit`
(`Edit|Write|MultiEdit` → `Edit|Write|MultiEdit|NotebookEdit`). No hook is
added, removed or reordered, and no other matcher changes.
- `hooks/hooks.metadata.json`: that entry's description and fingerprint.
- `scripts/hooks/gateguard-fact-force.js` and the
`scripts/lib/gateguard-*.js` modules it now requires.
Destructive Bash/PowerShell detection is unchanged. No secret, token,
credential, billing, webhook, workflow or network code is added or modified.
## Threat model
The actor is an agent, possibly steered by prompt-injected content, that wants
to edit a file without doing the investigation the gate asks for.
| Risk | Control | Regression evidence |
| --- | --- | --- |
| A new allowance (search credit, trivial edit, same-turn sibling, read-only first command, denial cap) reaches a sensitive target | Sensitive targets never get an allowance; sensitivity is judged on the path as written and its real path, and a resolution failure counts as sensitive | `sensitive-targets`, `cap-with-sensitive`, `windows-paths` scenarios |
| A search that did not cover the target earns credit (exclusion filters, include filters for another directory, wrong scope, same batch, earlier turn, error result, `Read`) | Credit fails to deny on any doubt ([design notes](../../gateguard/design-notes.md#fail-to-deny)) | `bypass-search-filters`, `bypass-turn-and-batch` scenarios |
| Code changes disguised as comment or whitespace edits | Judged against the file's own context; directives, continuations, strings and heredocs count as code | `bypass-comment-context`, `comment-only-edits` scenarios |
| Sibling collapse used to batch files the gate never questioned | Same class, same real directory, same human turn only; never for sensitive or hard-linked files | `bypass-siblings` scenario |
| A mutating first shell command treated as read-only | Quote-aware parse; redirection, substitution, pipes into unknown commands and unknown commands are not read-only | `first-shell-commands` scenario |
| Edits that the gate never saw | MultiEdit in its real shape, NotebookEdit, subagent edits of sensitive files, hard-linked aliases of sensitive files are gated | `subagent-edits`, `notebook-edits`, `hard-linked-targets` scenarios |
| The hook skipping the host permission prompt | The hook never emits `permissionDecision: "allow"` | Checked on every scenario step |
## Scanner output
`docs/security/evidence/gateguard-fact-force.sarif` is written by the
repository's scenario gate:
```bash
node scripts/dev/gateguard-eval.js --markdown --baseline c70874fa \
--sarif docs/security/evidence/gateguard-fact-force.sarif
```
- Exit code `0`; SARIF results: `0`.
- 20 scenarios, 184 hook calls, 87 of them must-deny.
- Reruns write a byte-identical file (no timing, paths or content).
| Gate result | this branch | `main` (`c70874fa`) |
| --- | ---: | ---: |
| Must-deny steps allowed (`error`) | 0 | 8 |
| Explicit `allow` decisions (`error`) | 0 | 0 |
| Hook errors (`error`) | 0 | 0 |
| Other expectation mismatches (`warning`) | 0 | 47 |
AgentShield's static scan of the hooks surface gives the same result on
this branch and on `main` (score 83/B, 0 critical, 0 high); see
[gateguard-fact-force-agentshield.md](gateguard-fact-force-agentshield.md)
and `gateguard-fact-force-agentshield.sarif`.
Negative control: the same gate run against `main`'s hook reports the 8
bypasses as SARIF errors. They are:
- two subagent edits of sensitive files (`src/auth/oauth.js`, a MultiEdit of
`config/secrets.yaml`);
- three MultiEdit calls in the tool's real shape (a top-level `file_path`):
`.env`, a code file, and a subagent's `config/.env.local`;
- two NotebookEdit calls on notebooks under `auth/` and `payments/`;
- a subagent's edit of a hard-linked file.
## Focused security regression suites
| Suite | this branch | `main` |
| --- | --- | --- |
| `tests/hooks/gateguard-fact-force.test.js` | 1019 passed, 0 failed | 704 passed, 0 failed |
| `tests/hooks/gateguard-scenarios.test.js` | 25 passed, 0 failed | — |
| `tests/lib/gateguard-target-class.test.js` | 31 passed, 0 failed | — |
| `tests/lib/gateguard-readonly-shell.test.js` | 18 passed, 0 failed | — |
| `tests/lib/gateguard-change-profile.test.js` | 47 passed, 0 failed | — |
| `tests/lib/gateguard-code-lexer.test.js` | 15 passed, 0 failed | — |
| `tests/lib/gateguard-file-context.test.js` | 7 passed, 0 failed | — |
| `tests/lib/gateguard-search-filters.test.js` | 10 passed, 0 failed | — |
| `tests/lib/gateguard-turn-scan.test.js` | 17 passed, 0 failed | — |
| `tests/scripts/gateguard-eval.test.js` | 15 passed, 0 failed | — |
| `tests/scripts/gateguard-latency.test.js` | 8 passed, 0 failed | — |
## Repository gates
Each of these gives the same result on this branch and on `main`:
| Command | Result |
| --- | --- |
| `npm run security:ioc-scan` | passed (26 files inspected) |
| `node scripts/ci/validate-hooks.js` | 24 hook matchers validated |
| `node scripts/ci/check-hooks-schema-keys.js` | all keys within the documented loader sets |
| `node scripts/ci/validate-workflow-security.js` | 12 workflow files validated |
| `node scripts/ci/validate-no-personal-paths.js` | passed |
| `node scripts/ci/check-unicode-safety.js` | passed |
## Config audit findings on `hooks/hooks.json`
The changed-config audit reports one medium finding ("No PreToolUse security
hooks") and ten low findings ("Missing deny: rm -rf / sudo / chmod 777 / ssh /
> /dev/"). The same eleven findings appear on every pull request that
touches `hooks/hooks.json` (for example #2838, #3055, #3135 and #3189), so this
change does not introduce them.
- `hooks/hooks.json` does register PreToolUse security hooks, including the
fact-force gate, `config-protection` and `governance-capture`.
- `permissions.deny` rules belong in Claude Code `settings.json`, not in a
plugin's hooks file. `check-hooks-schema-keys.js` rejects keys outside the
loader sets, so adding deny lists to `hooks.json` is not an option.
- Destructive shell commands are denied at runtime by the Bash dispatcher and
the PowerShell gate, which this change leaves unchanged.
## Known limits
The known limits are listed in
[evaluation.md](../../gateguard/evaluation.md#limits) and the
[design notes](../../gateguard/design-notes.md). The main ones:
- Credit trusts the local transcript.
- Configured exempt globs take precedence over sensitivity.
- Read-only git commands keep `main`'s exposure to git-config-driven
execution.
@@ -0,0 +1,97 @@
{
"version": "2.1.0",
"$schema": "https://json.schemastore.org/sarif-2.1.0.json",
"runs": [
{
"tool": {
"driver": {
"name": "gateguard-eval",
"informationUri": "https://github.com/affaan-m/ECC/blob/main/docs/gateguard/evaluation.md",
"rules": [
{
"id": "gateguard/must-deny-bypass",
"name": "MustDenyBypass",
"shortDescription": {
"text": "A step that must be denied (sensitive target, bypass attempt, or mutating first command) was allowed."
},
"defaultConfiguration": {
"level": "error"
}
},
{
"id": "gateguard/explicit-allow",
"name": "ExplicitAllow",
"shortDescription": {
"text": "The hook emitted permissionDecision \"allow\", which skips the host permission prompt."
},
"defaultConfiguration": {
"level": "error"
}
},
{
"id": "gateguard/hook-error",
"name": "HookError",
"shortDescription": {
"text": "The hook threw or produced unparseable output for a step."
},
"defaultConfiguration": {
"level": "error"
}
},
{
"id": "gateguard/expectation-mismatch",
"name": "ExpectationMismatch",
"shortDescription": {
"text": "The hook decision differs from the decision the scenario expects."
},
"defaultConfiguration": {
"level": "warning"
}
}
]
}
},
"automationDetails": {
"id": "gateguard/scenario-corpus"
},
"invocations": [
{
"executionSuccessful": true
}
],
"properties": {
"corpus": {
"scenarios": 20,
"steps": 184
},
"hooks": [
{
"label": "working tree",
"totals": {
"steps": 184,
"denials": 132,
"mustDenyBypasses": 0,
"mismatches": 0,
"explicitAllows": 0,
"errors": 0,
"skippedScenarios": 0
}
},
{
"label": "c70874fa",
"totals": {
"steps": 184,
"denials": 157,
"mustDenyBypasses": 8,
"mismatches": 55,
"explicitAllows": 0,
"errors": 0,
"skippedScenarios": 0
}
}
]
},
"results": []
}
]
}
+18
View File
@@ -86,6 +86,24 @@ 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,并提出相应的问题。
* **按变更内容提问:** 对 code 类目标还会读取变更内容:未触及公开声明的编辑改问"本文件或其模块中依赖所改行为的调用点",不涉及数据的变更省略数据格式问题;无法可靠判断(不支持的扩展名、超过 64 KiB 等)以及敏感目标时仍问原来的四个问题。
* **事先搜索抵免:** 若当前人类回合内已有成功完成(非错误结果)的 `Glob`、`Grep`、`LS` 或 `rg`/`grep`/`find` 等搜索命中该文件,则以附注代替拒绝。`Read` 从不计入;与被门控调用同批次的搜索、重复的 tool_use id、压缩(compaction)之前的搜索均不计入。文件名词干须按单词边界匹配(测试目标先去掉一个测试词缀:`.test`、`.spec`,以及 Python/Go 的 `test_`/`_test`,因此 `rg tokenizer src tests` 可抵免 `tests/tokenizer.test.js`,而 `index.test.js` 仍是通用词干 `index`),且目标须位于所搜索目录之内(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 文件,只改动注释和空白的编辑(MultiEdit 须该文件的所有条目都如此)以附注放行并计入 `trivial_allows`,但不标记为已检查,下一次改动代码的编辑仍按首次触碰门控(写入、Python 缩进变化、多行字符串等从不算作此类编辑)。
* **拒绝中指出最接近的未计入搜索:** 非敏感目标被拒绝且本回合有提及该文件却未能抵免的调用时,拒绝消息追加一行"Closest search this turn did not count (...)",说明最接近的一个及原因(同批次、被排除、范围外、读取 stdin、`Read` 等非搜索、文件名过于通用);细节经净化并截断至 60 个字符。
* **同回合同级文件合并:** 同一目录、同一类别(仅 code、test、prose)的新文件在同一回合内(完全没有 transcript 路径时为 120 秒内)只拒绝第一个,其余以附注放行。config、instruction,以及任何以 `.` 开头的路径段(点文件和所有点目录)或 8.3 短文件名下的文件从不合并;目录按解析符号链接后的真实位置判定和记录;有 transcript 路径但文件缺失、无法读取或得不到回合 ID 时不合并;编辑从不合并。
* **拒绝上限(可选):** 设置 `GATEGUARD_FACT_FORCE_MAX_DENIALS` 后,会话内首次触碰的拒绝次数达到该值后,新路径直接放行(计入 `cap_allows`)。`GATEGUARD_FACT_FORCE_FULL_DENIALS` 只决定消息详略(消息预算),而此变量决定是否拦截(拒绝预算)。事先搜索抵免和同级合并先于上限判定,不消耗上限;格式错误的值视为无上限,并在 stderr 警告一次。
* **敏感目标:** `.env`/`.env.*`、`*.pem`/`*.key`/`*.p12`/`*.pfx`、`id_rsa*`/`id_ed25519*`/`id_ecdsa*`/`id_dsa*`/`.netrc`/`.pgpass`/`credentials*`/`secrets.*`、任一路径段恰为 `auth`、`authn`、`authz`、`security`、`secrets`、`payment`、`payments`、`billing`、`migrations`,以及 `.github/workflows/` 下的文件,不适用抵免、合并和上限,首次触碰总是拒绝。按字面路径和解析符号链接后的真实位置同时判定(`src/tools -> ../auth` 时 `src/tools/login.py` 也是敏感目标),真实位置无法解析时视为敏感。判定顺序:exempt → subagent → checked → 敏感或硬链接? → 抵免 → 同级合并 → 上限 → 拒绝。`GATEGUARD_EXEMPT_GLOBS` 的豁免优先于一切规则,对敏感目标和硬链接目标同样生效。
* **硬链接目标:** 链接数大于 1 的文件(或指向此类文件的符号链接)可经其他名称访问,因此与敏感目标一样不适用抵免、变更画像、仅注释放行、同级合并、上限和子代理放行;拒绝消息附带 "Hard-linked target: ..." 一行而非敏感目标一行。除文件不存在外的任何 stat 错误都视为硬链接。
* **NotebookEdit:** 按对 `notebook_path` 的编辑门控(相同的类别、已检查键、抵免与上限规则;不读取单元格内容,因此总是完整问题,也从不按仅注释放行)。
* **例行 shell 门控与会话:** 门控触发前,只读命令(`ls`、`cat`、`rg`、`git status`/`log`/`diff`、`Get-ChildItem` 等,不含重定向、替换、`tee`、`xargs` 或未知命令)直接放行且不消耗门控,计入 `routine_readonly_passes`;以会话 ID 或 transcript 路径为键的状态空闲 8 小时失效,项目目录回退键仍为 30 分钟;子代理对敏感目标每个路径拒绝一次(不会为父会话解锁)。
* **判定指标(可选):** 设置 `GATEGUARD_METRICS=1` 后,每次判定向 `<GATEGUARD_STATE_DIR>/metrics.jsonl` 追加一行(不记录路径、命令或内容,超过 1 MiB 轮转为 `.1`);用 `node scripts/gateguard-report.js [--dir <path>] [--json]` 汇总。
* **兼容性:** 新增的环境变量只有可选的 `GATEGUARD_FACT_FORCE_MAX_DENIALS` 和 `GATEGUARD_METRICS`;除子代理首次触碰敏感或硬链接目标、以及每个笔记本的首次 NotebookEdit(此前不受门控)外,以前允许的操作不会变为拒绝,且从不返回 `permissionDecision: "allow"`。
## 快速开始
### 选项 A:使用 ECC 钩子(零安装)