mirror of
https://github.com/affaan-m/ECC.git
synced 2026-10-19 14:31:50 +02:00
feat(gateguard): integrate evidence-aware first-touch questions
This commit is contained in:
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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": []
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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 钩子(零安装)
|
||||
|
||||
Reference in New Issue
Block a user