Files
ECC/docs/gateguard/design-notes.md
T

33 KiB

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.

Index

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). 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). 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). 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 (five with the opt-in external-contract question), narrowed by the change profile (see 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 promptIds 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.

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. The same holds for a search confined to the target's own file name: when every include glob, or the Glob pattern, or every path pattern ends in the target's file name with no glob characters (**/tokenizer.js, find -name tokenizer.js, rg -g tokenizer.js), the search could only find the target, so its stem never credits; the closest-miss note says it was a lookup. 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.

Type filters

A file-type filter is an include or exclusion in another spelling, so it filters the same way. rg -t/--type adds the type's globs as includes and rg -T/--type-not adds them as exclusions, using ripgrep 14.1's built-in globs for common languages. A type name outside that table, --type-add and --type-clear redefine what a type means, so they make the search an unreadable filter that never credits. fd -e/--extension is an include of *.<ext>. The Grep tool's type parameter is read like rg -t.

Path patterns

A glob in a path argument limits the files a search sees just as an include does, so it must match the target for the stem to count. The Glob tool's pattern is matched against the target's path below its root, with * inside one directory and ** across directories. A shell path operand with glob characters (rg x src/*.py) is expanded by the shell before the search runs; it admits the target when it matches the target or a directory above it, since a matched directory is searched recursively. A git pathspec (git grep x -- '*.py') matches the same way, except that its * also matches /. Literal operands keep their scope rule, and one operand that admits the target is enough. PowerShell escapes with a backtick, so a PowerShell search is tokenized with its backslashes kept as path separators.

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 and --extension take a value; rg's -r/--replace, --type-not, --type-add and --type-clear take 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.