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
- Structure: Module layout, Fail to deny.
- Which targets are gated and how strictly: Exempt globs, NotebookEdit, MultiEdit paths, Sensitive targets, Hard-linked targets, Target resolution, Target classes, Worktree prefix, Windows name normalization.
- Right question: Change profile, Public surface, Data handling, Shell scripts, File context, Questions from the change profile, Trivial edits, Directive comments, Closest search that did not count.
- Right time, prior-search credit: Read is not evidence, Turn boundaries, Turn identity, Tool result pairing, Same-batch searches, Ambiguous shell is not evidence, Directory changes, Stdin is not a tree search, Search scope, Stem matching, Test stems, Search filters, Include filters, Type filters, Path patterns, Glob matching without RegExp, Flag parsing, PowerShell parameter binding.
- Right time, other allowances: New-file detection, Sibling collapse, Denial cap, Read-only first shell command, Idle window, Subagents.
- State and measurement: State file is untrusted, 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, andrun(). 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 inscripts/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.gitchecks, 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'stranscript-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 itsReadcalls (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 bycreateSearchEvidence()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,findname 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 usesgateguard-code-lexer.js, which reduces a snippet to its code lines, andgateguard-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 bycreateReadOnlyShell()from the hook'squoteAwareSegments, like the search evidence.scripts/lib/gateguard-metrics.js: builds, validates and appends the opt-in decision metrics lines (see Metrics).scripts/gateguard-report.jsreads 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 -> ../authcannot laundersrc/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(orsubagent-hard-linked) withsensitive: 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.
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. 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-Stringandfd, the first positional is the pattern unless-e/-f(grep family,rg,git greponly; fd's-eis an extension) supplied it; - fd's
-E/--excludeand--extensiontake a value; rg's-r/--replace,--type-not,--type-addand--type-cleartake 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/hooksnever 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 toroutine_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-Locationand 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(-zruns decompressors found onPATH, which older ripgrep on Windows also looked up in the working directory),tree -o/-R(also inside a short cluster). gittakes no global options. The subcommand must bestatus,log,diff,show,ls-files,rev-parseorbranch, and every option must be in that subcommand's allowlist, so--output(and its abbreviations),--ext-diffand--textconvreject.branchtakes 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_METRICSis 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 oneappendFileSyncafter 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
lstatand opened withO_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.jsonlpast 1 MiB it is renamed tometrics.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.