diff --git a/CHANGELOG.md b/CHANGELOG.md index c8c5686df..eab3fea1c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,27 @@ # Changelog +## Unreleased + +### Changed + +- GateGuard first-touch denials ask questions that fit the target: by class (instruction, test, prose, config, code) on its project-relative path, and for code by what the change touches. An edit that changes no public declaration asks for the in-module call sites instead of importers and public API, and the data-schema question is dropped when the change handles no data. Changes the hook cannot read with confidence keep the original four questions word for word. +- GateGuard credits investigation that already happened: a completed, non-error `Glob`, `Grep`, `LS`, or search-style Bash/PowerShell command in the current human turn that names the target and whose scope contains it passes the first touch with a note. `Read` never counts, nor do searches in the same batch, before a compaction, with error results, or whose filters exclude the target or limit the search to another directory. When a denial follows a search that nearly counted, it names that search and why it did not count. +- GateGuard passes comment and whitespace-only edits of code, test and prose files with a note, without marking the file checked, so the next change that alters code is still gated. +- GateGuard asks once for a batch of new files: after a new file is denied, later new files of the same class in the same real directory within the same human turn pass with a note. +- Read-only shell commands (`ls`, `cat`, `rg`, `git status`/`log`/`diff`, `Get-ChildItem`, …) no longer use up the once-per-session routine shell gate; the first other command is still gated (#2573). +- GateGuard session state keyed by a session id or transcript path now expires after 8 idle hours instead of 30 minutes; the project-directory fallback keeps 30 minutes. +- Add opt-in `GATEGUARD_FACT_FORCE_MAX_DENIALS`, a per-session denial cap: once that many first-touch denials have been issued, further new paths pass through (counted in `cap_allows`). Unset keeps the current behaviour; a malformed value is reported once on stderr and leaves the gate uncapped (#2755, thanks @SulimanAbdulrazzaq; closes #2608). +- GateGuard loads its target-classification, transcript, search-matching and change-profile modules only on the calls that use them, and parses only the searches that name the target, so shell commands and edits of already checked files stay within a few milliseconds of the previous hook. +- Add opt-in `GATEGUARD_METRICS` decision metrics (codes only, never paths, commands or content) and `scripts/gateguard-report.js` to summarise them. +- Sensitive targets (`.env*`, keys and certificates, `credentials*`, `secrets.*`, `auth`/`security`/`payment(s)`/`billing`/`migrations` path segments, `.github/workflows/`) and files with more than one hard link get none of these allowances, judged on the path as written and its real location, and are gated in subagents too. `GATEGUARD_EXEMPT_GLOBS` still takes precedence. +- Newly denied: a subagent's first touch of a sensitive or hard-linked file, and the first NotebookEdit of each notebook. Every other rule only removes denials and never returns an `allow` decision. See `skills/gateguard/SKILL.md`. + +### Fixed + +- GateGuard keys checked files by canonical path, resolving relative targets against the tool `cwd`, so `a.py`, `./a.py`, and the absolute path (or differently cased and separated Windows spellings) share one first touch. Keys from earlier state files are still honoured. +- GateGuard gates a MultiEdit call as its `tool_input.file_path` when its entries do not name their own path, which is the tool's own shape; such calls were never gated before, including on sensitive files. +- GateGuard gates NotebookEdit as a first-touch edit of its `notebook_path`; notebooks were never gated before. + ## 2.2.3 - 2026-10-01 ### Changed diff --git a/docs/gateguard/change-profile.md b/docs/gateguard/change-profile.md new file mode 100644 index 000000000..1bb8d2c1c --- /dev/null +++ b/docs/gateguard/change-profile.md @@ -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#`; 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 ``, ``; +- 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), ``, 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. diff --git a/docs/gateguard/design-notes.md b/docs/gateguard/design-notes.md new file mode 100644 index 000000000..5809dc9aa --- /dev/null +++ b/docs/gateguard/design-notes.md @@ -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#`; 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//` 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 ` 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 `\u0000` 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 ( ): .` 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. diff --git a/docs/gateguard/evaluation.md b/docs/gateguard/evaluation.md new file mode 100644 index 000000000..45676ce39 --- /dev/null +++ b/docs/gateguard/evaluation.md @@ -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 +: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 ` takes any ref and can be repeated (default +`upstream/main`); pass the pull request head (`4b02f669` above) to compare +against it. `--corpus ` 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 ` 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 `. 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. diff --git a/docs/ja-JP/skills/gateguard/SKILL.md b/docs/ja-JP/skills/gateguard/SKILL.md index bdf78b276..0a7fde34b 100644 --- a/docs/ja-JP/skills/gateguard/SKILL.md +++ b/docs/ja-JP/skills/gateguard/SKILL.md @@ -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//` は `/.claude/worktrees//.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 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` で各判定を `/metrics.jsonl` に 1 行ずつ記録します(パス・コマンド・内容は記録せず、1 MiB で `.1` にローテーション)。集計は `node scripts/gateguard-report.js [--dir ] [--json]` です。 +- **互換性:** 新しい環境変数はオプトインの `GATEGUARD_FACT_FORCE_MAX_DENIALS` と `GATEGUARD_METRICS` のみです。サブエージェントによる機密・ハードリンク対象への初回タッチと、各ノートブックへの最初の NotebookEdit(以前はゲート対象外)を除き、以前許可されていた操作が拒否されることはありません。 +- **信頼の限界:** クレジットはローカルのトランスクリプトを読むため、エージェントが理論上それを書き換えられます。検証するのは行動(検索が実行され結果が返ったこと)であり、意図ではありません。 + ## Quick Start ### Option A: Use the ECC hook (zero install) diff --git a/docs/security/evidence/gateguard-fact-force-agentshield.md b/docs/security/evidence/gateguard-fact-force-agentshield.md new file mode 100644 index 000000000..273eef768 --- /dev/null +++ b/docs/security/evidence/gateguard-fact-force-agentshield.md @@ -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 +npx --yes ecc-agentshield@1.6.0 scan --path \ + --format json --evidence-pack +``` + +The same root was built from `main` at `c70874fa`, saved with +`--save-baseline`, and compared with `--baseline --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 ``; 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. diff --git a/docs/security/evidence/gateguard-fact-force-agentshield.sarif b/docs/security/evidence/gateguard-fact-force-agentshield.sarif new file mode 100644 index 000000000..d68401e31 --- /dev/null +++ b/docs/security/evidence/gateguard-fact-force-agentshield.sarif @@ -0,0 +1,1338 @@ +{ + "version": "2.1.0", + "$schema": "https://json.schemastore.org/sarif-2.1.0.json", + "runs": [ + { + "tool": { + "driver": { + "name": "AgentShield", + "informationUri": "https://github.com/affaan-m/agentshield", + "rules": [ + { + "id": "hooks-no-pretooluse", + "name": "No PreToolUse security hooks configured", + "shortDescription": { + "text": "No PreToolUse security hooks configured" + }, + "fullDescription": { + "text": "No PreToolUse hooks are defined. These hooks can catch dangerous operations before they run, providing an essential security layer." + }, + "help": { + "text": "No PreToolUse hooks are defined. These hooks can catch dangerous operations before they run, providing an essential security layer.\n\nRecommended fix: Add PreToolUse hooks for security-sensitive operations" + }, + "defaultConfiguration": { + "level": "warning" + }, + "properties": { + "category": "misconfiguration", + "severity": "medium", + "security-severity": "5.0", + "tags": [ + "security", + "agent-config", + "misconfiguration" + ], + "precision": "high" + } + }, + { + "id": "hooks-chained-commands-0", + "name": "Hook has 31 chained commands", + "shortDescription": { + "text": "Hook has 31 chained commands" + }, + "fullDescription": { + "text": "A hook chains 31 commands together: \"node -e \"if(!process.env.PLUGIN_ROOT)throw new Error('Missing Codex PLUGIN_ROOT'...\". Complex chained commands in hooks are harder to audit and may perform operations beyond the hook's stated purpose. Consider breaking into a dedicated script file." + }, + "help": { + "text": "A hook chains 31 commands together: \"node -e \"if(!process.env.PLUGIN_ROOT)throw new Error('Missing Codex PLUGIN_ROOT'...\". Complex chained commands in hooks are harder to audit and may perform operations beyond the hook's stated purpose. Consider breaking into a dedicated script file.\n\nRecommended fix: Move complex logic to a script file" + }, + "defaultConfiguration": { + "level": "warning" + }, + "properties": { + "category": "hooks", + "severity": "medium", + "security-severity": "5.0", + "tags": [ + "security", + "agent-config", + "hooks" + ], + "precision": "high" + } + }, + { + "id": "hooks-inline-eval-payload", + "name": "SessionStart hook runs a 1177-character inline payload", + "shortDescription": { + "text": "SessionStart hook runs a 1177-character inline payload" + }, + "fullDescription": { + "text": "The hook command feeds a long inline payload to an interpreter or decoder instead of running a script file. Inline payloads are hard to review, are not anchored to a plugin or project directory, and are the usual way to hide a dropper in a hook." + }, + "help": { + "text": "The hook command feeds a long inline payload to an interpreter or decoder instead of running a script file. Inline payloads are hard to review, are not anchored to a plugin or project directory, and are the usual way to hide a dropper in a hook." + }, + "defaultConfiguration": { + "level": "warning" + }, + "properties": { + "category": "hooks", + "severity": "medium", + "security-severity": "5.0", + "tags": [ + "security", + "agent-config", + "hooks" + ], + "precision": "high" + } + }, + { + "id": "permissions-no-block", + "name": "No permissions block configured", + "shortDescription": { + "text": "No permissions block configured" + }, + "fullDescription": { + "text": "settings.json has configuration but no permissions section. Without explicit allow/deny lists, the agent relies on default permissions which may be too broad. Add a permissions block to restrict tool access." + }, + "help": { + "text": "settings.json has configuration but no permissions section. Without explicit allow/deny lists, the agent relies on default permissions which may be too broad. Add a permissions block to restrict tool access.\n\nRecommended fix: Add a permissions block with scoped allow and deny lists" + }, + "defaultConfiguration": { + "level": "warning" + }, + "properties": { + "category": "permissions", + "severity": "medium", + "security-severity": "5.0", + "tags": [ + "security", + "agent-config", + "permissions" + ], + "precision": "high" + } + }, + { + "id": "hooks-no-stop-hooks", + "name": "No Stop hooks for session-end verification", + "shortDescription": { + "text": "No Stop hooks for session-end verification" + }, + "fullDescription": { + "text": "Hooks are configured but no Stop hooks exist. Stop hooks run when a session ends and are useful for final verification — checking for uncommitted secrets, ensuring console.log statements were removed, or auditing file changes." + }, + "help": { + "text": "Hooks are configured but no Stop hooks exist. Stop hooks run when a session ends and are useful for final verification — checking for uncommitted secrets, ensuring console.log statements were removed, or auditing file changes.\n\nRecommended fix: Add a Stop hook for session-end checks" + }, + "defaultConfiguration": { + "level": "note" + }, + "properties": { + "category": "misconfiguration", + "severity": "low", + "security-severity": "2.5", + "tags": [ + "security", + "agent-config", + "misconfiguration" + ], + "precision": "high" + } + }, + { + "id": "hooks-code-transcript-access-1418", + "name": "Hook code reads Claude transcript input", + "shortDescription": { + "text": "Hook code reads Claude transcript input" + }, + "fullDescription": { + "text": "This non-shell hook implementation reads transcript-derived input (`transcript_path` or `CLAUDE_TRANSCRIPT_PATH`). That is common for Stop and SessionEnd hooks, but it should be reviewed because downstream logic can process sensitive prompt and tool history." + }, + "help": { + "text": "This non-shell hook implementation reads transcript-derived input (`transcript_path` or `CLAUDE_TRANSCRIPT_PATH`). That is common for Stop and SessionEnd hooks, but it should be reviewed because downstream logic can process sensitive prompt and tool history." + }, + "defaultConfiguration": { + "level": "note" + }, + "properties": { + "category": "hooks", + "severity": "info", + "security-severity": "1.0", + "tags": [ + "security", + "agent-config", + "hooks" + ], + "precision": "high" + } + }, + { + "id": "hooks-code-context-output-262", + "name": "Hook code injects content into Claude context", + "shortDescription": { + "text": "Hook code injects content into Claude context" + }, + "fullDescription": { + "text": "This non-shell hook implementation calls an output helper that writes content back into Claude context. That is often legitimate, but it should be reviewed because untrusted summaries or derived data can become prompt-injection surface." + }, + "help": { + "text": "This non-shell hook implementation calls an output helper that writes content back into Claude context. That is often legitimate, but it should be reviewed because untrusted summaries or derived data can become prompt-injection surface." + }, + "defaultConfiguration": { + "level": "note" + }, + "properties": { + "category": "hooks", + "severity": "info", + "security-severity": "1.0", + "tags": [ + "security", + "agent-config", + "hooks" + ], + "precision": "high" + } + }, + { + "id": "hooks-code-transcript-access-217", + "name": "Hook code reads Claude transcript input", + "shortDescription": { + "text": "Hook code reads Claude transcript input" + }, + "fullDescription": { + "text": "This non-shell hook implementation reads transcript-derived input (`transcript_path` or `CLAUDE_TRANSCRIPT_PATH`). That is common for Stop and SessionEnd hooks, but it should be reviewed because downstream logic can process sensitive prompt and tool history." + }, + "help": { + "text": "This non-shell hook implementation reads transcript-derived input (`transcript_path` or `CLAUDE_TRANSCRIPT_PATH`). That is common for Stop and SessionEnd hooks, but it should be reviewed because downstream logic can process sensitive prompt and tool history." + }, + "defaultConfiguration": { + "level": "note" + }, + "properties": { + "category": "hooks", + "severity": "info", + "security-severity": "1.0", + "tags": [ + "security", + "agent-config", + "hooks" + ], + "precision": "high" + } + }, + { + "id": "hooks-code-transcript-access-117", + "name": "Hook code reads Claude transcript input", + "shortDescription": { + "text": "Hook code reads Claude transcript input" + }, + "fullDescription": { + "text": "This non-shell hook implementation reads transcript-derived input (`transcript_path` or `CLAUDE_TRANSCRIPT_PATH`). That is common for Stop and SessionEnd hooks, but it should be reviewed because downstream logic can process sensitive prompt and tool history." + }, + "help": { + "text": "This non-shell hook implementation reads transcript-derived input (`transcript_path` or `CLAUDE_TRANSCRIPT_PATH`). That is common for Stop and SessionEnd hooks, but it should be reviewed because downstream logic can process sensitive prompt and tool history." + }, + "defaultConfiguration": { + "level": "note" + }, + "properties": { + "category": "hooks", + "severity": "info", + "security-severity": "1.0", + "tags": [ + "security", + "agent-config", + "hooks" + ], + "precision": "high" + } + }, + { + "id": "hooks-code-transcript-access-183", + "name": "Hook code reads Claude transcript input", + "shortDescription": { + "text": "Hook code reads Claude transcript input" + }, + "fullDescription": { + "text": "This non-shell hook implementation reads transcript-derived input (`transcript_path` or `CLAUDE_TRANSCRIPT_PATH`). That is common for Stop and SessionEnd hooks, but it should be reviewed because downstream logic can process sensitive prompt and tool history." + }, + "help": { + "text": "This non-shell hook implementation reads transcript-derived input (`transcript_path` or `CLAUDE_TRANSCRIPT_PATH`). That is common for Stop and SessionEnd hooks, but it should be reviewed because downstream logic can process sensitive prompt and tool history." + }, + "defaultConfiguration": { + "level": "note" + }, + "properties": { + "category": "hooks", + "severity": "info", + "security-severity": "1.0", + "tags": [ + "security", + "agent-config", + "hooks" + ], + "precision": "high" + } + }, + { + "id": "hooks-code-transcript-access-49", + "name": "Hook code reads Claude transcript input", + "shortDescription": { + "text": "Hook code reads Claude transcript input" + }, + "fullDescription": { + "text": "This non-shell hook implementation reads transcript-derived input (`transcript_path` or `CLAUDE_TRANSCRIPT_PATH`). That is common for Stop and SessionEnd hooks, but it should be reviewed because downstream logic can process sensitive prompt and tool history." + }, + "help": { + "text": "This non-shell hook implementation reads transcript-derived input (`transcript_path` or `CLAUDE_TRANSCRIPT_PATH`). That is common for Stop and SessionEnd hooks, but it should be reviewed because downstream logic can process sensitive prompt and tool history." + }, + "defaultConfiguration": { + "level": "note" + }, + "properties": { + "category": "hooks", + "severity": "info", + "security-severity": "1.0", + "tags": [ + "security", + "agent-config", + "hooks" + ], + "precision": "high" + } + }, + { + "id": "hooks-code-transcript-access-207", + "name": "Hook code reads Claude transcript input", + "shortDescription": { + "text": "Hook code reads Claude transcript input" + }, + "fullDescription": { + "text": "This non-shell hook implementation reads transcript-derived input (`transcript_path` or `CLAUDE_TRANSCRIPT_PATH`). That is common for Stop and SessionEnd hooks, but it should be reviewed because downstream logic can process sensitive prompt and tool history." + }, + "help": { + "text": "This non-shell hook implementation reads transcript-derived input (`transcript_path` or `CLAUDE_TRANSCRIPT_PATH`). That is common for Stop and SessionEnd hooks, but it should be reviewed because downstream logic can process sensitive prompt and tool history." + }, + "defaultConfiguration": { + "level": "note" + }, + "properties": { + "category": "hooks", + "severity": "info", + "security-severity": "1.0", + "tags": [ + "security", + "agent-config", + "hooks" + ], + "precision": "high" + } + } + ] + } + }, + "automationDetails": { + "id": "agentshield/security-scan" + }, + "invocations": [ + { + "executionSuccessful": true, + "endTimeUtc": "2026-09-30T16:08:01.455Z", + "workingDirectory": { + "uri": "" + } + } + ], + "properties": { + "score": 83, + "grade": "B", + "filesScanned": 28 + }, + "results": [ + { + "ruleId": "hooks-no-pretooluse", + "ruleIndex": 0, + "level": "warning", + "message": { + "text": "No PreToolUse hooks are defined. These hooks can catch dangerous operations before they run, providing an essential security layer." + }, + "locations": [ + { + "physicalLocation": { + "artifactLocation": { + "uri": "hooks/codex-hooks.json" + } + } + } + ], + "properties": { + "title": "No PreToolUse security hooks configured", + "category": "misconfiguration", + "severity": "medium", + "fix": "Add PreToolUse hooks for security-sensitive operations" + } + }, + { + "ruleId": "hooks-chained-commands-0", + "ruleIndex": 1, + "level": "warning", + "message": { + "text": "A hook chains 31 commands together: \"node -e \"if(!process.env.PLUGIN_ROOT)throw new Error('Missing Codex PLUGIN_ROOT'...\". Complex chained commands in hooks are harder to audit and may perform operations beyond the hook's stated purpose. Consider breaking into a dedicated script file." + }, + "locations": [ + { + "physicalLocation": { + "artifactLocation": { + "uri": "hooks/codex-hooks.json" + } + } + } + ], + "properties": { + "title": "Hook has 31 chained commands", + "category": "hooks", + "severity": "medium", + "evidence": "node -e \"if(!process.env.PLUGIN_ROOT)throw new Error('Missing Codex PLUGIN_ROOT');process.env.CLAUDE", + "fix": "Move complex logic to a script file" + } + }, + { + "ruleId": "hooks-inline-eval-payload", + "ruleIndex": 2, + "level": "warning", + "message": { + "text": "The hook command feeds a long inline payload to an interpreter or decoder instead of running a script file. Inline payloads are hard to review, are not anchored to a plugin or project directory, and are the usual way to hide a dropper in a hook." + }, + "locations": [ + { + "physicalLocation": { + "artifactLocation": { + "uri": "hooks/codex-hooks.json" + }, + "region": { + "startLine": 4 + } + } + } + ], + "properties": { + "title": "SessionStart hook runs a 1177-character inline payload", + "category": "hooks", + "severity": "medium", + "evidence": "node -e \"if(!process.env.PLUGIN_ROOT)throw new Error('Missing Codex PLUGIN_ROOT');process.env.CLAUDE_PLUGIN_ROOT=process..." + } + }, + { + "ruleId": "hooks-inline-eval-payload", + "ruleIndex": 2, + "level": "warning", + "message": { + "text": "This finding comes from a declarative hook manifest. Review the referenced hook implementation to confirm the exact runtime behavior. The hook command feeds a long inline payload to an interpreter or decoder instead of running a script file. Inline payloads are hard to review, are not anchored to a plugin or project directory, and are the usual way to hide a dropper in a hook." + }, + "locations": [ + { + "physicalLocation": { + "artifactLocation": { + "uri": "hooks/hooks.json" + }, + "region": { + "startLine": 3 + } + } + } + ], + "properties": { + "title": "Plugin hook manifest: PreToolUse hook runs a 1045-character inline payload", + "category": "hooks", + "severity": "medium", + "runtimeConfidence": "plugin-manifest", + "evidence": "node -e \"const p=require('path');const r=(function(){var p=require('path'),f=require('fs'),o=require('os');var e=process..." + } + }, + { + "ruleId": "hooks-inline-eval-payload", + "ruleIndex": 2, + "level": "warning", + "message": { + "text": "This finding comes from a declarative hook manifest. Review the referenced hook implementation to confirm the exact runtime behavior. The hook command feeds a long inline payload to an interpreter or decoder instead of running a script file. Inline payloads are hard to review, are not anchored to a plugin or project directory, and are the usual way to hide a dropper in a hook." + }, + "locations": [ + { + "physicalLocation": { + "artifactLocation": { + "uri": "hooks/hooks.json" + }, + "region": { + "startLine": 3 + } + } + } + ], + "properties": { + "title": "Plugin hook manifest: PreToolUse hook runs a 1130-character inline payload", + "category": "hooks", + "severity": "medium", + "runtimeConfidence": "plugin-manifest", + "evidence": "node -e \"const p=require('path');const r=(function(){var p=require('path'),f=require('fs'),o=require('os');var e=process..." + } + }, + { + "ruleId": "hooks-inline-eval-payload", + "ruleIndex": 2, + "level": "warning", + "message": { + "text": "This finding comes from a declarative hook manifest. Review the referenced hook implementation to confirm the exact runtime behavior. The hook command feeds a long inline payload to an interpreter or decoder instead of running a script file. Inline payloads are hard to review, are not anchored to a plugin or project directory, and are the usual way to hide a dropper in a hook." + }, + "locations": [ + { + "physicalLocation": { + "artifactLocation": { + "uri": "hooks/hooks.json" + }, + "region": { + "startLine": 3 + } + } + } + ], + "properties": { + "title": "Plugin hook manifest: PreToolUse hook runs a 1117-character inline payload", + "category": "hooks", + "severity": "medium", + "runtimeConfidence": "plugin-manifest", + "evidence": "node -e \"const p=require('path');const r=(function(){var p=require('path'),f=require('fs'),o=require('os');var e=process..." + } + }, + { + "ruleId": "hooks-inline-eval-payload", + "ruleIndex": 2, + "level": "warning", + "message": { + "text": "This finding comes from a declarative hook manifest. Review the referenced hook implementation to confirm the exact runtime behavior. The hook command feeds a long inline payload to an interpreter or decoder instead of running a script file. Inline payloads are hard to review, are not anchored to a plugin or project directory, and are the usual way to hide a dropper in a hook." + }, + "locations": [ + { + "physicalLocation": { + "artifactLocation": { + "uri": "hooks/hooks.json" + }, + "region": { + "startLine": 3 + } + } + } + ], + "properties": { + "title": "Plugin hook manifest: PreToolUse hook runs a 1120-character inline payload", + "category": "hooks", + "severity": "medium", + "runtimeConfidence": "plugin-manifest", + "evidence": "node -e \"const p=require('path');const r=(function(){var p=require('path'),f=require('fs'),o=require('os');var e=process..." + } + }, + { + "ruleId": "hooks-inline-eval-payload", + "ruleIndex": 2, + "level": "warning", + "message": { + "text": "This finding comes from a declarative hook manifest. Review the referenced hook implementation to confirm the exact runtime behavior. The hook command feeds a long inline payload to an interpreter or decoder instead of running a script file. Inline payloads are hard to review, are not anchored to a plugin or project directory, and are the usual way to hide a dropper in a hook." + }, + "locations": [ + { + "physicalLocation": { + "artifactLocation": { + "uri": "hooks/hooks.json" + }, + "region": { + "startLine": 3 + } + } + } + ], + "properties": { + "title": "Plugin hook manifest: PreToolUse hook runs a 1100-character inline payload", + "category": "hooks", + "severity": "medium", + "runtimeConfidence": "plugin-manifest", + "evidence": "node -e \"const p=require('path');const r=(function(){var p=require('path'),f=require('fs'),o=require('os');var e=process..." + } + }, + { + "ruleId": "hooks-inline-eval-payload", + "ruleIndex": 2, + "level": "warning", + "message": { + "text": "This finding comes from a declarative hook manifest. Review the referenced hook implementation to confirm the exact runtime behavior. The hook command feeds a long inline payload to an interpreter or decoder instead of running a script file. Inline payloads are hard to review, are not anchored to a plugin or project directory, and are the usual way to hide a dropper in a hook." + }, + "locations": [ + { + "physicalLocation": { + "artifactLocation": { + "uri": "hooks/hooks.json" + }, + "region": { + "startLine": 3 + } + } + } + ], + "properties": { + "title": "Plugin hook manifest: PreToolUse hook runs a 1115-character inline payload", + "category": "hooks", + "severity": "medium", + "runtimeConfidence": "plugin-manifest", + "evidence": "node -e \"const p=require('path');const r=(function(){var p=require('path'),f=require('fs'),o=require('os');var e=process..." + } + }, + { + "ruleId": "hooks-inline-eval-payload", + "ruleIndex": 2, + "level": "warning", + "message": { + "text": "This finding comes from a declarative hook manifest. Review the referenced hook implementation to confirm the exact runtime behavior. The hook command feeds a long inline payload to an interpreter or decoder instead of running a script file. Inline payloads are hard to review, are not anchored to a plugin or project directory, and are the usual way to hide a dropper in a hook." + }, + "locations": [ + { + "physicalLocation": { + "artifactLocation": { + "uri": "hooks/hooks.json" + }, + "region": { + "startLine": 3 + } + } + } + ], + "properties": { + "title": "Plugin hook manifest: PreToolUse hook runs a 1113-character inline payload", + "category": "hooks", + "severity": "medium", + "runtimeConfidence": "plugin-manifest", + "evidence": "node -e \"const p=require('path');const r=(function(){var p=require('path'),f=require('fs'),o=require('os');var e=process..." + } + }, + { + "ruleId": "hooks-inline-eval-payload", + "ruleIndex": 2, + "level": "warning", + "message": { + "text": "This finding comes from a declarative hook manifest. Review the referenced hook implementation to confirm the exact runtime behavior. The hook command feeds a long inline payload to an interpreter or decoder instead of running a script file. Inline payloads are hard to review, are not anchored to a plugin or project directory, and are the usual way to hide a dropper in a hook." + }, + "locations": [ + { + "physicalLocation": { + "artifactLocation": { + "uri": "hooks/hooks.json" + }, + "region": { + "startLine": 3 + } + } + } + ], + "properties": { + "title": "Plugin hook manifest: PreToolUse hook runs a 1111-character inline payload", + "category": "hooks", + "severity": "medium", + "runtimeConfidence": "plugin-manifest", + "evidence": "node -e \"const p=require('path');const r=(function(){var p=require('path'),f=require('fs'),o=require('os');var e=process..." + } + }, + { + "ruleId": "hooks-inline-eval-payload", + "ruleIndex": 2, + "level": "warning", + "message": { + "text": "This finding comes from a declarative hook manifest. Review the referenced hook implementation to confirm the exact runtime behavior. The hook command feeds a long inline payload to an interpreter or decoder instead of running a script file. Inline payloads are hard to review, are not anchored to a plugin or project directory, and are the usual way to hide a dropper in a hook." + }, + "locations": [ + { + "physicalLocation": { + "artifactLocation": { + "uri": "hooks/hooks.json" + }, + "region": { + "startLine": 3 + } + } + } + ], + "properties": { + "title": "Plugin hook manifest: PreToolUse hook runs a 1130-character inline payload", + "category": "hooks", + "severity": "medium", + "runtimeConfidence": "plugin-manifest", + "evidence": "node -e \"const p=require('path');const r=(function(){var p=require('path'),f=require('fs'),o=require('os');var e=process..." + } + }, + { + "ruleId": "hooks-inline-eval-payload", + "ruleIndex": 2, + "level": "warning", + "message": { + "text": "This finding comes from a declarative hook manifest. Review the referenced hook implementation to confirm the exact runtime behavior. The hook command feeds a long inline payload to an interpreter or decoder instead of running a script file. Inline payloads are hard to review, are not anchored to a plugin or project directory, and are the usual way to hide a dropper in a hook." + }, + "locations": [ + { + "physicalLocation": { + "artifactLocation": { + "uri": "hooks/hooks.json" + }, + "region": { + "startLine": 92 + } + } + } + ], + "properties": { + "title": "Plugin hook manifest: PreCompact hook runs a 1097-character inline payload", + "category": "hooks", + "severity": "medium", + "runtimeConfidence": "plugin-manifest", + "evidence": "node -e \"const p=require('path');const r=(function(){var p=require('path'),f=require('fs'),o=require('os');var e=process..." + } + }, + { + "ruleId": "hooks-inline-eval-payload", + "ruleIndex": 2, + "level": "warning", + "message": { + "text": "This finding comes from a declarative hook manifest. Review the referenced hook implementation to confirm the exact runtime behavior. The hook command feeds a long inline payload to an interpreter or decoder instead of running a script file. Inline payloads are hard to review, are not anchored to a plugin or project directory, and are the usual way to hide a dropper in a hook." + }, + "locations": [ + { + "physicalLocation": { + "artifactLocation": { + "uri": "hooks/hooks.json" + }, + "region": { + "startLine": 103 + } + } + } + ], + "properties": { + "title": "Plugin hook manifest: SessionStart hook runs a 1049-character inline payload", + "category": "hooks", + "severity": "medium", + "runtimeConfidence": "plugin-manifest", + "evidence": "node -e \"const p=require('path');const r=(function(){var p=require('path'),f=require('fs'),o=require('os');var e=process..." + } + }, + { + "ruleId": "hooks-inline-eval-payload", + "ruleIndex": 2, + "level": "warning", + "message": { + "text": "This finding comes from a declarative hook manifest. Review the referenced hook implementation to confirm the exact runtime behavior. The hook command feeds a long inline payload to an interpreter or decoder instead of running a script file. Inline payloads are hard to review, are not anchored to a plugin or project directory, and are the usual way to hide a dropper in a hook." + }, + "locations": [ + { + "physicalLocation": { + "artifactLocation": { + "uri": "hooks/hooks.json" + }, + "region": { + "startLine": 103 + } + } + } + ], + "properties": { + "title": "Plugin hook manifest: SessionStart hook runs a 1129-character inline payload", + "category": "hooks", + "severity": "medium", + "runtimeConfidence": "plugin-manifest", + "evidence": "node -e \"const p=require('path');const r=(function(){var p=require('path'),f=require('fs'),o=require('os');var e=process..." + } + }, + { + "ruleId": "hooks-inline-eval-payload", + "ruleIndex": 2, + "level": "warning", + "message": { + "text": "This finding comes from a declarative hook manifest. Review the referenced hook implementation to confirm the exact runtime behavior. The hook command feeds a long inline payload to an interpreter or decoder instead of running a script file. Inline payloads are hard to review, are not anchored to a plugin or project directory, and are the usual way to hide a dropper in a hook." + }, + "locations": [ + { + "physicalLocation": { + "artifactLocation": { + "uri": "hooks/hooks.json" + }, + "region": { + "startLine": 123 + } + } + } + ], + "properties": { + "title": "Plugin hook manifest: PostToolUse hook runs a 1015-character inline payload", + "category": "hooks", + "severity": "medium", + "runtimeConfidence": "plugin-manifest", + "evidence": "node -e \"const p=require('path');const r=(function(){var p=require('path'),f=require('fs'),o=require('os');var e=process..." + } + }, + { + "ruleId": "hooks-inline-eval-payload", + "ruleIndex": 2, + "level": "warning", + "message": { + "text": "This finding comes from a declarative hook manifest. Review the referenced hook implementation to confirm the exact runtime behavior. The hook command feeds a long inline payload to an interpreter or decoder instead of running a script file. Inline payloads are hard to review, are not anchored to a plugin or project directory, and are the usual way to hide a dropper in a hook." + }, + "locations": [ + { + "physicalLocation": { + "artifactLocation": { + "uri": "hooks/hooks.json" + }, + "region": { + "startLine": 123 + } + } + } + ], + "properties": { + "title": "Plugin hook manifest: PostToolUse hook runs a 1016-character inline payload", + "category": "hooks", + "severity": "medium", + "runtimeConfidence": "plugin-manifest", + "evidence": "node -e \"const p=require('path');const r=(function(){var p=require('path'),f=require('fs'),o=require('os');var e=process..." + } + }, + { + "ruleId": "hooks-inline-eval-payload", + "ruleIndex": 2, + "level": "warning", + "message": { + "text": "This finding comes from a declarative hook manifest. Review the referenced hook implementation to confirm the exact runtime behavior. The hook command feeds a long inline payload to an interpreter or decoder instead of running a script file. Inline payloads are hard to review, are not anchored to a plugin or project directory, and are the usual way to hide a dropper in a hook." + }, + "locations": [ + { + "physicalLocation": { + "artifactLocation": { + "uri": "hooks/hooks.json" + }, + "region": { + "startLine": 146 + } + } + } + ], + "properties": { + "title": "Plugin hook manifest: PostToolUseFailure hook runs a 1112-character inline payload", + "category": "hooks", + "severity": "medium", + "runtimeConfidence": "plugin-manifest", + "evidence": "node -e \"const p=require('path');const r=(function(){var p=require('path'),f=require('fs'),o=require('os');var e=process..." + } + }, + { + "ruleId": "hooks-inline-eval-payload", + "ruleIndex": 2, + "level": "warning", + "message": { + "text": "This finding comes from a declarative hook manifest. Review the referenced hook implementation to confirm the exact runtime behavior. The hook command feeds a long inline payload to an interpreter or decoder instead of running a script file. Inline payloads are hard to review, are not anchored to a plugin or project directory, and are the usual way to hide a dropper in a hook." + }, + "locations": [ + { + "physicalLocation": { + "artifactLocation": { + "uri": "hooks/hooks.json" + }, + "region": { + "startLine": 146 + } + } + } + ], + "properties": { + "title": "Plugin hook manifest: PostToolUseFailure hook runs a 1108-character inline payload", + "category": "hooks", + "severity": "medium", + "runtimeConfidence": "plugin-manifest", + "evidence": "node -e \"const p=require('path');const r=(function(){var p=require('path'),f=require('fs'),o=require('os');var e=process..." + } + }, + { + "ruleId": "hooks-inline-eval-payload", + "ruleIndex": 2, + "level": "warning", + "message": { + "text": "This finding comes from a declarative hook manifest. Review the referenced hook implementation to confirm the exact runtime behavior. The hook command feeds a long inline payload to an interpreter or decoder instead of running a script file. Inline payloads are hard to review, are not anchored to a plugin or project directory, and are the usual way to hide a dropper in a hook." + }, + "locations": [ + { + "physicalLocation": { + "artifactLocation": { + "uri": "hooks/hooks.json" + }, + "region": { + "startLine": 166 + } + } + } + ], + "properties": { + "title": "Plugin hook manifest: Stop hook runs a 1407-character inline payload", + "category": "hooks", + "severity": "medium", + "runtimeConfidence": "plugin-manifest", + "evidence": "node -e \"const p=require('path');const r=(function(){var p=require('path'),f=require('fs'),o=require('os');var e=process..." + } + }, + { + "ruleId": "hooks-inline-eval-payload", + "ruleIndex": 2, + "level": "warning", + "message": { + "text": "This finding comes from a declarative hook manifest. Review the referenced hook implementation to confirm the exact runtime behavior. The hook command feeds a long inline payload to an interpreter or decoder instead of running a script file. Inline payloads are hard to review, are not anchored to a plugin or project directory, and are the usual way to hide a dropper in a hook." + }, + "locations": [ + { + "physicalLocation": { + "artifactLocation": { + "uri": "hooks/hooks.json" + }, + "region": { + "startLine": 166 + } + } + } + ], + "properties": { + "title": "Plugin hook manifest: Stop hook runs a 1399-character inline payload", + "category": "hooks", + "severity": "medium", + "runtimeConfidence": "plugin-manifest", + "evidence": "node -e \"const p=require('path');const r=(function(){var p=require('path'),f=require('fs'),o=require('os');var e=process..." + } + }, + { + "ruleId": "hooks-inline-eval-payload", + "ruleIndex": 2, + "level": "warning", + "message": { + "text": "This finding comes from a declarative hook manifest. Review the referenced hook implementation to confirm the exact runtime behavior. The hook command feeds a long inline payload to an interpreter or decoder instead of running a script file. Inline payloads are hard to review, are not anchored to a plugin or project directory, and are the usual way to hide a dropper in a hook." + }, + "locations": [ + { + "physicalLocation": { + "artifactLocation": { + "uri": "hooks/hooks.json" + }, + "region": { + "startLine": 166 + } + } + } + ], + "properties": { + "title": "Plugin hook manifest: Stop hook runs a 1395-character inline payload", + "category": "hooks", + "severity": "medium", + "runtimeConfidence": "plugin-manifest", + "evidence": "node -e \"const p=require('path');const r=(function(){var p=require('path'),f=require('fs'),o=require('os');var e=process..." + } + }, + { + "ruleId": "hooks-inline-eval-payload", + "ruleIndex": 2, + "level": "warning", + "message": { + "text": "This finding comes from a declarative hook manifest. Review the referenced hook implementation to confirm the exact runtime behavior. The hook command feeds a long inline payload to an interpreter or decoder instead of running a script file. Inline payloads are hard to review, are not anchored to a plugin or project directory, and are the usual way to hide a dropper in a hook." + }, + "locations": [ + { + "physicalLocation": { + "artifactLocation": { + "uri": "hooks/hooks.json" + }, + "region": { + "startLine": 166 + } + } + } + ], + "properties": { + "title": "Plugin hook manifest: Stop hook runs a 1391-character inline payload", + "category": "hooks", + "severity": "medium", + "runtimeConfidence": "plugin-manifest", + "evidence": "node -e \"const p=require('path');const r=(function(){var p=require('path'),f=require('fs'),o=require('os');var e=process..." + } + }, + { + "ruleId": "hooks-inline-eval-payload", + "ruleIndex": 2, + "level": "warning", + "message": { + "text": "This finding comes from a declarative hook manifest. Review the referenced hook implementation to confirm the exact runtime behavior. The hook command feeds a long inline payload to an interpreter or decoder instead of running a script file. Inline payloads are hard to review, are not anchored to a plugin or project directory, and are the usual way to hide a dropper in a hook." + }, + "locations": [ + { + "physicalLocation": { + "artifactLocation": { + "uri": "hooks/hooks.json" + }, + "region": { + "startLine": 166 + } + } + } + ], + "properties": { + "title": "Plugin hook manifest: Stop hook runs a 1401-character inline payload", + "category": "hooks", + "severity": "medium", + "runtimeConfidence": "plugin-manifest", + "evidence": "node -e \"const p=require('path');const r=(function(){var p=require('path'),f=require('fs'),o=require('os');var e=process..." + } + }, + { + "ruleId": "hooks-inline-eval-payload", + "ruleIndex": 2, + "level": "warning", + "message": { + "text": "This finding comes from a declarative hook manifest. Review the referenced hook implementation to confirm the exact runtime behavior. The hook command feeds a long inline payload to an interpreter or decoder instead of running a script file. Inline payloads are hard to review, are not anchored to a plugin or project directory, and are the usual way to hide a dropper in a hook." + }, + "locations": [ + { + "physicalLocation": { + "artifactLocation": { + "uri": "hooks/hooks.json" + }, + "region": { + "startLine": 166 + } + } + } + ], + "properties": { + "title": "Plugin hook manifest: Stop hook runs a 1393-character inline payload", + "category": "hooks", + "severity": "medium", + "runtimeConfidence": "plugin-manifest", + "evidence": "node -e \"const p=require('path');const r=(function(){var p=require('path'),f=require('fs'),o=require('os');var e=process..." + } + }, + { + "ruleId": "hooks-inline-eval-payload", + "ruleIndex": 2, + "level": "warning", + "message": { + "text": "This finding comes from a declarative hook manifest. Review the referenced hook implementation to confirm the exact runtime behavior. The hook command feeds a long inline payload to an interpreter or decoder instead of running a script file. Inline payloads are hard to review, are not anchored to a plugin or project directory, and are the usual way to hide a dropper in a hook." + }, + "locations": [ + { + "physicalLocation": { + "artifactLocation": { + "uri": "hooks/hooks.json" + }, + "region": { + "startLine": 166 + } + } + } + ], + "properties": { + "title": "Plugin hook manifest: Stop hook runs a 1389-character inline payload", + "category": "hooks", + "severity": "medium", + "runtimeConfidence": "plugin-manifest", + "evidence": "node -e \"const p=require('path');const r=(function(){var p=require('path'),f=require('fs'),o=require('os');var e=process..." + } + }, + { + "ruleId": "hooks-inline-eval-payload", + "ruleIndex": 2, + "level": "warning", + "message": { + "text": "This finding comes from a declarative hook manifest. Review the referenced hook implementation to confirm the exact runtime behavior. The hook command feeds a long inline payload to an interpreter or decoder instead of running a script file. Inline payloads are hard to review, are not anchored to a plugin or project directory, and are the usual way to hide a dropper in a hook." + }, + "locations": [ + { + "physicalLocation": { + "artifactLocation": { + "uri": "hooks/hooks.json" + }, + "region": { + "startLine": 240 + } + } + } + ], + "properties": { + "title": "Plugin hook manifest: SessionEnd hook runs a 1400-character inline payload", + "category": "hooks", + "severity": "medium", + "runtimeConfidence": "plugin-manifest", + "evidence": "node -e \"const p=require('path');const r=(function(){var p=require('path'),f=require('fs'),o=require('os');var e=process..." + } + }, + { + "ruleId": "permissions-no-block", + "ruleIndex": 3, + "level": "warning", + "message": { + "text": "settings.json has configuration but no permissions section. Without explicit allow/deny lists, the agent relies on default permissions which may be too broad. Add a permissions block to restrict tool access." + }, + "locations": [ + { + "physicalLocation": { + "artifactLocation": { + "uri": "hooks/hooks.metadata.json" + } + } + } + ], + "properties": { + "title": "No permissions block configured", + "category": "permissions", + "severity": "medium", + "fix": "Add a permissions block with scoped allow and deny lists" + } + }, + { + "ruleId": "hooks-no-pretooluse", + "ruleIndex": 0, + "level": "warning", + "message": { + "text": "No PreToolUse hooks are defined. These hooks can catch dangerous operations before they run, providing an essential security layer." + }, + "locations": [ + { + "physicalLocation": { + "artifactLocation": { + "uri": "hooks/hooks.metadata.json" + } + } + } + ], + "properties": { + "title": "No PreToolUse security hooks configured", + "category": "misconfiguration", + "severity": "medium", + "fix": "Add PreToolUse hooks for security-sensitive operations" + } + }, + { + "ruleId": "hooks-no-stop-hooks", + "ruleIndex": 4, + "level": "note", + "message": { + "text": "Hooks are configured but no Stop hooks exist. Stop hooks run when a session ends and are useful for final verification — checking for uncommitted secrets, ensuring console.log statements were removed, or auditing file changes." + }, + "locations": [ + { + "physicalLocation": { + "artifactLocation": { + "uri": "hooks/codex-hooks.json" + } + } + } + ], + "properties": { + "title": "No Stop hooks for session-end verification", + "category": "misconfiguration", + "severity": "low", + "fix": "Add a Stop hook for session-end checks" + } + }, + { + "ruleId": "hooks-code-transcript-access-1418", + "ruleIndex": 5, + "level": "note", + "message": { + "text": "This non-shell hook implementation reads transcript-derived input (`transcript_path` or `CLAUDE_TRANSCRIPT_PATH`). That is common for Stop and SessionEnd hooks, but it should be reviewed because downstream logic can process sensitive prompt and tool history." + }, + "locations": [ + { + "physicalLocation": { + "artifactLocation": { + "uri": "scripts/hooks/gateguard-fact-force.js" + }, + "region": { + "startLine": 1418 + } + } + } + ], + "properties": { + "title": "Hook code reads Claude transcript input", + "category": "hooks", + "severity": "info", + "runtimeConfidence": "hook-code", + "evidence": "const transcriptPath = (data && (data.transcript_path || data.transcriptPath)) || process.env.CLAUDE_TRANSCRIPT_PATH;" + } + }, + { + "ruleId": "hooks-code-context-output-262", + "ruleIndex": 6, + "level": "note", + "message": { + "text": "This non-shell hook implementation calls an output helper that writes content back into Claude context. That is often legitimate, but it should be reviewed because untrusted summaries or derived data can become prompt-injection surface." + }, + "locations": [ + { + "physicalLocation": { + "artifactLocation": { + "uri": "scripts/hooks/suggest-compact.js" + }, + "region": { + "startLine": 262 + } + } + } + ], + "properties": { + "title": "Hook code injects content into Claude context", + "category": "hooks", + "severity": "info", + "runtimeConfidence": "hook-code", + "evidence": "output({" + } + }, + { + "ruleId": "hooks-code-transcript-access-217", + "ruleIndex": 7, + "level": "note", + "message": { + "text": "This non-shell hook implementation reads transcript-derived input (`transcript_path` or `CLAUDE_TRANSCRIPT_PATH`). That is common for Stop and SessionEnd hooks, but it should be reviewed because downstream logic can process sensitive prompt and tool history." + }, + "locations": [ + { + "physicalLocation": { + "artifactLocation": { + "uri": "scripts/hooks/suggest-compact.js" + }, + "region": { + "startLine": 217 + } + } + } + ], + "properties": { + "title": "Hook code reads Claude transcript input", + "category": "hooks", + "severity": "info", + "runtimeConfidence": "hook-code", + "evidence": "const transcriptPath = (input && typeof input.transcript_path === 'string') ? input.transcript_path : '';" + } + }, + { + "ruleId": "hooks-code-transcript-access-117", + "ruleIndex": 8, + "level": "note", + "message": { + "text": "This non-shell hook implementation reads transcript-derived input (`transcript_path` or `CLAUDE_TRANSCRIPT_PATH`). That is common for Stop and SessionEnd hooks, but it should be reviewed because downstream logic can process sensitive prompt and tool history." + }, + "locations": [ + { + "physicalLocation": { + "artifactLocation": { + "uri": "scripts/hooks/pre-compact.js" + }, + "region": { + "startLine": 117 + } + } + } + ], + "properties": { + "title": "Hook code reads Claude transcript input", + "category": "hooks", + "severity": "info", + "runtimeConfidence": "hook-code", + "evidence": "if (input && typeof input.transcript_path === 'string' && input.transcript_path.length > 0) {" + } + }, + { + "ruleId": "hooks-code-transcript-access-183", + "ruleIndex": 9, + "level": "note", + "message": { + "text": "This non-shell hook implementation reads transcript-derived input (`transcript_path` or `CLAUDE_TRANSCRIPT_PATH`). That is common for Stop and SessionEnd hooks, but it should be reviewed because downstream logic can process sensitive prompt and tool history." + }, + "locations": [ + { + "physicalLocation": { + "artifactLocation": { + "uri": "scripts/hooks/session-end.js" + }, + "region": { + "startLine": 183 + } + } + } + ], + "properties": { + "title": "Hook code reads Claude transcript input", + "category": "hooks", + "severity": "info", + "runtimeConfidence": "hook-code", + "evidence": "if (input && typeof input.transcript_path === 'string' && input.transcript_path.length > 0) {" + } + }, + { + "ruleId": "hooks-code-transcript-access-49", + "ruleIndex": 10, + "level": "note", + "message": { + "text": "This non-shell hook implementation reads transcript-derived input (`transcript_path` or `CLAUDE_TRANSCRIPT_PATH`). That is common for Stop and SessionEnd hooks, but it should be reviewed because downstream logic can process sensitive prompt and tool history." + }, + "locations": [ + { + "physicalLocation": { + "artifactLocation": { + "uri": "scripts/hooks/evaluate-session.js" + }, + "region": { + "startLine": 49 + } + } + } + ], + "properties": { + "title": "Hook code reads Claude transcript input", + "category": "hooks", + "severity": "info", + "runtimeConfidence": "hook-code", + "evidence": "transcriptPath = input.transcript_path;" + } + }, + { + "ruleId": "hooks-code-transcript-access-207", + "ruleIndex": 11, + "level": "note", + "message": { + "text": "This non-shell hook implementation reads transcript-derived input (`transcript_path` or `CLAUDE_TRANSCRIPT_PATH`). That is common for Stop and SessionEnd hooks, but it should be reviewed because downstream logic can process sensitive prompt and tool history." + }, + "locations": [ + { + "physicalLocation": { + "artifactLocation": { + "uri": "scripts/hooks/cost-tracker.js" + }, + "region": { + "startLine": 207 + } + } + } + ], + "properties": { + "title": "Hook code reads Claude transcript input", + "category": "hooks", + "severity": "info", + "runtimeConfidence": "hook-code", + "evidence": "const transcriptPath = (typeof input.transcript_path === 'string' && input.transcript_path)" + } + } + ] + } + ] +} diff --git a/docs/security/evidence/gateguard-fact-force.md b/docs/security/evidence/gateguard-fact-force.md new file mode 100644 index 000000000..65fd57bbc --- /dev/null +++ b/docs/security/evidence/gateguard-fact-force.md @@ -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. diff --git a/docs/security/evidence/gateguard-fact-force.sarif b/docs/security/evidence/gateguard-fact-force.sarif new file mode 100644 index 000000000..a0bc2e3fc --- /dev/null +++ b/docs/security/evidence/gateguard-fact-force.sarif @@ -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": [] + } + ] +} diff --git a/docs/zh-CN/skills/gateguard/SKILL.md b/docs/zh-CN/skills/gateguard/SKILL.md index 05b651d3d..f6669fa4f 100644 --- a/docs/zh-CN/skills/gateguard/SKILL.md +++ b/docs/zh-CN/skills/gateguard/SKILL.md @@ -86,6 +86,24 @@ LLM 的自我评估不起作用。问"你是否违反了任何策略?"答案 2. 此特定命令验证或生成的内容 ``` +## 按目标类别提问与已完成调查的认定 + +以上编辑/写入门控的问题适用于 **code** 类目标。完整规则见英文版 `skills/gateguard/SKILL.md`,要点如下: + +* **目标类别:** 首次编辑/写入按项目相对路径(去掉开头的 `.claude/worktrees//`)依次判定为 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` 后,每次判定向 `/metrics.jsonl` 追加一行(不记录路径、命令或内容,超过 1 MiB 轮转为 `.1`);用 `node scripts/gateguard-report.js [--dir ] [--json]` 汇总。 +* **兼容性:** 新增的环境变量只有可选的 `GATEGUARD_FACT_FORCE_MAX_DENIALS` 和 `GATEGUARD_METRICS`;除子代理首次触碰敏感或硬链接目标、以及每个笔记本的首次 NotebookEdit(此前不受门控)外,以前允许的操作不会变为拒绝,且从不返回 `permissionDecision: "allow"`。 + ## 快速开始 ### 选项 A:使用 ECC 钩子(零安装) diff --git a/hooks/hooks.json b/hooks/hooks.json index e399e2c17..6f47a4e5d 100644 --- a/hooks/hooks.json +++ b/hooks/hooks.json @@ -79,7 +79,7 @@ ] }, { - "matcher": "Edit|Write|MultiEdit", + "matcher": "Edit|Write|MultiEdit|NotebookEdit", "hooks": [ { "type": "command", diff --git a/hooks/hooks.metadata.json b/hooks/hooks.metadata.json index e598a823e..829151763 100644 --- a/hooks/hooks.metadata.json +++ b/hooks/hooks.metadata.json @@ -44,8 +44,8 @@ }, { "id": "pre:edit-write:gateguard-fact-force", - "description": "Fact-forcing gate: block first Edit/Write/MultiEdit per file and demand investigation (importers, data schemas, user instruction) before allowing", - "fingerprint": "32c4a312b4c2" + "description": "Fact-forcing gate: deny the first Edit/Write/MultiEdit/NotebookEdit of each file with questions chosen by target class and by what the change touches; a completed search naming the file in the current turn, a comment or whitespace-only edit, or a same-turn sibling of a new file already gated passes with a note instead; sensitive targets (secrets, keys, auth/payment code, migrations, CI workflows) and hard-linked files are always gated, also in subagents", + "fingerprint": "45fbef2d7f27" }, { "id": "pre:hookify-runtime", diff --git a/manifests/context-packs/skill-triggers@1.json b/manifests/context-packs/skill-triggers@1.json index 74134f94d..4923073a1 100644 --- a/manifests/context-packs/skill-triggers@1.json +++ b/manifests/context-packs/skill-triggers@1.json @@ -10,7 +10,7 @@ "id": "hand-seeded", "source": "manual-curation-pending-regeneration" }, - "registryDigest": "577a749c4ea19a03c2cd5263f1b1797cc45d395cae2b934b307c912bb2a97059", + "registryDigest": "9412b391eb5a4f4a59b78d36965554c815c1b76dd96b8758e2d1da2854c4c041", "schemaVersion": 1, "triggers": { "skill:api-connector-builder": [ diff --git a/package.json b/package.json index c37bfc4f0..efd00a843 100644 --- a/package.json +++ b/package.json @@ -130,6 +130,7 @@ "scripts/memory.js", "scripts/memory-mcp.mjs", "scripts/gemini-adapt-agents.js", + "scripts/gateguard-report.js", "scripts/github-coordination.js", "scripts/qwen-adapt-agents.js", "scripts/harness-adapter-compliance.js", diff --git a/scripts/dev/gateguard-eval.js b/scripts/dev/gateguard-eval.js new file mode 100644 index 000000000..261595600 --- /dev/null +++ b/scripts/dev/gateguard-eval.js @@ -0,0 +1,777 @@ +#!/usr/bin/env node +'use strict'; + +const { execFileSync } = require('child_process'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const { performance } = require('perf_hooks'); +const { pathToFileURL } = require('url'); +const { Worker, isMainThread, parentPort, workerData } = require('worker_threads'); + +const REPO_ROOT = path.join(__dirname, '..', '..'); +const HOOK_RELATIVE_PATH = 'scripts/hooks/gateguard-fact-force.js'; +const DEFAULT_CORPUS_DIR = path.join(REPO_ROOT, 'tests', 'fixtures', 'gateguard-scenarios'); +const DEFAULT_BASELINE = 'upstream/main'; +const EXPECTATIONS = new Set(['deny', 'allow']); +const TOKEN_CHARS = 4; +const CONDENSED_PREFIX = /^\[Fact-Forcing Gate\] \(denial #\d+ this session\) First (edit|creation) of /; + +const QUESTION_IDS = Object.freeze([ + 'importers', + 'public-api', + 'local-callers', + 'callers', + 'no-duplicate', + 'data-schema', + 'external-contract', + 'loader', + 'behaviour-change', + 'no-duplicate-instruction', + 'under-test', + 'existing-tests', + 'supersedes', + 'linked-from', + 'why-new-file', + 'references', + 'corrects-or-adds', + 'config-reader', + 'config-effect', + 'no-plaintext-secrets', + 'quote-instruction' +]); + +const LEGACY_CONDENSED_PHRASES = Object.freeze([ + { phrase: 'call sites in this file or its module', ids: { edit: 'local-callers', creation: 'local-callers' } }, + { phrase: 'importers/callers', ids: { edit: 'importers', creation: 'callers' } }, + { phrase: 'affected API', ids: { edit: 'public-api', creation: 'public-api' } }, + { phrase: 'data schemas', ids: { edit: 'data-schema', creation: 'data-schema' } }, + { phrase: 'verbatim instruction', ids: { edit: 'quote-instruction', creation: 'quote-instruction' } } +]); + +// --- corpus --- + +function fail(message) { + throw new Error(message); +} + +function validateStep(scenario, step, index) { + const where = `${scenario.name} step ${index + 1}`; + if (!step || typeof step !== 'object') fail(`${where}: not an object`); + if (typeof step.id !== 'string' || !step.id) fail(`${where}: missing id`); + if (!step.payload || typeof step.payload !== 'object') fail(`${where}: missing payload`); + if (typeof step.payload.tool_name !== 'string') fail(`${where}: payload.tool_name must be a string`); + if (!EXPECTATIONS.has(step.expect)) fail(`${where}: expect must be deny or allow`); + if (typeof step.mustDeny !== 'boolean') fail(`${where}: mustDeny must be a boolean`); + if (typeof step.redundant !== 'boolean') fail(`${where}: redundant must be a boolean`); + if (step.mustDeny && step.expect !== 'deny') fail(`${where}: mustDeny requires expect deny`); + if (step.redundant && step.expect !== 'allow') fail(`${where}: redundant requires expect allow`); + if (step.relevantQuestions !== undefined) { + const ids = step.relevantQuestions; + if (!Array.isArray(ids) || ids.some(id => !QUESTION_IDS.includes(id))) fail(`${where}: unknown relevantQuestions id`); + } + if (step.transcript !== undefined && !Array.isArray(step.transcript)) fail(`${where}: transcript must be an array`); +} + +function validateScenario(scenario, file) { + if (!scenario || typeof scenario !== 'object') fail(`${file}: not an object`); + if (typeof scenario.name !== 'string' || !scenario.name) fail(`${file}: missing name`); + if (!Array.isArray(scenario.steps) || scenario.steps.length === 0) fail(`${file}: steps must be a non-empty array`); + const ids = new Set(); + scenario.steps.forEach((step, index) => { + validateStep(scenario, step, index); + if (ids.has(step.id)) fail(`${scenario.name}: duplicate step id ${step.id}`); + ids.add(step.id); + }); +} + +/** Load and validate every scenario file in a corpus directory, sorted by file name. */ +function loadCorpus(dir = DEFAULT_CORPUS_DIR) { + return fs + .readdirSync(dir) + .filter(name => name.endsWith('.json')) + .sort() + .map(name => { + const scenario = JSON.parse(fs.readFileSync(path.join(dir, name), 'utf8')); + validateScenario(scenario, name); + return { ...scenario, file: name }; + }); +} + +// --- baseline materialisation --- + +function gitShow(ref, relativePath) { + return execFileSync('git', ['show', `${ref}:${relativePath}`], { + cwd: REPO_ROOT, + encoding: 'utf8', + maxBuffer: 16 * 1024 * 1024, + stdio: ['ignore', 'pipe', 'pipe'] + }); +} + +function relativeRequires(source) { + const specs = []; + const pattern = /require\(\s*['"](\.{1,2}\/[^'"]+)['"]\s*\)/g; + for (let match = pattern.exec(source); match; match = pattern.exec(source)) specs.push(match[1]); + return specs; +} + +function requireTarget(fromFile, spec) { + const joined = path.posix.normalize(path.posix.join(path.posix.dirname(fromFile), spec)); + return path.posix.extname(joined) ? joined : `${joined}.js`; +} + +/** Write the hook at a git ref, and every file it requires relatively, into a temp tree; returns the hook path. */ +function materializeHook(ref, outDir) { + const seen = new Set(); + const queue = [HOOK_RELATIVE_PATH]; + while (queue.length > 0) { + const relative = queue.shift(); + if (seen.has(relative)) continue; + seen.add(relative); + let source; + try { + source = gitShow(ref, relative); + } catch (error) { + fail(`cannot read ${relative} at ${ref} (fetch the ref first): ${String(error.stderr || error.message).trim()}`); + } + const target = path.join(outDir, ...relative.split('/')); + fs.mkdirSync(path.dirname(target), { recursive: true }); + fs.writeFileSync(target, source, 'utf8'); + if (relative.endsWith('.js')) relativeRequires(source).forEach(spec => queue.push(requireTarget(relative, spec))); + } + return path.join(outDir, ...HOOK_RELATIVE_PATH.split('/')); +} + +// --- scenario setup --- + +function substitute(value, vars) { + if (typeof value === 'string') return value.replace(/\{\{(root|transcript)\}\}/g, (_, name) => vars[name]); + if (Array.isArray(value)) return value.map(item => substitute(item, vars)); + if (value && typeof value === 'object') { + return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, substitute(item, vars)])); + } + return value; +} + +function createSymlink(root, link, target) { + const linkPath = path.join(root, ...link.split('/')); + fs.mkdirSync(path.dirname(linkPath), { recursive: true }); + const resolved = path.resolve(path.dirname(linkPath), ...target.split('/')); + const isDir = fs.existsSync(resolved) && fs.statSync(resolved).isDirectory(); + if (process.platform === 'win32' && isDir) fs.symlinkSync(resolved, linkPath, 'junction'); + else fs.symlinkSync(target, linkPath, isDir ? 'dir' : 'file'); +} + +function createHardLink(root, link, target) { + const linkPath = path.join(root, ...link.split('/')); + fs.mkdirSync(path.dirname(linkPath), { recursive: true }); + fs.linkSync(path.join(root, ...target.split('/')), linkPath); +} + +function prepareScenario(scenario, workDir) { + const root = scenario.root || fs.realpathSync(fs.mkdtempSync(path.join(workDir, 'project-'))); + if (!scenario.root) { + for (const [relative, content] of Object.entries(scenario.files || {})) { + const file = path.join(root, ...relative.split('/')); + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync(file, content, 'utf8'); + } + for (const dir of scenario.dirs || []) fs.mkdirSync(path.join(root, ...dir.split('/')), { recursive: true }); + try { + for (const [link, target] of Object.entries(scenario.symlinks || {})) createSymlink(root, link, target); + } catch (error) { + return { skipped: `symlinks unavailable: ${error.code || error.message}` }; + } + try { + for (const [link, target] of Object.entries(scenario.hardlinks || {})) createHardLink(root, link, target); + } catch (error) { + return { skipped: `hard links unavailable: ${error.code || error.message}` }; + } + } + const stateDir = fs.mkdtempSync(path.join(workDir, 'state-')); + const home = fs.mkdtempSync(path.join(workDir, 'home-')); + const transcript = path.join(fs.mkdtempSync(path.join(workDir, 'transcript-')), 'session.jsonl'); + const vars = { root, transcript }; + const env = { + PATH: process.env.PATH || '', + HOME: home, + USERPROFILE: home, + GATEGUARD_STATE_DIR: stateDir, + CLAUDE_PROJECT_DIR: root, + ...(process.env.SystemRoot ? { SystemRoot: process.env.SystemRoot } : {}), + ...(scenario.env || {}) + }; + const steps = scenario.steps.map(step => ({ + id: step.id, + records: substitute(step.transcript || [], vars), + payload: substitute(step.payload, vars) + })); + return { env, transcript, steps }; +} + +// --- hook execution --- + +function runStepsInWorker(hookFile, prepared) { + return new Promise((resolve, reject) => { + const worker = new Worker(__filename, { + env: prepared.env, + workerData: { gateguardEval: true, hookFile, transcript: prepared.transcript, steps: prepared.steps } + }); + let outcome = null; + worker.once('message', message => { + outcome = message; + }); + worker.once('error', reject); + worker.once('exit', code => { + if (outcome) resolve(outcome); + else reject(new Error(`scenario worker exited with code ${code}`)); + }); + }); +} + +function hookOutput(result) { + if (!result || typeof result !== 'object') return { decision: null, reason: '', context: '' }; + let parsed = null; + if (typeof result.stdout === 'string' && result.stdout) { + try { + parsed = JSON.parse(result.stdout); + } catch (_) { + parsed = null; + } + } + const specific = parsed && parsed.hookSpecificOutput ? parsed.hookSpecificOutput : {}; + const context = [].concat(result.additionalContext || specific.additionalContext || []).join('\n'); + return { decision: specific.permissionDecision || null, reason: specific.permissionDecisionReason || '', context }; +} + +function allowKind(context) { + if (context.includes('Prior search seen in this turn')) return 'credit'; + if (context.includes('Sibling of ')) return 'sibling'; + if (context.includes('Comment or whitespace-only change')) return 'trivial'; + return 'pass'; +} + +function workerMain() { + const { hookFile, transcript, steps } = workerData; + const hook = require(hookFile); + const results = []; + for (const step of steps) { + if (step.records.length > 0) { + fs.appendFileSync(transcript, step.records.map(record => `${JSON.stringify(record)}\n`).join(''), 'utf8'); + } + const raw = JSON.stringify(step.payload); + let result; + let error = null; + const started = performance.now(); + try { + result = hook.run(raw); + } catch (thrown) { + error = String(thrown && thrown.message ? thrown.message : thrown); + result = raw; + } + const latencyMs = performance.now() - started; + const output = hookOutput(result); + const denied = output.decision === 'deny'; + results.push({ + id: step.id, + decision: denied ? 'deny' : 'allow', + kind: denied ? 'deny' : allowKind(output.context), + explicitAllow: output.decision === 'allow', + reason: output.reason, + latencyMs, + error + }); + } + parentPort.postMessage({ results }); +} + +/** Run every scenario against one hook file; each scenario gets its own project, state dir, transcript and clean env. */ +async function runCorpus(hookFile, scenarios) { + const workDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gateguard-eval-')); + try { + const runs = []; + for (const scenario of scenarios) { + const prepared = prepareScenario(scenario, workDir); + if (prepared.skipped) { + runs.push({ scenario, skipped: prepared.skipped, results: [] }); + continue; + } + const { results } = await runStepsInWorker(hookFile, prepared); + runs.push({ scenario, skipped: null, results }); + } + return runs; + } finally { + fs.rmSync(workDir, { recursive: true, force: true }); + } +} + +// --- cold latency --- +// see docs/gateguard/evaluation.md#latency + +// A hook that throws is recorded the way workerMain records it - the raw payload +// stands in for the result, so the step reads as an allow - rather than ending the +// run. Otherwise one throwing step aborted the whole replay and no report was written. +const COLD_PROBE = [ + "const { performance } = require('perf_hooks');", + 'const started = performance.now();', + 'let result;', + 'let error = null;', + 'try {', + ' result = require(process.argv[1]).run(process.argv[2]);', + '} catch (thrown) {', + ' error = String(thrown && thrown.message ? thrown.message : thrown);', + ' result = process.argv[2];', + '}', + 'const ms = performance.now() - started;', + 'process.stdout.write(JSON.stringify({ ms, result: result === undefined ? null : result, error }));' +].join('\n'); + +/** Replay every scenario with one fresh Node process per step; returns per-step require+run latency and decisions. */ +function measureColdLatency(hookFile, scenarios) { + const workDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gateguard-cold-')); + try { + const steps = []; + for (const scenario of scenarios) { + const prepared = prepareScenario(scenario, workDir); + if (prepared.skipped) continue; + for (const step of prepared.steps) { + if (step.records.length > 0) { + fs.appendFileSync(prepared.transcript, step.records.map(record => `${JSON.stringify(record)}\n`).join(''), 'utf8'); + } + const raw = JSON.stringify(step.payload); + let probe; + try { + probe = JSON.parse(execFileSync(process.execPath, ['-e', COLD_PROBE, hookFile, raw], { + env: prepared.env, + encoding: 'utf8', + maxBuffer: 16 * 1024 * 1024, + stdio: ['ignore', 'pipe', 'ignore'] + })); + } catch (failed) { + // The process died or wrote nothing parseable, so there is no latency to + // report; the step still counts, as an allow, matching a throwing hook. + probe = { ms: null, result: raw, error: String(failed && failed.message ? failed.message : failed) }; + } + const output = hookOutput(probe.result); + steps.push({ + scenario: scenario.name, + step: step.id, + latencyMs: probe.ms, + decision: output.decision === 'deny' ? 'deny' : 'allow', + error: probe.error || null + }); + } + } + return steps; + } finally { + fs.rmSync(workDir, { recursive: true, force: true }); + } +} + +function coldSummary(coldSteps, warmSteps) { + // A step whose process died has no latency; it still counts toward disagreements + // and errors. Failures are counted on their own: a failed step is recorded as an + // allow, so where the worker also allowed it, it is no disagreement and would + // otherwise vanish from the report and the exit status. + const sorted = coldSteps.map(step => step.latencyMs).filter(Number.isFinite).sort((a, b) => a - b); + const warmDecisions = new Map(warmSteps.map(step => [`${step.scenario}/${step.step}`, step.decision])); + const disagreements = coldSteps.filter(step => warmDecisions.get(`${step.scenario}/${step.step}`) !== step.decision).length; + // With no measured step there is no latency to report; 0 would read as a fast hook. + const at = fraction => (sorted.length ? Number(percentile(sorted, fraction).toFixed(3)) : null); + return { + p50Ms: at(0.5), + p90Ms: at(0.9), + p95Ms: at(0.95), + samples: sorted.length, + errors: coldSteps.filter(step => step.error).length, + disagreements + }; +} + +// --- question mapping --- + +function questionTextTable() { + const { questionText, condensedQuestionPhrase } = require(path.join(REPO_ROOT, 'scripts', 'lib', 'gateguard-target-class')); + const table = new Map(QUESTION_IDS.map(id => [questionText(id), id])); + table.condensed = [ + ...QUESTION_IDS.filter(id => condensedQuestionPhrase(id)).map(id => ({ phrase: condensedQuestionPhrase(id), ids: { edit: id, creation: id } })), + ...LEGACY_CONDENSED_PHRASES + ]; + return table; +} + +/** Question ids a denial asked, read from its numbered list or its condensed hint. */ +function questionsAsked(reason, table) { + const numbered = reason + .split('\n') + .map(line => line.match(/^\d+\. (.+)$/)) + .filter(Boolean) + .map(match => table.get(match[1]) || `unmapped:${match[1]}`); + if (numbered.length > 0) return numbered; + const condensed = reason.match(CONDENSED_PREFIX); + if (!condensed) return []; + const phrases = table.condensed || LEGACY_CONDENSED_PHRASES; + return [...new Set(phrases.filter(({ phrase }) => reason.includes(phrase)).map(({ ids }) => ids[condensed[1]]))]; +} + +// --- metrics --- + +function percentile(sorted, fraction) { + if (sorted.length === 0) return 0; + const index = Math.min(sorted.length - 1, Math.max(0, Math.ceil(fraction * sorted.length) - 1)); + return sorted[index]; +} + +/** Metrics for one hook's run of the corpus, plus per-step detail. */ +function summarize(runs, table = questionTextTable()) { + const totals = { + scenarios: 0, + skippedScenarios: 0, + steps: 0, + denials: 0, + redundantDenials: 0, + mustDenyBypasses: 0, + mismatches: 0, + irrelevantQuestions: 0, + irrelevantInCondensed: 0, + missedQuestions: 0, + unmappedQuestions: 0, + explicitAllows: 0, + errors: 0, + denialTokens: 0, + allowsByKind: { credit: 0, sibling: 0, trivial: 0, pass: 0 } + }; + const latencies = []; + const steps = []; + const perScenario = []; + for (const run of runs) { + if (run.skipped) { + totals.skippedScenarios += 1; + perScenario.push({ name: run.scenario.name, skipped: run.skipped, steps: 0, denials: 0 }); + continue; + } + totals.scenarios += 1; + let scenarioDenials = 0; + run.results.forEach((result, index) => { + const step = run.scenario.steps[index]; + const denied = result.decision === 'deny'; + const relevant = Array.isArray(step.relevantQuestions) ? step.relevantQuestions : null; + const asked = denied && relevant ? questionsAsked(result.reason, table) : []; + const irrelevant = denied && relevant ? asked.filter(id => !relevant.includes(id)) : []; + const missed = denied && relevant ? relevant.filter(id => !asked.includes(id)) : []; + totals.steps += 1; + latencies.push(result.latencyMs); + if (denied) { + totals.denials += 1; + scenarioDenials += 1; + totals.denialTokens += Math.ceil(result.reason.length / TOKEN_CHARS); + if (step.redundant) totals.redundantDenials += 1; + } else { + totals.allowsByKind[result.kind] += 1; + if (step.mustDeny) totals.mustDenyBypasses += 1; + } + if (result.decision !== step.expect) totals.mismatches += 1; + const condensed = denied && CONDENSED_PREFIX.test(result.reason); + totals.irrelevantQuestions += irrelevant.length; + if (condensed) totals.irrelevantInCondensed += irrelevant.length; + totals.missedQuestions += missed.length; + totals.unmappedQuestions += asked.filter(id => id.startsWith('unmapped:')).length; + if (result.explicitAllow) totals.explicitAllows += 1; + if (result.error) totals.errors += 1; + steps.push({ + scenario: run.scenario.name, + file: run.scenario.file, + step: step.id, + expect: step.expect, + decision: result.decision, + kind: result.kind, + mustDeny: step.mustDeny, + redundant: step.redundant, + condensed, + questions: asked, + irrelevant, + missed, + latencyMs: Number(result.latencyMs.toFixed(3)), + ...(result.explicitAllow ? { explicitAllow: true } : {}), + ...(result.error ? { error: result.error } : {}) + }); + }); + perScenario.push({ name: run.scenario.name, skipped: null, steps: run.results.length, denials: scenarioDenials }); + } + const sorted = latencies.slice().sort((a, b) => a - b); + return { + totals, + latency: { + p50Ms: Number(percentile(sorted, 0.5).toFixed(3)), + p90Ms: Number(percentile(sorted, 0.9).toFixed(3)), + p95Ms: Number(percentile(sorted, 0.95).toFixed(3)) + }, + perScenario, + steps + }; +} + +// --- reporting --- + +/** A fresh-process percentile, or why there is none: not run, or no step measured. */ +function coldMs(s, key) { + if (!s.cold) return 'not run'; + return s.cold[key] === null ? 'no valid sample' : s.cold[key].toFixed(2); +} + +const METRIC_ROWS = [ + ['Steps', s => s.totals.steps], + ['Denials', s => s.totals.denials], + ['Redundant denials', s => s.totals.redundantDenials], + ['Must-deny bypasses', s => s.totals.mustDenyBypasses], + ['Expectation mismatches', s => s.totals.mismatches], + ['Irrelevant questions asked', s => s.totals.irrelevantQuestions], + ['Irrelevant questions in condensed denials', s => s.totals.irrelevantInCondensed], + ['Warranted questions not asked', s => s.totals.missedQuestions], + ['Estimated denial tokens', s => s.totals.denialTokens], + ['Allows with a credit note', s => s.totals.allowsByKind.credit], + ['Allows with a sibling note', s => s.totals.allowsByKind.sibling], + ['Allows with a trivial-edit note', s => s.totals.allowsByKind.trivial], + ['Hook latency p50, fresh process (ms)', s => coldMs(s, 'p50Ms')], + ['Hook latency p90, fresh process (ms)', s => coldMs(s, 'p90Ms')], + ['Hook latency p95, fresh process (ms)', s => coldMs(s, 'p95Ms')], + ['run() latency p50, warm (ms)', s => s.latency.p50Ms.toFixed(2)], + ['run() latency p90, warm (ms)', s => s.latency.p90Ms.toFixed(2)], + ['run() latency p95, warm (ms)', s => s.latency.p95Ms.toFixed(2)] +]; + +function markdownTable(header, rows) { + const line = cells => `| ${cells.join(' | ')} |`; + return [line(header), line(header.map((_, i) => (i === 0 ? '---' : '---:'))), ...rows.map(line)].join('\n'); +} + +/** Markdown report: metric table, per-scenario denials, and any working-tree mismatches. */ +function renderMarkdown(report) { + const labels = report.hooks.map(hook => hook.label); + const lines = [ + `Corpus: ${report.corpus.scenarios} scenarios, ${report.corpus.steps} steps.`, + '', + markdownTable(['Metric', ...labels], METRIC_ROWS.map(([name, pick]) => [name, ...report.hooks.map(hook => String(pick(hook.summary)))])), + '', + markdownTable( + ['Scenario', 'Steps', ...labels.map(label => `Denials: ${label}`)], + report.hooks[0].summary.perScenario.map((row, index) => [ + row.name, + String(row.steps), + ...report.hooks.map(hook => { + const entry = hook.summary.perScenario[index]; + return entry.skipped ? 'skipped' : String(entry.denials); + }) + ]) + ) + ]; + const working = report.hooks[0].summary; + const mismatched = working.steps.filter(step => step.decision !== step.expect); + if (mismatched.length > 0) { + lines.push('', `${report.hooks[0].label} mismatches:`, ''); + mismatched.forEach(step => lines.push(`- ${step.scenario} / ${step.step}: expected ${step.expect}, got ${step.decision} (${step.kind})`)); + } + const cold = report.hooks[0].summary.cold; + if (cold && cold.disagreements > 0) { + lines.push('', `${report.hooks[0].label}: ${cold.disagreements} step(s) decided differently in a fresh process.`); + } + if (cold && cold.errors > 0) { + lines.push('', `${report.hooks[0].label}: ${cold.errors} step(s) failed in a fresh process.`); + } + return `${lines.join('\n')}\n`; +} + +const SARIF_RULES = Object.freeze([ + { + id: 'gateguard/must-deny-bypass', + level: 'error', + name: 'MustDenyBypass', + text: 'A step that must be denied (sensitive target, bypass attempt, or mutating first command) was allowed.' + }, + { + id: 'gateguard/explicit-allow', + level: 'error', + name: 'ExplicitAllow', + text: 'The hook emitted permissionDecision "allow", which skips the host permission prompt.' + }, + { + id: 'gateguard/hook-error', + level: 'error', + name: 'HookError', + text: 'The hook threw or produced unparseable output for a step.' + }, + { + id: 'gateguard/expectation-mismatch', + level: 'warning', + name: 'ExpectationMismatch', + text: 'The hook decision differs from the decision the scenario expects.' + } +]); + +function sarifFindings(step) { + const findings = []; + if (step.mustDeny && step.decision !== 'deny') findings.push('gateguard/must-deny-bypass'); + if (step.explicitAllow) findings.push('gateguard/explicit-allow'); + if (step.error) findings.push('gateguard/hook-error'); + if (step.decision !== step.expect && findings.length === 0) findings.push('gateguard/expectation-mismatch'); + return findings; +} + +function sarifArtifactLocation(corpusDir, file) { + const absolute = path.resolve(corpusDir || DEFAULT_CORPUS_DIR, file || ''); + const relative = path.relative(REPO_ROOT, absolute); + if (relative && !relative.startsWith('..') && !path.isAbsolute(relative)) { + return { uri: relative.split(path.sep).join('/'), uriBaseId: '%SRCROOT%' }; + } + return { uri: pathToFileURL(absolute).href }; +} + +function sarifTotals(summary) { + const { steps, denials, mustDenyBypasses, mismatches, explicitAllows, errors, skippedScenarios } = summary.totals; + return { steps, denials, mustDenyBypasses, mismatches, explicitAllows, errors, skippedScenarios }; +} + +/** SARIF 2.1.0 log of working-tree gate failures, with per-hook totals; timing is omitted so output is reproducible. */ +function renderSarif(report) { + const working = report.hooks[0].summary; + const levels = new Map(SARIF_RULES.map(rule => [rule.id, rule.level])); + const results = []; + for (const step of working.steps) { + for (const ruleId of sarifFindings(step)) { + results.push({ + ruleId, + level: levels.get(ruleId), + message: { text: `${step.scenario} / ${step.step}: expected ${step.expect}, got ${step.decision} (${step.kind})` }, + locations: [ + { + physicalLocation: { artifactLocation: sarifArtifactLocation(report.corpusDir, step.file) } + } + ] + }); + } + } + const log = { + 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: SARIF_RULES.map(rule => ({ + id: rule.id, + name: rule.name, + shortDescription: { text: rule.text }, + defaultConfiguration: { level: rule.level } + })) + } + }, + automationDetails: { id: 'gateguard/scenario-corpus' }, + invocations: [{ executionSuccessful: true }], + properties: { + corpus: report.corpus, + hooks: report.hooks.map(hook => ({ label: hook.label, totals: sarifTotals(hook.summary) })) + }, + results + } + ] + }; + return `${JSON.stringify(log, null, 2)}\n`; +} + +// --- CLI --- + +const USAGE = [ + 'Usage: node scripts/dev/gateguard-eval.js [--markdown | --json] [--baseline ]... [--corpus ] [--sarif ]', + '', + ' --baseline git ref whose hook is compared with the working tree (repeatable;', + ` default ${DEFAULT_BASELINE})`, + ' --corpus scenario directory (default tests/fixtures/gateguard-scenarios)', + ' --markdown print markdown tables (default)', + ' --json print the full report as JSON', + ' --sarif also write working-tree gate failures as SARIF 2.1.0', + ' --no-cold skip the fresh-process latency pass (one Node process per step)' +].join('\n'); + +function parseArgs(argv) { + const options = { format: 'markdown', baselines: [], corpus: DEFAULT_CORPUS_DIR, sarif: null, cold: true, help: false }; + for (let i = 0; i < argv.length; i++) { + const arg = argv[i]; + if (arg === '--markdown') options.format = 'markdown'; + else if (arg === '--json') options.format = 'json'; + else if (arg === '--help' || arg === '-h') options.help = true; + else if (arg === '--no-cold') options.cold = false; + else if ((arg === '--baseline' || arg === '--corpus' || arg === '--sarif') && i + 1 < argv.length) { + if (arg === '--baseline') options.baselines.push(argv[++i]); + else if (arg === '--sarif') options.sarif = path.resolve(argv[++i]); + else options.corpus = path.resolve(argv[++i]); + } else fail(`unknown or incomplete argument: ${arg}\n\n${USAGE}`); + } + if (options.baselines.length === 0) options.baselines.push(DEFAULT_BASELINE); + return options; +} + +/** Evaluate the working-tree hook and each baseline ref over a corpus. */ +async function evaluate({ baselines = [DEFAULT_BASELINE], corpus = DEFAULT_CORPUS_DIR, cold = false } = {}) { + const scenarios = loadCorpus(corpus); + const table = questionTextTable(); + const hooks = [{ label: 'working tree', ref: null, file: path.join(REPO_ROOT, ...HOOK_RELATIVE_PATH.split('/')) }]; + const materialized = fs.mkdtempSync(path.join(os.tmpdir(), 'gateguard-baseline-')); + try { + baselines.forEach((ref, index) => { + hooks.push({ label: ref, ref, file: materializeHook(ref, path.join(materialized, String(index))) }); + }); + const evaluated = []; + for (const hook of hooks) { + const summary = summarize(await runCorpus(hook.file, scenarios), table); + if (cold) summary.cold = coldSummary(measureColdLatency(hook.file, scenarios), summary.steps); + evaluated.push({ label: hook.label, ref: hook.ref, summary }); + } + return { + corpus: { scenarios: scenarios.length, steps: scenarios.reduce((sum, scenario) => sum + scenario.steps.length, 0) }, + corpusDir: corpus, + hooks: evaluated + }; + } finally { + fs.rmSync(materialized, { recursive: true, force: true }); + } +} + +async function main() { + const options = parseArgs(process.argv.slice(2)); + if (options.help) { + process.stdout.write(`${USAGE}\n`); + return; + } + const report = await evaluate(options); + process.stdout.write(options.format === 'json' ? `${JSON.stringify(report, null, 2)}\n` : renderMarkdown(report)); + if (options.sarif) fs.writeFileSync(options.sarif, renderSarif(report), 'utf8'); + if (gateFails(report)) process.exitCode = 1; +} + +/** + * True when the working tree must not pass: a bypass, a mismatch, an explicit allow, + * a hook error in either pass, or a step a fresh process decided differently. + */ +function gateFails(report) { + const working = report.hooks[0].summary.totals; + const cold = report.hooks[0].summary.cold || {}; + return working.mustDenyBypasses > 0 || working.mismatches > 0 || working.explicitAllows > 0 || + working.errors > 0 || cold.disagreements > 0 || cold.errors > 0; +} + +if (!isMainThread && workerData && workerData.gateguardEval) { + workerMain(); +} else if (require.main === module) { + main().catch(error => { + process.stderr.write(`[gateguard-eval] ${error.message}\n`); + process.exitCode = 2; + }); +} + +module.exports = { + loadCorpus, materializeHook, runCorpus, measureColdLatency, coldSummary, summarize, questionsAsked, + renderMarkdown, renderSarif, parseArgs, evaluate, gateFails +}; diff --git a/scripts/dev/gateguard-latency.js b/scripts/dev/gateguard-latency.js new file mode 100644 index 000000000..138d2ab88 --- /dev/null +++ b/scripts/dev/gateguard-latency.js @@ -0,0 +1,234 @@ +#!/usr/bin/env node +'use strict'; + +const { execFileSync } = require('child_process'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); + +const REPO_ROOT = path.join(__dirname, '..', '..'); +const HOOK_FILE = path.join(REPO_ROOT, 'scripts', 'hooks', 'gateguard-fact-force.js'); +const DEFAULT_BASELINE = 'upstream/main'; +const DEFAULT_RUNS = 30; +const BOOTSTRAP_ROUNDS = 4000; + +// --- statistics --- + +/** Linear-interpolated quantile of a numeric sample. */ +function quantile(values, p) { + const sorted = values.slice().sort((a, b) => a - b); + if (sorted.length === 0) return NaN; + const at = (sorted.length - 1) * p; + const lo = Math.floor(at); + const hi = Math.ceil(at); + return sorted[lo] + (sorted[hi] - sorted[lo]) * (at - lo); +} + +function xorshift(seed) { + let state = seed >>> 0 || 1; + return n => { + state ^= state << 13; + state >>>= 0; + state ^= state >>> 17; + state ^= state << 5; + state >>>= 0; + return Math.floor((state / 4294967296) * n); + }; +} + +/** 95% percentile-bootstrap interval of quantile(b, p) - quantile(a, p); deterministic for a given seed. */ +function bootstrapDifference(a, b, p, { rounds = BOOTSTRAP_ROUNDS, seed = 0x9e3779b9 } = {}) { + const random = xorshift(seed); + const resample = values => values.map(() => values[random(values.length)]); + const differences = []; + for (let i = 0; i < rounds; i++) differences.push(quantile(resample(b), p) - quantile(resample(a), p)); + differences.sort((x, y) => x - y); + return [differences[Math.floor(rounds * 0.025)], differences[Math.ceil(rounds * 0.975) - 1]]; +} + +function verdict([low, high]) { + if (low > 0) return 'slower'; + if (high < 0) return 'faster'; + return 'no measurable change'; +} + +// --- fixture --- + +function buildFixture(workDir) { + const root = fs.realpathSync(fs.mkdtempSync(path.join(workDir, 'project-'))); + fs.mkdirSync(path.join(root, 'src')); + fs.writeFileSync(path.join(root, 'src', 'widget.js'), 'function widgetValue(x) {\n return x;\n}\n// note\n'.repeat(50)); + let big = ''; + for (let i = 0; i < 4000; i++) { + big += `// helper ${i}\nfunction helper${i}(a, b) {\n const s = "value ${i}"; /* note */\n return a + b * ${i};\n}\n`; + } + fs.writeFileSync(path.join(root, 'src', 'big.js'), `${big}function target(x) {\n return x;\n}\n`); + fs.writeFileSync(path.join(root, 'CLAUDE.md'), '# Rules\n\n- be careful\n'); + const transcript = path.join(root, 'session.jsonl'); + const records = [{ type: 'user', uuid: 'u1', promptId: 'p1', message: { role: 'user', content: 'fix the widget' } }]; + for (let i = 0; i < 20; i++) { + records.push({ + type: 'assistant', + uuid: `a${i}`, + message: { id: `m${i}`, role: 'assistant', content: [{ type: 'tool_use', id: `t${i}`, name: 'Bash', input: { command: `rg -g '!*.md' other${i} .` } }] } + }); + records.push({ type: 'user', uuid: `r${i}`, promptId: 'p1', message: { role: 'user', content: [{ type: 'tool_result', tool_use_id: `t${i}`, content: 'ok' }] } }); + } + fs.writeFileSync(transcript, `${records.map(record => JSON.stringify(record)).join('\n')}\n`); + const base = { session_id: 'latency', transcript_path: transcript, cwd: root, hook_event_name: 'PreToolUse', tool_use_id: 'pending' }; + const edit = (file, oldString, newString, extra = {}) => ({ + ...base, + tool_name: 'Edit', + tool_input: { file_path: path.join(root, file), old_string: oldString, new_string: newString, ...extra } + }); + const bash = command => ({ ...base, tool_name: 'Bash', tool_input: { command } }); + const cases = [ + ['Shell command, first of session', bash('npm test'), []], + ['Shell command, routine gate passed', bash('npm run build'), [bash('npm test')]], + ['Edit of a file already checked', edit('src/widget.js', ' return x;', ' return x + 1;'), [edit('src/widget.js', ' return x;', ' return x + 1;')]], + ['First edit, code, denied', edit('src/widget.js', ' return x;', ' return x + 1;'), []], + ['First edit, comment-only pass', edit('src/widget.js', '// note', '// note!', { replace_all: true }), []], + ['First edit, CLAUDE.md, denied', edit('CLAUDE.md', '- be careful', '- be very careful'), []], + ['First edit, 406 KiB file, denied', edit('src/big.js', ' return x;', ' return x + 1;'), []] + ]; + return { root, cases }; +} + +// --- measurement --- + +const SETUP_PROBE = 'const hook = require(process.argv[1]); for (const step of JSON.parse(process.argv[2])) hook.run(JSON.stringify(step));'; +const TIMED_PROBE = [ + "const { performance } = require('perf_hooks');", + 'const started = performance.now();', + 'require(process.argv[1]).run(process.argv[2]);', + 'process.stdout.write(String(performance.now() - started));' +].join('\n'); + +function sampleOnce(hookFile, payload, setup, root, workDir) { + const stateDir = fs.mkdtempSync(path.join(workDir, 'state-')); + try { + const env = { PATH: process.env.PATH || '', HOME: root, USERPROFILE: root, GATEGUARD_STATE_DIR: stateDir, CLAUDE_PROJECT_DIR: root }; + if (process.env.SystemRoot) env.SystemRoot = process.env.SystemRoot; + if (setup.length > 0) execFileSync(process.execPath, ['-e', SETUP_PROBE, hookFile, JSON.stringify(setup)], { env, stdio: 'ignore' }); + return Number(execFileSync(process.execPath, ['-e', TIMED_PROBE, hookFile, JSON.stringify(payload)], { env, encoding: 'utf8' })); + } finally { + fs.rmSync(stateDir, { recursive: true, force: true }); + } +} + +/** Fresh-process require()+run() samples per call type and hook, interleaved in a shuffled order each round. */ +function measure(hooks, { runs = DEFAULT_RUNS, seed = 7, onRound = () => {} } = {}) { + const workDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gateguard-latency-')); + try { + const { root, cases } = buildFixture(workDir); + const random = xorshift(seed); + const samples = Object.fromEntries(cases.map(([name]) => [name, Object.fromEntries(hooks.map(hook => [hook.label, []]))])); + for (let round = 0; round < runs; round++) { + for (const [name, payload, setup] of cases) { + const order = hooks.slice(); + for (let i = order.length - 1; i > 0; i--) { + const j = random(i + 1); + [order[i], order[j]] = [order[j], order[i]]; + } + for (const hook of order) samples[name][hook.label].push(sampleOnce(hook.file, payload, setup, root, workDir)); + } + onRound(round + 1); + } + return samples; + } finally { + fs.rmSync(workDir, { recursive: true, force: true }); + } +} + +/** p50/p90 per hook and the bootstrap interval of each baseline-to-working-tree difference. */ +function summarize(samples, labels) { + const [working, ...baselines] = labels; + return Object.entries(samples).map(([name, byHook]) => ({ + name, + quantiles: Object.fromEntries(labels.map(label => [label, { p50: quantile(byHook[label], 0.5), p90: quantile(byHook[label], 0.9) }])), + comparisons: baselines.map(baseline => { + const p50 = bootstrapDifference(byHook[baseline], byHook[working], 0.5); + const p90 = bootstrapDifference(byHook[baseline], byHook[working], 0.9); + return { baseline, p50, p90, p50Verdict: verdict(p50), p90Verdict: verdict(p90) }; + }) + })); +} + +function renderMarkdown(rows, labels, runs) { + const fmt = value => value.toFixed(1); + const lines = [ + `${runs} fresh processes per call type and hook, interleaved; require() + run() in ms; 95% bootstrap intervals of the working tree minus the baseline.`, + '', + `| Call | ${labels.map(label => `${label} p50 / p90`).join(' | ')} |`, + `| --- | ${labels.map(() => '---:').join(' | ')} |`, + ...rows.map(row => `| ${row.name} | ${labels.map(label => `${fmt(row.quantiles[label].p50)} / ${fmt(row.quantiles[label].p90)}`).join(' | ')} |`), + '' + ]; + for (const baseline of labels.slice(1)) { + lines.push(`| Call | p50 change vs ${baseline} | p90 change vs ${baseline} |`, '| --- | --- | --- |'); + for (const row of rows) { + const c = row.comparisons.find(entry => entry.baseline === baseline); + lines.push(`| ${row.name} | [${fmt(c.p50[0])}, ${fmt(c.p50[1])}] ${c.p50Verdict} | [${fmt(c.p90[0])}, ${fmt(c.p90[1])}] ${c.p90Verdict} |`); + } + lines.push(''); + } + return `${lines.join('\n')}`; +} + +// --- CLI --- + +const USAGE = [ + 'Usage: node scripts/dev/gateguard-latency.js [--baseline ]... [--runs ] [--json]', + '', + ' --baseline git ref whose hook is compared with the working tree (repeatable;', + ` default ${DEFAULT_BASELINE})`, + ` --runs fresh processes per call type and hook (default ${DEFAULT_RUNS})`, + ' --json print samples and summary as JSON' +].join('\n'); + +function parseArgs(argv) { + const options = { baselines: [], runs: DEFAULT_RUNS, format: 'markdown', help: false }; + for (let i = 0; i < argv.length; i++) { + const arg = argv[i]; + if (arg === '--json') options.format = 'json'; + else if (arg === '--help' || arg === '-h') options.help = true; + else if (arg === '--baseline' && i + 1 < argv.length) options.baselines.push(argv[++i]); + else if (arg === '--runs' && i + 1 < argv.length && /^[1-9]\d*$/.test(argv[i + 1])) options.runs = Number(argv[++i]); + else throw new Error(`unknown or incomplete argument: ${arg}\n\n${USAGE}`); + } + if (options.baselines.length === 0) options.baselines.push(DEFAULT_BASELINE); + return options; +} + +function main() { + const options = parseArgs(process.argv.slice(2)); + if (options.help) { + process.stdout.write(`${USAGE}\n`); + return; + } + const { materializeHook } = require('./gateguard-eval'); + const materialized = fs.mkdtempSync(path.join(os.tmpdir(), 'gateguard-latency-hooks-')); + try { + const hooks = [{ label: 'working tree', file: HOOK_FILE }].concat( + options.baselines.map((ref, index) => ({ label: ref, file: materializeHook(ref, path.join(materialized, String(index))) })) + ); + const samples = measure(hooks, { runs: options.runs, onRound: round => process.stderr.write(`\rround ${round}/${options.runs}`) }); + process.stderr.write('\n'); + const labels = hooks.map(hook => hook.label); + const rows = summarize(samples, labels); + process.stdout.write(options.format === 'json' ? `${JSON.stringify({ runs: options.runs, samples, rows }, null, 2)}\n` : `${renderMarkdown(rows, labels, options.runs)}\n`); + } finally { + fs.rmSync(materialized, { recursive: true, force: true }); + } +} + +if (require.main === module) { + try { + main(); + } catch (error) { + process.stderr.write(`[gateguard-latency] ${error.message}\n`); + process.exitCode = 2; + } +} + +module.exports = { quantile, bootstrapDifference, verdict, summarize, measure, parseArgs }; diff --git a/scripts/gateguard-report.js b/scripts/gateguard-report.js new file mode 100644 index 000000000..10c3bafcb --- /dev/null +++ b/scripts/gateguard-report.js @@ -0,0 +1,217 @@ +#!/usr/bin/env node +'use strict'; + +const fs = require('fs'); +const path = require('path'); +const { METRICS_FILE_NAME, isMetricsEvent } = require('./lib/gateguard-metrics'); + +const DENIAL_DECISIONS = new Set(['deny', 'routine-deny', 'destructive-deny']); +const FIRST_TOUCH_DECISIONS = ['deny', 'credit', 'sibling', 'cap', 'trivial']; +const NEAR_MISS_PREFIX = 'near-miss:'; +const TOP_QUESTIONS = 10; + +function showHelp() { + console.log(` +Usage: node scripts/gateguard-report.js [options] + +Summarises GateGuard decision metrics (written when GATEGUARD_METRICS=1). + +Options: + --dir Directory holding metrics.jsonl (default: GATEGUARD_STATE_DIR or ~/.gateguard) + --json Emit machine-readable JSON + --help Show this help text +`); +} + +function parseArgs(argv) { + const options = { dir: null, json: false, help: false }; + for (let index = 0; index < argv.length; index++) { + const arg = argv[index]; + if (arg === '--json') { + options.json = true; + } else if (arg === '--help' || arg === '-h') { + options.help = true; + } else if (arg === '--dir') { + const value = argv[index + 1]; + if (!value || value.startsWith('--')) throw new Error('Missing value for --dir'); + options.dir = value; + index++; + } else if (arg.startsWith('--dir=')) { + const value = arg.slice('--dir='.length); + if (!value) throw new Error('Missing value for --dir'); + options.dir = value; + } else { + throw new Error(`Unknown argument: ${arg}`); + } + } + return options; +} + +function defaultDir() { + return process.env.GATEGUARD_STATE_DIR || path.join(process.env.HOME || process.env.USERPROFILE || '/tmp', '.gateguard'); +} + +/** Valid events from `metrics.jsonl.1` then `metrics.jsonl` in `dir`, with the count of skipped lines. */ +function loadMetrics(dir) { + const current = path.join(dir, METRICS_FILE_NAME); + const files = [`${current}.1`, current].filter(file => { + try { + return fs.statSync(file).isFile(); + } catch (_) { + return false; + } + }); + const events = []; + let skipped = 0; + for (const file of files) { + for (const line of fs.readFileSync(file, 'utf8').split('\n')) { + if (!line.trim()) continue; + let parsed = null; + try { + parsed = JSON.parse(line); + } catch (_) { + parsed = null; + } + if (isMetricsEvent(parsed)) { + events.push(parsed); + } else { + skipped++; + } + } + } + return { dir, files, events, skipped }; +} + +function increment(counts, key) { + counts[key] = (counts[key] || 0) + 1; +} + +function byCount(counts) { + return Object.fromEntries(Object.entries(counts).sort(([a, x], [b, y]) => y - x || (a < b ? -1 : a > b ? 1 : 0))); +} + +function ratio(count, total) { + return total > 0 ? count / total : null; +} + +function summarizeGroup(events) { + const decisions = {}; + const denialClasses = {}; + const denialReasons = {}; + const questions = {}; + const firstTouchCounts = Object.fromEntries(FIRST_TOUCH_DECISIONS.map(decision => [decision, 0])); + let denials = 0; + let nearMiss = 0; + for (const event of events) { + increment(decisions, event.decision); + if (Object.hasOwn(firstTouchCounts, event.decision)) firstTouchCounts[event.decision]++; + if (DENIAL_DECISIONS.has(event.decision)) { + denials++; + increment(denialClasses, event.class || 'shell'); + increment(denialReasons, event.reason || 'unknown'); + } + if (event.decision === 'deny') { + if (typeof event.reason === 'string' && event.reason.startsWith(NEAR_MISS_PREFIX)) nearMiss++; + for (const id of event.questions || []) increment(questions, id); + } + } + const firstTouchTotal = FIRST_TOUCH_DECISIONS.reduce((sum, decision) => sum + firstTouchCounts[decision], 0); + return { + events: events.length, + first: events.length > 0 ? events[0].ts : null, + last: events.length > 0 ? events[events.length - 1].ts : null, + decisions: byCount(decisions), + denials: { total: denials, byClass: byCount(denialClasses), byReason: byCount(denialReasons) }, + nearMiss: { count: nearMiss, of: firstTouchCounts.deny, share: ratio(nearMiss, firstTouchCounts.deny) }, + firstTouch: { + total: firstTouchTotal, + counts: firstTouchCounts, + rates: Object.fromEntries(FIRST_TOUCH_DECISIONS.map(decision => [decision, ratio(firstTouchCounts[decision], firstTouchTotal)])) + }, + routineReadonlyPasses: decisions['routine-readonly'] || 0, + topQuestions: Object.entries(byCount(questions)).slice(0, TOP_QUESTIONS) + }; +} + +/** Totals and per-session summaries of metrics events. */ +function summarize(events) { + const bySession = new Map(); + for (const event of events) { + if (!bySession.has(event.session)) bySession.set(event.session, []); + bySession.get(event.session).push(event); + } + const sessions = {}; + for (const [session, sessionEvents] of bySession) sessions[session] = summarizeGroup(sessionEvents); + return { total: summarizeGroup(events), sessions }; +} + +function percent(value) { + return value === null ? 'n/a' : `${(value * 100).toFixed(1)}%`; +} + +function counts(entries) { + const list = Array.isArray(entries) ? entries : Object.entries(entries); + return list.length > 0 ? list.map(([key, count]) => `${key} ${count}`).join(', ') : 'none'; +} + +function formatGroup(title, group) { + const rates = FIRST_TOUCH_DECISIONS.map(decision => `${decision} ${percent(group.firstTouch.rates[decision])}`).join(', '); + return [ + title, + ` decisions: ${counts(group.decisions)}`, + ` denials: ${group.denials.total}`, + ` denials by class: ${counts(group.denials.byClass)}`, + ` denials by reason: ${counts(group.denials.byReason)}`, + ` near-miss share of first-touch denials: ${percent(group.nearMiss.share)} (${group.nearMiss.count} of ${group.nearMiss.of})`, + ` first-touch outcomes (${group.firstTouch.total}): ${rates}`, + ` routine read-only passes: ${group.routineReadonlyPasses}`, + ` most-asked questions: ${counts(group.topQuestions)}` + ].join('\n'); +} + +/** Plain-text report for `loadMetrics` output plus its `summarize` report. */ +function formatReport({ dir, events, skipped, report }) { + const sessionIds = Object.keys(report.sessions); + const header = + `GateGuard metrics${dir ? ` in ${dir}` : ''}: ${events.length} events in ${sessionIds.length} sessions` + + (skipped > 0 ? ` (${skipped} malformed lines skipped)` : ''); + const sections = [header, '', formatGroup('Total', report.total)]; + for (const session of sessionIds) { + const group = report.sessions[session]; + sections.push('', formatGroup(`Session ${session} (${group.first} to ${group.last})`, group)); + } + return `${sections.join('\n')}\n`; +} + +function main() { + try { + const options = parseArgs(process.argv.slice(2)); + if (options.help) { + showHelp(); + return; + } + const loaded = loadMetrics(options.dir || defaultDir()); + const report = summarize(loaded.events); + if (options.json) { + const output = { dir: loaded.dir, files: loaded.files, events: loaded.events.length, skipped: loaded.skipped, ...report }; + process.stdout.write(`${JSON.stringify(output, null, 2)}\n`); + } else if (loaded.events.length === 0) { + process.stdout.write( + `No GateGuard metrics in ${loaded.dir}` + + (loaded.skipped > 0 ? ` (${loaded.skipped} malformed lines skipped)` : '') + + '. Set GATEGUARD_METRICS=1 to record gate decisions.\n' + ); + } else { + process.stdout.write(formatReport({ ...loaded, report })); + } + } catch (error) { + process.stderr.write(`Error: ${error.message}\n`); + process.exit(1); + } +} + +if (require.main === module) { + main(); +} + +module.exports = { formatReport, loadMetrics, parseArgs, summarize }; diff --git a/scripts/hooks/gateguard-fact-force.js b/scripts/hooks/gateguard-fact-force.js index 9cb9447da..977ec90d1 100644 --- a/scripts/hooks/gateguard-fact-force.js +++ b/scripts/hooks/gateguard-fact-force.js @@ -9,7 +9,7 @@ * The act of investigation creates awareness that self-evaluation never did. * * Gates: - * - Edit/Write: list importers, affected API, verify data schemas, quote instruction + * - Edit/Write/MultiEdit/NotebookEdit (first touch): questions chosen by target class * - Bash/PowerShell (destructive): list targets, rollback plan, quote instruction * - Bash/PowerShell (routine): quote current instruction (once per session) * @@ -29,12 +29,74 @@ const { extractCommandSubstitutions, extractSubshellGroups, extractBraceGroups } const { classifyPowerShellDestructiveCommand } = require('../lib/powershell-destructive-command'); const { stripHeredocBodies } = require('./gateguard-heredoc'); +// --- Lazily loaded modules --- +// see docs/gateguard/design-notes.md#lazy-loading + +function lazily(load) { + let loaded = null; + return () => loaded || (loaded = load()); +} + +const targetClass = lazily(() => require('../lib/gateguard-target-class')); +const turnScan = lazily(() => require('../lib/gateguard-turn-scan')); +const changeProfile = lazily(() => require('../lib/gateguard-change-profile')); +const metricsLib = lazily(() => require('../lib/gateguard-metrics')); +const readOnlyShell = lazily(() => require('../lib/gateguard-readonly-shell').createReadOnlyShell({ quoteAwareSegments })); +const searchEvidence = lazily(() => + require('../lib/gateguard-search-evidence').createSearchEvidence({ quoteAwareSegments, commandBasename, SHELL_SEGMENT_SEPARATORS }) +); + +const questionIdsFor = (...args) => targetClass().questionIdsFor(...args); +const questionText = (...args) => targetClass().questionText(...args); +const condensedHintFor = (...args) => targetClass().condensedHintFor(...args); +const resolveTargetPath = (...args) => targetClass().resolveTargetPath(...args); +const canonicalPathKey = (...args) => targetClass().canonicalPathKey(...args); +const classifyTargetFor = (...args) => targetClass().classifyTargetFor(...args); +const collapseGateDir = (...args) => targetClass().collapseGateDir(...args); +const isSensitiveTargetFor = (...args) => targetClass().isSensitiveTargetFor(...args); +const isHardLinkedTargetFor = (...args) => targetClass().isHardLinkedTargetFor(...args); +const createTurnScanner = (...args) => turnScan().createTurnScanner(...args); +const currentTurnId = (...args) => turnScan().currentTurnId(...args); +const transcriptPathFor = (...args) => turnScan().transcriptPathFor(...args); +const profileChange = (...args) => changeProfile().profileChange(...args); +const appendMetrics = (...args) => metricsLib().appendMetrics(...args); +const metricsEvent = (...args) => metricsLib().metricsEvent(...args); +const isReadOnlyShellCommand = (...args) => readOnlyShell().isReadOnlyShellCommand(...args); +const hasEntries = list => Array.isArray(list) && list.length > 0; +const findCreditingSearch = (scan, filePath, allowDirMatch, data) => + scan && hasEntries(scan.searches) && (allowDirMatch || turnScan().searchesMayNameTarget(scan, filePath)) + ? searchEvidence().findCreditingSearch(scan, filePath, allowDirMatch, data) + : null; +const findClosestMiss = (scan, filePath, allowDirMatch, data) => + scan && (hasEntries(scan.reads) || (hasEntries(scan.searches) && (allowDirMatch || turnScan().searchesMayNameTarget(scan, filePath)))) + ? searchEvidence().findClosestMiss(scan, filePath, allowDirMatch, data) + : null; +const { + getDenialCount, + getCreditedCount, + getCapAllowCount, + getTrivialAllowCount, + getRoutineReadonlyPassCount, + getSiblingAllowCount, + getClassCounts, + mergeClassCounts, + incrementClassCount, + withEntry, + dirGateKey, + getDirGates, + mergeDirGates, + capDirGates +} = require('../lib/gateguard-state'); + // Session state — scoped per session to avoid cross-session races. const STATE_DIR = process.env.GATEGUARD_STATE_DIR || path.join(process.env.HOME || process.env.USERPROFILE || '/tmp', '.gateguard'); let activeStateFile = null; +let activeSessionKey = ''; -// State expires after 30 minutes of inactivity const SESSION_TIMEOUT_MS = 30 * 60 * 1000; +const SESSION_ID_TIMEOUT_MS = 8 * 60 * 60 * 1000; +const PROJECT_KEY_PREFIX = 'proj-'; +let activeIdleWindowMs = SESSION_TIMEOUT_MS; const READ_HEARTBEAT_MS = 60 * 1000; // Maximum checked entries to prevent unbounded growth @@ -110,7 +172,7 @@ function getExtraDestructiveRegex() { // Operator-supplied path exemptions. Comma-separated globs (`GATEGUARD_EXEMPT_GLOBS`) // matched against the normalized project-relative path (or full path for an // explicitly absolute glob). First-touch -// fact-forcing is skipped for a matching Edit/Write/MultiEdit target — intended for +// fact-forcing is skipped for a matching Edit/Write/MultiEdit/NotebookEdit target — intended for // low-import-value trees (tests, generated artifacts, scratch dirs) where "who imports // this / what schema" carries no signal. Memoized on the env value; malformed // patterns are dropped without granting exemptions. `*` matches within a path segment, @@ -154,7 +216,7 @@ function getExemptMatchers() { function isExemptPath(filePath, data) { const projectRoot = process.env.CLAUDE_PROJECT_DIR || data.cwd || process.cwd(); if (typeof projectRoot !== 'string' || typeof filePath !== 'string') return false; - const paths = /^[a-z]:[\\/]|^\\\\/i.test(projectRoot) ? path.win32 : path.posix; + const paths = targetClass().WINDOWS_PATH_PATTERN.test(projectRoot) ? path.win32 : path.posix; if (!paths.isAbsolute(projectRoot)) return false; const target = paths.resolve(projectRoot, filePath); const relative = paths.relative(projectRoot, target); @@ -1401,10 +1463,17 @@ function resolveSessionKey(data) { return hashSessionKey('proj', path.resolve(projectFingerprint)); } +// see docs/gateguard/design-notes.md#idle-window +function idleWindowForKey(sessionKey) { + return sessionKey.startsWith(PROJECT_KEY_PREFIX) ? SESSION_TIMEOUT_MS : SESSION_ID_TIMEOUT_MS; +} + function getStateFile(data) { if (!activeStateFile) { const sessionKey = resolveSessionKey(data); + activeSessionKey = sessionKey; activeStateFile = path.join(STATE_DIR, `state-${sessionKey}.json`); + activeIdleWindowMs = idleWindowForKey(sessionKey); } return activeStateFile; } @@ -1415,7 +1484,7 @@ function loadState() { if (fs.existsSync(stateFile)) { const state = JSON.parse(fs.readFileSync(stateFile, 'utf8')); const lastActive = state.last_active || 0; - if (Date.now() - lastActive > SESSION_TIMEOUT_MS) { + if (Date.now() - lastActive > activeIdleWindowMs) { try { fs.unlinkSync(stateFile); } catch (_) { @@ -1455,6 +1524,14 @@ function saveState(state) { let mergedChecked = Array.isArray(state.checked) ? state.checked : []; let mergedLastActive = typeof state.last_active === 'number' ? state.last_active : 0; let mergedDenials = getDenialCount(state); + let mergedCredited = getCreditedCount(state); + let mergedDirGates = getDirGates(state); + let mergedDenialsByClass = getClassCounts(state, 'denials_by_class'); + let mergedCreditedByClass = getClassCounts(state, 'credited_by_class'); + let mergedSiblingAllows = getSiblingAllowCount(state); + let mergedCapAllows = getCapAllowCount(state); + let mergedTrivialAllows = getTrivialAllowCount(state); + let mergedReadonlyPasses = getRoutineReadonlyPassCount(state); try { if (fs.existsSync(stateFile)) { @@ -1466,6 +1543,14 @@ function saveState(state) { mergedLastActive = Math.max(mergedLastActive, diskState.last_active); } mergedDenials = Math.max(mergedDenials, getDenialCount(diskState)); + mergedCredited = Math.max(mergedCredited, getCreditedCount(diskState)); + mergedDirGates = mergeDirGates(getDirGates(diskState), mergedDirGates); + mergedDenialsByClass = mergeClassCounts(getClassCounts(diskState, 'denials_by_class'), mergedDenialsByClass); + mergedCreditedByClass = mergeClassCounts(getClassCounts(diskState, 'credited_by_class'), mergedCreditedByClass); + mergedSiblingAllows = Math.max(mergedSiblingAllows, getSiblingAllowCount(diskState)); + mergedCapAllows = Math.max(mergedCapAllows, getCapAllowCount(diskState)); + mergedTrivialAllows = Math.max(mergedTrivialAllows, getTrivialAllowCount(diskState)); + mergedReadonlyPasses = Math.max(mergedReadonlyPasses, getRoutineReadonlyPassCount(diskState)); } } catch (_) { /* ignore malformed or transient disk state */ @@ -1474,7 +1559,15 @@ function saveState(state) { const finalState = { checked: pruneCheckedEntries(mergedChecked), last_active: Math.max(mergedLastActive, Date.now()), - fact_force_denials: mergedDenials + fact_force_denials: mergedDenials, + fact_force_credited: mergedCredited, + dir_gates: capDirGates(mergedDirGates), + denials_by_class: mergedDenialsByClass, + credited_by_class: mergedCreditedByClass, + sibling_allows: mergedSiblingAllows, + cap_allows: mergedCapAllows, + trivial_allows: mergedTrivialAllows, + routine_readonly_passes: mergedReadonlyPasses }; // Atomic write: temp file + rename prevents partial reads @@ -1539,60 +1632,135 @@ function getFullDenialBudget() { return DEFAULT_FULL_DENIALS; } +// --- Session cap on first-touch denials --- + const MAX_DENIALS_PATTERN = /^\d+$/; +const MAX_DENIALS_WARN_VALUE_CHARS = 64; +let maxDenialsWarnedFor = null; + +function warnMalformedMaxDenials(raw) { + if (raw === maxDenialsWarnedFor) return; + maxDenialsWarnedFor = raw; + const chars = Array.from(sanitizePath(raw)); + const shown = chars.length > MAX_DENIALS_WARN_VALUE_CHARS + ? `${chars.slice(0, MAX_DENIALS_WARN_VALUE_CHARS - 3).join('')}...` + : chars.join(''); + try { + process.stderr.write( + `[Fact-Forcing Gate] ignoring malformed GATEGUARD_FACT_FORCE_MAX_DENIALS=${shown}; the denial cap is not active.\n` + ); + } catch (_) { + /* stderr write failure is non-fatal */ + } +} -/** - * Session-wide ceiling on Edit/Write/MultiEdit fact-force denials, from - * GATEGUARD_FACT_FORCE_MAX_DENIALS. Opt-in: unset keeps the existing behavior - * of denying every new path, and the destructive-Bash gate is unaffected - * either way. - * - * The value is validated whole rather than with Number.parseInt, because a - * prefix parse turns '3.5', '3oops', and '0x3' into finite caps and would - * quietly weaken the gate on a typo. Anything that is not a complete - * non-negative decimal integer leaves the gate uncapped. - * - * @returns {number} the denial ceiling, or Number.POSITIVE_INFINITY when uncapped - */ function getMaxDenialBudget() { - const raw = (process.env.GATEGUARD_FACT_FORCE_MAX_DENIALS || '').trim(); - if (!MAX_DENIALS_PATTERN.test(raw)) { - return Number.POSITIVE_INFINITY; + const raw = process.env.GATEGUARD_FACT_FORCE_MAX_DENIALS || ''; + if (!raw) return Number.POSITIVE_INFINITY; + const trimmed = raw.trim(); + if (MAX_DENIALS_PATTERN.test(trimmed)) { + const parsed = Number(trimmed); + if (Number.isSafeInteger(parsed)) return parsed; } - const parsed = Number(raw); - return Number.isSafeInteger(parsed) ? parsed : Number.POSITIVE_INFINITY; + warnMalformedMaxDenials(raw); + return Number.POSITIVE_INFINITY; } -function getDenialCount(state) { - const n = Number(state && state.fact_force_denials); - return Number.isFinite(n) && n >= 0 ? Math.floor(n) : 0; +function markCheckedWith(key, update) { + const loaded = loadState(); + const withKey = loaded.checked.includes(key) ? loaded : { ...loaded, checked: [...loaded.checked, key] }; + const { state, value } = update(withKey); + return { ok: saveState(state), value }; } -/** - * Record a first-touch target AND count the fact-force denial in the same - * state write. Returns the new denial ordinal (1-based) plus whether the - * write persisted. - */ -function markCheckedAndCountDenial(key) { +function markCheckedAndCountDenial(key, { cls, dirGate, cap = Number.POSITIVE_INFINITY } = {}) { + const { ok, value: denials } = markCheckedWith(key, state => { + if (getDenialCount(state) >= cap) { + return { state: { ...state, cap_allows: getCapAllowCount(state) + 1 }, value: null }; + } + const ordinal = getDenialCount(state) + 1; + const next = { + ...state, + fact_force_denials: ordinal, + ...(cls ? { denials_by_class: incrementClassCount(getClassCounts(state, 'denials_by_class'), cls) } : {}), + ...(dirGate + ? { dir_gates: withEntry(getDirGates(state), dirGate.key, { turn: dirGate.turn || null, at: Date.now(), first: dirGate.first, ordinal }) } + : {}) + }; + return { state: next, value: ordinal }; + }); + return { ok, denials }; +} + +function markCheckedAndCountCredit(key, cls) { + return markCheckedWith(key, state => ({ + state: { + ...state, + fact_force_credited: getCreditedCount(state) + 1, + ...(cls ? { credited_by_class: incrementClassCount(getClassCounts(state, 'credited_by_class'), cls) } : {}) + }, + value: undefined + })).ok; +} + +// --- Trivial edits --- +// see docs/gateguard/change-profile.md#trivial-edits + +const TRIVIAL_CLASSES = new Set(['code', 'test', 'prose']); + +function countTrivialAllow() { const state = loadState(); - if (!state.checked.includes(key)) { - state.checked.push(key); - } - const denials = getDenialCount(state) + 1; - state.fact_force_denials = denials; - return { ok: saveState(state), denials }; + return saveState({ ...state, trivial_allows: getTrivialAllowCount(state) + 1 }); +} + +// see docs/gateguard/change-profile.md#when-a-change-is-profiled +function needsProfile(toolName, cls) { + return targetClass().questionsUseProfile(cls) || ((toolName === 'Edit' || toolName === 'MultiEdit') && TRIVIAL_CLASSES.has(cls)) || isMetricsEnabled(); +} + +function isTrivialChange(cls, restricted, profile) { + return !restricted && TRIVIAL_CLASSES.has(cls) && Boolean(profile) && profile.known === true && profile.trivial === true; +} + +// --- Read-only first shell command --- + +function countRoutineReadonlyPass() { + const state = loadState(); + return saveState({ ...state, routine_readonly_passes: getRoutineReadonlyPassCount(state) + 1 }); +} + +// --- Sibling gates --- + +const SIBLING_NO_TURN_WINDOW_MS = 120 * 1000; + +function markCheckedAndCountSibling(key) { + return markCheckedWith(key, state => ({ + state: { ...state, sibling_allows: getSiblingAllowCount(state) + 1 }, + value: undefined + })).ok; +} + +// see docs/gateguard/design-notes.md#sibling-collapse +function findSiblingGate(key, turnId, now = Date.now()) { + const gates = getDirGates(loadState()); + if (!Object.hasOwn(gates, key)) return null; + const entry = gates[key]; + const age = now - entry.at; + if (!(age >= 0) || entry.ordinal < 1) return null; + if (turnId) return entry.turn === turnId ? entry : null; + return entry.turn === null && age <= SIBLING_NO_TURN_WINDOW_MS ? entry : null; } function isChecked(key) { const state = loadState(); - const found = state.checked.includes(key); + const keys = Array.isArray(key) ? key : [key]; + const found = keys.some(k => state.checked.includes(k)); if (found && Date.now() - (state.last_active || 0) > READ_HEARTBEAT_MS) { saveState(state); } return found; } -// Prune stale session files older than 1 hour (function pruneStaleFiles() { try { const files = fs.readdirSync(STATE_DIR); @@ -1601,9 +1769,10 @@ function isChecked(key) { const isStateFile = f.startsWith('state-') && (f.endsWith('.json') || f.includes('.json.tmp.')); if (!isStateFile) continue; const fp = path.join(STATE_DIR, f); + const window = f.endsWith('.json') ? idleWindowForKey(f.slice('state-'.length)) : SESSION_TIMEOUT_MS; try { const stat = fs.statSync(fp); - if (now - stat.mtimeMs > SESSION_TIMEOUT_MS * 2) { + if (now - stat.mtimeMs > window * 2) { fs.unlinkSync(fp); } } catch (_) { @@ -1776,35 +1945,15 @@ function batchSiblingWarning(safePath) { ); } -function editGateMsg(filePath) { +function firstTouchGateMsg(filePath, isWrite, cls, profile) { const safe = sanitizePath(filePath); + const questions = questionIdsFor(cls, isWrite, profile).map(questionText); return [ '[Fact-Forcing Gate]', '', - `Before editing ${safe}, present these facts:`, + `Before ${isWrite ? 'creating' : 'editing'} ${safe}, present these facts:`, '', - '1. List ALL files that import/require this file (search the tree — Glob/Grep, or find/grep via Bash)', - '2. List the public functions/classes affected by this change', - '3. If this file reads/writes data files, show field names, structure, and date format (use redacted or synthetic values, not raw production data)', - "4. Quote the user's current instruction verbatim", - '', - batchSiblingWarning(safe), - '', - 'Present the facts, then retry the same operation.' - ].join('\n'); -} - -function writeGateMsg(filePath) { - const safe = sanitizePath(filePath); - return [ - '[Fact-Forcing Gate]', - '', - `Before creating ${safe}, present these facts:`, - '', - '1. Name the file(s) and line(s) that will call this new file', - '2. Confirm no existing file serves the same purpose (search the tree — Glob/Grep, or find/grep via Bash)', - '3. If this file reads/writes data files, show field names, structure, and date format (use redacted or synthetic values, not raw production data)', - "4. Quote the user's current instruction verbatim", + ...questions.map((question, index) => `${index + 1}. ${question}`), '', batchSiblingWarning(safe), '', @@ -1817,16 +1966,61 @@ function writeGateMsg(filePath) { * (#2142). Carries the denial ordinal so consecutive denials differ * textually, and a one-line recovery hint instead of the multi-line block. */ -function condensedGateMsg(action, filePath, ordinal) { +function condensedGateMsg(action, filePath, ordinal, cls = 'code', targetNote = '', profile = null, missNote = '') { const safe = sanitizePath(filePath); + const hint = condensedHintFor(cls, action === 'creation', profile); return ( `[Fact-Forcing Gate] (denial #${ordinal} this session) First ${action} of ${safe}: ` + - "briefly state importers/callers, affected API, data schemas if any, and the user's verbatim instruction, then retry. " + + `${hint} ` + `${batchSiblingWarning(safe)} ` + + (missNote ? `${missNote} ` : '') + + (targetNote ? `${targetNote} ` : '') + '(Use GATEGUARD_EXEMPT_GLOBS for path-scoped exemptions; GATEGUARD_FACT_FORCE_MAX_DENIALS caps denials per session; ECC_GATEGUARD=off disables this gate.)' ); } +const SENSITIVE_TARGET_NOTE = 'Sensitive target: prior-search credit, sibling collapse, and the denial cap do not apply.'; +const HARD_LINKED_TARGET_NOTE = 'Hard-linked target: prior-search credit, sibling collapse, and the denial cap do not apply.'; +const RETRY_LINE = 'Present the facts, then retry the same operation.'; + +function withNoteBeforeRetry(message, note) { + if (!note) return message; + const at = message.lastIndexOf(RETRY_LINE); + if (at < 0) return `${message}\n\n${note}`; + return `${message.slice(0, at)}${note}\n\n${message.slice(at)}`; +} + +function restrictedTargetNote(sensitive, linked) { + if (sensitive) return SENSITIVE_TARGET_NOTE; + return linked ? HARD_LINKED_TARGET_NOTE : ''; +} + +function denialReason(sensitive, linked, missNote, miss) { + if (sensitive) return 'sensitive'; + if (linked) return 'hard-linked'; + return missNote ? `near-miss:${miss.reason}` : 'first-touch'; +} + +function firstTouchDenial(filePath, { isWrite, denials, cls, sensitive, linked = false, profile, miss, reason }) { + const targetNote = restrictedTargetNote(sensitive, linked); + const missNote = targetNote ? '' : closestMissNote(miss); + recordDecision('deny', reason || denialReason(sensitive, linked, missNote, miss), { + target: filePath, + cls, + sensitive, + profile, + questions: questionIdsFor(cls, isWrite, profile) + }); + if (denials > getFullDenialBudget()) { + const action = isWrite ? 'creation' : 'edit'; + return denyResult(condensedGateMsg(action, filePath, denials, cls, targetNote, profile, missNote), { includeRecoveryHint: false }); + } + const message = withNoteBeforeRetry(firstTouchGateMsg(filePath, isWrite, cls, profile), missNote); + return denyResult(targetNote ? withNoteBeforeRetry(message, targetNote) : message, { + narrowRecoveryHint: EDIT_WRITE_NARROW_RECOVERY_HINT + }); +} + function destructiveBashMsg() { return [ '[Fact-Forcing Gate]', @@ -1897,15 +2091,241 @@ function denyResult(reason, options = {}) { } function allowWithStateWarning() { + recordDecision('pass', 'state-error'); return { stderr: '[Fact-Forcing Gate] GateGuard state could not be persisted; allowing this operation to avoid a permanent retry loop. Check GATEGUARD_STATE_DIR or filesystem permissions.', exitCode: 0 }; } +// --- Prior-search credit --- + +const CREDIT_DETAIL_MAX_CHARS = 80; + +function creditNote(match, filePath) { + const chars = Array.from(sanitizePath(match.detail).replace(/\s+/g, ' ')); + const detail = chars.length > CREDIT_DETAIL_MAX_CHARS ? `${chars.slice(0, CREDIT_DETAIL_MAX_CHARS - 3).join('')}...` : chars.join(''); + const calls = `${match.callsAgo} tool call${match.callsAgo === 1 ? '' : 's'} ago`; + return ( + `[Fact-Forcing Gate] Prior search seen in this turn (${match.name} ${detail}, ${calls}); ` + + `first-touch check satisfied for ${sanitizePath(filePath)}.` + ); +} + +// --- Closest search that did not count --- + +const MISS_DETAIL_MAX_CHARS = 60; +const MISS_REASON_TEXT = Object.freeze({ + 'same-batch': 'it was sent in the same batch as this call, so its result was not seen yet', + excluded: 'its filters exclude this file', + 'out-of-scope': 'its search path does not contain this file', + 'stdin-only': 'it searched piped input, not the tree', + 'not-a-search': 'only Glob, Grep, LS and shell search commands count', + 'generic-stem': 'this file name is too generic to match a search' +}); + +function closestMissNote(miss) { + if (!miss || !Object.hasOwn(MISS_REASON_TEXT, miss.reason)) return ''; + const chars = Array.from(sanitizePath(miss.detail).replace(/\s+/g, ' ').trim()); + const detail = chars.length > MISS_DETAIL_MAX_CHARS ? `${chars.slice(0, MISS_DETAIL_MAX_CHARS - 3).join('')}...` : chars.join(''); + return `Closest search this turn did not count (${sanitizePath(miss.name)} ${detail}): ${MISS_REASON_TEXT[miss.reason]}.`; +} + +function isMissingOnDisk(resolved) { + try { + fs.lstatSync(resolved); + return false; + } catch (error) { + return Boolean(error) && error.code === 'ENOENT'; + } +} + +function isNewFileTarget(filePath, data) { + try { + const target = resolveTargetPath(filePath, data); + return Boolean(target) && isMissingOnDisk(target.resolved); + } catch (_) { + return false; + } +} + +function newFileGateKey(filePath, data, cls) { + try { + const dir = collapseGateDir(filePath, data, cls); + return dir ? dirGateKey(cls, dir) : null; + } catch (_) { + return null; + } +} + +function trivialNote(filePath) { + return ( + `[Fact-Forcing Gate] Comment or whitespace-only change to ${sanitizePath(filePath)}; no first-touch check needed. ` + + 'The next change to this file that alters code is still checked.' + ); +} + +function siblingNote(gate) { + return ( + `[Fact-Forcing Gate] Sibling of ${sanitizePath(gate.first)} (gated earlier at denial #${gate.ordinal} this session); ` + + 'proceeding without a repeat denial.' + ); +} + +// --- Allowance eligibility --- +// see docs/gateguard/design-notes.md#hard-linked-targets + +function allowanceFacts(filePath, data) { + const sensitive = isSensitiveTargetFor(filePath, data); + const linked = !sensitive && isHardLinkedTargetFor(filePath, data); + return { sensitive, linked, restricted: sensitive || linked }; +} + +// --- Subagents --- +// see docs/gateguard/design-notes.md#subagents + +function subagentGateKey(fileKey) { + return `__subagent__${crypto.createHash('sha256').update(fileKey).digest('hex').slice(0, 16)}`; +} + +function subagentRestrictedDenial(filePath, data, isWrite) { + const { sensitive, linked, restricted } = allowanceFacts(filePath, data); + if (!restricted) return null; + const fileKey = canonicalPathKey(filePath, data); + const subKey = subagentGateKey(fileKey); + if (isChecked([fileKey, filePath, subKey])) return null; + const cls = classifyTargetFor(filePath, data); + const { ok, denials } = markCheckedAndCountDenial(subKey, { cls }); + if (!ok) return allowWithStateWarning(); + const reason = sensitive ? 'subagent-sensitive' : 'subagent-hard-linked'; + return firstTouchDenial(filePath, { isWrite, denials, cls, sensitive, linked, profile: null, miss: null, reason }); +} + +// --- Change profile --- +// see docs/gateguard/change-profile.md#file-context + +const MAX_CONTEXT_FILE_BYTES = 1024 * 1024; + +function readTargetText(filePath, data) { + let fd = null; + try { + const target = resolveTargetPath(filePath, data); + if (!target) return null; + fd = fs.openSync(target.resolved, fs.constants.O_RDONLY | (fs.constants.O_NONBLOCK || 0)); + const stat = fs.fstatSync(fd); + if (!stat.isFile() || stat.size > MAX_CONTEXT_FILE_BYTES) return null; + const buffer = Buffer.alloc(stat.size); + let read = 0; + while (read < buffer.length) { + const n = fs.readSync(fd, buffer, read, buffer.length - read, read); + if (n === 0) break; + read += n; + } + return buffer.toString('utf8', 0, read).replace(/\r\n/g, '\n'); + } catch (_) { + return null; + } finally { + if (fd !== null) { + try { + fs.closeSync(fd); + } catch (_) { + /* ignore */ + } + } + } +} + +function changeProfileFor(toolName, filePath, edits, content, data) { + const tool = toolName === 'Write' ? 'Write' : 'Edit'; + const fileText = tool === 'Edit' ? readTargetText(filePath, data) : null; + return profileChange({ filePath, tool, edits, content, fileText }); +} + +// see docs/gateguard/design-notes.md#multiedit-paths +function multiEditEntries(toolInput) { + const edits = Array.isArray(toolInput.edits) ? toolInput.edits : []; + const shared = typeof toolInput.file_path === 'string' ? toolInput.file_path : ''; + return edits.map(edit => (edit && typeof edit === 'object' && !edit.file_path && shared ? { ...edit, file_path: shared } : edit)); +} + +function entriesFor(edits, fileKey, data) { + return edits.filter(edit => edit && typeof edit.file_path === 'string' && canonicalPathKey(edit.file_path, data) === fileKey); +} + +// --- Metrics --- +// see docs/gateguard/design-notes.md#metrics + +let pendingMetrics = null; + +function isMetricsEnabled() { + return ECC_ENABLE_VALUES.has(normalizeEnvValue(process.env.GATEGUARD_METRICS)); +} + +function recordDecision(decision, reason, fields = {}) { + if (!pendingMetrics) return; + if (fields.key !== undefined) { + if (pendingMetrics.keys.has(fields.key)) return; + pendingMetrics.keys.add(fields.key); + } + pendingMetrics.entries.push({ ...fields, decision, reason }); +} + +function targetFacts(entry, data) { + if (!entry.target) { + return { cls: entry.cls === undefined ? null : entry.cls, sensitive: entry.sensitive === true }; + } + try { + return { + cls: entry.cls === undefined ? classifyTargetFor(entry.target, data) : entry.cls, + sensitive: entry.sensitive === undefined ? isSensitiveTargetFor(entry.target, data) : entry.sensitive + }; + } catch (_) { + return { cls: null, sensitive: false }; + } +} + +function flushMetrics() { + const batch = pendingMetrics; + pendingMetrics = null; + if (!batch || batch.entries.length === 0) return; + try { + const now = Date.now(); + const events = batch.entries.map(entry => + metricsEvent({ + sessionKey: activeSessionKey, + tool: batch.tool, + ...targetFacts(entry, batch.data), + decision: entry.decision, + reason: entry.reason, + questions: entry.questions, + profile: entry.profile, + now + }) + ); + appendMetrics(STATE_DIR, events); + } catch (_) { + /* ignore */ + } +} + +function exemptReason(filePath, data) { + if (!filePath) return 'no-path'; + if (isClaudeSettingsPath(filePath)) return 'claude-settings'; + return isExemptPath(filePath, data) ? 'exempt-glob' : ''; +} + // --- Core logic (exported for run-with-flags.js) --- function run(rawInput) { + pendingMetrics = null; + try { + return gate(rawInput); + } finally { + flushMetrics(); + } +} + +function gate(rawInput) { let data; try { data = typeof rawInput === 'string' ? JSON.parse(rawInput) : rawInput; @@ -1923,63 +2343,148 @@ function run(rawInput) { const rawToolName = data.tool_name || ''; const toolInput = data.tool_input || {}; // Normalize: case-insensitive matching via lookup map - const TOOL_MAP = { edit: 'Edit', write: 'Write', multiedit: 'MultiEdit', bash: 'Bash', powershell: 'PowerShell' }; + const TOOL_MAP = { edit: 'Edit', write: 'Write', multiedit: 'MultiEdit', notebookedit: 'NotebookEdit', bash: 'Bash', powershell: 'PowerShell' }; const toolName = TOOL_MAP[rawToolName.toLowerCase()] || rawToolName; + if (isMetricsEnabled()) { + pendingMetrics = { tool: toolName, data, entries: [], keys: new Set() }; + } const inSubagent = isSubagentInvocation(data); + let turnScanner = null; + const getTurnScan = () => (turnScanner || (turnScanner = createTurnScanner(data)))(); - if (toolName === 'Edit' || toolName === 'Write') { - const filePath = toolInput.file_path || ''; - if (!filePath || isClaudeSettingsPath(filePath) || isExemptPath(filePath, data)) { + if (toolName === 'Edit' || toolName === 'Write' || toolName === 'NotebookEdit') { + const filePath = (toolName === 'NotebookEdit' ? toolInput.notebook_path : toolInput.file_path) || ''; + const exempt = exemptReason(filePath, data); + if (exempt) { + recordDecision('pass-exempt', exempt, { target: filePath }); return rawInput; // allow } if (inSubagent) { - return rawInput; // parent session already passed the first-touch file gate + const denial = subagentRestrictedDenial(filePath, data, toolName === 'Write'); + if (!denial) recordDecision('pass-subagent', 'subagent', { target: filePath }); + return denial || rawInput; } - if (!isChecked(filePath)) { - const { ok, denials } = markCheckedAndCountDenial(filePath); + const fileKey = canonicalPathKey(filePath, data); + if (!isChecked([fileKey, filePath])) { + const isNewFile = toolName === 'Write' && isNewFileTarget(filePath, data); + const cls = classifyTargetFor(filePath, data); + // see docs/gateguard/design-notes.md#sensitive-targets + const { sensitive, linked, restricted } = allowanceFacts(filePath, data); + const credit = restricted ? null : findCreditingSearch(getTurnScan(), filePath, isNewFile, data); + if (credit) { + if (!markCheckedAndCountCredit(fileKey, cls)) { + return allowWithStateWarning(); + } + recordDecision('credit', 'prior-search', { target: filePath, cls, sensitive }); + return { additionalContext: creditNote(credit, filePath), exitCode: 0 }; + } + const profile = restricted || toolName === 'NotebookEdit' || !needsProfile(toolName, cls) + ? null + : changeProfileFor(toolName, filePath, [toolInput], toolInput.content, data); + if (toolName === 'Edit' && isTrivialChange(cls, restricted, profile)) { + if (!countTrivialAllow()) { + return allowWithStateWarning(); + } + recordDecision('trivial', 'comment-whitespace', { target: filePath, cls, sensitive, profile }); + return { additionalContext: trivialNote(filePath), exitCode: 0 }; + } + const turnId = isNewFile && !restricted ? currentTurnId(getTurnScan()) : null; + const collapsible = isNewFile && !restricted && (turnId !== null || !transcriptPathFor(data)); + const gateKey = collapsible ? newFileGateKey(filePath, data, cls) : null; + const sibling = gateKey ? findSiblingGate(gateKey, turnId) : null; + if (sibling) { + if (!markCheckedAndCountSibling(fileKey)) { + return allowWithStateWarning(); + } + recordDecision('sibling', 'same-turn-dir', { target: filePath, cls, sensitive, profile }); + return { additionalContext: siblingNote(sibling), exitCode: 0 }; + } + const dirGate = gateKey ? { key: gateKey, turn: turnId, first: sanitizePath(filePath) } : undefined; + const cap = restricted ? Number.POSITIVE_INFINITY : getMaxDenialBudget(); + const { ok, denials } = markCheckedAndCountDenial(fileKey, { cls, dirGate, cap }); if (!ok) { return allowWithStateWarning(); } - if (denials > getMaxDenialBudget()) { + if (denials === null) { + // see docs/gateguard/design-notes.md#denial-cap + recordDecision('cap', 'max-denials', { target: filePath, cls, sensitive, profile }); return rawInput; } - if (denials > getFullDenialBudget()) { - const action = toolName === 'Edit' ? 'edit' : 'creation'; - return denyResult(condensedGateMsg(action, filePath, denials), { includeRecoveryHint: false }); - } - return denyResult(toolName === 'Edit' ? editGateMsg(filePath) : writeGateMsg(filePath), { - narrowRecoveryHint: EDIT_WRITE_NARROW_RECOVERY_HINT - }); + const miss = restricted ? null : findClosestMiss(getTurnScan(), filePath, isNewFile, data); + return firstTouchDenial(filePath, { isWrite: toolName === 'Write', denials, cls, sensitive, linked, profile, miss }); } + recordDecision('pass-checked', 'checked', { target: filePath }); return rawInput; // allow } if (toolName === 'MultiEdit') { + const edits = multiEditEntries(toolInput); if (inSubagent) { - return rawInput; // parent session already passed the first-touch file gate + for (const edit of edits) { + const filePath = (edit && edit.file_path) || ''; + if (!filePath || isClaudeSettingsPath(filePath) || isExemptPath(filePath, data)) continue; + const denial = subagentRestrictedDenial(filePath, data, false); + if (denial) return denial; + } + recordDecision('pass-subagent', 'subagent'); + return rawInput; } - const edits = toolInput.edits || []; + const notes = []; + const trivialKeys = new Set(); for (const edit of edits) { - const filePath = edit.file_path || ''; - if (filePath && !isClaudeSettingsPath(filePath) && !isExemptPath(filePath, data) && !isChecked(filePath)) { - const { ok, denials } = markCheckedAndCountDenial(filePath); + const filePath = (edit && edit.file_path) || ''; + const exempt = exemptReason(filePath, data); + if (exempt) { + recordDecision('pass-exempt', exempt, { target: filePath, key: `exempt:${filePath}` }); + continue; + } + const fileKey = canonicalPathKey(filePath, data); + if (trivialKeys.has(fileKey)) continue; + if (!isChecked([fileKey, filePath])) { + const cls = classifyTargetFor(filePath, data); + const { sensitive, linked, restricted } = allowanceFacts(filePath, data); + const credit = restricted ? null : findCreditingSearch(getTurnScan(), filePath, false, data); + if (credit) { + if (!markCheckedAndCountCredit(fileKey, cls)) { + return allowWithStateWarning(); + } + recordDecision('credit', 'prior-search', { target: filePath, cls, sensitive, key: fileKey }); + notes.push(creditNote(credit, filePath)); + continue; + } + const profile = restricted || !needsProfile('MultiEdit', cls) + ? null + : changeProfileFor('Edit', filePath, entriesFor(edits, fileKey, data), undefined, data); + if (isTrivialChange(cls, restricted, profile)) { + if (!countTrivialAllow()) { + return allowWithStateWarning(); + } + trivialKeys.add(fileKey); + recordDecision('trivial', 'comment-whitespace', { target: filePath, cls, sensitive, profile, key: fileKey }); + notes.push(trivialNote(filePath)); + continue; + } + const cap = restricted ? Number.POSITIVE_INFINITY : getMaxDenialBudget(); + const { ok, denials } = markCheckedAndCountDenial(fileKey, { cls, cap }); if (!ok) { return allowWithStateWarning(); } - if (denials > getMaxDenialBudget()) { - return rawInput; + if (denials === null) { + // see docs/gateguard/design-notes.md#denial-cap + recordDecision('cap', 'max-denials', { target: filePath, cls, sensitive, profile, key: fileKey }); + continue; } - if (denials > getFullDenialBudget()) { - return denyResult(condensedGateMsg('edit', filePath, denials), { includeRecoveryHint: false }); - } - return denyResult(editGateMsg(filePath), { - narrowRecoveryHint: EDIT_WRITE_NARROW_RECOVERY_HINT - }); + const miss = restricted ? null : findClosestMiss(getTurnScan(), filePath, false, data); + return firstTouchDenial(filePath, { isWrite: false, denials, cls, sensitive, linked, profile, miss }); } + recordDecision('pass-checked', 'checked', { target: filePath, key: fileKey }); + } + if (notes.length > 0) { + return { additionalContext: notes, exitCode: 0 }; } return rawInput; // allow } @@ -1987,6 +2492,7 @@ function run(rawInput) { if (toolName === 'Bash' || toolName === 'PowerShell') { const command = toolInput.command || ''; if (isReadOnlyGitIntrospection(command)) { + recordDecision('pass', 'readonly-git'); return rawInput; } @@ -1997,8 +2503,10 @@ function run(rawInput) { if (!markChecked(key)) { return allowWithStateWarning(); } + recordDecision('destructive-deny', 'destructive'); return denyResult(destructiveBashMsg(), { includeRecoveryHint: false }); } + recordDecision('pass-checked', 'destructive-retry'); return rawInput; // allow retry after facts presented } @@ -2007,13 +2515,22 @@ function run(rawInput) { // (Cursor, OpenCode, etc.) where the once-per-session routine gate is // friction without signal. if (isRoutineBashGateDisabled()) { + recordDecision('pass-exempt', 'routine-disabled'); return rawInput; // routine gate opted out via env } if (!isChecked(ROUTINE_BASH_SESSION_KEY)) { + if (isReadOnlyShellCommand(toolName, command)) { + if (!countRoutineReadonlyPass()) { + return allowWithStateWarning(); + } + recordDecision('routine-readonly', 'readonly'); + return rawInput; + } if (!markChecked(ROUTINE_BASH_SESSION_KEY)) { return allowWithStateWarning(); } + recordDecision('routine-deny', 'first-command'); const hookId = toolName === 'PowerShell' ? POWERSHELL_HOOK_ID : BASH_HOOK_ID; const narrowRecoveryHint = toolName === 'PowerShell' ? ROUTINE_POWERSHELL_NARROW_RECOVERY_HINT @@ -2024,10 +2541,28 @@ function run(rawInput) { }); } + recordDecision('pass-checked', 'routine-checked'); return rawInput; // allow } return rawInput; // allow } -module.exports = { classifyDestructiveCommand, run }; +module.exports = { + run, + classifyDestructiveCommand, + findCreditingSearch, + findClosestMiss, + get classifyTarget() { + return targetClass().classifyTarget; + }, + get classifyTargetFor() { + return targetClass().classifyTargetFor; + }, + get isReadOnlyShellCommand() { + return readOnlyShell().isReadOnlyShellCommand; + }, + get scanCurrentTurn() { + return turnScan().scanCurrentTurn; + } +}; diff --git a/scripts/lib/file-tail.js b/scripts/lib/file-tail.js new file mode 100644 index 000000000..807a1a937 --- /dev/null +++ b/scripts/lib/file-tail.js @@ -0,0 +1,41 @@ +'use strict'; + +const fs = require('fs'); + +const DEFAULT_TRANSCRIPT_TAIL_BYTES = 256 * 1024; + +/** Last `tailBytes` of a file as UTF-8 text, with whether earlier bytes were skipped; null when unreadable. */ +function readFileTail(filePath, tailBytes) { + let fd; + try { + fd = fs.openSync(filePath, 'r'); + } catch { + return null; + } + + try { + const size = fs.fstatSync(fd).size; + const start = Math.max(0, size - tailBytes); + const length = size - start; + if (length <= 0) { + return { text: '', truncated: false }; + } + + const buffer = Buffer.alloc(length); + const bytesRead = fs.readSync(fd, buffer, 0, length, start); + return { + text: buffer.toString('utf8', 0, bytesRead), + truncated: start > 0 + }; + } catch { + return null; + } finally { + try { + fs.closeSync(fd); + } catch { + /* ignore */ + } + } +} + +module.exports = { DEFAULT_TRANSCRIPT_TAIL_BYTES, readFileTail }; diff --git a/scripts/lib/gateguard-change-profile.js b/scripts/lib/gateguard-change-profile.js new file mode 100644 index 000000000..89f655ca5 --- /dev/null +++ b/scripts/lib/gateguard-change-profile.js @@ -0,0 +1,457 @@ +'use strict'; + +const { isSpace, isIdentChar, isLetter, shCodeLines, psCodeLines, batchCodeLines, normalizeShellLine } = require('./gateguard-code-lexer'); +const { MAX_FILE_CHARS, lineEndAt, lineStartOf, cFamilyStartsFresh, pythonStartsFresh, shStartsFresh, psStartsFresh, batchStartsFresh, trivialInFile } = require('./gateguard-file-context'); + +// see docs/gateguard/change-profile.md#change-profile + +const MAX_SIDE_BYTES = 64 * 1024; +const MAX_TOTAL_BYTES = 256 * 1024; +const MAX_EDITS = 64; + +const UNKNOWN_PROFILE = Object.freeze({ known: false, language: null, touchesPublicSurface: true, touchesData: true, trivial: false }); + +const LANGUAGE_BY_EXT = new Map([ + ['.js', 'js'], ['.mjs', 'js'], ['.cjs', 'js'], ['.jsx', 'js'], ['.ts', 'js'], ['.tsx', 'js'], ['.mts', 'js'], ['.cts', 'js'], + ['.py', 'python'], ['.pyi', 'python'], + ['.go', 'go'], + ['.rs', 'rust'], + ['.java', 'java'], + ['.kt', 'kotlin'], ['.kts', 'kotlin'], + ['.cs', 'csharp'], + ['.c', 'c'], ['.h', 'c'], + ['.cc', 'cpp'], ['.cpp', 'cpp'], ['.cxx', 'cpp'], ['.hpp', 'cpp'], ['.hh', 'cpp'], ['.hxx', 'cpp'], + ['.sh', 'shell'], ['.bash', 'shell'], ['.zsh', 'shell'], + ['.ps1', 'powershell'], ['.psm1', 'powershell'], + ['.bat', 'batch'], ['.cmd', 'batch'] +]); + +// --- Lexing --- +// see docs/gateguard/change-profile.md#trivial-edits + +const C_FAMILY = { lineComment: '//', blockComment: true, quotes: '"\'', indentSensitive: false }; + +const C_CONTEXT = { ...C_FAMILY, startsFresh: cFamilyStartsFresh }; + +const LEX_SPECS = { + js: { ...C_CONTEXT, flavor: 'js', forbiddenCode: '`/', jsx: true }, + go: { ...C_CONTEXT, flavor: 'go', forbiddenCode: '`' }, + rust: { ...C_CONTEXT, flavor: 'rust', quotes: '"', rustChars: true, rawPrefixes: 'r#', rustDocs: true }, + java: { ...C_CONTEXT, flavor: 'java', tripleQuotes: true }, + kotlin: { ...C_CONTEXT, flavor: 'kotlin', tripleQuotes: true, noDollarInStrings: true }, + csharp: { ...C_CONTEXT, flavor: 'csharp', tripleQuotes: true, rawPrefixes: '@$' }, + c: { ...C_CONTEXT, flavor: 'c', rawPrefixes: 'R' }, + cpp: { ...C_CONTEXT, flavor: 'c', rawPrefixes: 'R' }, + python: { lineComment: '#', blockComment: false, quotes: '"\'', indentSensitive: true, tripleQuotes: true, fStrings: true, startsFresh: pythonStartsFresh }, + shell: { lexer: shCodeLines, normalize: line => normalizeShellLine(line, '\\'), startsFresh: shStartsFresh }, + powershell: { lexer: psCodeLines, normalize: line => normalizeShellLine(line, '`'), startsFresh: psStartsFresh }, + batch: { lexer: batchCodeLines, normalize: line => line, startsFresh: batchStartsFresh } +}; +const TS_EXTS = new Set(['.ts', '.mts', '.cts']); +const TS_SPEC = { ...LEX_SPECS.js, jsx: false }; + +function lexSpecFor(filePath, language) { + if (language !== 'js') return LEX_SPECS[language]; + const base = baseName(filePath); + return TS_EXTS.has(base.slice(base.lastIndexOf('.'))) ? TS_SPEC : LEX_SPECS.js; +} + +// --- Public surface --- + +function leadingWord(text, word) { + return text.startsWith(word) && !isIdentChar(text[word.length]); +} + +function containsWord(text, word) { + for (let at = text.indexOf(word); at !== -1; at = text.indexOf(word, at + 1)) { + if (!isIdentChar(text[at - 1]) && !isIdentChar(text[at + word.length])) return true; + } + return false; +} + +function identifierAt(text, start) { + let end = start; + while (end < text.length && isIdentChar(text[end])) end++; + return text.slice(start, end); +} + +function skipSpaces(text, at) { + while (at < text.length && isSpace(text[at])) at++; + return at; +} + +function isUpper(ch) { + return ch !== undefined && ch >= 'A' && ch <= 'Z'; +} + +function jsLineIsSurface(line) { + const t = line.slice(skipSpaces(line, 0)); + return leadingWord(t, 'export') || containsWord(t, 'exports') || leadingWord(t, 'public') || leadingWord(t, 'declare'); +} + +function pythonNameIsPublic(name) { + if (!name) return false; + return !name.startsWith('_') || (name.length > 4 && name.startsWith('__') && name.endsWith('__')); +} + +function pythonLineIsSurface(line, index) { + const start = skipSpaces(line, 0); + const t = line.slice(start); + if (t.includes('__all__')) return true; + for (const keyword of ['def', 'class']) { + if (leadingWord(t, keyword)) return pythonNameIsPublic(identifierAt(t, skipSpaces(t, keyword.length))); + } + if (leadingWord(t, 'async')) { + const rest = t.slice(skipSpaces(t, 5)); + if (leadingWord(rest, 'def')) return pythonNameIsPublic(identifierAt(rest, skipSpaces(rest, 3))); + } + if (start !== 0) return false; + const name = identifierAt(t, 0); + const rest = skipSpaces(t, name.length); + const after = t[rest]; + const assigned = (after === '=' && t[rest + 1] !== '=') || (after === ':' && skipSpaces(t, rest + 1) < t.length); + if (!name || !isLetter(name[0]) || !assigned) return false; + return index > 0 ? pythonNameIsPublic(name) : name === name.toUpperCase(); +} + +function goLineIsSurface(line) { + const t = line.slice(skipSpaces(line, 0)); + if (isUpper(t[0])) return true; + if (leadingWord(t, 'package')) return true; + for (const keyword of ['type', 'var', 'const']) { + if (leadingWord(t, keyword)) return isUpper(t[skipSpaces(t, keyword.length)]); + } + if (!leadingWord(t, 'func')) return false; + let at = skipSpaces(t, 4); + if (t[at] === '(') { + const close = t.indexOf(')', at); + if (close === -1) return true; + at = skipSpaces(t, close + 1); + } + return isUpper(t[at]); +} + +const RUST_SURFACE_ATTRIBUTES = ['#[macro_export', '#[derive', '#[repr', '#[no_mangle', '#[unsafe(no_mangle', '#[export_name', '#[unsafe(export_name']; + +function rustLineIsSurface(line) { + const t = line.slice(skipSpaces(line, 0)); + return leadingWord(t, 'pub') || leadingWord(t, 'impl') || leadingWord(t, 'trait') || leadingWord(t, 'extern') || + RUST_SURFACE_ATTRIBUTES.some(attribute => t.startsWith(attribute)); +} + +function shellFunctionName(t) { + let end = 0; + while (end < t.length && (isIdentChar(t[end]) || t[end] === '-' || t[end] === ':' || t[end] === '.')) end++; + if (end === 0) return false; + const open = skipSpaces(t, end); + return t[open] === '(' && t[skipSpaces(t, open + 1)] === ')'; +} + +function shellLineIsSurface(line) { + const t = line.slice(skipSpaces(line, 0)); + if (leadingWord(t, 'export') || leadingWord(t, 'function')) return true; + if (leadingWord(t, 'declare') || leadingWord(t, 'typeset')) { + const flags = t.slice(skipSpaces(t, 7)); + return flags[0] === '-' && identifierAt(flags, 1).includes('x'); + } + return shellFunctionName(t); +} + +const POWERSHELL_SURFACE_WORDS = ['function', 'filter', 'workflow', 'class', 'enum', 'param', 'export-modulemember', '[cmdletbinding', '$global:']; + +function powershellLineIsSurface(line) { + const t = line.slice(skipSpaces(line, 0)).toLowerCase(); + return POWERSHELL_SURFACE_WORDS.some(word => t.startsWith(word) && (word.endsWith(':') || !isIdentChar(t[word.length]))); +} + +const SURFACE_BY_LANGUAGE = { + js: jsLineIsSurface, + python: pythonLineIsSurface, + go: goLineIsSurface, + rust: rustLineIsSurface, + shell: shellLineIsSurface, + powershell: powershellLineIsSurface +}; + +function baseName(filePath) { + return String(filePath).replace(/\\/g, '/').split('/').pop().toLowerCase(); +} + +function wholeFileIsSurface(filePath, language) { + const base = baseName(filePath); + return (language === 'js' && (base.endsWith('.d.ts') || base.endsWith('.d.mts') || base.endsWith('.d.cts'))) || + (language === 'python' && base === '__init__.py'); +} + +function textTouchesSurface(text, lineIsSurface) { + let index = 0; + let start = 0; + for (let i = 0; i <= text.length; i++) { + if (i === text.length || text[i] === '\n' || text[i] === '\r') { + if (lineIsSurface(text.slice(start, i), index)) return true; + index++; + start = i + 1; + } + } + return false; +} + +// --- Container members --- +// see docs/gateguard/change-profile.md#public-surface + +const MAX_OPENER_SCAN = 4 * 1024 * 1024; +const JS_BODY_KINDS = ['function', 'async']; +const JS_MEMBER_KINDS = ['class', 'abstract']; +const JS_CONTAINER_KINDS = ['interface', 'enum', 'type', 'namespace', 'module', 'const', 'let', 'var', 'declare', '{']; +const RUST_CONTAINER_KINDS = ['enum', 'struct', 'union', 'trait', 'use']; + +function indentOf(line) { + return line.slice(0, skipSpaces(line, 0)); +} + +function lineAt(text, start) { + const end = lineEndAt(text, start); + return { text: text.slice(start, end), next: end + (text[end] === '\r' && text[end + 1] === '\n' ? 2 : 1) }; +} + +function isCommentLine(t) { + return t.startsWith('//') || t.startsWith('/*') || t.startsWith('*') || t.startsWith('#') || t.startsWith('@'); +} + +function enclosingOpener(text, at, budget) { + let end = lineStartOf(text, at); + while (end > 0) { + let last = end - 1; + if (text[last] === '\n' && text[last - 1] === '\r') last--; + const start = lineStartOf(text, last); + budget.left -= end - start; + if (budget.left < 0) return null; + const line = text.slice(start, last); + end = start; + if (!line.trim() || isSpace(line[0])) continue; + if ('}])'.includes(line[0])) return null; + if (isCommentLine(line)) continue; + return { line, start }; + } + return null; +} + +function jsContainerKind(opener) { + const t = opener.trimEnd(); + const bodyLike = t.includes('=>') || containsWord(t, 'function'); + if (containsWord(t, 'exports')) return !bodyLike && /[{[(]$/.test(t) ? 'all' : null; + if (!leadingWord(t, 'export')) return null; + let rest = t.slice(skipSpaces(t, 6)); + if (leadingWord(rest, 'default')) rest = rest.slice(skipSpaces(rest, 7)); + if (JS_BODY_KINDS.some(word => leadingWord(rest, word))) return null; + if (JS_MEMBER_KINDS.some(word => leadingWord(rest, word))) return 'members'; + if (rest.startsWith('{')) return 'all'; + if (!JS_CONTAINER_KINDS.some(word => leadingWord(rest, word))) return null; + return bodyLike ? null : 'all'; +} + +function containerKind(opener, language) { + if (language === 'js') return jsContainerKind(opener); + if (language === 'python') { + if (opener.includes('__all__')) return 'all'; + return leadingWord(opener, 'class') && pythonNameIsPublic(identifierAt(opener, skipSpaces(opener, 5))) ? 'fields' : null; + } + if (language === 'rust' && leadingWord(opener, 'pub')) { + const words = opener.split(/[^A-Za-z0-9_]+/); + return RUST_CONTAINER_KINDS.some(word => words.includes(word)) ? 'members' : null; + } + return null; +} + +function memberIndent(text, opener) { + let at = lineAt(text, opener.start).next; + while (at < text.length) { + const line = lineAt(text, at); + if (line.text.trim()) return indentOf(line.text); + at = line.next; + } + return null; +} + +function isMemberLine(line, indent, kind) { + if (!indent || indentOf(line) !== indent) return false; + const t = line.slice(indent.length); + if (!t || '}])'.includes(t[0]) || isCommentLine(t)) return false; + if (kind !== 'fields') return true; + const name = identifierAt(t, 0); + const after = t[skipSpaces(t, name.length)]; + return pythonNameIsPublic(name) && isLetter(name[0]) && (after === ':' || (after === '=' && t[skipSpaces(t, name.length) + 1] !== '=')); +} + +function containerTouchesSurface(pairs, fileText, language) { + if (typeof fileText !== 'string' || fileText.length > MAX_FILE_CHARS) return false; + const budget = { left: MAX_OPENER_SCAN }; + for (const [oldString, newString] of pairs) { + const at = oldString ? fileText.indexOf(oldString) : -1; + if (at === -1) continue; + const opener = enclosingOpener(fileText, at, budget); + if (budget.left < 0) return true; + const kind = opener ? containerKind(opener.line, language) : null; + if (!kind) continue; + if (kind === 'all') return true; + const indent = memberIndent(fileText, opener); + const start = lineStartOf(fileText, at); + const end = lineEndAt(fileText, at + oldString.length); + const before = fileText.slice(start, end); + const after = `${fileText.slice(start, at)}${newString}${fileText.slice(at + oldString.length, end)}`; + if (`${before}\n${after}`.split(/\r\n|\r|\n/).some(line => isMemberLine(line, indent, kind))) return true; + } + return false; +} + +// --- Data handling --- + +const DATA_WORDS = new Set([ + 'json', 'jsonl', 'ndjson', 'yaml', 'yml', 'csv', 'tsv', 'xml', 'toml', 'ini', 'parquet', 'avro', 'arrow', 'pickle', 'protobuf', + 'serde', 'serialize', 'serializer', 'serialise', 'deserialize', 'deserializer', 'deserialise', 'marshal', 'unmarshal', + 'schema', 'schemas', 'sql', 'sqlite', 'sqlite3', 'postgres', 'mysql', 'mongo', 'mongodb', 'database', 'db', 'cursor', + 'migration', 'migrations', 'orm', 'dataframe', 'pandas', + 'date', 'dates', 'datetime', 'timestamp', 'timestamps', 'strftime', 'strptime', 'isoformat', 'iso', 'iso8601', 'timezone', + 'tz', 'utc', 'epoch', 'dayjs', 'moment', 'luxon', 'instant', + 'fs', 'fopen', 'fread', 'fwrite', 'fgets', 'ioutil', 'bufio', 'pathlib', 'shutil', 'readfile', 'writefile', 'fstream', + 'ifstream', 'ofstream', + 'chrono', 'zoneinfo', 'pytz', 'tzinfo', 'temporal', 'base64', 'encoding', 'charset', 'codec', 'msgpack', 'bson', 'cbor', + 'xlsx', 'storage', 'cookie', 'cookies', 'redis', 'prisma', 'knex', 'sequelize', 'mongoose', 'typeorm', 'drizzle', + 'sqlalchemy', 'jdbc', 'firestore', 'dynamodb' +]); +const DATA_PAIRS = [ + ['read', 'file'], ['write', 'file'], ['append', 'file'], ['open', 'file'], ['read', 'text'], ['write', 'text'], + ['read', 'bytes'], ['write', 'bytes'], ['read', 'to'], ['read', 'lines'], ['write', 'lines'], ['read', 'all'], + ['file', 'reader'], ['file', 'writer'], ['file', 'stream'], ['read', 'csv'], ['to', 'csv'], + ['time', 'now'], ['time', 'parse'], ['time', 'unix'], ['time', 'since'], ['time', 'format'], ['time', 'time'], + ['time', 'duration'], ['time', 'zone'], ['time', 'stamp'], ['system', 'time'] +]; +const SQL_PAIRS = [['select', 'from'], ['insert', 'into'], ['delete', 'from'], ['create', 'table'], ['alter', 'table'], ['drop', 'table'], ['update', 'set']]; + +function* words(text) { + let word = ''; + let prev = ''; + for (let i = 0; i <= text.length; i++) { + const ch = text[i]; + const alnum = ch !== undefined && ((ch >= 'a' && ch <= 'z') || (ch >= 'A' && ch <= 'Z') || (ch >= '0' && ch <= '9')); + const boundary = alnum && word && ( + (isUpper(ch) && !isUpper(prev) && !(prev >= '0' && prev <= '9')) || + (isUpper(ch) && isUpper(prev) && text[i + 1] >= 'a' && text[i + 1] <= 'z') + ); + if (!alnum || boundary) { + if (word) yield { word: word.toLowerCase(), call: !alnum && text[skipSpaces(text, i)] === '(' }; + word = alnum ? ch : ''; + } else { + word += ch; + } + prev = ch; + } +} + +const SHELL_LANGUAGES = new Set(['shell', 'powershell', 'batch']); +const SHELL_DATA_WORDS = new Set(['curl', 'wget', 'jq', 'yq', 'xmllint', 'psql', 'tee', 'iwr', 'irm', 'clixml']); +const SHELL_DATA_PAIRS = [['out', 'file'], ['get', 'content'], ['set', 'content'], ['add', 'content'], ['web', 'request'], ['rest', 'method']]; +const NULL_TARGETS = ['/dev/null', '/dev/stdout', '/dev/stderr', '$null', 'nul']; + +function redirectTargetIsFile(text, at) { + const start = skipSpaces(text, at); + const ch = text[start]; + if (ch === undefined || ch === '\n' || ch === '\r' || ch === '&') return false; + const rest = text.slice(start, start + 12).toLowerCase(); + return !NULL_TARGETS.some(target => rest.startsWith(target) && !isIdentChar(rest[target.length]) && rest[target.length] !== '.'); +} + +function shellRedirectsFile(text) { + for (let i = 0; i < text.length; i++) { + const ch = text[i]; + if (ch === '>') { + const prev = text[i - 1]; + if (prev === '-' || prev === '=' || prev === '>') continue; + let at = i + 1; + if (text[at] === '>' || text[at] === '|') at++; + if (text[at] === '=' || text[at] === '&') continue; + if (redirectTargetIsFile(text, at)) return true; + } else if (ch === '<') { + const next = text[i + 1]; + if (text[i - 1] === '<' || next === '<' || next === '(' || next === '&' || next === '#' || next === '=') continue; + if (redirectTargetIsFile(text, i + 1)) return true; + } + } + return false; +} + +function textTouchesData(text, language) { + const shell = SHELL_LANGUAGES.has(language); + if (shell && shellRedirectsFile(text)) return true; + const seen = new Set(); + let previous = ''; + for (const { word, call } of words(text)) { + if (DATA_WORDS.has(word)) return true; + if (shell && (SHELL_DATA_WORDS.has(word) || SHELL_DATA_PAIRS.some(([a, b]) => previous === a && word === b))) return true; + if (word === 'open' && call) return true; + if (DATA_PAIRS.some(([a, b]) => previous === a && word === b)) return true; + seen.add(word); + previous = word; + } + return SQL_PAIRS.some(([a, b]) => seen.has(a) && seen.has(b)); +} + +// --- Profile --- + +/** Language of a target by extension, or null when unsupported. */ +function languageFor(filePath) { + if (typeof filePath !== 'string' || !filePath) return null; + const base = baseName(filePath); + const dot = base.lastIndexOf('.'); + if (dot <= 0) return null; + return LANGUAGE_BY_EXT.get(base.slice(dot)) || null; +} + +function changeTexts(input) { + if (input.tool === 'Write') { + return typeof input.content === 'string' ? { sides: [input.content], pairs: [] } : null; + } + if (input.tool !== 'Edit' || !Array.isArray(input.edits) || input.edits.length === 0 || input.edits.length > MAX_EDITS) return null; + const pairs = []; + for (const entry of input.edits) { + if (!entry || typeof entry !== 'object') return null; + const oldString = entry.old_string; + const newString = entry.new_string; + if (typeof oldString !== 'string' || typeof newString !== 'string') return null; + pairs.push([oldString, newString, entry.replace_all === true]); + } + return { sides: pairs.flatMap(([oldString, newString]) => [oldString, newString]), pairs }; +} + +function withinBounds(sides) { + let total = 0; + for (const side of sides) { + if (side.length > MAX_SIDE_BYTES) return false; + const bytes = Buffer.byteLength(side, 'utf8'); + if (bytes > MAX_SIDE_BYTES) return false; + total += bytes; + if (total > MAX_TOTAL_BYTES) return false; + } + return true; +} + +/** Profile of one target's change from Edit/MultiEdit entries or Write content, judged against `fileText` when given; unknown on any doubt. */ +function profileChange(input) { + try { + if (!input || typeof input !== 'object') return UNKNOWN_PROFILE; + const language = languageFor(input.filePath); + if (!language) return UNKNOWN_PROFILE; + const texts = changeTexts(input); + if (!texts || !withinBounds(texts.sides)) return UNKNOWN_PROFILE; + const lineIsSurface = SURFACE_BY_LANGUAGE[language]; + const touchesPublicSurface = input.tool === 'Write' || !lineIsSurface || wholeFileIsSurface(input.filePath, language) || + texts.sides.some(side => textTouchesSurface(side, lineIsSurface)) || containerTouchesSurface(texts.pairs, input.fileText, language); + const touchesData = texts.sides.some(side => textTouchesData(side, language)); + const trivial = input.tool === 'Edit' && trivialInFile(texts.pairs, input.fileText, lexSpecFor(input.filePath, language)); + return Object.freeze({ known: true, language, touchesPublicSurface, touchesData, trivial }); + } catch (_) { + return UNKNOWN_PROFILE; + } +} + +module.exports = { MAX_SIDE_BYTES, MAX_TOTAL_BYTES, MAX_EDITS, UNKNOWN_PROFILE, languageFor, profileChange }; diff --git a/scripts/lib/gateguard-code-lexer.js b/scripts/lib/gateguard-code-lexer.js new file mode 100644 index 000000000..29676d51f --- /dev/null +++ b/scripts/lib/gateguard-code-lexer.js @@ -0,0 +1,560 @@ +'use strict'; + +// --- Code lines --- +// see docs/gateguard/change-profile.md#trivial-edits + +function isSpace(ch) { + return ch === ' ' || ch === '\t' || ch === '\f' || ch === '\v'; +} + +function isIdentChar(ch) { + return ch !== undefined && ((ch >= 'a' && ch <= 'z') || (ch >= 'A' && ch <= 'Z') || (ch >= '0' && ch <= '9') || ch === '_' || ch === '$'); +} + +function isLetter(ch) { + return ch !== undefined && ((ch >= 'a' && ch <= 'z') || (ch >= 'A' && ch <= 'Z')); +} + +function rustCharEnd(text, i) { + if (text[i + 1] === '\\') { + if (text[i + 2] === "'") return text[i + 3] === "'" ? i + 3 : -1; + const end = text.indexOf("'", i + 3); + return end !== -1 && end <= i + 12 ? end : -1; + } + const cp = text.codePointAt(i + 1); + if (cp === undefined || text[i + 1] === '\n' || text[i + 1] === '\r' || text[i + 1] === "'") return -1; + const end = i + (cp > 0xffff ? 3 : 2); + return text[end] === "'" ? end : -1; +} + +function stringPrefixIsUnsafe(code, spec) { + const last = code[code.length - 1]; + if (spec.rawPrefixes && last !== undefined && spec.rawPrefixes.includes(last)) return true; + if (!spec.fStrings || !isLetter(last)) return false; + const prefix = isLetter(code[code.length - 2]) ? code.slice(-2) : last; + return prefix.toLowerCase().includes('f'); +} + +// --- Directive comments --- +// see docs/gateguard/change-profile.md#directive-comments + +const DIRECTIVE_LEADS = ['@', '#', '<', '+', '!', 'type:', 'global ', 'globals ', 'exported ', 'requires']; +const DIRECTIVE_RAW_LEADS = ['!', '/', 'go:', 'export ', 'extern ', 'line ']; +const DIRECTIVE_WORDS = [ + 'lint', 'jshint', 'ts-', 'noqa', 'nosec', 'semgrep', 'sonar', 'noinspection', 'pragma', 'coding:', 'coding=', 'fmt:', 'isort:', + 'mypy:', 'pyright:', 'pyre-', 'yapf:', 'ruff:', 'flake8', 'bandit', 'no cover', 'nocover', 'istanbul', 'c8 ', 'v8 ', + 'prettier-', 'biome-', 'deno-', 'dprint-', 'rome-', 'webpack', 'vite-', '__pure__', '__no_side_effects__', + '__inline__', '@preserve', '@license', 'sourcemappingurl', 'sourceurl', '+build', 'cgo', 'clang-', 'cppcheck', + 'coverity', 'iwyu', 'fallthrough', 'fall through', 'fall-through', 'fallthru', 'fall thru', 'shellcheck', + 'psscriptanalyzer', 'suppress', 'jscs', 'checkstyle', 'spotbugs', 'findbugs', 'nopmd', 'codeql', 'lgtm', 'gitleaks', + 'trufflehog', 'detect-secrets', 'allowlist', 'vim:', 'vi:', ' ex:', '-*-', 'code generated', 'do not edit', + 'rubocop', 'swiftlint', 'resharper', '@ts-', '@type', '@typedef', '@template', '@satisfies', '@import', '@callback', + '@overload', '@enum', '@this', '@implements', '@extends', '@augments', '@jsx', '@flow', '@noflow', '@generated', + 'compdef', 'autoload', 'output:' +]; + +function isDirectiveComment(body, spec) { + if (spec.rustDocs && (body[0] === '/' || body[0] === '!' || body[0] === '*')) return true; + if (DIRECTIVE_RAW_LEADS.some(lead => body.startsWith(lead))) return true; + const lower = body.toLowerCase(); + let start = 0; + while (start < lower.length && (isSpace(lower[start]) || lower[start] === '*' || lower[start] === '/')) start++; + const lead = lower.slice(start); + if (DIRECTIVE_LEADS.some(word => lead.startsWith(word))) return true; + if (DIRECTIVE_WORDS.some(word => lower.includes(word))) return true; + return lower.includes('@') && lower.includes('{'); +} + +function directiveMarker(body, spec) { + return isDirectiveComment(body, spec) ? ` \u0001${Buffer.from(body, 'utf8').toString('hex')}\u0001 ` : ''; +} + +function endsWithContinuation(code) { + let end = code.length; + while (end > 0 && isSpace(code[end - 1])) end--; + return code[end - 1] === '\\'; +} + +const CODE_SPECIALS = new WeakMap(); + +function escapeClass(chars) { + return chars.replace(/[\\\]^-]/g, ch => `\\${ch}`); +} + +function codeSpecials(spec) { + let specials = CODE_SPECIALS.get(spec); + if (!specials) { + const chars = `\n\r/${spec.lineComment === '#' ? '#' : ''}${spec.quotes || ''}${spec.forbiddenCode || ''}${spec.jsx !== undefined ? '<-' : ''}${spec.rustChars ? "'" : ''}`; + specials = { pattern: new RegExp(`[${escapeClass(chars)}]`, 'g'), chars: new Set(chars) }; + CODE_SPECIALS.set(spec, specials); + } + return specials; +} + +function nextSpecial(pattern, text, i) { + pattern.lastIndex = i; + const match = pattern.exec(text); + return match ? match.index : text.length; +} + +const LINE_BREAK = /[\n\r]/g; +const BLOCK_SPECIALS = /[\n\r*/]/g; +const STRING_SPECIALS = new Map(); + +function stringSpecials(quote) { + let pattern = STRING_SPECIALS.get(quote); + if (!pattern) { + pattern = new RegExp(`[\\n\\r\\\\${escapeClass(quote)}]`, 'g'); + STRING_SPECIALS.set(quote, pattern); + } + return pattern; +} + +function lexCodeLines(text, spec) { + const specials = codeSpecials(spec); + const lines = []; + let code = ''; + let state = 'code'; + let quote = ''; + let stringBody = ''; + let commentStart = 0; + let tail = ''; + const append = piece => { + code += piece; + tail = (tail + piece).slice(-2); + }; + const endComment = end => { + const marker = directiveMarker(text.slice(commentStart, end), spec); + if (marker) append(marker); + }; + const endLine = () => { + if (endsWithContinuation(code)) return false; + lines.push(code); + code = ''; + tail = ''; + return true; + }; + if (spec.jsx !== undefined && text.startsWith('#!')) state = 'line'; + for (let i = 0; i < text.length; i++) { + const ch = text[i]; + if (ch === '\n' || ch === '\r') { + if (state === 'string') return null; + if (state === 'line') { + if (text.slice(commentStart, i).trimEnd().endsWith('\\')) return null; + endComment(i); + state = 'code'; + } + if (state === 'block') { + lines.push(code); + code = ''; + tail = ''; + } else if (!endLine()) { + return null; + } + if (ch === '\r' && text[i + 1] === '\n') i++; + continue; + } + if (state === 'line') { + i = nextSpecial(LINE_BREAK, text, i) - 1; + continue; + } + if (state === 'block') { + if (ch !== '*' && ch !== '/') { + i = nextSpecial(BLOCK_SPECIALS, text, i + 1) - 1; + continue; + } + if (ch === '*' && text[i + 1] === '/') { + endComment(i); + state = 'code'; + append(' '); + i++; + } else if (ch === '/' && text[i + 1] === '*') { + return null; + } + continue; + } + if (state === 'string') { + if (ch !== '\\' && ch !== quote) { + const end = nextSpecial(stringSpecials(quote), text, i + 1); + const run = text.slice(i, end); + append(run); + stringBody += run; + i = end - 1; + continue; + } + append(ch); + if (ch === '\\') { + const next = text[i + 1]; + if (next === undefined || next === '\n' || next === '\r') return null; + append(next); + i++; + } else if (ch === quote) { + if (spec.noDollarInStrings && stringBody.includes('$')) return null; + state = 'code'; + } else { + stringBody += ch; + } + continue; + } + if (!specials.chars.has(ch)) { + const end = nextSpecial(specials.pattern, text, i + 1); + append(text.slice(i, end)); + i = end - 1; + continue; + } + if (spec.lineComment === '//' && ch === '/' && text[i + 1] === '/') { + state = 'line'; + commentStart = i + 2; + i++; + continue; + } + if (spec.lineComment === '#' && ch === '#') { + state = 'line'; + commentStart = i + 1; + continue; + } + if (spec.blockComment && ch === '/' && text[i + 1] === '*') { + state = 'block'; + commentStart = i + 2; + i++; + continue; + } + if (spec.forbiddenCode && spec.forbiddenCode.includes(ch)) return null; + if (spec.jsx && ch === '<' && (isLetter(text[i + 1]) || text[i + 1] === '/' || text[i + 1] === '>' || text[i + 1] === '!')) return null; + if (spec.jsx !== undefined && ch === '-' && text[i + 1] === '-' && text[i + 2] === '>') return null; + if (ch === "'" && spec.rustChars) { + const end = rustCharEnd(text, i); + append(end === -1 ? ch : text.slice(i, end + 1)); + if (end !== -1) i = end; + continue; + } + if (spec.quotes.includes(ch)) { + if (spec.tripleQuotes && text[i + 1] === ch && text[i + 2] === ch) return null; + if (stringPrefixIsUnsafe(tail, spec)) return null; + state = 'string'; + quote = ch; + stringBody = ''; + append(ch); + continue; + } + append(ch); + } + if (state === 'string' || state === 'block') return null; + if (state === 'line') { + if (text.slice(commentStart).trimEnd().endsWith('\\')) return null; + endComment(text.length); + } + return endLine() ? lines : null; +} + +function normalizeCodeLine(line, spec) { + let out = ''; + let pendingSpace = false; + let quote = ''; + let indent = ''; + let leading = true; + for (let i = 0; i < line.length; i++) { + const ch = line[i]; + if (quote) { + out += ch; + if (ch === '\\' && i + 1 < line.length) out += line[++i]; + else if (ch === quote) quote = ''; + continue; + } + if (isSpace(ch)) { + if (leading) indent += ch; + else pendingSpace = true; + continue; + } + leading = false; + if (pendingSpace && out) out += ' '; + pendingSpace = false; + if (ch === "'" && spec.rustChars) { + const end = rustCharEnd(line, i); + out += end === -1 ? ch : line.slice(i, end + 1); + if (end !== -1) i = end; + continue; + } + if (spec.quotes.includes(ch)) quote = ch; + out += ch; + } + if (!out) return ''; + return spec.indentSensitive ? `${indent}\u0000${out}` : out; +} + +function hasSpaceAfterContinuation(line) { + let end = line.length; + while (end > 0 && isSpace(line[end - 1])) end--; + return end < line.length && line[end - 1] === '\\'; +} + +function codeSignature(text, spec) { + const lines = spec.lexer ? spec.lexer(text) : lexCodeLines(text, spec); + if (lines === null) return null; + const signature = []; + for (const line of lines) { + if (hasSpaceAfterContinuation(line)) return null; + const normalized = spec.normalize ? spec.normalize(line) : normalizeCodeLine(line, spec); + if (normalized === null) return null; + if (normalized) signature.push(normalized); + } + return signature; +} + +function isTrivialEdit(oldString, newString, spec) { + if (spec.lineComment === '//' && (oldString.includes('??/') || newString.includes('??/'))) return false; + const before = codeSignature(oldString, spec); + if (before === null) return false; + const after = codeSignature(newString, spec); + if (after === null || before.length !== after.length) return false; + return before.every((line, index) => line === after[index]); +} + + +// --- Shell lexing --- +// see docs/gateguard/change-profile.md#shell-scripts + +const SHELL_SPEC = Object.freeze({}); + +function isLineStart(text, i) { + return i === 0 || text[i - 1] === '\n' || text[i - 1] === '\r'; +} + +function hashStartsComment(text, i) { + if (isLineStart(text, i)) return true; + const prev = text[i - 1]; + if (prev === ' ' || prev === '\t' || prev === ';') return true; + return '()|&<>'.includes(prev) ? null : false; +} + +function shDoubleQuoteEnd(text, i) { + for (let j = i + 1; j < text.length; j++) { + const ch = text[j]; + if (ch === '"') return j; + if (ch === '\n' || ch === '\r' || ch === '`') return -1; + if (ch === '\\') { + if (j + 1 >= text.length || text[j + 1] === '\n' || text[j + 1] === '\r') return -1; + j++; + } else if (ch === '$' && text[j + 1] === '(') { + return -1; + } else if (ch === '$' && text[j + 1] === '{') { + const close = text.indexOf('}', j + 2); + if (close === -1) return -1; + for (let k = j + 2; k < close; k++) { + if ('"\'\n\r'.includes(text[k])) return -1; + } + j = close; + } + } + return -1; +} + +function hasLoneCarriageReturn(text) { + for (let at = text.indexOf('\r'); at !== -1; at = text.indexOf('\r', at + 1)) { + if (text[at + 1] !== '\n') return true; + } + return false; +} + +function isBlank(ch) { + return ch === ' ' || ch === '\t'; +} + +function shCodeLines(text) { + if (text.includes('<<') || text.includes('`') || hasLoneCarriageReturn(text)) return null; + const lines = []; + let code = ''; + for (let i = 0; i < text.length; i++) { + const ch = text[i]; + if (ch === '\n' || ch === '\r') { + lines.push(code); + code = ''; + if (ch === '\r' && text[i + 1] === '\n') i++; + continue; + } + if (ch === '\\') { + const next = text[i + 1]; + if (next === undefined || next === '\n' || next === '\r') return null; + code += ch + next; + i++; + continue; + } + if (ch === "'") { + if (text[i - 1] === '$') return null; + const end = text.indexOf("'", i + 1); + if (end === -1) return null; + const body = text.slice(i, end + 1); + if (body.includes('\n') || body.includes('\r')) return null; + code += body; + i = end; + continue; + } + if (ch === '"') { + const end = shDoubleQuoteEnd(text, i); + if (end === -1) return null; + code += text.slice(i, end + 1); + i = end; + continue; + } + if (ch === '#') { + const starts = hashStartsComment(text, i); + if (starts === null) return null; + if (starts) { + const start = i + 1; + while (i + 1 < text.length && text[i + 1] !== '\n' && text[i + 1] !== '\r') i++; + code += directiveMarker(text.slice(start, i + 1), SHELL_SPEC); + continue; + } + } + code += ch; + } + lines.push(code); + return lines; +} + +const PS_SMART_QUOTES = /[\u2018\u2019\u201a\u201b\u201c\u201d\u201e]/; + +function psHashStartsComment(text, i) { + if (isLineStart(text, i)) return true; + const prev = text[i - 1]; + return prev === ' ' || prev === '\t' || prev === ';'; +} + +function psQuoteEnd(text, i) { + const quote = text[i]; + for (let j = i + 1; j < text.length; j++) { + const ch = text[j]; + if (ch === '\n' || ch === '\r') return -1; + if (quote === '"' && ch === '`') { + if (j + 1 >= text.length || text[j + 1] === '\n' || text[j + 1] === '\r') return -1; + j++; + } else if (quote === '"' && ch === '$' && text[j + 1] === '(') { + return -1; + } else if (ch === quote) { + if (text[j + 1] !== quote) return j; + j++; + } + } + return -1; +} + +function psCodeLines(text) { + if (text.includes('@"') || text.includes("@'") || PS_SMART_QUOTES.test(text)) return null; + const lines = []; + let code = ''; + let block = false; + let blockStart = 0; + for (let i = 0; i < text.length; i++) { + const ch = text[i]; + if (ch === '\n' || ch === '\r') { + lines.push(code); + code = ''; + if (ch === '\r' && text[i + 1] === '\n') i++; + continue; + } + if (block) { + if (ch === '<' && text[i + 1] === '#') return null; + if (ch === '#' && text[i + 1] === '>') { + block = false; + code += directiveMarker(text.slice(blockStart, i), SHELL_SPEC) || ' '; + i++; + } + continue; + } + if (ch === '`') { + const next = text[i + 1]; + if (next === undefined || next === '\n' || next === '\r') return null; + code += ch + next; + i++; + continue; + } + if (ch === "'" || ch === '"') { + const end = psQuoteEnd(text, i); + if (end === -1) return null; + code += text.slice(i, end + 1); + i = end; + continue; + } + if (ch === '<' && text[i + 1] === '#') { + if (!psHashStartsComment(text, i)) return null; + block = true; + blockStart = i + 2; + i++; + continue; + } + if (ch === '#') { + if (!psHashStartsComment(text, i)) return null; + const start = i + 1; + while (i + 1 < text.length && text[i + 1] !== '\n' && text[i + 1] !== '\r') i++; + code += directiveMarker(text.slice(start, i + 1), SHELL_SPEC); + continue; + } + code += ch; + } + if (block) return null; + lines.push(code); + return lines; +} + +function batchCodeLines(text) { + const lines = []; + for (const line of text.split(/\r\n|\r|\n/)) { + if (line.endsWith('^')) return null; + let start = 0; + while (isBlank(line[start])) start++; + if (line[start] === '@') start++; + while (isBlank(line[start])) start++; + const word = line.slice(start, start + 3).toLowerCase(); + const after = line[start + 3]; + if (word === 'rem' && (after === undefined || isBlank(after))) { + if (/[%^&|<>()]/.test(line)) return null; + lines.push(directiveMarker(line.slice(start + 4), SHELL_SPEC)); + } else { + lines.push(line.trim() ? line : ''); + } + } + return lines; +} + +function normalizeShellLine(line, escape) { + let out = ''; + let pendingSpace = false; + for (let i = 0; i < line.length; i++) { + const ch = line[i]; + if (isBlank(ch)) { + pendingSpace = true; + continue; + } + if (pendingSpace && out) out += ' '; + pendingSpace = false; + if (ch === escape && i + 1 < line.length) { + out += ch + line[++i]; + } else if (ch === "'" || ch === '"') { + const end = escape === '`' ? psQuoteEnd(line, i) : ch === "'" ? line.indexOf("'", i + 1) : shDoubleQuoteEnd(line, i); + if (end === -1) return null; + out += line.slice(i, end + 1); + i = end; + } else { + out += ch; + } + } + return out; +} + + +module.exports = { + isSpace, + isIdentChar, + isLetter, + rustCharEnd, + isBlank, + hashStartsComment, + hasLoneCarriageReturn, + PS_SMART_QUOTES, + psHashStartsComment, + isTrivialEdit, + shCodeLines, + psCodeLines, + batchCodeLines, + normalizeShellLine +}; diff --git a/scripts/lib/gateguard-file-context.js b/scripts/lib/gateguard-file-context.js new file mode 100644 index 000000000..4c571c16d --- /dev/null +++ b/scripts/lib/gateguard-file-context.js @@ -0,0 +1,735 @@ +'use strict'; + +const { isSpace, isIdentChar, isLetter, rustCharEnd, isBlank, hashStartsComment, hasLoneCarriageReturn, PS_SMART_QUOTES, psHashStartsComment, isTrivialEdit } = require('./gateguard-code-lexer'); + +// --- File context --- +// see docs/gateguard/change-profile.md#file-context + +const REPLACEMENT_PATTERN = /\$[$&'`<0-9]/; +const MAX_FILE_CHARS = 1024 * 1024; +const MAX_WINDOW_CHARS = 128 * 1024; +const MAX_CONTEXT_WORK = 8 * 1024 * 1024; +const JS_REGEX_KEYWORDS = new Set(['return', 'typeof', 'instanceof', 'in', 'of', 'new', 'delete', 'void', 'throw', 'case', 'do', 'else', 'yield', 'await']); +const JS_CONTROL_HEADERS = new Set(['if', 'while', 'for', 'with']); +const CPP_RAW_PREFIXES = new Set(['R', 'u8R', 'uR', 'UR', 'LR']); + +function isNewline(ch) { + return ch === '\n' || ch === '\r'; +} + +function lineEndAt(text, i) { + let j = i; + while (j < text.length && !isNewline(text[j])) j++; + return j; +} + +function quotedEnd(text, i, quote, multiline) { + for (let j = i + 1; j < text.length; j++) { + const ch = text[j]; + if (ch === '\\') { + j++; + if (text[j] === '\r' && text[j + 1] === '\n') j++; + } else if (ch === quote) { + return j + 1; + } else if (isNewline(ch) && !multiline) { + return -1; + } + } + return -1; +} + +function regexEnd(text, i) { + let inClass = false; + for (let j = i + 1; j < text.length; j++) { + const ch = text[j]; + if (isNewline(ch)) return -1; + if (ch === '\\') { + if (j + 1 >= text.length || isNewline(text[j + 1])) return -1; + j++; + } else if (inClass) { + if (ch === ']') inClass = false; + } else if (ch === '[') { + inClass = true; + } else if (ch === '/') { + let k = j + 1; + while (isIdentChar(text[k])) k++; + return k; + } + } + return -1; +} + +function nestedBlockEnd(text, i) { + let depth = 0; + for (let j = i; j + 1 < text.length; j++) { + if (text[j] === '/' && text[j + 1] === '*') { + depth++; + j++; + } else if (text[j] === '*' && text[j + 1] === '/') { + depth--; + j++; + if (depth === 0) return j + 1; + } + } + return -1; +} + +function textBlockEnd(text, i, escapes) { + for (let j = i + 3; j < text.length; j++) { + if (escapes && text[j] === '\\') { + j++; + } else if (!escapes && text[j] === '$' && text[j + 1] === '{') { + return -1; + } else if (text.startsWith('"""', j)) { + let end = j + 3; + while (text[end] === '"') end++; + return end; + } + } + return -1; +} + +function kotlinStringEnd(text, i) { + for (let j = i + 1; j < text.length; j++) { + const ch = text[j]; + if (ch === '\\') j++; + else if (ch === '$' && text[j + 1] === '{') return -1; + else if (ch === '"') return j + 1; + else if (isNewline(ch)) return -1; + } + return -1; +} + +function csharpStringEnd(text, i) { + let j = i; + let verbatim = false; + let interpolated = false; + while (text[j] === '@' || text[j] === '$') { + if (text[j] === '@') verbatim = true; + else interpolated = true; + j++; + } + if (text.startsWith('"""', j)) return -1; + for (let k = j + 1; k < text.length; k++) { + const ch = text[k]; + if (ch === '"') { + if (verbatim && text[k + 1] === '"') { + k++; + continue; + } + return k + 1; + } + if (!verbatim && ch === '\\') { + k++; + } else if (!verbatim && isNewline(ch)) { + return -1; + } else if (interpolated && ch === '{') { + if (text[k + 1] === '{') { + k++; + continue; + } + let m = k + 1; + while (m < text.length && text[m] !== '}') { + if ('"\'{/\n\r'.includes(text[m])) return -1; + m++; + } + if (m >= text.length) return -1; + k = m; + } + } + return -1; +} + +function cppRawEnd(text, quote) { + const open = text.indexOf('(', quote + 1); + if (open === -1 || open - quote - 1 > 16) return -1; + const delimiter = text.slice(quote + 1, open); + for (const ch of delimiter) { + if (isSpace(ch) || isNewline(ch) || '()\\"'.includes(ch)) return -1; + } + const end = text.indexOf(`)${delimiter}"`, open + 1); + return end === -1 ? -1 : end + delimiter.length + 2; +} + +function rustRawEnd(text, i) { + let j = i + (text[i] === 'r' ? 1 : 2); + let hashes = ''; + while (text[j] === '#') { + hashes += '#'; + j++; + } + if (text[j] !== '"') return null; + const end = text.indexOf(`"${hashes}`, j + 1); + return end === -1 ? -1 : end + 1 + hashes.length; +} + +function wordStart(text, i) { + let start = i; + while (start > 0 && isIdentChar(text[start - 1])) start--; + return start; +} + +function cFamilyStringEnd(text, i, spec) { + const ch = text[i]; + const flavor = spec.flavor; + if (ch === '`') { + if (flavor === 'kotlin') return quotedEnd(text, i, '`', false); + if (flavor !== 'go') return -1; + const end = text.indexOf('`', i + 1); + return end === -1 ? -1 : end + 1; + } + if (ch === "'") { + if (flavor === 'rust') { + const end = rustCharEnd(text, i); + return end === -1 ? null : end + 1; + } + if (flavor === 'c' && isIdentChar(text[i - 1]) && isIdentChar(text[i + 1])) { + const first = text[wordStart(text, i)]; + if (first >= '0' && first <= '9') return null; + } + return quotedEnd(text, i, "'", false); + } + if (flavor === 'c') { + const start = wordStart(text, i); + const prefix = text.slice(start, i); + if (CPP_RAW_PREFIXES.has(prefix)) return cppRawEnd(text, i); + if (prefix.endsWith('R')) return -1; + } + if (flavor === 'java' && text.startsWith('"""', i)) return textBlockEnd(text, i, true); + if (flavor === 'kotlin') return text.startsWith('"""', i) ? textBlockEnd(text, i, false) : kotlinStringEnd(text, i); + if (flavor === 'csharp' && text.startsWith('"""', i)) return -1; + return quotedEnd(text, i, '"', flavor === 'rust'); +} + +function cFamilyStartsFresh(text, spec) { + const js = spec.flavor === 'js'; + const frames = [{ template: false, depth: 0 }]; + const parens = []; + let lastWord = ''; + let lastSig = 'op'; + let lineEnd = ''; + let continued = false; + let i = js && text.startsWith('#!') ? lineEndAt(text, 0) : 0; + while (i < text.length) { + const frame = frames[frames.length - 1]; + const ch = text[i]; + const next = text[i + 1]; + if (frame.template) { + if (ch === '\\') { + i += 2; + } else if (ch === '`') { + frames.pop(); + lastSig = 'value'; + i++; + } else if (ch === '$' && next === '{') { + frames.push({ template: false, depth: 0 }); + lastSig = 'op'; + i += 2; + } else { + i++; + } + continue; + } + if (isNewline(ch)) { + continued = lineEnd === '\\'; + lineEnd = ''; + i++; + continue; + } + if (isSpace(ch)) { + i++; + continue; + } + if (ch === '/' && next === '/') { + const end = lineEndAt(text, i); + if (text.slice(i, end).trimEnd().endsWith('\\')) return false; + i = end; + continue; + } + if (ch === '/' && next === '*') { + const end = spec.rustDocs ? nestedBlockEnd(text, i) : text.indexOf('*/', i + 2); + if (end === -1) return false; + i = spec.rustDocs ? end : end + 2; + lineEnd = ''; + continue; + } + lineEnd = ch; + if (js) { + if (ch === '`') { + frames.push({ template: true, depth: 0 }); + i++; + continue; + } + if (ch === '/') { + if (lastSig === 'op') { + const end = regexEnd(text, i); + if (end === -1) return false; + lineEnd = text[end - 1]; + lastSig = 'value'; + i = end; + continue; + } + if (lastSig === 'close' && /[/'"`]/.test(text.slice(i + 1, lineEndAt(text, i)))) return false; + lastSig = 'op'; + i++; + continue; + } + if (ch === '<' && (next === '!' || (spec.jsx && (isLetter(next) || next === '/' || next === '>')))) return false; + if (ch === '-' && next === '-' && text[i + 2] === '>') return false; + if (frames.length > 1 && ch === '{') frame.depth++; + if (frames.length > 1 && ch === '}') { + if (frame.depth === 0) { + frames.pop(); + i++; + continue; + } + frame.depth--; + } + } + if (spec.flavor === 'csharp' && (ch === '@' || ch === '$') && /^[@$]{1,3}"/.test(text.slice(i, i + 4))) { + const end = csharpStringEnd(text, i); + if (end === -1) return false; + lineEnd = '"'; + i = end; + continue; + } + if (spec.flavor === 'rust' && (ch === 'r' || ((ch === 'b' || ch === 'c') && next === 'r')) && !isIdentChar(text[i - 1])) { + const end = rustRawEnd(text, i); + if (end === -1) return false; + if (end !== null) { + lineEnd = text[end - 1]; + lastSig = 'value'; + i = end; + continue; + } + } + if (ch === '"' || ch === "'" || ch === '`') { + const end = cFamilyStringEnd(text, i, spec); + if (end === -1) return false; + if (end !== null) { + lineEnd = text[end - 1]; + lastSig = 'value'; + i = end; + continue; + } + i++; + continue; + } + if (isIdentChar(ch)) { + let end = i; + while (end < text.length && isIdentChar(text[end])) end++; + lastWord = text.slice(i, end); + lastSig = js && JS_REGEX_KEYWORDS.has(lastWord) ? 'op' : 'value'; + lineEnd = text[end - 1]; + i = end; + continue; + } + if (ch === '(') { + parens.push(lastSig === 'value' && JS_CONTROL_HEADERS.has(lastWord)); + lastSig = 'op'; + } else if (ch === ')') { + lastSig = parens.pop() ? 'op' : 'value'; + } else { + lastSig = ch === '}' ? 'close' : ch === ']' ? 'value' : 'op'; + } + lastWord = ''; + i++; + } + return frames.length === 1 && !continued; +} + +function pythonStringEnd(text, i, prefix) { + const quote = text[i]; + const triple = text[i + 1] === quote && text[i + 2] === quote; + const fString = prefix.toLowerCase().includes('f'); + let depth = 0; + for (let j = i + (triple ? 3 : 1); j < text.length; j++) { + const ch = text[j]; + if (fString && depth > 0) { + if (ch === '{') { + depth++; + } else if (ch === '}') { + depth--; + } else if ((ch === '"' || ch === "'") && ch !== quote) { + let k = j + 1; + while (k < text.length && text[k] !== ch) { + if (text[k] === quote || text[k] === '\\' || isNewline(text[k])) return -1; + k++; + } + if (k >= text.length) return -1; + j = k; + } else if (ch === quote || ch === '#' || ch === '\\' || isNewline(ch)) { + return -1; + } + continue; + } + if (ch === '\\') { + j++; + if (text[j] === '\r' && text[j + 1] === '\n') j++; + } else if (fString && ch === '{') { + if (text[j + 1] === '{') j++; + else depth = 1; + } else if (isNewline(ch) && !triple) { + return -1; + } else if (ch === quote && (!triple || (text[j + 1] === quote && text[j + 2] === quote))) { + return j + (triple ? 3 : 1); + } + } + return -1; +} + +function pythonStartsFresh(text) { + let lineEnd = ''; + let continued = false; + let i = 0; + while (i < text.length) { + const ch = text[i]; + if (isNewline(ch)) { + continued = lineEnd === '\\'; + lineEnd = ''; + i++; + } else if (isSpace(ch)) { + i++; + } else if (ch === '#') { + i = lineEndAt(text, i); + } else if (ch === '"' || ch === "'") { + const start = wordStart(text, i); + const prefix = text.slice(start, i); + if (prefix.length > 2 || /[^rbuf]/i.test(prefix)) return false; + const end = pythonStringEnd(text, i, prefix); + if (end === -1) return false; + lineEnd = ch; + i = end; + } else { + lineEnd = ch; + i++; + } + } + return !continued; +} + +function heredocDelimiter(text, i) { + let j = i + 2; + const stripTabs = text[j] === '-'; + if (stripTabs) j++; + while (isBlank(text[j])) j++; + let word = ''; + while (j < text.length && !isBlank(text[j]) && !isNewline(text[j]) && !';|&<>()'.includes(text[j])) { + if (!'\'"\\'.includes(text[j])) word += text[j]; + j++; + } + return word ? { word, stripTabs, end: j } : null; +} + +function skipHeredocs(text, i, pending) { + let at = i; + for (const doc of pending) { + for (;;) { + if (at >= text.length) return -1; + const end = lineEndAt(text, at); + let line = text.slice(at, end); + if (doc.stripTabs) line = line.replace(/^\t+/, ''); + at = end + (text[end] === '\r' && text[end + 1] === '\n' ? 2 : 1); + if (line === doc.word) break; + } + } + return at; +} + +const MAX_NESTING = 8; + +function shSingleQuoteEnd(text, i) { + if (text[i - 1] === '$') return quotedEnd(text, i, "'", true); + return text.indexOf("'", i + 1) + 1 || -1; +} + +function shSubstitutionEnd(text, i, level) { + if (level > MAX_NESTING) return -1; + let depth = 0; + for (let j = i + 1; j < text.length; j++) { + const ch = text[j]; + if (ch === '\\') { + j++; + } else if (ch === '(') { + depth++; + } else if (ch === ')') { + depth--; + if (depth === 0) return j + 1; + } else if (ch === "'" || ch === '"') { + const end = ch === "'" ? shSingleQuoteEnd(text, j) : shDoubleQuoteScan(text, j, level + 1); + if (end === -1) return -1; + j = end - 1; + } else if (ch === '`' || (ch === '<' && text[j + 1] === '<') || (ch === '#' && hashStartsComment(text, j) !== false)) { + return -1; + } else if (ch === 'c' && text.startsWith('case', j) && !isIdentChar(text[j - 1]) && !isIdentChar(text[j + 4])) { + return -1; + } + } + return -1; +} + +function shDoubleQuoteScan(text, i, level) { + for (let j = i + 1; j < text.length; j++) { + const ch = text[j]; + if (ch === '"') return j + 1; + if (ch === '\\') { + j++; + } else if (ch === '`') { + return -1; + } else if (ch === '$' && text[j + 1] === '(') { + const end = shSubstitutionEnd(text, j, level); + if (end === -1) return -1; + j = end - 1; + } else if (ch === '$' && text[j + 1] === '{') { + const close = text.indexOf('}', j + 2); + if (close === -1 || /["'\n\r]/.test(text.slice(j + 2, close))) return -1; + j = close; + } + } + return -1; +} + +function shStartsFresh(text) { + if (hasLoneCarriageReturn(text)) return false; + const pending = []; + let lineEnd = ''; + let continued = false; + let i = 0; + while (i < text.length) { + const ch = text[i]; + if (isNewline(ch)) { + continued = lineEnd === '\\'; + lineEnd = ''; + i += ch === '\r' && text[i + 1] === '\n' ? 2 : 1; + if (pending.length > 0) { + i = skipHeredocs(text, i, pending); + if (i === -1) return false; + pending.length = 0; + } + continue; + } + if (isBlank(ch)) { + i++; + continue; + } + lineEnd = ch; + if (ch === '\\') { + i += isNewline(text[i + 1]) ? 1 : 2; + } else if (ch === "'") { + const end = shSingleQuoteEnd(text, i); + if (end === -1) return false; + i = end; + } else if (ch === '"') { + const end = shDoubleQuoteScan(text, i, 0); + if (end === -1) return false; + i = end; + } else if (ch === '`') { + const end = text.indexOf('`', i + 1); + if (end === -1 || /['"\\#\n\r]/.test(text.slice(i + 1, end))) return false; + i = end + 1; + } else if (ch === '#') { + const starts = hashStartsComment(text, i); + if (starts === null) return false; + i = starts ? lineEndAt(text, i) : i + 1; + if (starts) lineEnd = ''; + } else if (ch === '<' && text[i + 1] === '<') { + if (text[i + 2] === '<') { + i += 3; + continue; + } + const doc = heredocDelimiter(text, i); + if (!doc) return false; + pending.push(doc); + i = doc.end; + } else { + i++; + } + } + return !continued && pending.length === 0; +} + +function psSingleQuoteEnd(text, i) { + for (let j = i + 1; j < text.length; j++) { + if (text[j] !== "'") continue; + if (text[j + 1] !== "'") return j + 1; + j++; + } + return -1; +} + +function psSubexpressionEnd(text, i, level) { + if (level > MAX_NESTING) return -1; + let depth = 0; + for (let j = i + 1; j < text.length; j++) { + const ch = text[j]; + if (ch === '`') { + j++; + } else if (ch === '(') { + depth++; + } else if (ch === ')') { + depth--; + if (depth === 0) return j + 1; + } else if (ch === "'" || ch === '"') { + const end = ch === "'" ? psSingleQuoteEnd(text, j) : psDoubleQuoteScan(text, j, level + 1); + if (end === -1) return -1; + j = end - 1; + } else if (ch === '#' || (ch === '@' && (text[j + 1] === '"' || text[j + 1] === "'"))) { + return -1; + } + } + return -1; +} + +function psDoubleQuoteScan(text, i, level) { + for (let j = i + 1; j < text.length; j++) { + const ch = text[j]; + if (ch === '`') { + j++; + } else if (ch === '$' && text[j + 1] === '(') { + const end = psSubexpressionEnd(text, j, level); + if (end === -1) return -1; + j = end - 1; + } else if (ch === '"') { + if (text[j + 1] !== '"') return j + 1; + j++; + } + } + return -1; +} + +function psStartsFresh(text) { + if (PS_SMART_QUOTES.test(text)) return false; + let lineEnd = ''; + let continued = false; + let i = 0; + while (i < text.length) { + const ch = text[i]; + const next = text[i + 1]; + if (isNewline(ch)) { + continued = lineEnd === '`'; + lineEnd = ''; + i++; + continue; + } + if (isBlank(ch)) { + i++; + continue; + } + lineEnd = ch; + if (ch === '`') { + i += isNewline(next) ? 1 : 2; + } else if (ch === '@' && (next === '"' || next === "'")) { + const open = lineEndAt(text, i); + if (text.slice(i + 2, open).trim() !== '') return false; + let at = open; + let found = -1; + while (at < text.length) { + at += text[at] === '\r' && text[at + 1] === '\n' ? 2 : 1; + if (text.startsWith(`${next}@`, at)) { + found = at + 2; + break; + } + at = lineEndAt(text, at); + } + if (found === -1) return false; + i = found; + } else if (ch === "'" || ch === '"') { + const end = ch === "'" ? psSingleQuoteEnd(text, i) : psDoubleQuoteScan(text, i, 0); + if (end === -1) return false; + i = end; + } else if (ch === '<' && next === '#') { + if (!psHashStartsComment(text, i)) return false; + const end = text.indexOf('#>', i + 2); + if (end === -1) return false; + i = end + 2; + lineEnd = ''; + } else if (ch === '#') { + if (psHashStartsComment(text, i)) { + i = lineEndAt(text, i); + lineEnd = ''; + } else if (isIdentChar(text[i - 1]) || '-.:'.includes(text[i - 1])) { + i++; + } else { + return false; + } + } else { + i++; + } + } + return !continued; +} + +function batchStartsFresh(text) { + const lines = text.split(/\r\n|\r|\n/); + const last = lines.length > 1 ? lines[lines.length - 2] : ''; + return !last.endsWith('^'); +} + +function lineStartOf(text, i) { + let start = i; + while (start > 0 && !isNewline(text[start - 1])) start--; + return start; +} + +function windowStart(text, at) { + const start = lineStartOf(text, at); + if (start === 0) return 0; + let end = start - 1; + if (text[end] === '\n' && text[end - 1] === '\r') end--; + return lineStartOf(text, end); +} + +function windowEnd(text, at) { + const end = lineEndAt(text, at); + if (end >= text.length) return text.length; + return end + (text[end] === '\r' && text[end + 1] === '\n' ? 2 : 1); +} + +function occurrences(text, needle, replaceAll) { + const first = text.indexOf(needle); + if (first === -1) return null; + let last = first; + for (let at = text.indexOf(needle, first + needle.length); at !== -1; at = text.indexOf(needle, at + needle.length)) { + if (!replaceAll) return null; + last = at; + } + return { first, last }; +} + +function trivialInFile(pairs, fileText, spec) { + if (typeof fileText !== 'string' || fileText.length > MAX_FILE_CHARS || !spec.startsFresh) return false; + if (spec.flavor === 'go' && fileText.includes('"C"')) return false; + let text = fileText; + let work = 0; + for (const [oldString, newString, replaceAll] of pairs) { + if (!oldString || REPLACEMENT_PATTERN.test(newString)) return false; + const found = occurrences(text, oldString, replaceAll); + if (!found) return false; + const start = windowStart(text, found.first); + const end = windowEnd(text, found.last + oldString.length); + work += start + 2 * (end - start); + if (end - start > MAX_WINDOW_CHARS || work > MAX_CONTEXT_WORK) return false; + const before = text.slice(start, end); + const after = replaceAll + ? before.split(oldString).join(newString) + : `${text.slice(start, found.first)}${newString}${text.slice(found.first + oldString.length, end)}`; + // see docs/gateguard/change-profile.md#file-context + if (!isTrivialEdit(before, after, spec) || !spec.startsFresh(text.slice(0, start), spec)) return false; + text = `${text.slice(0, start)}${after}${text.slice(end)}`; + if (text.length > MAX_FILE_CHARS) return false; + } + return true; +} + + +module.exports = { + MAX_FILE_CHARS, + lineEndAt, + lineStartOf, + cFamilyStartsFresh, + pythonStartsFresh, + shStartsFresh, + psStartsFresh, + batchStartsFresh, + trivialInFile +}; diff --git a/scripts/lib/gateguard-metrics.js b/scripts/lib/gateguard-metrics.js new file mode 100644 index 000000000..4f545945d --- /dev/null +++ b/scripts/lib/gateguard-metrics.js @@ -0,0 +1,133 @@ +'use strict'; + +const crypto = require('crypto'); +const fs = require('fs'); +const path = require('path'); + +const METRICS_SCHEMA_VERSION = 1; +const METRICS_FILE_NAME = 'metrics.jsonl'; +const METRICS_MAX_BYTES = 1024 * 1024; +const SESSION_DIGEST_LENGTH = 12; +const CODE_MAX_LENGTH = 48; +const CODE_PATTERN = /^[a-z][a-z0-9-]*(?::[a-z][a-z0-9-]*)?$/; +const SESSION_PATTERN = /^[0-9a-f]{12}$/; +const APPEND_FLAGS = fs.constants.O_WRONLY | fs.constants.O_APPEND | fs.constants.O_CREAT | (fs.constants.O_NOFOLLOW || 0); + +const DECISIONS = Object.freeze([ + 'deny', + 'credit', + 'sibling', + 'cap', + 'trivial', + 'pass-checked', + 'pass-exempt', + 'pass-subagent', + 'routine-deny', + 'routine-readonly', + 'destructive-deny', + 'pass' +]); +const DECISION_SET = new Set(DECISIONS); +const TOOLS = new Set(['Edit', 'Write', 'MultiEdit', 'NotebookEdit', 'Bash', 'PowerShell']); + +function isCode(value) { + return typeof value === 'string' && value.length <= CODE_MAX_LENGTH && CODE_PATTERN.test(value); +} + +function codeOrNull(value) { + return isCode(value) ? value : null; +} + +function sessionDigest(sessionKey) { + return crypto.createHash('sha256').update(String(sessionKey || '')).digest('hex').slice(0, SESSION_DIGEST_LENGTH); +} + +function profileFields(profile) { + if (!profile || typeof profile !== 'object') return null; + return { + known: profile.known === true, + language: codeOrNull(profile.language), + touchesPublicSurface: profile.touchesPublicSurface === true, + touchesData: profile.touchesData === true, + trivial: profile.trivial === true + }; +} + +/** One metrics line for a gate decision, or null when the decision or tool is not recorded. */ +function metricsEvent({ sessionKey, tool, cls, decision, reason, questions, sensitive, profile, now = Date.now() }) { + if (!DECISION_SET.has(decision) || !TOOLS.has(tool)) return null; + return { + v: METRICS_SCHEMA_VERSION, + ts: new Date(now).toISOString(), + session: sessionDigest(sessionKey), + tool, + class: codeOrNull(cls), + decision, + reason: codeOrNull(reason), + questions: Array.isArray(questions) ? questions.filter(isCode) : null, + sensitive: sensitive === true, + profile: profileFields(profile) + }; +} + +/** Whether a parsed line has the shape `metricsEvent` writes. */ +function isMetricsEvent(value) { + if (!value || typeof value !== 'object' || Array.isArray(value)) return false; + if (value.v !== METRICS_SCHEMA_VERSION || typeof value.ts !== 'string') return false; + if (typeof value.session !== 'string' || !SESSION_PATTERN.test(value.session)) return false; + if (!TOOLS.has(value.tool) || !DECISION_SET.has(value.decision)) return false; + if (value.class !== null && !isCode(value.class)) return false; + if (value.reason !== null && !isCode(value.reason)) return false; + if (value.questions !== null && !(Array.isArray(value.questions) && value.questions.every(isCode))) return false; + return typeof value.sensitive === 'boolean'; +} + +/** Append events to `/metrics.jsonl` in one write, rotating to `.1` past the size cap; never throws. */ +function appendMetrics(dir, events) { + let fd = null; + try { + const lines = events + .filter(Boolean) + .map(event => `${JSON.stringify(event)}\n`) + .join(''); + if (!lines) return false; + const file = path.join(dir, METRICS_FILE_NAME); + fs.mkdirSync(dir, { recursive: true }); + let size = 0; + try { + const stat = fs.lstatSync(file); + if (!stat.isFile()) return false; + size = stat.size; + } catch (_) { + size = 0; + } + if (size > 0 && size + Buffer.byteLength(lines) > METRICS_MAX_BYTES) { + fs.renameSync(file, `${file}.1`); + } + fd = fs.openSync(file, APPEND_FLAGS, 0o600); + if (!fs.fstatSync(fd).isFile()) return false; + fs.writeSync(fd, lines); + return true; + } catch (_) { + return false; + } finally { + if (fd !== null) { + try { + fs.closeSync(fd); + } catch (_) { + /* ignore */ + } + } + } +} + +module.exports = { + DECISIONS, + METRICS_FILE_NAME, + METRICS_MAX_BYTES, + METRICS_SCHEMA_VERSION, + appendMetrics, + isMetricsEvent, + metricsEvent, + sessionDigest +}; diff --git a/scripts/lib/gateguard-readonly-shell.js b/scripts/lib/gateguard-readonly-shell.js new file mode 100644 index 000000000..ac9ed51a7 --- /dev/null +++ b/scripts/lib/gateguard-readonly-shell.js @@ -0,0 +1,186 @@ +'use strict'; + +// see docs/gateguard/design-notes.md#read-only-first-shell-command + +const MAX_COMMAND_CHARS = 4096; + +// --- character screen --- + +const BASH_UNQUOTED_REJECT = new Set(['\\', '`', '$', '<', '>', '(', ')', '{', '}']); +const BASH_DOUBLE_QUOTED_REJECT = new Set(['\\', '`', '$']); +const BASH_SINGLE_QUOTED_REJECT = new Set(['\\']); +const PS_UNQUOTED_REJECT = new Set(['`', '$', '<', '>', '(', ')', '{', '}', '@', '[', ']']); +const PS_DOUBLE_QUOTED_REJECT = new Set(['`', '$']); +const PS_SINGLE_QUOTED_REJECT = new Set(); + +const DIALECTS = Object.freeze({ + Bash: { unquoted: BASH_UNQUOTED_REJECT, double: BASH_DOUBLE_QUOTED_REJECT, single: BASH_SINGLE_QUOTED_REJECT }, + PowerShell: { unquoted: PS_UNQUOTED_REJECT, double: PS_DOUBLE_QUOTED_REJECT, single: PS_SINGLE_QUOTED_REJECT } +}); + +function isPlainAscii(code) { + return code === 0x09 || (code >= 0x20 && code < 0x7f); +} + +function passesCharacterScreen(command, dialect) { + let quote = null; + for (let index = 0; index < command.length; index++) { + const ch = command[index]; + if (!isPlainAscii(ch.charCodeAt(0))) return false; + if (quote === "'") { + if (dialect.single.has(ch)) return false; + if (ch === "'") quote = null; + continue; + } + if (quote === '"') { + if (dialect.double.has(ch)) return false; + if (ch === '"') quote = null; + continue; + } + if (ch === "'" || ch === '"') { + quote = ch; + continue; + } + if (dialect.unquoted.has(ch)) return false; + if (ch === '&') { + if (command[index + 1] !== '&') return false; + index++; + } + } + return quote === null; +} + +// --- per-command rules --- + +const anyArgs = () => true; +const noArg = pattern => args => !args.some(arg => pattern.test(arg)); + +const FIND_ACTIONS = /^-(exec|ok|delete|fprint|fls)/; +const FD_EXEC = /^(--exec|-[^-]*[xX])/; +const RG_EXEC = /^(--(pre|hostname-bin|search-zip)|-[^-]*z)/; +const TREE_WRITE = /^(-[^-]*[oR]|--o)/; + +const BASH_COMMANDS = new Map([ + ['ls', anyArgs], + ['pwd', anyArgs], + ['cat', anyArgs], + ['head', anyArgs], + ['tail', anyArgs], + ['wc', anyArgs], + ['grep', anyArgs], + ['rg', noArg(RG_EXEC)], + ['find', noArg(FIND_ACTIONS)], + ['fd', noArg(FD_EXEC)], + ['tree', noArg(TREE_WRITE)], + ['git', isReadOnlyGit] +]); + +const POWERSHELL_COMMANDS = new Map([ + ['get-childitem', anyArgs], + ['gci', anyArgs], + ['dir', anyArgs], + ['ls', anyArgs], + ['get-content', anyArgs], + ['gc', anyArgs], + ['cat', anyArgs], + ['type', anyArgs], + ['select-string', anyArgs], + ['sls', anyArgs], + ['get-location', anyArgs], + ['gl', anyArgs], + ['pwd', anyArgs], + ['rg', noArg(RG_EXEC)], + ['git', isReadOnlyGit] +]); + +// --- git --- + +const GIT_LOG_VALUE_FLAGS = /^--(max-count=\d+|since=.*|until=.*|author=.*|grep=.*|format=.*|pretty=.*|date=.*)$/; +const GIT_DIFF_VALUE_FLAGS = /^(-U\d+|--unified=\d+)$/; + +const GIT_SUBCOMMANDS = new Map([ + ['status', { positional: true, flags: ['--short', '-s', '--branch', '-b', '--porcelain', '--porcelain=v1', '--porcelain=v2', '-uno', '-unormal', '-uall'] }], + [ + 'log', + { + positional: true, + flags: ['--oneline', '--stat', '--name-only', '--name-status', '--graph', '--decorate', '--all', '-p', '--patch', '--no-color', '--reverse', '--first-parent', '--no-merges', '--merges', '--follow', '--abbrev-commit', '-n'], + pattern: value => /^-\d+$/.test(value) || GIT_LOG_VALUE_FLAGS.test(value) + } + ], + [ + 'diff', + { + positional: true, + flags: ['--name-only', '--name-status', '--cached', '--staged', '--stat', '--numstat', '--shortstat', '--no-color', '-w', '--ignore-all-space', '--word-diff', '--merge-base', '--check'], + pattern: value => GIT_DIFF_VALUE_FLAGS.test(value) + } + ], + [ + 'show', + { + positional: true, + flags: ['--stat', '--name-only', '--name-status', '--oneline', '--no-color', '-p', '--patch', '--no-patch', '-s'], + pattern: value => /^--(format|pretty)=/.test(value) + } + ], + ['ls-files', { positional: true, flags: ['--others', '-o', '--exclude-standard', '--cached', '-c', '--modified', '-m', '--deleted', '-d', '--stage', '-s'] }], + ['rev-parse', { positional: true, flags: ['--abbrev-ref', '--show-toplevel', '--git-dir', '--short', '--verify', '--is-inside-work-tree', '--show-prefix', '--symbolic-full-name'] }], + ['branch', { positional: false, flags: ['--show-current', '-a', '--all', '-r', '--remotes', '--list', '-v', '-vv', '--no-color'] }] +]); + +function isReadOnlyGit(args) { + if (args.length === 0) return false; + const rule = GIT_SUBCOMMANDS.get(args[0]); + if (!rule) return false; + let pathspec = false; + for (const arg of args.slice(1)) { + if (pathspec) continue; + if (arg === '--') { + if (!rule.positional) return false; + pathspec = true; + continue; + } + if (arg.startsWith('-')) { + if (rule.flags.includes(arg) || (rule.pattern && rule.pattern(arg))) continue; + return false; + } + if (!rule.positional) return false; + } + return true; +} + +// --- entry --- + +function createReadOnlyShell({ quoteAwareSegments }) { + function segmentsFor(command, toolName) { + return quoteAwareSegments(toolName === 'PowerShell' ? command.replace(/\\/g, '\\\\') : command); + } + + function isReadOnlySegment(words, toolName) { + const [name, ...args] = words; + if (!name || /[=/\\*?[]/.test(name)) return false; + const table = toolName === 'PowerShell' ? POWERSHELL_COMMANDS : BASH_COMMANDS; + const rule = table.get(toolName === 'PowerShell' ? name.toLowerCase() : name); + if (!rule) return false; + if (toolName === 'PowerShell' && args.includes('--%')) return false; + return rule(args); + } + + /** True only when every segment of the command is allowlisted read-only introspection. */ + function isReadOnlyShellCommand(toolName, command) { + try { + const dialect = Object.hasOwn(DIALECTS, toolName) ? DIALECTS[toolName] : null; + if (!dialect || typeof command !== 'string' || command.length > MAX_COMMAND_CHARS) return false; + if (!passesCharacterScreen(command, dialect)) return false; + const segments = segmentsFor(command, toolName); + return segments.length > 0 && segments.every(words => isReadOnlySegment(words, toolName)); + } catch (_) { + return false; + } + } + + return { isReadOnlyShellCommand }; +} + +module.exports = { createReadOnlyShell }; diff --git a/scripts/lib/gateguard-search-evidence.js b/scripts/lib/gateguard-search-evidence.js new file mode 100644 index 000000000..92d8806e2 --- /dev/null +++ b/scripts/lib/gateguard-search-evidence.js @@ -0,0 +1,534 @@ +'use strict'; + +const fs = require('fs'); +const path = require('path'); +const { WINDOWS_PATH_PATTERN, classifyTargetFor } = require('./gateguard-target-class'); +const { excludedBatchId } = require('./gateguard-turn-scan'); +const { + POWERSHELL_SEARCHES, + isInsideDir, + valueFlagsFor, + powershellFilterArg, + grepToolFilters, + shellSearchFilters, + filtersAdmitTarget, + includesAdmitTarget +} = require('./gateguard-search-filters'); + +// --- Search commands and flags --- + +const SHELL_SEARCH_COMMANDS = new Set([ + 'rg', + 'grep', + 'egrep', + 'fgrep', + 'git', + 'find', + 'fd', + 'ls', + 'tree', + 'get-childitem', + 'gci', + 'select-string', + 'sls' +]); +const GIT_SEARCH_SUBCOMMANDS = new Set(['grep', 'ls-files']); +const DIRECTORY_CHANGE_COMMANDS = new Set(['cd', 'pushd', 'popd', 'chdir', 'set-location', 'sl', 'push-location', 'pop-location']); +const PATTERN_FIRST_SEARCHES = new Set(['grep', 'egrep', 'fgrep', 'rg', 'git grep', 'select-string', 'sls', 'fd']); +const RECURSIVE_BY_DEFAULT = new Set(['rg', 'git grep', 'git ls-files', 'find', 'fd', 'tree']); +const GREP_FAMILY = new Set(['grep', 'egrep', 'fgrep']); +const PATTERN_VALUE_FLAGS = new Set(['-e', '-f', '--regexp', '--file']); +const PATTERN_FLAG_SEARCHES = new Set(['grep', 'egrep', 'fgrep', 'rg', 'git grep']); +const POWERSHELL_VALUE_FLAGS = new Set(['-path', '-literalpath', '-depth', '-pattern']); +const POWERSHELL_PATH_FLAGS = new Set(['-path', '-literalpath']); +const GENERIC_STEMS = new Set([ + 'index', + 'main', + 'init', + '__init__', + 'utils', + 'util', + 'types', + 'readme', + 'test', + 'tests', + 'config', + 'mod', + 'lib', + 'setup', + 'app', + 'spec', + 'helpers', + 'common' +]); +const MIN_STEM_LENGTH = 4; +const MAX_SEARCH_COMMAND_CHARS = 8192; +// see docs/gateguard/design-notes.md#ambiguous-shell-is-not-evidence +const AMBIGUOUS_SHELL_PATTERN = /`|\$\(|[<>]\(|<= MIN_STEM_LENGTH && !GENERIC_STEMS.has(stem) ? stem : null; +} + +// see docs/gateguard/design-notes.md#search-prefilter +function inputMayMention(input, word) { + if (!input || typeof input !== 'object') return false; + let text = ''; + for (const value of Object.values(input)) { + if (typeof value === 'string') text += `\n${value}`; + } + return text.toLowerCase().replace(/["'\\]/g, '').includes(word); +} + +// see docs/gateguard/design-notes.md#stem-matching +function stemMatcher(stem) { + if (!stem) return null; + return { test: text => containsWord(String(text), stem) }; +} + +function isStemWordChar(ch) { + return ch !== undefined && ((ch >= 'a' && ch <= 'z') || (ch >= '0' && ch <= '9')); +} + +function containsWord(text, word) { + for (let at = text.indexOf(word); at !== -1; at = text.indexOf(word, at + 1)) { + if (!isStemWordChar(text[at - 1]) && !isStemWordChar(text[at + word.length])) return true; + } + return false; +} + +function dirContext(targetPath, data) { + const base = (data && typeof data.cwd === 'string' && data.cwd) || process.env.CLAUDE_PROJECT_DIR || process.cwd(); + const isWin = WINDOWS_PATH_PATTERN.test(base) || WINDOWS_PATH_PATTERN.test(targetPath); + const paths = isWin ? path.win32 : path.posix; + if (!paths.isAbsolute(base)) return null; + const resolveNative = (...parts) => paths.resolve(base, ...parts); + const resolveDir = (...parts) => { + const dir = resolveNative(...parts).replace(/\\/g, '/'); + return isWin ? dir.toLowerCase() : dir; + }; + const targetKey = resolveDir(targetPath); + return { resolveDir, resolveNative, targetKey, targetDir: path.posix.dirname(targetKey) }; +} + +function isDirectoryNow(nativePath) { + try { + return fs.statSync(nativePath).isDirectory(); + } catch (_) { + return false; + } +} + +function globLiteralPrefix(pattern) { + const segments = pattern.split(/[\\/]/); + const firstGlob = segments.findIndex(segment => GLOB_CHARS_PATTERN.test(segment)); + const literal = firstGlob === -1 ? segments.slice(0, -1) : segments.slice(0, firstGlob); + const joined = literal.join('/'); + return joined || (literal.length > 0 && pattern.startsWith('/') ? '/' : ''); +} + +function stringsOf(...values) { + return values.filter(value => typeof value === 'string' && value); +} + +function baseAfterDirectoryChange(base, lead, tokens) { + if (!['cd', 'chdir', 'set-location', 'sl'].includes(lead) || tokens.length !== 2) return null; + const dir = tokens[1]; + if (!dir || /^[-$~]/.test(dir) || GLOB_CHARS_PATTERN.test(dir)) return null; + if (isAbsoluteShellPath(dir)) return [dir]; + return base === null ? null : base.concat(dir); +} + +function isAbsoluteShellPath(arg) { + return path.posix.isAbsolute(arg) || WINDOWS_PATH_PATTERN.test(arg); +} + +function operandScopePath(arg) { + if (!GLOB_CHARS_PATTERN.test(arg)) return arg; + const segments = arg.split(/[\\/]/); + const literal = segments.slice(0, segments.findIndex(segment => GLOB_CHARS_PATTERN.test(segment))); + if (literal.length === 0) return '.'; + return literal.join('/') || '/'; +} + +function shellSearchScopes(parsed, base) { + if (parsed.operands.length === 0) return base === null ? [] : [base]; + const scopes = []; + for (const operand of parsed.operands) { + if (!operand || operand === '-' || /^[$~]/.test(operand)) continue; + const scoped = operandScopePath(operand); + if (isAbsoluteShellPath(scoped)) scopes.push([scoped]); + else if (base !== null) scopes.push(base.concat(scoped)); + } + return scopes; +} + +function isRecursiveFlag(kind, arg) { + const lower = arg.toLowerCase(); + if (POWERSHELL_SEARCHES.has(kind)) return (kind === 'get-childitem' || kind === 'gci') && lower.startsWith('-rec'); + if (arg === '--recursive' || arg === '--dereference-recursive') return GREP_FAMILY.has(kind) || kind === 'ls'; + if (!/^-[a-zA-Z]+$/.test(arg)) return false; + if (GREP_FAMILY.has(kind)) return /[rR]/.test(arg); + return kind === 'ls' && arg.includes('R'); +} + +function flagEffect(kind, args, i) { + const arg = args[i]; + if (POWERSHELL_SEARCHES.has(kind)) { + const lower = arg.toLowerCase(); + const filter = powershellFilterArg(kind, args, i); + const consumes = filter ? filter.end - i : POWERSHELL_VALUE_FLAGS.has(lower) ? 1 : 0; + return { consumes, patternFlag: lower === '-pattern', pathFlag: POWERSHELL_PATH_FLAGS.has(lower) }; + } + const values = valueFlagsFor(kind); + const takesValue = flag => values.has(flag) || (kind === 'rg' && (flag === '-r' || flag === '--replace')); + const isPatternFlag = flag => PATTERN_FLAG_SEARCHES.has(kind) && PATTERN_VALUE_FLAGS.has(flag); + const name = arg.split('=')[0]; + if (arg.startsWith('--') || kind === 'find' || takesValue(arg)) { + return { consumes: !arg.includes('=') && takesValue(name) ? 1 : 0, patternFlag: isPatternFlag(name), pathFlag: false }; + } + for (let i = 1; i < arg.length; i++) { + const flag = `-${arg[i]}`; + if (takesValue(flag)) return { consumes: i === arg.length - 1 ? 1 : 0, patternFlag: isPatternFlag(flag), pathFlag: false }; + } + return { consumes: 0, patternFlag: false, pathFlag: false }; +} + +function parseSearchArgs(kind, args) { + const positionals = []; + const pathValues = []; + let recursive = RECURSIVE_BY_DEFAULT.has(kind); + let patternGiven = false; + let expressionStarted = false; + let inputRedirect = false; + for (let i = 0; i < args.length; i++) { + const arg = args[i]; + if (/^\d*[<>]/.test(arg)) { + inputRedirect = /^\d* 1 && arg.startsWith('-')) { + expressionStarted = true; + if (isRecursiveFlag(kind, arg)) recursive = true; + const effect = flagEffect(kind, args, i); + if (effect.patternFlag) patternGiven = true; + if (effect.pathFlag && i + 1 < args.length) pathValues.push(args[i + 1]); + i += effect.consumes; + continue; + } + if (kind === 'find' && (expressionStarted || arg === '(' || arg === '!')) { + expressionStarted = true; + continue; + } + positionals.push(arg); + } + const dirOperands = PATTERN_FIRST_SEARCHES.has(kind) && !patternGiven ? positionals.slice(1) : positionals; + const operands = dirOperands.concat(pathValues); + return { operands, dirOperands, recursive, stdin: inputRedirect && operands.length === 0 }; +} + +function shellDirCandidate(arg) { + if (!arg || /^[$~]/.test(arg)) return ''; + const dir = arg.replace(/(?:[\\/]\*\*|[\\/]\*|[\\/])+$/, ''); + if (!dir || dir === '.' || GLOB_CHARS_PATTERN.test(dir)) return ''; + return dir; +} + +function evidenceInScope(item, ctx) { + if (item.scopes) { + const covers = parts => { + const scope = ctx.resolveDir(...parts); + return scope === ctx.targetKey || isInsideDir(ctx.targetKey, scope); + }; + if (!item.scopes.some(covers)) return false; + } + if (item.scope) { + const scope = ctx.resolveDir(...item.scope); + if (scope === ctx.targetKey || !isInsideDir(ctx.targetKey, scope)) return false; + if (item.scopeExact && ctx.targetDir !== scope) return false; + } + // see docs/gateguard/design-notes.md#search-scope + if (item.operands && item.operands.length > 0 && item.operands.every(parts => ctx.resolveDir(...parts) === ctx.targetKey)) { + return false; + } + return true; +} + +function evidenceNamesDir(item, ctx) { + return item.dirs.some( + parts => ctx.resolveDir(...parts) === ctx.targetDir && (!item.dirsMustExist || isDirectoryNow(ctx.resolveNative(...parts))) + ); +} + +// --- Prior-search credit --- + +const MIN_GENERIC_MISS_STEM = 3; +const MISS_RANK = Object.freeze({ 'same-batch': 0, excluded: 1, 'out-of-scope': 2, 'stdin-only': 3, 'not-a-search': 4, 'generic-stem': 5 }); + +/** Build the prior-search matcher around the hook's shell segmenter. */ +function createSearchEvidence({ quoteAwareSegments, commandBasename, SHELL_SEGMENT_SEPARATORS }) { + function searchEvidence(search, shellDirsTrusted, misses) { + const input = search.input; + if (search.name === 'Glob') { + if (typeof input.pattern !== 'string' || !input.pattern) return []; + const explicit = typeof input.path === 'string' && input.path ? input.path : ''; + const prefix = globLiteralPrefix(input.pattern); + const scope = [explicit || '.', prefix || '.']; + if (!GLOB_CHARS_PATTERN.test(input.pattern)) { + const lookup = [explicit || '.', input.pattern]; + return [{ texts: [input.pattern], dirs: [], scope, scopeExact: true, operands: [lookup], detail: input.pattern }]; + } + const dirs = prefix || explicit ? [scope] : []; + return [{ texts: [input.pattern], dirs, scope, detail: input.pattern }]; + } + if (search.name === 'Grep') { + if (typeof input.pattern !== 'string' || !input.pattern) return []; + const explicit = typeof input.path === 'string' && input.path ? input.path : ''; + const detail = typeof input.glob === 'string' && input.glob ? `${input.pattern} --glob ${input.glob}` : input.pattern; + const filters = grepToolFilters(input.glob); + return [{ + texts: stringsOf(input.pattern, ...filters.positives), + dirs: explicit ? [[explicit]] : [], + scope: [explicit || '.'], + detail, + pathIncludes: true, + ...filters + }]; + } + if (search.name === 'LS') { + return stringsOf(input.path).map(dir => ({ texts: [], dirs: [[dir]], scope: [dir], detail: dir })); + } + return shellSearchEvidence(input.command, shellDirsTrusted, misses); + } + + function segmentPipeFlags(input) { + const flags = []; + let quote = null; + let escaped = false; + let hasContent = false; + let piped = false; + for (const ch of String(input || '')) { + if (escaped) { + escaped = false; + } else if (ch === '\\') { + escaped = true; + hasContent = true; + } else if (quote) { + if (ch === quote) quote = null; + } else if (ch === '"' || ch === "'") { + quote = ch; + hasContent = true; + } else if (SHELL_SEGMENT_SEPARATORS.has(ch)) { + if (hasContent) { + flags.push(piped); + piped = false; + hasContent = false; + } + piped = piped || ch === '|'; + } else if (!/\s/.test(ch)) { + hasContent = true; + } + } + if (hasContent) flags.push(piped); + return flags; + } + + function shellSearchEvidence(command, dirsTrusted, misses) { + if (typeof command !== 'string' || !command || command.length > MAX_SEARCH_COMMAND_CHARS) return []; + if (AMBIGUOUS_SHELL_PATTERN.test(command)) return []; + const pipeFlags = segmentPipeFlags(command); + const evidence = []; + let trusted = dirsTrusted; + // see docs/gateguard/design-notes.md#directory-changes + let base = dirsTrusted ? [] : null; + quoteAwareSegments(command).forEach((tokens, index) => { + const lead = commandBasename(tokens[0]); + if (DIRECTORY_CHANGE_COMMANDS.has(lead)) { + trusted = false; + base = baseAfterDirectoryChange(base, lead, tokens); + return; + } + const miss = reason => { + if (misses) misses.push({ texts: [tokens.join(' ')], detail: tokens.join(' '), reason }); + }; + if (!SHELL_SEARCH_COMMANDS.has(lead) || (lead === 'git' && !GIT_SEARCH_SUBCOMMANDS.has(tokens[1]))) { + miss('not-a-search'); + return; + } + const kind = lead === 'git' ? `git ${tokens[1]}` : lead; + const args = tokens.slice(lead === 'git' ? 2 : 1); + const parsed = parseSearchArgs(kind, args); + // see docs/gateguard/design-notes.md#stdin-is-not-a-tree-search + if (parsed.stdin || (parsed.operands.length === 0 && (!parsed.recursive || pipeFlags[index] !== false))) { + miss('stdin-only'); + return; + } + const scopes = shellSearchScopes(parsed, base); + if (scopes.length === 0) return; + const filters = shellSearchFilters(kind, args); + // see docs/gateguard/design-notes.md#search-filters + const stemText = tokens.slice(0, tokens.length - args.length).concat(args.filter((_, i) => !filters.dropped.has(i))).join(' '); + const dirs = trusted ? parsed.dirOperands.map(shellDirCandidate).filter(Boolean).map(dir => [dir]) : []; + evidence.push({ + texts: [stemText], + dirs, + dirsMustExist: true, + scopes, + operands: parsed.operands.map(op => [op]), + detail: tokens.join(' '), + exclusions: filters.exclusions, + includes: filters.includes, + pathIncludes: kind === 'rg', + opaque: filters.opaque + }); + }); + return evidence; + } + + const evidenceCache = new WeakMap(); + const trustCache = new WeakMap(); + + function cachedEvidence(search, shellDirsTrusted) { + if (!search || typeof search !== 'object') return { items: [], misses: [] }; + let byTrust = evidenceCache.get(search); + if (!byTrust) { + byTrust = new Map(); + evidenceCache.set(search, byTrust); + } + let entry = byTrust.get(shellDirsTrusted); + if (!entry) { + const misses = []; + entry = { items: searchEvidence(search, shellDirsTrusted, misses), misses }; + byTrust.set(shellDirsTrusted, entry); + } + return entry; + } + + function shellDirsTrustedFor(scan) { + if (!trustCache.has(scan)) trustCache.set(scan, !turnChangesDirectory(scan.shellCommands)); + return trustCache.get(scan); + } + + function turnChangesDirectory(shellCommands) { + if (!Array.isArray(shellCommands)) return false; + return shellCommands.some(command => { + if (typeof command !== 'string') return false; + if (command.length > MAX_SEARCH_COMMAND_CHARS) return true; + return quoteAwareSegments(command).some(tokens => DIRECTORY_CHANGE_COMMANDS.has(commandBasename(tokens[0]))); + }); + } + + /** First completed search of the current turn that credits the target, else null. */ + function findCreditingSearch(scan, targetPath, allowDirMatch, data) { + try { + if (!scan || !Array.isArray(scan.searches) || scan.searches.length === 0) return null; + if (typeof targetPath !== 'string' || !targetPath) return null; + const ctx = dirContext(targetPath, data); + if (!ctx) return null; + const eligible = eligibleStem(targetPath, data); + if (eligible === null && !allowDirMatch) return null; + const stem = stemMatcher(eligible); + // see docs/gateguard/design-notes.md#same-batch-searches + const excluded = excludedBatchId(scan, data); + let shellDirsTrusted = null; + for (const search of scan.searches) { + if (typeof search.messageId !== 'string' || !search.messageId || search.messageId === excluded) continue; + if (!allowDirMatch && !inputMayMention(search.input, eligible)) continue; + if (shellDirsTrusted === null) shellDirsTrusted = shellDirsTrustedFor(scan); + for (const item of cachedEvidence(search, shellDirsTrusted).items) { + if (!evidenceInScope(item, ctx) || !filtersAdmitTarget(item, ctx, stem)) continue; + const byStem = stem !== null && includesAdmitTarget(item, ctx) && item.texts.some(text => stem.test(text.toLowerCase())); + const byDir = Boolean(allowDirMatch) && evidenceNamesDir(item, ctx); + if (byStem || byDir) { + return { name: search.name, detail: item.detail, callsAgo: search.callsAgo }; + } + } + } + return null; + } catch (_) { + return null; + } + } + + // --- Closest non-qualifying search --- + // see docs/gateguard/design-notes.md#closest-search-that-did-not-count + + function mentionsTarget(texts, stem, rawStem) { + const matcher = stem || rawStem; + return Boolean(matcher) && texts.some(text => typeof text === 'string' && matcher.test(text.toLowerCase())); + } + + function itemMiss(item, ctx, stem, rawStem, allowDirMatch, sameBatch) { + if (stem === null) return mentionsTarget(item.texts, null, rawStem) ? 'generic-stem' : null; + const byDir = Boolean(allowDirMatch) && evidenceNamesDir(item, ctx); + const mentioned = mentionsTarget(item.texts, stem, null); + if (!mentioned && !byDir) return null; + const inScope = evidenceInScope(item, ctx); + const admitted = filtersAdmitTarget(item, ctx, stem); + const included = includesAdmitTarget(item, ctx); + if (inScope && admitted && (byDir || included)) return sameBatch ? 'same-batch' : null; + if (!inScope) return 'out-of-scope'; + return 'excluded'; + } + + /** Closest search of the current turn that did not credit the target, with a reason code, else null. */ + function findClosestMiss(scan, targetPath, allowDirMatch, data) { + try { + if (!scan || !Array.isArray(scan.searches) || typeof targetPath !== 'string' || !targetPath) return null; + const ctx = dirContext(targetPath, data); + if (!ctx) return null; + const eligible = eligibleStem(targetPath, data); + const stem = stemMatcher(eligible); + const bare = bareStem(targetPath, data); + const rawStem = eligible === null && bare.length >= MIN_GENERIC_MISS_STEM ? stemMatcher(bare) : null; + const excluded = excludedBatchId(scan, data); + let shellDirsTrusted = null; + let best = null; + const consider = (name, detail, reason) => { + if (reason && (best === null || MISS_RANK[reason] < MISS_RANK[best.reason])) best = { name, detail, reason }; + }; + for (const search of scan.searches) { + if (!search || typeof search.messageId !== 'string' || !search.messageId) continue; + if (!allowDirMatch && (!bare || !inputMayMention(search.input, bare))) continue; + if (shellDirsTrusted === null) shellDirsTrusted = shellDirsTrustedFor(scan); + const { items, misses } = cachedEvidence(search, shellDirsTrusted); + for (const item of items) { + consider(search.name, item.detail, itemMiss(item, ctx, stem, rawStem, allowDirMatch, search.messageId === excluded)); + } + for (const miss of misses) { + if (mentionsTarget(miss.texts, stem, rawStem)) consider(search.name, miss.detail, stem === null ? 'generic-stem' : miss.reason); + } + } + for (const read of Array.isArray(scan.reads) ? scan.reads : []) { + if (!read || typeof read.path !== 'string' || !read.path) continue; + const readsTarget = ctx.resolveDir(read.path) === ctx.targetKey || mentionsTarget([read.path], stem, null); + if (readsTarget) consider('Read', read.path, 'not-a-search'); + } + return best; + } catch (_) { + return null; + } + } + + return { findCreditingSearch, findClosestMiss }; +} + +module.exports = { createSearchEvidence }; diff --git a/scripts/lib/gateguard-search-filters.js b/scripts/lib/gateguard-search-filters.js new file mode 100644 index 000000000..2077aff7f --- /dev/null +++ b/scripts/lib/gateguard-search-filters.js @@ -0,0 +1,459 @@ +'use strict'; + +// --- Search flags --- + +const POWERSHELL_SEARCHES = new Set(['get-childitem', 'gci', 'select-string', 'sls']); + +const VALUE_FLAGS = new Set([ + '-e', '-f', '-g', '-t', '-T', '-m', '-A', '-B', '-C', + '--regexp', '--file', '--type', '--glob', '--iglob', '--include', '--exclude', '--exclude-dir', '--exclude-from', + '--ignore-file', '--max-count', '--replace', + '-name', '-iname', '-path', '-ipath', '-type', '-maxdepth', '-mindepth', '-newer', '-regex', '-size', '-user', '-group', '-perm' +]); + +const LS_VALUE_FLAGS = new Set(['-I', '--ignore', '--hide']); + +const TREE_VALUE_FLAGS = new Set(['-L', '-P', '-I', '-o']); + +const FD_VALUE_FLAGS = new Set([...VALUE_FLAGS, '-E']); + +const POWERSHELL_FILTER_PARAMS = new Set(['filter', 'include', 'exclude']); + +const POWERSHELL_COMMON_PARAMS = [ + 'verbose', 'debug', 'erroraction', 'warningaction', 'informationaction', 'progressaction', + 'errorvariable', 'warningvariable', 'informationvariable', 'outvariable', 'outbuffer', 'pipelinevariable' +]; + +const POWERSHELL_CHILDITEM_PARAMS = [ + 'path', 'literalpath', 'filter', 'include', 'exclude', 'recurse', 'depth', 'force', 'name', + 'attributes', 'directory', 'file', 'hidden', 'readonly', 'system', 'followsymlink' +]; + +const POWERSHELL_SELECTSTRING_PARAMS = [ + 'pattern', 'path', 'literalpath', 'inputobject', 'simplematch', 'casesensitive', 'quiet', 'list', 'noemphasis', + 'include', 'exclude', 'notmatch', 'allmatches', 'encoding', 'context', 'raw', 'culture' +]; + +const POWERSHELL_PARAM_ALIASES = new Set([ + 's', 'ad', 'd', 'af', 'ah', 'h', 'ar', 'as', 'lp', 'pspath', + 'vb', 'db', 'ea', 'wa', 'infa', 'proga', 'ev', 'wv', 'iv', 'ov', 'ob', 'pv' +]); + +function isInsideDir(targetKey, dirKey) { + return targetKey.startsWith(dirKey.endsWith('/') ? dirKey : `${dirKey}/`); +} + +function valueFlagsFor(kind) { + if (kind === 'ls' || kind === 'git ls-files') return LS_VALUE_FLAGS; + if (kind === 'tree') return TREE_VALUE_FLAGS; + if (kind === 'fd') return FD_VALUE_FLAGS; + return VALUE_FLAGS; +} + +// --- Search filters --- +// see docs/gateguard/design-notes.md#search-filters + +const MAX_FILTER_GLOB_CHARS = 256; +const MAX_BRACE_ALTERNATIVES = 32; +const MAX_FILTERS_PER_SEARCH = 32; +const FIND_NAME_FLAGS = new Set(['-name', '-iname', '-path', '-ipath', '-wholename', '-iwholename', '-regex', '-iregex']); +const FIND_BASENAME_FLAGS = new Set(['-name', '-iname']); +const OPAQUE_FILTER_FLAGS = new Set(['--exclude-from', '--ignore-file', '--exclude-per-directory']); +const EXCLUDE_LONG_FLAGS = new Set(['--exclude', '--exclude-dir', '--ignore', '--hide']); +const LS_ONLY_EXCLUDE_FLAGS = new Set(['--ignore', '--hide']); +const GIT_EXCLUDE_PATHSPEC = /^:(?:[!^]|\([^)]*\bexclude\b[^)]*\))/; + +function splitTopLevelCommas(value) { + const parts = []; + let depth = 0; + let current = ''; + for (const ch of String(value)) { + if (ch === '{') depth += 1; + else if (ch === '}' && depth > 0) depth -= 1; + if (ch === ',' && depth === 0) { + parts.push(current); + current = ''; + } else { + current += ch; + } + } + parts.push(current); + return parts.map(part => part.trim()).filter(Boolean); +} + +function expandBraces(glob) { + const open = glob.indexOf('{'); + if (open === -1) return [glob]; + let depth = 0; + for (let i = open; i < glob.length; i++) { + if (glob[i] === '{') depth += 1; + else if (glob[i] === '}' && --depth === 0) { + const head = glob.slice(0, open); + const tail = glob.slice(i + 1); + const results = []; + for (const option of splitTopLevelCommas(glob.slice(open + 1, i)).concat(glob[open + 1] === ',' ? [''] : [])) { + const expanded = expandBraces(`${head}${option}${tail}`); + if (!expanded) return null; + results.push(...expanded); + if (results.length > MAX_BRACE_ALTERNATIVES) return null; + } + return results; + } + } + return [glob]; +} + +function globTokens(glob) { + const tokens = []; + for (let i = 0; i < glob.length; i++) { + const ch = glob[i]; + if (ch === '*' && glob[i + 1] === '*') { + const dirs = glob[i + 2] === '/'; + tokens.push({ type: dirs ? 'dirs' : 'deep' }); + i += dirs ? 2 : 1; + } else if (ch === '*') { + tokens.push({ type: 'star' }); + } else if (ch === '?') { + tokens.push({ type: 'one' }); + } else if (ch === '[') { + const parsed = bracketClass(glob, i); + if (!parsed) return null; + tokens.push(parsed.token); + i = parsed.end; + } else { + tokens.push({ type: 'lit', ch }); + } + } + return tokens; +} + +function bracketClass(glob, open) { + let i = open + 1; + const negated = glob[i] === '!' || glob[i] === '^'; + if (negated) i += 1; + const ranges = []; + for (let first = true; i < glob.length && (first || glob[i] !== ']'); first = false) { + if (glob[i + 1] === '-' && i + 2 < glob.length && glob[i + 2] !== ']') { + ranges.push([glob[i], glob[i + 2]]); + i += 3; + } else { + ranges.push([glob[i], glob[i]]); + i += 1; + } + } + if (i >= glob.length) return null; + return { token: { type: 'class', negated, ranges }, end: i }; +} + +function classMatches(token, ch) { + if (ch === '/') return false; + const forms = [ch, ch.toLowerCase(), ch.toUpperCase()]; + const member = token.ranges.some(([lo, hi]) => forms.some(form => form >= lo && form <= hi)); + return member !== token.negated; +} + +// see docs/gateguard/design-notes.md#glob-matching-without-regexp +function wildcardMatch(glob, text) { + const tokens = globTokens(glob); + if (!tokens) return null; + const n = text.length; + let next = new Array(n + 1).fill(false); + next[n] = true; + for (let t = tokens.length - 1; t >= 0; t--) { + const token = tokens[t]; + const row = new Array(n + 1).fill(false); + let afterSlash = false; + for (let j = n; j >= 0; j--) { + if (token.type === 'lit') row[j] = j < n && text[j] === token.ch && next[j + 1]; + else if (token.type === 'one') row[j] = j < n && text[j] !== '/' && next[j + 1]; + else if (token.type === 'class') row[j] = j < n && classMatches(token, text[j]) && next[j + 1]; + else if (token.type === 'star') row[j] = next[j] || (j < n && text[j] !== '/' && row[j + 1]); + else if (token.type === 'deep') row[j] = next[j] || (j < n && row[j + 1]); + else { + if (j < n && text[j] === '/' && next[j + 1]) afterSlash = true; + row[j] = next[j] || afterSlash; + } + } + next = row; + } + return next[0]; +} + +function normalizeFilterGlob(glob) { + return String(glob).toLowerCase().replace(/\\/g, '/').replace(/^(?:\.\/)+/, '').replace(/^\/+/, '').replace(/\/+$/, ''); +} + +function globMatches(glob, text, malformed) { + if (glob.length > MAX_FILTER_GLOB_CHARS) return null; + const alternatives = expandBraces(glob); + if (!alternatives) return null; + return alternatives.some(alternative => { + const matched = wildcardMatch(alternative, text); + return matched === null ? malformed : matched; + }); +} + +function targetSegments(ctx) { + return ctx.targetKey.toLowerCase().split('/').filter(Boolean); +} + +function exclusionCoversTarget(exclusion, segments, stem) { + const glob = normalizeFilterGlob(exclusion); + if (!glob) return true; + if (stem !== null && stem.test(glob)) return true; + for (let start = 0; start < segments.length; start++) { + for (let end = start + 1; end <= segments.length; end++) { + const matched = globMatches(glob, segments.slice(start, end).join('/'), true); + if (matched !== false) return true; + } + } + return false; +} + +function filtersAdmitTarget(item, ctx, stem) { + if (item.opaque) return false; + const exclusions = Array.isArray(item.exclusions) ? item.exclusions : []; + if (exclusions.length === 0) return true; + if (exclusions.length > MAX_FILTERS_PER_SEARCH) return false; + const segments = targetSegments(ctx); + return !exclusions.some(exclusion => exclusionCoversTarget(exclusion, segments, stem)); +} + +function scopeRelativeSegments(item, ctx) { + const scopes = item.scopes || (item.scope ? [item.scope] : []); + const relatives = []; + for (const parts of scopes) { + const scope = ctx.resolveDir(...parts); + if (isInsideDir(ctx.targetKey, scope)) relatives.push(ctx.targetKey.slice(scope.replace(/\/+$/, '').length + 1).toLowerCase().split('/')); + } + return relatives; +} + +function includeAdmitsTarget(include, item, ctx) { + const glob = normalizeFilterGlob(include); + const unanchored = glob.replace(/^(?:\*\*\/)+/, ''); + if (!unanchored) return false; + if (isBasenameGlob(unanchored)) { + const segments = targetSegments(ctx); + return globMatches(unanchored, segments[segments.length - 1] || '', false) === true; + } + if (!item.pathIncludes) return false; + return scopeRelativeSegments(item, ctx).some(segments => { + const starts = unanchored === glob ? [0] : segments.map((_, start) => start); + return starts.some(start => globMatches(unanchored, segments.slice(start).join('/'), false) === true); + }); +} + +// see docs/gateguard/design-notes.md#include-filters +function includesAdmitTarget(item, ctx) { + const includes = Array.isArray(item.includes) ? item.includes : []; + if (includes.length === 0) return true; + if (includes.length > MAX_FILTERS_PER_SEARCH) return false; + return includes.some(include => includeAdmitsTarget(include, item, ctx)); +} + +function isBasenameGlob(glob) { + return !/[\\/]/.test(String(glob).replace(/^(?:\*\*[\\/])+/, '')); +} + +function grepToolFilters(glob) { + const filters = { positives: [], includes: [], exclusions: [], opaque: false }; + if (typeof glob !== 'string' || !glob) return filters; + for (const part of splitTopLevelCommas(glob)) { + if (part.startsWith('!')) filters.exclusions.push(part.slice(1)); + else { + filters.positives.push(part); + filters.includes.push(part); + } + } + return filters; +} + +function shellSearchFilters(kind, args) { + const filters = { exclusions: [], includes: [], opaque: false, dropped: new Set() }; + if (kind === 'find') return findSearchFilters(args, filters); + if (POWERSHELL_SEARCHES.has(kind)) return powershellSearchFilters(kind, args, filters); + for (let i = 0; i < args.length; i++) { + const arg = args[i]; + if (/^\d*[<>]/.test(arg)) break; + const consumed = applyShellFilterArg(kind, args, i, filters); + i += consumed; + } + return filters; +} + +function applyShellFilterArg(kind, args, i, filters) { + const arg = args[i]; + const exclude = (value, indexes, separator) => { + for (const glob of separator ? String(value).split(separator) : [value]) { + if (glob) filters.exclusions.push(glob); + } + indexes.forEach(index => filters.dropped.add(index)); + }; + const include = value => { + if (value) filters.includes.push(value); + }; + if (kind.startsWith('git ') && GIT_EXCLUDE_PATHSPEC.test(arg)) { + exclude(arg.replace(GIT_EXCLUDE_PATHSPEC, ''), [i]); + return 0; + } + if (arg.startsWith('--')) { + const eq = arg.indexOf('='); + const name = eq > 0 ? arg.slice(0, eq) : arg; + const hasNext = eq <= 0 && i + 1 < args.length; + const value = eq > 0 ? arg.slice(eq + 1) : hasNext ? args[i + 1] : ''; + const indexes = eq > 0 || !hasNext ? [i] : [i, i + 1]; + const consumed = indexes.length - 1; + if (OPAQUE_FILTER_FLAGS.has(name)) { + filters.opaque = true; + return consumed; + } + if (name === '--glob' || name === '--iglob') { + if (value.startsWith('!')) exclude(value.slice(1), indexes); + else include(value); + return consumed; + } + if (name === '--include') { + include(value); + return consumed; + } + if (EXCLUDE_LONG_FLAGS.has(name) && (kind === 'ls' || !LS_ONLY_EXCLUDE_FLAGS.has(name))) { + exclude(value, indexes); + return consumed; + } + return 0; + } + if (!/^-[A-Za-z]/.test(arg)) return 0; + const letters = shortFilterLetters(kind); + const valueFlags = valueFlagsFor(kind); + for (let k = 1; k < arg.length; k++) { + const letter = arg[k]; + if (!letters.has(letter)) { + if (valueFlags.has(`-${letter}`)) return k === arg.length - 1 ? 1 : 0; + continue; + } + const attached = arg.slice(k + 1); + const value = attached || args[i + 1] || ''; + const indexes = attached || i + 1 >= args.length ? [i] : [i, i + 1]; + const consumed = indexes.length - 1; + applyShortFilter(kind, letter, value, indexes, { filters, exclude, include }); + return consumed; + } + return 0; +} + +function shortFilterLetters(kind) { + if (kind === 'rg') return new Set(['g']); + if (kind === 'fd') return new Set(['E']); + if (kind === 'ls') return new Set(['I']); + if (kind === 'tree') return new Set(['I', 'P']); + if (kind === 'git ls-files') return new Set(['x', 'X']); + return new Set(); +} + +function applyShortFilter(kind, letter, value, indexes, { filters, exclude, include }) { + if (kind === 'rg') { + if (value.startsWith('!')) exclude(value.slice(1), indexes); + else include(value); + } else if (kind === 'tree' && letter === 'P') { + value.split('|').forEach(include); + } else if (kind === 'git ls-files' && letter === 'X') { + filters.opaque = true; + } else { + exclude(value, indexes, kind === 'tree' ? '|' : ''); + } +} + +function findSearchFilters(args, filters) { + let negateNext = false; + let negatedDepth = 0; + let depth = 0; + for (let i = 0; i < args.length; i++) { + const arg = args[i]; + if (/^\d*[<>]/.test(arg)) break; + if (arg === '-not' || arg === '!') { + negateNext = true; + filters.dropped.add(i); + continue; + } + if (arg === '(') { + depth += 1; + if (negateNext && negatedDepth === 0) negatedDepth = depth; + negateNext = false; + continue; + } + if (arg === ')') { + if (negatedDepth === depth) negatedDepth = 0; + depth = Math.max(depth - 1, 0); + continue; + } + if (FIND_NAME_FLAGS.has(arg) && i + 1 < args.length) { + const value = args[i + 1]; + if (negateNext || negatedDepth > 0 || args[i + 2] === '-prune') { + filters.exclusions.push(value); + filters.dropped.add(i); + filters.dropped.add(i + 1); + } else if (FIND_BASENAME_FLAGS.has(arg)) { + filters.includes.push(value); + } + i += 1; + } + negateNext = false; + } + return filters; +} + +function powershellParam(kind, arg) { + const colon = arg.indexOf(':'); + const spelled = (colon === -1 ? arg.slice(1) : arg.slice(1, colon)).toLowerCase(); + const inline = colon === -1 ? null : arg.slice(colon + 1); + const own = kind === 'get-childitem' || kind === 'gci' ? POWERSHELL_CHILDITEM_PARAMS : POWERSHELL_SELECTSTRING_PARAMS; + const params = own.concat(POWERSHELL_COMMON_PARAMS); + if (!spelled) return { name: null, inline }; + if (params.includes(spelled) || POWERSHELL_PARAM_ALIASES.has(spelled)) return { name: spelled, inline }; + const matches = params.filter(param => param.startsWith(spelled)); + return { name: matches.length === 1 ? matches[0] : null, inline }; +} + +function powershellFilterArg(kind, args, i) { + if (!/^-[A-Za-z]/.test(args[i])) return null; + const { name, inline } = powershellParam(kind, args[i]); + if (!POWERSHELL_FILTER_PARAMS.has(name)) return null; + const first = inline ? i : i + 1; + if (first >= args.length) return { name, globs: [], end: i }; + let end = first; + while (end + 1 < args.length && (args[end].endsWith(',') || args[end + 1].startsWith(','))) end += 1; + const parts = [inline || args[first]].concat(args.slice(first + 1, end + 1)); + return { name, globs: splitTopLevelCommas(parts.join(',')), end }; +} + +function powershellSearchFilters(kind, args, filters) { + for (let i = 0; i < args.length; i++) { + const filter = powershellFilterArg(kind, args, i); + if (filter) { + if (filter.name === 'exclude') { + filters.exclusions.push(...filter.globs); + for (let k = i; k <= filter.end; k++) filters.dropped.add(k); + } else { + filter.globs.filter(Boolean).forEach(glob => filters.includes.push(glob)); + } + i = filter.end; + } else if (/^-[A-Za-z]/.test(args[i]) && powershellParam(kind, args[i]).name === null) { + // see docs/gateguard/design-notes.md#powershell-parameter-binding + filters.dropped.add(i); + if (!args[i].includes(':') && i + 1 < args.length && !args[i + 1].startsWith('-')) filters.dropped.add(i + 1); + } + } + return filters; +} + +module.exports = { + POWERSHELL_SEARCHES, + isInsideDir, + valueFlagsFor, + powershellFilterArg, + grepToolFilters, + shellSearchFilters, + filtersAdmitTarget, + includesAdmitTarget +}; diff --git a/scripts/lib/gateguard-state.js b/scripts/lib/gateguard-state.js new file mode 100644 index 000000000..aa6dc5678 --- /dev/null +++ b/scripts/lib/gateguard-state.js @@ -0,0 +1,143 @@ +'use strict'; + +// --- Counters --- +// see docs/gateguard/design-notes.md#state-file-is-untrusted + +function toCount(value) { + const n = Number(value); + return Number.isFinite(n) && n >= 0 ? Math.floor(n) : 0; +} + +function getDenialCount(state) { + return toCount(state && state.fact_force_denials); +} + +function getCreditedCount(state) { + return toCount(state && state.fact_force_credited); +} + +function getCapAllowCount(state) { + return toCount(state && state.cap_allows); +} + +function getTrivialAllowCount(state) { + return toCount(state && state.trivial_allows); +} + +function getRoutineReadonlyPassCount(state) { + return toCount(state && state.routine_readonly_passes); +} + +// --- Sibling dir gates --- + +const MAX_DIR_GATES = 50; +const DIR_GATE_KEY_SEPARATOR = '\u0000'; +const UNSAFE_MAP_KEYS = new Set(['__proto__', 'constructor', 'prototype']); + +function isPlainObject(value) { + return Boolean(value) && typeof value === 'object' && !Array.isArray(value); +} + +function isSafeKey(key) { + return typeof key === 'string' && key !== '' && !UNSAFE_MAP_KEYS.has(key); +} + +function nullMap(entries) { + const map = Object.create(null); + for (const [key, value] of entries) map[key] = value; + return map; +} + +function withEntry(map, key, value) { + return nullMap([...Object.entries(map), [key, value]]); +} + +function getClassCounts(state, field) { + const raw = state && state[field]; + if (!isPlainObject(raw)) return nullMap([]); + const entries = Object.keys(raw) + .filter(cls => isSafeKey(cls) && typeof raw[cls] === 'number' && toCount(raw[cls]) > 0) + .map(cls => [cls, toCount(raw[cls])]); + return nullMap(entries); +} + +function mergeClassCounts(a, b) { + const merged = nullMap(Object.entries(a)); + for (const [cls, n] of Object.entries(b)) { + merged[cls] = Math.max(Object.hasOwn(merged, cls) ? merged[cls] : 0, n); + } + return merged; +} + +function incrementClassCount(counts, cls) { + if (!isSafeKey(cls)) return counts; + return withEntry(counts, cls, (Object.hasOwn(counts, cls) ? counts[cls] : 0) + 1); +} + +function getSiblingAllowCount(state) { + return toCount(state && state.sibling_allows); +} + +function dirGateKey(cls, dir) { + return `${cls}${DIR_GATE_KEY_SEPARATOR}${dir}`; +} + +function isDirGateKey(key) { + if (!isSafeKey(key)) return false; + const at = key.indexOf(DIR_GATE_KEY_SEPARATOR); + return at > 0 && at < key.length - 1 && require('./gateguard-target-class').COLLAPSIBLE_CLASSES.has(key.slice(0, at)); +} + +function isDirGateEntry(entry) { + return ( + isPlainObject(entry) && + typeof entry.at === 'number' && + Number.isFinite(entry.at) && + typeof entry.first === 'string' && + (entry.turn === null || entry.turn === undefined || typeof entry.turn === 'string') + ); +} + +function getDirGates(state) { + const raw = state && state.dir_gates; + if (!isPlainObject(raw)) return nullMap([]); + const entries = Object.keys(raw) + .filter(key => isDirGateKey(key) && isDirGateEntry(raw[key])) + .map(key => { + const entry = raw[key]; + return [key, { turn: entry.turn || null, at: entry.at, first: entry.first, ordinal: toCount(entry.ordinal) }]; + }); + return nullMap(entries); +} + +/** Merge two dir-gate maps; the newer `at` wins per key. */ +function mergeDirGates(older, newer) { + const merged = nullMap(Object.entries(older)); + for (const [key, entry] of Object.entries(newer)) { + if (!Object.hasOwn(merged, key) || entry.at >= merged[key].at) merged[key] = entry; + } + return merged; +} + +function capDirGates(gates) { + const entries = Object.entries(gates); + if (entries.length <= MAX_DIR_GATES) return gates; + return nullMap(entries.sort((a, b) => b[1].at - a[1].at).slice(0, MAX_DIR_GATES)); +} + +module.exports = { + getDenialCount, + getCreditedCount, + getCapAllowCount, + getTrivialAllowCount, + getRoutineReadonlyPassCount, + getSiblingAllowCount, + getClassCounts, + mergeClassCounts, + incrementClassCount, + withEntry, + dirGateKey, + getDirGates, + mergeDirGates, + capDirGates +}; diff --git a/scripts/lib/gateguard-target-class.js b/scripts/lib/gateguard-target-class.js new file mode 100644 index 000000000..0f2e3a4db --- /dev/null +++ b/scripts/lib/gateguard-target-class.js @@ -0,0 +1,442 @@ +'use strict'; + +const fs = require('fs'); +const path = require('path'); + +const WINDOWS_PATH_PATTERN = /^[a-z]:[\\/]|^\\\\/i; + +/** Canonical checked-state key for a target. */ +function canonicalPathKey(filePath, data) { + try { + const target = resolveTargetPath(filePath, data); + if (!target) return filePath; + const key = target.resolved.replace(/\\/g, '/'); + return target.isWin ? key.toLowerCase() : key; + } catch (_) { + return filePath; + } +} + +/** Resolve a target to the file the tool will touch. */ +// see docs/gateguard/design-notes.md#target-resolution +function resolveTargetPath(filePath, data) { + const base = (data && data.cwd) || process.env.CLAUDE_PROJECT_DIR || process.cwd(); + if (typeof base !== 'string' || typeof filePath !== 'string') return null; + const isWin = WINDOWS_PATH_PATTERN.test(base) || WINDOWS_PATH_PATTERN.test(filePath); + const paths = isWin ? path.win32 : path.posix; + if (!paths.isAbsolute(base)) return null; + return { resolved: paths.resolve(base, filePath), isWin }; +} + +// --- Target classes --- + +const INSTRUCTION_BASENAMES = new Set([ + 'claude.md', + 'agents.md', + 'agent.md', + 'gemini.md', + 'skill.md', + 'copilot-instructions.md', + '.cursorrules', + '.windsurfrules' +]); +const INSTRUCTION_ANYWHERE_EXTS = new Set(['.mdc']); +const INSTRUCTION_DIRS = new Set(['.claude', 'agents', 'commands', 'skills', 'rules', 'hooks', '.cursor', '.codex', '.opencode']); +const INSTRUCTION_EXTS = new Set(['.md', '.mdx', '.txt']); +const TEST_DIRS = new Set(['tests', 'test', '__tests__']); +const TEST_BASENAME_PATTERN = /\.(test|spec)\.|^test_.*\.py$|_test\.(py|go)$/; +const PROSE_EXTS = new Set(['.md', '.mdx', '.txt', '.rst', '.adoc']); +const CONFIG_EXTS = new Set(['.json', '.jsonc', '.yaml', '.yml', '.toml', '.ini']); +const ENV_BASENAME_PATTERN = /^\.env($|\.)/; +const CODE_EXTS = new Set(['.ts', '.js', '.mjs', '.cjs', '.py', '.go', '.rs', '.java', '.rb']); + +/** Classify a path; first match wins: instruction, test, prose, config, code (the fallback). */ +function classifyTarget(filePath) { + try { + const normalized = String(filePath || '').replace(/\\/g, '/').toLowerCase(); + const segments = normalized.split('/').filter(Boolean); + const base = segments.pop() || ''; + const ext = path.posix.extname(base); + if (INSTRUCTION_BASENAMES.has(base) || INSTRUCTION_ANYWHERE_EXTS.has(ext)) return 'instruction'; + if (INSTRUCTION_EXTS.has(ext) && segments.some(segment => INSTRUCTION_DIRS.has(segment))) return 'instruction'; + if (ext === '.md' && base.includes('instructions') && segments.includes('.github')) return 'instruction'; + if (TEST_BASENAME_PATTERN.test(base) || segments.some(segment => TEST_DIRS.has(segment))) return 'test'; + if (PROSE_EXTS.has(ext)) return 'prose'; + if (CONFIG_EXTS.has(ext) || (ENV_BASENAME_PATTERN.test(base) && !CODE_EXTS.has(ext))) return 'config'; + return 'code'; + } catch (_) { + return 'code'; + } +} + +const WORKTREE_PREFIX_PATTERN = /^\.claude\/worktrees\/([^/]+)\//; + +function isHostPathStyle(isWin) { + return isWin === (process.platform === 'win32'); +} + +function foldKey(nativePath, isWin) { + const key = nativePath.replace(/\\/g, '/'); + return isWin ? key.toLowerCase() : key; +} + +function canonicalProjectRoot(data) { + const root = process.env.CLAUDE_PROJECT_DIR || (data && data.cwd) || process.cwd(); + if (typeof root !== 'string') return null; + const isWin = WINDOWS_PATH_PATTERN.test(root); + const paths = isWin ? path.win32 : path.posix; + if (!paths.isAbsolute(root)) return null; + const native = paths.resolve(root); + return { key: foldKey(native, isWin), native, isWin }; +} + +// see docs/gateguard/design-notes.md#worktree-prefix +function isRealWorktree(rootNative, isWin, name) { + if (!isHostPathStyle(isWin)) return false; + const paths = isWin ? path.win32 : path.posix; + return fs.existsSync(paths.join(rootNative, '.claude', 'worktrees', name, '.git')); +} + +const STREAM_MARKER = '::$'; + +// see docs/gateguard/design-notes.md#windows-name-normalization +function normalizeWindowsSegments(classPath, isWin) { + const segments = classPath.split('/'); + const last = segments.length - 1; + return segments + .map((segment, index) => { + if (!isWin && !segment.includes(STREAM_MARKER)) return segment; + let name = segment; + const colon = name.indexOf(':'); + if (index === last && colon > 0) name = name.slice(0, colon); + name = name.replace(/[. ]+$/, ''); + return name || segment; + }) + .join('/'); +} + +function projectRelativeClassPath(key, root, isWin) { + const target = isWin ? key.toLowerCase() : key; + const rootKey = isWin ? root.key.toLowerCase() : root.key; + const prefix = rootKey.endsWith('/') ? rootKey : `${rootKey}/`; + if (!target.startsWith(prefix)) return null; + let relative = key.slice(prefix.length); + const worktree = relative.match(WORKTREE_PREFIX_PATTERN); + if (worktree && isRealWorktree(root.native, root.isWin, worktree[1])) relative = relative.slice(worktree[0].length); + return normalizeWindowsSegments(relative, isWin); +} + +/** Project-relative path a target is classified by. */ +function classPathFor(filePath, data) { + try { + const key = canonicalPathKey(filePath, data); + const root = canonicalProjectRoot(data); + const isWin = Boolean(root && root.isWin) || WINDOWS_PATH_PATTERN.test(key); + if (!root) return normalizeWindowsSegments(key, isWin); + const relative = projectRelativeClassPath(key, root, isWin); + return relative === null ? normalizeWindowsSegments(key, isWin) : relative; + } catch (_) { + return filePath; + } +} + +/** Class of a hook target, judged on its project-relative path. */ +function classifyTargetFor(filePath, data) { + return classifyTarget(classPathFor(filePath, data)); +} + +const SEARCH_THE_TREE = '(search the tree — Glob/Grep, or find/grep via Bash)'; +const QUOTE_INSTRUCTION = "Quote the user's current instruction verbatim"; + +const QUESTION_TEXT = Object.freeze({ + importers: `List ALL files that import/require this file ${SEARCH_THE_TREE}`, + 'public-api': 'List the public functions/classes affected by this change', + 'local-callers': `List the call sites in this file or its module that rely on the changed behaviour ${SEARCH_THE_TREE}`, + callers: 'Name the file(s) and line(s) that will call this new file', + 'no-duplicate': `Confirm no existing file serves the same purpose ${SEARCH_THE_TREE}`, + 'data-schema': + 'If this file reads/writes data files, show field names, structure, and date format (use redacted or synthetic values, not raw production data)', + 'external-contract': + 'Name what outside this repository decides the format, units, timezone or protocol semantics here (the consumer, the producer, or a stated convention), or state that the choice is unconstrained', + loader: 'Name the harness/loader that reads this file (Claude Code, Codex, Cursor, OpenCode, …) and when it loads it', + 'behaviour-change': 'Describe what agent behaviour changes as a result', + 'no-duplicate-instruction': `Confirm no existing instruction, skill, or agent file already covers this ${SEARCH_THE_TREE}`, + 'under-test': 'Name what behaviour is under test and which module/function it exercises', + 'existing-tests': `Name the existing test file(s) covering this module, or confirm none exist ${SEARCH_THE_TREE}`, + supersedes: `Name any existing doc this supersedes or duplicates ${SEARCH_THE_TREE}`, + 'linked-from': 'State where it will be linked or referenced from', + 'why-new-file': 'Explain why a new file rather than editing an existing one', + references: `List other docs or code that reference the section being changed ${SEARCH_THE_TREE}`, + 'corrects-or-adds': 'State what the change corrects or adds', + 'config-reader': 'Name which process/tool reads this file and when', + 'config-effect': 'Describe the effect of the change', + 'no-plaintext-secrets': 'Confirm no secrets or credentials are being written in plain text', + 'quote-instruction': QUOTE_INSTRUCTION +}); + +const CLASS_QUESTION_IDS = { + instruction: () => ['loader', 'behaviour-change', 'no-duplicate-instruction'], + test: () => ['under-test', 'existing-tests'], + prose: isWrite => (isWrite ? ['supersedes', 'linked-from', 'why-new-file'] : ['references', 'corrects-or-adds']), + config: () => ['config-reader', 'config-effect', 'no-plaintext-secrets'] +}; + +const CLASS_QUESTIONS = Object.fromEntries( + Object.entries(CLASS_QUESTION_IDS).map(([cls, ids]) => [cls, isWrite => ids(isWrite).map(id => QUESTION_TEXT[id])]) +); + +// see docs/gateguard/change-profile.md#questions-from-the-change-profile +function codeQuestionIds(isWrite, profile) { + const known = Boolean(profile) && profile.known === true; + const surface = !known || profile.touchesPublicSurface !== false; + const data = !known || profile.touchesData !== false; + const opening = isWrite ? ['callers', 'no-duplicate'] : surface ? ['importers', 'public-api'] : ['local-callers']; + // A change that handles data is usually constrained by something outside the + // tree - a consumer's dialect, a reporting timezone, a protocol's semantics - + // and the repository cannot settle it. data-schema asks what the change + // touches; external-contract asks who decides what it has to be. + return data ? [...opening, 'data-schema', 'external-contract'] : opening; +} + +/** True when a class's questions depend on the change profile. */ +function questionsUseProfile(cls) { + return !Object.hasOwn(CLASS_QUESTION_IDS, cls); +} + +/** Stable ids of the first-touch questions for a class, action and change profile. */ +function questionIdsFor(cls, isWrite, profile) { + const ids = Object.hasOwn(CLASS_QUESTION_IDS, cls) ? CLASS_QUESTION_IDS[cls](Boolean(isWrite)) : codeQuestionIds(Boolean(isWrite), profile); + return [...ids, 'quote-instruction']; +} + +/** Question text for an id from `questionIdsFor`. */ +function questionText(id) { + return Object.hasOwn(QUESTION_TEXT, id) ? QUESTION_TEXT[id] : ''; +} + +const CODE_CONDENSED_HINT = + "briefly state importers/callers, affected API, data schemas if any, what outside the repository fixes the format or semantics, and the user's verbatim instruction, then retry."; + +const CONDENSED_PHRASES = Object.freeze({ + importers: 'the files that import this file', + 'public-api': 'the public functions/classes affected', + 'local-callers': 'the call sites in this file or its module that rely on the change', + callers: 'the file(s) and line(s) that will call it', + 'no-duplicate': 'that no existing file serves the same purpose', + 'data-schema': 'the data schemas it reads or writes', + 'external-contract': 'what outside the repository fixes the format or semantics', + 'quote-instruction': "the user's verbatim instruction" +}); + +/** Condensed-hint phrase for a code question id, or '' when it has none. */ +function condensedQuestionPhrase(id) { + return Object.hasOwn(CONDENSED_PHRASES, id) ? CONDENSED_PHRASES[id] : ''; +} + +function joinPhrases(phrases) { + if (phrases.length <= 2) return phrases.join(' and '); + return `${phrases.slice(0, -1).join(', ')}, and ${phrases[phrases.length - 1]}`; +} + +const CLASS_CONDENSED_HINTS = { + instruction: () => + "briefly state which harness loads this file, the agent behaviour it changes, that no existing instruction file covers it, and the user's verbatim instruction, then retry.", + test: () => + "briefly state the behaviour and module under test, existing test files for it (or none), and the user's verbatim instruction, then retry.", + prose: isWrite => + isWrite + ? "briefly state what this supersedes, where it is linked from, and the user's verbatim instruction, then retry." + : "briefly state what references the changed section, what the change corrects or adds, and the user's verbatim instruction, then retry.", + config: () => + "briefly state which process reads this file, the effect of the change, that no secrets are written in plain text, and the user's verbatim instruction, then retry." +}; + +/** One-line condensed hint matching `questionIdsFor`. */ +function condensedHintFor(cls, isWrite, profile) { + if (Object.hasOwn(CLASS_CONDENSED_HINTS, cls)) return CLASS_CONDENSED_HINTS[cls](Boolean(isWrite)); + if (!isWrite && !(profile && profile.known === true)) return CODE_CONDENSED_HINT; + const phrases = questionIdsFor(cls, isWrite, profile).map(condensedQuestionPhrase).filter(Boolean); + return `briefly state ${joinPhrases(phrases)}, then retry.`; +} + +// --- Sensitive targets --- +// see docs/gateguard/design-notes.md#sensitive-targets + +const SENSITIVE_EXTS = new Set(['.pem', '.key', '.p12', '.pfx']); +const SENSITIVE_BASENAME_PATTERN = /^(\.env($|\.)|id_rsa|id_ed25519|id_ecdsa|id_dsa|\.netrc$|\.pgpass$|credentials|secrets\.)/; +const SENSITIVE_SEGMENTS = new Set(['auth', 'authn', 'authz', 'security', 'secrets', 'payment', 'payments', 'billing', 'migrations']); +const SENSITIVE_PREFIX = '.github/workflows/'; + +/** True for a sensitive class path; any error or non-string input is sensitive. */ +function isSensitiveTarget(classPath) { + try { + if (typeof classPath !== 'string' || !classPath) return true; + const normalized = classPath.replace(/\\/g, '/').toLowerCase(); + const segments = normalized.split('/').filter(Boolean); + const base = segments[segments.length - 1] || ''; + if (SENSITIVE_BASENAME_PATTERN.test(base) || SENSITIVE_EXTS.has(path.posix.extname(base))) return true; + if (segments.some(segment => SENSITIVE_SEGMENTS.has(segment))) return true; + return `/${segments.join('/')}`.includes(`/${SENSITIVE_PREFIX}`); + } catch (_) { + return true; + } +} + +/** Sensitive on the lexical or the real (symlink-resolved) path; any error is sensitive. */ +function isSensitiveTargetFor(filePath, data) { + try { + if (typeof filePath !== 'string' || !filePath) return true; + if (isSensitiveTarget(classPathFor(filePath, data))) return true; + return isSensitiveRealTarget(filePath, data); + } catch (_) { + return true; + } +} + +function isSensitiveRealTarget(filePath, data) { + const target = resolveTargetPath(filePath, data); + if (!target || !isHostPathStyle(target.isWin)) return false; + const paths = target.isWin ? path.win32 : path.posix; + const real = realTargetPath(target.resolved, paths); + if (!real) return true; + const realKey = foldKey(real, target.isWin); + if (realKey === foldKey(target.resolved, target.isWin)) return false; + const root = canonicalProjectRoot(data); + if (root && root.isWin === target.isWin) { + const realRoot = realpathOfNearestAncestor(root.native, paths); + if (!realRoot) return true; + const realRootInfo = { ...root, key: foldKey(realRoot, target.isWin), native: realRoot }; + const realClassPath = projectRelativeClassPath(realKey, realRootInfo, target.isWin); + if (realClassPath !== null) return isSensitiveTarget(realClassPath); + } + return isSensitiveTarget(normalizeWindowsSegments(realKey, target.isWin)); +} + +// --- Hard-linked targets --- +// see docs/gateguard/design-notes.md#hard-linked-targets + +const MISSING_TARGET_CODES = new Set(['ENOENT', 'ENOTDIR']); + +function hasOtherLinks(stat) { + return !stat.isDirectory() && stat.nlink > 1; +} + +/** True when the target file has more than one hard link; a stat error other than a missing file counts as linked. */ +function isHardLinkedTargetFor(filePath, data) { + try { + const target = resolveTargetPath(filePath, data); + if (!target) return true; + if (!isHostPathStyle(target.isWin)) return false; + const stat = fs.lstatSync(target.resolved); + if (!stat.isSymbolicLink()) return hasOtherLinks(stat); + return hasOtherLinks(fs.statSync(target.resolved)); + } catch (error) { + return !(error && MISSING_TARGET_CODES.has(error.code)); + } +} + +// --- Sibling collapse eligibility --- +// see docs/gateguard/design-notes.md#sibling-collapse + +const COLLAPSIBLE_CLASSES = new Set(['code', 'test', 'prose']); +const SHORT_NAME_PATTERN = /~\d/; + +function isCollapsibleClassPath(classPath, cls) { + if (!COLLAPSIBLE_CLASSES.has(cls)) return false; + const segments = String(classPath).toLowerCase().split('/').filter(Boolean); + if (segments.length === 0) return false; + return !segments.some(segment => segment.startsWith('.') || SHORT_NAME_PATTERN.test(segment)); +} + +/** Lexical collapse screen; `collapseGateDir` adds the real-directory screen. */ +function isCollapsibleTarget(filePath, data, cls) { + return isCollapsibleClassPath(classPathFor(filePath, data), cls); +} + +function realpathOfNearestAncestor(nativePath, paths) { + const realpath = fs.realpathSync.native || fs.realpathSync; + const missing = []; + let current = nativePath; + for (;;) { + try { + const real = realpath(current); + if (!fs.statSync(real).isDirectory()) return null; + return missing.length > 0 ? paths.join(real, ...missing.reverse()) : real; + } catch (error) { + if (!error || error.code !== 'ENOENT' || !isMissingEntry(current)) return null; + const parent = paths.dirname(current); + if (parent === current) return null; + missing.push(paths.basename(current)); + current = parent; + } + } +} + +function realTargetPath(resolved, paths) { + const realpath = fs.realpathSync.native || fs.realpathSync; + try { + return realpath(resolved); + } catch (error) { + if (!error || error.code !== 'ENOENT' || !isMissingEntry(resolved)) return null; + } + const realDir = realpathOfNearestAncestor(paths.dirname(resolved), paths); + return realDir ? paths.join(realDir, paths.basename(resolved)) : null; +} + +function isMissingEntry(nativePath) { + try { + fs.lstatSync(nativePath); + return false; + } catch (error) { + return Boolean(error) && error.code === 'ENOENT'; + } +} + +/** Real directory a new file's sibling gate is keyed by, or null when it may not collapse. */ +function collapseGateDir(filePath, data, cls) { + try { + if (!isCollapsibleTarget(filePath, data, cls)) return null; + const target = resolveTargetPath(filePath, data); + if (!target || !isHostPathStyle(target.isWin)) return null; + const paths = target.isWin ? path.win32 : path.posix; + const lexicalDir = paths.dirname(target.resolved); + const realDir = realpathOfNearestAncestor(lexicalDir, paths); + if (!realDir) return null; + const realKey = foldKey(realDir, target.isWin); + if (realKey === foldKey(lexicalDir, target.isWin)) return realKey; + const root = canonicalProjectRoot(data); + if (!root || root.isWin !== target.isWin) return null; + const realRoot = realpathOfNearestAncestor(root.native, paths); + if (!realRoot) return null; + const realTargetKey = foldKey(paths.join(realDir, paths.basename(target.resolved)), target.isWin); + const realClassPath = projectRelativeClassPath(realTargetKey, { ...root, key: foldKey(realRoot, target.isWin), native: realRoot }, target.isWin); + if (realClassPath === null || classifyTarget(realClassPath) !== cls) return null; + if (isSensitiveTarget(realClassPath)) return null; + return isCollapsibleClassPath(realClassPath, cls) ? realKey : null; + } catch (_) { + return null; + } +} + +module.exports = { + questionsUseProfile, + WINDOWS_PATH_PATTERN, + COLLAPSIBLE_CLASSES, + CLASS_QUESTIONS, + CLASS_CONDENSED_HINTS, + QUOTE_INSTRUCTION, + questionIdsFor, + questionText, + condensedHintFor, + condensedQuestionPhrase, + resolveTargetPath, + canonicalPathKey, + classifyTarget, + classPathFor, + classifyTargetFor, + isCollapsibleTarget, + collapseGateDir, + isSensitiveTarget, + isSensitiveTargetFor, + isHardLinkedTargetFor +}; diff --git a/scripts/lib/gateguard-turn-scan.js b/scripts/lib/gateguard-turn-scan.js new file mode 100644 index 000000000..26cf20041 --- /dev/null +++ b/scripts/lib/gateguard-turn-scan.js @@ -0,0 +1,280 @@ +'use strict'; + +const crypto = require('crypto'); +const fs = require('fs'); +const path = require('path'); +const { readFileTail, DEFAULT_TRANSCRIPT_TAIL_BYTES } = require('./file-tail'); + +const TRANSCRIPT_SCAN_MAX_LINES = 2000; +// see docs/gateguard/design-notes.md#read-is-not-evidence +const SEARCH_TOOL_NAMES = new Set(['Glob', 'Grep', 'LS', 'Bash', 'PowerShell']); +const SHELL_TOOL_NAMES = new Set(['Bash', 'PowerShell']); +const TOOL_USE_ERROR_PREFIX = ''; + +function isObject(value) { + return Boolean(value) && typeof value === 'object'; +} + +// see docs/gateguard/design-notes.md#fail-to-deny +function neverThrows(fn, fallback) { + return (...args) => { + try { + return fn(...args); + } catch (_) { + return fallback; + } + }; +} + +/** Transcript path from the hook payload or CLAUDE_TRANSCRIPT_PATH; '' when none. */ +function transcriptPathFor(data) { + const candidate = (isObject(data) && (data.transcript_path || data.transcriptPath)) || process.env.CLAUDE_TRANSCRIPT_PATH; + return typeof candidate === 'string' ? candidate : ''; +} + +function pendingToolUseId(data) { + return isObject(data) && typeof data.tool_use_id === 'string' ? data.tool_use_id : ''; +} + +function recordContent(record) { + const message = isObject(record) ? record.message : undefined; + return isObject(message) ? message.content : undefined; +} + +function isSkippedTranscriptRecord(record) { + return ( + !isObject(record) || + Array.isArray(record) || + record.isSidechain === true || + record.isMeta === true || + (record.type !== 'user' && record.type !== 'assistant') + ); +} + +/** True for a compaction summary or boundary record. */ +function isCompactionBoundary(record) { + return ( + Boolean(record) && + typeof record === 'object' && + record.isSidechain !== true && + (record.isCompactSummary === true || (record.type === 'system' && record.subtype === 'compact_boundary')) + ); +} + +const MAX_PROMPT_ID_CHARS = 128; + +function recordPromptId(record) { + const id = isObject(record) ? record.promptId : undefined; + return typeof id === 'string' && id && id.length <= MAX_PROMPT_ID_CHARS ? id : null; +} + +function boundaryTurnId(record, line) { + if (typeof record.uuid === 'string' && record.uuid) return record.uuid; + return `h:${crypto.createHash('sha256').update(line).digest('hex').slice(0, 16)}`; +} + +/** True for a user record made only of `tool_result` blocks. */ +// see docs/gateguard/design-notes.md#turn-boundaries +function isToolResultRecord(record) { + const content = recordContent(record); + return ( + Array.isArray(content) && + content.length > 0 && + content.every(block => block && typeof block === 'object' && block.type === 'tool_result') + ); +} + +function toolResultText(content) { + if (typeof content === 'string') return content; + if (!Array.isArray(content)) return ''; + const first = content.find(block => block && typeof block === 'object' && block.type === 'text'); + return first && typeof first.text === 'string' ? first.text : ''; +} + +function isErrorToolResult(block) { + if (!isObject(block)) return false; + if (block.is_error === true || block.is_error === 'true') return true; + return toolResultText(block.content).trimStart().startsWith(TOOL_USE_ERROR_PREFIX); +} + +/** Scan the transcript tail back to the start of the current turn; null when unavailable. */ +function scanCurrentTurn(transcriptPath, pendingId = '') { + if (typeof transcriptPath !== 'string' || !transcriptPath) return null; + let stat; + try { + stat = fs.statSync(transcriptPath); + } catch (_) { + return null; + } + if (!stat.isFile()) return null; + const tail = readFileTail(transcriptPath, DEFAULT_TRANSCRIPT_TAIL_BYTES); + if (!tail) return null; + + const lines = tail.text.split('\n'); + const firstLine = Math.max(tail.truncated ? 1 : 0, lines.length - TRANSCRIPT_SCAN_MAX_LINES); + const okResultIds = new Set(); + const toolUses = []; + const batchIds = new Map(); + let newestMessageId = null; + const windowPromptIds = new Set(); + const finish = turnId => ({ + turnId, + searches: collectTurnSearches(toolUses, okResultIds, pendingId), + batchIds, + newestMessageId, + shellCommands: collectShellCommands(toolUses), + reads: collectTurnReads(toolUses, pendingId) + }); + + for (let i = lines.length - 1; i >= firstLine; i--) { + const line = lines[i].trim(); + if (!line) continue; + let record; + try { + record = JSON.parse(line); + } catch (_) { + continue; + } + if (isCompactionBoundary(record)) return finish(recordPromptId(record) || boundaryTurnId(record, line)); + if (isObject(record) && record.type === 'user' && record.isSidechain !== true) { + const id = recordPromptId(record); + if (id) windowPromptIds.add(id); + } + if (isSkippedTranscriptRecord(record)) continue; + + if (record.type === 'user') { + if (!isToolResultRecord(record)) return finish(recordPromptId(record) || boundaryTurnId(record, line)); + for (const block of recordContent(record)) { + if (typeof block.tool_use_id === 'string' && !isErrorToolResult(block)) { + okResultIds.add(block.tool_use_id); + } + } + continue; + } + + if (!isObject(record.message)) continue; + const content = recordContent(record); + const messageId = typeof record.message.id === 'string' && record.message.id ? record.message.id : null; + if (newestMessageId === null && messageId) newestMessageId = messageId; + if (!Array.isArray(content)) continue; + for (let j = content.length - 1; j >= 0; j--) { + const block = content[j]; + if (!block || typeof block !== 'object' || block.type !== 'tool_use') continue; + toolUses.push({ block, messageId }); + if (messageId && typeof block.id === 'string') batchIds.set(block.id, messageId); + } + } + // see docs/gateguard/design-notes.md#turn-identity + if ((tail.truncated || firstLine > 0) && windowPromptIds.size <= 1) { + return finish(windowPromptIds.size === 1 ? [...windowPromptIds][0] : null); + } + return { turnId: null, searches: [], batchIds: new Map(), newestMessageId: null, shellCommands: [], reads: [] }; +} + +function collectTurnSearches(toolUsesNewestFirst, okResultIds, pendingId) { + const idCounts = new Map(); + for (const { block } of toolUsesNewestFirst) { + if (typeof block.id === 'string') idCounts.set(block.id, (idCounts.get(block.id) || 0) + 1); + } + const searches = []; + const priorCalls = toolUsesNewestFirst.filter(({ block }) => !pendingId || block.id !== pendingId); + priorCalls.forEach(({ block, messageId }, index) => { + if (typeof block.id !== 'string' || !okResultIds.has(block.id)) return; + // see docs/gateguard/design-notes.md#tool-result-pairing + if (idCounts.get(block.id) !== 1) return; + if (!SEARCH_TOOL_NAMES.has(block.name)) return; + if (!block.input || typeof block.input !== 'object') return; + searches.push({ name: block.name, input: block.input, callsAgo: index + 1, messageId }); + }); + return searches; +} + +function collectTurnReads(toolUsesNewestFirst, pendingId) { + const reads = []; + toolUsesNewestFirst + .filter(({ block }) => !pendingId || block.id !== pendingId) + .forEach(({ block }, index) => { + if (block.name !== 'Read' || !isObject(block.input) || typeof block.input.file_path !== 'string') return; + reads.push({ name: 'Read', path: block.input.file_path, callsAgo: index + 1 }); + }); + return reads; +} + +function collectShellCommands(toolUses) { + return toolUses + .filter(({ block }) => SHELL_TOOL_NAMES.has(block.name) && block.input && typeof block.input === 'object') + .map(({ block }) => (typeof block.input.command === 'string' ? block.input.command : '')); +} + +/** Message id of the pending call's batch, whose searches never credit. */ +function excludedBatchId(scan, data) { + if (!isObject(scan)) return null; + const pendingId = pendingToolUseId(data); + const located = pendingId && scan.batchIds instanceof Map ? scan.batchIds.get(pendingId) : undefined; + if (located) return located; + return typeof scan.newestMessageId === 'string' ? scan.newestMessageId : null; +} + +/** Lazily scans once per hook invocation, shared by every MultiEdit entry. */ +function createTurnScanner(data) { + let scanned = false; + let scan = null; + return () => { + if (!scanned) { + scanned = true; + try { + scan = scanCurrentTurn(transcriptPathFor(data), pendingToolUseId(data)); + } catch (_) { + scan = null; + } + } + return scan; + }; +} + +// see docs/gateguard/design-notes.md#search-prefilter +function targetNeedle(filePath) { + const base = (String(filePath).split(/[\\/]/).pop() || '').toLowerCase(); + const ext = path.posix.extname(base); + let stem = ext ? base.slice(0, -ext.length) : base; + if (stem.endsWith('.test') || stem.endsWith('.spec')) stem = stem.slice(0, -5); + if (stem.startsWith('test_')) stem = stem.slice(5); + if (stem.endsWith('_test')) stem = stem.slice(0, -5); + return stem; +} + +/** False only when no search of the turn can name the target's stem, so matching can be skipped. */ +function searchesMayNameTarget(scan, filePath) { + if (!scan || !Array.isArray(scan.searches) || scan.searches.length === 0) return false; + const needle = targetNeedle(filePath); + if (!needle) return true; + return scan.searches.some(search => { + const input = search && search.input; + if (!isObject(input)) return false; + let text = ''; + for (const value of Object.values(input)) { + if (typeof value === 'string') text += `\n${value}`; + } + return text.toLowerCase().replace(/["'\\]/g, '').includes(needle); + }); +} + +function currentTurnId(scan) { + return scan && typeof scan.turnId === 'string' && scan.turnId ? scan.turnId : null; +} + +module.exports = { + TRANSCRIPT_SCAN_MAX_LINES, + SEARCH_TOOL_NAMES, + SHELL_TOOL_NAMES, + transcriptPathFor: neverThrows(transcriptPathFor, ''), + pendingToolUseId: neverThrows(pendingToolUseId, ''), + isCompactionBoundary: neverThrows(isCompactionBoundary, false), + isToolResultRecord: neverThrows(isToolResultRecord, false), + isErrorToolResult: neverThrows(isErrorToolResult, false), + scanCurrentTurn: neverThrows(scanCurrentTurn, null), + excludedBatchId: neverThrows(excludedBatchId, null), + createTurnScanner: neverThrows(createTurnScanner, () => null), + currentTurnId: neverThrows(currentTurnId, null), + searchesMayNameTarget: neverThrows(searchesMayNameTarget, true) +}; diff --git a/scripts/lib/transcript-context.js b/scripts/lib/transcript-context.js index 853a35b2a..87c6bb2d4 100644 --- a/scripts/lib/transcript-context.js +++ b/scripts/lib/transcript-context.js @@ -17,14 +17,13 @@ * keeping the PreToolUse hook fast even for very large sessions. */ -const fs = require('fs'); +const { readFileTail, DEFAULT_TRANSCRIPT_TAIL_BYTES } = require('./file-tail'); const STANDARD_CONTEXT_WINDOW_TOKENS = 200000; const LARGE_CONTEXT_WINDOW_TOKENS = 1000000; const DEFAULT_CONTEXT_THRESHOLD_STANDARD = 160000; const DEFAULT_CONTEXT_THRESHOLD_LARGE = 250000; const DEFAULT_CONTEXT_INTERVAL_TOKENS = 60000; -const DEFAULT_TRANSCRIPT_TAIL_BYTES = 256 * 1024; const MAX_TOKEN_SETTING = 10000000; const LARGE_WINDOW_MODEL_MARKER = '[1m]'; @@ -59,38 +58,6 @@ function isKnownModelFamilyMatch(model, familyId) { * Read the trailing `tailBytes` of a file as UTF-8. * Returns null when the file is missing or unreadable. */ -function readFileTail(filePath, tailBytes) { - let fd; - try { - fd = fs.openSync(filePath, 'r'); - } catch { - return null; - } - - try { - const size = fs.fstatSync(fd).size; - const start = Math.max(0, size - tailBytes); - const length = size - start; - if (length <= 0) { - return { text: '', truncated: false }; - } - - const buffer = Buffer.alloc(length); - const bytesRead = fs.readSync(fd, buffer, 0, length, start); - return { - text: buffer.toString('utf8', 0, bytesRead), - truncated: start > 0 - }; - } catch { - return null; - } finally { - try { - fs.closeSync(fd); - } catch { - /* ignore */ - } - } -} /** * Extract the context token total from a transcript record's usage block. @@ -281,6 +248,7 @@ module.exports = { DEFAULT_CONTEXT_THRESHOLD_LARGE, DEFAULT_CONTEXT_INTERVAL_TOKENS, DEFAULT_TRANSCRIPT_TAIL_BYTES, + readFileTail, readLatestContextTokens, resolveContextWindow, resolveContextWindowTokens, diff --git a/skills/gateguard/SKILL.md b/skills/gateguard/SKILL.md index 62f9bff18..e6ebcc1f7 100644 --- a/skills/gateguard/SKILL.md +++ b/skills/gateguard/SKILL.md @@ -1,6 +1,6 @@ --- name: gateguard -description: "PreToolUse fact-forcing gate that denies the first Edit/Write/Bash (including MultiEdit) attempt until the agent presents concrete facts (importers, data schemas, verbatim user instruction), then allows retry; A/B-tested at +2.25 quality points. Use when enabling or configuring the GateGuard hook, exempting paths via env vars, or handling first-touch denials." +description: "PreToolUse fact-forcing gate that denies the first Edit/Write/Bash (including MultiEdit and NotebookEdit) attempt until the agent presents concrete facts (importers, data schemas, verbatim user instruction), then allows retry; A/B-tested at +2.25 quality points. Use when enabling or configuring the GateGuard hook, exempting paths via env vars, or handling first-touch denials." metadata: origin: community --- @@ -48,7 +48,15 @@ Both agents produce code that runs and passes tests. The difference is design de ### Edit / MultiEdit Gate (first edit per file) -MultiEdit is handled identically — each file in the batch is gated individually. +MultiEdit is handled identically — each file in the batch is gated individually; +entries without their own `file_path` are gated as the call's `file_path`. +NotebookEdit is gated as an Edit of its `notebook_path`: same class, checked +key, credit and cap rules, the full questions (the cell change is not read), +and never a comment-only pass. + +The questions depend on the target's class (see +[Questions by target class](#questions-by-target-class)). For **code** +targets the Edit gate asks: ``` Before editing {file_path}, present these facts: @@ -60,8 +68,16 @@ Before editing {file_path}, present these facts: 4. Quote the user's current instruction verbatim ``` +These four are the full set, asked whenever the change cannot be read with +confidence. When it can (see [Questions fit the change](#questions-fit-the-change)), +an edit that touches no public declaration asks instead for the call sites in +the file or its module that rely on the changed behaviour, and item 3 is +dropped when the change handles no data. + ### Write Gate (first new file creation) +For **code** targets the Write gate asks: + ``` Before creating {file_path}, present these facts: @@ -72,6 +88,101 @@ Before creating {file_path}, present these facts: 4. Quote the user's current instruction verbatim ``` +Item 3 is dropped when the new content handles no data. + +### Questions fit the change + +For **code** targets the gate reads the change it is about to allow (Edit +`old_string`/`new_string`, every MultiEdit entry for the file, Write +`content`) and decides three things: + +- **Public surface:** a line on either side declares or exposes a public name. + JS/TS: `export`, `exports`, `public`, `declare` (`.d.ts` files always); Python: + `def`/`class` not starting with `_`, dunders, `__all__`, a module-level public + assignment such as `BASE_URL = ...` (`__init__.py` always); Go: capitalised + `func`/`type`/`var`/`const` names, `package`, lines starting with a capital; + Rust: `pub`, `impl`, `trait`, `extern`, `#[macro_export]`, `#[derive]`, + `#[repr]`, `#[no_mangle]`; shell: function definitions, + `export`, `declare -x`; PowerShell: `function`, `filter`, `param`, + `Export-ModuleMember`, `[CmdletBinding`. A member of an exported container + counts too, judged from the file: lines inside an exported interface, enum, + type, object or export list, member declarations of an exported class, + `__all__` entries, public class attributes and dataclass fields, and members + of a `pub` enum, struct, trait or `use` list. Java, Kotlin, C#, C, C++ and batch + files, and every Write, always count as touching it. Touching it keeps questions 1 and 2; otherwise both are + replaced by "List the call sites in this file or its module that rely on the + changed behaviour". +- **Data:** words for file I/O, formats (`json`, `csv`, `yaml`, …), + serialisation, encodings, schemas, SQL, data stores, browser storage, dates + and clock reads on either side; in shell scripts also + redirection to a file, `curl`, `wget`, `jq` and PowerShell file and web + cmdlets. Without them the data + question is dropped. +- **Trivial:** see [Comment and whitespace-only edits](#comment-and-whitespace-only-edits). + +Supported extensions: `.js .mjs .cjs .jsx .ts .tsx .mts .cts .py .pyi .go .rs +.java .kt .kts .cs .c .h .cc .cpp .cxx .hpp .hh .hxx .sh .bash .zsh .ps1 .psm1 +.bat .cmd`. Any other extension, a +missing or non-string field, more than 64 MultiEdit entries, a side over +64 KiB, or 256 KiB in total means the full questions, word for word. Sensitive +and hard-linked targets and NotebookEdit calls always get the full questions. Other classes keep their class +questions. Each question has a stable id (`importers`, `public-api`, +`local-callers`, `callers`, `no-duplicate`, `data-schema`, +`quote-instruction`, and one per class question), listed in +`docs/gateguard/change-profile.md`. + +### Questions by target class + +The code questions carry no signal for a `SKILL.md`, a README, a test, or a +YAML file, so the first-touch gate classifies the target first. + +Classification uses the target's path relative to the project root +(`CLAUDE_PROJECT_DIR`, then the payload `cwd`, then the process directory), +so a project that lives under, say, `~/work/tests/` does not turn every file +into a test. A leading `.claude/worktrees//` is stripped, since a +worktree mirrors the project, but only when +`/.claude/worktrees//.git` exists; otherwise the path stays under +`.claude`. On Windows-style paths (and any segment containing `::$`), trailing +dots/spaces and a `:stream` suffix are dropped first, so `CLAUDE.md.` and +`CLAUDE.md::$DATA` classify as `CLAUDE.md`. Targets outside the root are +classified by their absolute path. The path is lowercased and `/`-separated, +and the first match wins: + +1. **instruction** — basename `CLAUDE.md`, `AGENTS.md`, `AGENT.md`, + `GEMINI.md`, `SKILL.md`, `copilot-instructions.md`, `.cursorrules`, or + `.windsurfrules`; any `.mdc` file; a `.md`/`.mdx`/`.txt` file under a + `.claude`, `agents`, `commands`, `skills`, `rules`, `hooks`, `.cursor`, + `.codex`, or `.opencode` directory; or a `*instructions*.md` file under + `.github/` +2. **test** — `*.test.*`, `*.spec.*`, `test_*.py`, `*_test.py`, `*_test.go`, + or anything under a `tests`, `test`, or `__tests__` directory +3. **prose** — `.md`, `.mdx`, `.txt`, `.rst`, `.adoc` +4. **config** — `.json`, `.jsonc`, `.yaml`, `.yml`, `.toml`, `.ini`, or a + `.env` / `.env.*` file that does not end in a code extension (`.env.local` + is config; `.env.example.ts` and `.envrc` are code) +5. **code** — everything else (a `.js` under `hooks/` or `skills/` is code) + +Every class ends with "Quote the user's current instruction verbatim", keeps +the batch-sibling warning, and uses the same questions for Edit, MultiEdit, +and Write unless noted. Items marked *(search)* carry the suffix +"(search the tree — Glob/Grep, or find/grep via Bash)". + +| Class | Questions | +|---|---| +| instruction | Name the harness/loader that reads this file (Claude Code, Codex, Cursor, OpenCode, …) and when it loads it · Describe what agent behaviour changes as a result · Confirm no existing instruction, skill, or agent file already covers this *(search)* | +| test | Name what behaviour is under test and which module/function it exercises · Name the existing test file(s) covering this module, or confirm none exist *(search)* | +| prose (Write) | Name any existing doc this supersedes or duplicates *(search)* · State where it will be linked or referenced from · Explain why a new file rather than editing an existing one | +| prose (Edit) | List other docs or code that reference the section being changed *(search)* · State what the change corrects or adds | +| config | Name which process/tool reads this file and when · Describe the effect of the change · Confirm no secrets or credentials are being written in plain text | + +Condensed denials (after `GATEGUARD_FACT_FORCE_FULL_DENIALS`) carry a +one-line hint for the same class. For code targets the hint names the same +questions as the full denial, one short phrase each; an Edit whose change +cannot be read keeps the fixed hint (importers/callers, affected API, data +schemas if any). Besides the message text, the class only +decides [sibling collapse](#same-turn-sibling-creation-collapse): which files +may collapse, and that a sibling must share the first file's class. + ### Destructive Bash Gate (every destructive command) Triggers on: `rm -rf`, `git reset --hard`, `git push --force`, `drop table`, etc. @@ -89,6 +200,23 @@ Triggers on: `rm -rf`, `git reset --hard`, `git push --force`, `drop table`, etc 2. What this specific command verifies or produces ``` +Read-only introspection does not use this gate up: until it fires, a command +whose every `;`, `|`, `&&` or `||` segment is `ls`, `pwd`, `cat`, `head`, +`tail`, `wc`, `grep`, `rg`, `find`, `fd`, `tree`, or `git status`/`log`/ +`diff`/`show`/`ls-files`/`rev-parse`/`branch` with read-only options (in +PowerShell: `Get-ChildItem`, `Get-Content`, `Select-String`, `Get-Location`, +their aliases, `rg`, `git`) passes and is counted in +`routine_readonly_passes`. The first other command is still gated. Any +redirection, `tee`, substitution, backtick, variable, environment +assignment, wrapper (`env`, `sudo`, `xargs`, `sh -c`), unknown command, +`find -exec`/`-delete`, `fd -x`, `rg --pre`/`-z`, `tree -o`, `git` global option or +write/execute option, or PowerShell `$`, `@`, `(`, `{` or `--%` makes the +command ordinary. Destructive detection runs first, unchanged. + +Session state expires after 8 idle hours when it is keyed by a session id or +transcript path, and after 30 idle minutes when it falls back to the project +directory. + ## Parallel Batches and Partial Application The first-touch gate evaluates each tool call independently. When several @@ -109,6 +237,318 @@ already have been applied. Treat it literally: A batch-wide lock is not possible: hooks see tool calls one at a time, so the gate cannot know which calls arrived together. +## What Counts as Already Done + +A first-touch denial is meant to force investigation. When the transcript +shows the investigation already happened, or the answers would repeat the +previous denial word for word, the gate lets the call through with a note +instead of a denial. Each rule below falls back to the normal denial when +anything is ambiguous. + +### Order of checks + +For each Edit/Write/NotebookEdit target (and each MultiEdit entry) the gate +decides in this order; the first rule that applies wins: + +1. **Exempt** (`GATEGUARD_EXEMPT_GLOBS`, Claude settings files) — allowed. + Operator exemptions are authoritative: they win over every later rule, + including the sensitive and hard-link rules, so a glob that covers + `src/auth/**` or a hard-linked file turns its check off. Exempt only + paths that may be edited without the check. +2. **Subagent** call — allowed (the parent session was already gated), + except a [sensitive](#sensitive-targets) or + [hard-linked](#hard-linked-targets) target: denied once per path + unless the parent already gated it. A subagent's retry does not unlock + the path for the parent. +3. **Already checked** this session — allowed. +4. **Sensitive or hard-linked target?** — if so, skip straight to the + denial (step 9). +5. **Prior-search credit** — allowed with a note. +6. **Comment or whitespace-only Edit** — allowed with a note, not marked + checked. +7. **Sibling collapse** — allowed with a note. +8. **Denial cap** (`GATEGUARD_FACT_FORCE_MAX_DENIALS`, opt-in) — passes + through once the session's denials have reached the cap. +9. **Deny** — counted in `fact_force_denials`. + +Credit, trivial and sibling allows never consume the denial cap. + +### Sensitive targets + +Some files are too costly to change without the full check, so prior-search +credit, the comment or whitespace-only pass, the change profile, sibling +collapse, and the denial cap never apply to them: they are +denied on first touch exactly as without these rules, and the denial counts +as usual. The denial carries one extra line: "Sensitive target: prior-search +credit, sibling collapse, and the denial cap do not apply." A target is +sensitive, judged on the same lowercased project-relative path as its class +**and** on its real (symlink-resolved) location, when either matches: + +- its file name is `.env`, `.netrc`, or `.pgpass`, starts with `.env.`, ends + in `.pem`, `.key`, `.p12`, or `.pfx`, or starts with `id_rsa`, `id_ed25519`, + `id_ecdsa`, `id_dsa`, `credentials`, or `secrets.`; +- any path segment is exactly `auth`, `authn`, `authz`, `security`, + `secrets`, `payment`, `payments`, `billing`, or `migrations` (whole + segments only: `src/author.py`, `docs/authoring.md`, and + `lib/paymentutils.py` are ordinary); +- or it lies under `.github/workflows/`. + +The real location is the file's own realpath when it exists, else the +realpath of its nearest existing directory plus the rest of the path, judged +relative to the realpath of the project root, so `src/tools -> ../auth` makes +`src/tools/login.py` sensitive. A real location outside the project is judged +on its absolute path. Any error while deciding (a dangling symlink, a file +where a directory should be, a permission error) counts as sensitive. +Destructive and routine shell gates are unaffected. + +### Hard-linked targets + +A file with more than one hard link (`nlink > 1`, or a symlink to such a file) +is also reachable under another name, which may be sensitive or in another +class, so the path being edited says little about what changes. It gets the +same treatment as a sensitive target — no prior-search credit, change profile, +comment-only pass, sibling collapse, denial cap, or subagent bypass — and its +denial carries "Hard-linked target: prior-search credit, sibling collapse, and +the denial cap do not apply." instead of the sensitive line (a target that is +both says "Sensitive target"). A stat error other than a missing file counts as +hard-linked; a new file that does not exist yet is not. + +### Prior-search credit + +An Edit, Write, NotebookEdit, or MultiEdit entry is allowed, with an +`additionalContext` note naming the search, when a qualifying search in the +**current human turn** already covered the file: + +- **Qualifying tools:** `Glob`, `Grep`, `LS`, and Bash/PowerShell command + segments led by `rg`, `grep`, `egrep`, `fgrep`, `git grep`, + `git ls-files`, `find`, `fd`, `ls`, `tree`, `Get-ChildItem`, `gci`, + `Select-String`, or `sls`. **`Read` never counts** — Claude Code already + requires a Read before an Edit, so it proves nothing extra. +- **Current human turn:** the turn starts at the latest real user message or + at a compaction (a compact-summary record or a `compact_boundary` marker), + so searches from before a compaction never count. Tool results, meta, and + sidechain records do not start a turn. Only the transcript tail is read + (last 256 KiB / 2000 lines); when the turn started before that window, the + whole window is the current turn, provided every `promptId` in it agrees. + Two or more `promptId`s there mean a turn started through a record the scan + does not recognise, so that window gives no credit. A transcript with no + turn start at all gives no credit. +- **Completed, unambiguous result:** the search needs a `tool_result` in the + turn that is not an error (`is_error` of `true` or `"true"`, or content + starting with ``). A tool-use id that appears more than once + never counts. +- **Not the same batch:** searches sent in the gated call's own assistant + message never count, since their results were not seen when the edit was + decided. If the payload's `tool_use_id` is missing or not in the + transcript, the newest assistant message in the turn is excluded instead. A + search without a message id never counts. +- **Stem match:** the target's stem (basename minus its last extension, + lowercased, at least 4 characters) must appear as a whole word — bounded by + non-alphanumeric characters or the ends of the text — in the Glob pattern, + the Grep pattern or glob, or the shell segment. `payment.py` is credited by + a search for `payment` or `payment_service`, but not by one for + `payments`. Exclusions are never a stem source. Generic stems (`index`, `main`, `init`, `__init__`, + `utils`, `util`, `types`, `readme`, `test`, `tests`, `config`, `mod`, + `lib`, `setup`, `app`, `spec`, `helpers`, `common`) never match. For a test + target, one test affix is stripped first (`.test`, `.spec`, and for Python + and Go `test_` or `_test`), so `rg tokenizer src tests` credits + `tests/tokenizer.test.js`; `index.test.js` still reduces to the generic + `index`. +- **Exclusions never credit:** a Grep `glob` is split on top-level commas; + entries starting with `!` are exclusions. In shell segments, `rg -g`/ + `--glob`/`--iglob` values starting with `!`, `grep --exclude`/ + `--exclude-dir`, `fd -E`/`--exclude`, `ls -I`/`--ignore`/`--hide`, + `tree -I`, `git ls-files -x`, git `:!x`/`:^x`/`:(exclude)x` pathspecs, + `find -name`/`-path`/… under `-not` or `!` or followed by `-prune`, and + PowerShell `-Exclude` are exclusions. If an exclusion covers the target + (its glob matches the file name or any directory on its path, or it names + the stem), that search cannot credit the target at all: `rg -g '!widget.py' + TODO .` never credits `widget.py`, while `rg -g '!*.md' widget .` still + does. `--exclude-from`, `--ignore-file`, and `git ls-files -X` hide names + the hook cannot see, so such a segment never credits. File-name globs that + restrict a search (Grep `glob: '*.py'`, `rg -g '*.py'`, `grep --include`, + `find -name`, `tree -P`, `-Include`/`-Filter`) must match the target's file + name for its stem to count. +- **Scope:** `Glob`, `Grep`, and `LS` only credit a target strictly inside the + directory they searched (their `path`, else the tool `cwd`, plus a Glob + pattern's literal leading directories). A Glob without glob characters is a + one-file lookup: it names no directory and credits only files directly in + its directory, never the looked-up file itself. A search whose path is the target is not a search. +- **Shell segments:** a segment without a path operand counts only when it is + recursive (`rg`, `git grep`, `git ls-files`, `find`, `fd`, and `tree` by + default; `grep -r`/`-R`/`--recursive`, `ls -R`, `Get-ChildItem -Recurse`) + and not fed by a pipe; otherwise it reads stdin, not the tree. A segment + whose path operands all resolve to the target only reads the target and + does not count (`ls src/a.py` never credits `src/a.py`). Commands containing + `$( )`, backticks, `<( )`, `>( )`, or heredocs, or longer than 8192 + characters, never credit. +- **Shell scope:** a shell segment only credits a target equal to or inside + one of its path operands (resolved against the tool `cwd`; a glob operand + counts as its literal directory), or inside the `cwd` when it has none: + `rg payment docs` or `grep -rn payment /usr/share/doc` never credit + `src/payment.py`. An input redirection before any operand + (`rg payment < file`) or a `<` inside a word reads stdin and never counts. + After `cd ` in the same command, relative operands resolve against that + directory; after any other directory change, or one in another call of the + turn, only absolute operands count. +- **Directory match (new files only):** a **Write that creates a file that + does not exist yet** may also match when the search named exactly the + target's directory: a Glob `path` plus the pattern's literal prefix (a + pattern that starts with a glob character and has no `path` names no + directory), a Grep or LS `path`, or a shell path operand. For shell + operands, the pattern argument of grep-style tools is skipped (unless + `-e`/`-f`/`--regexp`/`--file`/`-Pattern` supplied the pattern), values of + value-taking flags (`-g`, `--include`, `-name`, `-Filter`, …) are skipped, + trailing `/`, `/*`, and `/**` are dropped, `.`, `~`/`$` paths, and globs + name no directory, and the operand must be an existing directory at the + time of the check. If any Bash/PowerShell call in the turn changes directory + (`cd`, `pushd`, `popd`, `chdir`, `Set-Location`, `sl`, `Push-Location`, + `Pop-Location`), shell directory matching is off for the whole turn; stem + matching still applies. **Edit, MultiEdit, and a Write that overwrites an + existing file match by stem only.** +- **Paths:** relative targets and search paths resolve against the tool's + `cwd`, then `CLAUDE_PROJECT_DIR`, then the process directory. Comparison is + case-insensitive for Windows paths only. Shell commands are read with bash + quoting rules, so on Windows an operand credits when spelled `C:/x/src`; + backslash spellings (`C:\x\src`, quoted or not) and MSYS spellings + (`/c/x/src`) do not credit and the write is denied as usual. +- **Note, not denial:** the credited file is marked checked and + `fact_force_credited` is incremented; the denial count and ordinal are not + touched. MultiEdit credits per entry and denies the first uncredited entry + as before. +- **Falls back to deny:** a missing, unreadable, or non-file transcript, + garbage records, or any internal error mean no credit. +- **Closest miss in the denial:** when an ordinary (not sensitive or + hard-linked) first touch is denied + and a call in the turn mentioned the file without crediting it, the denial + adds one line, "Closest search this turn did not count (`` + ``): ``.", naming the closest such call and why it failed: sent + in the same batch, its filters exclude the file, its search path does not + contain it, it searched piped input, it was a `Read` or another command + rather than a search, or the file name is too generic to match. The detail + is sanitized and cut to 60 characters; no other transcript text is shown. + +### Comment and whitespace-only edits + +An Edit whose old and new text differ only in comments and whitespace changes +no behaviour, so the first-touch questions carry no signal. Such an Edit (or a +MultiEdit whose entries for that file are all such changes) of a `code`, +`test`, or `prose` target that is not sensitive passes with the note "Comment +or whitespace-only change to ``". The file is **not** marked checked: +the next change to it that alters code meets the normal first-touch gate. The +pass is counted in `trivial_allows` and never touches the denial count. + +It applies only to the extensions listed in +[Questions fit the change](#questions-fit-the-change), and only when every +entry reads cleanly: + +- comments are `//` and `/* */` (C family), `#` (Python), `#` at the start of + a word (shell), `#` and `<# #>` (PowerShell), or `REM` lines (batch); + C preprocessor lines and batch `::` labels are code; directive comments are + code too (shebangs, encoding cookies, `# type:`, `# noqa`, `# nosec`, + `// @ts-...`, `eslint-...`, `//go:build`, `// +build`, `//go:embed`, Rust + doc comments, `#Requires`, `shellcheck`, `NOLINT`, `NOSONAR`, fallthrough + markers and similar); whitespace inside strings, line breaks between code, and + the presence of whitespace between tokens are code (`a+b` → `a + b` is not + trivial); +- Python indentation of code lines is code; +- never trivial: a Write; any multi-line or raw string form (backticks, triple + quotes, `r"`, `R"`, `@"`, `$"`, f-strings, Kotlin `$`), a string running to + the end of a line, a line comment or code line ending in `\`, `??/`, a + nested or unterminated block comment; in JS/TS any `/` outside a comment (regex versus + division), JSX-like tags, and `-->`; in shell scripts heredocs, backticks, + `$(` inside double quotes, `$'...'`, line continuations and here-strings + (see the design notes for the full list); +- config, instruction, sensitive and hard-linked targets, and NotebookEdit + calls never pass this way. + +The edit is also checked against the current file (a regular file of at +most 1 MiB, read by the hook and never stored): `old_string` must be found as +the Edit tool would apply it, the lines around the change must compare equal +as above, and the text before them must end in plain code, not inside a +template literal, docstring, raw string, heredoc or here-string and not after +a line continuation. A missing, unreadable or larger file, or a Go file that +imports `"C"`, never passes this way. + +### Same-turn sibling creation collapse + +When several new files are created in one directory, the answers for each +would be identical, so only the first is denied: + +- Applies to a **Write of a file that does not exist yet** whose class is + `code`, `test`, or `prose`. Never collapsed: `config` and `instruction` + files, and any path with a segment starting with `.` (dotfiles and every + dot-directory: `.claude`, `.github`, `.git`, `.husky`, `.devcontainer`, + `.githooks`, `.idea`, `.vscode`, …) or an 8.3 short name (`CLAUDE~1`), + checked on the same project-relative path as the class. +- The directory is also resolved to its **real location** (symlinks + followed; missing directories are resolved from the nearest existing + parent). If that differs from the path as written, the real path must be + inside the project, keep the same class, and pass the same screen, so a + symlink such as `src/tools -> .claude/hooks` never collapses. Gates are + keyed by the real directory. Any filesystem error means no collapse. +- After such a Write is denied, later first-touch Writes of new files **of the + same class in the same directory** are allowed with the note "Sibling of + `` (gated earlier at denial #`` this session)". A code denial never + lets a test, README, or config file through. +- The window is the **same human turn**. The turn id is the turn-start + record's own `promptId`, else its `uuid`, else a hash of that record. When + the turn started before the transcript tail, the id is the `promptId` that + every user record in the window agrees on (Claude Code stamps it on the + prompt and on every tool result of the turn); if they disagree there is no + turn id. Only when **no transcript path is available at all** does a gate + recorded without a turn id match for **120 seconds**; a transcript that is + missing, unreadable, or yields no turn id means no collapse. A turn-scoped + gate never matches a call without a turn id, and vice versa; a gate stamped + in the future is ignored. +- Prior-search credit is checked first. **Edits and MultiEdits are never + collapsed**, and an existing file is never treated as a sibling. + +### Canonical path keys + +Checked state is keyed on the canonical path: a relative `file_path` resolves +against the tool's `cwd` (where the tool runs), then `CLAUDE_PROJECT_DIR`, +then the process directory, with `/` separators, lowercased on Windows only. +`a.py`, `./a.py`, and the absolute path share one first-touch. State files +written by earlier versions (raw keys) are still honoured. +`GATEGUARD_EXEMPT_GLOBS` keeps its project-relative matching (below). + +### In-session counters + +The per-session state file (under `GATEGUARD_STATE_DIR`) records these fields. +They record class names only, never paths (except `dir_gates`, which stores +the sanitized first file per directory): + +| Field | Meaning | +|---|---| +| `fact_force_credited` | Number of first touches allowed by prior-search credit | +| `denials_by_class` | Denials per target class (`code`, `prose`, `test`, …) | +| `credited_by_class` | Credits per target class | +| `sibling_allows` | Number of Writes allowed by sibling collapse | +| `dir_gates` | Per class and directory, the denial that opened a sibling window (turn, time, first file, ordinal); capped at 50 entries | +| `cap_allows` | Number of first touches passed through by the denial cap (`GATEGUARD_FACT_FORCE_MAX_DENIALS`); these never count as denials | +| `trivial_allows` | Number of comment or whitespace-only edits passed without the first-touch check; these never mark the file checked | +| `routine_readonly_passes` | Number of read-only shell commands passed before the routine shell gate fired; these never mark it checked | + +Missing or malformed fields load as empty or zero, so older state files keep +working. + +### Compatibility and limits + +- **Two new, opt-in environment variables:** + `GATEGUARD_FACT_FORCE_MAX_DENIALS` (unset means no cap) and + `GATEGUARD_METRICS` (unset means no metrics). The existing controls are + unchanged. +- **Nothing previously allowed is now denied**, except a subagent's first + touch of a sensitive or hard-linked target, and the first NotebookEdit of + each notebook (NotebookEdit was not gated before). The other rules only + remove denials, and never return `permissionDecision: "allow"`, so other hooks + and permission rules still apply. +- **Trust limit:** prior-search credit reads the local transcript file, + which the agent could in principle write to. The credit verifies observed + behaviour (a search ran and returned), not intent or how carefully the + result was read. + ## Quick Start ### Option A: Use the ECC hook (zero install) @@ -120,11 +560,13 @@ If GateGuard blocks setup or repair work, start the session with `ECC_DISABLED_HOOKS` with the GateGuard hook ID. In long sessions, only the first `GATEGUARD_FACT_FORCE_FULL_DENIALS` -fact-force denials (default 3) emit the full four-fact block; later +fact-force denials (default 3) emit the full fact block; later denials are condensed to a single line carrying the denial ordinal, so near-identical blocks cannot accumulate in the context window and amplify model repetition loops (#2142). Retrying the same file or -command after presenting facts never re-triggers the gate. +command after presenting facts never re-triggers the gate. That is a +message budget; to stop denying new files after a number of denials, set +the opt-in denial budget `GATEGUARD_FACT_FORCE_MAX_DENIALS` (below). #### Graduated controls @@ -135,15 +577,56 @@ load-bearing destructive-Bash checks keep running: | Variable | Default | Effect | |---|---|---| | `GATEGUARD_BASH_ROUTINE_DISABLED` | unset (gate on) | Disables the **routine-Bash** gate only. The destructive-Bash gate (`rm -rf`, `git reset --hard`, `drop table`, `dd if=`, …) is unaffected. | -| `GATEGUARD_EXEMPT_GLOBS` | unset (no exemptions) | Comma-separated globs; a matching Edit/Write/MultiEdit target skips first-touch fact-forcing. Intended for low-import-value trees (tests, generated artifacts, scratch dirs) where "who imports this / what schema" carries no signal. | -| `GATEGUARD_FACT_FORCE_FULL_DENIALS` | `3` | How many denials emit the full four-fact block before later ones condense to a single line. `0` condenses from the very first denial. | -| `GATEGUARD_FACT_FORCE_MAX_DENIALS` | unset (no cap) | Caps the total number of new-path Edit/Write/MultiEdit denials in a session. After the configured number of denials, new paths are allowed instead of denied; destructive Bash stays gated independently. The cap is best effort when hooks run concurrently: a lost count update can add a denial, but never lets a new path through early. Opt-in — unset, or any value that is not a whole non-negative integer, preserves the existing deny-every-new-path behaviour. Unlike `GATEGUARD_FACT_FORCE_FULL_DENIALS`, which only controls message detail, this changes whether the operation is blocked. | +| `GATEGUARD_EXEMPT_GLOBS` | unset (no exemptions) | Comma-separated globs; a matching Edit/Write/MultiEdit/NotebookEdit target skips first-touch fact-forcing, even when it is sensitive or hard-linked. Intended for low-import-value trees (tests, generated artifacts, scratch dirs) where "who imports this / what schema" carries no signal. | +| `GATEGUARD_FACT_FORCE_FULL_DENIALS` | `3` | How many denials emit the full fact block before later ones condense to a single line. `0` condenses from the very first denial. | +| `GATEGUARD_FACT_FORCE_MAX_DENIALS` | unset (no cap) | A **denial budget**: caps how many first-touch Edit/Write/MultiEdit/NotebookEdit denials a session draws. Once that many denials have been issued, further new paths pass through instead of being denied (counted in `cap_allows`, not as denials); `0` passes from the first. Prior-search credit and sibling collapse run first and never use it up, and [sensitive](#sensitive-targets) and [hard-linked](#hard-linked-targets) targets are always denied. Destructive and routine Bash stay gated. The cap is best effort when hooks run concurrently: a lost count update can add a denial, but never lets a new path through early. Opt-in: unset, or any value that is not a whole non-negative integer (surrounding spaces allowed), keeps the deny-every-new-path behaviour; a malformed value is reported once on stderr (`ignoring malformed GATEGUARD_FACT_FORCE_MAX_DENIALS=…; the denial cap is not active.`). Unlike `GATEGUARD_FACT_FORCE_FULL_DENIALS`, a **message budget** that only changes how much text a denial carries, this changes whether the operation is blocked. Condensed denials name this variable. | | `GATEGUARD_BASH_EXTRA_DESTRUCTIVE` | unset | Extra destructive-command patterns, as regex source, added to the built-in set. A malformed regex is treated as unset (built-ins still apply) and logged once to stderr. | | `GATEGUARD_STATE_DIR` | `~/.gateguard` | Where per-session gate state is kept. If state cannot be persisted the gate allows the operation rather than looping, and names this variable in the warning. | +| `GATEGUARD_METRICS` | unset (off) | Records one line per Edit/Write/MultiEdit/NotebookEdit/Bash/PowerShell decision in `/metrics.jsonl` (see [Decision metrics](#decision-metrics)). Never changes a decision. | -`GATEGUARD_BASH_ROUTINE_DISABLED` accepts `1`, `true`, `on`, `enabled`, -`enable`, or `yes` (case- and whitespace-insensitive); any other value -leaves the gate on. +`GATEGUARD_BASH_ROUTINE_DISABLED` and `GATEGUARD_METRICS` accept `1`, `true`, +`on`, `enabled`, `enable`, or `yes` (case- and whitespace-insensitive); any +other value leaves the routine gate on and metrics off. + +#### Decision metrics + +With `GATEGUARD_METRICS=1`, each gate decision appends one JSON line to +`metrics.jsonl` in the state directory (a MultiEdit call writes one line per +file it decided): + +```json +{"v":1,"ts":"2026-09-01T10:02:00.000Z","session":"3f9c0a1b2d4e","tool":"Edit","class":"code","decision":"deny","reason":"near-miss:out-of-scope","questions":["local-callers","quote-instruction"],"sensitive":false,"profile":{"known":true,"language":"js","touchesPublicSurface":false,"touchesData":false,"trivial":false}} +``` + +| Field | Meaning | +|---|---| +| `v` | Schema version (`1`) | +| `session` | First 12 hex characters of the sha256 of the session key | +| `tool` | `Edit`, `Write`, `MultiEdit`, `NotebookEdit`, `Bash` or `PowerShell` | +| `class` | Target class, or `null` for shell commands | +| `decision` | `deny`, `credit`, `sibling`, `cap`, `trivial`, `pass-checked`, `pass-exempt`, `pass-subagent`, `routine-deny`, `routine-readonly`, `destructive-deny` or `pass` | +| `reason` | Short code: `first-touch`, `sensitive`, `hard-linked`, `near-miss:`, `subagent-sensitive`, `subagent-hard-linked`, `prior-search`, `comment-whitespace`, `same-turn-dir`, `max-denials`, `checked`, `exempt-glob`, `claude-settings`, `no-path`, `subagent`, `readonly-git`, `readonly`, `first-command`, `routine-checked`, `routine-disabled`, `destructive`, `destructive-retry`, `state-error` | +| `questions` | Question ids of a first-touch denial; `null` otherwise | +| `sensitive` | Whether the target is a [sensitive target](#sensitive-targets) | +| `profile` | Change-profile flags when the profile was computed; `null` otherwise | + +**Privacy:** lines never contain paths, commands, file content, transcript +text or the session id itself; every text field must be a short lowercase +code or is written as `null`. Metrics errors are ignored. Past 1 MiB the file +is renamed to `metrics.jsonl.1`, replacing the previous one. + +Summarise the metrics with: + +```bash +node scripts/gateguard-report.js # GATEGUARD_STATE_DIR or ~/.gateguard +node scripts/gateguard-report.js --dir /path/to/state --json +``` + +It reads `metrics.jsonl.1` then `metrics.jsonl`, skips malformed lines, and +prints, per session and in total: decisions by type, denials by class and +reason, the share of first-touch denials that followed a near-miss search, +the rates of denial, credit, sibling collapse, cap and trivial edits among +first touches, routine read-only passes, and the most-asked question ids. #### Turning the gate off completely @@ -166,6 +649,8 @@ Both patterns and paths use `/` separators and lowercase matching. `*` matches within a segment, `**` across segments, and `?` one non-separator character. `**/` includes zero directories, so `**/tests/**` also matches `tests/foo.js`. Malformed patterns are dropped without granting an exemption. +An exemption applies before every other rule, including the sensitive and +hard-link rules (see [Order of checks](#order-of-checks)). Since 2.2.1, `services/**` only covers the project's root services tree, and `*.md` only covers its root Markdown files. Use `**/*.md` for all Markdown @@ -197,7 +682,7 @@ This adds `.gateguard.yml` for per-project configuration (custom messages, ignor ## Best Practices -- Let the gate fire naturally. Don't try to pre-answer the gate questions — the investigation itself is what improves quality. +- Let the gate fire naturally. Don't try to pre-answer the gate questions — the investigation itself is what improves quality. A real search for the target before editing is investigation, and is credited (see [What Counts as Already Done](#what-counts-as-already-done)). - Customize gate messages for your domain. If your project has specific conventions, add them to the gate prompts. - Use `.gateguard.yml` to ignore paths like `.venv/`, `node_modules/`, `.git/`. diff --git a/tests/fixtures/gateguard-metrics/metrics.jsonl b/tests/fixtures/gateguard-metrics/metrics.jsonl new file mode 100644 index 000000000..06e6fd4ff --- /dev/null +++ b/tests/fixtures/gateguard-metrics/metrics.jsonl @@ -0,0 +1,17 @@ +{"v":1,"ts":"2026-09-01T10:02:00.000Z","session":"aaaaaaaaaaaa","tool":"Edit","class":"code","decision":"deny","reason":"near-miss:out-of-scope","questions":["local-callers","quote-instruction"],"sensitive":false,"profile":{"known":true,"language":"js","touchesPublicSurface":false,"touchesData":false,"trivial":false}} +not json +{"v":1,"ts":"2026-09-01T10:03:00.000Z","session":"aaaaaaaaaaaa","tool":"Edit","class":"code","decision":"credit","reason":"prior-search","questions":null,"sensitive":false,"profile":null} +{"v":2,"ts":"2026-09-01T10:03:30.000Z","session":"aaaaaaaaaaaa","tool":"Edit","class":"code","decision":"deny","reason":"first-touch","questions":[],"sensitive":false,"profile":null} +{"v":1,"ts":"2026-09-01T10:04:00.000Z","session":"aaaaaaaaaaaa","tool":"Edit","class":"code","decision":"trivial","reason":"comment-whitespace","questions":null,"sensitive":false,"profile":{"known":true,"language":"js","touchesPublicSurface":false,"touchesData":false,"trivial":true}} +{"v":1,"ts":"2026-09-01T10:05:00.000Z","session":"aaaaaaaaaaaa","tool":"Bash","class":null,"decision":"routine-deny","reason":"first-command","questions":null,"sensitive":false,"profile":null} +{"v":1,"ts":"2026-09-01T11:00:00.000Z","session":"NOT-A-HASH","tool":"Edit","class":"code","decision":"deny","reason":"first-touch","questions":[],"sensitive":false,"profile":null} + +{"v":1,"ts":"2026-09-01T11:00:30.000Z","session":"bbbbbbbbbbbb","tool":"Edit","class":"code","decision":"allow","reason":"first-touch","questions":null,"sensitive":false,"profile":null} +[] +{"v":1,"ts":"2026-09-01T11:00:40.000Z","session":"bbbbbbbbbbbb","tool":"Edit","class":"code","decision":"deny","reason":"/home/user/secret.js","questions":null,"sensitive":false,"profile":null} +{"v":1,"ts":"2026-09-01T11:01:00.000Z","session":"bbbbbbbbbbbb","tool":"Write","class":"code","decision":"deny","reason":"first-touch","questions":["callers","no-duplicate","quote-instruction"],"sensitive":false,"profile":{"known":true,"language":"py","touchesPublicSurface":true,"touchesData":false,"trivial":false}} +{"v":1,"ts":"2026-09-01T11:02:00.000Z","session":"bbbbbbbbbbbb","tool":"Write","class":"code","decision":"sibling","reason":"same-turn-dir","questions":null,"sensitive":false,"profile":null} +{"v":1,"ts":"2026-09-01T11:03:00.000Z","session":"bbbbbbbbbbbb","tool":"Edit","class":"config","decision":"deny","reason":"sensitive","questions":["config-reader","config-effect","no-plaintext-secrets","quote-instruction"],"sensitive":true,"profile":null} +{"v":1,"ts":"2026-09-01T11:04:00.000Z","session":"bbbbbbbbbbbb","tool":"Edit","class":"code","decision":"cap","reason":"max-denials","questions":null,"sensitive":false,"profile":null} +{"v":1,"ts":"2026-09-01T11:05:00.000Z","session":"bbbbbbbbbbbb","tool":"PowerShell","class":null,"decision":"destructive-deny","reason":"destructive","questions":null,"sensitive":false,"profile":null} +{"v":1,"ts":"2026-09-01T11:06:00.000Z","session":"bbbbbbbbbbbb","tool":"Edit","class":"code","decision":"pass-checked","reason":"checked","questions":null,"sensitive":false,"profile":null} diff --git a/tests/fixtures/gateguard-metrics/metrics.jsonl.1 b/tests/fixtures/gateguard-metrics/metrics.jsonl.1 new file mode 100644 index 000000000..34ea592b4 --- /dev/null +++ b/tests/fixtures/gateguard-metrics/metrics.jsonl.1 @@ -0,0 +1,2 @@ +{"v":1,"ts":"2026-09-01T10:00:00.000Z","session":"aaaaaaaaaaaa","tool":"Edit","class":"code","decision":"deny","reason":"first-touch","questions":["importers","public-api","quote-instruction"],"sensitive":false,"profile":{"known":true,"language":"js","touchesPublicSurface":true,"touchesData":false,"trivial":false}} +{"v":1,"ts":"2026-09-01T10:01:00.000Z","session":"aaaaaaaaaaaa","tool":"Bash","class":null,"decision":"routine-readonly","reason":"readonly","questions":null,"sensitive":false,"profile":null} diff --git a/tests/fixtures/gateguard-scenarios/01-docs-heavy-session.json b/tests/fixtures/gateguard-scenarios/01-docs-heavy-session.json new file mode 100644 index 000000000..2b24a4c90 --- /dev/null +++ b/tests/fixtures/gateguard-scenarios/01-docs-heavy-session.json @@ -0,0 +1,1260 @@ +{ + "name": "docs-heavy-session", + "description": "Documentation task: two scoped searches, a read-only first shell command, four new guide pages, four edits of existing Markdown files, two new helper tools, a first mutating command and a first code edit.", + "files": { + "README.md": "# Exporter\n\nSee docs/install.md.\n", + "CHANGELOG.md": "# Changelog\n\n## Unreleased\n", + "docs/hooks.md": "# Hooks\n\nExport hooks run after each export.\n", + "docs/install.md": "# Install\n\nRun the installer, then export a sample.\n", + "docs/guides/overview.md": "# Guides\n", + "scripts/tools/lint-docs.js": "'use strict';\n\nmodule.exports = { lintDocs: files => files.length };\n", + "scripts/lib/report.js": "'use strict';\n\nfunction formatRow(cells) {\n return cells.join(' | ');\n}\n\nfunction renderReport(rows) {\n return rows.map(formatRow).join('\\n');\n}\n\nmodule.exports = { renderReport };\n" + }, + "steps": [ + { + "id": "ls-docs", + "note": "Read-only first shell command.", + "transcript": [ + { + "type": "user", + "uuid": "docs-heavy-session-001", + "sessionId": "sess-docs-heavy", + "isSidechain": false, + "promptId": "prompt-docs-1", + "message": { + "role": "user", + "content": "Document the new export command: add guide pages under docs/guides, update the docs that mention exporting, add two helper tools under scripts/tools, and make formatRow in scripts/lib/report.js trim its cells." + } + }, + { + "type": "assistant", + "uuid": "docs-heavy-session-002", + "sessionId": "sess-docs-heavy", + "isSidechain": false, + "message": { + "id": "msg_docsheavysession_01", + "type": "message", + "role": "assistant", + "model": "claude-sonnet-4-5", + "content": [ + { + "type": "tool_use", + "id": "toolu_docsheavysession_001", + "name": "Glob", + "input": { + "pattern": "docs/**/*.md" + } + } + ] + } + }, + { + "type": "user", + "uuid": "docs-heavy-session-003", + "sessionId": "sess-docs-heavy", + "isSidechain": false, + "promptId": "prompt-docs-1", + "message": { + "role": "user", + "content": [ + { + "type": "tool_result", + "tool_use_id": "toolu_docsheavysession_001", + "content": "ok" + } + ] + } + }, + { + "type": "assistant", + "uuid": "docs-heavy-session-004", + "sessionId": "sess-docs-heavy", + "isSidechain": false, + "message": { + "id": "msg_docsheavysession_02", + "type": "message", + "role": "assistant", + "model": "claude-sonnet-4-5", + "content": [ + { + "type": "tool_use", + "id": "toolu_docsheavysession_002", + "name": "Grep", + "input": { + "pattern": "export", + "path": "{{root}}/docs", + "output_mode": "files_with_matches" + } + } + ] + } + }, + { + "type": "user", + "uuid": "docs-heavy-session-005", + "sessionId": "sess-docs-heavy", + "isSidechain": false, + "promptId": "prompt-docs-1", + "message": { + "role": "user", + "content": [ + { + "type": "tool_result", + "tool_use_id": "toolu_docsheavysession_002", + "content": "ok" + } + ] + } + }, + { + "type": "assistant", + "uuid": "docs-heavy-session-006", + "sessionId": "sess-docs-heavy", + "isSidechain": false, + "message": { + "id": "msg_docsheavysession_03", + "type": "message", + "role": "assistant", + "model": "claude-sonnet-4-5", + "content": [ + { + "type": "tool_use", + "id": "toolu_docsheavysession_003", + "name": "Bash", + "input": { + "command": "ls docs", + "description": "List docs" + } + } + ] + } + } + ], + "payload": { + "session_id": "sess-docs-heavy", + "transcript_path": "{{transcript}}", + "cwd": "{{root}}", + "permission_mode": "default", + "hook_event_name": "PreToolUse", + "tool_name": "Bash", + "tool_input": { + "command": "ls docs", + "description": "List docs" + }, + "tool_use_id": "toolu_docsheavysession_003" + }, + "expect": "allow", + "mustDeny": false, + "redundant": true + }, + { + "id": "write-export-guide", + "note": "The docs-wide search for \"export\" already covered what this page could duplicate.", + "transcript": [ + { + "type": "user", + "uuid": "docs-heavy-session-007", + "sessionId": "sess-docs-heavy", + "isSidechain": false, + "promptId": "prompt-docs-1", + "message": { + "role": "user", + "content": [ + { + "type": "tool_result", + "tool_use_id": "toolu_docsheavysession_003", + "content": "ok" + } + ] + } + }, + { + "type": "assistant", + "uuid": "docs-heavy-session-008", + "sessionId": "sess-docs-heavy", + "isSidechain": false, + "message": { + "id": "msg_docsheavysession_04", + "type": "message", + "role": "assistant", + "model": "claude-sonnet-4-5", + "content": [ + { + "type": "tool_use", + "id": "toolu_docsheavysession_004", + "name": "Read", + "input": { + "file_path": "{{root}}/docs/guides/overview.md" + } + } + ] + } + }, + { + "type": "user", + "uuid": "docs-heavy-session-009", + "sessionId": "sess-docs-heavy", + "isSidechain": false, + "promptId": "prompt-docs-1", + "message": { + "role": "user", + "content": [ + { + "type": "tool_result", + "tool_use_id": "toolu_docsheavysession_004", + "content": "ok" + } + ] + } + }, + { + "type": "assistant", + "uuid": "docs-heavy-session-010", + "sessionId": "sess-docs-heavy", + "isSidechain": false, + "message": { + "id": "msg_docsheavysession_05", + "type": "message", + "role": "assistant", + "model": "claude-sonnet-4-5", + "content": [ + { + "type": "tool_use", + "id": "toolu_docsheavysession_005", + "name": "Write", + "input": { + "file_path": "{{root}}/docs/guides/export.md", + "content": "# Export\n\nUse `exporter run` to export.\n" + } + } + ] + } + } + ], + "payload": { + "session_id": "sess-docs-heavy", + "transcript_path": "{{transcript}}", + "cwd": "{{root}}", + "permission_mode": "default", + "hook_event_name": "PreToolUse", + "tool_name": "Write", + "tool_input": { + "file_path": "{{root}}/docs/guides/export.md", + "content": "# Export\n\nUse `exporter run` to export.\n" + }, + "tool_use_id": "toolu_docsheavysession_005" + }, + "expect": "allow", + "mustDeny": false, + "redundant": true + }, + { + "id": "write-export-formats", + "note": "First new guide page the searches did not name.", + "transcript": [ + { + "type": "user", + "uuid": "docs-heavy-session-011", + "sessionId": "sess-docs-heavy", + "isSidechain": false, + "promptId": "prompt-docs-1", + "message": { + "role": "user", + "content": [ + { + "type": "tool_result", + "tool_use_id": "toolu_docsheavysession_005", + "content": "ok" + } + ] + } + }, + { + "type": "assistant", + "uuid": "docs-heavy-session-012", + "sessionId": "sess-docs-heavy", + "isSidechain": false, + "message": { + "id": "msg_docsheavysession_06", + "type": "message", + "role": "assistant", + "model": "claude-sonnet-4-5", + "content": [ + { + "type": "tool_use", + "id": "toolu_docsheavysession_006", + "name": "Write", + "input": { + "file_path": "{{root}}/docs/guides/export-formats.md", + "content": "# Export formats\n\nCSV and JSON.\n" + } + } + ] + } + } + ], + "payload": { + "session_id": "sess-docs-heavy", + "transcript_path": "{{transcript}}", + "cwd": "{{root}}", + "permission_mode": "default", + "hook_event_name": "PreToolUse", + "tool_name": "Write", + "tool_input": { + "file_path": "{{root}}/docs/guides/export-formats.md", + "content": "# Export formats\n\nCSV and JSON.\n" + }, + "tool_use_id": "toolu_docsheavysession_006" + }, + "expect": "deny", + "mustDeny": false, + "redundant": false + }, + { + "id": "write-export-recipes", + "note": "Same-turn sibling of the gated guide page.", + "transcript": [ + { + "type": "user", + "uuid": "docs-heavy-session-013", + "sessionId": "sess-docs-heavy", + "isSidechain": false, + "promptId": "prompt-docs-1", + "message": { + "role": "user", + "content": [ + { + "type": "tool_result", + "tool_use_id": "toolu_docsheavysession_006", + "content": "ok" + } + ] + } + }, + { + "type": "assistant", + "uuid": "docs-heavy-session-014", + "sessionId": "sess-docs-heavy", + "isSidechain": false, + "message": { + "id": "msg_docsheavysession_07", + "type": "message", + "role": "assistant", + "model": "claude-sonnet-4-5", + "content": [ + { + "type": "tool_use", + "id": "toolu_docsheavysession_007", + "name": "Write", + "input": { + "file_path": "{{root}}/docs/guides/export-recipes.md", + "content": "# Export recipes\n" + } + } + ] + } + } + ], + "payload": { + "session_id": "sess-docs-heavy", + "transcript_path": "{{transcript}}", + "cwd": "{{root}}", + "permission_mode": "default", + "hook_event_name": "PreToolUse", + "tool_name": "Write", + "tool_input": { + "file_path": "{{root}}/docs/guides/export-recipes.md", + "content": "# Export recipes\n" + }, + "tool_use_id": "toolu_docsheavysession_007" + }, + "expect": "allow", + "mustDeny": false, + "redundant": true + }, + { + "id": "write-export-faq", + "note": "Same-turn sibling of the gated guide page.", + "transcript": [ + { + "type": "user", + "uuid": "docs-heavy-session-015", + "sessionId": "sess-docs-heavy", + "isSidechain": false, + "promptId": "prompt-docs-1", + "message": { + "role": "user", + "content": [ + { + "type": "tool_result", + "tool_use_id": "toolu_docsheavysession_007", + "content": "ok" + } + ] + } + }, + { + "type": "assistant", + "uuid": "docs-heavy-session-016", + "sessionId": "sess-docs-heavy", + "isSidechain": false, + "message": { + "id": "msg_docsheavysession_08", + "type": "message", + "role": "assistant", + "model": "claude-sonnet-4-5", + "content": [ + { + "type": "tool_use", + "id": "toolu_docsheavysession_008", + "name": "Write", + "input": { + "file_path": "{{root}}/docs/guides/export-faq.md", + "content": "# Export FAQ\n" + } + } + ] + } + } + ], + "payload": { + "session_id": "sess-docs-heavy", + "transcript_path": "{{transcript}}", + "cwd": "{{root}}", + "permission_mode": "default", + "hook_event_name": "PreToolUse", + "tool_name": "Write", + "tool_input": { + "file_path": "{{root}}/docs/guides/export-faq.md", + "content": "# Export FAQ\n" + }, + "tool_use_id": "toolu_docsheavysession_008" + }, + "expect": "allow", + "mustDeny": false, + "redundant": true + }, + { + "id": "edit-hooks-doc", + "note": "References to this page were searched for.", + "transcript": [ + { + "type": "user", + "uuid": "docs-heavy-session-017", + "sessionId": "sess-docs-heavy", + "isSidechain": false, + "promptId": "prompt-docs-1", + "message": { + "role": "user", + "content": [ + { + "type": "tool_result", + "tool_use_id": "toolu_docsheavysession_008", + "content": "ok" + } + ] + } + }, + { + "type": "assistant", + "uuid": "docs-heavy-session-018", + "sessionId": "sess-docs-heavy", + "isSidechain": false, + "message": { + "id": "msg_docsheavysession_09", + "type": "message", + "role": "assistant", + "model": "claude-sonnet-4-5", + "content": [ + { + "type": "tool_use", + "id": "toolu_docsheavysession_009", + "name": "Grep", + "input": { + "pattern": "hooks.md|install.md", + "path": "{{root}}", + "output_mode": "content" + } + } + ] + } + }, + { + "type": "user", + "uuid": "docs-heavy-session-019", + "sessionId": "sess-docs-heavy", + "isSidechain": false, + "promptId": "prompt-docs-1", + "message": { + "role": "user", + "content": [ + { + "type": "tool_result", + "tool_use_id": "toolu_docsheavysession_009", + "content": "ok" + } + ] + } + }, + { + "type": "assistant", + "uuid": "docs-heavy-session-020", + "sessionId": "sess-docs-heavy", + "isSidechain": false, + "message": { + "id": "msg_docsheavysession_10", + "type": "message", + "role": "assistant", + "model": "claude-sonnet-4-5", + "content": [ + { + "type": "tool_use", + "id": "toolu_docsheavysession_010", + "name": "Read", + "input": { + "file_path": "{{root}}/docs/hooks.md" + } + } + ] + } + }, + { + "type": "user", + "uuid": "docs-heavy-session-021", + "sessionId": "sess-docs-heavy", + "isSidechain": false, + "promptId": "prompt-docs-1", + "message": { + "role": "user", + "content": [ + { + "type": "tool_result", + "tool_use_id": "toolu_docsheavysession_010", + "content": "ok" + } + ] + } + }, + { + "type": "assistant", + "uuid": "docs-heavy-session-022", + "sessionId": "sess-docs-heavy", + "isSidechain": false, + "message": { + "id": "msg_docsheavysession_11", + "type": "message", + "role": "assistant", + "model": "claude-sonnet-4-5", + "content": [ + { + "type": "tool_use", + "id": "toolu_docsheavysession_011", + "name": "Edit", + "input": { + "file_path": "{{root}}/docs/hooks.md", + "old_string": "Export hooks run after each export.", + "new_string": "Export hooks run after each export. See the export guide." + } + } + ] + } + } + ], + "payload": { + "session_id": "sess-docs-heavy", + "transcript_path": "{{transcript}}", + "cwd": "{{root}}", + "permission_mode": "default", + "hook_event_name": "PreToolUse", + "tool_name": "Edit", + "tool_input": { + "file_path": "{{root}}/docs/hooks.md", + "old_string": "Export hooks run after each export.", + "new_string": "Export hooks run after each export. See the export guide." + }, + "tool_use_id": "toolu_docsheavysession_011" + }, + "expect": "allow", + "mustDeny": false, + "redundant": true + }, + { + "id": "edit-install-doc", + "note": "References to this page were searched for.", + "transcript": [ + { + "type": "user", + "uuid": "docs-heavy-session-023", + "sessionId": "sess-docs-heavy", + "isSidechain": false, + "promptId": "prompt-docs-1", + "message": { + "role": "user", + "content": [ + { + "type": "tool_result", + "tool_use_id": "toolu_docsheavysession_011", + "content": "ok" + } + ] + } + }, + { + "type": "assistant", + "uuid": "docs-heavy-session-024", + "sessionId": "sess-docs-heavy", + "isSidechain": false, + "message": { + "id": "msg_docsheavysession_12", + "type": "message", + "role": "assistant", + "model": "claude-sonnet-4-5", + "content": [ + { + "type": "tool_use", + "id": "toolu_docsheavysession_012", + "name": "Read", + "input": { + "file_path": "{{root}}/docs/install.md" + } + } + ] + } + }, + { + "type": "user", + "uuid": "docs-heavy-session-025", + "sessionId": "sess-docs-heavy", + "isSidechain": false, + "promptId": "prompt-docs-1", + "message": { + "role": "user", + "content": [ + { + "type": "tool_result", + "tool_use_id": "toolu_docsheavysession_012", + "content": "ok" + } + ] + } + }, + { + "type": "assistant", + "uuid": "docs-heavy-session-026", + "sessionId": "sess-docs-heavy", + "isSidechain": false, + "message": { + "id": "msg_docsheavysession_13", + "type": "message", + "role": "assistant", + "model": "claude-sonnet-4-5", + "content": [ + { + "type": "tool_use", + "id": "toolu_docsheavysession_013", + "name": "Edit", + "input": { + "file_path": "{{root}}/docs/install.md", + "old_string": "then export a sample.", + "new_string": "then export a sample with `exporter run`." + } + } + ] + } + } + ], + "payload": { + "session_id": "sess-docs-heavy", + "transcript_path": "{{transcript}}", + "cwd": "{{root}}", + "permission_mode": "default", + "hook_event_name": "PreToolUse", + "tool_name": "Edit", + "tool_input": { + "file_path": "{{root}}/docs/install.md", + "old_string": "then export a sample.", + "new_string": "then export a sample with `exporter run`." + }, + "tool_use_id": "toolu_docsheavysession_013" + }, + "expect": "allow", + "mustDeny": false, + "redundant": true + }, + { + "id": "edit-readme", + "note": "README is a generic name; nothing searched for its references.", + "transcript": [ + { + "type": "user", + "uuid": "docs-heavy-session-027", + "sessionId": "sess-docs-heavy", + "isSidechain": false, + "promptId": "prompt-docs-1", + "message": { + "role": "user", + "content": [ + { + "type": "tool_result", + "tool_use_id": "toolu_docsheavysession_013", + "content": "ok" + } + ] + } + }, + { + "type": "assistant", + "uuid": "docs-heavy-session-028", + "sessionId": "sess-docs-heavy", + "isSidechain": false, + "message": { + "id": "msg_docsheavysession_14", + "type": "message", + "role": "assistant", + "model": "claude-sonnet-4-5", + "content": [ + { + "type": "tool_use", + "id": "toolu_docsheavysession_014", + "name": "Read", + "input": { + "file_path": "{{root}}/README.md" + } + } + ] + } + }, + { + "type": "user", + "uuid": "docs-heavy-session-029", + "sessionId": "sess-docs-heavy", + "isSidechain": false, + "promptId": "prompt-docs-1", + "message": { + "role": "user", + "content": [ + { + "type": "tool_result", + "tool_use_id": "toolu_docsheavysession_014", + "content": "ok" + } + ] + } + }, + { + "type": "assistant", + "uuid": "docs-heavy-session-030", + "sessionId": "sess-docs-heavy", + "isSidechain": false, + "message": { + "id": "msg_docsheavysession_15", + "type": "message", + "role": "assistant", + "model": "claude-sonnet-4-5", + "content": [ + { + "type": "tool_use", + "id": "toolu_docsheavysession_015", + "name": "Edit", + "input": { + "file_path": "{{root}}/README.md", + "old_string": "See docs/install.md.", + "new_string": "See docs/install.md and docs/guides/export.md." + } + } + ] + } + } + ], + "payload": { + "session_id": "sess-docs-heavy", + "transcript_path": "{{transcript}}", + "cwd": "{{root}}", + "permission_mode": "default", + "hook_event_name": "PreToolUse", + "tool_name": "Edit", + "tool_input": { + "file_path": "{{root}}/README.md", + "old_string": "See docs/install.md.", + "new_string": "See docs/install.md and docs/guides/export.md." + }, + "tool_use_id": "toolu_docsheavysession_015" + }, + "expect": "deny", + "mustDeny": false, + "redundant": false + }, + { + "id": "edit-changelog", + "transcript": [ + { + "type": "user", + "uuid": "docs-heavy-session-031", + "sessionId": "sess-docs-heavy", + "isSidechain": false, + "promptId": "prompt-docs-1", + "message": { + "role": "user", + "content": [ + { + "type": "tool_result", + "tool_use_id": "toolu_docsheavysession_015", + "content": "ok" + } + ] + } + }, + { + "type": "assistant", + "uuid": "docs-heavy-session-032", + "sessionId": "sess-docs-heavy", + "isSidechain": false, + "message": { + "id": "msg_docsheavysession_16", + "type": "message", + "role": "assistant", + "model": "claude-sonnet-4-5", + "content": [ + { + "type": "tool_use", + "id": "toolu_docsheavysession_016", + "name": "Edit", + "input": { + "file_path": "{{root}}/CHANGELOG.md", + "old_string": "## Unreleased\n", + "new_string": "## Unreleased\n\n- Document the export command.\n" + } + } + ] + } + } + ], + "payload": { + "session_id": "sess-docs-heavy", + "transcript_path": "{{transcript}}", + "cwd": "{{root}}", + "permission_mode": "default", + "hook_event_name": "PreToolUse", + "tool_name": "Edit", + "tool_input": { + "file_path": "{{root}}/CHANGELOG.md", + "old_string": "## Unreleased\n", + "new_string": "## Unreleased\n\n- Document the export command.\n" + }, + "tool_use_id": "toolu_docsheavysession_016" + }, + "expect": "deny", + "mustDeny": false, + "redundant": false + }, + { + "id": "write-export-csv-tool", + "note": "New tool that reads JSON and writes CSV.", + "transcript": [ + { + "type": "user", + "uuid": "docs-heavy-session-033", + "sessionId": "sess-docs-heavy", + "isSidechain": false, + "promptId": "prompt-docs-1", + "message": { + "role": "user", + "content": [ + { + "type": "tool_result", + "tool_use_id": "toolu_docsheavysession_016", + "content": "ok" + } + ] + } + }, + { + "type": "assistant", + "uuid": "docs-heavy-session-034", + "sessionId": "sess-docs-heavy", + "isSidechain": false, + "message": { + "id": "msg_docsheavysession_17", + "type": "message", + "role": "assistant", + "model": "claude-sonnet-4-5", + "content": [ + { + "type": "tool_use", + "id": "toolu_docsheavysession_017", + "name": "Read", + "input": { + "file_path": "{{root}}/scripts/tools/lint-docs.js" + } + } + ] + } + }, + { + "type": "user", + "uuid": "docs-heavy-session-035", + "sessionId": "sess-docs-heavy", + "isSidechain": false, + "promptId": "prompt-docs-1", + "message": { + "role": "user", + "content": [ + { + "type": "tool_result", + "tool_use_id": "toolu_docsheavysession_017", + "content": "ok" + } + ] + } + }, + { + "type": "assistant", + "uuid": "docs-heavy-session-036", + "sessionId": "sess-docs-heavy", + "isSidechain": false, + "message": { + "id": "msg_docsheavysession_18", + "type": "message", + "role": "assistant", + "model": "claude-sonnet-4-5", + "content": [ + { + "type": "tool_use", + "id": "toolu_docsheavysession_018", + "name": "Write", + "input": { + "file_path": "{{root}}/scripts/tools/export-csv.js", + "content": "'use strict';\n\nconst fs = require('fs');\n\nfunction toCsv(rows) {\n return rows.map(row => row.join(',')).join('\\n');\n}\n\nmodule.exports = { toCsv, exportCsv: file => toCsv(JSON.parse(fs.readFileSync(file, 'utf8'))) };\n" + } + } + ] + } + } + ], + "payload": { + "session_id": "sess-docs-heavy", + "transcript_path": "{{transcript}}", + "cwd": "{{root}}", + "permission_mode": "default", + "hook_event_name": "PreToolUse", + "tool_name": "Write", + "tool_input": { + "file_path": "{{root}}/scripts/tools/export-csv.js", + "content": "'use strict';\n\nconst fs = require('fs');\n\nfunction toCsv(rows) {\n return rows.map(row => row.join(',')).join('\\n');\n}\n\nmodule.exports = { toCsv, exportCsv: file => toCsv(JSON.parse(fs.readFileSync(file, 'utf8'))) };\n" + }, + "tool_use_id": "toolu_docsheavysession_018" + }, + "expect": "deny", + "mustDeny": false, + "redundant": false, + "relevantQuestions": [ + "callers", + "no-duplicate", + "data-schema", + "external-contract", + "quote-instruction" + ] + }, + { + "id": "write-export-json-tool", + "note": "Same-turn sibling of the gated tool.", + "transcript": [ + { + "type": "user", + "uuid": "docs-heavy-session-037", + "sessionId": "sess-docs-heavy", + "isSidechain": false, + "promptId": "prompt-docs-1", + "message": { + "role": "user", + "content": [ + { + "type": "tool_result", + "tool_use_id": "toolu_docsheavysession_018", + "content": "ok" + } + ] + } + }, + { + "type": "assistant", + "uuid": "docs-heavy-session-038", + "sessionId": "sess-docs-heavy", + "isSidechain": false, + "message": { + "id": "msg_docsheavysession_19", + "type": "message", + "role": "assistant", + "model": "claude-sonnet-4-5", + "content": [ + { + "type": "tool_use", + "id": "toolu_docsheavysession_019", + "name": "Write", + "input": { + "file_path": "{{root}}/scripts/tools/export-json.js", + "content": "'use strict';\n\nconst fs = require('fs');\n\nmodule.exports = { exportJson: (file, rows) => fs.writeFileSync(file, JSON.stringify(rows)) };\n" + } + } + ] + } + } + ], + "payload": { + "session_id": "sess-docs-heavy", + "transcript_path": "{{transcript}}", + "cwd": "{{root}}", + "permission_mode": "default", + "hook_event_name": "PreToolUse", + "tool_name": "Write", + "tool_input": { + "file_path": "{{root}}/scripts/tools/export-json.js", + "content": "'use strict';\n\nconst fs = require('fs');\n\nmodule.exports = { exportJson: (file, rows) => fs.writeFileSync(file, JSON.stringify(rows)) };\n" + }, + "tool_use_id": "toolu_docsheavysession_019" + }, + "expect": "allow", + "mustDeny": false, + "redundant": true, + "relevantQuestions": [ + "callers", + "no-duplicate", + "data-schema", + "external-contract", + "quote-instruction" + ] + }, + { + "id": "run-tool-check", + "note": "First command that is not read-only.", + "transcript": [ + { + "type": "user", + "uuid": "docs-heavy-session-039", + "sessionId": "sess-docs-heavy", + "isSidechain": false, + "promptId": "prompt-docs-1", + "message": { + "role": "user", + "content": [ + { + "type": "tool_result", + "tool_use_id": "toolu_docsheavysession_019", + "content": "ok" + } + ] + } + }, + { + "type": "assistant", + "uuid": "docs-heavy-session-040", + "sessionId": "sess-docs-heavy", + "isSidechain": false, + "message": { + "id": "msg_docsheavysession_20", + "type": "message", + "role": "assistant", + "model": "claude-sonnet-4-5", + "content": [ + { + "type": "tool_use", + "id": "toolu_docsheavysession_020", + "name": "Bash", + "input": { + "command": "node scripts/tools/export-csv.js --check docs", + "description": "Run the new tool" + } + } + ] + } + } + ], + "payload": { + "session_id": "sess-docs-heavy", + "transcript_path": "{{transcript}}", + "cwd": "{{root}}", + "permission_mode": "default", + "hook_event_name": "PreToolUse", + "tool_name": "Bash", + "tool_input": { + "command": "node scripts/tools/export-csv.js --check docs", + "description": "Run the new tool" + }, + "tool_use_id": "toolu_docsheavysession_020" + }, + "expect": "deny", + "mustDeny": false, + "redundant": false + }, + { + "id": "edit-report-internal", + "note": "Body of an unexported helper.", + "transcript": [ + { + "type": "user", + "uuid": "docs-heavy-session-041", + "sessionId": "sess-docs-heavy", + "isSidechain": false, + "promptId": "prompt-docs-1", + "message": { + "role": "user", + "content": [ + { + "type": "tool_result", + "tool_use_id": "toolu_docsheavysession_020", + "content": "ok" + } + ] + } + }, + { + "type": "assistant", + "uuid": "docs-heavy-session-042", + "sessionId": "sess-docs-heavy", + "isSidechain": false, + "message": { + "id": "msg_docsheavysession_21", + "type": "message", + "role": "assistant", + "model": "claude-sonnet-4-5", + "content": [ + { + "type": "tool_use", + "id": "toolu_docsheavysession_021", + "name": "Read", + "input": { + "file_path": "{{root}}/scripts/lib/report.js" + } + } + ] + } + }, + { + "type": "user", + "uuid": "docs-heavy-session-043", + "sessionId": "sess-docs-heavy", + "isSidechain": false, + "promptId": "prompt-docs-1", + "message": { + "role": "user", + "content": [ + { + "type": "tool_result", + "tool_use_id": "toolu_docsheavysession_021", + "content": "ok" + } + ] + } + }, + { + "type": "assistant", + "uuid": "docs-heavy-session-044", + "sessionId": "sess-docs-heavy", + "isSidechain": false, + "message": { + "id": "msg_docsheavysession_22", + "type": "message", + "role": "assistant", + "model": "claude-sonnet-4-5", + "content": [ + { + "type": "tool_use", + "id": "toolu_docsheavysession_022", + "name": "Edit", + "input": { + "file_path": "{{root}}/scripts/lib/report.js", + "old_string": " return cells.join(' | ');", + "new_string": " return cells.map(cell => cell.trim()).join(' | ');" + } + } + ] + } + } + ], + "payload": { + "session_id": "sess-docs-heavy", + "transcript_path": "{{transcript}}", + "cwd": "{{root}}", + "permission_mode": "default", + "hook_event_name": "PreToolUse", + "tool_name": "Edit", + "tool_input": { + "file_path": "{{root}}/scripts/lib/report.js", + "old_string": " return cells.join(' | ');", + "new_string": " return cells.map(cell => cell.trim()).join(' | ');" + }, + "tool_use_id": "toolu_docsheavysession_022" + }, + "expect": "deny", + "mustDeny": false, + "redundant": false, + "relevantQuestions": [ + "local-callers", + "quote-instruction" + ] + }, + { + "id": "edit-report-again", + "transcript": [ + { + "type": "user", + "uuid": "docs-heavy-session-045", + "sessionId": "sess-docs-heavy", + "isSidechain": false, + "promptId": "prompt-docs-1", + "message": { + "role": "user", + "content": [ + { + "type": "tool_result", + "tool_use_id": "toolu_docsheavysession_022", + "content": "ok" + } + ] + } + }, + { + "type": "assistant", + "uuid": "docs-heavy-session-046", + "sessionId": "sess-docs-heavy", + "isSidechain": false, + "message": { + "id": "msg_docsheavysession_23", + "type": "message", + "role": "assistant", + "model": "claude-sonnet-4-5", + "content": [ + { + "type": "tool_use", + "id": "toolu_docsheavysession_023", + "name": "Edit", + "input": { + "file_path": "{{root}}/scripts/lib/report.js", + "old_string": "function formatRow(cells) {", + "new_string": "function formatRow(cells = []) {" + } + } + ] + } + } + ], + "payload": { + "session_id": "sess-docs-heavy", + "transcript_path": "{{transcript}}", + "cwd": "{{root}}", + "permission_mode": "default", + "hook_event_name": "PreToolUse", + "tool_name": "Edit", + "tool_input": { + "file_path": "{{root}}/scripts/lib/report.js", + "old_string": "function formatRow(cells) {", + "new_string": "function formatRow(cells = []) {" + }, + "tool_use_id": "toolu_docsheavysession_023" + }, + "expect": "allow", + "mustDeny": false, + "redundant": false + } + ] +} diff --git a/tests/fixtures/gateguard-scenarios/02-scaffold-module.json b/tests/fixtures/gateguard-scenarios/02-scaffold-module.json new file mode 100644 index 000000000..3c7862f0f --- /dev/null +++ b/tests/fixtures/gateguard-scenarios/02-scaffold-module.json @@ -0,0 +1,743 @@ +{ + "name": "scaffold-module", + "description": "Scaffold of five components and three tests in fresh directories, then a second turn adding two more files.", + "files": { + "src/app.ts": "export function start(): void {}\n", + "package.json": "{ \"name\": \"widgets-app\" }\n" + }, + "steps": [ + { + "id": "write-button", + "transcript": [ + { + "type": "user", + "uuid": "scaffold-module-001", + "sessionId": "sess-scaffold", + "isSidechain": false, + "promptId": "prompt-scaffold-1", + "message": { + "role": "user", + "content": "Scaffold a widgets module in src/widgets with button, card, modal, tabs and panel components, plus tests for the first three." + } + }, + { + "type": "assistant", + "uuid": "scaffold-module-002", + "sessionId": "sess-scaffold", + "isSidechain": false, + "message": { + "id": "msg_scaffoldmodule_01", + "type": "message", + "role": "assistant", + "model": "claude-sonnet-4-5", + "content": [ + { + "type": "tool_use", + "id": "toolu_scaffoldmodule_001", + "name": "Glob", + "input": { + "pattern": "src/**/*.ts" + } + } + ] + } + }, + { + "type": "user", + "uuid": "scaffold-module-003", + "sessionId": "sess-scaffold", + "isSidechain": false, + "promptId": "prompt-scaffold-1", + "message": { + "role": "user", + "content": [ + { + "type": "tool_result", + "tool_use_id": "toolu_scaffoldmodule_001", + "content": "ok" + } + ] + } + }, + { + "type": "assistant", + "uuid": "scaffold-module-004", + "sessionId": "sess-scaffold", + "isSidechain": false, + "message": { + "id": "msg_scaffoldmodule_02", + "type": "message", + "role": "assistant", + "model": "claude-sonnet-4-5", + "content": [ + { + "type": "tool_use", + "id": "toolu_scaffoldmodule_002", + "name": "Read", + "input": { + "file_path": "{{root}}/src/app.ts" + } + } + ] + } + }, + { + "type": "user", + "uuid": "scaffold-module-005", + "sessionId": "sess-scaffold", + "isSidechain": false, + "promptId": "prompt-scaffold-1", + "message": { + "role": "user", + "content": [ + { + "type": "tool_result", + "tool_use_id": "toolu_scaffoldmodule_002", + "content": "ok" + } + ] + } + }, + { + "type": "assistant", + "uuid": "scaffold-module-006", + "sessionId": "sess-scaffold", + "isSidechain": false, + "message": { + "id": "msg_scaffoldmodule_03", + "type": "message", + "role": "assistant", + "model": "claude-sonnet-4-5", + "content": [ + { + "type": "tool_use", + "id": "toolu_scaffoldmodule_003", + "name": "Write", + "input": { + "file_path": "{{root}}/src/widgets/button.ts", + "content": "export function Button(label: string) {\n return { kind: 'button', label };\n}\n" + } + } + ] + } + } + ], + "payload": { + "session_id": "sess-scaffold", + "transcript_path": "{{transcript}}", + "cwd": "{{root}}", + "permission_mode": "default", + "hook_event_name": "PreToolUse", + "tool_name": "Write", + "tool_input": { + "file_path": "{{root}}/src/widgets/button.ts", + "content": "export function Button(label: string) {\n return { kind: 'button', label };\n}\n" + }, + "tool_use_id": "toolu_scaffoldmodule_003" + }, + "expect": "deny", + "mustDeny": false, + "redundant": false, + "relevantQuestions": [ + "callers", + "no-duplicate", + "quote-instruction" + ] + }, + { + "id": "write-card", + "note": "Same-turn sibling in the gated directory.", + "transcript": [ + { + "type": "user", + "uuid": "scaffold-module-007", + "sessionId": "sess-scaffold", + "isSidechain": false, + "promptId": "prompt-scaffold-1", + "message": { + "role": "user", + "content": [ + { + "type": "tool_result", + "tool_use_id": "toolu_scaffoldmodule_003", + "content": "ok" + } + ] + } + }, + { + "type": "assistant", + "uuid": "scaffold-module-008", + "sessionId": "sess-scaffold", + "isSidechain": false, + "message": { + "id": "msg_scaffoldmodule_04", + "type": "message", + "role": "assistant", + "model": "claude-sonnet-4-5", + "content": [ + { + "type": "tool_use", + "id": "toolu_scaffoldmodule_004", + "name": "Write", + "input": { + "file_path": "{{root}}/src/widgets/card.ts", + "content": "export function Card(label: string) {\n return { kind: 'card', label };\n}\n" + } + } + ] + } + } + ], + "payload": { + "session_id": "sess-scaffold", + "transcript_path": "{{transcript}}", + "cwd": "{{root}}", + "permission_mode": "default", + "hook_event_name": "PreToolUse", + "tool_name": "Write", + "tool_input": { + "file_path": "{{root}}/src/widgets/card.ts", + "content": "export function Card(label: string) {\n return { kind: 'card', label };\n}\n" + }, + "tool_use_id": "toolu_scaffoldmodule_004" + }, + "expect": "allow", + "mustDeny": false, + "redundant": true, + "relevantQuestions": [ + "callers", + "no-duplicate", + "quote-instruction" + ] + }, + { + "id": "write-modal", + "note": "Same-turn sibling in the gated directory.", + "transcript": [ + { + "type": "user", + "uuid": "scaffold-module-009", + "sessionId": "sess-scaffold", + "isSidechain": false, + "promptId": "prompt-scaffold-1", + "message": { + "role": "user", + "content": [ + { + "type": "tool_result", + "tool_use_id": "toolu_scaffoldmodule_004", + "content": "ok" + } + ] + } + }, + { + "type": "assistant", + "uuid": "scaffold-module-010", + "sessionId": "sess-scaffold", + "isSidechain": false, + "message": { + "id": "msg_scaffoldmodule_05", + "type": "message", + "role": "assistant", + "model": "claude-sonnet-4-5", + "content": [ + { + "type": "tool_use", + "id": "toolu_scaffoldmodule_005", + "name": "Write", + "input": { + "file_path": "{{root}}/src/widgets/modal.ts", + "content": "export function Modal(label: string) {\n return { kind: 'modal', label };\n}\n" + } + } + ] + } + } + ], + "payload": { + "session_id": "sess-scaffold", + "transcript_path": "{{transcript}}", + "cwd": "{{root}}", + "permission_mode": "default", + "hook_event_name": "PreToolUse", + "tool_name": "Write", + "tool_input": { + "file_path": "{{root}}/src/widgets/modal.ts", + "content": "export function Modal(label: string) {\n return { kind: 'modal', label };\n}\n" + }, + "tool_use_id": "toolu_scaffoldmodule_005" + }, + "expect": "allow", + "mustDeny": false, + "redundant": true, + "relevantQuestions": [ + "callers", + "no-duplicate", + "quote-instruction" + ] + }, + { + "id": "write-tabs", + "note": "Same-turn sibling in the gated directory.", + "transcript": [ + { + "type": "user", + "uuid": "scaffold-module-011", + "sessionId": "sess-scaffold", + "isSidechain": false, + "promptId": "prompt-scaffold-1", + "message": { + "role": "user", + "content": [ + { + "type": "tool_result", + "tool_use_id": "toolu_scaffoldmodule_005", + "content": "ok" + } + ] + } + }, + { + "type": "assistant", + "uuid": "scaffold-module-012", + "sessionId": "sess-scaffold", + "isSidechain": false, + "message": { + "id": "msg_scaffoldmodule_06", + "type": "message", + "role": "assistant", + "model": "claude-sonnet-4-5", + "content": [ + { + "type": "tool_use", + "id": "toolu_scaffoldmodule_006", + "name": "Write", + "input": { + "file_path": "{{root}}/src/widgets/tabs.ts", + "content": "export function Tabs(label: string) {\n return { kind: 'tabs', label };\n}\n" + } + } + ] + } + } + ], + "payload": { + "session_id": "sess-scaffold", + "transcript_path": "{{transcript}}", + "cwd": "{{root}}", + "permission_mode": "default", + "hook_event_name": "PreToolUse", + "tool_name": "Write", + "tool_input": { + "file_path": "{{root}}/src/widgets/tabs.ts", + "content": "export function Tabs(label: string) {\n return { kind: 'tabs', label };\n}\n" + }, + "tool_use_id": "toolu_scaffoldmodule_006" + }, + "expect": "allow", + "mustDeny": false, + "redundant": true, + "relevantQuestions": [ + "callers", + "no-duplicate", + "quote-instruction" + ] + }, + { + "id": "write-panel", + "note": "Same-turn sibling in the gated directory.", + "transcript": [ + { + "type": "user", + "uuid": "scaffold-module-013", + "sessionId": "sess-scaffold", + "isSidechain": false, + "promptId": "prompt-scaffold-1", + "message": { + "role": "user", + "content": [ + { + "type": "tool_result", + "tool_use_id": "toolu_scaffoldmodule_006", + "content": "ok" + } + ] + } + }, + { + "type": "assistant", + "uuid": "scaffold-module-014", + "sessionId": "sess-scaffold", + "isSidechain": false, + "message": { + "id": "msg_scaffoldmodule_07", + "type": "message", + "role": "assistant", + "model": "claude-sonnet-4-5", + "content": [ + { + "type": "tool_use", + "id": "toolu_scaffoldmodule_007", + "name": "Write", + "input": { + "file_path": "{{root}}/src/widgets/panel.ts", + "content": "export function Panel(label: string) {\n return { kind: 'panel', label };\n}\n" + } + } + ] + } + } + ], + "payload": { + "session_id": "sess-scaffold", + "transcript_path": "{{transcript}}", + "cwd": "{{root}}", + "permission_mode": "default", + "hook_event_name": "PreToolUse", + "tool_name": "Write", + "tool_input": { + "file_path": "{{root}}/src/widgets/panel.ts", + "content": "export function Panel(label: string) {\n return { kind: 'panel', label };\n}\n" + }, + "tool_use_id": "toolu_scaffoldmodule_007" + }, + "expect": "allow", + "mustDeny": false, + "redundant": true, + "relevantQuestions": [ + "callers", + "no-duplicate", + "quote-instruction" + ] + }, + { + "id": "write-button-test", + "transcript": [ + { + "type": "user", + "uuid": "scaffold-module-015", + "sessionId": "sess-scaffold", + "isSidechain": false, + "promptId": "prompt-scaffold-1", + "message": { + "role": "user", + "content": [ + { + "type": "tool_result", + "tool_use_id": "toolu_scaffoldmodule_007", + "content": "ok" + } + ] + } + }, + { + "type": "assistant", + "uuid": "scaffold-module-016", + "sessionId": "sess-scaffold", + "isSidechain": false, + "message": { + "id": "msg_scaffoldmodule_08", + "type": "message", + "role": "assistant", + "model": "claude-sonnet-4-5", + "content": [ + { + "type": "tool_use", + "id": "toolu_scaffoldmodule_008", + "name": "Write", + "input": { + "file_path": "{{root}}/tests/widgets/button.test.ts", + "content": "import { Button } from '../../src/widgets/button';\n\ntest('button keeps its label', () => {\n expect(Button('x').label).toBe('x');\n});\n" + } + } + ] + } + } + ], + "payload": { + "session_id": "sess-scaffold", + "transcript_path": "{{transcript}}", + "cwd": "{{root}}", + "permission_mode": "default", + "hook_event_name": "PreToolUse", + "tool_name": "Write", + "tool_input": { + "file_path": "{{root}}/tests/widgets/button.test.ts", + "content": "import { Button } from '../../src/widgets/button';\n\ntest('button keeps its label', () => {\n expect(Button('x').label).toBe('x');\n});\n" + }, + "tool_use_id": "toolu_scaffoldmodule_008" + }, + "expect": "deny", + "mustDeny": false, + "redundant": false + }, + { + "id": "write-card-test", + "transcript": [ + { + "type": "user", + "uuid": "scaffold-module-017", + "sessionId": "sess-scaffold", + "isSidechain": false, + "promptId": "prompt-scaffold-1", + "message": { + "role": "user", + "content": [ + { + "type": "tool_result", + "tool_use_id": "toolu_scaffoldmodule_008", + "content": "ok" + } + ] + } + }, + { + "type": "assistant", + "uuid": "scaffold-module-018", + "sessionId": "sess-scaffold", + "isSidechain": false, + "message": { + "id": "msg_scaffoldmodule_09", + "type": "message", + "role": "assistant", + "model": "claude-sonnet-4-5", + "content": [ + { + "type": "tool_use", + "id": "toolu_scaffoldmodule_009", + "name": "Write", + "input": { + "file_path": "{{root}}/tests/widgets/card.test.ts", + "content": "import { Card } from '../../src/widgets/card';\n\ntest('card keeps its label', () => {\n expect(Card('x').label).toBe('x');\n});\n" + } + } + ] + } + } + ], + "payload": { + "session_id": "sess-scaffold", + "transcript_path": "{{transcript}}", + "cwd": "{{root}}", + "permission_mode": "default", + "hook_event_name": "PreToolUse", + "tool_name": "Write", + "tool_input": { + "file_path": "{{root}}/tests/widgets/card.test.ts", + "content": "import { Card } from '../../src/widgets/card';\n\ntest('card keeps its label', () => {\n expect(Card('x').label).toBe('x');\n});\n" + }, + "tool_use_id": "toolu_scaffoldmodule_009" + }, + "expect": "allow", + "mustDeny": false, + "redundant": true + }, + { + "id": "write-modal-test", + "transcript": [ + { + "type": "user", + "uuid": "scaffold-module-019", + "sessionId": "sess-scaffold", + "isSidechain": false, + "promptId": "prompt-scaffold-1", + "message": { + "role": "user", + "content": [ + { + "type": "tool_result", + "tool_use_id": "toolu_scaffoldmodule_009", + "content": "ok" + } + ] + } + }, + { + "type": "assistant", + "uuid": "scaffold-module-020", + "sessionId": "sess-scaffold", + "isSidechain": false, + "message": { + "id": "msg_scaffoldmodule_10", + "type": "message", + "role": "assistant", + "model": "claude-sonnet-4-5", + "content": [ + { + "type": "tool_use", + "id": "toolu_scaffoldmodule_010", + "name": "Write", + "input": { + "file_path": "{{root}}/tests/widgets/modal.test.ts", + "content": "import { Modal } from '../../src/widgets/modal';\n\ntest('modal keeps its label', () => {\n expect(Modal('x').label).toBe('x');\n});\n" + } + } + ] + } + } + ], + "payload": { + "session_id": "sess-scaffold", + "transcript_path": "{{transcript}}", + "cwd": "{{root}}", + "permission_mode": "default", + "hook_event_name": "PreToolUse", + "tool_name": "Write", + "tool_input": { + "file_path": "{{root}}/tests/widgets/modal.test.ts", + "content": "import { Modal } from '../../src/widgets/modal';\n\ntest('modal keeps its label', () => {\n expect(Modal('x').label).toBe('x');\n});\n" + }, + "tool_use_id": "toolu_scaffoldmodule_010" + }, + "expect": "allow", + "mustDeny": false, + "redundant": true + }, + { + "id": "write-tooltip-next-turn", + "note": "A new turn reopens the directory gate.", + "transcript": [ + { + "type": "user", + "uuid": "scaffold-module-021", + "sessionId": "sess-scaffold", + "isSidechain": false, + "promptId": "prompt-scaffold-1", + "message": { + "role": "user", + "content": [ + { + "type": "tool_result", + "tool_use_id": "toolu_scaffoldmodule_010", + "content": "ok" + } + ] + } + }, + { + "type": "user", + "uuid": "scaffold-module-022", + "sessionId": "sess-scaffold", + "isSidechain": false, + "promptId": "prompt-scaffold-2", + "message": { + "role": "user", + "content": "Now add a tooltip widget and a barrel file." + } + }, + { + "type": "assistant", + "uuid": "scaffold-module-023", + "sessionId": "sess-scaffold", + "isSidechain": false, + "message": { + "id": "msg_scaffoldmodule_11", + "type": "message", + "role": "assistant", + "model": "claude-sonnet-4-5", + "content": [ + { + "type": "tool_use", + "id": "toolu_scaffoldmodule_011", + "name": "Write", + "input": { + "file_path": "{{root}}/src/widgets/tooltip.ts", + "content": "export function Tooltip(label: string) {\n return { kind: 'tooltip', label };\n}\n" + } + } + ] + } + } + ], + "payload": { + "session_id": "sess-scaffold", + "transcript_path": "{{transcript}}", + "cwd": "{{root}}", + "permission_mode": "default", + "hook_event_name": "PreToolUse", + "tool_name": "Write", + "tool_input": { + "file_path": "{{root}}/src/widgets/tooltip.ts", + "content": "export function Tooltip(label: string) {\n return { kind: 'tooltip', label };\n}\n" + }, + "tool_use_id": "toolu_scaffoldmodule_011" + }, + "expect": "deny", + "mustDeny": false, + "redundant": false, + "relevantQuestions": [ + "callers", + "no-duplicate", + "quote-instruction" + ] + }, + { + "id": "write-barrel", + "transcript": [ + { + "type": "user", + "uuid": "scaffold-module-024", + "sessionId": "sess-scaffold", + "isSidechain": false, + "promptId": "prompt-scaffold-2", + "message": { + "role": "user", + "content": [ + { + "type": "tool_result", + "tool_use_id": "toolu_scaffoldmodule_011", + "content": "ok" + } + ] + } + }, + { + "type": "assistant", + "uuid": "scaffold-module-025", + "sessionId": "sess-scaffold", + "isSidechain": false, + "message": { + "id": "msg_scaffoldmodule_12", + "type": "message", + "role": "assistant", + "model": "claude-sonnet-4-5", + "content": [ + { + "type": "tool_use", + "id": "toolu_scaffoldmodule_012", + "name": "Write", + "input": { + "file_path": "{{root}}/src/widgets/index.ts", + "content": "export * from './button';\nexport * from './tooltip';\n" + } + } + ] + } + } + ], + "payload": { + "session_id": "sess-scaffold", + "transcript_path": "{{transcript}}", + "cwd": "{{root}}", + "permission_mode": "default", + "hook_event_name": "PreToolUse", + "tool_name": "Write", + "tool_input": { + "file_path": "{{root}}/src/widgets/index.ts", + "content": "export * from './button';\nexport * from './tooltip';\n" + }, + "tool_use_id": "toolu_scaffoldmodule_012" + }, + "expect": "allow", + "mustDeny": false, + "redundant": true, + "relevantQuestions": [ + "callers", + "no-duplicate", + "quote-instruction" + ] + } + ] +} diff --git a/tests/fixtures/gateguard-scenarios/03-bugfix-after-scoped-search.json b/tests/fixtures/gateguard-scenarios/03-bugfix-after-scoped-search.json new file mode 100644 index 000000000..df66dea39 --- /dev/null +++ b/tests/fixtures/gateguard-scenarios/03-bugfix-after-scoped-search.json @@ -0,0 +1,463 @@ +{ + "name": "bugfix-after-scoped-search", + "description": "Bug fix preceded by a scoped rg that names the file, then a test edit, a mutating first command and read-only git.", + "files": { + "src/parser/tokenizer.js": "'use strict';\n\nfunction columnOf(index, lineStart) {\n return index - lineStart;\n}\n\nfunction tokenize(text) {\n return text.split(/\\s+/).map((word, index) => ({ word, column: columnOf(index, 0) }));\n}\n\nmodule.exports = { tokenize };\n", + "src/parser/lexer.js": "'use strict';\n\nconst { tokenize } = require('./tokenizer');\n\nmodule.exports = { lex: text => tokenize(text) };\n", + "tests/parser/tokenizer.test.js": "'use strict';\n\nconst { tokenize } = require('../../src/parser/tokenizer');\n\ntest('columns', () => expect(tokenize('a b')[0].column).toBe(0));\n" + }, + "steps": [ + { + "id": "rg-tokenizer", + "note": "Read-only first shell command.", + "transcript": [ + { + "type": "user", + "uuid": "bugfix-after-scoped-search-001", + "sessionId": "sess-bugfix", + "isSidechain": false, + "promptId": "prompt-bugfix-1", + "message": { + "role": "user", + "content": "Column numbers in tokenizer errors are off by one; fix it and update the test." + } + }, + { + "type": "assistant", + "uuid": "bugfix-after-scoped-search-002", + "sessionId": "sess-bugfix", + "isSidechain": false, + "message": { + "id": "msg_bugfixafterscopedsearch_01", + "type": "message", + "role": "assistant", + "model": "claude-sonnet-4-5", + "content": [ + { + "type": "tool_use", + "id": "toolu_bugfixafterscopedsearch_001", + "name": "Bash", + "input": { + "command": "rg -n \"tokenizer\" src tests", + "description": "Find tokenizer users" + } + } + ] + } + } + ], + "payload": { + "session_id": "sess-bugfix", + "transcript_path": "{{transcript}}", + "cwd": "{{root}}", + "permission_mode": "default", + "hook_event_name": "PreToolUse", + "tool_name": "Bash", + "tool_input": { + "command": "rg -n \"tokenizer\" src tests", + "description": "Find tokenizer users" + }, + "tool_use_id": "toolu_bugfixafterscopedsearch_001" + }, + "expect": "allow", + "mustDeny": false, + "redundant": true + }, + { + "id": "edit-tokenizer", + "note": "The scoped rg already found every user of the file.", + "transcript": [ + { + "type": "user", + "uuid": "bugfix-after-scoped-search-003", + "sessionId": "sess-bugfix", + "isSidechain": false, + "promptId": "prompt-bugfix-1", + "message": { + "role": "user", + "content": [ + { + "type": "tool_result", + "tool_use_id": "toolu_bugfixafterscopedsearch_001", + "content": "ok" + } + ] + } + }, + { + "type": "assistant", + "uuid": "bugfix-after-scoped-search-004", + "sessionId": "sess-bugfix", + "isSidechain": false, + "message": { + "id": "msg_bugfixafterscopedsearch_02", + "type": "message", + "role": "assistant", + "model": "claude-sonnet-4-5", + "content": [ + { + "type": "tool_use", + "id": "toolu_bugfixafterscopedsearch_002", + "name": "Read", + "input": { + "file_path": "{{root}}/src/parser/tokenizer.js" + } + } + ] + } + }, + { + "type": "user", + "uuid": "bugfix-after-scoped-search-005", + "sessionId": "sess-bugfix", + "isSidechain": false, + "promptId": "prompt-bugfix-1", + "message": { + "role": "user", + "content": [ + { + "type": "tool_result", + "tool_use_id": "toolu_bugfixafterscopedsearch_002", + "content": "ok" + } + ] + } + }, + { + "type": "assistant", + "uuid": "bugfix-after-scoped-search-006", + "sessionId": "sess-bugfix", + "isSidechain": false, + "message": { + "id": "msg_bugfixafterscopedsearch_03", + "type": "message", + "role": "assistant", + "model": "claude-sonnet-4-5", + "content": [ + { + "type": "tool_use", + "id": "toolu_bugfixafterscopedsearch_003", + "name": "Edit", + "input": { + "file_path": "{{root}}/src/parser/tokenizer.js", + "old_string": " return index - lineStart;", + "new_string": " return index - lineStart + 1;" + } + } + ] + } + } + ], + "payload": { + "session_id": "sess-bugfix", + "transcript_path": "{{transcript}}", + "cwd": "{{root}}", + "permission_mode": "default", + "hook_event_name": "PreToolUse", + "tool_name": "Edit", + "tool_input": { + "file_path": "{{root}}/src/parser/tokenizer.js", + "old_string": " return index - lineStart;", + "new_string": " return index - lineStart + 1;" + }, + "tool_use_id": "toolu_bugfixafterscopedsearch_003" + }, + "expect": "allow", + "mustDeny": false, + "redundant": true, + "relevantQuestions": [ + "local-callers", + "quote-instruction" + ] + }, + { + "id": "edit-tokenizer-again", + "transcript": [ + { + "type": "user", + "uuid": "bugfix-after-scoped-search-007", + "sessionId": "sess-bugfix", + "isSidechain": false, + "promptId": "prompt-bugfix-1", + "message": { + "role": "user", + "content": [ + { + "type": "tool_result", + "tool_use_id": "toolu_bugfixafterscopedsearch_003", + "content": "ok" + } + ] + } + }, + { + "type": "assistant", + "uuid": "bugfix-after-scoped-search-008", + "sessionId": "sess-bugfix", + "isSidechain": false, + "message": { + "id": "msg_bugfixafterscopedsearch_04", + "type": "message", + "role": "assistant", + "model": "claude-sonnet-4-5", + "content": [ + { + "type": "tool_use", + "id": "toolu_bugfixafterscopedsearch_004", + "name": "Edit", + "input": { + "file_path": "{{root}}/src/parser/tokenizer.js", + "old_string": "function columnOf(index, lineStart) {", + "new_string": "function columnOf(index, lineStart = 0) {" + } + } + ] + } + } + ], + "payload": { + "session_id": "sess-bugfix", + "transcript_path": "{{transcript}}", + "cwd": "{{root}}", + "permission_mode": "default", + "hook_event_name": "PreToolUse", + "tool_name": "Edit", + "tool_input": { + "file_path": "{{root}}/src/parser/tokenizer.js", + "old_string": "function columnOf(index, lineStart) {", + "new_string": "function columnOf(index, lineStart = 0) {" + }, + "tool_use_id": "toolu_bugfixafterscopedsearch_004" + }, + "expect": "allow", + "mustDeny": false, + "redundant": false + }, + { + "id": "edit-tokenizer-test", + "note": "rg tokenizer src tests already searched the test directory for the module the test is named after.", + "transcript": [ + { + "type": "user", + "uuid": "bugfix-after-scoped-search-009", + "sessionId": "sess-bugfix", + "isSidechain": false, + "promptId": "prompt-bugfix-1", + "message": { + "role": "user", + "content": [ + { + "type": "tool_result", + "tool_use_id": "toolu_bugfixafterscopedsearch_004", + "content": "ok" + } + ] + } + }, + { + "type": "assistant", + "uuid": "bugfix-after-scoped-search-010", + "sessionId": "sess-bugfix", + "isSidechain": false, + "message": { + "id": "msg_bugfixafterscopedsearch_05", + "type": "message", + "role": "assistant", + "model": "claude-sonnet-4-5", + "content": [ + { + "type": "tool_use", + "id": "toolu_bugfixafterscopedsearch_005", + "name": "Read", + "input": { + "file_path": "{{root}}/tests/parser/tokenizer.test.js" + } + } + ] + } + }, + { + "type": "user", + "uuid": "bugfix-after-scoped-search-011", + "sessionId": "sess-bugfix", + "isSidechain": false, + "promptId": "prompt-bugfix-1", + "message": { + "role": "user", + "content": [ + { + "type": "tool_result", + "tool_use_id": "toolu_bugfixafterscopedsearch_005", + "content": "ok" + } + ] + } + }, + { + "type": "assistant", + "uuid": "bugfix-after-scoped-search-012", + "sessionId": "sess-bugfix", + "isSidechain": false, + "message": { + "id": "msg_bugfixafterscopedsearch_06", + "type": "message", + "role": "assistant", + "model": "claude-sonnet-4-5", + "content": [ + { + "type": "tool_use", + "id": "toolu_bugfixafterscopedsearch_006", + "name": "Edit", + "input": { + "file_path": "{{root}}/tests/parser/tokenizer.test.js", + "old_string": ".toBe(0));", + "new_string": ".toBe(1));" + } + } + ] + } + } + ], + "payload": { + "session_id": "sess-bugfix", + "transcript_path": "{{transcript}}", + "cwd": "{{root}}", + "permission_mode": "default", + "hook_event_name": "PreToolUse", + "tool_name": "Edit", + "tool_input": { + "file_path": "{{root}}/tests/parser/tokenizer.test.js", + "old_string": ".toBe(0));", + "new_string": ".toBe(1));" + }, + "tool_use_id": "toolu_bugfixafterscopedsearch_006" + }, + "expect": "allow", + "mustDeny": false, + "redundant": true + }, + { + "id": "npm-test", + "note": "First command that is not read-only.", + "transcript": [ + { + "type": "user", + "uuid": "bugfix-after-scoped-search-013", + "sessionId": "sess-bugfix", + "isSidechain": false, + "promptId": "prompt-bugfix-1", + "message": { + "role": "user", + "content": [ + { + "type": "tool_result", + "tool_use_id": "toolu_bugfixafterscopedsearch_006", + "content": "ok" + } + ] + } + }, + { + "type": "assistant", + "uuid": "bugfix-after-scoped-search-014", + "sessionId": "sess-bugfix", + "isSidechain": false, + "message": { + "id": "msg_bugfixafterscopedsearch_07", + "type": "message", + "role": "assistant", + "model": "claude-sonnet-4-5", + "content": [ + { + "type": "tool_use", + "id": "toolu_bugfixafterscopedsearch_007", + "name": "Bash", + "input": { + "command": "npm test -- tests/parser", + "description": "Run parser tests" + } + } + ] + } + } + ], + "payload": { + "session_id": "sess-bugfix", + "transcript_path": "{{transcript}}", + "cwd": "{{root}}", + "permission_mode": "default", + "hook_event_name": "PreToolUse", + "tool_name": "Bash", + "tool_input": { + "command": "npm test -- tests/parser", + "description": "Run parser tests" + }, + "tool_use_id": "toolu_bugfixafterscopedsearch_007" + }, + "expect": "deny", + "mustDeny": false, + "redundant": false + }, + { + "id": "git-diff-stat", + "transcript": [ + { + "type": "user", + "uuid": "bugfix-after-scoped-search-015", + "sessionId": "sess-bugfix", + "isSidechain": false, + "promptId": "prompt-bugfix-1", + "message": { + "role": "user", + "content": [ + { + "type": "tool_result", + "tool_use_id": "toolu_bugfixafterscopedsearch_007", + "content": "ok" + } + ] + } + }, + { + "type": "assistant", + "uuid": "bugfix-after-scoped-search-016", + "sessionId": "sess-bugfix", + "isSidechain": false, + "message": { + "id": "msg_bugfixafterscopedsearch_08", + "type": "message", + "role": "assistant", + "model": "claude-sonnet-4-5", + "content": [ + { + "type": "tool_use", + "id": "toolu_bugfixafterscopedsearch_008", + "name": "Bash", + "input": { + "command": "git diff --stat", + "description": "Show diff summary" + } + } + ] + } + } + ], + "payload": { + "session_id": "sess-bugfix", + "transcript_path": "{{transcript}}", + "cwd": "{{root}}", + "permission_mode": "default", + "hook_event_name": "PreToolUse", + "tool_name": "Bash", + "tool_input": { + "command": "git diff --stat", + "description": "Show diff summary" + }, + "tool_use_id": "toolu_bugfixafterscopedsearch_008" + }, + "expect": "allow", + "mustDeny": false, + "redundant": false + } + ] +} diff --git a/tests/fixtures/gateguard-scenarios/04-cold-writes.json b/tests/fixtures/gateguard-scenarios/04-cold-writes.json new file mode 100644 index 000000000..26efdee7c --- /dev/null +++ b/tests/fixtures/gateguard-scenarios/04-cold-writes.json @@ -0,0 +1,167 @@ +{ + "name": "cold-writes", + "description": "Writes and edits with no investigation: first without any transcript, then in a transcript turn with no searches.", + "files": { + "src/services/__init__.py": "", + "src/services/notifier.py": "def notify(user, message):\n return f\"{user}: {message}\"\n" + }, + "steps": [ + { + "id": "write-mailer", + "note": "Reads a template file from disk.", + "transcript": [], + "payload": { + "session_id": "sess-cold", + "cwd": "{{root}}", + "permission_mode": "default", + "hook_event_name": "PreToolUse", + "tool_name": "Write", + "tool_input": { + "file_path": "{{root}}/src/services/mailer.py", + "content": "import smtplib\n\n\ndef send_template(path, to):\n with open(path) as handle:\n body = handle.read()\n smtplib.SMTP(\"localhost\").sendmail(\"noreply\", [to], body)\n" + }, + "tool_use_id": "toolu_coldwrites_001" + }, + "expect": "deny", + "mustDeny": false, + "redundant": false, + "relevantQuestions": [ + "callers", + "no-duplicate", + "data-schema", + "external-contract", + "quote-instruction" + ] + }, + { + "id": "edit-mailer", + "transcript": [], + "payload": { + "session_id": "sess-cold", + "cwd": "{{root}}", + "permission_mode": "default", + "hook_event_name": "PreToolUse", + "tool_name": "Edit", + "tool_input": { + "file_path": "{{root}}/src/services/mailer.py", + "old_string": "\"localhost\"", + "new_string": "\"mail.internal\"" + }, + "tool_use_id": "toolu_coldwrites_002" + }, + "expect": "allow", + "mustDeny": false, + "redundant": false + }, + { + "id": "write-sms", + "note": "Sibling within the no-transcript window.", + "transcript": [], + "payload": { + "session_id": "sess-cold", + "cwd": "{{root}}", + "permission_mode": "default", + "hook_event_name": "PreToolUse", + "tool_name": "Write", + "tool_input": { + "file_path": "{{root}}/src/services/sms.py", + "content": "def send_sms(number, text):\n return (number, text)\n" + }, + "tool_use_id": "toolu_coldwrites_003" + }, + "expect": "allow", + "mustDeny": false, + "redundant": true, + "relevantQuestions": [ + "callers", + "no-duplicate", + "quote-instruction" + ] + }, + { + "id": "edit-notifier-signature", + "transcript": [], + "payload": { + "session_id": "sess-cold", + "cwd": "{{root}}", + "permission_mode": "default", + "hook_event_name": "PreToolUse", + "tool_name": "Edit", + "tool_input": { + "file_path": "{{root}}/src/services/notifier.py", + "old_string": "def notify(user, message):", + "new_string": "def notify(user, message, channel=\"email\"):" + }, + "tool_use_id": "toolu_coldwrites_004" + }, + "expect": "deny", + "mustDeny": false, + "redundant": false, + "relevantQuestions": [ + "importers", + "public-api", + "quote-instruction" + ] + }, + { + "id": "write-cleanup-job", + "transcript": [ + { + "type": "user", + "uuid": "cold-writes-001", + "sessionId": "sess-cold", + "isSidechain": false, + "promptId": "prompt-cold-1", + "message": { + "role": "user", + "content": "Add a cleanup job." + } + }, + { + "type": "assistant", + "uuid": "cold-writes-002", + "sessionId": "sess-cold", + "isSidechain": false, + "message": { + "id": "msg_coldwrites_01", + "type": "message", + "role": "assistant", + "model": "claude-sonnet-4-5", + "content": [ + { + "type": "tool_use", + "id": "toolu_coldwrites_005", + "name": "Write", + "input": { + "file_path": "{{root}}/src/jobs/cleanup.go", + "content": "package jobs\n\nfunc Cleanup(paths []string) int {\n\treturn len(paths)\n}\n" + } + } + ] + } + } + ], + "payload": { + "session_id": "sess-cold-2", + "transcript_path": "{{transcript}}", + "cwd": "{{root}}", + "permission_mode": "default", + "hook_event_name": "PreToolUse", + "tool_name": "Write", + "tool_input": { + "file_path": "{{root}}/src/jobs/cleanup.go", + "content": "package jobs\n\nfunc Cleanup(paths []string) int {\n\treturn len(paths)\n}\n" + }, + "tool_use_id": "toolu_coldwrites_005" + }, + "expect": "deny", + "mustDeny": false, + "redundant": false, + "relevantQuestions": [ + "callers", + "no-duplicate", + "quote-instruction" + ] + } + ] +} diff --git a/tests/fixtures/gateguard-scenarios/05-exported-api-edits.json b/tests/fixtures/gateguard-scenarios/05-exported-api-edits.json new file mode 100644 index 000000000..069bf47b3 --- /dev/null +++ b/tests/fixtures/gateguard-scenarios/05-exported-api-edits.json @@ -0,0 +1,465 @@ +{ + "name": "exported-api-edits", + "description": "Signature changes of exported symbols in JS, Python, Go, Rust, Java and a declaration file.", + "files": { + "src/api/client.js": "export function fetchUser(id) {\n return request(`/users/${id}`);\n}\n", + "pkg/store/service.py": "def load_user(user_id):\n return {\"id\": user_id}\n", + "internal/codec/codec.go": "package codec\n\nfunc Encode(v string) string {\n\treturn v\n}\n", + "crates/core/src/lib.rs": "pub fn parse(input: &str) -> Vec {\n lex(input)\n}\n", + "app/src/main/java/com/acme/OrderService.java": "public class OrderService {\n public List findOrders(String customerId) {\n return repo.find(customerId);\n }\n}\n", + "src/api/types.d.ts": "export interface User {\n id: string;\n name: string;\n}\n" + }, + "steps": [ + { + "id": "edit-js-export", + "transcript": [ + { + "type": "user", + "uuid": "exported-api-edits-001", + "sessionId": "sess-exported", + "isSidechain": false, + "promptId": "prompt-exported-1", + "message": { + "role": "user", + "content": "Add an options argument to the public user and order APIs." + } + }, + { + "type": "assistant", + "uuid": "exported-api-edits-002", + "sessionId": "sess-exported", + "isSidechain": false, + "message": { + "id": "msg_exportedapiedits_01", + "type": "message", + "role": "assistant", + "model": "claude-sonnet-4-5", + "content": [ + { + "type": "tool_use", + "id": "toolu_exportedapiedits_001", + "name": "Read", + "input": { + "file_path": "{{root}}/src/api/client.js" + } + } + ] + } + }, + { + "type": "user", + "uuid": "exported-api-edits-003", + "sessionId": "sess-exported", + "isSidechain": false, + "promptId": "prompt-exported-1", + "message": { + "role": "user", + "content": [ + { + "type": "tool_result", + "tool_use_id": "toolu_exportedapiedits_001", + "content": "ok" + } + ] + } + }, + { + "type": "assistant", + "uuid": "exported-api-edits-004", + "sessionId": "sess-exported", + "isSidechain": false, + "message": { + "id": "msg_exportedapiedits_02", + "type": "message", + "role": "assistant", + "model": "claude-sonnet-4-5", + "content": [ + { + "type": "tool_use", + "id": "toolu_exportedapiedits_002", + "name": "Edit", + "input": { + "file_path": "{{root}}/src/api/client.js", + "old_string": "export function fetchUser(id) {", + "new_string": "export function fetchUser(id, options = {}) {" + } + } + ] + } + } + ], + "payload": { + "session_id": "sess-exported", + "transcript_path": "{{transcript}}", + "cwd": "{{root}}", + "permission_mode": "default", + "hook_event_name": "PreToolUse", + "tool_name": "Edit", + "tool_input": { + "file_path": "{{root}}/src/api/client.js", + "old_string": "export function fetchUser(id) {", + "new_string": "export function fetchUser(id, options = {}) {" + }, + "tool_use_id": "toolu_exportedapiedits_002" + }, + "expect": "deny", + "mustDeny": false, + "redundant": false, + "relevantQuestions": [ + "importers", + "public-api", + "quote-instruction" + ] + }, + { + "id": "edit-python-public-def", + "transcript": [ + { + "type": "user", + "uuid": "exported-api-edits-005", + "sessionId": "sess-exported", + "isSidechain": false, + "promptId": "prompt-exported-1", + "message": { + "role": "user", + "content": [ + { + "type": "tool_result", + "tool_use_id": "toolu_exportedapiedits_002", + "content": "ok" + } + ] + } + }, + { + "type": "assistant", + "uuid": "exported-api-edits-006", + "sessionId": "sess-exported", + "isSidechain": false, + "message": { + "id": "msg_exportedapiedits_03", + "type": "message", + "role": "assistant", + "model": "claude-sonnet-4-5", + "content": [ + { + "type": "tool_use", + "id": "toolu_exportedapiedits_003", + "name": "Edit", + "input": { + "file_path": "{{root}}/pkg/store/service.py", + "old_string": "def load_user(user_id):", + "new_string": "def load_user(user_id, *, cache=None):" + } + } + ] + } + } + ], + "payload": { + "session_id": "sess-exported", + "transcript_path": "{{transcript}}", + "cwd": "{{root}}", + "permission_mode": "default", + "hook_event_name": "PreToolUse", + "tool_name": "Edit", + "tool_input": { + "file_path": "{{root}}/pkg/store/service.py", + "old_string": "def load_user(user_id):", + "new_string": "def load_user(user_id, *, cache=None):" + }, + "tool_use_id": "toolu_exportedapiedits_003" + }, + "expect": "deny", + "mustDeny": false, + "redundant": false, + "relevantQuestions": [ + "importers", + "public-api", + "quote-instruction" + ] + }, + { + "id": "edit-go-exported-func", + "transcript": [ + { + "type": "user", + "uuid": "exported-api-edits-007", + "sessionId": "sess-exported", + "isSidechain": false, + "promptId": "prompt-exported-1", + "message": { + "role": "user", + "content": [ + { + "type": "tool_result", + "tool_use_id": "toolu_exportedapiedits_003", + "content": "ok" + } + ] + } + }, + { + "type": "assistant", + "uuid": "exported-api-edits-008", + "sessionId": "sess-exported", + "isSidechain": false, + "message": { + "id": "msg_exportedapiedits_04", + "type": "message", + "role": "assistant", + "model": "claude-sonnet-4-5", + "content": [ + { + "type": "tool_use", + "id": "toolu_exportedapiedits_004", + "name": "Edit", + "input": { + "file_path": "{{root}}/internal/codec/codec.go", + "old_string": "func Encode(v string) string {", + "new_string": "func Encode(v string, strict bool) string {" + } + } + ] + } + } + ], + "payload": { + "session_id": "sess-exported", + "transcript_path": "{{transcript}}", + "cwd": "{{root}}", + "permission_mode": "default", + "hook_event_name": "PreToolUse", + "tool_name": "Edit", + "tool_input": { + "file_path": "{{root}}/internal/codec/codec.go", + "old_string": "func Encode(v string) string {", + "new_string": "func Encode(v string, strict bool) string {" + }, + "tool_use_id": "toolu_exportedapiedits_004" + }, + "expect": "deny", + "mustDeny": false, + "redundant": false, + "relevantQuestions": [ + "importers", + "public-api", + "quote-instruction" + ] + }, + { + "id": "edit-rust-pub-fn", + "transcript": [ + { + "type": "user", + "uuid": "exported-api-edits-009", + "sessionId": "sess-exported", + "isSidechain": false, + "promptId": "prompt-exported-1", + "message": { + "role": "user", + "content": [ + { + "type": "tool_result", + "tool_use_id": "toolu_exportedapiedits_004", + "content": "ok" + } + ] + } + }, + { + "type": "assistant", + "uuid": "exported-api-edits-010", + "sessionId": "sess-exported", + "isSidechain": false, + "message": { + "id": "msg_exportedapiedits_05", + "type": "message", + "role": "assistant", + "model": "claude-sonnet-4-5", + "content": [ + { + "type": "tool_use", + "id": "toolu_exportedapiedits_005", + "name": "Edit", + "input": { + "file_path": "{{root}}/crates/core/src/lib.rs", + "old_string": "pub fn parse(input: &str) -> Vec {", + "new_string": "pub fn parse(input: &str, opts: &Options) -> Vec {" + } + } + ] + } + } + ], + "payload": { + "session_id": "sess-exported", + "transcript_path": "{{transcript}}", + "cwd": "{{root}}", + "permission_mode": "default", + "hook_event_name": "PreToolUse", + "tool_name": "Edit", + "tool_input": { + "file_path": "{{root}}/crates/core/src/lib.rs", + "old_string": "pub fn parse(input: &str) -> Vec {", + "new_string": "pub fn parse(input: &str, opts: &Options) -> Vec {" + }, + "tool_use_id": "toolu_exportedapiedits_005" + }, + "expect": "deny", + "mustDeny": false, + "redundant": false, + "relevantQuestions": [ + "importers", + "public-api", + "quote-instruction" + ] + }, + { + "id": "edit-java-public-method", + "transcript": [ + { + "type": "user", + "uuid": "exported-api-edits-011", + "sessionId": "sess-exported", + "isSidechain": false, + "promptId": "prompt-exported-1", + "message": { + "role": "user", + "content": [ + { + "type": "tool_result", + "tool_use_id": "toolu_exportedapiedits_005", + "content": "ok" + } + ] + } + }, + { + "type": "assistant", + "uuid": "exported-api-edits-012", + "sessionId": "sess-exported", + "isSidechain": false, + "message": { + "id": "msg_exportedapiedits_06", + "type": "message", + "role": "assistant", + "model": "claude-sonnet-4-5", + "content": [ + { + "type": "tool_use", + "id": "toolu_exportedapiedits_006", + "name": "Edit", + "input": { + "file_path": "{{root}}/app/src/main/java/com/acme/OrderService.java", + "old_string": " public List findOrders(String customerId) {", + "new_string": " public List findOrders(String customerId, int limit) {" + } + } + ] + } + } + ], + "payload": { + "session_id": "sess-exported", + "transcript_path": "{{transcript}}", + "cwd": "{{root}}", + "permission_mode": "default", + "hook_event_name": "PreToolUse", + "tool_name": "Edit", + "tool_input": { + "file_path": "{{root}}/app/src/main/java/com/acme/OrderService.java", + "old_string": " public List findOrders(String customerId) {", + "new_string": " public List findOrders(String customerId, int limit) {" + }, + "tool_use_id": "toolu_exportedapiedits_006" + }, + "expect": "deny", + "mustDeny": false, + "redundant": false, + "relevantQuestions": [ + "importers", + "public-api", + "quote-instruction" + ] + }, + { + "id": "multiedit-declaration-file", + "transcript": [ + { + "type": "user", + "uuid": "exported-api-edits-013", + "sessionId": "sess-exported", + "isSidechain": false, + "promptId": "prompt-exported-1", + "message": { + "role": "user", + "content": [ + { + "type": "tool_result", + "tool_use_id": "toolu_exportedapiedits_006", + "content": "ok" + } + ] + } + }, + { + "type": "assistant", + "uuid": "exported-api-edits-014", + "sessionId": "sess-exported", + "isSidechain": false, + "message": { + "id": "msg_exportedapiedits_07", + "type": "message", + "role": "assistant", + "model": "claude-sonnet-4-5", + "content": [ + { + "type": "tool_use", + "id": "toolu_exportedapiedits_007", + "name": "MultiEdit", + "input": { + "file_path": "{{root}}/src/api/types.d.ts", + "edits": [ + { + "file_path": "{{root}}/src/api/types.d.ts", + "old_string": " name: string;", + "new_string": " name: string;\n email?: string;" + } + ] + } + } + ] + } + } + ], + "payload": { + "session_id": "sess-exported", + "transcript_path": "{{transcript}}", + "cwd": "{{root}}", + "permission_mode": "default", + "hook_event_name": "PreToolUse", + "tool_name": "MultiEdit", + "tool_input": { + "file_path": "{{root}}/src/api/types.d.ts", + "edits": [ + { + "file_path": "{{root}}/src/api/types.d.ts", + "old_string": " name: string;", + "new_string": " name: string;\n email?: string;" + } + ] + }, + "tool_use_id": "toolu_exportedapiedits_007" + }, + "expect": "deny", + "mustDeny": false, + "redundant": false, + "relevantQuestions": [ + "importers", + "public-api", + "quote-instruction" + ] + } + ] +} diff --git a/tests/fixtures/gateguard-scenarios/06-internal-only-edits.json b/tests/fixtures/gateguard-scenarios/06-internal-only-edits.json new file mode 100644 index 000000000..a4cc38c2d --- /dev/null +++ b/tests/fixtures/gateguard-scenarios/06-internal-only-edits.json @@ -0,0 +1,376 @@ +{ + "name": "internal-only-edits", + "description": "Body-only edits of unexported code in JS, Python, Go and TypeScript.", + "files": { + "src/cache/lru.js": "'use strict';\n\nfunction evictOldest(entries) {\n for (const entry of entries) {\n entries.delete(entry.key);\n return entry;\n }\n}\n\nmodule.exports = { createCache: () => new Map(), evictOldest };\n", + "pkg/store/_helpers.py": "def _normalize(name):\n return name.strip().lower()\n", + "internal/codec/escape.go": "package codec\n\nfunc escapeRune(r rune) string {\n\treturn string(r)\n}\n", + "src/cache/policy.ts": "const limit = 100;\n\nexport function allow(size: number): boolean {\n return size < limit;\n}\n" + }, + "steps": [ + { + "id": "edit-js-body", + "transcript": [ + { + "type": "user", + "uuid": "internal-only-edits-001", + "sessionId": "sess-internal", + "isSidechain": false, + "promptId": "prompt-internal-1", + "message": { + "role": "user", + "content": "Make LRU eviction skip pinned entries and tidy the helpers." + } + }, + { + "type": "assistant", + "uuid": "internal-only-edits-002", + "sessionId": "sess-internal", + "isSidechain": false, + "message": { + "id": "msg_internalonlyedits_01", + "type": "message", + "role": "assistant", + "model": "claude-sonnet-4-5", + "content": [ + { + "type": "tool_use", + "id": "toolu_internalonlyedits_001", + "name": "Read", + "input": { + "file_path": "{{root}}/src/cache/lru.js" + } + } + ] + } + }, + { + "type": "user", + "uuid": "internal-only-edits-003", + "sessionId": "sess-internal", + "isSidechain": false, + "promptId": "prompt-internal-1", + "message": { + "role": "user", + "content": [ + { + "type": "tool_result", + "tool_use_id": "toolu_internalonlyedits_001", + "content": "ok" + } + ] + } + }, + { + "type": "assistant", + "uuid": "internal-only-edits-004", + "sessionId": "sess-internal", + "isSidechain": false, + "message": { + "id": "msg_internalonlyedits_02", + "type": "message", + "role": "assistant", + "model": "claude-sonnet-4-5", + "content": [ + { + "type": "tool_use", + "id": "toolu_internalonlyedits_002", + "name": "Edit", + "input": { + "file_path": "{{root}}/src/cache/lru.js", + "old_string": " for (const entry of entries) {\n entries.delete(entry.key);", + "new_string": " for (const entry of entries) {\n if (entry.pinned) continue;\n entries.delete(entry.key);" + } + } + ] + } + } + ], + "payload": { + "session_id": "sess-internal", + "transcript_path": "{{transcript}}", + "cwd": "{{root}}", + "permission_mode": "default", + "hook_event_name": "PreToolUse", + "tool_name": "Edit", + "tool_input": { + "file_path": "{{root}}/src/cache/lru.js", + "old_string": " for (const entry of entries) {\n entries.delete(entry.key);", + "new_string": " for (const entry of entries) {\n if (entry.pinned) continue;\n entries.delete(entry.key);" + }, + "tool_use_id": "toolu_internalonlyedits_002" + }, + "expect": "deny", + "mustDeny": false, + "redundant": false, + "relevantQuestions": [ + "local-callers", + "quote-instruction" + ] + }, + { + "id": "edit-python-private", + "transcript": [ + { + "type": "user", + "uuid": "internal-only-edits-005", + "sessionId": "sess-internal", + "isSidechain": false, + "promptId": "prompt-internal-1", + "message": { + "role": "user", + "content": [ + { + "type": "tool_result", + "tool_use_id": "toolu_internalonlyedits_002", + "content": "ok" + } + ] + } + }, + { + "type": "assistant", + "uuid": "internal-only-edits-006", + "sessionId": "sess-internal", + "isSidechain": false, + "message": { + "id": "msg_internalonlyedits_03", + "type": "message", + "role": "assistant", + "model": "claude-sonnet-4-5", + "content": [ + { + "type": "tool_use", + "id": "toolu_internalonlyedits_003", + "name": "Edit", + "input": { + "file_path": "{{root}}/pkg/store/_helpers.py", + "old_string": " return name.strip().lower()", + "new_string": " return name.strip().casefold()" + } + } + ] + } + } + ], + "payload": { + "session_id": "sess-internal", + "transcript_path": "{{transcript}}", + "cwd": "{{root}}", + "permission_mode": "default", + "hook_event_name": "PreToolUse", + "tool_name": "Edit", + "tool_input": { + "file_path": "{{root}}/pkg/store/_helpers.py", + "old_string": " return name.strip().lower()", + "new_string": " return name.strip().casefold()" + }, + "tool_use_id": "toolu_internalonlyedits_003" + }, + "expect": "deny", + "mustDeny": false, + "redundant": false, + "relevantQuestions": [ + "local-callers", + "quote-instruction" + ] + }, + { + "id": "edit-go-unexported", + "transcript": [ + { + "type": "user", + "uuid": "internal-only-edits-007", + "sessionId": "sess-internal", + "isSidechain": false, + "promptId": "prompt-internal-1", + "message": { + "role": "user", + "content": [ + { + "type": "tool_result", + "tool_use_id": "toolu_internalonlyedits_003", + "content": "ok" + } + ] + } + }, + { + "type": "assistant", + "uuid": "internal-only-edits-008", + "sessionId": "sess-internal", + "isSidechain": false, + "message": { + "id": "msg_internalonlyedits_04", + "type": "message", + "role": "assistant", + "model": "claude-sonnet-4-5", + "content": [ + { + "type": "tool_use", + "id": "toolu_internalonlyedits_004", + "name": "Edit", + "input": { + "file_path": "{{root}}/internal/codec/escape.go", + "old_string": "\treturn string(r)", + "new_string": "\treturn strconv.QuoteRune(r)" + } + } + ] + } + } + ], + "payload": { + "session_id": "sess-internal", + "transcript_path": "{{transcript}}", + "cwd": "{{root}}", + "permission_mode": "default", + "hook_event_name": "PreToolUse", + "tool_name": "Edit", + "tool_input": { + "file_path": "{{root}}/internal/codec/escape.go", + "old_string": "\treturn string(r)", + "new_string": "\treturn strconv.QuoteRune(r)" + }, + "tool_use_id": "toolu_internalonlyedits_004" + }, + "expect": "deny", + "mustDeny": false, + "redundant": false, + "relevantQuestions": [ + "local-callers", + "quote-instruction" + ] + }, + { + "id": "edit-ts-module-constant", + "transcript": [ + { + "type": "user", + "uuid": "internal-only-edits-009", + "sessionId": "sess-internal", + "isSidechain": false, + "promptId": "prompt-internal-1", + "message": { + "role": "user", + "content": [ + { + "type": "tool_result", + "tool_use_id": "toolu_internalonlyedits_004", + "content": "ok" + } + ] + } + }, + { + "type": "assistant", + "uuid": "internal-only-edits-010", + "sessionId": "sess-internal", + "isSidechain": false, + "message": { + "id": "msg_internalonlyedits_05", + "type": "message", + "role": "assistant", + "model": "claude-sonnet-4-5", + "content": [ + { + "type": "tool_use", + "id": "toolu_internalonlyedits_005", + "name": "Edit", + "input": { + "file_path": "{{root}}/src/cache/policy.ts", + "old_string": "const limit = 100;", + "new_string": "const limit = 128;" + } + } + ] + } + } + ], + "payload": { + "session_id": "sess-internal", + "transcript_path": "{{transcript}}", + "cwd": "{{root}}", + "permission_mode": "default", + "hook_event_name": "PreToolUse", + "tool_name": "Edit", + "tool_input": { + "file_path": "{{root}}/src/cache/policy.ts", + "old_string": "const limit = 100;", + "new_string": "const limit = 128;" + }, + "tool_use_id": "toolu_internalonlyedits_005" + }, + "expect": "deny", + "mustDeny": false, + "redundant": false, + "relevantQuestions": [ + "local-callers", + "quote-instruction" + ] + }, + { + "id": "edit-js-body-again", + "transcript": [ + { + "type": "user", + "uuid": "internal-only-edits-011", + "sessionId": "sess-internal", + "isSidechain": false, + "promptId": "prompt-internal-1", + "message": { + "role": "user", + "content": [ + { + "type": "tool_result", + "tool_use_id": "toolu_internalonlyedits_005", + "content": "ok" + } + ] + } + }, + { + "type": "assistant", + "uuid": "internal-only-edits-012", + "sessionId": "sess-internal", + "isSidechain": false, + "message": { + "id": "msg_internalonlyedits_06", + "type": "message", + "role": "assistant", + "model": "claude-sonnet-4-5", + "content": [ + { + "type": "tool_use", + "id": "toolu_internalonlyedits_006", + "name": "Edit", + "input": { + "file_path": "{{root}}/src/cache/lru.js", + "old_string": " return entry;", + "new_string": " return entry || null;" + } + } + ] + } + } + ], + "payload": { + "session_id": "sess-internal", + "transcript_path": "{{transcript}}", + "cwd": "{{root}}", + "permission_mode": "default", + "hook_event_name": "PreToolUse", + "tool_name": "Edit", + "tool_input": { + "file_path": "{{root}}/src/cache/lru.js", + "old_string": " return entry;", + "new_string": " return entry || null;" + }, + "tool_use_id": "toolu_internalonlyedits_006" + }, + "expect": "allow", + "mustDeny": false, + "redundant": false + } + ] +} diff --git a/tests/fixtures/gateguard-scenarios/07-data-handling-edits.json b/tests/fixtures/gateguard-scenarios/07-data-handling-edits.json new file mode 100644 index 000000000..8cdf4183b --- /dev/null +++ b/tests/fixtures/gateguard-scenarios/07-data-handling-edits.json @@ -0,0 +1,351 @@ +{ + "name": "data-handling-edits", + "description": "Edits and a new module that parse, serialise, query or format data.", + "files": { + "src/report/loader.js": "'use strict';\n\nfunction readRows(raw) {\n const rows = raw.split('\\n');\n return rows;\n}\n\nmodule.exports = { load: readRows };\n", + "etl/ingest.py": "def ingest(handle):\n out = []\n for line in handle:\n out.append(line)\n return out\n", + "src/report/dates.ts": "export function formatDay(d: Date): string {\n return d.toDateString();\n}\n", + "internal/store/query.go": "package store\n\nfunc countUsers(db *sql.DB) int {\n\trows, _ := db.Query(\"SELECT id FROM users\")\n\treturn count(rows)\n}\n" + }, + "steps": [ + { + "id": "edit-js-json-parse", + "transcript": [ + { + "type": "user", + "uuid": "data-handling-edits-001", + "sessionId": "sess-data", + "isSidechain": false, + "promptId": "prompt-data-1", + "message": { + "role": "user", + "content": "Switch the report loader to the JSON feed and use ISO dates." + } + }, + { + "type": "assistant", + "uuid": "data-handling-edits-002", + "sessionId": "sess-data", + "isSidechain": false, + "message": { + "id": "msg_datahandlingedits_01", + "type": "message", + "role": "assistant", + "model": "claude-sonnet-4-5", + "content": [ + { + "type": "tool_use", + "id": "toolu_datahandlingedits_001", + "name": "Edit", + "input": { + "file_path": "{{root}}/src/report/loader.js", + "old_string": " const rows = raw.split('\\n');", + "new_string": " const rows = JSON.parse(raw).rows;" + } + } + ] + } + } + ], + "payload": { + "session_id": "sess-data", + "transcript_path": "{{transcript}}", + "cwd": "{{root}}", + "permission_mode": "default", + "hook_event_name": "PreToolUse", + "tool_name": "Edit", + "tool_input": { + "file_path": "{{root}}/src/report/loader.js", + "old_string": " const rows = raw.split('\\n');", + "new_string": " const rows = JSON.parse(raw).rows;" + }, + "tool_use_id": "toolu_datahandlingedits_001" + }, + "expect": "deny", + "mustDeny": false, + "redundant": false, + "relevantQuestions": [ + "local-callers", + "data-schema", + "external-contract", + "quote-instruction" + ] + }, + { + "id": "edit-python-csv", + "transcript": [ + { + "type": "user", + "uuid": "data-handling-edits-003", + "sessionId": "sess-data", + "isSidechain": false, + "promptId": "prompt-data-1", + "message": { + "role": "user", + "content": [ + { + "type": "tool_result", + "tool_use_id": "toolu_datahandlingedits_001", + "content": "ok" + } + ] + } + }, + { + "type": "assistant", + "uuid": "data-handling-edits-004", + "sessionId": "sess-data", + "isSidechain": false, + "message": { + "id": "msg_datahandlingedits_02", + "type": "message", + "role": "assistant", + "model": "claude-sonnet-4-5", + "content": [ + { + "type": "tool_use", + "id": "toolu_datahandlingedits_002", + "name": "Edit", + "input": { + "file_path": "{{root}}/etl/ingest.py", + "old_string": " for line in handle:\n out.append(line)", + "new_string": " for row in csv.DictReader(handle):\n out.append(row)" + } + } + ] + } + } + ], + "payload": { + "session_id": "sess-data", + "transcript_path": "{{transcript}}", + "cwd": "{{root}}", + "permission_mode": "default", + "hook_event_name": "PreToolUse", + "tool_name": "Edit", + "tool_input": { + "file_path": "{{root}}/etl/ingest.py", + "old_string": " for line in handle:\n out.append(line)", + "new_string": " for row in csv.DictReader(handle):\n out.append(row)" + }, + "tool_use_id": "toolu_datahandlingedits_002" + }, + "expect": "deny", + "mustDeny": false, + "redundant": false, + "relevantQuestions": [ + "local-callers", + "data-schema", + "external-contract", + "quote-instruction" + ] + }, + { + "id": "edit-exported-date-format", + "transcript": [ + { + "type": "user", + "uuid": "data-handling-edits-005", + "sessionId": "sess-data", + "isSidechain": false, + "promptId": "prompt-data-1", + "message": { + "role": "user", + "content": [ + { + "type": "tool_result", + "tool_use_id": "toolu_datahandlingedits_002", + "content": "ok" + } + ] + } + }, + { + "type": "assistant", + "uuid": "data-handling-edits-006", + "sessionId": "sess-data", + "isSidechain": false, + "message": { + "id": "msg_datahandlingedits_03", + "type": "message", + "role": "assistant", + "model": "claude-sonnet-4-5", + "content": [ + { + "type": "tool_use", + "id": "toolu_datahandlingedits_003", + "name": "Edit", + "input": { + "file_path": "{{root}}/src/report/dates.ts", + "old_string": "export function formatDay(d: Date): string {\n return d.toDateString();", + "new_string": "export function formatDay(d: Date): string {\n return d.toISOString().slice(0, 10);" + } + } + ] + } + } + ], + "payload": { + "session_id": "sess-data", + "transcript_path": "{{transcript}}", + "cwd": "{{root}}", + "permission_mode": "default", + "hook_event_name": "PreToolUse", + "tool_name": "Edit", + "tool_input": { + "file_path": "{{root}}/src/report/dates.ts", + "old_string": "export function formatDay(d: Date): string {\n return d.toDateString();", + "new_string": "export function formatDay(d: Date): string {\n return d.toISOString().slice(0, 10);" + }, + "tool_use_id": "toolu_datahandlingedits_003" + }, + "expect": "deny", + "mustDeny": false, + "redundant": false, + "relevantQuestions": [ + "importers", + "public-api", + "data-schema", + "external-contract", + "quote-instruction" + ] + }, + { + "id": "write-feed-schema", + "transcript": [ + { + "type": "user", + "uuid": "data-handling-edits-007", + "sessionId": "sess-data", + "isSidechain": false, + "promptId": "prompt-data-1", + "message": { + "role": "user", + "content": [ + { + "type": "tool_result", + "tool_use_id": "toolu_datahandlingedits_003", + "content": "ok" + } + ] + } + }, + { + "type": "assistant", + "uuid": "data-handling-edits-008", + "sessionId": "sess-data", + "isSidechain": false, + "message": { + "id": "msg_datahandlingedits_04", + "type": "message", + "role": "assistant", + "model": "claude-sonnet-4-5", + "content": [ + { + "type": "tool_use", + "id": "toolu_datahandlingedits_004", + "name": "Write", + "input": { + "file_path": "{{root}}/src/report/feed-schema.ts", + "content": "export const feedSchema = {\n type: 'object',\n properties: { rows: { type: 'array' }, generatedAt: { type: 'string', format: 'date-time' } }\n};\n" + } + } + ] + } + } + ], + "payload": { + "session_id": "sess-data", + "transcript_path": "{{transcript}}", + "cwd": "{{root}}", + "permission_mode": "default", + "hook_event_name": "PreToolUse", + "tool_name": "Write", + "tool_input": { + "file_path": "{{root}}/src/report/feed-schema.ts", + "content": "export const feedSchema = {\n type: 'object',\n properties: { rows: { type: 'array' }, generatedAt: { type: 'string', format: 'date-time' } }\n};\n" + }, + "tool_use_id": "toolu_datahandlingedits_004" + }, + "expect": "deny", + "mustDeny": false, + "redundant": false, + "relevantQuestions": [ + "callers", + "no-duplicate", + "data-schema", + "external-contract", + "quote-instruction" + ] + }, + { + "id": "edit-go-sql", + "transcript": [ + { + "type": "user", + "uuid": "data-handling-edits-009", + "sessionId": "sess-data", + "isSidechain": false, + "promptId": "prompt-data-1", + "message": { + "role": "user", + "content": [ + { + "type": "tool_result", + "tool_use_id": "toolu_datahandlingedits_004", + "content": "ok" + } + ] + } + }, + { + "type": "assistant", + "uuid": "data-handling-edits-010", + "sessionId": "sess-data", + "isSidechain": false, + "message": { + "id": "msg_datahandlingedits_05", + "type": "message", + "role": "assistant", + "model": "claude-sonnet-4-5", + "content": [ + { + "type": "tool_use", + "id": "toolu_datahandlingedits_005", + "name": "Edit", + "input": { + "file_path": "{{root}}/internal/store/query.go", + "old_string": "\trows, _ := db.Query(\"SELECT id FROM users\")", + "new_string": "\trows, _ := db.Query(\"SELECT id FROM users WHERE active\")" + } + } + ] + } + } + ], + "payload": { + "session_id": "sess-data", + "transcript_path": "{{transcript}}", + "cwd": "{{root}}", + "permission_mode": "default", + "hook_event_name": "PreToolUse", + "tool_name": "Edit", + "tool_input": { + "file_path": "{{root}}/internal/store/query.go", + "old_string": "\trows, _ := db.Query(\"SELECT id FROM users\")", + "new_string": "\trows, _ := db.Query(\"SELECT id FROM users WHERE active\")" + }, + "tool_use_id": "toolu_datahandlingedits_005" + }, + "expect": "deny", + "mustDeny": false, + "redundant": false, + "relevantQuestions": [ + "local-callers", + "data-schema", + "external-contract", + "quote-instruction" + ] + } + ] +} diff --git a/tests/fixtures/gateguard-scenarios/08-comment-only-edits.json b/tests/fixtures/gateguard-scenarios/08-comment-only-edits.json new file mode 100644 index 000000000..ec5914b20 --- /dev/null +++ b/tests/fixtures/gateguard-scenarios/08-comment-only-edits.json @@ -0,0 +1,687 @@ +{ + "name": "comment-only-edits", + "description": "Comment and whitespace-only edits, the Python indentation trap, and edits that only look comment-only.", + "files": { + "src/format/pad.js": "// pads left\nfunction padLeft(s, n) {\n return s.padStart(n);\n}\n", + "tools/fetch.py": "def fetch(url):\n # retry twice\n retries = 2\n return url, retries\n", + "src/format/align.py": "def align(width):\n if width:\n pad(width)\n flush()\n", + "src/format/wrap.js": "// wraps text\nfunction wrap(s) {\n return s;\n}\n", + "scripts/build.sh": "#!/bin/sh\n# build\nmake all\n", + "src/format/trim.js": "function trim(a) {\n if (a) { return a.trim(); }\n}\n", + "src/format/banner.js": "const banner = `v1 // stable`;\n", + "tools/links.py": "BASE = \"https://example.test/#home\"\n", + "src/native/buf.c": "// buffer size\nint size = 64;\n" + }, + "steps": [ + { + "id": "trivial-js-comment", + "transcript": [ + { + "type": "user", + "uuid": "comment-only-edits-001", + "sessionId": "sess-comments", + "isSidechain": false, + "promptId": "prompt-comments-1", + "message": { + "role": "user", + "content": "Clarify the comments in the formatting helpers." + } + }, + { + "type": "assistant", + "uuid": "comment-only-edits-002", + "sessionId": "sess-comments", + "isSidechain": false, + "message": { + "id": "msg_commentonlyedits_01", + "type": "message", + "role": "assistant", + "model": "claude-sonnet-4-5", + "content": [ + { + "type": "tool_use", + "id": "toolu_commentonlyedits_001", + "name": "Edit", + "input": { + "file_path": "{{root}}/src/format/pad.js", + "old_string": "// pads left\nfunction padLeft(s, n) {", + "new_string": "// Pads a string on the left to width n.\nfunction padLeft(s, n) {" + } + } + ] + } + } + ], + "payload": { + "session_id": "sess-comments", + "transcript_path": "{{transcript}}", + "cwd": "{{root}}", + "permission_mode": "default", + "hook_event_name": "PreToolUse", + "tool_name": "Edit", + "tool_input": { + "file_path": "{{root}}/src/format/pad.js", + "old_string": "// pads left\nfunction padLeft(s, n) {", + "new_string": "// Pads a string on the left to width n.\nfunction padLeft(s, n) {" + }, + "tool_use_id": "toolu_commentonlyedits_001" + }, + "expect": "allow", + "mustDeny": false, + "redundant": true, + "relevantQuestions": [ + "quote-instruction" + ] + }, + { + "id": "code-edit-after-trivial", + "note": "A trivial pass does not mark the file checked.", + "transcript": [ + { + "type": "user", + "uuid": "comment-only-edits-003", + "sessionId": "sess-comments", + "isSidechain": false, + "promptId": "prompt-comments-1", + "message": { + "role": "user", + "content": [ + { + "type": "tool_result", + "tool_use_id": "toolu_commentonlyedits_001", + "content": "ok" + } + ] + } + }, + { + "type": "assistant", + "uuid": "comment-only-edits-004", + "sessionId": "sess-comments", + "isSidechain": false, + "message": { + "id": "msg_commentonlyedits_02", + "type": "message", + "role": "assistant", + "model": "claude-sonnet-4-5", + "content": [ + { + "type": "tool_use", + "id": "toolu_commentonlyedits_002", + "name": "Edit", + "input": { + "file_path": "{{root}}/src/format/pad.js", + "old_string": " return s.padStart(n);", + "new_string": " return String(s).padStart(n);" + } + } + ] + } + } + ], + "payload": { + "session_id": "sess-comments", + "transcript_path": "{{transcript}}", + "cwd": "{{root}}", + "permission_mode": "default", + "hook_event_name": "PreToolUse", + "tool_name": "Edit", + "tool_input": { + "file_path": "{{root}}/src/format/pad.js", + "old_string": " return s.padStart(n);", + "new_string": " return String(s).padStart(n);" + }, + "tool_use_id": "toolu_commentonlyedits_002" + }, + "expect": "deny", + "mustDeny": false, + "redundant": false, + "relevantQuestions": [ + "local-callers", + "quote-instruction" + ] + }, + { + "id": "trivial-python-comment", + "transcript": [ + { + "type": "user", + "uuid": "comment-only-edits-005", + "sessionId": "sess-comments", + "isSidechain": false, + "promptId": "prompt-comments-1", + "message": { + "role": "user", + "content": [ + { + "type": "tool_result", + "tool_use_id": "toolu_commentonlyedits_002", + "content": "ok" + } + ] + } + }, + { + "type": "assistant", + "uuid": "comment-only-edits-006", + "sessionId": "sess-comments", + "isSidechain": false, + "message": { + "id": "msg_commentonlyedits_03", + "type": "message", + "role": "assistant", + "model": "claude-sonnet-4-5", + "content": [ + { + "type": "tool_use", + "id": "toolu_commentonlyedits_003", + "name": "Edit", + "input": { + "file_path": "{{root}}/tools/fetch.py", + "old_string": " # retry twice\n retries = 2", + "new_string": " # Retry up to two times before giving up.\n retries = 2" + } + } + ] + } + } + ], + "payload": { + "session_id": "sess-comments", + "transcript_path": "{{transcript}}", + "cwd": "{{root}}", + "permission_mode": "default", + "hook_event_name": "PreToolUse", + "tool_name": "Edit", + "tool_input": { + "file_path": "{{root}}/tools/fetch.py", + "old_string": " # retry twice\n retries = 2", + "new_string": " # Retry up to two times before giving up.\n retries = 2" + }, + "tool_use_id": "toolu_commentonlyedits_003" + }, + "expect": "allow", + "mustDeny": false, + "redundant": true, + "relevantQuestions": [ + "quote-instruction" + ] + }, + { + "id": "python-indentation-change", + "note": "Only whitespace changed, but indentation is code in Python.", + "transcript": [ + { + "type": "user", + "uuid": "comment-only-edits-007", + "sessionId": "sess-comments", + "isSidechain": false, + "promptId": "prompt-comments-1", + "message": { + "role": "user", + "content": [ + { + "type": "tool_result", + "tool_use_id": "toolu_commentonlyedits_003", + "content": "ok" + } + ] + } + }, + { + "type": "assistant", + "uuid": "comment-only-edits-008", + "sessionId": "sess-comments", + "isSidechain": false, + "message": { + "id": "msg_commentonlyedits_04", + "type": "message", + "role": "assistant", + "model": "claude-sonnet-4-5", + "content": [ + { + "type": "tool_use", + "id": "toolu_commentonlyedits_004", + "name": "Edit", + "input": { + "file_path": "{{root}}/src/format/align.py", + "old_string": " pad(width)\n flush()", + "new_string": " pad(width)\n flush()" + } + } + ] + } + } + ], + "payload": { + "session_id": "sess-comments", + "transcript_path": "{{transcript}}", + "cwd": "{{root}}", + "permission_mode": "default", + "hook_event_name": "PreToolUse", + "tool_name": "Edit", + "tool_input": { + "file_path": "{{root}}/src/format/align.py", + "old_string": " pad(width)\n flush()", + "new_string": " pad(width)\n flush()" + }, + "tool_use_id": "toolu_commentonlyedits_004" + }, + "expect": "deny", + "mustDeny": true, + "redundant": false, + "relevantQuestions": [ + "local-callers", + "quote-instruction" + ] + }, + { + "id": "comment-plus-code", + "transcript": [ + { + "type": "user", + "uuid": "comment-only-edits-009", + "sessionId": "sess-comments", + "isSidechain": false, + "promptId": "prompt-comments-1", + "message": { + "role": "user", + "content": [ + { + "type": "tool_result", + "tool_use_id": "toolu_commentonlyedits_004", + "content": "ok" + } + ] + } + }, + { + "type": "assistant", + "uuid": "comment-only-edits-010", + "sessionId": "sess-comments", + "isSidechain": false, + "message": { + "id": "msg_commentonlyedits_05", + "type": "message", + "role": "assistant", + "model": "claude-sonnet-4-5", + "content": [ + { + "type": "tool_use", + "id": "toolu_commentonlyedits_005", + "name": "Edit", + "input": { + "file_path": "{{root}}/src/format/wrap.js", + "old_string": "// wraps text\nfunction wrap(s) {\n return s;", + "new_string": "// Wraps text.\nfunction wrap(s) {\n return s.trim();" + } + } + ] + } + } + ], + "payload": { + "session_id": "sess-comments", + "transcript_path": "{{transcript}}", + "cwd": "{{root}}", + "permission_mode": "default", + "hook_event_name": "PreToolUse", + "tool_name": "Edit", + "tool_input": { + "file_path": "{{root}}/src/format/wrap.js", + "old_string": "// wraps text\nfunction wrap(s) {\n return s;", + "new_string": "// Wraps text.\nfunction wrap(s) {\n return s.trim();" + }, + "tool_use_id": "toolu_commentonlyedits_005" + }, + "expect": "deny", + "mustDeny": true, + "redundant": false, + "relevantQuestions": [ + "local-callers", + "quote-instruction" + ] + }, + { + "id": "unknown-extension-comment", + "note": "A comment-only change to a shell script; # starts a comment only at the start of a word.", + "transcript": [ + { + "type": "user", + "uuid": "comment-only-edits-011", + "sessionId": "sess-comments", + "isSidechain": false, + "promptId": "prompt-comments-1", + "message": { + "role": "user", + "content": [ + { + "type": "tool_result", + "tool_use_id": "toolu_commentonlyedits_005", + "content": "ok" + } + ] + } + }, + { + "type": "assistant", + "uuid": "comment-only-edits-012", + "sessionId": "sess-comments", + "isSidechain": false, + "message": { + "id": "msg_commentonlyedits_06", + "type": "message", + "role": "assistant", + "model": "claude-sonnet-4-5", + "content": [ + { + "type": "tool_use", + "id": "toolu_commentonlyedits_006", + "name": "Edit", + "input": { + "file_path": "{{root}}/scripts/build.sh", + "old_string": "# build\n", + "new_string": "# Build all targets.\n" + } + } + ] + } + } + ], + "payload": { + "session_id": "sess-comments", + "transcript_path": "{{transcript}}", + "cwd": "{{root}}", + "permission_mode": "default", + "hook_event_name": "PreToolUse", + "tool_name": "Edit", + "tool_input": { + "file_path": "{{root}}/scripts/build.sh", + "old_string": "# build\n", + "new_string": "# Build all targets.\n" + }, + "tool_use_id": "toolu_commentonlyedits_006" + }, + "expect": "allow", + "mustDeny": false, + "redundant": true, + "relevantQuestions": [ + "quote-instruction" + ] + }, + { + "id": "whitespace-reflow", + "transcript": [ + { + "type": "user", + "uuid": "comment-only-edits-013", + "sessionId": "sess-comments", + "isSidechain": false, + "promptId": "prompt-comments-1", + "message": { + "role": "user", + "content": [ + { + "type": "tool_result", + "tool_use_id": "toolu_commentonlyedits_006", + "content": "ok" + } + ] + } + }, + { + "type": "assistant", + "uuid": "comment-only-edits-014", + "sessionId": "sess-comments", + "isSidechain": false, + "message": { + "id": "msg_commentonlyedits_07", + "type": "message", + "role": "assistant", + "model": "claude-sonnet-4-5", + "content": [ + { + "type": "tool_use", + "id": "toolu_commentonlyedits_007", + "name": "Edit", + "input": { + "file_path": "{{root}}/src/format/trim.js", + "old_string": "function trim(a) {\n if (a) { return a.trim(); }", + "new_string": "function trim(a) {\n\n if (a) { return a.trim(); }" + } + } + ] + } + } + ], + "payload": { + "session_id": "sess-comments", + "transcript_path": "{{transcript}}", + "cwd": "{{root}}", + "permission_mode": "default", + "hook_event_name": "PreToolUse", + "tool_name": "Edit", + "tool_input": { + "file_path": "{{root}}/src/format/trim.js", + "old_string": "function trim(a) {\n if (a) { return a.trim(); }", + "new_string": "function trim(a) {\n\n if (a) { return a.trim(); }" + }, + "tool_use_id": "toolu_commentonlyedits_007" + }, + "expect": "allow", + "mustDeny": false, + "redundant": true, + "relevantQuestions": [ + "quote-instruction" + ] + }, + { + "id": "template-literal-disguise", + "note": "Text after // sits inside a template literal.", + "transcript": [ + { + "type": "user", + "uuid": "comment-only-edits-015", + "sessionId": "sess-comments", + "isSidechain": false, + "promptId": "prompt-comments-1", + "message": { + "role": "user", + "content": [ + { + "type": "tool_result", + "tool_use_id": "toolu_commentonlyedits_007", + "content": "ok" + } + ] + } + }, + { + "type": "assistant", + "uuid": "comment-only-edits-016", + "sessionId": "sess-comments", + "isSidechain": false, + "message": { + "id": "msg_commentonlyedits_08", + "type": "message", + "role": "assistant", + "model": "claude-sonnet-4-5", + "content": [ + { + "type": "tool_use", + "id": "toolu_commentonlyedits_008", + "name": "Edit", + "input": { + "file_path": "{{root}}/src/format/banner.js", + "old_string": "const banner = `v1 // stable`;", + "new_string": "const banner = `v2 // stable`;" + } + } + ] + } + } + ], + "payload": { + "session_id": "sess-comments", + "transcript_path": "{{transcript}}", + "cwd": "{{root}}", + "permission_mode": "default", + "hook_event_name": "PreToolUse", + "tool_name": "Edit", + "tool_input": { + "file_path": "{{root}}/src/format/banner.js", + "old_string": "const banner = `v1 // stable`;", + "new_string": "const banner = `v2 // stable`;" + }, + "tool_use_id": "toolu_commentonlyedits_008" + }, + "expect": "deny", + "mustDeny": true, + "redundant": false, + "relevantQuestions": [ + "local-callers", + "quote-instruction" + ] + }, + { + "id": "python-hash-in-string", + "note": "The # is inside a string; BASE is a public module constant.", + "transcript": [ + { + "type": "user", + "uuid": "comment-only-edits-017", + "sessionId": "sess-comments", + "isSidechain": false, + "promptId": "prompt-comments-1", + "message": { + "role": "user", + "content": [ + { + "type": "tool_result", + "tool_use_id": "toolu_commentonlyedits_008", + "content": "ok" + } + ] + } + }, + { + "type": "assistant", + "uuid": "comment-only-edits-018", + "sessionId": "sess-comments", + "isSidechain": false, + "message": { + "id": "msg_commentonlyedits_09", + "type": "message", + "role": "assistant", + "model": "claude-sonnet-4-5", + "content": [ + { + "type": "tool_use", + "id": "toolu_commentonlyedits_009", + "name": "Edit", + "input": { + "file_path": "{{root}}/tools/links.py", + "old_string": "BASE = \"https://example.test/#home\"", + "new_string": "BASE = \"https://example.test/#admin\"" + } + } + ] + } + } + ], + "payload": { + "session_id": "sess-comments", + "transcript_path": "{{transcript}}", + "cwd": "{{root}}", + "permission_mode": "default", + "hook_event_name": "PreToolUse", + "tool_name": "Edit", + "tool_input": { + "file_path": "{{root}}/tools/links.py", + "old_string": "BASE = \"https://example.test/#home\"", + "new_string": "BASE = \"https://example.test/#admin\"" + }, + "tool_use_id": "toolu_commentonlyedits_009" + }, + "expect": "deny", + "mustDeny": true, + "redundant": false, + "relevantQuestions": [ + "importers", + "public-api", + "quote-instruction" + ] + }, + { + "id": "c-comment-continuation", + "note": "A trailing backslash continues the comment over the declaration.", + "transcript": [ + { + "type": "user", + "uuid": "comment-only-edits-019", + "sessionId": "sess-comments", + "isSidechain": false, + "promptId": "prompt-comments-1", + "message": { + "role": "user", + "content": [ + { + "type": "tool_result", + "tool_use_id": "toolu_commentonlyedits_009", + "content": "ok" + } + ] + } + }, + { + "type": "assistant", + "uuid": "comment-only-edits-020", + "sessionId": "sess-comments", + "isSidechain": false, + "message": { + "id": "msg_commentonlyedits_10", + "type": "message", + "role": "assistant", + "model": "claude-sonnet-4-5", + "content": [ + { + "type": "tool_use", + "id": "toolu_commentonlyedits_010", + "name": "Edit", + "input": { + "file_path": "{{root}}/src/native/buf.c", + "old_string": "// buffer size\nint size = 64;", + "new_string": "// buffer size \\\nint size = 64;" + } + } + ] + } + } + ], + "payload": { + "session_id": "sess-comments", + "transcript_path": "{{transcript}}", + "cwd": "{{root}}", + "permission_mode": "default", + "hook_event_name": "PreToolUse", + "tool_name": "Edit", + "tool_input": { + "file_path": "{{root}}/src/native/buf.c", + "old_string": "// buffer size\nint size = 64;", + "new_string": "// buffer size \\\nint size = 64;" + }, + "tool_use_id": "toolu_commentonlyedits_010" + }, + "expect": "deny", + "mustDeny": true, + "redundant": false, + "relevantQuestions": [ + "importers", + "public-api", + "data-schema", + "external-contract", + "quote-instruction" + ] + } + ] +} diff --git a/tests/fixtures/gateguard-scenarios/09-sensitive-targets.json b/tests/fixtures/gateguard-scenarios/09-sensitive-targets.json new file mode 100644 index 000000000..b10276418 --- /dev/null +++ b/tests/fixtures/gateguard-scenarios/09-sensitive-targets.json @@ -0,0 +1,651 @@ +{ + "name": "sensitive-targets", + "description": "Secrets, auth code, CI workflows, migrations and keys, including a symlinked alias and searches that would otherwise credit.", + "files": { + "src/auth/session.js": "'use strict';\n\nfunction sign(payload, key) {\n return hmac(key, payload);\n}\n\nmodule.exports = { sign };\n", + "src/auth/token.js": "// token helpers\nfunction mint() {\n return random(32);\n}\n", + ".github/workflows/ci.yml": "name: ci\n# run tests\non: [push]\n", + "db/migrations/0001_init.sql": "CREATE TABLE users (id INT);\n", + "certs/server.pem": "-----BEGIN CERTIFICATE-----\nMIIB\n-----END CERTIFICATE-----\n" + }, + "symlinks": { + "lib/vault": "../src/auth" + }, + "steps": [ + { + "id": "edit-auth-after-search", + "note": "A crediting search never credits a sensitive target.", + "transcript": [ + { + "type": "user", + "uuid": "sensitive-targets-001", + "sessionId": "sess-sensitive", + "isSidechain": false, + "promptId": "prompt-sensitive-1", + "message": { + "role": "user", + "content": "Rotate the session signing key handling and update CI." + } + }, + { + "type": "assistant", + "uuid": "sensitive-targets-002", + "sessionId": "sess-sensitive", + "isSidechain": false, + "message": { + "id": "msg_sensitivetargets_01", + "type": "message", + "role": "assistant", + "model": "claude-sonnet-4-5", + "content": [ + { + "type": "tool_use", + "id": "toolu_sensitivetargets_001", + "name": "Grep", + "input": { + "pattern": "session", + "path": "{{root}}/src/auth" + } + } + ] + } + }, + { + "type": "user", + "uuid": "sensitive-targets-003", + "sessionId": "sess-sensitive", + "isSidechain": false, + "promptId": "prompt-sensitive-1", + "message": { + "role": "user", + "content": [ + { + "type": "tool_result", + "tool_use_id": "toolu_sensitivetargets_001", + "content": "ok" + } + ] + } + }, + { + "type": "assistant", + "uuid": "sensitive-targets-004", + "sessionId": "sess-sensitive", + "isSidechain": false, + "message": { + "id": "msg_sensitivetargets_02", + "type": "message", + "role": "assistant", + "model": "claude-sonnet-4-5", + "content": [ + { + "type": "tool_use", + "id": "toolu_sensitivetargets_002", + "name": "Read", + "input": { + "file_path": "{{root}}/src/auth/session.js" + } + } + ] + } + }, + { + "type": "user", + "uuid": "sensitive-targets-005", + "sessionId": "sess-sensitive", + "isSidechain": false, + "promptId": "prompt-sensitive-1", + "message": { + "role": "user", + "content": [ + { + "type": "tool_result", + "tool_use_id": "toolu_sensitivetargets_002", + "content": "ok" + } + ] + } + }, + { + "type": "assistant", + "uuid": "sensitive-targets-006", + "sessionId": "sess-sensitive", + "isSidechain": false, + "message": { + "id": "msg_sensitivetargets_03", + "type": "message", + "role": "assistant", + "model": "claude-sonnet-4-5", + "content": [ + { + "type": "tool_use", + "id": "toolu_sensitivetargets_003", + "name": "Edit", + "input": { + "file_path": "{{root}}/src/auth/session.js", + "old_string": " return hmac(key, payload);", + "new_string": " return hmac(rotate(key), payload);" + } + } + ] + } + } + ], + "payload": { + "session_id": "sess-sensitive", + "transcript_path": "{{transcript}}", + "cwd": "{{root}}", + "permission_mode": "default", + "hook_event_name": "PreToolUse", + "tool_name": "Edit", + "tool_input": { + "file_path": "{{root}}/src/auth/session.js", + "old_string": " return hmac(key, payload);", + "new_string": " return hmac(rotate(key), payload);" + }, + "tool_use_id": "toolu_sensitivetargets_003" + }, + "expect": "deny", + "mustDeny": true, + "redundant": false, + "relevantQuestions": [ + "importers", + "public-api", + "data-schema", + "external-contract", + "quote-instruction" + ] + }, + { + "id": "write-env", + "transcript": [ + { + "type": "user", + "uuid": "sensitive-targets-007", + "sessionId": "sess-sensitive", + "isSidechain": false, + "promptId": "prompt-sensitive-1", + "message": { + "role": "user", + "content": [ + { + "type": "tool_result", + "tool_use_id": "toolu_sensitivetargets_003", + "content": "ok" + } + ] + } + }, + { + "type": "assistant", + "uuid": "sensitive-targets-008", + "sessionId": "sess-sensitive", + "isSidechain": false, + "message": { + "id": "msg_sensitivetargets_04", + "type": "message", + "role": "assistant", + "model": "claude-sonnet-4-5", + "content": [ + { + "type": "tool_use", + "id": "toolu_sensitivetargets_004", + "name": "Write", + "input": { + "file_path": "{{root}}/.env", + "content": "SESSION_KEY=example\n" + } + } + ] + } + } + ], + "payload": { + "session_id": "sess-sensitive", + "transcript_path": "{{transcript}}", + "cwd": "{{root}}", + "permission_mode": "default", + "hook_event_name": "PreToolUse", + "tool_name": "Write", + "tool_input": { + "file_path": "{{root}}/.env", + "content": "SESSION_KEY=example\n" + }, + "tool_use_id": "toolu_sensitivetargets_004" + }, + "expect": "deny", + "mustDeny": true, + "redundant": false + }, + { + "id": "workflow-comment-only", + "transcript": [ + { + "type": "user", + "uuid": "sensitive-targets-009", + "sessionId": "sess-sensitive", + "isSidechain": false, + "promptId": "prompt-sensitive-1", + "message": { + "role": "user", + "content": [ + { + "type": "tool_result", + "tool_use_id": "toolu_sensitivetargets_004", + "content": "ok" + } + ] + } + }, + { + "type": "assistant", + "uuid": "sensitive-targets-010", + "sessionId": "sess-sensitive", + "isSidechain": false, + "message": { + "id": "msg_sensitivetargets_05", + "type": "message", + "role": "assistant", + "model": "claude-sonnet-4-5", + "content": [ + { + "type": "tool_use", + "id": "toolu_sensitivetargets_005", + "name": "Edit", + "input": { + "file_path": "{{root}}/.github/workflows/ci.yml", + "old_string": "# run tests\n", + "new_string": "# Run the full test suite.\n" + } + } + ] + } + } + ], + "payload": { + "session_id": "sess-sensitive", + "transcript_path": "{{transcript}}", + "cwd": "{{root}}", + "permission_mode": "default", + "hook_event_name": "PreToolUse", + "tool_name": "Edit", + "tool_input": { + "file_path": "{{root}}/.github/workflows/ci.yml", + "old_string": "# run tests\n", + "new_string": "# Run the full test suite.\n" + }, + "tool_use_id": "toolu_sensitivetargets_005" + }, + "expect": "deny", + "mustDeny": true, + "redundant": false + }, + { + "id": "edit-symlink-alias", + "note": "lib/vault is a symlink to src/auth.", + "transcript": [ + { + "type": "user", + "uuid": "sensitive-targets-011", + "sessionId": "sess-sensitive", + "isSidechain": false, + "promptId": "prompt-sensitive-1", + "message": { + "role": "user", + "content": [ + { + "type": "tool_result", + "tool_use_id": "toolu_sensitivetargets_005", + "content": "ok" + } + ] + } + }, + { + "type": "assistant", + "uuid": "sensitive-targets-012", + "sessionId": "sess-sensitive", + "isSidechain": false, + "message": { + "id": "msg_sensitivetargets_06", + "type": "message", + "role": "assistant", + "model": "claude-sonnet-4-5", + "content": [ + { + "type": "tool_use", + "id": "toolu_sensitivetargets_006", + "name": "Bash", + "input": { + "command": "rg -n token lib/vault" + } + } + ] + } + }, + { + "type": "user", + "uuid": "sensitive-targets-013", + "sessionId": "sess-sensitive", + "isSidechain": false, + "promptId": "prompt-sensitive-1", + "message": { + "role": "user", + "content": [ + { + "type": "tool_result", + "tool_use_id": "toolu_sensitivetargets_006", + "content": "ok" + } + ] + } + }, + { + "type": "assistant", + "uuid": "sensitive-targets-014", + "sessionId": "sess-sensitive", + "isSidechain": false, + "message": { + "id": "msg_sensitivetargets_07", + "type": "message", + "role": "assistant", + "model": "claude-sonnet-4-5", + "content": [ + { + "type": "tool_use", + "id": "toolu_sensitivetargets_007", + "name": "Edit", + "input": { + "file_path": "{{root}}/lib/vault/token.js", + "old_string": " return random(32);", + "new_string": " return random(64);" + } + } + ] + } + } + ], + "payload": { + "session_id": "sess-sensitive", + "transcript_path": "{{transcript}}", + "cwd": "{{root}}", + "permission_mode": "default", + "hook_event_name": "PreToolUse", + "tool_name": "Edit", + "tool_input": { + "file_path": "{{root}}/lib/vault/token.js", + "old_string": " return random(32);", + "new_string": " return random(64);" + }, + "tool_use_id": "toolu_sensitivetargets_007" + }, + "expect": "deny", + "mustDeny": true, + "redundant": false, + "relevantQuestions": [ + "importers", + "public-api", + "data-schema", + "external-contract", + "quote-instruction" + ] + }, + { + "id": "write-migration", + "transcript": [ + { + "type": "user", + "uuid": "sensitive-targets-015", + "sessionId": "sess-sensitive", + "isSidechain": false, + "promptId": "prompt-sensitive-1", + "message": { + "role": "user", + "content": [ + { + "type": "tool_result", + "tool_use_id": "toolu_sensitivetargets_007", + "content": "ok" + } + ] + } + }, + { + "type": "assistant", + "uuid": "sensitive-targets-016", + "sessionId": "sess-sensitive", + "isSidechain": false, + "message": { + "id": "msg_sensitivetargets_08", + "type": "message", + "role": "assistant", + "model": "claude-sonnet-4-5", + "content": [ + { + "type": "tool_use", + "id": "toolu_sensitivetargets_008", + "name": "Write", + "input": { + "file_path": "{{root}}/db/migrations/0002_add_index.sql", + "content": "CREATE INDEX users_id ON users (id);\n" + } + } + ] + } + } + ], + "payload": { + "session_id": "sess-sensitive", + "transcript_path": "{{transcript}}", + "cwd": "{{root}}", + "permission_mode": "default", + "hook_event_name": "PreToolUse", + "tool_name": "Write", + "tool_input": { + "file_path": "{{root}}/db/migrations/0002_add_index.sql", + "content": "CREATE INDEX users_id ON users (id);\n" + }, + "tool_use_id": "toolu_sensitivetargets_008" + }, + "expect": "deny", + "mustDeny": true, + "redundant": false + }, + { + "id": "write-migration-sibling", + "note": "Sensitive directories never collapse.", + "transcript": [ + { + "type": "user", + "uuid": "sensitive-targets-017", + "sessionId": "sess-sensitive", + "isSidechain": false, + "promptId": "prompt-sensitive-1", + "message": { + "role": "user", + "content": [ + { + "type": "tool_result", + "tool_use_id": "toolu_sensitivetargets_008", + "content": "ok" + } + ] + } + }, + { + "type": "assistant", + "uuid": "sensitive-targets-018", + "sessionId": "sess-sensitive", + "isSidechain": false, + "message": { + "id": "msg_sensitivetargets_09", + "type": "message", + "role": "assistant", + "model": "claude-sonnet-4-5", + "content": [ + { + "type": "tool_use", + "id": "toolu_sensitivetargets_009", + "name": "Write", + "input": { + "file_path": "{{root}}/db/migrations/0003_backfill.sql", + "content": "UPDATE users SET id = id;\n" + } + } + ] + } + } + ], + "payload": { + "session_id": "sess-sensitive", + "transcript_path": "{{transcript}}", + "cwd": "{{root}}", + "permission_mode": "default", + "hook_event_name": "PreToolUse", + "tool_name": "Write", + "tool_input": { + "file_path": "{{root}}/db/migrations/0003_backfill.sql", + "content": "UPDATE users SET id = id;\n" + }, + "tool_use_id": "toolu_sensitivetargets_009" + }, + "expect": "deny", + "mustDeny": true, + "redundant": false + }, + { + "id": "edit-certificate", + "transcript": [ + { + "type": "user", + "uuid": "sensitive-targets-019", + "sessionId": "sess-sensitive", + "isSidechain": false, + "promptId": "prompt-sensitive-1", + "message": { + "role": "user", + "content": [ + { + "type": "tool_result", + "tool_use_id": "toolu_sensitivetargets_009", + "content": "ok" + } + ] + } + }, + { + "type": "assistant", + "uuid": "sensitive-targets-020", + "sessionId": "sess-sensitive", + "isSidechain": false, + "message": { + "id": "msg_sensitivetargets_10", + "type": "message", + "role": "assistant", + "model": "claude-sonnet-4-5", + "content": [ + { + "type": "tool_use", + "id": "toolu_sensitivetargets_010", + "name": "Edit", + "input": { + "file_path": "{{root}}/certs/server.pem", + "old_string": "MIIB", + "new_string": "MIIC" + } + } + ] + } + } + ], + "payload": { + "session_id": "sess-sensitive", + "transcript_path": "{{transcript}}", + "cwd": "{{root}}", + "permission_mode": "default", + "hook_event_name": "PreToolUse", + "tool_name": "Edit", + "tool_input": { + "file_path": "{{root}}/certs/server.pem", + "old_string": "MIIB", + "new_string": "MIIC" + }, + "tool_use_id": "toolu_sensitivetargets_010" + }, + "expect": "deny", + "mustDeny": true, + "redundant": false + }, + { + "id": "auth-comment-only", + "note": "Sensitive targets are never trivial.", + "transcript": [ + { + "type": "user", + "uuid": "sensitive-targets-021", + "sessionId": "sess-sensitive", + "isSidechain": false, + "promptId": "prompt-sensitive-1", + "message": { + "role": "user", + "content": [ + { + "type": "tool_result", + "tool_use_id": "toolu_sensitivetargets_010", + "content": "ok" + } + ] + } + }, + { + "type": "assistant", + "uuid": "sensitive-targets-022", + "sessionId": "sess-sensitive", + "isSidechain": false, + "message": { + "id": "msg_sensitivetargets_11", + "type": "message", + "role": "assistant", + "model": "claude-sonnet-4-5", + "content": [ + { + "type": "tool_use", + "id": "toolu_sensitivetargets_011", + "name": "Edit", + "input": { + "file_path": "{{root}}/src/auth/token.js", + "old_string": "// token helpers\nfunction mint() {", + "new_string": "// Token helpers.\nfunction mint() {" + } + } + ] + } + } + ], + "payload": { + "session_id": "sess-sensitive", + "transcript_path": "{{transcript}}", + "cwd": "{{root}}", + "permission_mode": "default", + "hook_event_name": "PreToolUse", + "tool_name": "Edit", + "tool_input": { + "file_path": "{{root}}/src/auth/token.js", + "old_string": "// token helpers\nfunction mint() {", + "new_string": "// Token helpers.\nfunction mint() {" + }, + "tool_use_id": "toolu_sensitivetargets_011" + }, + "expect": "deny", + "mustDeny": true, + "redundant": false, + "relevantQuestions": [ + "importers", + "public-api", + "data-schema", + "external-contract", + "quote-instruction" + ] + } + ] +} diff --git a/tests/fixtures/gateguard-scenarios/10-subagent-edits.json b/tests/fixtures/gateguard-scenarios/10-subagent-edits.json new file mode 100644 index 000000000..510489599 --- /dev/null +++ b/tests/fixtures/gateguard-scenarios/10-subagent-edits.json @@ -0,0 +1,418 @@ +{ + "name": "subagent-edits", + "description": "A subagent editing ordinary and sensitive files, its retry, and the parent touching the same sensitive file.", + "files": { + "src/ui/banner.js": "module.exports = { text: 'Welcome' };\n", + "src/auth/oauth.js": "const scopes = ['read'];\n\nmodule.exports = { scopes };\n", + "config/secrets.yaml": "client_secret: example\n" + }, + "steps": [ + { + "id": "subagent-edit-banner", + "note": "The parent was gated on the delegated work.", + "transcript": [ + { + "type": "user", + "uuid": "subagent-edits-001", + "sessionId": "sess-subagent", + "isSidechain": false, + "promptId": "prompt-subagent-1", + "message": { + "role": "user", + "content": "Have a subagent update the banner text and the OAuth scopes." + } + }, + { + "type": "assistant", + "uuid": "subagent-edits-002", + "sessionId": "sess-subagent", + "isSidechain": false, + "message": { + "id": "msg_subagentedits_01", + "type": "message", + "role": "assistant", + "model": "claude-sonnet-4-5", + "content": [ + { + "type": "tool_use", + "id": "toolu_subagentedits_001", + "name": "Edit", + "input": { + "file_path": "{{root}}/src/ui/banner.js", + "old_string": "'Welcome'", + "new_string": "'Welcome back'" + } + } + ] + } + } + ], + "payload": { + "session_id": "sess-subagent", + "transcript_path": "{{transcript}}", + "cwd": "{{root}}", + "permission_mode": "default", + "hook_event_name": "PreToolUse", + "tool_name": "Edit", + "tool_input": { + "file_path": "{{root}}/src/ui/banner.js", + "old_string": "'Welcome'", + "new_string": "'Welcome back'" + }, + "tool_use_id": "toolu_subagentedits_001", + "agent_id": "agent-banner-oauth", + "agent_type": "general-purpose" + }, + "expect": "allow", + "mustDeny": false, + "redundant": true + }, + { + "id": "subagent-edit-oauth", + "transcript": [ + { + "type": "user", + "uuid": "subagent-edits-003", + "sessionId": "sess-subagent", + "isSidechain": false, + "promptId": "prompt-subagent-1", + "message": { + "role": "user", + "content": [ + { + "type": "tool_result", + "tool_use_id": "toolu_subagentedits_001", + "content": "ok" + } + ] + } + }, + { + "type": "assistant", + "uuid": "subagent-edits-004", + "sessionId": "sess-subagent", + "isSidechain": false, + "message": { + "id": "msg_subagentedits_02", + "type": "message", + "role": "assistant", + "model": "claude-sonnet-4-5", + "content": [ + { + "type": "tool_use", + "id": "toolu_subagentedits_002", + "name": "Edit", + "input": { + "file_path": "{{root}}/src/auth/oauth.js", + "old_string": "['read']", + "new_string": "['read', 'profile']" + } + } + ] + } + } + ], + "payload": { + "session_id": "sess-subagent", + "transcript_path": "{{transcript}}", + "cwd": "{{root}}", + "permission_mode": "default", + "hook_event_name": "PreToolUse", + "tool_name": "Edit", + "tool_input": { + "file_path": "{{root}}/src/auth/oauth.js", + "old_string": "['read']", + "new_string": "['read', 'profile']" + }, + "tool_use_id": "toolu_subagentedits_002", + "agent_id": "agent-banner-oauth", + "agent_type": "general-purpose" + }, + "expect": "deny", + "mustDeny": true, + "redundant": false, + "relevantQuestions": [ + "importers", + "public-api", + "data-schema", + "external-contract", + "quote-instruction" + ] + }, + { + "id": "subagent-edit-oauth-retry", + "note": "Retry after presenting the facts.", + "transcript": [ + { + "type": "user", + "uuid": "subagent-edits-005", + "sessionId": "sess-subagent", + "isSidechain": false, + "promptId": "prompt-subagent-1", + "message": { + "role": "user", + "content": [ + { + "type": "tool_result", + "tool_use_id": "toolu_subagentedits_002", + "content": "ok" + } + ] + } + }, + { + "type": "assistant", + "uuid": "subagent-edits-006", + "sessionId": "sess-subagent", + "isSidechain": false, + "message": { + "id": "msg_subagentedits_03", + "type": "message", + "role": "assistant", + "model": "claude-sonnet-4-5", + "content": [ + { + "type": "tool_use", + "id": "toolu_subagentedits_003", + "name": "Edit", + "input": { + "file_path": "{{root}}/src/auth/oauth.js", + "old_string": "['read']", + "new_string": "['read', 'profile']" + } + } + ] + } + } + ], + "payload": { + "session_id": "sess-subagent", + "transcript_path": "{{transcript}}", + "cwd": "{{root}}", + "permission_mode": "default", + "hook_event_name": "PreToolUse", + "tool_name": "Edit", + "tool_input": { + "file_path": "{{root}}/src/auth/oauth.js", + "old_string": "['read']", + "new_string": "['read', 'profile']" + }, + "tool_use_id": "toolu_subagentedits_003", + "agent_id": "agent-banner-oauth", + "agent_type": "general-purpose" + }, + "expect": "allow", + "mustDeny": false, + "redundant": false + }, + { + "id": "parent-edit-oauth", + "note": "The subagent retry does not unlock the path for the parent.", + "transcript": [ + { + "type": "user", + "uuid": "subagent-edits-007", + "sessionId": "sess-subagent", + "isSidechain": false, + "promptId": "prompt-subagent-1", + "message": { + "role": "user", + "content": [ + { + "type": "tool_result", + "tool_use_id": "toolu_subagentedits_003", + "content": "ok" + } + ] + } + }, + { + "type": "assistant", + "uuid": "subagent-edits-008", + "sessionId": "sess-subagent", + "isSidechain": false, + "message": { + "id": "msg_subagentedits_04", + "type": "message", + "role": "assistant", + "model": "claude-sonnet-4-5", + "content": [ + { + "type": "tool_use", + "id": "toolu_subagentedits_004", + "name": "Edit", + "input": { + "file_path": "{{root}}/src/auth/oauth.js", + "old_string": "['read', 'profile']", + "new_string": "['read', 'profile', 'email']" + } + } + ] + } + } + ], + "payload": { + "session_id": "sess-subagent", + "transcript_path": "{{transcript}}", + "cwd": "{{root}}", + "permission_mode": "default", + "hook_event_name": "PreToolUse", + "tool_name": "Edit", + "tool_input": { + "file_path": "{{root}}/src/auth/oauth.js", + "old_string": "['read', 'profile']", + "new_string": "['read', 'profile', 'email']" + }, + "tool_use_id": "toolu_subagentedits_004" + }, + "expect": "deny", + "mustDeny": true, + "redundant": false, + "relevantQuestions": [ + "importers", + "public-api", + "data-schema", + "external-contract", + "quote-instruction" + ] + }, + { + "id": "subagent-multiedit-secrets", + "transcript": [ + { + "type": "user", + "uuid": "subagent-edits-009", + "sessionId": "sess-subagent", + "isSidechain": false, + "promptId": "prompt-subagent-1", + "message": { + "role": "user", + "content": [ + { + "type": "tool_result", + "tool_use_id": "toolu_subagentedits_004", + "content": "ok" + } + ] + } + }, + { + "type": "assistant", + "uuid": "subagent-edits-010", + "sessionId": "sess-subagent", + "isSidechain": false, + "message": { + "id": "msg_subagentedits_05", + "type": "message", + "role": "assistant", + "model": "claude-sonnet-4-5", + "content": [ + { + "type": "tool_use", + "id": "toolu_subagentedits_005", + "name": "MultiEdit", + "input": { + "file_path": "{{root}}/config/secrets.yaml", + "edits": [ + { + "file_path": "{{root}}/config/secrets.yaml", + "old_string": "example", + "new_string": "rotated" + } + ] + } + } + ] + } + } + ], + "payload": { + "session_id": "sess-subagent", + "transcript_path": "{{transcript}}", + "cwd": "{{root}}", + "permission_mode": "default", + "hook_event_name": "PreToolUse", + "tool_name": "MultiEdit", + "tool_input": { + "file_path": "{{root}}/config/secrets.yaml", + "edits": [ + { + "file_path": "{{root}}/config/secrets.yaml", + "old_string": "example", + "new_string": "rotated" + } + ] + }, + "tool_use_id": "toolu_subagentedits_005", + "agent_id": "agent-banner-oauth", + "agent_type": "general-purpose" + }, + "expect": "deny", + "mustDeny": true, + "redundant": false + }, + { + "id": "subagent-write-footer", + "transcript": [ + { + "type": "user", + "uuid": "subagent-edits-011", + "sessionId": "sess-subagent", + "isSidechain": false, + "promptId": "prompt-subagent-1", + "message": { + "role": "user", + "content": [ + { + "type": "tool_result", + "tool_use_id": "toolu_subagentedits_005", + "content": "ok" + } + ] + } + }, + { + "type": "assistant", + "uuid": "subagent-edits-012", + "sessionId": "sess-subagent", + "isSidechain": false, + "message": { + "id": "msg_subagentedits_06", + "type": "message", + "role": "assistant", + "model": "claude-sonnet-4-5", + "content": [ + { + "type": "tool_use", + "id": "toolu_subagentedits_006", + "name": "Write", + "input": { + "file_path": "{{root}}/src/ui/footer.js", + "content": "module.exports = { text: 'Bye' };\n" + } + } + ] + } + } + ], + "payload": { + "session_id": "sess-subagent", + "transcript_path": "{{transcript}}", + "cwd": "{{root}}", + "permission_mode": "default", + "hook_event_name": "PreToolUse", + "tool_name": "Write", + "tool_input": { + "file_path": "{{root}}/src/ui/footer.js", + "content": "module.exports = { text: 'Bye' };\n" + }, + "tool_use_id": "toolu_subagentedits_006", + "agent_id": "agent-banner-oauth", + "agent_type": "general-purpose" + }, + "expect": "allow", + "mustDeny": false, + "redundant": true + } + ] +} diff --git a/tests/fixtures/gateguard-scenarios/11-first-shell-commands.json b/tests/fixtures/gateguard-scenarios/11-first-shell-commands.json new file mode 100644 index 000000000..47678aaa8 --- /dev/null +++ b/tests/fixtures/gateguard-scenarios/11-first-shell-commands.json @@ -0,0 +1,390 @@ +{ + "name": "first-shell-commands", + "description": "First shell command of fresh sessions: read-only introspection, mutating commands, destructive commands and commands that only look read-only.", + "files": { + "package.json": "{ \"name\": \"shell-app\" }\n", + "src/index.js": "module.exports = {};\n", + ".env": "TOKEN=example\n" + }, + "steps": [ + { + "id": "git-status", + "transcript": [], + "payload": { + "session_id": "sess-shell-01", + "cwd": "{{root}}", + "permission_mode": "default", + "hook_event_name": "PreToolUse", + "tool_name": "Bash", + "tool_input": { + "command": "git status --short", + "description": "git-status" + }, + "tool_use_id": "toolu_firstshellcommands_001" + }, + "expect": "allow", + "mustDeny": false, + "redundant": true + }, + { + "id": "ls-src", + "transcript": [], + "payload": { + "session_id": "sess-shell-ls", + "cwd": "{{root}}", + "permission_mode": "default", + "hook_event_name": "PreToolUse", + "tool_name": "Bash", + "tool_input": { + "command": "ls -la src", + "description": "ls-src" + }, + "tool_use_id": "toolu_firstshellcommands_002" + }, + "expect": "allow", + "mustDeny": false, + "redundant": true + }, + { + "id": "make-after-ls", + "note": "A read-only pass does not consume the routine gate.", + "transcript": [], + "payload": { + "session_id": "sess-shell-ls", + "cwd": "{{root}}", + "permission_mode": "default", + "hook_event_name": "PreToolUse", + "tool_name": "Bash", + "tool_input": { + "command": "make build", + "description": "make-after-ls" + }, + "tool_use_id": "toolu_firstshellcommands_003" + }, + "expect": "deny", + "mustDeny": false, + "redundant": false + }, + { + "id": "cat-pipe-head", + "transcript": [], + "payload": { + "session_id": "sess-shell-02", + "cwd": "{{root}}", + "permission_mode": "default", + "hook_event_name": "PreToolUse", + "tool_name": "Bash", + "tool_input": { + "command": "cat package.json | head -20", + "description": "cat-pipe-head" + }, + "tool_use_id": "toolu_firstshellcommands_004" + }, + "expect": "allow", + "mustDeny": false, + "redundant": true + }, + { + "id": "rg-glob", + "transcript": [], + "payload": { + "session_id": "sess-shell-03", + "cwd": "{{root}}", + "permission_mode": "default", + "hook_event_name": "PreToolUse", + "tool_name": "Bash", + "tool_input": { + "command": "rg -n TODO src --glob '*.js'", + "description": "rg-glob" + }, + "tool_use_id": "toolu_firstshellcommands_005" + }, + "expect": "allow", + "mustDeny": false, + "redundant": true + }, + { + "id": "find-name", + "transcript": [], + "payload": { + "session_id": "sess-shell-04", + "cwd": "{{root}}", + "permission_mode": "default", + "hook_event_name": "PreToolUse", + "tool_name": "Bash", + "tool_input": { + "command": "find src -name '*.test.js'", + "description": "find-name" + }, + "tool_use_id": "toolu_firstshellcommands_006" + }, + "expect": "allow", + "mustDeny": false, + "redundant": true + }, + { + "id": "git-log-path", + "transcript": [], + "payload": { + "session_id": "sess-shell-05", + "cwd": "{{root}}", + "permission_mode": "default", + "hook_event_name": "PreToolUse", + "tool_name": "Bash", + "tool_input": { + "command": "git log --oneline -n 5 -- src", + "description": "git-log-path" + }, + "tool_use_id": "toolu_firstshellcommands_007" + }, + "expect": "allow", + "mustDeny": false, + "redundant": true + }, + { + "id": "git-diff-range", + "transcript": [], + "payload": { + "session_id": "sess-shell-06", + "cwd": "{{root}}", + "permission_mode": "default", + "hook_event_name": "PreToolUse", + "tool_name": "Bash", + "tool_input": { + "command": "git diff HEAD~3 -- src", + "description": "git-diff-range" + }, + "tool_use_id": "toolu_firstshellcommands_008" + }, + "expect": "allow", + "mustDeny": false, + "redundant": true + }, + { + "id": "powershell-gci", + "transcript": [], + "payload": { + "session_id": "sess-shell-07", + "cwd": "{{root}}", + "permission_mode": "default", + "hook_event_name": "PreToolUse", + "tool_name": "PowerShell", + "tool_input": { + "command": "Get-ChildItem -Path src -Recurse -Filter *.js" + }, + "tool_use_id": "toolu_firstshellcommands_009" + }, + "expect": "allow", + "mustDeny": false, + "redundant": true + }, + { + "id": "npm-install", + "transcript": [], + "payload": { + "session_id": "sess-shell-08", + "cwd": "{{root}}", + "permission_mode": "default", + "hook_event_name": "PreToolUse", + "tool_name": "Bash", + "tool_input": { + "command": "npm install left-pad", + "description": "npm-install" + }, + "tool_use_id": "toolu_firstshellcommands_010" + }, + "expect": "deny", + "mustDeny": true, + "redundant": false + }, + { + "id": "readonly-then-publish", + "transcript": [], + "payload": { + "session_id": "sess-shell-09", + "cwd": "{{root}}", + "permission_mode": "default", + "hook_event_name": "PreToolUse", + "tool_name": "Bash", + "tool_input": { + "command": "git status && npm publish", + "description": "readonly-then-publish" + }, + "tool_use_id": "toolu_firstshellcommands_011" + }, + "expect": "deny", + "mustDeny": true, + "redundant": false + }, + { + "id": "redirect-to-file", + "transcript": [], + "payload": { + "session_id": "sess-shell-10", + "cwd": "{{root}}", + "permission_mode": "default", + "hook_event_name": "PreToolUse", + "tool_name": "Bash", + "tool_input": { + "command": "cat .env > leak.txt", + "description": "redirect-to-file" + }, + "tool_use_id": "toolu_firstshellcommands_012" + }, + "expect": "deny", + "mustDeny": true, + "redundant": false + }, + { + "id": "command-substitution", + "transcript": [], + "payload": { + "session_id": "sess-shell-11", + "cwd": "{{root}}", + "permission_mode": "default", + "hook_event_name": "PreToolUse", + "tool_name": "Bash", + "tool_input": { + "command": "ls $(touch pwned)", + "description": "command-substitution" + }, + "tool_use_id": "toolu_firstshellcommands_013" + }, + "expect": "deny", + "mustDeny": true, + "redundant": false + }, + { + "id": "rg-preprocessor", + "transcript": [], + "payload": { + "session_id": "sess-shell-12", + "cwd": "{{root}}", + "permission_mode": "default", + "hook_event_name": "PreToolUse", + "tool_name": "Bash", + "tool_input": { + "command": "rg --pre ./decode.sh secret .", + "description": "rg-preprocessor" + }, + "tool_use_id": "toolu_firstshellcommands_014" + }, + "expect": "deny", + "mustDeny": true, + "redundant": false + }, + { + "id": "find-delete", + "transcript": [], + "payload": { + "session_id": "sess-shell-13", + "cwd": "{{root}}", + "permission_mode": "default", + "hook_event_name": "PreToolUse", + "tool_name": "Bash", + "tool_input": { + "command": "find . -name '*.tmp' -delete", + "description": "find-delete" + }, + "tool_use_id": "toolu_firstshellcommands_015" + }, + "expect": "deny", + "mustDeny": true, + "redundant": false + }, + { + "id": "git-log-output", + "transcript": [], + "payload": { + "session_id": "sess-shell-14", + "cwd": "{{root}}", + "permission_mode": "default", + "hook_event_name": "PreToolUse", + "tool_name": "Bash", + "tool_input": { + "command": "git log --output=notes.txt", + "description": "git-log-output" + }, + "tool_use_id": "toolu_firstshellcommands_016" + }, + "expect": "deny", + "mustDeny": true, + "redundant": false + }, + { + "id": "powershell-pipe-to-set-content", + "transcript": [], + "payload": { + "session_id": "sess-shell-15", + "cwd": "{{root}}", + "permission_mode": "default", + "hook_event_name": "PreToolUse", + "tool_name": "PowerShell", + "tool_input": { + "command": "Get-Content .env | Set-Content out.txt" + }, + "tool_use_id": "toolu_firstshellcommands_017" + }, + "expect": "deny", + "mustDeny": true, + "redundant": false + }, + { + "id": "powershell-readonly-then-write", + "transcript": [], + "payload": { + "session_id": "sess-shell-16", + "cwd": "{{root}}", + "permission_mode": "default", + "hook_event_name": "PreToolUse", + "tool_name": "PowerShell", + "tool_input": { + "command": "Get-ChildItem; Set-Content notes.txt hi" + }, + "tool_use_id": "toolu_firstshellcommands_018" + }, + "expect": "deny", + "mustDeny": true, + "redundant": false + }, + { + "id": "rm-rf", + "transcript": [], + "payload": { + "session_id": "sess-shell-rm", + "cwd": "{{root}}", + "permission_mode": "default", + "hook_event_name": "PreToolUse", + "tool_name": "Bash", + "tool_input": { + "command": "rm -rf build", + "description": "rm-rf" + }, + "tool_use_id": "toolu_firstshellcommands_019" + }, + "expect": "deny", + "mustDeny": true, + "redundant": false + }, + { + "id": "rm-rf-retry", + "note": "Retry after presenting the facts.", + "transcript": [], + "payload": { + "session_id": "sess-shell-rm", + "cwd": "{{root}}", + "permission_mode": "default", + "hook_event_name": "PreToolUse", + "tool_name": "Bash", + "tool_input": { + "command": "rm -rf build", + "description": "rm-rf-retry" + }, + "tool_use_id": "toolu_firstshellcommands_020" + }, + "expect": "allow", + "mustDeny": false, + "redundant": false + } + ] +} diff --git a/tests/fixtures/gateguard-scenarios/12-windows-paths.json b/tests/fixtures/gateguard-scenarios/12-windows-paths.json new file mode 100644 index 000000000..a2009a99d --- /dev/null +++ b/tests/fixtures/gateguard-scenarios/12-windows-paths.json @@ -0,0 +1,556 @@ +{ + "name": "windows-paths", + "description": "Windows-style paths: mixed separators and case, a PowerShell search, sensitive names with odd case and a stream suffix.", + "root": "C:\\work\\proj", + "files": {}, + "steps": [ + { + "id": "edit-engine-backslash", + "transcript": [ + { + "type": "user", + "uuid": "windows-paths-001", + "sessionId": "sess-windows", + "isSidechain": false, + "promptId": "prompt-windows-1", + "message": { + "role": "user", + "content": "Speed up the renderer and fix the engine loop." + } + }, + { + "type": "assistant", + "uuid": "windows-paths-002", + "sessionId": "sess-windows", + "isSidechain": false, + "message": { + "id": "msg_windowspaths_01", + "type": "message", + "role": "assistant", + "model": "claude-sonnet-4-5", + "content": [ + { + "type": "tool_use", + "id": "toolu_windowspaths_001", + "name": "Edit", + "input": { + "file_path": "src\\core\\engine.ts", + "old_string": " let frames = 0;", + "new_string": " let frames = 1;" + } + } + ] + } + } + ], + "payload": { + "session_id": "sess-windows", + "transcript_path": "{{transcript}}", + "cwd": "C:\\work\\proj", + "permission_mode": "default", + "hook_event_name": "PreToolUse", + "tool_name": "Edit", + "tool_input": { + "file_path": "src\\core\\engine.ts", + "old_string": " let frames = 0;", + "new_string": " let frames = 1;" + }, + "tool_use_id": "toolu_windowspaths_001" + }, + "expect": "deny", + "mustDeny": false, + "redundant": false, + "relevantQuestions": [ + "local-callers", + "quote-instruction" + ] + }, + { + "id": "edit-engine-forward-slash", + "note": "Same file, different spelling.", + "transcript": [ + { + "type": "user", + "uuid": "windows-paths-003", + "sessionId": "sess-windows", + "isSidechain": false, + "promptId": "prompt-windows-1", + "message": { + "role": "user", + "content": [ + { + "type": "tool_result", + "tool_use_id": "toolu_windowspaths_001", + "content": "ok" + } + ] + } + }, + { + "type": "assistant", + "uuid": "windows-paths-004", + "sessionId": "sess-windows", + "isSidechain": false, + "message": { + "id": "msg_windowspaths_02", + "type": "message", + "role": "assistant", + "model": "claude-sonnet-4-5", + "content": [ + { + "type": "tool_use", + "id": "toolu_windowspaths_002", + "name": "Edit", + "input": { + "file_path": "c:/work/proj/src/core/engine.ts", + "old_string": " frames += 1;", + "new_string": " frames += 2;" + } + } + ] + } + } + ], + "payload": { + "session_id": "sess-windows", + "transcript_path": "{{transcript}}", + "cwd": "C:\\work\\proj", + "permission_mode": "default", + "hook_event_name": "PreToolUse", + "tool_name": "Edit", + "tool_input": { + "file_path": "c:/work/proj/src/core/engine.ts", + "old_string": " frames += 1;", + "new_string": " frames += 2;" + }, + "tool_use_id": "toolu_windowspaths_002" + }, + "expect": "allow", + "mustDeny": false, + "redundant": true, + "relevantQuestions": [ + "local-callers", + "quote-instruction" + ] + }, + { + "id": "edit-renderer-after-select-string", + "transcript": [ + { + "type": "user", + "uuid": "windows-paths-005", + "sessionId": "sess-windows", + "isSidechain": false, + "promptId": "prompt-windows-1", + "message": { + "role": "user", + "content": [ + { + "type": "tool_result", + "tool_use_id": "toolu_windowspaths_002", + "content": "ok" + } + ] + } + }, + { + "type": "assistant", + "uuid": "windows-paths-006", + "sessionId": "sess-windows", + "isSidechain": false, + "message": { + "id": "msg_windowspaths_03", + "type": "message", + "role": "assistant", + "model": "claude-sonnet-4-5", + "content": [ + { + "type": "tool_use", + "id": "toolu_windowspaths_003", + "name": "PowerShell", + "input": { + "command": "Select-String -Pattern renderer -Path src\\core\\*.ts" + } + } + ] + } + }, + { + "type": "user", + "uuid": "windows-paths-007", + "sessionId": "sess-windows", + "isSidechain": false, + "promptId": "prompt-windows-1", + "message": { + "role": "user", + "content": [ + { + "type": "tool_result", + "tool_use_id": "toolu_windowspaths_003", + "content": "ok" + } + ] + } + }, + { + "type": "assistant", + "uuid": "windows-paths-008", + "sessionId": "sess-windows", + "isSidechain": false, + "message": { + "id": "msg_windowspaths_04", + "type": "message", + "role": "assistant", + "model": "claude-sonnet-4-5", + "content": [ + { + "type": "tool_use", + "id": "toolu_windowspaths_004", + "name": "Edit", + "input": { + "file_path": "C:\\work\\proj\\src\\core\\renderer.ts", + "old_string": " const scale = 1;", + "new_string": " const scale = 2;" + } + } + ] + } + } + ], + "payload": { + "session_id": "sess-windows", + "transcript_path": "{{transcript}}", + "cwd": "C:\\work\\proj", + "permission_mode": "default", + "hook_event_name": "PreToolUse", + "tool_name": "Edit", + "tool_input": { + "file_path": "C:\\work\\proj\\src\\core\\renderer.ts", + "old_string": " const scale = 1;", + "new_string": " const scale = 2;" + }, + "tool_use_id": "toolu_windowspaths_004" + }, + "expect": "allow", + "mustDeny": false, + "redundant": true, + "relevantQuestions": [ + "local-callers", + "quote-instruction" + ] + }, + { + "id": "edit-env-mixed-case", + "transcript": [ + { + "type": "user", + "uuid": "windows-paths-009", + "sessionId": "sess-windows", + "isSidechain": false, + "promptId": "prompt-windows-1", + "message": { + "role": "user", + "content": [ + { + "type": "tool_result", + "tool_use_id": "toolu_windowspaths_004", + "content": "ok" + } + ] + } + }, + { + "type": "assistant", + "uuid": "windows-paths-010", + "sessionId": "sess-windows", + "isSidechain": false, + "message": { + "id": "msg_windowspaths_05", + "type": "message", + "role": "assistant", + "model": "claude-sonnet-4-5", + "content": [ + { + "type": "tool_use", + "id": "toolu_windowspaths_005", + "name": "Edit", + "input": { + "file_path": "C:\\work\\proj\\.ENV.local", + "old_string": "DEBUG=0", + "new_string": "DEBUG=1" + } + } + ] + } + } + ], + "payload": { + "session_id": "sess-windows", + "transcript_path": "{{transcript}}", + "cwd": "C:\\work\\proj", + "permission_mode": "default", + "hook_event_name": "PreToolUse", + "tool_name": "Edit", + "tool_input": { + "file_path": "C:\\work\\proj\\.ENV.local", + "old_string": "DEBUG=0", + "new_string": "DEBUG=1" + }, + "tool_use_id": "toolu_windowspaths_005" + }, + "expect": "deny", + "mustDeny": true, + "redundant": false + }, + { + "id": "edit-auth-mixed-case", + "transcript": [ + { + "type": "user", + "uuid": "windows-paths-011", + "sessionId": "sess-windows", + "isSidechain": false, + "promptId": "prompt-windows-1", + "message": { + "role": "user", + "content": [ + { + "type": "tool_result", + "tool_use_id": "toolu_windowspaths_005", + "content": "ok" + } + ] + } + }, + { + "type": "assistant", + "uuid": "windows-paths-012", + "sessionId": "sess-windows", + "isSidechain": false, + "message": { + "id": "msg_windowspaths_06", + "type": "message", + "role": "assistant", + "model": "claude-sonnet-4-5", + "content": [ + { + "type": "tool_use", + "id": "toolu_windowspaths_006", + "name": "Edit", + "input": { + "file_path": "src\\Auth\\TokenStore.cs", + "old_string": " var ttl = 60;", + "new_string": " var ttl = 120;" + } + } + ] + } + } + ], + "payload": { + "session_id": "sess-windows", + "transcript_path": "{{transcript}}", + "cwd": "C:\\work\\proj", + "permission_mode": "default", + "hook_event_name": "PreToolUse", + "tool_name": "Edit", + "tool_input": { + "file_path": "src\\Auth\\TokenStore.cs", + "old_string": " var ttl = 60;", + "new_string": " var ttl = 120;" + }, + "tool_use_id": "toolu_windowspaths_006" + }, + "expect": "deny", + "mustDeny": true, + "redundant": false, + "relevantQuestions": [ + "importers", + "public-api", + "data-schema", + "external-contract", + "quote-instruction" + ] + }, + { + "id": "write-env-stream", + "transcript": [ + { + "type": "user", + "uuid": "windows-paths-013", + "sessionId": "sess-windows", + "isSidechain": false, + "promptId": "prompt-windows-1", + "message": { + "role": "user", + "content": [ + { + "type": "tool_result", + "tool_use_id": "toolu_windowspaths_006", + "content": "ok" + } + ] + } + }, + { + "type": "assistant", + "uuid": "windows-paths-014", + "sessionId": "sess-windows", + "isSidechain": false, + "message": { + "id": "msg_windowspaths_07", + "type": "message", + "role": "assistant", + "model": "claude-sonnet-4-5", + "content": [ + { + "type": "tool_use", + "id": "toolu_windowspaths_007", + "name": "Write", + "input": { + "file_path": "C:\\work\\proj\\.env::$DATA", + "content": "TOKEN=x\n" + } + } + ] + } + } + ], + "payload": { + "session_id": "sess-windows", + "transcript_path": "{{transcript}}", + "cwd": "C:\\work\\proj", + "permission_mode": "default", + "hook_event_name": "PreToolUse", + "tool_name": "Write", + "tool_input": { + "file_path": "C:\\work\\proj\\.env::$DATA", + "content": "TOKEN=x\n" + }, + "tool_use_id": "toolu_windowspaths_007" + }, + "expect": "deny", + "mustDeny": true, + "redundant": false + }, + { + "id": "powershell-readonly-first", + "transcript": [ + { + "type": "user", + "uuid": "windows-paths-015", + "sessionId": "sess-windows", + "isSidechain": false, + "promptId": "prompt-windows-1", + "message": { + "role": "user", + "content": [ + { + "type": "tool_result", + "tool_use_id": "toolu_windowspaths_007", + "content": "ok" + } + ] + } + }, + { + "type": "assistant", + "uuid": "windows-paths-016", + "sessionId": "sess-windows", + "isSidechain": false, + "message": { + "id": "msg_windowspaths_08", + "type": "message", + "role": "assistant", + "model": "claude-sonnet-4-5", + "content": [ + { + "type": "tool_use", + "id": "toolu_windowspaths_008", + "name": "PowerShell", + "input": { + "command": "Get-ChildItem -Path src -Recurse -Filter *.cs" + } + } + ] + } + } + ], + "payload": { + "session_id": "sess-windows", + "transcript_path": "{{transcript}}", + "cwd": "C:\\work\\proj", + "permission_mode": "default", + "hook_event_name": "PreToolUse", + "tool_name": "PowerShell", + "tool_input": { + "command": "Get-ChildItem -Path src -Recurse -Filter *.cs" + }, + "tool_use_id": "toolu_windowspaths_008" + }, + "expect": "allow", + "mustDeny": false, + "redundant": true + }, + { + "id": "powershell-mutating", + "note": "First command that is not read-only.", + "transcript": [ + { + "type": "user", + "uuid": "windows-paths-017", + "sessionId": "sess-windows", + "isSidechain": false, + "promptId": "prompt-windows-1", + "message": { + "role": "user", + "content": [ + { + "type": "tool_result", + "tool_use_id": "toolu_windowspaths_008", + "content": "ok" + } + ] + } + }, + { + "type": "assistant", + "uuid": "windows-paths-018", + "sessionId": "sess-windows", + "isSidechain": false, + "message": { + "id": "msg_windowspaths_09", + "type": "message", + "role": "assistant", + "model": "claude-sonnet-4-5", + "content": [ + { + "type": "tool_use", + "id": "toolu_windowspaths_009", + "name": "PowerShell", + "input": { + "command": "New-Item -ItemType File notes.txt" + } + } + ] + } + } + ], + "payload": { + "session_id": "sess-windows", + "transcript_path": "{{transcript}}", + "cwd": "C:\\work\\proj", + "permission_mode": "default", + "hook_event_name": "PreToolUse", + "tool_name": "PowerShell", + "tool_input": { + "command": "New-Item -ItemType File notes.txt" + }, + "tool_use_id": "toolu_windowspaths_009" + }, + "expect": "deny", + "mustDeny": false, + "redundant": false + } + ] +} diff --git a/tests/fixtures/gateguard-scenarios/13-bypass-search-filters.json b/tests/fixtures/gateguard-scenarios/13-bypass-search-filters.json new file mode 100644 index 000000000..a2addb244 --- /dev/null +++ b/tests/fixtures/gateguard-scenarios/13-bypass-search-filters.json @@ -0,0 +1,2048 @@ +{ + "name": "bypass-search-filters", + "description": "Searches that mention the target but never searched it: exclusion globs, bracket classes, PowerShell -Exclude spellings, stdin searches, the target as operand, another directory, a generic stem, a missed include and a directory-qualified include of another directory; then four controls whose filters leave the target in scope.", + "files": { + "src/rates/converter.js": "function converterValue(x) {\n return x;\n}\n", + "src/rates/rounding.js": "function roundingValue(x) {\n return x;\n}\n", + "src/rates/currency.js": "function currencyValue(x) {\n return x;\n}\n", + "src/rates/ledger.js": "function ledgerValue(x) {\n return x;\n}\n", + "src/rates/formatter.js": "function formatterValue(x) {\n return x;\n}\n", + "src/rates/exchange.js": "function exchangeValue(x) {\n return x;\n}\n", + "src/rates/quotes.js": "function quotesValue(x) {\n return x;\n}\n", + "src/rates/spread.js": "function spreadValue(x) {\n return x;\n}\n", + "src/rates/margins.js": "function marginsValue(x) {\n return x;\n}\n", + "src/rates/pricing.js": "function pricingValue(x) {\n return x;\n}\n", + "src/rates/fallback.js": "function fallbackValue(x) {\n return x;\n}\n", + "src/rates/index.js": "function indexValue(x) {\n return x;\n}\n", + "src/rates/tariff.js": "function tariffValue(x) {\n return x;\n}\n", + "src/rates/bands.js": "function bandsValue(x) {\n return x;\n}\n", + "src/rates/settle.js": "function settleValue(x) {\n return x;\n}\n", + "src/rates/clearing.js": "function clearingValue(x) {\n return x;\n}\n", + "src/rates/netting.js": "function nettingValue(x) {\n return x;\n}\n", + "lib/rates/fallback.js": "function fallbackValue(x) {\n return x;\n}\n", + "lib/rates/sweep.js": "function sweepValue(x) {\n return x;\n}\n", + "src/rates/sweep.js": "function sweepValue(x) {\n return x;\n}\n" + }, + "steps": [ + { + "id": "rg-exclusion-glob", + "transcript": [ + { + "type": "user", + "uuid": "bypass-search-filters-001", + "sessionId": "sess-bypass-filters", + "isSidechain": false, + "promptId": "prompt-filters-1", + "message": { + "role": "user", + "content": "Tighten rounding across the rates module." + } + }, + { + "type": "assistant", + "uuid": "bypass-search-filters-002", + "sessionId": "sess-bypass-filters", + "isSidechain": false, + "message": { + "id": "msg_bypasssearchfilters_01", + "type": "message", + "role": "assistant", + "model": "claude-sonnet-4-5", + "content": [ + { + "type": "tool_use", + "id": "toolu_bypasssearchfilters_001", + "name": "Bash", + "input": { + "command": "rg -n converter -g '!src/rates/**' src" + } + } + ] + } + }, + { + "type": "user", + "uuid": "bypass-search-filters-003", + "sessionId": "sess-bypass-filters", + "isSidechain": false, + "promptId": "prompt-filters-1", + "message": { + "role": "user", + "content": [ + { + "type": "tool_result", + "tool_use_id": "toolu_bypasssearchfilters_001", + "content": "ok" + } + ] + } + }, + { + "type": "assistant", + "uuid": "bypass-search-filters-004", + "sessionId": "sess-bypass-filters", + "isSidechain": false, + "message": { + "id": "msg_bypasssearchfilters_02", + "type": "message", + "role": "assistant", + "model": "claude-sonnet-4-5", + "content": [ + { + "type": "tool_use", + "id": "toolu_bypasssearchfilters_002", + "name": "Edit", + "input": { + "file_path": "{{root}}/src/rates/converter.js", + "old_string": " return x;", + "new_string": " return Math.round(x * 100) / 100;" + } + } + ] + } + } + ], + "payload": { + "session_id": "sess-bypass-filters", + "transcript_path": "{{transcript}}", + "cwd": "{{root}}", + "permission_mode": "default", + "hook_event_name": "PreToolUse", + "tool_name": "Edit", + "tool_input": { + "file_path": "{{root}}/src/rates/converter.js", + "old_string": " return x;", + "new_string": " return Math.round(x * 100) / 100;" + }, + "tool_use_id": "toolu_bypasssearchfilters_002" + }, + "expect": "deny", + "mustDeny": true, + "redundant": false, + "relevantQuestions": [ + "local-callers", + "quote-instruction" + ] + }, + { + "id": "grep-tool-exclusion-glob", + "transcript": [ + { + "type": "user", + "uuid": "bypass-search-filters-005", + "sessionId": "sess-bypass-filters", + "isSidechain": false, + "promptId": "prompt-filters-1", + "message": { + "role": "user", + "content": [ + { + "type": "tool_result", + "tool_use_id": "toolu_bypasssearchfilters_002", + "content": "ok" + } + ] + } + }, + { + "type": "assistant", + "uuid": "bypass-search-filters-006", + "sessionId": "sess-bypass-filters", + "isSidechain": false, + "message": { + "id": "msg_bypasssearchfilters_03", + "type": "message", + "role": "assistant", + "model": "claude-sonnet-4-5", + "content": [ + { + "type": "tool_use", + "id": "toolu_bypasssearchfilters_003", + "name": "Grep", + "input": { + "pattern": "rounding", + "path": "{{root}}/src", + "glob": "!**/rounding.js" + } + } + ] + } + }, + { + "type": "user", + "uuid": "bypass-search-filters-007", + "sessionId": "sess-bypass-filters", + "isSidechain": false, + "promptId": "prompt-filters-1", + "message": { + "role": "user", + "content": [ + { + "type": "tool_result", + "tool_use_id": "toolu_bypasssearchfilters_003", + "content": "ok" + } + ] + } + }, + { + "type": "assistant", + "uuid": "bypass-search-filters-008", + "sessionId": "sess-bypass-filters", + "isSidechain": false, + "message": { + "id": "msg_bypasssearchfilters_04", + "type": "message", + "role": "assistant", + "model": "claude-sonnet-4-5", + "content": [ + { + "type": "tool_use", + "id": "toolu_bypasssearchfilters_004", + "name": "Edit", + "input": { + "file_path": "{{root}}/src/rates/rounding.js", + "old_string": " return x;", + "new_string": " return Math.round(x * 100) / 100;" + } + } + ] + } + } + ], + "payload": { + "session_id": "sess-bypass-filters", + "transcript_path": "{{transcript}}", + "cwd": "{{root}}", + "permission_mode": "default", + "hook_event_name": "PreToolUse", + "tool_name": "Edit", + "tool_input": { + "file_path": "{{root}}/src/rates/rounding.js", + "old_string": " return x;", + "new_string": " return Math.round(x * 100) / 100;" + }, + "tool_use_id": "toolu_bypasssearchfilters_004" + }, + "expect": "deny", + "mustDeny": true, + "redundant": false, + "relevantQuestions": [ + "local-callers", + "quote-instruction" + ] + }, + { + "id": "bracket-class-exclusion", + "transcript": [ + { + "type": "user", + "uuid": "bypass-search-filters-009", + "sessionId": "sess-bypass-filters", + "isSidechain": false, + "promptId": "prompt-filters-1", + "message": { + "role": "user", + "content": [ + { + "type": "tool_result", + "tool_use_id": "toolu_bypasssearchfilters_004", + "content": "ok" + } + ] + } + }, + { + "type": "assistant", + "uuid": "bypass-search-filters-010", + "sessionId": "sess-bypass-filters", + "isSidechain": false, + "message": { + "id": "msg_bypasssearchfilters_05", + "type": "message", + "role": "assistant", + "model": "claude-sonnet-4-5", + "content": [ + { + "type": "tool_use", + "id": "toolu_bypasssearchfilters_005", + "name": "Bash", + "input": { + "command": "rg -n currency --glob '!src/[r]ates/*' src" + } + } + ] + } + }, + { + "type": "user", + "uuid": "bypass-search-filters-011", + "sessionId": "sess-bypass-filters", + "isSidechain": false, + "promptId": "prompt-filters-1", + "message": { + "role": "user", + "content": [ + { + "type": "tool_result", + "tool_use_id": "toolu_bypasssearchfilters_005", + "content": "ok" + } + ] + } + }, + { + "type": "assistant", + "uuid": "bypass-search-filters-012", + "sessionId": "sess-bypass-filters", + "isSidechain": false, + "message": { + "id": "msg_bypasssearchfilters_06", + "type": "message", + "role": "assistant", + "model": "claude-sonnet-4-5", + "content": [ + { + "type": "tool_use", + "id": "toolu_bypasssearchfilters_006", + "name": "Edit", + "input": { + "file_path": "{{root}}/src/rates/currency.js", + "old_string": " return x;", + "new_string": " return Math.round(x * 100) / 100;" + } + } + ] + } + } + ], + "payload": { + "session_id": "sess-bypass-filters", + "transcript_path": "{{transcript}}", + "cwd": "{{root}}", + "permission_mode": "default", + "hook_event_name": "PreToolUse", + "tool_name": "Edit", + "tool_input": { + "file_path": "{{root}}/src/rates/currency.js", + "old_string": " return x;", + "new_string": " return Math.round(x * 100) / 100;" + }, + "tool_use_id": "toolu_bypasssearchfilters_006" + }, + "expect": "deny", + "mustDeny": true, + "redundant": false, + "relevantQuestions": [ + "local-callers", + "quote-instruction" + ] + }, + { + "id": "negated-bracket-class-exclusion", + "transcript": [ + { + "type": "user", + "uuid": "bypass-search-filters-013", + "sessionId": "sess-bypass-filters", + "isSidechain": false, + "promptId": "prompt-filters-1", + "message": { + "role": "user", + "content": [ + { + "type": "tool_result", + "tool_use_id": "toolu_bypasssearchfilters_006", + "content": "ok" + } + ] + } + }, + { + "type": "assistant", + "uuid": "bypass-search-filters-014", + "sessionId": "sess-bypass-filters", + "isSidechain": false, + "message": { + "id": "msg_bypasssearchfilters_07", + "type": "message", + "role": "assistant", + "model": "claude-sonnet-4-5", + "content": [ + { + "type": "tool_use", + "id": "toolu_bypasssearchfilters_007", + "name": "Bash", + "input": { + "command": "rg -n ledger --glob '!**/l[!x]dger.js' src" + } + } + ] + } + }, + { + "type": "user", + "uuid": "bypass-search-filters-015", + "sessionId": "sess-bypass-filters", + "isSidechain": false, + "promptId": "prompt-filters-1", + "message": { + "role": "user", + "content": [ + { + "type": "tool_result", + "tool_use_id": "toolu_bypasssearchfilters_007", + "content": "ok" + } + ] + } + }, + { + "type": "assistant", + "uuid": "bypass-search-filters-016", + "sessionId": "sess-bypass-filters", + "isSidechain": false, + "message": { + "id": "msg_bypasssearchfilters_08", + "type": "message", + "role": "assistant", + "model": "claude-sonnet-4-5", + "content": [ + { + "type": "tool_use", + "id": "toolu_bypasssearchfilters_008", + "name": "Edit", + "input": { + "file_path": "{{root}}/src/rates/ledger.js", + "old_string": " return x;", + "new_string": " return Math.round(x * 100) / 100;" + } + } + ] + } + } + ], + "payload": { + "session_id": "sess-bypass-filters", + "transcript_path": "{{transcript}}", + "cwd": "{{root}}", + "permission_mode": "default", + "hook_event_name": "PreToolUse", + "tool_name": "Edit", + "tool_input": { + "file_path": "{{root}}/src/rates/ledger.js", + "old_string": " return x;", + "new_string": " return Math.round(x * 100) / 100;" + }, + "tool_use_id": "toolu_bypasssearchfilters_008" + }, + "expect": "deny", + "mustDeny": true, + "redundant": false, + "relevantQuestions": [ + "local-callers", + "quote-instruction" + ] + }, + { + "id": "powershell-exclude-prefix", + "transcript": [ + { + "type": "user", + "uuid": "bypass-search-filters-017", + "sessionId": "sess-bypass-filters", + "isSidechain": false, + "promptId": "prompt-filters-1", + "message": { + "role": "user", + "content": [ + { + "type": "tool_result", + "tool_use_id": "toolu_bypasssearchfilters_008", + "content": "ok" + } + ] + } + }, + { + "type": "assistant", + "uuid": "bypass-search-filters-018", + "sessionId": "sess-bypass-filters", + "isSidechain": false, + "message": { + "id": "msg_bypasssearchfilters_09", + "type": "message", + "role": "assistant", + "model": "claude-sonnet-4-5", + "content": [ + { + "type": "tool_use", + "id": "toolu_bypasssearchfilters_009", + "name": "PowerShell", + "input": { + "command": "Get-ChildItem -Path src -Recurse -Exc formatter.js" + } + } + ] + } + }, + { + "type": "user", + "uuid": "bypass-search-filters-019", + "sessionId": "sess-bypass-filters", + "isSidechain": false, + "promptId": "prompt-filters-1", + "message": { + "role": "user", + "content": [ + { + "type": "tool_result", + "tool_use_id": "toolu_bypasssearchfilters_009", + "content": "ok" + } + ] + } + }, + { + "type": "assistant", + "uuid": "bypass-search-filters-020", + "sessionId": "sess-bypass-filters", + "isSidechain": false, + "message": { + "id": "msg_bypasssearchfilters_10", + "type": "message", + "role": "assistant", + "model": "claude-sonnet-4-5", + "content": [ + { + "type": "tool_use", + "id": "toolu_bypasssearchfilters_010", + "name": "Edit", + "input": { + "file_path": "{{root}}/src/rates/formatter.js", + "old_string": " return x;", + "new_string": " return Math.round(x * 100) / 100;" + } + } + ] + } + } + ], + "payload": { + "session_id": "sess-bypass-filters", + "transcript_path": "{{transcript}}", + "cwd": "{{root}}", + "permission_mode": "default", + "hook_event_name": "PreToolUse", + "tool_name": "Edit", + "tool_input": { + "file_path": "{{root}}/src/rates/formatter.js", + "old_string": " return x;", + "new_string": " return Math.round(x * 100) / 100;" + }, + "tool_use_id": "toolu_bypasssearchfilters_010" + }, + "expect": "deny", + "mustDeny": true, + "redundant": false, + "relevantQuestions": [ + "local-callers", + "quote-instruction" + ] + }, + { + "id": "powershell-exclude-colon", + "transcript": [ + { + "type": "user", + "uuid": "bypass-search-filters-021", + "sessionId": "sess-bypass-filters", + "isSidechain": false, + "promptId": "prompt-filters-1", + "message": { + "role": "user", + "content": [ + { + "type": "tool_result", + "tool_use_id": "toolu_bypasssearchfilters_010", + "content": "ok" + } + ] + } + }, + { + "type": "assistant", + "uuid": "bypass-search-filters-022", + "sessionId": "sess-bypass-filters", + "isSidechain": false, + "message": { + "id": "msg_bypasssearchfilters_11", + "type": "message", + "role": "assistant", + "model": "claude-sonnet-4-5", + "content": [ + { + "type": "tool_use", + "id": "toolu_bypasssearchfilters_011", + "name": "PowerShell", + "input": { + "command": "Get-ChildItem src -Recurse -ex:exchange*" + } + } + ] + } + }, + { + "type": "user", + "uuid": "bypass-search-filters-023", + "sessionId": "sess-bypass-filters", + "isSidechain": false, + "promptId": "prompt-filters-1", + "message": { + "role": "user", + "content": [ + { + "type": "tool_result", + "tool_use_id": "toolu_bypasssearchfilters_011", + "content": "ok" + } + ] + } + }, + { + "type": "assistant", + "uuid": "bypass-search-filters-024", + "sessionId": "sess-bypass-filters", + "isSidechain": false, + "message": { + "id": "msg_bypasssearchfilters_12", + "type": "message", + "role": "assistant", + "model": "claude-sonnet-4-5", + "content": [ + { + "type": "tool_use", + "id": "toolu_bypasssearchfilters_012", + "name": "Edit", + "input": { + "file_path": "{{root}}/src/rates/exchange.js", + "old_string": " return x;", + "new_string": " return Math.round(x * 100) / 100;" + } + } + ] + } + } + ], + "payload": { + "session_id": "sess-bypass-filters", + "transcript_path": "{{transcript}}", + "cwd": "{{root}}", + "permission_mode": "default", + "hook_event_name": "PreToolUse", + "tool_name": "Edit", + "tool_input": { + "file_path": "{{root}}/src/rates/exchange.js", + "old_string": " return x;", + "new_string": " return Math.round(x * 100) / 100;" + }, + "tool_use_id": "toolu_bypasssearchfilters_012" + }, + "expect": "deny", + "mustDeny": true, + "redundant": false, + "relevantQuestions": [ + "local-callers", + "quote-instruction" + ] + }, + { + "id": "powershell-exclude-comma-list", + "transcript": [ + { + "type": "user", + "uuid": "bypass-search-filters-025", + "sessionId": "sess-bypass-filters", + "isSidechain": false, + "promptId": "prompt-filters-1", + "message": { + "role": "user", + "content": [ + { + "type": "tool_result", + "tool_use_id": "toolu_bypasssearchfilters_012", + "content": "ok" + } + ] + } + }, + { + "type": "assistant", + "uuid": "bypass-search-filters-026", + "sessionId": "sess-bypass-filters", + "isSidechain": false, + "message": { + "id": "msg_bypasssearchfilters_13", + "type": "message", + "role": "assistant", + "model": "claude-sonnet-4-5", + "content": [ + { + "type": "tool_use", + "id": "toolu_bypasssearchfilters_013", + "name": "PowerShell", + "input": { + "command": "Select-String -Pattern quotes -Path src\\rates\\* -Exclude spread.js, quotes.js" + } + } + ] + } + }, + { + "type": "user", + "uuid": "bypass-search-filters-027", + "sessionId": "sess-bypass-filters", + "isSidechain": false, + "promptId": "prompt-filters-1", + "message": { + "role": "user", + "content": [ + { + "type": "tool_result", + "tool_use_id": "toolu_bypasssearchfilters_013", + "content": "ok" + } + ] + } + }, + { + "type": "assistant", + "uuid": "bypass-search-filters-028", + "sessionId": "sess-bypass-filters", + "isSidechain": false, + "message": { + "id": "msg_bypasssearchfilters_14", + "type": "message", + "role": "assistant", + "model": "claude-sonnet-4-5", + "content": [ + { + "type": "tool_use", + "id": "toolu_bypasssearchfilters_014", + "name": "Edit", + "input": { + "file_path": "{{root}}/src/rates/quotes.js", + "old_string": " return x;", + "new_string": " return Math.round(x * 100) / 100;" + } + } + ] + } + } + ], + "payload": { + "session_id": "sess-bypass-filters", + "transcript_path": "{{transcript}}", + "cwd": "{{root}}", + "permission_mode": "default", + "hook_event_name": "PreToolUse", + "tool_name": "Edit", + "tool_input": { + "file_path": "{{root}}/src/rates/quotes.js", + "old_string": " return x;", + "new_string": " return Math.round(x * 100) / 100;" + }, + "tool_use_id": "toolu_bypasssearchfilters_014" + }, + "expect": "deny", + "mustDeny": true, + "redundant": false, + "relevantQuestions": [ + "local-callers", + "quote-instruction" + ] + }, + { + "id": "grep-of-piped-stdin", + "transcript": [ + { + "type": "user", + "uuid": "bypass-search-filters-029", + "sessionId": "sess-bypass-filters", + "isSidechain": false, + "promptId": "prompt-filters-1", + "message": { + "role": "user", + "content": [ + { + "type": "tool_result", + "tool_use_id": "toolu_bypasssearchfilters_014", + "content": "ok" + } + ] + } + }, + { + "type": "assistant", + "uuid": "bypass-search-filters-030", + "sessionId": "sess-bypass-filters", + "isSidechain": false, + "message": { + "id": "msg_bypasssearchfilters_15", + "type": "message", + "role": "assistant", + "model": "claude-sonnet-4-5", + "content": [ + { + "type": "tool_use", + "id": "toolu_bypasssearchfilters_015", + "name": "Bash", + "input": { + "command": "cat src/rates/spread.js | grep -n spread" + } + } + ] + } + }, + { + "type": "user", + "uuid": "bypass-search-filters-031", + "sessionId": "sess-bypass-filters", + "isSidechain": false, + "promptId": "prompt-filters-1", + "message": { + "role": "user", + "content": [ + { + "type": "tool_result", + "tool_use_id": "toolu_bypasssearchfilters_015", + "content": "ok" + } + ] + } + }, + { + "type": "assistant", + "uuid": "bypass-search-filters-032", + "sessionId": "sess-bypass-filters", + "isSidechain": false, + "message": { + "id": "msg_bypasssearchfilters_16", + "type": "message", + "role": "assistant", + "model": "claude-sonnet-4-5", + "content": [ + { + "type": "tool_use", + "id": "toolu_bypasssearchfilters_016", + "name": "Edit", + "input": { + "file_path": "{{root}}/src/rates/spread.js", + "old_string": " return x;", + "new_string": " return Math.round(x * 100) / 100;" + } + } + ] + } + } + ], + "payload": { + "session_id": "sess-bypass-filters", + "transcript_path": "{{transcript}}", + "cwd": "{{root}}", + "permission_mode": "default", + "hook_event_name": "PreToolUse", + "tool_name": "Edit", + "tool_input": { + "file_path": "{{root}}/src/rates/spread.js", + "old_string": " return x;", + "new_string": " return Math.round(x * 100) / 100;" + }, + "tool_use_id": "toolu_bypasssearchfilters_016" + }, + "expect": "deny", + "mustDeny": true, + "redundant": false, + "relevantQuestions": [ + "local-callers", + "quote-instruction" + ] + }, + { + "id": "grep-of-redirected-stdin", + "transcript": [ + { + "type": "user", + "uuid": "bypass-search-filters-033", + "sessionId": "sess-bypass-filters", + "isSidechain": false, + "promptId": "prompt-filters-1", + "message": { + "role": "user", + "content": [ + { + "type": "tool_result", + "tool_use_id": "toolu_bypasssearchfilters_016", + "content": "ok" + } + ] + } + }, + { + "type": "assistant", + "uuid": "bypass-search-filters-034", + "sessionId": "sess-bypass-filters", + "isSidechain": false, + "message": { + "id": "msg_bypasssearchfilters_17", + "type": "message", + "role": "assistant", + "model": "claude-sonnet-4-5", + "content": [ + { + "type": "tool_use", + "id": "toolu_bypasssearchfilters_017", + "name": "Bash", + "input": { + "command": "grep -n margins < src/rates/margins.js" + } + } + ] + } + }, + { + "type": "user", + "uuid": "bypass-search-filters-035", + "sessionId": "sess-bypass-filters", + "isSidechain": false, + "promptId": "prompt-filters-1", + "message": { + "role": "user", + "content": [ + { + "type": "tool_result", + "tool_use_id": "toolu_bypasssearchfilters_017", + "content": "ok" + } + ] + } + }, + { + "type": "assistant", + "uuid": "bypass-search-filters-036", + "sessionId": "sess-bypass-filters", + "isSidechain": false, + "message": { + "id": "msg_bypasssearchfilters_18", + "type": "message", + "role": "assistant", + "model": "claude-sonnet-4-5", + "content": [ + { + "type": "tool_use", + "id": "toolu_bypasssearchfilters_018", + "name": "Edit", + "input": { + "file_path": "{{root}}/src/rates/margins.js", + "old_string": " return x;", + "new_string": " return Math.round(x * 100) / 100;" + } + } + ] + } + } + ], + "payload": { + "session_id": "sess-bypass-filters", + "transcript_path": "{{transcript}}", + "cwd": "{{root}}", + "permission_mode": "default", + "hook_event_name": "PreToolUse", + "tool_name": "Edit", + "tool_input": { + "file_path": "{{root}}/src/rates/margins.js", + "old_string": " return x;", + "new_string": " return Math.round(x * 100) / 100;" + }, + "tool_use_id": "toolu_bypasssearchfilters_018" + }, + "expect": "deny", + "mustDeny": true, + "redundant": false, + "relevantQuestions": [ + "local-callers", + "quote-instruction" + ] + }, + { + "id": "search-of-the-target-itself", + "note": "Reading the target is not a search for its users.", + "transcript": [ + { + "type": "user", + "uuid": "bypass-search-filters-037", + "sessionId": "sess-bypass-filters", + "isSidechain": false, + "promptId": "prompt-filters-1", + "message": { + "role": "user", + "content": [ + { + "type": "tool_result", + "tool_use_id": "toolu_bypasssearchfilters_018", + "content": "ok" + } + ] + } + }, + { + "type": "assistant", + "uuid": "bypass-search-filters-038", + "sessionId": "sess-bypass-filters", + "isSidechain": false, + "message": { + "id": "msg_bypasssearchfilters_19", + "type": "message", + "role": "assistant", + "model": "claude-sonnet-4-5", + "content": [ + { + "type": "tool_use", + "id": "toolu_bypasssearchfilters_019", + "name": "Bash", + "input": { + "command": "rg -n pricing src/rates/pricing.js" + } + } + ] + } + }, + { + "type": "user", + "uuid": "bypass-search-filters-039", + "sessionId": "sess-bypass-filters", + "isSidechain": false, + "promptId": "prompt-filters-1", + "message": { + "role": "user", + "content": [ + { + "type": "tool_result", + "tool_use_id": "toolu_bypasssearchfilters_019", + "content": "ok" + } + ] + } + }, + { + "type": "assistant", + "uuid": "bypass-search-filters-040", + "sessionId": "sess-bypass-filters", + "isSidechain": false, + "message": { + "id": "msg_bypasssearchfilters_20", + "type": "message", + "role": "assistant", + "model": "claude-sonnet-4-5", + "content": [ + { + "type": "tool_use", + "id": "toolu_bypasssearchfilters_020", + "name": "Edit", + "input": { + "file_path": "{{root}}/src/rates/pricing.js", + "old_string": " return x;", + "new_string": " return Math.round(x * 100) / 100;" + } + } + ] + } + } + ], + "payload": { + "session_id": "sess-bypass-filters", + "transcript_path": "{{transcript}}", + "cwd": "{{root}}", + "permission_mode": "default", + "hook_event_name": "PreToolUse", + "tool_name": "Edit", + "tool_input": { + "file_path": "{{root}}/src/rates/pricing.js", + "old_string": " return x;", + "new_string": " return Math.round(x * 100) / 100;" + }, + "tool_use_id": "toolu_bypasssearchfilters_020" + }, + "expect": "deny", + "mustDeny": true, + "redundant": false, + "relevantQuestions": [ + "local-callers", + "quote-instruction" + ] + }, + { + "id": "search-of-another-directory", + "transcript": [ + { + "type": "user", + "uuid": "bypass-search-filters-041", + "sessionId": "sess-bypass-filters", + "isSidechain": false, + "promptId": "prompt-filters-1", + "message": { + "role": "user", + "content": [ + { + "type": "tool_result", + "tool_use_id": "toolu_bypasssearchfilters_020", + "content": "ok" + } + ] + } + }, + { + "type": "assistant", + "uuid": "bypass-search-filters-042", + "sessionId": "sess-bypass-filters", + "isSidechain": false, + "message": { + "id": "msg_bypasssearchfilters_21", + "type": "message", + "role": "assistant", + "model": "claude-sonnet-4-5", + "content": [ + { + "type": "tool_use", + "id": "toolu_bypasssearchfilters_021", + "name": "Bash", + "input": { + "command": "rg -n fallback lib" + } + } + ] + } + }, + { + "type": "user", + "uuid": "bypass-search-filters-043", + "sessionId": "sess-bypass-filters", + "isSidechain": false, + "promptId": "prompt-filters-1", + "message": { + "role": "user", + "content": [ + { + "type": "tool_result", + "tool_use_id": "toolu_bypasssearchfilters_021", + "content": "ok" + } + ] + } + }, + { + "type": "assistant", + "uuid": "bypass-search-filters-044", + "sessionId": "sess-bypass-filters", + "isSidechain": false, + "message": { + "id": "msg_bypasssearchfilters_22", + "type": "message", + "role": "assistant", + "model": "claude-sonnet-4-5", + "content": [ + { + "type": "tool_use", + "id": "toolu_bypasssearchfilters_022", + "name": "Edit", + "input": { + "file_path": "{{root}}/src/rates/fallback.js", + "old_string": " return x;", + "new_string": " return Math.round(x * 100) / 100;" + } + } + ] + } + } + ], + "payload": { + "session_id": "sess-bypass-filters", + "transcript_path": "{{transcript}}", + "cwd": "{{root}}", + "permission_mode": "default", + "hook_event_name": "PreToolUse", + "tool_name": "Edit", + "tool_input": { + "file_path": "{{root}}/src/rates/fallback.js", + "old_string": " return x;", + "new_string": " return Math.round(x * 100) / 100;" + }, + "tool_use_id": "toolu_bypasssearchfilters_022" + }, + "expect": "deny", + "mustDeny": true, + "redundant": false, + "relevantQuestions": [ + "local-callers", + "quote-instruction" + ] + }, + { + "id": "generic-stem", + "transcript": [ + { + "type": "user", + "uuid": "bypass-search-filters-045", + "sessionId": "sess-bypass-filters", + "isSidechain": false, + "promptId": "prompt-filters-1", + "message": { + "role": "user", + "content": [ + { + "type": "tool_result", + "tool_use_id": "toolu_bypasssearchfilters_022", + "content": "ok" + } + ] + } + }, + { + "type": "assistant", + "uuid": "bypass-search-filters-046", + "sessionId": "sess-bypass-filters", + "isSidechain": false, + "message": { + "id": "msg_bypasssearchfilters_23", + "type": "message", + "role": "assistant", + "model": "claude-sonnet-4-5", + "content": [ + { + "type": "tool_use", + "id": "toolu_bypasssearchfilters_023", + "name": "Bash", + "input": { + "command": "rg -n index src/rates" + } + } + ] + } + }, + { + "type": "user", + "uuid": "bypass-search-filters-047", + "sessionId": "sess-bypass-filters", + "isSidechain": false, + "promptId": "prompt-filters-1", + "message": { + "role": "user", + "content": [ + { + "type": "tool_result", + "tool_use_id": "toolu_bypasssearchfilters_023", + "content": "ok" + } + ] + } + }, + { + "type": "assistant", + "uuid": "bypass-search-filters-048", + "sessionId": "sess-bypass-filters", + "isSidechain": false, + "message": { + "id": "msg_bypasssearchfilters_24", + "type": "message", + "role": "assistant", + "model": "claude-sonnet-4-5", + "content": [ + { + "type": "tool_use", + "id": "toolu_bypasssearchfilters_024", + "name": "Edit", + "input": { + "file_path": "{{root}}/src/rates/index.js", + "old_string": " return x;", + "new_string": " return Math.round(x * 100) / 100;" + } + } + ] + } + } + ], + "payload": { + "session_id": "sess-bypass-filters", + "transcript_path": "{{transcript}}", + "cwd": "{{root}}", + "permission_mode": "default", + "hook_event_name": "PreToolUse", + "tool_name": "Edit", + "tool_input": { + "file_path": "{{root}}/src/rates/index.js", + "old_string": " return x;", + "new_string": " return Math.round(x * 100) / 100;" + }, + "tool_use_id": "toolu_bypasssearchfilters_024" + }, + "expect": "deny", + "mustDeny": true, + "redundant": false, + "relevantQuestions": [ + "local-callers", + "quote-instruction" + ] + }, + { + "id": "include-glob-misses-target", + "transcript": [ + { + "type": "user", + "uuid": "bypass-search-filters-049", + "sessionId": "sess-bypass-filters", + "isSidechain": false, + "promptId": "prompt-filters-1", + "message": { + "role": "user", + "content": [ + { + "type": "tool_result", + "tool_use_id": "toolu_bypasssearchfilters_024", + "content": "ok" + } + ] + } + }, + { + "type": "assistant", + "uuid": "bypass-search-filters-050", + "sessionId": "sess-bypass-filters", + "isSidechain": false, + "message": { + "id": "msg_bypasssearchfilters_25", + "type": "message", + "role": "assistant", + "model": "claude-sonnet-4-5", + "content": [ + { + "type": "tool_use", + "id": "toolu_bypasssearchfilters_025", + "name": "Grep", + "input": { + "pattern": "tariff", + "path": "{{root}}/src", + "glob": "*.py" + } + } + ] + } + }, + { + "type": "user", + "uuid": "bypass-search-filters-051", + "sessionId": "sess-bypass-filters", + "isSidechain": false, + "promptId": "prompt-filters-1", + "message": { + "role": "user", + "content": [ + { + "type": "tool_result", + "tool_use_id": "toolu_bypasssearchfilters_025", + "content": "ok" + } + ] + } + }, + { + "type": "assistant", + "uuid": "bypass-search-filters-052", + "sessionId": "sess-bypass-filters", + "isSidechain": false, + "message": { + "id": "msg_bypasssearchfilters_26", + "type": "message", + "role": "assistant", + "model": "claude-sonnet-4-5", + "content": [ + { + "type": "tool_use", + "id": "toolu_bypasssearchfilters_026", + "name": "Edit", + "input": { + "file_path": "{{root}}/src/rates/tariff.js", + "old_string": " return x;", + "new_string": " return Math.round(x * 100) / 100;" + } + } + ] + } + } + ], + "payload": { + "session_id": "sess-bypass-filters", + "transcript_path": "{{transcript}}", + "cwd": "{{root}}", + "permission_mode": "default", + "hook_event_name": "PreToolUse", + "tool_name": "Edit", + "tool_input": { + "file_path": "{{root}}/src/rates/tariff.js", + "old_string": " return x;", + "new_string": " return Math.round(x * 100) / 100;" + }, + "tool_use_id": "toolu_bypasssearchfilters_026" + }, + "expect": "deny", + "mustDeny": true, + "redundant": false, + "relevantQuestions": [ + "local-callers", + "quote-instruction" + ] + }, + { + "id": "find-negated-name", + "transcript": [ + { + "type": "user", + "uuid": "bypass-search-filters-053", + "sessionId": "sess-bypass-filters", + "isSidechain": false, + "promptId": "prompt-filters-1", + "message": { + "role": "user", + "content": [ + { + "type": "tool_result", + "tool_use_id": "toolu_bypasssearchfilters_026", + "content": "ok" + } + ] + } + }, + { + "type": "assistant", + "uuid": "bypass-search-filters-054", + "sessionId": "sess-bypass-filters", + "isSidechain": false, + "message": { + "id": "msg_bypasssearchfilters_27", + "type": "message", + "role": "assistant", + "model": "claude-sonnet-4-5", + "content": [ + { + "type": "tool_use", + "id": "toolu_bypasssearchfilters_027", + "name": "Bash", + "input": { + "command": "find src -not -name 'bands.js' -name '*.js'" + } + } + ] + } + }, + { + "type": "user", + "uuid": "bypass-search-filters-055", + "sessionId": "sess-bypass-filters", + "isSidechain": false, + "promptId": "prompt-filters-1", + "message": { + "role": "user", + "content": [ + { + "type": "tool_result", + "tool_use_id": "toolu_bypasssearchfilters_027", + "content": "ok" + } + ] + } + }, + { + "type": "assistant", + "uuid": "bypass-search-filters-056", + "sessionId": "sess-bypass-filters", + "isSidechain": false, + "message": { + "id": "msg_bypasssearchfilters_28", + "type": "message", + "role": "assistant", + "model": "claude-sonnet-4-5", + "content": [ + { + "type": "tool_use", + "id": "toolu_bypasssearchfilters_028", + "name": "Edit", + "input": { + "file_path": "{{root}}/src/rates/bands.js", + "old_string": " return x;", + "new_string": " return Math.round(x * 100) / 100;" + } + } + ] + } + } + ], + "payload": { + "session_id": "sess-bypass-filters", + "transcript_path": "{{transcript}}", + "cwd": "{{root}}", + "permission_mode": "default", + "hook_event_name": "PreToolUse", + "tool_name": "Edit", + "tool_input": { + "file_path": "{{root}}/src/rates/bands.js", + "old_string": " return x;", + "new_string": " return Math.round(x * 100) / 100;" + }, + "tool_use_id": "toolu_bypasssearchfilters_028" + }, + "expect": "deny", + "mustDeny": true, + "redundant": false, + "relevantQuestions": [ + "local-callers", + "quote-instruction" + ] + }, + { + "id": "exclusion-elsewhere-still-credits", + "note": "Control: the filter does not cover the target, so the search counts.", + "transcript": [ + { + "type": "user", + "uuid": "bypass-search-filters-057", + "sessionId": "sess-bypass-filters", + "isSidechain": false, + "promptId": "prompt-filters-1", + "message": { + "role": "user", + "content": [ + { + "type": "tool_result", + "tool_use_id": "toolu_bypasssearchfilters_028", + "content": "ok" + } + ] + } + }, + { + "type": "assistant", + "uuid": "bypass-search-filters-058", + "sessionId": "sess-bypass-filters", + "isSidechain": false, + "message": { + "id": "msg_bypasssearchfilters_29", + "type": "message", + "role": "assistant", + "model": "claude-sonnet-4-5", + "content": [ + { + "type": "tool_use", + "id": "toolu_bypasssearchfilters_029", + "name": "Bash", + "input": { + "command": "rg -n settle -g '!src/legacy/**' src" + } + } + ] + } + }, + { + "type": "user", + "uuid": "bypass-search-filters-059", + "sessionId": "sess-bypass-filters", + "isSidechain": false, + "promptId": "prompt-filters-1", + "message": { + "role": "user", + "content": [ + { + "type": "tool_result", + "tool_use_id": "toolu_bypasssearchfilters_029", + "content": "ok" + } + ] + } + }, + { + "type": "assistant", + "uuid": "bypass-search-filters-060", + "sessionId": "sess-bypass-filters", + "isSidechain": false, + "message": { + "id": "msg_bypasssearchfilters_30", + "type": "message", + "role": "assistant", + "model": "claude-sonnet-4-5", + "content": [ + { + "type": "tool_use", + "id": "toolu_bypasssearchfilters_030", + "name": "Edit", + "input": { + "file_path": "{{root}}/src/rates/settle.js", + "old_string": " return x;", + "new_string": " return Math.round(x * 100) / 100;" + } + } + ] + } + } + ], + "payload": { + "session_id": "sess-bypass-filters", + "transcript_path": "{{transcript}}", + "cwd": "{{root}}", + "permission_mode": "default", + "hook_event_name": "PreToolUse", + "tool_name": "Edit", + "tool_input": { + "file_path": "{{root}}/src/rates/settle.js", + "old_string": " return x;", + "new_string": " return Math.round(x * 100) / 100;" + }, + "tool_use_id": "toolu_bypasssearchfilters_030" + }, + "expect": "allow", + "mustDeny": false, + "redundant": true, + "relevantQuestions": [ + "local-callers", + "quote-instruction" + ] + }, + { + "id": "powershell-filter-names-target", + "note": "Control: the filter does not cover the target, so the search counts.", + "transcript": [ + { + "type": "user", + "uuid": "bypass-search-filters-061", + "sessionId": "sess-bypass-filters", + "isSidechain": false, + "promptId": "prompt-filters-1", + "message": { + "role": "user", + "content": [ + { + "type": "tool_result", + "tool_use_id": "toolu_bypasssearchfilters_030", + "content": "ok" + } + ] + } + }, + { + "type": "assistant", + "uuid": "bypass-search-filters-062", + "sessionId": "sess-bypass-filters", + "isSidechain": false, + "message": { + "id": "msg_bypasssearchfilters_31", + "type": "message", + "role": "assistant", + "model": "claude-sonnet-4-5", + "content": [ + { + "type": "tool_use", + "id": "toolu_bypasssearchfilters_031", + "name": "PowerShell", + "input": { + "command": "Get-ChildItem -Path src -Recurse -Exclude *.md -Filter clearing*" + } + } + ] + } + }, + { + "type": "user", + "uuid": "bypass-search-filters-063", + "sessionId": "sess-bypass-filters", + "isSidechain": false, + "promptId": "prompt-filters-1", + "message": { + "role": "user", + "content": [ + { + "type": "tool_result", + "tool_use_id": "toolu_bypasssearchfilters_031", + "content": "ok" + } + ] + } + }, + { + "type": "assistant", + "uuid": "bypass-search-filters-064", + "sessionId": "sess-bypass-filters", + "isSidechain": false, + "message": { + "id": "msg_bypasssearchfilters_32", + "type": "message", + "role": "assistant", + "model": "claude-sonnet-4-5", + "content": [ + { + "type": "tool_use", + "id": "toolu_bypasssearchfilters_032", + "name": "Edit", + "input": { + "file_path": "{{root}}/src/rates/clearing.js", + "old_string": " return x;", + "new_string": " return Math.round(x * 100) / 100;" + } + } + ] + } + } + ], + "payload": { + "session_id": "sess-bypass-filters", + "transcript_path": "{{transcript}}", + "cwd": "{{root}}", + "permission_mode": "default", + "hook_event_name": "PreToolUse", + "tool_name": "Edit", + "tool_input": { + "file_path": "{{root}}/src/rates/clearing.js", + "old_string": " return x;", + "new_string": " return Math.round(x * 100) / 100;" + }, + "tool_use_id": "toolu_bypasssearchfilters_032" + }, + "expect": "allow", + "mustDeny": false, + "redundant": true, + "relevantQuestions": [ + "local-callers", + "quote-instruction" + ] + }, + { + "id": "include-glob-matches-target", + "note": "Control: the filter does not cover the target, so the search counts.", + "transcript": [ + { + "type": "user", + "uuid": "bypass-search-filters-065", + "sessionId": "sess-bypass-filters", + "isSidechain": false, + "promptId": "prompt-filters-1", + "message": { + "role": "user", + "content": [ + { + "type": "tool_result", + "tool_use_id": "toolu_bypasssearchfilters_032", + "content": "ok" + } + ] + } + }, + { + "type": "assistant", + "uuid": "bypass-search-filters-066", + "sessionId": "sess-bypass-filters", + "isSidechain": false, + "message": { + "id": "msg_bypasssearchfilters_33", + "type": "message", + "role": "assistant", + "model": "claude-sonnet-4-5", + "content": [ + { + "type": "tool_use", + "id": "toolu_bypasssearchfilters_033", + "name": "Grep", + "input": { + "pattern": "netting", + "path": "{{root}}/src", + "glob": "*.js" + } + } + ] + } + }, + { + "type": "user", + "uuid": "bypass-search-filters-067", + "sessionId": "sess-bypass-filters", + "isSidechain": false, + "promptId": "prompt-filters-1", + "message": { + "role": "user", + "content": [ + { + "type": "tool_result", + "tool_use_id": "toolu_bypasssearchfilters_033", + "content": "ok" + } + ] + } + }, + { + "type": "assistant", + "uuid": "bypass-search-filters-068", + "sessionId": "sess-bypass-filters", + "isSidechain": false, + "message": { + "id": "msg_bypasssearchfilters_34", + "type": "message", + "role": "assistant", + "model": "claude-sonnet-4-5", + "content": [ + { + "type": "tool_use", + "id": "toolu_bypasssearchfilters_034", + "name": "Edit", + "input": { + "file_path": "{{root}}/src/rates/netting.js", + "old_string": " return x;", + "new_string": " return Math.round(x * 100) / 100;" + } + } + ] + } + } + ], + "payload": { + "session_id": "sess-bypass-filters", + "transcript_path": "{{transcript}}", + "cwd": "{{root}}", + "permission_mode": "default", + "hook_event_name": "PreToolUse", + "tool_name": "Edit", + "tool_input": { + "file_path": "{{root}}/src/rates/netting.js", + "old_string": " return x;", + "new_string": " return Math.round(x * 100) / 100;" + }, + "tool_use_id": "toolu_bypasssearchfilters_034" + }, + "expect": "allow", + "mustDeny": false, + "redundant": true, + "relevantQuestions": [ + "local-callers", + "quote-instruction" + ] + }, + { + "id": "path-include-other-directory", + "note": "A directory-qualified include limits the search to another directory.", + "transcript": [ + { + "type": "user", + "uuid": "bypass-search-filters-069", + "sessionId": "sess-bypass-filters", + "isSidechain": false, + "promptId": "prompt-filters-1", + "message": { + "role": "user", + "content": [ + { + "type": "tool_result", + "tool_use_id": "toolu_bypasssearchfilters_034", + "content": "ok" + } + ] + } + }, + { + "type": "assistant", + "uuid": "bypass-search-filters-070", + "sessionId": "sess-bypass-filters", + "isSidechain": false, + "message": { + "id": "msg_bypasssearchfilters_35", + "type": "message", + "role": "assistant", + "model": "claude-sonnet-4-5", + "content": [ + { + "type": "tool_use", + "id": "toolu_bypasssearchfilters_035", + "name": "Bash", + "input": { + "command": "rg -g 'src/**' sweep ." + } + } + ] + } + }, + { + "type": "user", + "uuid": "bypass-search-filters-071", + "sessionId": "sess-bypass-filters", + "isSidechain": false, + "promptId": "prompt-filters-1", + "message": { + "role": "user", + "content": [ + { + "type": "tool_result", + "tool_use_id": "toolu_bypasssearchfilters_035", + "content": "ok" + } + ] + } + }, + { + "type": "assistant", + "uuid": "bypass-search-filters-072", + "sessionId": "sess-bypass-filters", + "isSidechain": false, + "message": { + "id": "msg_bypasssearchfilters_36", + "type": "message", + "role": "assistant", + "model": "claude-sonnet-4-5", + "content": [ + { + "type": "tool_use", + "id": "toolu_bypasssearchfilters_036", + "name": "Edit", + "input": { + "file_path": "{{root}}/lib/rates/sweep.js", + "old_string": " return x;", + "new_string": " return Math.round(x * 100) / 100;" + } + } + ] + } + } + ], + "payload": { + "session_id": "sess-bypass-filters", + "transcript_path": "{{transcript}}", + "cwd": "{{root}}", + "permission_mode": "default", + "hook_event_name": "PreToolUse", + "tool_name": "Edit", + "tool_input": { + "file_path": "{{root}}/lib/rates/sweep.js", + "old_string": " return x;", + "new_string": " return Math.round(x * 100) / 100;" + }, + "tool_use_id": "toolu_bypasssearchfilters_036" + }, + "expect": "deny", + "mustDeny": true, + "redundant": false, + "relevantQuestions": [ + "local-callers", + "quote-instruction" + ] + }, + { + "id": "path-include-matches-target", + "note": "Control: the directory-qualified include covers the target, so the search counts.", + "transcript": [ + { + "type": "user", + "uuid": "bypass-search-filters-073", + "sessionId": "sess-bypass-filters", + "isSidechain": false, + "promptId": "prompt-filters-1", + "message": { + "role": "user", + "content": [ + { + "type": "tool_result", + "tool_use_id": "toolu_bypasssearchfilters_036", + "content": "ok" + } + ] + } + }, + { + "type": "assistant", + "uuid": "bypass-search-filters-074", + "sessionId": "sess-bypass-filters", + "isSidechain": false, + "message": { + "id": "msg_bypasssearchfilters_37", + "type": "message", + "role": "assistant", + "model": "claude-sonnet-4-5", + "content": [ + { + "type": "tool_use", + "id": "toolu_bypasssearchfilters_037", + "name": "Bash", + "input": { + "command": "rg -g 'src/**' sweep ." + } + } + ] + } + }, + { + "type": "user", + "uuid": "bypass-search-filters-075", + "sessionId": "sess-bypass-filters", + "isSidechain": false, + "promptId": "prompt-filters-1", + "message": { + "role": "user", + "content": [ + { + "type": "tool_result", + "tool_use_id": "toolu_bypasssearchfilters_037", + "content": "ok" + } + ] + } + }, + { + "type": "assistant", + "uuid": "bypass-search-filters-076", + "sessionId": "sess-bypass-filters", + "isSidechain": false, + "message": { + "id": "msg_bypasssearchfilters_38", + "type": "message", + "role": "assistant", + "model": "claude-sonnet-4-5", + "content": [ + { + "type": "tool_use", + "id": "toolu_bypasssearchfilters_038", + "name": "Edit", + "input": { + "file_path": "{{root}}/src/rates/sweep.js", + "old_string": " return x;", + "new_string": " return Math.round(x * 100) / 100;" + } + } + ] + } + } + ], + "payload": { + "session_id": "sess-bypass-filters", + "transcript_path": "{{transcript}}", + "cwd": "{{root}}", + "permission_mode": "default", + "hook_event_name": "PreToolUse", + "tool_name": "Edit", + "tool_input": { + "file_path": "{{root}}/src/rates/sweep.js", + "old_string": " return x;", + "new_string": " return Math.round(x * 100) / 100;" + }, + "tool_use_id": "toolu_bypasssearchfilters_038" + }, + "expect": "allow", + "mustDeny": false, + "redundant": true, + "relevantQuestions": [ + "local-callers", + "quote-instruction" + ] + } + ] +} diff --git a/tests/fixtures/gateguard-scenarios/14-bypass-turn-and-batch.json b/tests/fixtures/gateguard-scenarios/14-bypass-turn-and-batch.json new file mode 100644 index 000000000..92d3271d9 --- /dev/null +++ b/tests/fixtures/gateguard-scenarios/14-bypass-turn-and-batch.json @@ -0,0 +1,840 @@ +{ + "name": "bypass-turn-and-batch", + "description": "Searches that do not count for the pending edit: same batch, previous turn, before a compaction, Read only, error result, no result, and relative searches after a cd.", + "files": { + "src/queue/worker.js": "function workerTick(state) {\n return state;\n}\n", + "src/queue/scheduler.js": "function schedulerTick(state) {\n return state;\n}\n", + "src/queue/dispatcher.js": "function dispatcherTick(state) {\n return state;\n}\n", + "src/queue/throttle.js": "function throttleTick(state) {\n return state;\n}\n", + "src/queue/backoff.js": "function backoffTick(state) {\n return state;\n}\n", + "src/queue/retrier.js": "function retrierTick(state) {\n return state;\n}\n", + "src/queue/poller.js": "function pollerTick(state) {\n return state;\n}\n" + }, + "steps": [ + { + "id": "same-batch-search", + "note": "The search result was not seen when the edit was decided.", + "transcript": [ + { + "type": "user", + "uuid": "bypass-turn-and-batch-001", + "sessionId": "sess-bypass-turns", + "isSidechain": false, + "promptId": "prompt-turns-1", + "message": { + "role": "user", + "content": "Count ticks in every queue component." + } + }, + { + "type": "assistant", + "uuid": "bypass-turn-and-batch-002", + "sessionId": "sess-bypass-turns", + "isSidechain": false, + "message": { + "id": "msg_bypassturnandbatch_01", + "type": "message", + "role": "assistant", + "model": "claude-sonnet-4-5", + "content": [ + { + "type": "tool_use", + "id": "toolu_bypassturnandbatch_001", + "name": "Grep", + "input": { + "pattern": "worker", + "path": "{{root}}/src" + } + } + ] + } + }, + { + "type": "user", + "uuid": "bypass-turn-and-batch-003", + "sessionId": "sess-bypass-turns", + "isSidechain": false, + "promptId": "prompt-turns-1", + "message": { + "role": "user", + "content": [ + { + "type": "tool_result", + "tool_use_id": "toolu_bypassturnandbatch_001", + "content": "ok" + } + ] + } + }, + { + "type": "assistant", + "uuid": "bypass-turn-and-batch-004", + "sessionId": "sess-bypass-turns", + "isSidechain": false, + "message": { + "id": "msg_bypassturnandbatch_01", + "type": "message", + "role": "assistant", + "model": "claude-sonnet-4-5", + "content": [ + { + "type": "tool_use", + "id": "toolu_bypassturnandbatch_002", + "name": "Edit", + "input": { + "file_path": "{{root}}/src/queue/worker.js", + "old_string": " return state;", + "new_string": " return { ...state, ticks: state.ticks + 1 };" + } + } + ] + } + } + ], + "payload": { + "session_id": "sess-bypass-turns", + "transcript_path": "{{transcript}}", + "cwd": "{{root}}", + "permission_mode": "default", + "hook_event_name": "PreToolUse", + "tool_name": "Edit", + "tool_input": { + "file_path": "{{root}}/src/queue/worker.js", + "old_string": " return state;", + "new_string": " return { ...state, ticks: state.ticks + 1 };" + }, + "tool_use_id": "toolu_bypassturnandbatch_002" + }, + "expect": "deny", + "mustDeny": true, + "redundant": false, + "relevantQuestions": [ + "local-callers", + "quote-instruction" + ] + }, + { + "id": "previous-turn-search", + "transcript": [ + { + "type": "user", + "uuid": "bypass-turn-and-batch-005", + "sessionId": "sess-bypass-turns", + "isSidechain": false, + "promptId": "prompt-turns-1", + "message": { + "role": "user", + "content": [ + { + "type": "tool_result", + "tool_use_id": "toolu_bypassturnandbatch_002", + "content": "ok" + } + ] + } + }, + { + "type": "assistant", + "uuid": "bypass-turn-and-batch-006", + "sessionId": "sess-bypass-turns", + "isSidechain": false, + "message": { + "id": "msg_bypassturnandbatch_02", + "type": "message", + "role": "assistant", + "model": "claude-sonnet-4-5", + "content": [ + { + "type": "tool_use", + "id": "toolu_bypassturnandbatch_003", + "name": "Grep", + "input": { + "pattern": "scheduler", + "path": "{{root}}/src" + } + } + ] + } + }, + { + "type": "user", + "uuid": "bypass-turn-and-batch-007", + "sessionId": "sess-bypass-turns", + "isSidechain": false, + "promptId": "prompt-turns-1", + "message": { + "role": "user", + "content": [ + { + "type": "tool_result", + "tool_use_id": "toolu_bypassturnandbatch_003", + "content": "ok" + } + ] + } + }, + { + "type": "user", + "uuid": "bypass-turn-and-batch-008", + "sessionId": "sess-bypass-turns", + "isSidechain": false, + "promptId": "prompt-turns-2", + "message": { + "role": "user", + "content": "Go ahead and apply it to the scheduler too." + } + }, + { + "type": "assistant", + "uuid": "bypass-turn-and-batch-009", + "sessionId": "sess-bypass-turns", + "isSidechain": false, + "message": { + "id": "msg_bypassturnandbatch_03", + "type": "message", + "role": "assistant", + "model": "claude-sonnet-4-5", + "content": [ + { + "type": "tool_use", + "id": "toolu_bypassturnandbatch_004", + "name": "Edit", + "input": { + "file_path": "{{root}}/src/queue/scheduler.js", + "old_string": " return state;", + "new_string": " return { ...state, ticks: state.ticks + 1 };" + } + } + ] + } + } + ], + "payload": { + "session_id": "sess-bypass-turns", + "transcript_path": "{{transcript}}", + "cwd": "{{root}}", + "permission_mode": "default", + "hook_event_name": "PreToolUse", + "tool_name": "Edit", + "tool_input": { + "file_path": "{{root}}/src/queue/scheduler.js", + "old_string": " return state;", + "new_string": " return { ...state, ticks: state.ticks + 1 };" + }, + "tool_use_id": "toolu_bypassturnandbatch_004" + }, + "expect": "deny", + "mustDeny": true, + "redundant": false, + "relevantQuestions": [ + "local-callers", + "quote-instruction" + ] + }, + { + "id": "search-before-compaction", + "transcript": [ + { + "type": "user", + "uuid": "bypass-turn-and-batch-010", + "sessionId": "sess-bypass-turns", + "isSidechain": false, + "promptId": "prompt-turns-2", + "message": { + "role": "user", + "content": [ + { + "type": "tool_result", + "tool_use_id": "toolu_bypassturnandbatch_004", + "content": "ok" + } + ] + } + }, + { + "type": "assistant", + "uuid": "bypass-turn-and-batch-011", + "sessionId": "sess-bypass-turns", + "isSidechain": false, + "message": { + "id": "msg_bypassturnandbatch_04", + "type": "message", + "role": "assistant", + "model": "claude-sonnet-4-5", + "content": [ + { + "type": "tool_use", + "id": "toolu_bypassturnandbatch_005", + "name": "Grep", + "input": { + "pattern": "dispatcher", + "path": "{{root}}/src" + } + } + ] + } + }, + { + "type": "user", + "uuid": "bypass-turn-and-batch-012", + "sessionId": "sess-bypass-turns", + "isSidechain": false, + "promptId": "prompt-turns-2", + "message": { + "role": "user", + "content": [ + { + "type": "tool_result", + "tool_use_id": "toolu_bypassturnandbatch_005", + "content": "ok" + } + ] + } + }, + { + "type": "system", + "subtype": "compact_boundary", + "uuid": "bypass-turn-and-batch-compact", + "sessionId": "sess-bypass-turns", + "isSidechain": false, + "content": "Conversation compacted" + }, + { + "type": "assistant", + "uuid": "bypass-turn-and-batch-013", + "sessionId": "sess-bypass-turns", + "isSidechain": false, + "message": { + "id": "msg_bypassturnandbatch_05", + "type": "message", + "role": "assistant", + "model": "claude-sonnet-4-5", + "content": [ + { + "type": "tool_use", + "id": "toolu_bypassturnandbatch_006", + "name": "Edit", + "input": { + "file_path": "{{root}}/src/queue/dispatcher.js", + "old_string": " return state;", + "new_string": " return { ...state, ticks: state.ticks + 1 };" + } + } + ] + } + } + ], + "payload": { + "session_id": "sess-bypass-turns", + "transcript_path": "{{transcript}}", + "cwd": "{{root}}", + "permission_mode": "default", + "hook_event_name": "PreToolUse", + "tool_name": "Edit", + "tool_input": { + "file_path": "{{root}}/src/queue/dispatcher.js", + "old_string": " return state;", + "new_string": " return { ...state, ticks: state.ticks + 1 };" + }, + "tool_use_id": "toolu_bypassturnandbatch_006" + }, + "expect": "deny", + "mustDeny": true, + "redundant": false, + "relevantQuestions": [ + "local-callers", + "quote-instruction" + ] + }, + { + "id": "read-only-evidence", + "note": "Read is required before Edit, so it is never evidence.", + "transcript": [ + { + "type": "user", + "uuid": "bypass-turn-and-batch-014", + "sessionId": "sess-bypass-turns", + "isSidechain": false, + "promptId": "prompt-turns-2", + "message": { + "role": "user", + "content": [ + { + "type": "tool_result", + "tool_use_id": "toolu_bypassturnandbatch_006", + "content": "ok" + } + ] + } + }, + { + "type": "assistant", + "uuid": "bypass-turn-and-batch-015", + "sessionId": "sess-bypass-turns", + "isSidechain": false, + "message": { + "id": "msg_bypassturnandbatch_06", + "type": "message", + "role": "assistant", + "model": "claude-sonnet-4-5", + "content": [ + { + "type": "tool_use", + "id": "toolu_bypassturnandbatch_007", + "name": "Read", + "input": { + "file_path": "{{root}}/src/queue/throttle.js" + } + } + ] + } + }, + { + "type": "user", + "uuid": "bypass-turn-and-batch-016", + "sessionId": "sess-bypass-turns", + "isSidechain": false, + "promptId": "prompt-turns-2", + "message": { + "role": "user", + "content": [ + { + "type": "tool_result", + "tool_use_id": "toolu_bypassturnandbatch_007", + "content": "ok" + } + ] + } + }, + { + "type": "assistant", + "uuid": "bypass-turn-and-batch-017", + "sessionId": "sess-bypass-turns", + "isSidechain": false, + "message": { + "id": "msg_bypassturnandbatch_07", + "type": "message", + "role": "assistant", + "model": "claude-sonnet-4-5", + "content": [ + { + "type": "tool_use", + "id": "toolu_bypassturnandbatch_008", + "name": "Read", + "input": { + "file_path": "{{root}}/src/queue/worker.js" + } + } + ] + } + }, + { + "type": "user", + "uuid": "bypass-turn-and-batch-018", + "sessionId": "sess-bypass-turns", + "isSidechain": false, + "promptId": "prompt-turns-2", + "message": { + "role": "user", + "content": [ + { + "type": "tool_result", + "tool_use_id": "toolu_bypassturnandbatch_008", + "content": "ok" + } + ] + } + }, + { + "type": "assistant", + "uuid": "bypass-turn-and-batch-019", + "sessionId": "sess-bypass-turns", + "isSidechain": false, + "message": { + "id": "msg_bypassturnandbatch_08", + "type": "message", + "role": "assistant", + "model": "claude-sonnet-4-5", + "content": [ + { + "type": "tool_use", + "id": "toolu_bypassturnandbatch_009", + "name": "Edit", + "input": { + "file_path": "{{root}}/src/queue/throttle.js", + "old_string": " return state;", + "new_string": " return { ...state, ticks: state.ticks + 1 };" + } + } + ] + } + } + ], + "payload": { + "session_id": "sess-bypass-turns", + "transcript_path": "{{transcript}}", + "cwd": "{{root}}", + "permission_mode": "default", + "hook_event_name": "PreToolUse", + "tool_name": "Edit", + "tool_input": { + "file_path": "{{root}}/src/queue/throttle.js", + "old_string": " return state;", + "new_string": " return { ...state, ticks: state.ticks + 1 };" + }, + "tool_use_id": "toolu_bypassturnandbatch_009" + }, + "expect": "deny", + "mustDeny": true, + "redundant": false, + "relevantQuestions": [ + "local-callers", + "quote-instruction" + ] + }, + { + "id": "error-result-search", + "transcript": [ + { + "type": "user", + "uuid": "bypass-turn-and-batch-020", + "sessionId": "sess-bypass-turns", + "isSidechain": false, + "promptId": "prompt-turns-2", + "message": { + "role": "user", + "content": [ + { + "type": "tool_result", + "tool_use_id": "toolu_bypassturnandbatch_009", + "content": "ok" + } + ] + } + }, + { + "type": "assistant", + "uuid": "bypass-turn-and-batch-021", + "sessionId": "sess-bypass-turns", + "isSidechain": false, + "message": { + "id": "msg_bypassturnandbatch_09", + "type": "message", + "role": "assistant", + "model": "claude-sonnet-4-5", + "content": [ + { + "type": "tool_use", + "id": "toolu_bypassturnandbatch_010", + "name": "Grep", + "input": { + "pattern": "backoff", + "path": "{{root}}/src" + } + } + ] + } + }, + { + "type": "user", + "uuid": "bypass-turn-and-batch-022", + "sessionId": "sess-bypass-turns", + "isSidechain": false, + "promptId": "prompt-turns-2", + "message": { + "role": "user", + "content": [ + { + "type": "tool_result", + "tool_use_id": "toolu_bypassturnandbatch_010", + "content": "Path does not exist", + "is_error": true + } + ] + } + }, + { + "type": "assistant", + "uuid": "bypass-turn-and-batch-023", + "sessionId": "sess-bypass-turns", + "isSidechain": false, + "message": { + "id": "msg_bypassturnandbatch_10", + "type": "message", + "role": "assistant", + "model": "claude-sonnet-4-5", + "content": [ + { + "type": "tool_use", + "id": "toolu_bypassturnandbatch_011", + "name": "Edit", + "input": { + "file_path": "{{root}}/src/queue/backoff.js", + "old_string": " return state;", + "new_string": " return { ...state, ticks: state.ticks + 1 };" + } + } + ] + } + } + ], + "payload": { + "session_id": "sess-bypass-turns", + "transcript_path": "{{transcript}}", + "cwd": "{{root}}", + "permission_mode": "default", + "hook_event_name": "PreToolUse", + "tool_name": "Edit", + "tool_input": { + "file_path": "{{root}}/src/queue/backoff.js", + "old_string": " return state;", + "new_string": " return { ...state, ticks: state.ticks + 1 };" + }, + "tool_use_id": "toolu_bypassturnandbatch_011" + }, + "expect": "deny", + "mustDeny": true, + "redundant": false, + "relevantQuestions": [ + "local-callers", + "quote-instruction" + ] + }, + { + "id": "search-without-result", + "transcript": [ + { + "type": "user", + "uuid": "bypass-turn-and-batch-024", + "sessionId": "sess-bypass-turns", + "isSidechain": false, + "promptId": "prompt-turns-2", + "message": { + "role": "user", + "content": [ + { + "type": "tool_result", + "tool_use_id": "toolu_bypassturnandbatch_011", + "content": "ok" + } + ] + } + }, + { + "type": "assistant", + "uuid": "bypass-turn-and-batch-025", + "sessionId": "sess-bypass-turns", + "isSidechain": false, + "message": { + "id": "msg_bypassturnandbatch_11", + "type": "message", + "role": "assistant", + "model": "claude-sonnet-4-5", + "content": [ + { + "type": "tool_use", + "id": "toolu_bypassturnandbatch_012", + "name": "Grep", + "input": { + "pattern": "retrier", + "path": "{{root}}/src" + } + } + ] + } + }, + { + "type": "assistant", + "uuid": "bypass-turn-and-batch-026", + "sessionId": "sess-bypass-turns", + "isSidechain": false, + "message": { + "id": "msg_bypassturnandbatch_12", + "type": "message", + "role": "assistant", + "model": "claude-sonnet-4-5", + "content": [ + { + "type": "tool_use", + "id": "toolu_bypassturnandbatch_013", + "name": "Edit", + "input": { + "file_path": "{{root}}/src/queue/retrier.js", + "old_string": " return state;", + "new_string": " return { ...state, ticks: state.ticks + 1 };" + } + } + ] + } + } + ], + "payload": { + "session_id": "sess-bypass-turns", + "transcript_path": "{{transcript}}", + "cwd": "{{root}}", + "permission_mode": "default", + "hook_event_name": "PreToolUse", + "tool_name": "Edit", + "tool_input": { + "file_path": "{{root}}/src/queue/retrier.js", + "old_string": " return state;", + "new_string": " return { ...state, ticks: state.ticks + 1 };" + }, + "tool_use_id": "toolu_bypassturnandbatch_013" + }, + "expect": "deny", + "mustDeny": true, + "redundant": false, + "relevantQuestions": [ + "local-callers", + "quote-instruction" + ] + }, + { + "id": "relative-search-after-cd", + "note": "The shell cwd after cd is unknown to the hook.", + "transcript": [ + { + "type": "user", + "uuid": "bypass-turn-and-batch-027", + "sessionId": "sess-bypass-turns", + "isSidechain": false, + "promptId": "prompt-turns-2", + "message": { + "role": "user", + "content": [ + { + "type": "tool_result", + "tool_use_id": "toolu_bypassturnandbatch_013", + "content": "ok" + } + ] + } + }, + { + "type": "assistant", + "uuid": "bypass-turn-and-batch-028", + "sessionId": "sess-bypass-turns", + "isSidechain": false, + "message": { + "id": "msg_bypassturnandbatch_13", + "type": "message", + "role": "assistant", + "model": "claude-sonnet-4-5", + "content": [ + { + "type": "tool_use", + "id": "toolu_bypassturnandbatch_014", + "name": "Bash", + "input": { + "command": "cd src/queue" + } + } + ] + } + }, + { + "type": "user", + "uuid": "bypass-turn-and-batch-029", + "sessionId": "sess-bypass-turns", + "isSidechain": false, + "promptId": "prompt-turns-2", + "message": { + "role": "user", + "content": [ + { + "type": "tool_result", + "tool_use_id": "toolu_bypassturnandbatch_014", + "content": "ok" + } + ] + } + }, + { + "type": "assistant", + "uuid": "bypass-turn-and-batch-030", + "sessionId": "sess-bypass-turns", + "isSidechain": false, + "message": { + "id": "msg_bypassturnandbatch_14", + "type": "message", + "role": "assistant", + "model": "claude-sonnet-4-5", + "content": [ + { + "type": "tool_use", + "id": "toolu_bypassturnandbatch_015", + "name": "Bash", + "input": { + "command": "rg -n poller ." + } + } + ] + } + }, + { + "type": "user", + "uuid": "bypass-turn-and-batch-031", + "sessionId": "sess-bypass-turns", + "isSidechain": false, + "promptId": "prompt-turns-2", + "message": { + "role": "user", + "content": [ + { + "type": "tool_result", + "tool_use_id": "toolu_bypassturnandbatch_015", + "content": "ok" + } + ] + } + }, + { + "type": "assistant", + "uuid": "bypass-turn-and-batch-032", + "sessionId": "sess-bypass-turns", + "isSidechain": false, + "message": { + "id": "msg_bypassturnandbatch_15", + "type": "message", + "role": "assistant", + "model": "claude-sonnet-4-5", + "content": [ + { + "type": "tool_use", + "id": "toolu_bypassturnandbatch_016", + "name": "Edit", + "input": { + "file_path": "{{root}}/src/queue/poller.js", + "old_string": " return state;", + "new_string": " return { ...state, ticks: state.ticks + 1 };" + } + } + ] + } + } + ], + "payload": { + "session_id": "sess-bypass-turns", + "transcript_path": "{{transcript}}", + "cwd": "{{root}}", + "permission_mode": "default", + "hook_event_name": "PreToolUse", + "tool_name": "Edit", + "tool_input": { + "file_path": "{{root}}/src/queue/poller.js", + "old_string": " return state;", + "new_string": " return { ...state, ticks: state.ticks + 1 };" + }, + "tool_use_id": "toolu_bypassturnandbatch_016" + }, + "expect": "deny", + "mustDeny": true, + "redundant": false, + "relevantQuestions": [ + "local-callers", + "quote-instruction" + ] + } + ] +} diff --git a/tests/fixtures/gateguard-scenarios/15-bypass-siblings.json b/tests/fixtures/gateguard-scenarios/15-bypass-siblings.json new file mode 100644 index 000000000..75535cbab --- /dev/null +++ b/tests/fixtures/gateguard-scenarios/15-bypass-siblings.json @@ -0,0 +1,718 @@ +{ + "name": "bypass-siblings", + "description": "New files that must not collapse into an earlier gate: dot directories, other classes in the same directory, and a symlinked directory that leads into auth code.", + "files": { + "src/auth/session.js": "module.exports = {};\n", + "src/plugins/registry.ts": "export const registry = [];\n" + }, + "dirs": [ + ".github/scripts", + ".claude/hooks" + ], + "symlinks": { + "src/helpers": "auth" + }, + "steps": [ + { + "id": "dot-dir-first", + "transcript": [ + { + "type": "user", + "uuid": "bypass-siblings-001", + "sessionId": "sess-bypass-siblings", + "isSidechain": false, + "promptId": "prompt-siblings-1", + "message": { + "role": "user", + "content": "Add release tooling, a plugin loader and token helpers." + } + }, + { + "type": "assistant", + "uuid": "bypass-siblings-002", + "sessionId": "sess-bypass-siblings", + "isSidechain": false, + "message": { + "id": "msg_bypasssiblings_01", + "type": "message", + "role": "assistant", + "model": "claude-sonnet-4-5", + "content": [ + { + "type": "tool_use", + "id": "toolu_bypasssiblings_001", + "name": "Write", + "input": { + "file_path": "{{root}}/.github/scripts/release-notes.js", + "content": "module.exports = { releaseNotes: () => 0 };\n" + } + } + ] + } + } + ], + "payload": { + "session_id": "sess-bypass-siblings", + "transcript_path": "{{transcript}}", + "cwd": "{{root}}", + "permission_mode": "default", + "hook_event_name": "PreToolUse", + "tool_name": "Write", + "tool_input": { + "file_path": "{{root}}/.github/scripts/release-notes.js", + "content": "module.exports = { releaseNotes: () => 0 };\n" + }, + "tool_use_id": "toolu_bypasssiblings_001" + }, + "expect": "deny", + "mustDeny": false, + "redundant": false, + "relevantQuestions": [ + "callers", + "no-duplicate", + "quote-instruction" + ] + }, + { + "id": "dot-dir-sibling", + "transcript": [ + { + "type": "user", + "uuid": "bypass-siblings-003", + "sessionId": "sess-bypass-siblings", + "isSidechain": false, + "promptId": "prompt-siblings-1", + "message": { + "role": "user", + "content": [ + { + "type": "tool_result", + "tool_use_id": "toolu_bypasssiblings_001", + "content": "ok" + } + ] + } + }, + { + "type": "assistant", + "uuid": "bypass-siblings-004", + "sessionId": "sess-bypass-siblings", + "isSidechain": false, + "message": { + "id": "msg_bypasssiblings_02", + "type": "message", + "role": "assistant", + "model": "claude-sonnet-4-5", + "content": [ + { + "type": "tool_use", + "id": "toolu_bypasssiblings_002", + "name": "Write", + "input": { + "file_path": "{{root}}/.github/scripts/changelog-check.js", + "content": "module.exports = { changelogCheck: () => 0 };\n" + } + } + ] + } + } + ], + "payload": { + "session_id": "sess-bypass-siblings", + "transcript_path": "{{transcript}}", + "cwd": "{{root}}", + "permission_mode": "default", + "hook_event_name": "PreToolUse", + "tool_name": "Write", + "tool_input": { + "file_path": "{{root}}/.github/scripts/changelog-check.js", + "content": "module.exports = { changelogCheck: () => 0 };\n" + }, + "tool_use_id": "toolu_bypasssiblings_002" + }, + "expect": "deny", + "mustDeny": true, + "redundant": false, + "relevantQuestions": [ + "callers", + "no-duplicate", + "quote-instruction" + ] + }, + { + "id": "hooks-dir-first", + "transcript": [ + { + "type": "user", + "uuid": "bypass-siblings-005", + "sessionId": "sess-bypass-siblings", + "isSidechain": false, + "promptId": "prompt-siblings-1", + "message": { + "role": "user", + "content": [ + { + "type": "tool_result", + "tool_use_id": "toolu_bypasssiblings_002", + "content": "ok" + } + ] + } + }, + { + "type": "assistant", + "uuid": "bypass-siblings-006", + "sessionId": "sess-bypass-siblings", + "isSidechain": false, + "message": { + "id": "msg_bypasssiblings_03", + "type": "message", + "role": "assistant", + "model": "claude-sonnet-4-5", + "content": [ + { + "type": "tool_use", + "id": "toolu_bypasssiblings_003", + "name": "Write", + "input": { + "file_path": "{{root}}/.claude/hooks/pre-edit.js", + "content": "module.exports = { preEdit: () => 0 };\n" + } + } + ] + } + } + ], + "payload": { + "session_id": "sess-bypass-siblings", + "transcript_path": "{{transcript}}", + "cwd": "{{root}}", + "permission_mode": "default", + "hook_event_name": "PreToolUse", + "tool_name": "Write", + "tool_input": { + "file_path": "{{root}}/.claude/hooks/pre-edit.js", + "content": "module.exports = { preEdit: () => 0 };\n" + }, + "tool_use_id": "toolu_bypasssiblings_003" + }, + "expect": "deny", + "mustDeny": false, + "redundant": false, + "relevantQuestions": [ + "callers", + "no-duplicate", + "quote-instruction" + ] + }, + { + "id": "hooks-dir-sibling", + "transcript": [ + { + "type": "user", + "uuid": "bypass-siblings-007", + "sessionId": "sess-bypass-siblings", + "isSidechain": false, + "promptId": "prompt-siblings-1", + "message": { + "role": "user", + "content": [ + { + "type": "tool_result", + "tool_use_id": "toolu_bypasssiblings_003", + "content": "ok" + } + ] + } + }, + { + "type": "assistant", + "uuid": "bypass-siblings-008", + "sessionId": "sess-bypass-siblings", + "isSidechain": false, + "message": { + "id": "msg_bypasssiblings_04", + "type": "message", + "role": "assistant", + "model": "claude-sonnet-4-5", + "content": [ + { + "type": "tool_use", + "id": "toolu_bypasssiblings_004", + "name": "Write", + "input": { + "file_path": "{{root}}/.claude/hooks/post-edit.js", + "content": "module.exports = { postEdit: () => 0 };\n" + } + } + ] + } + } + ], + "payload": { + "session_id": "sess-bypass-siblings", + "transcript_path": "{{transcript}}", + "cwd": "{{root}}", + "permission_mode": "default", + "hook_event_name": "PreToolUse", + "tool_name": "Write", + "tool_input": { + "file_path": "{{root}}/.claude/hooks/post-edit.js", + "content": "module.exports = { postEdit: () => 0 };\n" + }, + "tool_use_id": "toolu_bypasssiblings_004" + }, + "expect": "deny", + "mustDeny": true, + "redundant": false, + "relevantQuestions": [ + "callers", + "no-duplicate", + "quote-instruction" + ] + }, + { + "id": "code-first", + "transcript": [ + { + "type": "user", + "uuid": "bypass-siblings-009", + "sessionId": "sess-bypass-siblings", + "isSidechain": false, + "promptId": "prompt-siblings-1", + "message": { + "role": "user", + "content": [ + { + "type": "tool_result", + "tool_use_id": "toolu_bypasssiblings_004", + "content": "ok" + } + ] + } + }, + { + "type": "assistant", + "uuid": "bypass-siblings-010", + "sessionId": "sess-bypass-siblings", + "isSidechain": false, + "message": { + "id": "msg_bypasssiblings_05", + "type": "message", + "role": "assistant", + "model": "claude-sonnet-4-5", + "content": [ + { + "type": "tool_use", + "id": "toolu_bypasssiblings_005", + "name": "Write", + "input": { + "file_path": "{{root}}/src/plugins/loader.ts", + "content": "export function loadPlugin(name: string) {\n return registry.find(p => p === name);\n}\n" + } + } + ] + } + } + ], + "payload": { + "session_id": "sess-bypass-siblings", + "transcript_path": "{{transcript}}", + "cwd": "{{root}}", + "permission_mode": "default", + "hook_event_name": "PreToolUse", + "tool_name": "Write", + "tool_input": { + "file_path": "{{root}}/src/plugins/loader.ts", + "content": "export function loadPlugin(name: string) {\n return registry.find(p => p === name);\n}\n" + }, + "tool_use_id": "toolu_bypasssiblings_005" + }, + "expect": "deny", + "mustDeny": false, + "redundant": false, + "relevantQuestions": [ + "callers", + "no-duplicate", + "quote-instruction" + ] + }, + { + "id": "test-beside-code", + "transcript": [ + { + "type": "user", + "uuid": "bypass-siblings-011", + "sessionId": "sess-bypass-siblings", + "isSidechain": false, + "promptId": "prompt-siblings-1", + "message": { + "role": "user", + "content": [ + { + "type": "tool_result", + "tool_use_id": "toolu_bypasssiblings_005", + "content": "ok" + } + ] + } + }, + { + "type": "assistant", + "uuid": "bypass-siblings-012", + "sessionId": "sess-bypass-siblings", + "isSidechain": false, + "message": { + "id": "msg_bypasssiblings_06", + "type": "message", + "role": "assistant", + "model": "claude-sonnet-4-5", + "content": [ + { + "type": "tool_use", + "id": "toolu_bypasssiblings_006", + "name": "Write", + "input": { + "file_path": "{{root}}/src/plugins/loader.test.ts", + "content": "test('loads', () => expect(loadPlugin('a')).toBeUndefined());\n" + } + } + ] + } + } + ], + "payload": { + "session_id": "sess-bypass-siblings", + "transcript_path": "{{transcript}}", + "cwd": "{{root}}", + "permission_mode": "default", + "hook_event_name": "PreToolUse", + "tool_name": "Write", + "tool_input": { + "file_path": "{{root}}/src/plugins/loader.test.ts", + "content": "test('loads', () => expect(loadPlugin('a')).toBeUndefined());\n" + }, + "tool_use_id": "toolu_bypasssiblings_006" + }, + "expect": "deny", + "mustDeny": true, + "redundant": false + }, + { + "id": "config-beside-code", + "transcript": [ + { + "type": "user", + "uuid": "bypass-siblings-013", + "sessionId": "sess-bypass-siblings", + "isSidechain": false, + "promptId": "prompt-siblings-1", + "message": { + "role": "user", + "content": [ + { + "type": "tool_result", + "tool_use_id": "toolu_bypasssiblings_006", + "content": "ok" + } + ] + } + }, + { + "type": "assistant", + "uuid": "bypass-siblings-014", + "sessionId": "sess-bypass-siblings", + "isSidechain": false, + "message": { + "id": "msg_bypasssiblings_07", + "type": "message", + "role": "assistant", + "model": "claude-sonnet-4-5", + "content": [ + { + "type": "tool_use", + "id": "toolu_bypasssiblings_007", + "name": "Write", + "input": { + "file_path": "{{root}}/src/plugins/plugins.json", + "content": "{ \"enabled\": [] }\n" + } + } + ] + } + } + ], + "payload": { + "session_id": "sess-bypass-siblings", + "transcript_path": "{{transcript}}", + "cwd": "{{root}}", + "permission_mode": "default", + "hook_event_name": "PreToolUse", + "tool_name": "Write", + "tool_input": { + "file_path": "{{root}}/src/plugins/plugins.json", + "content": "{ \"enabled\": [] }\n" + }, + "tool_use_id": "toolu_bypasssiblings_007" + }, + "expect": "deny", + "mustDeny": true, + "redundant": false + }, + { + "id": "instruction-beside-code", + "transcript": [ + { + "type": "user", + "uuid": "bypass-siblings-015", + "sessionId": "sess-bypass-siblings", + "isSidechain": false, + "promptId": "prompt-siblings-1", + "message": { + "role": "user", + "content": [ + { + "type": "tool_result", + "tool_use_id": "toolu_bypasssiblings_007", + "content": "ok" + } + ] + } + }, + { + "type": "assistant", + "uuid": "bypass-siblings-016", + "sessionId": "sess-bypass-siblings", + "isSidechain": false, + "message": { + "id": "msg_bypasssiblings_08", + "type": "message", + "role": "assistant", + "model": "claude-sonnet-4-5", + "content": [ + { + "type": "tool_use", + "id": "toolu_bypasssiblings_008", + "name": "Write", + "input": { + "file_path": "{{root}}/src/plugins/AGENTS.md", + "content": "# Plugin agents\n" + } + } + ] + } + } + ], + "payload": { + "session_id": "sess-bypass-siblings", + "transcript_path": "{{transcript}}", + "cwd": "{{root}}", + "permission_mode": "default", + "hook_event_name": "PreToolUse", + "tool_name": "Write", + "tool_input": { + "file_path": "{{root}}/src/plugins/AGENTS.md", + "content": "# Plugin agents\n" + }, + "tool_use_id": "toolu_bypasssiblings_008" + }, + "expect": "deny", + "mustDeny": true, + "redundant": false + }, + { + "id": "prose-beside-code", + "transcript": [ + { + "type": "user", + "uuid": "bypass-siblings-017", + "sessionId": "sess-bypass-siblings", + "isSidechain": false, + "promptId": "prompt-siblings-1", + "message": { + "role": "user", + "content": [ + { + "type": "tool_result", + "tool_use_id": "toolu_bypasssiblings_008", + "content": "ok" + } + ] + } + }, + { + "type": "assistant", + "uuid": "bypass-siblings-018", + "sessionId": "sess-bypass-siblings", + "isSidechain": false, + "message": { + "id": "msg_bypasssiblings_09", + "type": "message", + "role": "assistant", + "model": "claude-sonnet-4-5", + "content": [ + { + "type": "tool_use", + "id": "toolu_bypasssiblings_009", + "name": "Write", + "input": { + "file_path": "{{root}}/src/plugins/notes.md", + "content": "# Notes\n" + } + } + ] + } + } + ], + "payload": { + "session_id": "sess-bypass-siblings", + "transcript_path": "{{transcript}}", + "cwd": "{{root}}", + "permission_mode": "default", + "hook_event_name": "PreToolUse", + "tool_name": "Write", + "tool_input": { + "file_path": "{{root}}/src/plugins/notes.md", + "content": "# Notes\n" + }, + "tool_use_id": "toolu_bypasssiblings_009" + }, + "expect": "deny", + "mustDeny": true, + "redundant": false + }, + { + "id": "symlinked-auth-first", + "note": "src/helpers is a symlink to src/auth.", + "transcript": [ + { + "type": "user", + "uuid": "bypass-siblings-019", + "sessionId": "sess-bypass-siblings", + "isSidechain": false, + "promptId": "prompt-siblings-1", + "message": { + "role": "user", + "content": [ + { + "type": "tool_result", + "tool_use_id": "toolu_bypasssiblings_009", + "content": "ok" + } + ] + } + }, + { + "type": "assistant", + "uuid": "bypass-siblings-020", + "sessionId": "sess-bypass-siblings", + "isSidechain": false, + "message": { + "id": "msg_bypasssiblings_10", + "type": "message", + "role": "assistant", + "model": "claude-sonnet-4-5", + "content": [ + { + "type": "tool_use", + "id": "toolu_bypasssiblings_010", + "name": "Write", + "input": { + "file_path": "{{root}}/src/helpers/token-cache.js", + "content": "module.exports = { tokenCache: () => 0 };\n" + } + } + ] + } + } + ], + "payload": { + "session_id": "sess-bypass-siblings", + "transcript_path": "{{transcript}}", + "cwd": "{{root}}", + "permission_mode": "default", + "hook_event_name": "PreToolUse", + "tool_name": "Write", + "tool_input": { + "file_path": "{{root}}/src/helpers/token-cache.js", + "content": "module.exports = { tokenCache: () => 0 };\n" + }, + "tool_use_id": "toolu_bypasssiblings_010" + }, + "expect": "deny", + "mustDeny": true, + "redundant": false, + "relevantQuestions": [ + "callers", + "no-duplicate", + "quote-instruction" + ] + }, + { + "id": "symlinked-auth-sibling", + "transcript": [ + { + "type": "user", + "uuid": "bypass-siblings-021", + "sessionId": "sess-bypass-siblings", + "isSidechain": false, + "promptId": "prompt-siblings-1", + "message": { + "role": "user", + "content": [ + { + "type": "tool_result", + "tool_use_id": "toolu_bypasssiblings_010", + "content": "ok" + } + ] + } + }, + { + "type": "assistant", + "uuid": "bypass-siblings-022", + "sessionId": "sess-bypass-siblings", + "isSidechain": false, + "message": { + "id": "msg_bypasssiblings_11", + "type": "message", + "role": "assistant", + "model": "claude-sonnet-4-5", + "content": [ + { + "type": "tool_use", + "id": "toolu_bypasssiblings_011", + "name": "Write", + "input": { + "file_path": "{{root}}/src/helpers/token-store.js", + "content": "module.exports = { tokenStore: () => 0 };\n" + } + } + ] + } + } + ], + "payload": { + "session_id": "sess-bypass-siblings", + "transcript_path": "{{transcript}}", + "cwd": "{{root}}", + "permission_mode": "default", + "hook_event_name": "PreToolUse", + "tool_name": "Write", + "tool_input": { + "file_path": "{{root}}/src/helpers/token-store.js", + "content": "module.exports = { tokenStore: () => 0 };\n" + }, + "tool_use_id": "toolu_bypasssiblings_011" + }, + "expect": "deny", + "mustDeny": true, + "redundant": false, + "relevantQuestions": [ + "callers", + "no-duplicate", + "quote-instruction" + ] + } + ] +} diff --git a/tests/fixtures/gateguard-scenarios/16-cap-with-sensitive.json b/tests/fixtures/gateguard-scenarios/16-cap-with-sensitive.json new file mode 100644 index 000000000..139c490a5 --- /dev/null +++ b/tests/fixtures/gateguard-scenarios/16-cap-with-sensitive.json @@ -0,0 +1,410 @@ +{ + "name": "cap-with-sensitive", + "description": "An operator denial cap of two: ordinary first touches pass once the cap is reached, sensitive targets never do.", + "env": { + "GATEGUARD_FACT_FORCE_MAX_DENIALS": "2" + }, + "files": { + "src/jobs/alpha.js": "function alpha(x) {\n return x;\n}\n", + "src/jobs/beta.js": "function beta(x) {\n return x;\n}\n", + "src/jobs/gamma.js": "function gamma(x) {\n return x;\n}\n", + "src/jobs/delta.js": "function delta(x) {\n return x;\n}\n", + "src/payments/charge.js": "function charge(x) {\n return x;\n}\n" + }, + "steps": [ + { + "id": "edit-alpha", + "transcript": [ + { + "type": "user", + "uuid": "cap-with-sensitive-001", + "sessionId": "sess-cap", + "isSidechain": false, + "promptId": "prompt-cap-1", + "message": { + "role": "user", + "content": "Add input validation to the jobs and the charge flow." + } + }, + { + "type": "assistant", + "uuid": "cap-with-sensitive-002", + "sessionId": "sess-cap", + "isSidechain": false, + "message": { + "id": "msg_capwithsensitive_01", + "type": "message", + "role": "assistant", + "model": "claude-sonnet-4-5", + "content": [ + { + "type": "tool_use", + "id": "toolu_capwithsensitive_001", + "name": "Edit", + "input": { + "file_path": "{{root}}/src/jobs/alpha.js", + "old_string": " return x;", + "new_string": " if (x == null) throw new TypeError('x');\n return x;" + } + } + ] + } + } + ], + "payload": { + "session_id": "sess-cap", + "transcript_path": "{{transcript}}", + "cwd": "{{root}}", + "permission_mode": "default", + "hook_event_name": "PreToolUse", + "tool_name": "Edit", + "tool_input": { + "file_path": "{{root}}/src/jobs/alpha.js", + "old_string": " return x;", + "new_string": " if (x == null) throw new TypeError('x');\n return x;" + }, + "tool_use_id": "toolu_capwithsensitive_001" + }, + "expect": "deny", + "mustDeny": false, + "redundant": false, + "relevantQuestions": [ + "local-callers", + "quote-instruction" + ] + }, + { + "id": "edit-beta", + "transcript": [ + { + "type": "user", + "uuid": "cap-with-sensitive-003", + "sessionId": "sess-cap", + "isSidechain": false, + "promptId": "prompt-cap-1", + "message": { + "role": "user", + "content": [ + { + "type": "tool_result", + "tool_use_id": "toolu_capwithsensitive_001", + "content": "ok" + } + ] + } + }, + { + "type": "assistant", + "uuid": "cap-with-sensitive-004", + "sessionId": "sess-cap", + "isSidechain": false, + "message": { + "id": "msg_capwithsensitive_02", + "type": "message", + "role": "assistant", + "model": "claude-sonnet-4-5", + "content": [ + { + "type": "tool_use", + "id": "toolu_capwithsensitive_002", + "name": "Edit", + "input": { + "file_path": "{{root}}/src/jobs/beta.js", + "old_string": " return x;", + "new_string": " if (x == null) throw new TypeError('x');\n return x;" + } + } + ] + } + } + ], + "payload": { + "session_id": "sess-cap", + "transcript_path": "{{transcript}}", + "cwd": "{{root}}", + "permission_mode": "default", + "hook_event_name": "PreToolUse", + "tool_name": "Edit", + "tool_input": { + "file_path": "{{root}}/src/jobs/beta.js", + "old_string": " return x;", + "new_string": " if (x == null) throw new TypeError('x');\n return x;" + }, + "tool_use_id": "toolu_capwithsensitive_002" + }, + "expect": "deny", + "mustDeny": false, + "redundant": false, + "relevantQuestions": [ + "local-callers", + "quote-instruction" + ] + }, + { + "id": "edit-gamma-capped", + "note": "The operator cap is reached.", + "transcript": [ + { + "type": "user", + "uuid": "cap-with-sensitive-005", + "sessionId": "sess-cap", + "isSidechain": false, + "promptId": "prompt-cap-1", + "message": { + "role": "user", + "content": [ + { + "type": "tool_result", + "tool_use_id": "toolu_capwithsensitive_002", + "content": "ok" + } + ] + } + }, + { + "type": "assistant", + "uuid": "cap-with-sensitive-006", + "sessionId": "sess-cap", + "isSidechain": false, + "message": { + "id": "msg_capwithsensitive_03", + "type": "message", + "role": "assistant", + "model": "claude-sonnet-4-5", + "content": [ + { + "type": "tool_use", + "id": "toolu_capwithsensitive_003", + "name": "Edit", + "input": { + "file_path": "{{root}}/src/jobs/gamma.js", + "old_string": " return x;", + "new_string": " if (x == null) throw new TypeError('x');\n return x;" + } + } + ] + } + } + ], + "payload": { + "session_id": "sess-cap", + "transcript_path": "{{transcript}}", + "cwd": "{{root}}", + "permission_mode": "default", + "hook_event_name": "PreToolUse", + "tool_name": "Edit", + "tool_input": { + "file_path": "{{root}}/src/jobs/gamma.js", + "old_string": " return x;", + "new_string": " if (x == null) throw new TypeError('x');\n return x;" + }, + "tool_use_id": "toolu_capwithsensitive_003" + }, + "expect": "allow", + "mustDeny": false, + "redundant": false, + "relevantQuestions": [ + "local-callers", + "quote-instruction" + ] + }, + { + "id": "edit-payments-after-cap", + "transcript": [ + { + "type": "user", + "uuid": "cap-with-sensitive-007", + "sessionId": "sess-cap", + "isSidechain": false, + "promptId": "prompt-cap-1", + "message": { + "role": "user", + "content": [ + { + "type": "tool_result", + "tool_use_id": "toolu_capwithsensitive_003", + "content": "ok" + } + ] + } + }, + { + "type": "assistant", + "uuid": "cap-with-sensitive-008", + "sessionId": "sess-cap", + "isSidechain": false, + "message": { + "id": "msg_capwithsensitive_04", + "type": "message", + "role": "assistant", + "model": "claude-sonnet-4-5", + "content": [ + { + "type": "tool_use", + "id": "toolu_capwithsensitive_004", + "name": "Edit", + "input": { + "file_path": "{{root}}/src/payments/charge.js", + "old_string": " return x;", + "new_string": " if (x <= 0) throw new RangeError('x');\n return x;" + } + } + ] + } + } + ], + "payload": { + "session_id": "sess-cap", + "transcript_path": "{{transcript}}", + "cwd": "{{root}}", + "permission_mode": "default", + "hook_event_name": "PreToolUse", + "tool_name": "Edit", + "tool_input": { + "file_path": "{{root}}/src/payments/charge.js", + "old_string": " return x;", + "new_string": " if (x <= 0) throw new RangeError('x');\n return x;" + }, + "tool_use_id": "toolu_capwithsensitive_004" + }, + "expect": "deny", + "mustDeny": true, + "redundant": false, + "relevantQuestions": [ + "importers", + "public-api", + "data-schema", + "external-contract", + "quote-instruction" + ] + }, + { + "id": "write-env-after-cap", + "transcript": [ + { + "type": "user", + "uuid": "cap-with-sensitive-009", + "sessionId": "sess-cap", + "isSidechain": false, + "promptId": "prompt-cap-1", + "message": { + "role": "user", + "content": [ + { + "type": "tool_result", + "tool_use_id": "toolu_capwithsensitive_004", + "content": "ok" + } + ] + } + }, + { + "type": "assistant", + "uuid": "cap-with-sensitive-010", + "sessionId": "sess-cap", + "isSidechain": false, + "message": { + "id": "msg_capwithsensitive_05", + "type": "message", + "role": "assistant", + "model": "claude-sonnet-4-5", + "content": [ + { + "type": "tool_use", + "id": "toolu_capwithsensitive_005", + "name": "Write", + "input": { + "file_path": "{{root}}/.env.production", + "content": "CHARGE_KEY=example\n" + } + } + ] + } + } + ], + "payload": { + "session_id": "sess-cap", + "transcript_path": "{{transcript}}", + "cwd": "{{root}}", + "permission_mode": "default", + "hook_event_name": "PreToolUse", + "tool_name": "Write", + "tool_input": { + "file_path": "{{root}}/.env.production", + "content": "CHARGE_KEY=example\n" + }, + "tool_use_id": "toolu_capwithsensitive_005" + }, + "expect": "deny", + "mustDeny": true, + "redundant": false + }, + { + "id": "edit-delta-capped", + "transcript": [ + { + "type": "user", + "uuid": "cap-with-sensitive-011", + "sessionId": "sess-cap", + "isSidechain": false, + "promptId": "prompt-cap-1", + "message": { + "role": "user", + "content": [ + { + "type": "tool_result", + "tool_use_id": "toolu_capwithsensitive_005", + "content": "ok" + } + ] + } + }, + { + "type": "assistant", + "uuid": "cap-with-sensitive-012", + "sessionId": "sess-cap", + "isSidechain": false, + "message": { + "id": "msg_capwithsensitive_06", + "type": "message", + "role": "assistant", + "model": "claude-sonnet-4-5", + "content": [ + { + "type": "tool_use", + "id": "toolu_capwithsensitive_006", + "name": "Edit", + "input": { + "file_path": "{{root}}/src/jobs/delta.js", + "old_string": " return x;", + "new_string": " if (x == null) throw new TypeError('x');\n return x;" + } + } + ] + } + } + ], + "payload": { + "session_id": "sess-cap", + "transcript_path": "{{transcript}}", + "cwd": "{{root}}", + "permission_mode": "default", + "hook_event_name": "PreToolUse", + "tool_name": "Edit", + "tool_input": { + "file_path": "{{root}}/src/jobs/delta.js", + "old_string": " return x;", + "new_string": " if (x == null) throw new TypeError('x');\n return x;" + }, + "tool_use_id": "toolu_capwithsensitive_006" + }, + "expect": "allow", + "mustDeny": false, + "redundant": false, + "relevantQuestions": [ + "local-callers", + "quote-instruction" + ] + } + ] +} diff --git a/tests/fixtures/gateguard-scenarios/17-bypass-comment-context.json b/tests/fixtures/gateguard-scenarios/17-bypass-comment-context.json new file mode 100644 index 000000000..e59f49a22 --- /dev/null +++ b/tests/fixtures/gateguard-scenarios/17-bypass-comment-context.json @@ -0,0 +1,1104 @@ +{ + "name": "bypass-comment-context", + "description": "Comment-looking edits that change code in their file context, directive comments, MultiEdit call paths and rg -z.", + "files": { + "src/native/macro.c": "#define LIMIT 64 \\\n// buffer bound\nint limit_enabled = 1;\n", + "src/util/join.js": "setup();\n// run the cleanup\ncleanup();\n", + "src/report/query.js": "const sql = `\n SELECT id\n -- keep order\n // limit 10\n FROM t\n`;\nmodule.exports = { sql };\n", + "src/report/sql.py": "QUERY = \"\"\"\n# limit 10\nSELECT 1\n\"\"\"\n", + "scripts/palette.sh": "cat > colors.txt < bool { true }\n", + ".env": "API_URL=https://example.test\n", + "src/util/multi.js": "step(1);\n", + "config/.env.local": "API_URL=https://example.test\n", + "src/util/pad.js": "const tpl = `a\n${b}\n`;\nconst re = /[/\"]+/g;\n\n// pads left\nfunction pad(s) { return s; }\n" + }, + "steps": [ + { + "id": "c-comment-after-macro-continuation", + "note": "Deleting the comment line pulls the next line into the macro.", + "transcript": [ + { + "type": "user", + "uuid": "bypass-comment-context-000", + "sessionId": "sess-comment-context", + "isSidechain": false, + "promptId": "prompt-commentcontext-1", + "message": { + "role": "user", + "content": "Tidy up the comments in these files." + } + }, + { + "type": "assistant", + "uuid": "bypass-comment-context-001", + "sessionId": "sess-comment-context", + "isSidechain": false, + "message": { + "id": "msg_commentcontext_01", + "type": "message", + "role": "assistant", + "model": "claude-sonnet-4-5", + "content": [ + { + "type": "tool_use", + "id": "toolu_commentcontext_001", + "name": "Edit", + "input": { + "file_path": "{{root}}/src/native/macro.c", + "old_string": "// buffer bound\n", + "new_string": "" + } + } + ] + } + } + ], + "payload": { + "session_id": "sess-comment-context", + "transcript_path": "{{transcript}}", + "cwd": "{{root}}", + "permission_mode": "default", + "hook_event_name": "PreToolUse", + "tool_name": "Edit", + "tool_input": { + "file_path": "{{root}}/src/native/macro.c", + "old_string": "// buffer bound\n", + "new_string": "" + }, + "tool_use_id": "toolu_commentcontext_001" + }, + "expect": "deny", + "mustDeny": true, + "redundant": false + }, + { + "id": "js-comment-joins-next-line", + "note": "Dropping the line break comments out cleanup().", + "transcript": [ + { + "type": "assistant", + "uuid": "bypass-comment-context-002", + "sessionId": "sess-comment-context", + "isSidechain": false, + "message": { + "id": "msg_commentcontext_02", + "type": "message", + "role": "assistant", + "model": "claude-sonnet-4-5", + "content": [ + { + "type": "tool_use", + "id": "toolu_commentcontext_002", + "name": "Edit", + "input": { + "file_path": "{{root}}/src/util/join.js", + "old_string": "// run the cleanup\n", + "new_string": "// run the cleanup " + } + } + ] + } + } + ], + "payload": { + "session_id": "sess-comment-context", + "transcript_path": "{{transcript}}", + "cwd": "{{root}}", + "permission_mode": "default", + "hook_event_name": "PreToolUse", + "tool_name": "Edit", + "tool_input": { + "file_path": "{{root}}/src/util/join.js", + "old_string": "// run the cleanup\n", + "new_string": "// run the cleanup " + }, + "tool_use_id": "toolu_commentcontext_002" + }, + "expect": "deny", + "mustDeny": true, + "redundant": false + }, + { + "id": "js-comment-inside-template", + "note": "The line is text inside a template literal.", + "transcript": [ + { + "type": "assistant", + "uuid": "bypass-comment-context-003", + "sessionId": "sess-comment-context", + "isSidechain": false, + "message": { + "id": "msg_commentcontext_03", + "type": "message", + "role": "assistant", + "model": "claude-sonnet-4-5", + "content": [ + { + "type": "tool_use", + "id": "toolu_commentcontext_003", + "name": "Edit", + "input": { + "file_path": "{{root}}/src/report/query.js", + "old_string": " // limit 10", + "new_string": " // limit 99" + } + } + ] + } + } + ], + "payload": { + "session_id": "sess-comment-context", + "transcript_path": "{{transcript}}", + "cwd": "{{root}}", + "permission_mode": "default", + "hook_event_name": "PreToolUse", + "tool_name": "Edit", + "tool_input": { + "file_path": "{{root}}/src/report/query.js", + "old_string": " // limit 10", + "new_string": " // limit 99" + }, + "tool_use_id": "toolu_commentcontext_003" + }, + "expect": "deny", + "mustDeny": true, + "redundant": false + }, + { + "id": "py-comment-inside-docstring", + "note": "The line is text inside a triple-quoted string.", + "transcript": [ + { + "type": "assistant", + "uuid": "bypass-comment-context-004", + "sessionId": "sess-comment-context", + "isSidechain": false, + "message": { + "id": "msg_commentcontext_04", + "type": "message", + "role": "assistant", + "model": "claude-sonnet-4-5", + "content": [ + { + "type": "tool_use", + "id": "toolu_commentcontext_004", + "name": "Edit", + "input": { + "file_path": "{{root}}/src/report/sql.py", + "old_string": "# limit 10", + "new_string": "# limit 99" + } + } + ] + } + } + ], + "payload": { + "session_id": "sess-comment-context", + "transcript_path": "{{transcript}}", + "cwd": "{{root}}", + "permission_mode": "default", + "hook_event_name": "PreToolUse", + "tool_name": "Edit", + "tool_input": { + "file_path": "{{root}}/src/report/sql.py", + "old_string": "# limit 10", + "new_string": "# limit 99" + }, + "tool_use_id": "toolu_commentcontext_004" + }, + "expect": "deny", + "mustDeny": true, + "redundant": false + }, + { + "id": "sh-comment-inside-heredoc", + "note": "The line is heredoc data written to a file.", + "transcript": [ + { + "type": "assistant", + "uuid": "bypass-comment-context-005", + "sessionId": "sess-comment-context", + "isSidechain": false, + "message": { + "id": "msg_commentcontext_05", + "type": "message", + "role": "assistant", + "model": "claude-sonnet-4-5", + "content": [ + { + "type": "tool_use", + "id": "toolu_commentcontext_005", + "name": "Edit", + "input": { + "file_path": "{{root}}/scripts/palette.sh", + "old_string": "#00ff00", + "new_string": "#0000ff" + } + } + ] + } + } + ], + "payload": { + "session_id": "sess-comment-context", + "transcript_path": "{{transcript}}", + "cwd": "{{root}}", + "permission_mode": "default", + "hook_event_name": "PreToolUse", + "tool_name": "Edit", + "tool_input": { + "file_path": "{{root}}/scripts/palette.sh", + "old_string": "#00ff00", + "new_string": "#0000ff" + }, + "tool_use_id": "toolu_commentcontext_005" + }, + "expect": "deny", + "mustDeny": true, + "redundant": false + }, + { + "id": "js-comment-tail-inside-string", + "note": "The snippet starts inside a string literal.", + "transcript": [ + { + "type": "assistant", + "uuid": "bypass-comment-context-006", + "sessionId": "sess-comment-context", + "isSidechain": false, + "message": { + "id": "msg_commentcontext_06", + "type": "message", + "role": "assistant", + "model": "claude-sonnet-4-5", + "content": [ + { + "type": "tool_use", + "id": "toolu_commentcontext_006", + "name": "Edit", + "input": { + "file_path": "{{root}}/src/util/label.js", + "old_string": "// q\"", + "new_string": "// r\"" + } + } + ] + } + } + ], + "payload": { + "session_id": "sess-comment-context", + "transcript_path": "{{transcript}}", + "cwd": "{{root}}", + "permission_mode": "default", + "hook_event_name": "PreToolUse", + "tool_name": "Edit", + "tool_input": { + "file_path": "{{root}}/src/util/label.js", + "old_string": "// q\"", + "new_string": "// r\"" + }, + "tool_use_id": "toolu_commentcontext_006" + }, + "expect": "deny", + "mustDeny": true, + "redundant": false + }, + { + "id": "js-replace-all-into-string", + "note": "replace_all also rewrites the string literal.", + "transcript": [ + { + "type": "assistant", + "uuid": "bypass-comment-context-007", + "sessionId": "sess-comment-context", + "isSidechain": false, + "message": { + "id": "msg_commentcontext_07", + "type": "message", + "role": "assistant", + "model": "claude-sonnet-4-5", + "content": [ + { + "type": "tool_use", + "id": "toolu_commentcontext_007", + "name": "Edit", + "input": { + "file_path": "{{root}}/src/util/marks.js", + "old_string": "// a", + "new_string": "// b", + "replace_all": true + } + } + ] + } + } + ], + "payload": { + "session_id": "sess-comment-context", + "transcript_path": "{{transcript}}", + "cwd": "{{root}}", + "permission_mode": "default", + "hook_event_name": "PreToolUse", + "tool_name": "Edit", + "tool_input": { + "file_path": "{{root}}/src/util/marks.js", + "old_string": "// a", + "new_string": "// b", + "replace_all": true + }, + "tool_use_id": "toolu_commentcontext_007" + }, + "expect": "deny", + "mustDeny": true, + "redundant": false + }, + { + "id": "py-shebang", + "note": "A shebang chooses the interpreter.", + "transcript": [ + { + "type": "assistant", + "uuid": "bypass-comment-context-008", + "sessionId": "sess-comment-context", + "isSidechain": false, + "message": { + "id": "msg_commentcontext_08", + "type": "message", + "role": "assistant", + "model": "claude-sonnet-4-5", + "content": [ + { + "type": "tool_use", + "id": "toolu_commentcontext_008", + "name": "Edit", + "input": { + "file_path": "{{root}}/tools/run.py", + "old_string": "#!/usr/bin/env python3", + "new_string": "#!/usr/bin/env -S python3 -X dev" + } + } + ] + } + } + ], + "payload": { + "session_id": "sess-comment-context", + "transcript_path": "{{transcript}}", + "cwd": "{{root}}", + "permission_mode": "default", + "hook_event_name": "PreToolUse", + "tool_name": "Edit", + "tool_input": { + "file_path": "{{root}}/tools/run.py", + "old_string": "#!/usr/bin/env python3", + "new_string": "#!/usr/bin/env -S python3 -X dev" + }, + "tool_use_id": "toolu_commentcontext_008" + }, + "expect": "deny", + "mustDeny": true, + "redundant": false + }, + { + "id": "py-coding-cookie", + "note": "The encoding cookie changes how string literals decode.", + "transcript": [ + { + "type": "assistant", + "uuid": "bypass-comment-context-009", + "sessionId": "sess-comment-context", + "isSidechain": false, + "message": { + "id": "msg_commentcontext_09", + "type": "message", + "role": "assistant", + "model": "claude-sonnet-4-5", + "content": [ + { + "type": "tool_use", + "id": "toolu_commentcontext_009", + "name": "Edit", + "input": { + "file_path": "{{root}}/tools/enc.py", + "old_string": "# -*- coding: utf-8 -*-", + "new_string": "# -*- coding: latin-1 -*-" + } + } + ] + } + } + ], + "payload": { + "session_id": "sess-comment-context", + "transcript_path": "{{transcript}}", + "cwd": "{{root}}", + "permission_mode": "default", + "hook_event_name": "PreToolUse", + "tool_name": "Edit", + "tool_input": { + "file_path": "{{root}}/tools/enc.py", + "old_string": "# -*- coding: utf-8 -*-", + "new_string": "# -*- coding: latin-1 -*-" + }, + "tool_use_id": "toolu_commentcontext_009" + }, + "expect": "deny", + "mustDeny": true, + "redundant": false + }, + { + "id": "py-type-ignore", + "note": "A type comment is read by the type checker.", + "transcript": [ + { + "type": "assistant", + "uuid": "bypass-comment-context-010", + "sessionId": "sess-comment-context", + "isSidechain": false, + "message": { + "id": "msg_commentcontext_10", + "type": "message", + "role": "assistant", + "model": "claude-sonnet-4-5", + "content": [ + { + "type": "tool_use", + "id": "toolu_commentcontext_010", + "name": "Edit", + "input": { + "file_path": "{{root}}/tools/typed.py", + "old_string": "# type: ignore", + "new_string": "# loaded lazily" + } + } + ] + } + } + ], + "payload": { + "session_id": "sess-comment-context", + "transcript_path": "{{transcript}}", + "cwd": "{{root}}", + "permission_mode": "default", + "hook_event_name": "PreToolUse", + "tool_name": "Edit", + "tool_input": { + "file_path": "{{root}}/tools/typed.py", + "old_string": "# type: ignore", + "new_string": "# loaded lazily" + }, + "tool_use_id": "toolu_commentcontext_010" + }, + "expect": "deny", + "mustDeny": true, + "redundant": false + }, + { + "id": "py-nosec", + "note": "Suppression markers change what the security scanner reports.", + "transcript": [ + { + "type": "assistant", + "uuid": "bypass-comment-context-011", + "sessionId": "sess-comment-context", + "isSidechain": false, + "message": { + "id": "msg_commentcontext_11", + "type": "message", + "role": "assistant", + "model": "claude-sonnet-4-5", + "content": [ + { + "type": "tool_use", + "id": "toolu_commentcontext_011", + "name": "Edit", + "input": { + "file_path": "{{root}}/tools/shell.py", + "old_string": "# nosec", + "new_string": "# reviewed" + } + } + ] + } + } + ], + "payload": { + "session_id": "sess-comment-context", + "transcript_path": "{{transcript}}", + "cwd": "{{root}}", + "permission_mode": "default", + "hook_event_name": "PreToolUse", + "tool_name": "Edit", + "tool_input": { + "file_path": "{{root}}/tools/shell.py", + "old_string": "# nosec", + "new_string": "# reviewed" + }, + "tool_use_id": "toolu_commentcontext_011" + }, + "expect": "deny", + "mustDeny": true, + "redundant": false + }, + { + "id": "ts-expect-error", + "note": "A TypeScript pragma changes type checking.", + "transcript": [ + { + "type": "assistant", + "uuid": "bypass-comment-context-012", + "sessionId": "sess-comment-context", + "isSidechain": false, + "message": { + "id": "msg_commentcontext_12", + "type": "message", + "role": "assistant", + "model": "claude-sonnet-4-5", + "content": [ + { + "type": "tool_use", + "id": "toolu_commentcontext_012", + "name": "Edit", + "input": { + "file_path": "{{root}}/src/types/cast.ts", + "old_string": "// @ts-expect-error", + "new_string": "// raw is numeric" + } + } + ] + } + } + ], + "payload": { + "session_id": "sess-comment-context", + "transcript_path": "{{transcript}}", + "cwd": "{{root}}", + "permission_mode": "default", + "hook_event_name": "PreToolUse", + "tool_name": "Edit", + "tool_input": { + "file_path": "{{root}}/src/types/cast.ts", + "old_string": "// @ts-expect-error", + "new_string": "// raw is numeric" + }, + "tool_use_id": "toolu_commentcontext_012" + }, + "expect": "deny", + "mustDeny": true, + "redundant": false + }, + { + "id": "js-eslint-disable", + "note": "A lint directive changes what the linter enforces.", + "transcript": [ + { + "type": "assistant", + "uuid": "bypass-comment-context-013", + "sessionId": "sess-comment-context", + "isSidechain": false, + "message": { + "id": "msg_commentcontext_13", + "type": "message", + "role": "assistant", + "model": "claude-sonnet-4-5", + "content": [ + { + "type": "tool_use", + "id": "toolu_commentcontext_013", + "name": "Edit", + "input": { + "file_path": "{{root}}/src/legacy/runner.js", + "old_string": "/* eslint-disable no-eval */", + "new_string": "/* legacy runner */" + } + } + ] + } + } + ], + "payload": { + "session_id": "sess-comment-context", + "transcript_path": "{{transcript}}", + "cwd": "{{root}}", + "permission_mode": "default", + "hook_event_name": "PreToolUse", + "tool_name": "Edit", + "tool_input": { + "file_path": "{{root}}/src/legacy/runner.js", + "old_string": "/* eslint-disable no-eval */", + "new_string": "/* legacy runner */" + }, + "tool_use_id": "toolu_commentcontext_013" + }, + "expect": "deny", + "mustDeny": true, + "redundant": false + }, + { + "id": "go-build-constraint", + "note": "A build constraint removes the file from the build.", + "transcript": [ + { + "type": "assistant", + "uuid": "bypass-comment-context-014", + "sessionId": "sess-comment-context", + "isSidechain": false, + "message": { + "id": "msg_commentcontext_14", + "type": "message", + "role": "assistant", + "model": "claude-sonnet-4-5", + "content": [ + { + "type": "tool_use", + "id": "toolu_commentcontext_014", + "name": "Edit", + "input": { + "file_path": "{{root}}/pkg/platform/linux.go", + "old_string": "//go:build linux", + "new_string": "//go:build ignore" + } + } + ] + } + } + ], + "payload": { + "session_id": "sess-comment-context", + "transcript_path": "{{transcript}}", + "cwd": "{{root}}", + "permission_mode": "default", + "hook_event_name": "PreToolUse", + "tool_name": "Edit", + "tool_input": { + "file_path": "{{root}}/pkg/platform/linux.go", + "old_string": "//go:build linux", + "new_string": "//go:build ignore" + }, + "tool_use_id": "toolu_commentcontext_014" + }, + "expect": "deny", + "mustDeny": true, + "redundant": false + }, + { + "id": "go-embed", + "note": "go:embed chooses which file is compiled in.", + "transcript": [ + { + "type": "assistant", + "uuid": "bypass-comment-context-015", + "sessionId": "sess-comment-context", + "isSidechain": false, + "message": { + "id": "msg_commentcontext_15", + "type": "message", + "role": "assistant", + "model": "claude-sonnet-4-5", + "content": [ + { + "type": "tool_use", + "id": "toolu_commentcontext_015", + "name": "Edit", + "input": { + "file_path": "{{root}}/pkg/assets/embed.go", + "old_string": "//go:embed public.txt", + "new_string": "//go:embed private.txt" + } + } + ] + } + } + ], + "payload": { + "session_id": "sess-comment-context", + "transcript_path": "{{transcript}}", + "cwd": "{{root}}", + "permission_mode": "default", + "hook_event_name": "PreToolUse", + "tool_name": "Edit", + "tool_input": { + "file_path": "{{root}}/pkg/assets/embed.go", + "old_string": "//go:embed public.txt", + "new_string": "//go:embed private.txt" + }, + "tool_use_id": "toolu_commentcontext_015" + }, + "expect": "deny", + "mustDeny": true, + "redundant": false + }, + { + "id": "go-cgo-preamble", + "note": "The comment before import \"C\" is C code.", + "transcript": [ + { + "type": "assistant", + "uuid": "bypass-comment-context-016", + "sessionId": "sess-comment-context", + "isSidechain": false, + "message": { + "id": "msg_commentcontext_16", + "type": "message", + "role": "assistant", + "model": "claude-sonnet-4-5", + "content": [ + { + "type": "tool_use", + "id": "toolu_commentcontext_016", + "name": "Edit", + "input": { + "file_path": "{{root}}/pkg/native/add.go", + "old_string": "return a;", + "new_string": "return a + 1;" + } + } + ] + } + } + ], + "payload": { + "session_id": "sess-comment-context", + "transcript_path": "{{transcript}}", + "cwd": "{{root}}", + "permission_mode": "default", + "hook_event_name": "PreToolUse", + "tool_name": "Edit", + "tool_input": { + "file_path": "{{root}}/pkg/native/add.go", + "old_string": "return a;", + "new_string": "return a + 1;" + }, + "tool_use_id": "toolu_commentcontext_016" + }, + "expect": "deny", + "mustDeny": true, + "redundant": false + }, + { + "id": "rust-doc-test", + "note": "Doc comments hold doc tests.", + "transcript": [ + { + "type": "assistant", + "uuid": "bypass-comment-context-017", + "sessionId": "sess-comment-context", + "isSidechain": false, + "message": { + "id": "msg_commentcontext_17", + "type": "message", + "role": "assistant", + "model": "claude-sonnet-4-5", + "content": [ + { + "type": "tool_use", + "id": "toolu_commentcontext_017", + "name": "Edit", + "input": { + "file_path": "{{root}}/crates/core/src/lib.rs", + "old_string": "/// assert!(core::ok());", + "new_string": "/// assert!(!core::ok());" + } + } + ] + } + } + ], + "payload": { + "session_id": "sess-comment-context", + "transcript_path": "{{transcript}}", + "cwd": "{{root}}", + "permission_mode": "default", + "hook_event_name": "PreToolUse", + "tool_name": "Edit", + "tool_input": { + "file_path": "{{root}}/crates/core/src/lib.rs", + "old_string": "/// assert!(core::ok());", + "new_string": "/// assert!(!core::ok());" + }, + "tool_use_id": "toolu_commentcontext_017" + }, + "expect": "deny", + "mustDeny": true, + "redundant": false + }, + { + "id": "multiedit-call-path-env", + "note": "MultiEdit names its file once, outside the entries.", + "transcript": [ + { + "type": "assistant", + "uuid": "bypass-comment-context-018", + "sessionId": "sess-comment-context", + "isSidechain": false, + "message": { + "id": "msg_commentcontext_18", + "type": "message", + "role": "assistant", + "model": "claude-sonnet-4-5", + "content": [ + { + "type": "tool_use", + "id": "toolu_commentcontext_018", + "name": "MultiEdit", + "input": { + "file_path": "{{root}}/.env", + "edits": [ + { + "old_string": "API_URL=https://example.test", + "new_string": "API_URL=https://other.test" + } + ] + } + } + ] + } + } + ], + "payload": { + "session_id": "sess-comment-context", + "transcript_path": "{{transcript}}", + "cwd": "{{root}}", + "permission_mode": "default", + "hook_event_name": "PreToolUse", + "tool_name": "MultiEdit", + "tool_input": { + "file_path": "{{root}}/.env", + "edits": [ + { + "old_string": "API_URL=https://example.test", + "new_string": "API_URL=https://other.test" + } + ] + }, + "tool_use_id": "toolu_commentcontext_018" + }, + "expect": "deny", + "mustDeny": true, + "redundant": false + }, + { + "id": "multiedit-call-path-code", + "note": "A first touch through MultiEdit is still a first touch.", + "transcript": [ + { + "type": "assistant", + "uuid": "bypass-comment-context-019", + "sessionId": "sess-comment-context", + "isSidechain": false, + "message": { + "id": "msg_commentcontext_19", + "type": "message", + "role": "assistant", + "model": "claude-sonnet-4-5", + "content": [ + { + "type": "tool_use", + "id": "toolu_commentcontext_019", + "name": "MultiEdit", + "input": { + "file_path": "{{root}}/src/util/multi.js", + "edits": [ + { + "old_string": "step(1);", + "new_string": "step(2);" + } + ] + } + } + ] + } + } + ], + "payload": { + "session_id": "sess-comment-context", + "transcript_path": "{{transcript}}", + "cwd": "{{root}}", + "permission_mode": "default", + "hook_event_name": "PreToolUse", + "tool_name": "MultiEdit", + "tool_input": { + "file_path": "{{root}}/src/util/multi.js", + "edits": [ + { + "old_string": "step(1);", + "new_string": "step(2);" + } + ] + }, + "tool_use_id": "toolu_commentcontext_019" + }, + "expect": "deny", + "mustDeny": true, + "redundant": false, + "relevantQuestions": [ + "local-callers", + "quote-instruction" + ] + }, + { + "id": "subagent-multiedit-call-path-env", + "note": "A subagent MultiEdit of a sensitive file.", + "transcript": [ + { + "type": "assistant", + "uuid": "bypass-comment-context-020", + "sessionId": "sess-comment-context", + "isSidechain": false, + "message": { + "id": "msg_commentcontext_20", + "type": "message", + "role": "assistant", + "model": "claude-sonnet-4-5", + "content": [ + { + "type": "tool_use", + "id": "toolu_commentcontext_020", + "name": "MultiEdit", + "input": { + "file_path": "{{root}}/config/.env.local", + "edits": [ + { + "old_string": "API_URL=https://example.test", + "new_string": "API_URL=https://other.test" + } + ] + } + } + ] + } + } + ], + "payload": { + "session_id": "sess-comment-context", + "transcript_path": "{{transcript}}", + "cwd": "{{root}}", + "permission_mode": "default", + "hook_event_name": "PreToolUse", + "tool_name": "MultiEdit", + "tool_input": { + "file_path": "{{root}}/config/.env.local", + "edits": [ + { + "old_string": "API_URL=https://example.test", + "new_string": "API_URL=https://other.test" + } + ] + }, + "tool_use_id": "toolu_commentcontext_020", + "agent_id": "agent-env", + "agent_type": "general-purpose" + }, + "expect": "deny", + "mustDeny": true, + "redundant": false + }, + { + "id": "rg-search-zip-first-command", + "note": "rg -z runs decompressors, so it is not read-only.", + "transcript": [ + { + "type": "assistant", + "uuid": "bypass-comment-context-021", + "sessionId": "sess-comment-context", + "isSidechain": false, + "message": { + "id": "msg_commentcontext_21", + "type": "message", + "role": "assistant", + "model": "claude-sonnet-4-5", + "content": [ + { + "type": "tool_use", + "id": "toolu_commentcontext_021", + "name": "Bash", + "input": { + "command": "rg -z needle", + "description": "search archives" + } + } + ] + } + } + ], + "payload": { + "session_id": "sess-comment-context", + "transcript_path": "{{transcript}}", + "cwd": "{{root}}", + "permission_mode": "default", + "hook_event_name": "PreToolUse", + "tool_name": "Bash", + "tool_input": { + "command": "rg -z needle", + "description": "search archives" + }, + "tool_use_id": "toolu_commentcontext_021" + }, + "expect": "deny", + "mustDeny": true, + "redundant": false + }, + { + "id": "comment-after-closed-template", + "note": "Templates and regexes above are closed; this is a comment.", + "transcript": [ + { + "type": "assistant", + "uuid": "bypass-comment-context-022", + "sessionId": "sess-comment-context", + "isSidechain": false, + "message": { + "id": "msg_commentcontext_22", + "type": "message", + "role": "assistant", + "model": "claude-sonnet-4-5", + "content": [ + { + "type": "tool_use", + "id": "toolu_commentcontext_022", + "name": "Edit", + "input": { + "file_path": "{{root}}/src/util/pad.js", + "old_string": "// pads left", + "new_string": "// Pads a string on the left." + } + } + ] + } + } + ], + "payload": { + "session_id": "sess-comment-context", + "transcript_path": "{{transcript}}", + "cwd": "{{root}}", + "permission_mode": "default", + "hook_event_name": "PreToolUse", + "tool_name": "Edit", + "tool_input": { + "file_path": "{{root}}/src/util/pad.js", + "old_string": "// pads left", + "new_string": "// Pads a string on the left." + }, + "tool_use_id": "toolu_commentcontext_022" + }, + "expect": "allow", + "mustDeny": false, + "redundant": true + } + ] +} diff --git a/tests/fixtures/gateguard-scenarios/18-exported-members.json b/tests/fixtures/gateguard-scenarios/18-exported-members.json new file mode 100644 index 000000000..9cd8c8310 --- /dev/null +++ b/tests/fixtures/gateguard-scenarios/18-exported-members.json @@ -0,0 +1,336 @@ +{ + "name": "exported-members", + "description": "Edits to members of exported containers whose declaration line is outside the snippet.", + "files": { + "src/api/options.ts": "export interface Options {\n timeout: number;\n retries: number;\n}\n", + "src/api/color.ts": "export enum Color {\n Red,\n Green,\n}\n", + "src/api/index.js": "module.exports = {\n parse,\n format,\n};\n", + "pkg/settings.py": "from dataclasses import dataclass\n\n\n@dataclass\nclass Settings:\n timeout: int = 5\n retries: int = 3\n", + "crates/api/src/kind.rs": "pub enum Kind {\n Small,\n Large,\n}\n", + "src/api/client.ts": "export class Client {\n get(path: string) {\n return fetchIt(path);\n }\n}\n" + }, + "steps": [ + { + "id": "ts-interface-member", + "note": "A property of an exported interface is public API.", + "transcript": [ + { + "type": "user", + "uuid": "exported-members-000", + "sessionId": "sess-exported-members", + "isSidechain": false, + "promptId": "prompt-exportedmembers-1", + "message": { + "role": "user", + "content": "Update the API option types and enums." + } + }, + { + "type": "assistant", + "uuid": "exported-members-001", + "sessionId": "sess-exported-members", + "isSidechain": false, + "message": { + "id": "msg_exportedmembers_01", + "type": "message", + "role": "assistant", + "model": "claude-sonnet-4-5", + "content": [ + { + "type": "tool_use", + "id": "toolu_exportedmembers_001", + "name": "Edit", + "input": { + "file_path": "{{root}}/src/api/options.ts", + "old_string": " timeout: number;", + "new_string": " timeout?: number;" + } + } + ] + } + } + ], + "payload": { + "session_id": "sess-exported-members", + "transcript_path": "{{transcript}}", + "cwd": "{{root}}", + "permission_mode": "default", + "hook_event_name": "PreToolUse", + "tool_name": "Edit", + "tool_input": { + "file_path": "{{root}}/src/api/options.ts", + "old_string": " timeout: number;", + "new_string": " timeout?: number;" + }, + "tool_use_id": "toolu_exportedmembers_001" + }, + "expect": "deny", + "mustDeny": false, + "redundant": false, + "relevantQuestions": [ + "importers", + "public-api", + "quote-instruction" + ] + }, + { + "id": "ts-enum-variant", + "note": "Removing a variant of an exported enum breaks importers.", + "transcript": [ + { + "type": "assistant", + "uuid": "exported-members-002", + "sessionId": "sess-exported-members", + "isSidechain": false, + "message": { + "id": "msg_exportedmembers_02", + "type": "message", + "role": "assistant", + "model": "claude-sonnet-4-5", + "content": [ + { + "type": "tool_use", + "id": "toolu_exportedmembers_002", + "name": "Edit", + "input": { + "file_path": "{{root}}/src/api/color.ts", + "old_string": " Green,\n", + "new_string": "" + } + } + ] + } + } + ], + "payload": { + "session_id": "sess-exported-members", + "transcript_path": "{{transcript}}", + "cwd": "{{root}}", + "permission_mode": "default", + "hook_event_name": "PreToolUse", + "tool_name": "Edit", + "tool_input": { + "file_path": "{{root}}/src/api/color.ts", + "old_string": " Green,\n", + "new_string": "" + }, + "tool_use_id": "toolu_exportedmembers_002" + }, + "expect": "deny", + "mustDeny": false, + "redundant": false, + "relevantQuestions": [ + "importers", + "public-api", + "quote-instruction" + ] + }, + { + "id": "js-export-list-entry", + "note": "Dropping a name from module.exports.", + "transcript": [ + { + "type": "assistant", + "uuid": "exported-members-003", + "sessionId": "sess-exported-members", + "isSidechain": false, + "message": { + "id": "msg_exportedmembers_03", + "type": "message", + "role": "assistant", + "model": "claude-sonnet-4-5", + "content": [ + { + "type": "tool_use", + "id": "toolu_exportedmembers_003", + "name": "Edit", + "input": { + "file_path": "{{root}}/src/api/index.js", + "old_string": " format,\n", + "new_string": "" + } + } + ] + } + } + ], + "payload": { + "session_id": "sess-exported-members", + "transcript_path": "{{transcript}}", + "cwd": "{{root}}", + "permission_mode": "default", + "hook_event_name": "PreToolUse", + "tool_name": "Edit", + "tool_input": { + "file_path": "{{root}}/src/api/index.js", + "old_string": " format,\n", + "new_string": "" + }, + "tool_use_id": "toolu_exportedmembers_003" + }, + "expect": "deny", + "mustDeny": false, + "redundant": false, + "relevantQuestions": [ + "importers", + "public-api", + "quote-instruction" + ] + }, + { + "id": "py-dataclass-field", + "note": "A dataclass field is part of the constructor.", + "transcript": [ + { + "type": "assistant", + "uuid": "exported-members-004", + "sessionId": "sess-exported-members", + "isSidechain": false, + "message": { + "id": "msg_exportedmembers_04", + "type": "message", + "role": "assistant", + "model": "claude-sonnet-4-5", + "content": [ + { + "type": "tool_use", + "id": "toolu_exportedmembers_004", + "name": "Edit", + "input": { + "file_path": "{{root}}/pkg/settings.py", + "old_string": " timeout: int = 5", + "new_string": " timeout: float = 5.0" + } + } + ] + } + } + ], + "payload": { + "session_id": "sess-exported-members", + "transcript_path": "{{transcript}}", + "cwd": "{{root}}", + "permission_mode": "default", + "hook_event_name": "PreToolUse", + "tool_name": "Edit", + "tool_input": { + "file_path": "{{root}}/pkg/settings.py", + "old_string": " timeout: int = 5", + "new_string": " timeout: float = 5.0" + }, + "tool_use_id": "toolu_exportedmembers_004" + }, + "expect": "deny", + "mustDeny": false, + "redundant": false, + "relevantQuestions": [ + "importers", + "public-api", + "quote-instruction" + ] + }, + { + "id": "rust-pub-enum-variant", + "note": "Removing a variant of a pub enum breaks callers.", + "transcript": [ + { + "type": "assistant", + "uuid": "exported-members-005", + "sessionId": "sess-exported-members", + "isSidechain": false, + "message": { + "id": "msg_exportedmembers_05", + "type": "message", + "role": "assistant", + "model": "claude-sonnet-4-5", + "content": [ + { + "type": "tool_use", + "id": "toolu_exportedmembers_005", + "name": "Edit", + "input": { + "file_path": "{{root}}/crates/api/src/kind.rs", + "old_string": " Large,\n", + "new_string": "" + } + } + ] + } + } + ], + "payload": { + "session_id": "sess-exported-members", + "transcript_path": "{{transcript}}", + "cwd": "{{root}}", + "permission_mode": "default", + "hook_event_name": "PreToolUse", + "tool_name": "Edit", + "tool_input": { + "file_path": "{{root}}/crates/api/src/kind.rs", + "old_string": " Large,\n", + "new_string": "" + }, + "tool_use_id": "toolu_exportedmembers_005" + }, + "expect": "deny", + "mustDeny": false, + "redundant": false, + "relevantQuestions": [ + "importers", + "public-api", + "quote-instruction" + ] + }, + { + "id": "ts-method-body", + "note": "A method body of an exported class stays a local change.", + "transcript": [ + { + "type": "assistant", + "uuid": "exported-members-006", + "sessionId": "sess-exported-members", + "isSidechain": false, + "message": { + "id": "msg_exportedmembers_06", + "type": "message", + "role": "assistant", + "model": "claude-sonnet-4-5", + "content": [ + { + "type": "tool_use", + "id": "toolu_exportedmembers_006", + "name": "Edit", + "input": { + "file_path": "{{root}}/src/api/client.ts", + "old_string": " return fetchIt(path);", + "new_string": " return fetchIt(path, { cache: false });" + } + } + ] + } + } + ], + "payload": { + "session_id": "sess-exported-members", + "transcript_path": "{{transcript}}", + "cwd": "{{root}}", + "permission_mode": "default", + "hook_event_name": "PreToolUse", + "tool_name": "Edit", + "tool_input": { + "file_path": "{{root}}/src/api/client.ts", + "old_string": " return fetchIt(path);", + "new_string": " return fetchIt(path, { cache: false });" + }, + "tool_use_id": "toolu_exportedmembers_006" + }, + "expect": "deny", + "mustDeny": false, + "redundant": false, + "relevantQuestions": [ + "local-callers", + "quote-instruction" + ] + } + ] +} diff --git a/tests/fixtures/gateguard-scenarios/19-notebook-edits.json b/tests/fixtures/gateguard-scenarios/19-notebook-edits.json new file mode 100644 index 000000000..9cd22a4c4 --- /dev/null +++ b/tests/fixtures/gateguard-scenarios/19-notebook-edits.json @@ -0,0 +1,501 @@ +{ + "name": "notebook-edits", + "description": "Jupyter notebook edits: a cold edit, its retry, a credited test notebook, a comment-only cell, and sensitive notebooks in the parent and a subagent.", + "files": { + "notebooks/forecast.ipynb": "{\n \"cells\": [\n {\n \"cell_type\": \"code\",\n \"id\": \"c1\",\n \"metadata\": {},\n \"source\": [\n \"import pandas as pd\\nsales = pd.read_csv('data/sales.csv')\\n\"\n ],\n \"outputs\": [],\n \"execution_count\": null\n }\n ],\n \"metadata\": {},\n \"nbformat\": 4,\n \"nbformat_minor\": 5\n}\n", + "notebooks/cleanup.ipynb": "{\n \"cells\": [\n {\n \"cell_type\": \"code\",\n \"id\": \"c1\",\n \"metadata\": {},\n \"source\": [\n \"# drop empty rows\\nrows = [r for r in rows if r]\\n\"\n ],\n \"outputs\": [],\n \"execution_count\": null\n }\n ],\n \"metadata\": {},\n \"nbformat\": 4,\n \"nbformat_minor\": 5\n}\n", + "tests/notebooks/check_model.ipynb": "{\n \"cells\": [\n {\n \"cell_type\": \"code\",\n \"id\": \"c1\",\n \"metadata\": {},\n \"source\": [\n \"assert model.score(X, y) > 0.8\\n\"\n ],\n \"outputs\": [],\n \"execution_count\": null\n }\n ],\n \"metadata\": {},\n \"nbformat\": 4,\n \"nbformat_minor\": 5\n}\n", + "src/auth/session_audit.ipynb": "{\n \"cells\": [\n {\n \"cell_type\": \"code\",\n \"id\": \"c1\",\n \"metadata\": {},\n \"source\": [\n \"sessions = load_sessions()\\n\"\n ],\n \"outputs\": [],\n \"execution_count\": null\n }\n ],\n \"metadata\": {},\n \"nbformat\": 4,\n \"nbformat_minor\": 5\n}\n", + "src/payments/ledger.ipynb": "{\n \"cells\": [\n {\n \"cell_type\": \"code\",\n \"id\": \"c1\",\n \"metadata\": {},\n \"source\": [\n \"ledger = load_ledger()\\n\"\n ],\n \"outputs\": [],\n \"execution_count\": null\n }\n ],\n \"metadata\": {},\n \"nbformat\": 4,\n \"nbformat_minor\": 5\n}\n", + "data/sales.csv": "month,total\n2026-01,10\n" + }, + "steps": [ + { + "id": "cold-forecast", + "note": "First NotebookEdit with no investigation; the change profile is unknown, so the full code questions are asked.", + "transcript": [ + { + "type": "user", + "uuid": "notebook-edits-001", + "sessionId": "sess-notebook", + "isSidechain": false, + "promptId": "prompt-notebook-1", + "message": { + "role": "user", + "content": "Group the forecast by quarter instead of by month." + } + }, + { + "type": "assistant", + "uuid": "notebook-edits-002", + "sessionId": "sess-notebook", + "isSidechain": false, + "message": { + "id": "msg_notebookedits_01", + "type": "message", + "role": "assistant", + "model": "claude-sonnet-4-5", + "content": [ + { + "type": "tool_use", + "id": "toolu_notebookedits_001", + "name": "NotebookEdit", + "input": { + "notebook_path": "{{root}}/notebooks/forecast.ipynb", + "cell_id": "c1", + "new_source": "sales = pd.read_csv('data/sales.csv')\nsales.groupby('quarter').sum()\n" + } + } + ] + } + } + ], + "payload": { + "session_id": "sess-notebook", + "transcript_path": "{{transcript}}", + "cwd": "{{root}}", + "permission_mode": "default", + "hook_event_name": "PreToolUse", + "tool_name": "NotebookEdit", + "tool_input": { + "notebook_path": "{{root}}/notebooks/forecast.ipynb", + "cell_id": "c1", + "new_source": "sales = pd.read_csv('data/sales.csv')\nsales.groupby('quarter').sum()\n" + }, + "tool_use_id": "toolu_notebookedits_001" + }, + "expect": "deny", + "mustDeny": false, + "redundant": false, + "relevantQuestions": [ + "importers", + "public-api", + "data-schema", + "external-contract", + "quote-instruction" + ] + }, + { + "id": "retry-forecast", + "note": "Retry after the facts are presented.", + "transcript": [ + { + "type": "user", + "uuid": "notebook-edits-003", + "sessionId": "sess-notebook", + "isSidechain": false, + "promptId": "prompt-notebook-1", + "message": { + "role": "user", + "content": [ + { + "type": "tool_result", + "tool_use_id": "toolu_notebookedits_001", + "content": "denied" + } + ] + } + }, + { + "type": "assistant", + "uuid": "notebook-edits-004", + "sessionId": "sess-notebook", + "isSidechain": false, + "message": { + "id": "msg_notebookedits_02", + "type": "message", + "role": "assistant", + "model": "claude-sonnet-4-5", + "content": [ + { + "type": "tool_use", + "id": "toolu_notebookedits_002", + "name": "NotebookEdit", + "input": { + "notebook_path": "{{root}}/notebooks/forecast.ipynb", + "cell_id": "c1", + "new_source": "sales.groupby('quarter').sum()\n" + } + } + ] + } + } + ], + "payload": { + "session_id": "sess-notebook", + "transcript_path": "{{transcript}}", + "cwd": "{{root}}", + "permission_mode": "default", + "hook_event_name": "PreToolUse", + "tool_name": "NotebookEdit", + "tool_input": { + "notebook_path": "{{root}}/notebooks/forecast.ipynb", + "cell_id": "c1", + "new_source": "sales.groupby('quarter').sum()\n" + }, + "tool_use_id": "toolu_notebookedits_002" + }, + "expect": "allow", + "mustDeny": false, + "redundant": false + }, + { + "id": "credited-test-notebook", + "note": "A scoped Grep that names the notebook earlier in the turn credits the first touch.", + "transcript": [ + { + "type": "user", + "uuid": "notebook-edits-005", + "sessionId": "sess-notebook", + "isSidechain": false, + "promptId": "prompt-notebook-1", + "message": { + "role": "user", + "content": [ + { + "type": "tool_result", + "tool_use_id": "toolu_notebookedits_002", + "content": "ok" + } + ] + } + }, + { + "type": "user", + "uuid": "notebook-edits-006", + "sessionId": "sess-notebook", + "isSidechain": false, + "promptId": "prompt-notebook-2", + "message": { + "role": "user", + "content": "Tighten the model check threshold to 0.85." + } + }, + { + "type": "assistant", + "uuid": "notebook-edits-007", + "sessionId": "sess-notebook", + "isSidechain": false, + "message": { + "id": "msg_notebookedits_03", + "type": "message", + "role": "assistant", + "model": "claude-sonnet-4-5", + "content": [ + { + "type": "tool_use", + "id": "toolu_notebookedits_003", + "name": "Grep", + "input": { + "pattern": "check_model", + "path": "{{root}}/tests", + "output_mode": "files_with_matches" + } + } + ] + } + }, + { + "type": "user", + "uuid": "notebook-edits-008", + "sessionId": "sess-notebook", + "isSidechain": false, + "promptId": "prompt-notebook-2", + "message": { + "role": "user", + "content": [ + { + "type": "tool_result", + "tool_use_id": "toolu_notebookedits_003", + "content": "tests/notebooks/check_model.ipynb" + } + ] + } + }, + { + "type": "assistant", + "uuid": "notebook-edits-009", + "sessionId": "sess-notebook", + "isSidechain": false, + "message": { + "id": "msg_notebookedits_04", + "type": "message", + "role": "assistant", + "model": "claude-sonnet-4-5", + "content": [ + { + "type": "tool_use", + "id": "toolu_notebookedits_004", + "name": "NotebookEdit", + "input": { + "notebook_path": "{{root}}/tests/notebooks/check_model.ipynb", + "cell_id": "c1", + "new_source": "assert model.score(X, y) > 0.85\n" + } + } + ] + } + } + ], + "payload": { + "session_id": "sess-notebook", + "transcript_path": "{{transcript}}", + "cwd": "{{root}}", + "permission_mode": "default", + "hook_event_name": "PreToolUse", + "tool_name": "NotebookEdit", + "tool_input": { + "notebook_path": "{{root}}/tests/notebooks/check_model.ipynb", + "cell_id": "c1", + "new_source": "assert model.score(X, y) > 0.85\n" + }, + "tool_use_id": "toolu_notebookedits_004" + }, + "expect": "allow", + "mustDeny": false, + "redundant": true + }, + { + "id": "comment-only-cleanup", + "note": "A comment-only cell source is not a trivial pass for a notebook.", + "transcript": [ + { + "type": "user", + "uuid": "notebook-edits-010", + "sessionId": "sess-notebook", + "isSidechain": false, + "promptId": "prompt-notebook-2", + "message": { + "role": "user", + "content": [ + { + "type": "tool_result", + "tool_use_id": "toolu_notebookedits_004", + "content": "ok" + } + ] + } + }, + { + "type": "assistant", + "uuid": "notebook-edits-011", + "sessionId": "sess-notebook", + "isSidechain": false, + "message": { + "id": "msg_notebookedits_05", + "type": "message", + "role": "assistant", + "model": "claude-sonnet-4-5", + "content": [ + { + "type": "tool_use", + "id": "toolu_notebookedits_005", + "name": "NotebookEdit", + "input": { + "notebook_path": "{{root}}/notebooks/cleanup.ipynb", + "cell_id": "c1", + "new_source": "# drop empty and blank rows\nrows = [r for r in rows if r]\n" + } + } + ] + } + } + ], + "payload": { + "session_id": "sess-notebook", + "transcript_path": "{{transcript}}", + "cwd": "{{root}}", + "permission_mode": "default", + "hook_event_name": "PreToolUse", + "tool_name": "NotebookEdit", + "tool_input": { + "notebook_path": "{{root}}/notebooks/cleanup.ipynb", + "cell_id": "c1", + "new_source": "# drop empty and blank rows\nrows = [r for r in rows if r]\n" + }, + "tool_use_id": "toolu_notebookedits_005" + }, + "expect": "deny", + "mustDeny": false, + "redundant": false, + "relevantQuestions": [ + "importers", + "public-api", + "data-schema", + "external-contract", + "quote-instruction" + ] + }, + { + "id": "sensitive-notebook-after-search", + "note": "A notebook under auth/ is sensitive: the crediting search does not count.", + "transcript": [ + { + "type": "user", + "uuid": "notebook-edits-012", + "sessionId": "sess-notebook", + "isSidechain": false, + "promptId": "prompt-notebook-2", + "message": { + "role": "user", + "content": [ + { + "type": "tool_result", + "tool_use_id": "toolu_notebookedits_005", + "content": "denied" + } + ] + } + }, + { + "type": "user", + "uuid": "notebook-edits-013", + "sessionId": "sess-notebook", + "isSidechain": false, + "promptId": "prompt-notebook-3", + "message": { + "role": "user", + "content": "Add a column for the session origin to the audit notebook." + } + }, + { + "type": "assistant", + "uuid": "notebook-edits-014", + "sessionId": "sess-notebook", + "isSidechain": false, + "message": { + "id": "msg_notebookedits_06", + "type": "message", + "role": "assistant", + "model": "claude-sonnet-4-5", + "content": [ + { + "type": "tool_use", + "id": "toolu_notebookedits_006", + "name": "Grep", + "input": { + "pattern": "session_audit", + "path": "{{root}}/src" + } + } + ] + } + }, + { + "type": "user", + "uuid": "notebook-edits-015", + "sessionId": "sess-notebook", + "isSidechain": false, + "promptId": "prompt-notebook-3", + "message": { + "role": "user", + "content": [ + { + "type": "tool_result", + "tool_use_id": "toolu_notebookedits_006", + "content": "src/auth/session_audit.ipynb" + } + ] + } + }, + { + "type": "assistant", + "uuid": "notebook-edits-016", + "sessionId": "sess-notebook", + "isSidechain": false, + "message": { + "id": "msg_notebookedits_07", + "type": "message", + "role": "assistant", + "model": "claude-sonnet-4-5", + "content": [ + { + "type": "tool_use", + "id": "toolu_notebookedits_007", + "name": "NotebookEdit", + "input": { + "notebook_path": "{{root}}/src/auth/session_audit.ipynb", + "cell_id": "c1", + "new_source": "sessions = load_sessions(columns=['origin'])\n" + } + } + ] + } + } + ], + "payload": { + "session_id": "sess-notebook", + "transcript_path": "{{transcript}}", + "cwd": "{{root}}", + "permission_mode": "default", + "hook_event_name": "PreToolUse", + "tool_name": "NotebookEdit", + "tool_input": { + "notebook_path": "{{root}}/src/auth/session_audit.ipynb", + "cell_id": "c1", + "new_source": "sessions = load_sessions(columns=['origin'])\n" + }, + "tool_use_id": "toolu_notebookedits_007" + }, + "expect": "deny", + "mustDeny": true, + "redundant": false, + "relevantQuestions": [ + "importers", + "public-api", + "data-schema", + "external-contract", + "quote-instruction" + ] + }, + { + "id": "subagent-sensitive-notebook", + "note": "A subagent touching a sensitive notebook is gated like the parent.", + "transcript": [ + { + "type": "user", + "uuid": "notebook-edits-017", + "sessionId": "sess-notebook", + "isSidechain": false, + "promptId": "prompt-notebook-3", + "message": { + "role": "user", + "content": [ + { + "type": "tool_result", + "tool_use_id": "toolu_notebookedits_007", + "content": "denied" + } + ] + } + } + ], + "payload": { + "session_id": "sess-notebook", + "transcript_path": "{{transcript}}", + "cwd": "{{root}}", + "permission_mode": "default", + "hook_event_name": "PreToolUse", + "tool_name": "NotebookEdit", + "tool_input": { + "notebook_path": "{{root}}/src/payments/ledger.ipynb", + "cell_id": "c1", + "new_source": "ledger = load_ledger(currency='EUR')\n" + }, + "tool_use_id": "toolu_notebookedits_008", + "agent_id": "agent-ledger", + "agent_type": "general-purpose" + }, + "expect": "deny", + "mustDeny": true, + "redundant": false, + "relevantQuestions": [ + "importers", + "public-api", + "data-schema", + "external-contract", + "quote-instruction" + ] + } + ] +} diff --git a/tests/fixtures/gateguard-scenarios/20-hard-linked-targets.json b/tests/fixtures/gateguard-scenarios/20-hard-linked-targets.json new file mode 100644 index 000000000..23421e027 --- /dev/null +++ b/tests/fixtures/gateguard-scenarios/20-hard-linked-targets.json @@ -0,0 +1,304 @@ +{ + "name": "hard-linked-targets", + "description": "Edits of files with a second hard-linked name: a credited-looking alias, a comment-only change and a subagent edit, with a single-link control.", + "files": { + "src/reports/writer.py": "def write_report(rows):\n # one row per line\n return '\\n'.join(rows)\n", + "src/reports/format_rows.py": "def format_rows(rows):\n return [str(r) for r in rows]\n", + "src/cache/keys.py": "def cache_key(user):\n return f'user:{user}'\n" + }, + "hardlinks": { + "tools/report_writer.py": "src/reports/writer.py", + "vendor/cache_keys.py": "src/cache/keys.py" + }, + "steps": [ + { + "id": "edit-linked-alias-after-search", + "note": "tools/report_writer.py is a second name for src/reports/writer.py; the Grep that names it does not count.", + "transcript": [ + { + "type": "user", + "uuid": "hard-linked-targets-001", + "sessionId": "sess-hardlink", + "isSidechain": false, + "promptId": "prompt-hardlink-1", + "message": { + "role": "user", + "content": "Make the report writer join rows with CRLF." + } + }, + { + "type": "assistant", + "uuid": "hard-linked-targets-002", + "sessionId": "sess-hardlink", + "isSidechain": false, + "message": { + "id": "msg_hardlinkedtargets_01", + "type": "message", + "role": "assistant", + "model": "claude-sonnet-4-5", + "content": [ + { + "type": "tool_use", + "id": "toolu_hardlinkedtargets_001", + "name": "Grep", + "input": { + "pattern": "report_writer|format_rows", + "path": "{{root}}" + } + } + ] + } + }, + { + "type": "user", + "uuid": "hard-linked-targets-003", + "sessionId": "sess-hardlink", + "isSidechain": false, + "promptId": "prompt-hardlink-1", + "message": { + "role": "user", + "content": [ + { + "type": "tool_result", + "tool_use_id": "toolu_hardlinkedtargets_001", + "content": "tools/report_writer.py\nsrc/reports/format_rows.py" + } + ] + } + }, + { + "type": "assistant", + "uuid": "hard-linked-targets-004", + "sessionId": "sess-hardlink", + "isSidechain": false, + "message": { + "id": "msg_hardlinkedtargets_02", + "type": "message", + "role": "assistant", + "model": "claude-sonnet-4-5", + "content": [ + { + "type": "tool_use", + "id": "toolu_hardlinkedtargets_002", + "name": "Edit", + "input": { + "file_path": "{{root}}/tools/report_writer.py", + "old_string": "'\\n'.join(rows)", + "new_string": "'\\r\\n'.join(rows)" + } + } + ] + } + } + ], + "payload": { + "session_id": "sess-hardlink", + "transcript_path": "{{transcript}}", + "cwd": "{{root}}", + "permission_mode": "default", + "hook_event_name": "PreToolUse", + "tool_name": "Edit", + "tool_input": { + "file_path": "{{root}}/tools/report_writer.py", + "old_string": "'\\n'.join(rows)", + "new_string": "'\\r\\n'.join(rows)" + }, + "tool_use_id": "toolu_hardlinkedtargets_002" + }, + "expect": "deny", + "mustDeny": true, + "redundant": false, + "relevantQuestions": [ + "importers", + "public-api", + "data-schema", + "external-contract", + "quote-instruction" + ] + }, + { + "id": "edit-single-link-control", + "note": "Control: a single-link file named by the same search is credited.", + "transcript": [ + { + "type": "user", + "uuid": "hard-linked-targets-005", + "sessionId": "sess-hardlink", + "isSidechain": false, + "promptId": "prompt-hardlink-1", + "message": { + "role": "user", + "content": [ + { + "type": "tool_result", + "tool_use_id": "toolu_hardlinkedtargets_002", + "content": "denied" + } + ] + } + }, + { + "type": "assistant", + "uuid": "hard-linked-targets-006", + "sessionId": "sess-hardlink", + "isSidechain": false, + "message": { + "id": "msg_hardlinkedtargets_03", + "type": "message", + "role": "assistant", + "model": "claude-sonnet-4-5", + "content": [ + { + "type": "tool_use", + "id": "toolu_hardlinkedtargets_003", + "name": "Edit", + "input": { + "file_path": "{{root}}/src/reports/format_rows.py", + "old_string": "str(r)", + "new_string": "repr(r)" + } + } + ] + } + } + ], + "payload": { + "session_id": "sess-hardlink", + "transcript_path": "{{transcript}}", + "cwd": "{{root}}", + "permission_mode": "default", + "hook_event_name": "PreToolUse", + "tool_name": "Edit", + "tool_input": { + "file_path": "{{root}}/src/reports/format_rows.py", + "old_string": "str(r)", + "new_string": "repr(r)" + }, + "tool_use_id": "toolu_hardlinkedtargets_003" + }, + "expect": "allow", + "mustDeny": false, + "redundant": true + }, + { + "id": "comment-only-linked-original", + "note": "Comment-only change to the other name of the linked file: no trivial pass.", + "transcript": [ + { + "type": "user", + "uuid": "hard-linked-targets-007", + "sessionId": "sess-hardlink", + "isSidechain": false, + "promptId": "prompt-hardlink-1", + "message": { + "role": "user", + "content": [ + { + "type": "tool_result", + "tool_use_id": "toolu_hardlinkedtargets_003", + "content": "ok" + } + ] + } + }, + { + "type": "assistant", + "uuid": "hard-linked-targets-008", + "sessionId": "sess-hardlink", + "isSidechain": false, + "message": { + "id": "msg_hardlinkedtargets_04", + "type": "message", + "role": "assistant", + "model": "claude-sonnet-4-5", + "content": [ + { + "type": "tool_use", + "id": "toolu_hardlinkedtargets_004", + "name": "Edit", + "input": { + "file_path": "{{root}}/src/cache/keys.py", + "old_string": "def cache_key(user):", + "new_string": "def cache_key(user): # stable across deploys" + } + } + ] + } + } + ], + "payload": { + "session_id": "sess-hardlink", + "transcript_path": "{{transcript}}", + "cwd": "{{root}}", + "permission_mode": "default", + "hook_event_name": "PreToolUse", + "tool_name": "Edit", + "tool_input": { + "file_path": "{{root}}/src/cache/keys.py", + "old_string": "def cache_key(user):", + "new_string": "def cache_key(user): # stable across deploys" + }, + "tool_use_id": "toolu_hardlinkedtargets_004" + }, + "expect": "deny", + "mustDeny": true, + "redundant": false, + "relevantQuestions": [ + "importers", + "public-api", + "data-schema", + "external-contract", + "quote-instruction" + ] + }, + { + "id": "subagent-linked-vendor-copy", + "note": "A subagent editing a hard-linked file is gated; ordinary subagent edits still pass.", + "transcript": [ + { + "type": "user", + "uuid": "hard-linked-targets-009", + "sessionId": "sess-hardlink", + "isSidechain": false, + "promptId": "prompt-hardlink-1", + "message": { + "role": "user", + "content": [ + { + "type": "tool_result", + "tool_use_id": "toolu_hardlinkedtargets_004", + "content": "denied" + } + ] + } + } + ], + "payload": { + "session_id": "sess-hardlink", + "transcript_path": "{{transcript}}", + "cwd": "{{root}}", + "permission_mode": "default", + "hook_event_name": "PreToolUse", + "tool_name": "Edit", + "tool_input": { + "file_path": "{{root}}/vendor/cache_keys.py", + "old_string": "f'user:{user}'", + "new_string": "f'u:{user}'" + }, + "tool_use_id": "toolu_hardlinkedtargets_005", + "agent_id": "agent-cache", + "agent_type": "general-purpose" + }, + "expect": "deny", + "mustDeny": true, + "redundant": false, + "relevantQuestions": [ + "importers", + "public-api", + "data-schema", + "external-contract", + "quote-instruction" + ] + } + ] +} diff --git a/tests/hooks/gateguard-fact-force.test.js b/tests/hooks/gateguard-fact-force.test.js index e28df0295..eddb03e5c 100644 --- a/tests/hooks/gateguard-fact-force.test.js +++ b/tests/hooks/gateguard-fact-force.test.js @@ -46,7 +46,7 @@ function writeExpiredState() { fs.mkdirSync(stateDir, { recursive: true }); const expired = { checked: ['some_file.js', '__bash_session__'], - last_active: Date.now() - 31 * 60 * 1000 // 31 minutes ago + last_active: Date.now() - (8 * 60 + 1) * 60 * 1000 }; fs.writeFileSync(stateFile, JSON.stringify(expired), 'utf8'); } catch (_) { @@ -145,6 +145,12 @@ function parseOutput(stdout) { } } +// Canonical state keys fold separators and case on Windows-style paths, so lookups go through this. +const WINDOWS_STYLE_PATH = /^[a-z]:[\\/]|^\\\\/i; +function stateKey(p) { + return WINDOWS_STYLE_PATH.test(p) ? p.replace(/\\/g, '/').toLowerCase() : p; +} + function loadDirectHook(env = {}) { delete require.cache[require.resolve(hookScript)]; Object.assign(process.env, { @@ -738,7 +744,7 @@ function runTests() { test('denies first Edit per file with fact-forcing message', () => { const input = { tool_name: 'Edit', - tool_input: { file_path: '/src/app.js', old_string: 'foo', new_string: 'bar' } + tool_input: { file_path: '/src/app.js', old_string: 'export function foo() {', new_string: 'export function bar() {' } }; const result = runHook(input); assert.strictEqual(result.code, 0, 'exit code should be 0'); @@ -1033,7 +1039,7 @@ function runTests() { test('denies first routine Bash, allows second', () => { const input = { tool_name: 'Bash', - tool_input: { command: 'ls -la' } + tool_input: { command: 'npm test' } }; // First call: should deny @@ -1060,7 +1066,7 @@ function runTests() { // --- Test 6: session state resets after timeout --- if ( - test('session state resets after 30-minute timeout', () => { + test('session state resets after the idle window', () => { writeExpiredState(); const input = { tool_name: 'Edit', @@ -1400,7 +1406,7 @@ function runTests() { const result = runBashHook({ tool_name: 'Bash', - tool_input: { command: 'pwd' } + tool_input: { command: 'npm test' } }); const output = parseOutput(result.stdout); assert.ok(output, 'should produce valid JSON output'); @@ -1423,7 +1429,7 @@ function runTests() { const input = { session_id: 'raw-session-1234', tool_name: 'Bash', - tool_input: { command: 'ls -la' } + tool_input: { command: 'npm test' } }; const first = runBashHook(input, { @@ -1516,7 +1522,7 @@ function runTests() { const input = { session_id: longSessionId, tool_name: 'Bash', - tool_input: { command: 'ls -la' } + tool_input: { command: 'npm test' } }; const first = runBashHook(input, { @@ -1613,15 +1619,21 @@ function runTests() { // --- Test 23: quoted shell separators are not read-only git bypasses clearState(); if ( - test('does not treat quoted shell separators as read-only git introspection', () => { + test('checks a quoted separator in a git argument as one command after destructive detection', () => { const result = runBashHook({ tool_name: 'Bash', tool_input: { command: 'git show HEAD:"docs/a;b.md"' } }); const output = parseOutput(result.stdout); assert.ok(output, 'should produce valid JSON output'); - assert.strictEqual(output.hookSpecificOutput.permissionDecision, 'deny'); - assert.ok(output.hookSpecificOutput.permissionDecisionReason.includes('current user request')); + assert.ok(!output.hookSpecificOutput, 'read-only first command passes'); + const state = JSON.parse(fs.readFileSync(stateFile, 'utf8')); + assert.strictEqual(state.routine_readonly_passes, 1, 'passed by the read-only check, not the early git allowlist'); + assert.ok(!state.checked.includes('__bash_session__'), 'routine gate stays unchecked'); + + const routine = parseOutput(runBashHook({ tool_name: 'Bash', tool_input: { command: 'git show HEAD:"docs/a;b.md" > out.txt' } }).stdout); + assert.strictEqual(routine.hookSpecificOutput.permissionDecision, 'deny'); + assert.ok(routine.hookSpecificOutput.permissionDecisionReason.includes('current user request')); }) ) passed++; @@ -1636,7 +1648,7 @@ function runTests() { fs.writeFileSync(staleFile, JSON.stringify({ checked: [], last_active: Date.now() }), 'utf8'); fs.writeFileSync(freshFile, JSON.stringify({ checked: [], last_active: Date.now() }), 'utf8'); - const staleTime = new Date(Date.now() - 61 * 60 * 1000); + const staleTime = new Date(Date.now() - 17 * 60 * 60 * 1000); fs.utimesSync(staleFile, staleTime, staleTime); const result = runHook({ @@ -1660,7 +1672,7 @@ function runTests() { const input = { transcript_path: path.join(stateDir, 'session.jsonl'), tool_name: 'Bash', - tool_input: { command: 'pwd' } + tool_input: { command: 'npm test' } }; const first = runBashHook(input, { @@ -1697,7 +1709,7 @@ function runTests() { test('uses project directory fallback when no session or transcript id exists', () => { const input = { tool_name: 'Bash', - tool_input: { command: 'pwd' } + tool_input: { command: 'npm test' } }; const fallbackEnv = { CLAUDE_SESSION_ID: '', @@ -1808,7 +1820,10 @@ function runTests() { const persisted = JSON.parse(fs.readFileSync(stateFile, 'utf8')); assert.ok(persisted.checked.includes('/src/concurrent.js'), 'concurrent disk entry should be preserved'); - assert.ok(persisted.checked.includes('/src/new-edit.js'), 'new in-memory entry should be persisted'); + assert.ok( + persisted.checked.some(k => k === '/src/new-edit.js' || /^[a-z]:\/src\/new-edit\.js$/.test(k)), + 'new in-memory entry should be persisted' + ); }) ) passed++; @@ -1955,7 +1970,7 @@ function runTests() { clearState(); if ( test('routine Bash remains gated in subagent context', () => { - const result = runFreshSessionBash('pwd', { agent_id: 'agent-abc-123' }); + const result = runFreshSessionBash('npm test', { agent_id: 'agent-abc-123' }); const output = parseOutput(result.stdout); assert.ok(output, 'subagent Bash should produce JSON output'); assert.strictEqual(output.hookSpecificOutput.permissionDecision, 'deny'); @@ -3091,7 +3106,7 @@ function runTests() { clearState(); if ( test('GATEGUARD_BASH_ROUTINE_DISABLED unset preserves baseline (denies first routine bash)', () => { - const input = { tool_name: 'Bash', tool_input: { command: 'ls -la' } }; + const input = { tool_name: 'Bash', tool_input: { command: 'npm test' } }; const result = runBashHook(input); assert.strictEqual(result.code, 0, 'exit code should be 0'); const output = parseOutput(result.stdout); @@ -3107,7 +3122,7 @@ function runTests() { test('GATEGUARD_BASH_ROUTINE_DISABLED=0 / off / false keeps current behavior', () => { for (const value of ['0', 'false', 'off', '', 'random-value']) { clearState(); - const result = runBashHook({ tool_name: 'Bash', tool_input: { command: 'ls -la' } }, { GATEGUARD_BASH_ROUTINE_DISABLED: value }); + const result = runBashHook({ tool_name: 'Bash', tool_input: { command: 'npm test' } }, { GATEGUARD_BASH_ROUTINE_DISABLED: value }); const output = parseOutput(result.stdout); assert.ok(output, `value="${value}": should produce JSON`); assert.strictEqual(output.hookSpecificOutput.permissionDecision, 'deny', `value="${value}": routine gate should still fire`); @@ -3175,7 +3190,7 @@ function runTests() { // should fall back to the built-in patterns. A plain `ls` should // therefore hit the routine gate (denied first time) and a // built-in destructive (`rm -rf`) should still fire the destructive gate. - const lsResult = runBashHook({ tool_name: 'Bash', tool_input: { command: 'ls -la' } }, { GATEGUARD_BASH_EXTRA_DESTRUCTIVE: '(unclosed' }); + const lsResult = runBashHook({ tool_name: 'Bash', tool_input: { command: 'npm test' } }, { GATEGUARD_BASH_EXTRA_DESTRUCTIVE: '(unclosed' }); assert.strictEqual(lsResult.code, 0, 'malformed regex must not crash hook'); const lsOutput = parseOutput(lsResult.stdout); assert.ok(lsOutput, 'should produce JSON despite bad env regex'); @@ -3475,7 +3490,8 @@ function runTests() { test('consecutive condensed denials are textually different (ordinal advances)', () => { writeState({ checked: [], last_active: Date.now(), fact_force_denials: 5 }); const first = parseOutput(runHook({ tool_name: 'Write', tool_input: { file_path: '/src/damp-a.js', content: 'x' } }).stdout); - const second = parseOutput(runHook({ tool_name: 'Write', tool_input: { file_path: '/src/damp-b.js', content: 'x' } }).stdout); + // Different directory: a same-dir new-file Write would be a sibling allow, not a denial. + const second = parseOutput(runHook({ tool_name: 'Write', tool_input: { file_path: '/lib/damp-b.js', content: 'x' } }).stdout); const firstReason = first.hookSpecificOutput.permissionDecisionReason; const secondReason = second.hookSpecificOutput.permissionDecisionReason; assert.ok(firstReason.includes('denial #6'), `expected ordinal 6, got: ${firstReason}`); @@ -4183,6 +4199,3202 @@ function runTests() { passed++; else failed++; + // --- Canonical checked-path keys --- + const decisionOf = result => { + const output = parseOutput(result.stdout); + return output && output.hookSpecificOutput ? output.hookSpecificOutput.permissionDecision : undefined; + }; + const denialCount = () => JSON.parse(fs.readFileSync(stateFile, 'utf8')).fact_force_denials; + + clearState(); + if ( + test('a.py, ./a.py and /a.py share one checked key (one denial)', () => { + const env = { CLAUDE_PROJECT_DIR: '/proj-keys' }; + const edit = file_path => runHook({ tool_name: 'Edit', tool_input: { file_path, old_string: 'a', new_string: 'b' } }, env); + assert.strictEqual(decisionOf(edit('a.py')), 'deny', 'first touch of a.py is denied'); + assert.notStrictEqual(decisionOf(edit('./a.py')), 'deny', './a.py is the same file'); + assert.notStrictEqual(decisionOf(edit('/proj-keys/a.py')), 'deny', 'absolute spelling is the same file'); + assert.strictEqual(denialCount(), 1, 'exactly one denial counted'); + }) + ) + passed++; + else failed++; + + clearState(); + if ( + test('raw key from an older state file still counts as checked', () => { + writeState({ checked: ['a.py'], last_active: Date.now(), fact_force_denials: 1 }); + const result = runHook( + { tool_name: 'Edit', tool_input: { file_path: 'a.py', old_string: 'a', new_string: 'b' } }, + { CLAUDE_PROJECT_DIR: '/proj-keys' } + ); + assert.notStrictEqual(decisionOf(result), 'deny', 'raw legacy key must not be re-denied'); + assert.strictEqual(denialCount(), 1, 'no new denial counted'); + }) + ) + passed++; + else failed++; + + clearState(); + if ( + test('Windows root folds separators and case into one key', () => { + const env = { CLAUDE_PROJECT_DIR: 'C:\\proj' }; + const write = file_path => runHook({ tool_name: 'Write', tool_input: { file_path, content: 'x' } }, env); + assert.strictEqual(decisionOf(write('src\\a.py')), 'deny', 'first touch is denied'); + assert.notStrictEqual(decisionOf(write('c:/proj/src/a.py')), 'deny', 'forward-slash lowercase spelling is the same file'); + assert.notStrictEqual(decisionOf(write('C:\\PROJ\\src\\A.py')), 'deny', 'case differs only on win32'); + assert.strictEqual(denialCount(), 1, 'exactly one denial counted'); + }) + ) + passed++; + else failed++; + + clearState(); + if ( + test('MultiEdit entries use the canonical key', () => { + const env = { CLAUDE_PROJECT_DIR: '/proj-keys' }; + assert.strictEqual( + decisionOf(runHook({ tool_name: 'Edit', tool_input: { file_path: 'b.py', old_string: 'a', new_string: 'b' } }, env)), + 'deny' + ); + const multi = runHook( + { tool_name: 'MultiEdit', tool_input: { edits: [{ file_path: './b.py', old_string: 'a', new_string: 'b' }] } }, + env + ); + assert.notStrictEqual(decisionOf(multi), 'deny', 'MultiEdit on ./b.py matches the checked b.py'); + assert.strictEqual(denialCount(), 1, 'exactly one denial counted'); + }) + ) + passed++; + else failed++; + + // --- Relative targets resolve against the tool cwd, not the project root --- + clearState(); + if ( + test('relative target resolves against data.cwd before CLAUDE_PROJECT_DIR', () => { + const env = { CLAUDE_PROJECT_DIR: '/proj-cwd' }; + const edit = file_path => + runHook({ tool_name: 'Edit', cwd: '/proj-cwd/sub', tool_input: { file_path, old_string: 'a', new_string: 'b' } }, env); + assert.strictEqual(decisionOf(edit('src/a.py')), 'deny', 'first touch of src/a.py (cwd sub) is denied'); + assert.notStrictEqual(decisionOf(edit('/proj-cwd/sub/src/a.py')), 'deny', 'absolute spelling of the same file is checked'); + assert.strictEqual(decisionOf(edit('/proj-cwd/src/a.py')), 'deny', 'a different file under the project root is gated'); + assert.strictEqual(denialCount(), 2, 'two distinct files, two denials'); + }) + ) + passed++; + else failed++; + + clearState(); + if ( + test('without data.cwd a relative target still resolves against CLAUDE_PROJECT_DIR', () => { + const env = { CLAUDE_PROJECT_DIR: '/proj-cwd' }; + const edit = file_path => runHook({ tool_name: 'Edit', tool_input: { file_path, old_string: 'a', new_string: 'b' } }, env); + assert.strictEqual(decisionOf(edit('src/a.py')), 'deny'); + assert.notStrictEqual(decisionOf(edit('/proj-cwd/src/a.py')), 'deny', 'project-root fallback unchanged'); + }) + ) + passed++; + else failed++; + + // --- Questions by target class --- + const CLASS_ENV = { CLAUDE_PROJECT_DIR: '/proj-classes' }; + const QUOTE_LINE = "Quote the user's current instruction verbatim"; + const RETRY_LINE = 'Present the facts, then retry the same operation.'; + const classDenialReason = (toolName, file_path, env = {}) => { + const tool_input = toolName === 'MultiEdit' + ? { edits: [{ file_path, old_string: 'a', new_string: 'b' }] } + : { file_path, old_string: 'a', new_string: 'b', content: 'x' }; + const output = parseOutput(runHook({ tool_name: toolName, tool_input }, { ...CLASS_ENV, ...env }).stdout); + assert.ok(output && output.hookSpecificOutput, `${toolName} ${file_path} should be denied`); + assert.strictEqual(output.hookSpecificOutput.permissionDecision, 'deny'); + return output.hookSpecificOutput.permissionDecisionReason; + }; + const assertNoCodeQuestions = reason => { + assert.ok(!reason.includes('call this new file'), 'must not ask the code Write questions'); + assert.ok(!reason.includes('import/require'), 'must not ask the code Edit questions'); + assert.ok(!reason.includes('public functions/classes'), 'must not ask the code Edit questions'); + }; + const assertFrame = (reason, verb, file) => { + assert.ok(reason.startsWith('[Fact-Forcing Gate]\n\n'), 'keeps the gate header'); + assert.ok(reason.includes(`Before ${verb} ${file}, present these facts:`), `header names ${verb} ${file}`); + assert.ok(reason.includes(QUOTE_LINE), 'list ends with the verbatim-instruction line'); + assert.ok(reason.includes(`If this call was sent in a parallel batch, other edits to ${file}`), 'keeps batch-sibling warning'); + assert.ok(reason.includes(RETRY_LINE), 'keeps the closing retry line'); + assert.ok(reason.includes('GATEGUARD_EXEMPT_GLOBS'), 'keeps the path-scoped recovery hint'); + }; + + clearState(); + if ( + test('first-touch Write of docs/guide.md asks prose questions, not code questions', () => { + const reason = classDenialReason('Write', 'docs/guide.md'); + assertFrame(reason, 'creating', 'docs/guide.md'); + assert.ok(reason.includes('supersedes or duplicates'), 'asks what it supersedes'); + assert.ok(reason.includes('linked or referenced from'), 'asks where it is linked from'); + assert.ok(reason.includes('why a new file rather than editing an existing one'), 'asks why a new file'); + assert.ok(reason.includes(`4. ${QUOTE_LINE}`), 'quote is item 4'); + assertNoCodeQuestions(reason); + }) + ) + passed++; + else failed++; + + clearState(); + if ( + test('first-touch Edit of docs/guide.md asks prose Edit questions', () => { + const reason = classDenialReason('Edit', 'docs/guide.md'); + assertFrame(reason, 'editing', 'docs/guide.md'); + assert.ok(reason.includes('reference the section being changed'), 'asks what references the section'); + assert.ok(reason.includes('corrects or adds'), 'asks what the change corrects or adds'); + assert.ok(reason.includes(`3. ${QUOTE_LINE}`), 'quote is item 3'); + assert.ok(!reason.includes('supersedes'), 'Write-only prose question absent'); + assertNoCodeQuestions(reason); + }) + ) + passed++; + else failed++; + + clearState(); + if ( + test('skills/x/SKILL.md and .claude/agents/a.md get instruction questions', () => { + for (const file of ['skills/x/SKILL.md', '.claude/agents/a.md', 'CLAUDE.md']) { + const reason = classDenialReason('Write', file); + assertFrame(reason, 'creating', file); + assert.ok(reason.includes('harness/loader'), `${file}: asks which harness reads it`); + assert.ok(reason.includes('agent behaviour changes'), `${file}: asks what behaviour changes`); + assert.ok(reason.includes('instruction, skill, or agent file already covers'), `${file}: asks for existing coverage`); + assert.ok(reason.includes(`4. ${QUOTE_LINE}`), `${file}: quote is item 4`); + assertNoCodeQuestions(reason); + } + }) + ) + passed++; + else failed++; + + clearState(); + if ( + test('src/a.py Write and Edit keep the code text byte-identical', () => { + const fullReason = tool_input => { + const output = parseOutput(runHook({ tool_name: tool_input.content === undefined ? 'Edit' : 'Write', tool_input }, CLASS_ENV).stdout); + assert.ok(output && output.hookSpecificOutput, `${tool_input.file_path} should be denied`); + return output.hookSpecificOutput.permissionDecisionReason; + }; + const writeReason = fullReason({ file_path: 'src/a.py', content: 'import json\n' }); + const expectedWrite = [ + '[Fact-Forcing Gate]', + '', + 'Before creating src/a.py, present these facts:', + '', + '1. Name the file(s) and line(s) that will call this new file', + '2. Confirm no existing file serves the same purpose (search the tree — Glob/Grep, or find/grep via Bash)', + '3. If this file reads/writes data files, show field names, structure, and date format (use redacted or synthetic values, not raw production data)', + '4. Name what outside this repository decides the format, units, timezone or protocol semantics here (the consumer, the producer, or a stated convention), or state that the choice is unconstrained', + "5. Quote the user's current instruction verbatim", + '', + 'If this call was sent in a parallel batch, other edits to src/a.py from that batch may already have been applied. Re-read the file before building on them.', + '', + 'Present the facts, then retry the same operation.', + '' + ].join('\n'); + assert.ok(writeReason.startsWith(expectedWrite), 'code Write block unchanged'); + const editReason = fullReason({ file_path: 'src/b.py', old_string: 'def load(path):', new_string: 'def load_json(path):' }); + const expectedEdit = [ + '[Fact-Forcing Gate]', + '', + 'Before editing src/b.py, present these facts:', + '', + '1. List ALL files that import/require this file (search the tree — Glob/Grep, or find/grep via Bash)', + '2. List the public functions/classes affected by this change', + '3. If this file reads/writes data files, show field names, structure, and date format (use redacted or synthetic values, not raw production data)', + '4. Name what outside this repository decides the format, units, timezone or protocol semantics here (the consumer, the producer, or a stated convention), or state that the choice is unconstrained', + "5. Quote the user's current instruction verbatim", + '', + 'If this call was sent in a parallel batch, other edits to src/b.py from that batch may already have been applied. Re-read the file before building on them.', + '', + 'Present the facts, then retry the same operation.', + '' + ].join('\n'); + assert.ok(editReason.startsWith(expectedEdit), 'code Edit block unchanged'); + }) + ) + passed++; + else failed++; + + clearState(); + if ( + test('src/a.test.ts and tests/helpers.js get test questions', () => { + for (const file of ['src/a.test.ts', 'tests/helpers.js']) { + const reason = classDenialReason('Write', file); + assertFrame(reason, 'creating', file); + assert.ok(reason.includes('behaviour is under test'), `${file}: asks what is under test`); + assert.ok(reason.includes('existing test file(s)'), `${file}: asks for existing test files`); + assert.ok(reason.includes(`3. ${QUOTE_LINE}`), `${file}: quote is item 3`); + assertNoCodeQuestions(reason); + } + }) + ) + passed++; + else failed++; + + clearState(); + if ( + test('config/app.yaml and .env.local get config questions', () => { + for (const file of ['config/app.yaml', '.env.local']) { + const reason = classDenialReason('Write', file); + assertFrame(reason, 'creating', file); + assert.ok(reason.includes('process/tool reads this file'), `${file}: asks what reads it`); + assert.ok(reason.includes('effect of the change'), `${file}: asks for the effect`); + assert.ok(reason.includes('no secrets or credentials'), `${file}: asks about secrets`); + assert.ok(reason.includes(`4. ${QUOTE_LINE}`), `${file}: quote is item 4`); + assertNoCodeQuestions(reason); + } + }) + ) + passed++; + else failed++; + + // --- Questions from the change profile --- + const PROFILE_ENV = { CLAUDE_PROJECT_DIR: '/proj-profile' }; + const profileDenialReason = (toolName, tool_input, env = {}) => { + const output = parseOutput(runHook({ tool_name: toolName, tool_input }, { ...PROFILE_ENV, ...env }).stdout); + assert.ok(output && output.hookSpecificOutput, `${toolName} ${JSON.stringify(tool_input).slice(0, 80)} should be denied`); + assert.strictEqual(output.hookSpecificOutput.permissionDecision, 'deny'); + return output.hookSpecificOutput.permissionDecisionReason; + }; + const listedQuestions = reason => reason.split('\n').filter(line => /^\d+\. /.test(line)).map(line => line.replace(/^\d+\. /, '')); + const QUESTION_LINES = { + importers: 'List ALL files that import/require this file (search the tree — Glob/Grep, or find/grep via Bash)', + publicApi: 'List the public functions/classes affected by this change', + localCallers: 'List the call sites in this file or its module that rely on the changed behaviour (search the tree — Glob/Grep, or find/grep via Bash)', + callers: 'Name the file(s) and line(s) that will call this new file', + noDuplicate: 'Confirm no existing file serves the same purpose (search the tree — Glob/Grep, or find/grep via Bash)', + dataSchema: 'If this file reads/writes data files, show field names, structure, and date format (use redacted or synthetic values, not raw production data)', + externalContract: + 'Name what outside this repository decides the format, units, timezone or protocol semantics here (the consumer, the producer, or a stated convention), or state that the choice is unconstrained', + quote: QUOTE_LINE + }; + const B2_FULL_EDIT = [QUESTION_LINES.importers, QUESTION_LINES.publicApi, QUESTION_LINES.dataSchema, QUESTION_LINES.externalContract, QUESTION_LINES.quote]; + const B2_FULL_WRITE = [QUESTION_LINES.callers, QUESTION_LINES.noDuplicate, QUESTION_LINES.dataSchema, QUESTION_LINES.externalContract, QUESTION_LINES.quote]; + + clearState(); + if ( + test('an Edit that changes an exported signature asks for importers and the affected public API', () => { + const reason = profileDenialReason('Edit', { file_path: 'src/api.js', old_string: 'export function load(a) {', new_string: 'export function load(a, b) {' }); + assert.deepStrictEqual(listedQuestions(reason), [QUESTION_LINES.importers, QUESTION_LINES.publicApi, QUESTION_LINES.quote]); + assertFrame(reason, 'editing', 'src/api.js'); + }) + ) + passed++; + else failed++; + + clearState(); + if ( + test('an Edit of a function body asks for call sites in the file or module instead of importers', () => { + const reason = profileDenialReason('Edit', { file_path: 'src/calc.js', old_string: ' return a + 1;', new_string: ' return a * 2;' }); + assert.deepStrictEqual(listedQuestions(reason), [QUESTION_LINES.localCallers, QUESTION_LINES.quote]); + assertFrame(reason, 'editing', 'src/calc.js'); + }) + ) + passed++; + else failed++; + + clearState(); + if ( + test('an Edit that handles data keeps the data-schema question', () => { + const reason = profileDenialReason('Edit', { file_path: 'src/calc.py', old_string: ' x = 1', new_string: ' rows = json.load(fh)' }); + assert.deepStrictEqual(listedQuestions(reason), [QUESTION_LINES.localCallers, QUESTION_LINES.dataSchema, QUESTION_LINES.externalContract, QUESTION_LINES.quote]); + }) + ) + passed++; + else failed++; + + clearState(); + if ( + test('a Write of a new code file without data handling drops the data-schema question', () => { + const reason = profileDenialReason('Write', { file_path: 'src/new_mod.js', content: 'export const x = 1;\n' }); + assert.deepStrictEqual(listedQuestions(reason), [QUESTION_LINES.callers, QUESTION_LINES.noDuplicate, QUESTION_LINES.quote]); + const withData = profileDenialReason('Write', { file_path: 'lib/new_io.js', content: "const fs = require('fs');\n" }); + assert.deepStrictEqual(listedQuestions(withData), B2_FULL_WRITE); + }) + ) + passed++; + else failed++; + + clearState(); + if ( + test('an unknown or over-bound change keeps the full code questions byte-identical', () => { + const cases = [ + ['Edit', { file_path: 'src/a.rb', old_string: 'a', new_string: 'b' }, B2_FULL_EDIT], + ['Edit', { file_path: 'src/big.js', old_string: 'x'.repeat(70 * 1024), new_string: 'y' }, B2_FULL_EDIT], + ['Edit', { file_path: 'src/shape.js' }, B2_FULL_EDIT], + ['Edit', { file_path: 'src/shape2.js', old_string: 5, new_string: 'b' }, B2_FULL_EDIT], + ['Write', { file_path: 'src/d.rb', content: 'x' }, B2_FULL_WRITE], + ['Write', { file_path: 'src/e.js' }, B2_FULL_WRITE] + ]; + for (const [tool, input, expected] of cases) { + clearState(); + const reason = profileDenialReason(tool, input); + assert.deepStrictEqual(listedQuestions(reason), expected, `${input.file_path}`); + const header = `Before ${tool === 'Write' ? 'creating' : 'editing'} ${input.file_path}, present these facts:`; + const block = ['[Fact-Forcing Gate]', '', header, '', ...expected.map((q, i) => `${i + 1}. ${q}`), ''].join('\n'); + assert.ok(reason.startsWith(block), `${input.file_path}: block unchanged`); + } + }) + ) + passed++; + else failed++; + + clearState(); + if ( + test('sensitive code targets always get the full code questions', () => { + const reason = profileDenialReason('Edit', { file_path: 'src/auth/login.js', old_string: ' return a + 1;', new_string: ' return a * 2;' }); + assert.deepStrictEqual(listedQuestions(reason), B2_FULL_EDIT); + assert.ok(reason.includes('Sensitive target'), 'sensitive note'); + const write = profileDenialReason('Write', { file_path: 'src/billing/new.js', content: 'export const x = 1;\n' }); + assert.deepStrictEqual(listedQuestions(write), B2_FULL_WRITE); + }) + ) + passed++; + else failed++; + + clearState(); + if ( + test('config, instruction and test targets keep their class questions whatever the change', () => { + const config = listedQuestions(profileDenialReason('Write', { file_path: 'config/app.yaml', content: 'a: 1\n' })); + assert.strictEqual(config.length, 4, 'config keeps three questions and the quote'); + assert.ok(config[2].includes('no secrets or credentials')); + const instruction = listedQuestions(profileDenialReason('Edit', { file_path: 'CLAUDE.md', old_string: 'a', new_string: 'b' })); + assert.strictEqual(instruction.length, 4, 'instruction keeps three questions and the quote'); + const testFile = listedQuestions(profileDenialReason('Edit', { file_path: 'src/a.test.js', old_string: ' return 1;', new_string: ' return 2;' })); + assert.ok(testFile[0].includes('behaviour is under test'), 'test questions'); + assert.strictEqual(testFile.length, 3); + }) + ) + passed++; + else failed++; + + clearState(); + if ( + test('MultiEdit questions follow every entry for the denied file', () => { + const surface = profileDenialReason('MultiEdit', { + edits: [ + { file_path: 'src/m.js', old_string: ' return 1;', new_string: ' return 2;' }, + { file_path: 'src/m.js', old_string: 'function f() {', new_string: 'export function f() {' } + ] + }); + assert.deepStrictEqual(listedQuestions(surface), [QUESTION_LINES.importers, QUESTION_LINES.publicApi, QUESTION_LINES.quote]); + clearState(); + const local = profileDenialReason('MultiEdit', { + edits: [ + { file_path: 'src/n.js', old_string: ' return 1;', new_string: ' return 2;' }, + { file_path: 'src/other.js', old_string: 'export const y = 1;', new_string: 'export const y = 2;' } + ] + }); + assert.deepStrictEqual(listedQuestions(local), [QUESTION_LINES.localCallers, QUESTION_LINES.quote], 'entries for other files do not count'); + }) + ) + passed++; + else failed++; + + clearState(); + if ( + test('shell scripts ask the questions their change warrants', () => { + const local = profileDenialReason('Edit', { file_path: 'scripts/q1.sh', old_string: ' echo "a"', new_string: ' echo "b"' }); + assert.deepStrictEqual(listedQuestions(local), [QUESTION_LINES.localCallers, QUESTION_LINES.quote]); + clearState(); + const fn = profileDenialReason('Edit', { file_path: 'scripts/q2.sh', old_string: 'deploy() {', new_string: 'deploy_all() {' }); + assert.deepStrictEqual(listedQuestions(fn), [QUESTION_LINES.importers, QUESTION_LINES.publicApi, QUESTION_LINES.quote]); + clearState(); + const exported = profileDenialReason('Edit', { file_path: 'scripts/q3.sh', old_string: 'export MODE=a', new_string: 'export MODE=b' }); + assert.deepStrictEqual(listedQuestions(exported), [QUESTION_LINES.importers, QUESTION_LINES.publicApi, QUESTION_LINES.quote]); + clearState(); + const fetch = profileDenialReason('Edit', { file_path: 'scripts/q4.sh', old_string: ' run', new_string: ' curl -s "$URL" > out.json' }); + assert.deepStrictEqual(listedQuestions(fetch), [QUESTION_LINES.localCallers, QUESTION_LINES.dataSchema, QUESTION_LINES.externalContract, QUESTION_LINES.quote]); + clearState(); + const ps = profileDenialReason('Edit', { file_path: 'scripts/q5.ps1', old_string: 'function Get-A {', new_string: 'function Get-B {' }); + assert.deepStrictEqual(listedQuestions(ps), [QUESTION_LINES.importers, QUESTION_LINES.publicApi, QUESTION_LINES.quote]); + clearState(); + const bat = profileDenialReason('Edit', { file_path: 'scripts/q6.bat', old_string: 'echo a', new_string: 'echo b' }); + assert.deepStrictEqual(listedQuestions(bat), [QUESTION_LINES.importers, QUESTION_LINES.publicApi, QUESTION_LINES.quote], 'batch always counts as surface'); + }) + ) + passed++; + else failed++; + + clearState(); + if ( + test('a one-line edit of a Python module constant asks for importers', () => { + const reason = profileDenialReason('Edit', { file_path: 'tools/q7.py', old_string: 'BASE = "https://a.test/#home"', new_string: 'BASE = "https://a.test/#admin"' }); + assert.deepStrictEqual(listedQuestions(reason), [QUESTION_LINES.importers, QUESTION_LINES.publicApi, QUESTION_LINES.quote]); + clearState(); + const local = profileDenialReason('Edit', { file_path: 'tools/q8.py', old_string: 'result = compute(a)', new_string: 'result = compute(b)' }); + assert.deepStrictEqual(listedQuestions(local), [QUESTION_LINES.localCallers, QUESTION_LINES.quote]); + }) + ) + passed++; + else failed++; + + clearState(); + if ( + test('condensed denials follow the change profile', () => { + const env = { GATEGUARD_FACT_FORCE_FULL_DENIALS: '0' }; + const local = profileDenialReason('Edit', { file_path: 'src/c1.js', old_string: ' return 1;', new_string: ' return 2;' }, env); + assert.ok(local.includes('the call sites in this file or its module that rely on the change'), local); + assert.ok(!local.includes('importers/callers'), 'no importer hint for a local change'); + assert.ok(!local.includes('data schemas'), 'no data hint for a change without data'); + const created = profileDenialReason('Write', { file_path: 'src/c3.js', content: 'export const a = 1;\n' }, env); + assert.ok(created.includes('that no existing file serves the same purpose'), created); + assert.ok(created.includes('(denial #') && created.includes('parallel batch') && created.includes('ECC_GATEGUARD=off'), 'keeps ordinal and hints'); + const data = profileDenialReason('Edit', { file_path: 'src/c4.js', old_string: ' return 1;', new_string: ' return JSON.parse(raw);' }, env); + assert.ok(data.includes('the data schemas it reads or writes'), data); + const full = profileDenialReason('Edit', { file_path: 'src/c2.rb', old_string: 'a', new_string: 'b' }, env); + assert.ok(full.includes('briefly state importers/callers, affected API, data schemas if any'), 'unknown profile keeps the hint'); + }) + ) + passed++; + else failed++; + + // --- Comment and whitespace-only edits --- + const trivialRoot = fs.mkdtempSync(path.join(tmpRoot, 'gateguard-trivial-')); + const seedTrivialFiles = tool_input => { + const entries = Array.isArray(tool_input.edits) ? tool_input.edits : [tool_input]; + const byFile = new Map(); + for (const entry of entries) { + if (!entry || typeof entry !== 'object') continue; + const rel = entry.file_path || tool_input.file_path; + if (typeof rel !== 'string' || typeof entry.old_string !== 'string') continue; + byFile.set(rel, [...(byFile.get(rel) || []), entry.old_string]); + } + for (const [rel, parts] of byFile) { + const file = path.join(trivialRoot, rel); + if (fs.existsSync(file)) continue; + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync(file, `${parts.join('\n')}\n`); + } + }; + const trivialRun = (toolName, tool_input, env = {}) => { + seedTrivialFiles(tool_input); + const result = runHook({ tool_name: toolName, tool_input }, { CLAUDE_PROJECT_DIR: trivialRoot, ...env }); + const output = parseOutput(result.stdout); + const hso = output && output.hookSpecificOutput ? output.hookSpecificOutput : {}; + const context = Array.isArray(hso.additionalContext) ? hso.additionalContext.join('\n') : String(hso.additionalContext || ''); + return { result, decision: hso.permissionDecision, context, reason: hso.permissionDecisionReason || '' }; + }; + const trivialState = () => (fs.existsSync(stateFile) ? JSON.parse(fs.readFileSync(stateFile, 'utf8')) : {}); + const trivialKey = rel => `${trivialRoot}/${rel}`; + const TRIVIAL_NOTE = 'Comment or whitespace-only change to'; + + clearState(); + if ( + test('a comment-only Edit of an unchecked code file passes with a note and is not marked checked', () => { + const out = trivialRun('Edit', { file_path: 'src/t1.js', old_string: '// old note\nfoo();', new_string: '// new note\nfoo();' }); + assert.notStrictEqual(out.decision, 'deny', out.result.stdout); + assert.ok(out.context.includes(`${TRIVIAL_NOTE} src/t1.js`), out.context); + assert.strictEqual(out.result.code, 0); + const state = trivialState(); + assert.ok(!(state.checked || []).includes(trivialKey('src/t1.js')), 'not marked checked'); + assert.strictEqual(state.trivial_allows, 1); + assert.strictEqual(state.fact_force_denials || 0, 0, 'denial count untouched'); + assert.ok(!out.result.stdout.includes('"allow"'), 'never an allow decision'); + }) + ) + passed++; + else failed++; + + if ( + test('a later code change to the same file is still gated', () => { + const out = trivialRun('Edit', { file_path: 'src/t1.js', old_string: 'foo();', new_string: 'bar();' }); + assert.strictEqual(out.decision, 'deny', out.result.stdout); + const state = trivialState(); + assert.strictEqual(state.fact_force_denials, 1); + assert.strictEqual(state.trivial_allows, 1, 'trivial counter kept'); + }) + ) + passed++; + else failed++; + + clearState(); + if ( + test('whitespace-only reindents and trivial test-file edits pass', () => { + const js = trivialRun('Edit', { file_path: 'src/t2.js', old_string: 'if (x) {\n foo();\n}', new_string: 'if (x) {\n foo();\n}' }); + assert.ok(js.context.includes(TRIVIAL_NOTE), js.result.stdout); + const spec = trivialRun('Edit', { file_path: 'src/t2.test.js', old_string: "it('a', () => {}); // old", new_string: "it('a', () => {}); // new" }); + assert.ok(spec.context.includes(TRIVIAL_NOTE), spec.result.stdout); + assert.strictEqual(trivialState().trivial_allows, 2); + }) + ) + passed++; + else failed++; + + clearState(); + if ( + test('comment-only edits of shell, PowerShell and batch scripts pass without marking them checked', () => { + const cases = [ + { file_path: 'scripts/s1.sh', old_string: '# build\n', new_string: '# Build all targets.\n' }, + { file_path: 'scripts/s2.ps1', old_string: '<# old #>\nGet-Item a', new_string: '<# new #>\nGet-Item a' }, + { file_path: 'scripts/s3.cmd', old_string: 'REM old\necho a', new_string: 'REM new\necho a' } + ]; + for (const input of cases) { + const out = trivialRun('Edit', input); + assert.notStrictEqual(out.decision, 'deny', out.result.stdout); + assert.ok(out.context.includes(`${TRIVIAL_NOTE} ${input.file_path}`), out.context); + assert.ok(!(trivialState().checked || []).includes(trivialKey(input.file_path)), 'not marked checked'); + } + assert.strictEqual(trivialState().trivial_allows, 3); + }) + ) + passed++; + else failed++; + + clearState(); + if ( + test('trivial-looking edits are still denied where the pass does not apply', () => { + const cases = [ + ['sensitive target', { file_path: 'src/auth/session.js', old_string: '// old\nfoo();', new_string: '// new\nfoo();' }], + ['comment plus code', { file_path: 'src/d1.js', old_string: '// old\nfoo(1);', new_string: '// new\nfoo(2);' }], + ['python indentation', { file_path: 'src/d2.py', old_string: 'if x:\n y()', new_string: 'if x:\n y()' }], + ['unknown extension', { file_path: 'src/d3.rb', old_string: '# old', new_string: '# new' }], + ['config target', { file_path: 'config/d4.yaml', old_string: '# old', new_string: '# new' }], + ['instruction target', { file_path: 'CLAUDE.md', old_string: '', new_string: '' }], + ['instruction code file', { file_path: '.claude/hooks/d5.md', old_string: '// old', new_string: '// new' }], + ['template literal', { file_path: 'src/d6.js', old_string: '// old\n`;', new_string: '// new\n`;' }], + ['over the bound', { file_path: 'src/d7.js', old_string: `// ${'x'.repeat(70 * 1024)}`, new_string: '// y' }], + ['missing new_string', { file_path: 'src/d8.js', old_string: '// old' }], + ['sensitive shell script', { file_path: 'scripts/auth/rotate.sh', old_string: '# old\nrun', new_string: '# new\nrun' }], + ['workflow shell script', { file_path: '.github/workflows/d9.sh', old_string: '# old', new_string: '# new' }], + ['shell heredoc', { file_path: 'scripts/d10.sh', old_string: 'cat < { + const cases = [ + ['macro continuation', 'src/ctx/a.c', '#define A 1 \\\n// c\nint x;\n', { old_string: '// c\n', new_string: '' }], + ['joined next line', 'src/ctx/b.js', 'a();\n// c\nb();\n', { old_string: '// c\n', new_string: '// c ' }], + ['template literal', 'src/ctx/c.js', 'const q = `\n // hint\n SELECT 1\n`;\n', { old_string: ' // hint', new_string: ' // other' }], + ['docstring', 'src/ctx/d.py', 'Q = """\n# limit 10\nSELECT 1\n"""\n', { old_string: '# limit 10', new_string: '# limit 99' }], + ['heredoc body', 'scripts/ctx/e.sh', 'cat > colors.txt < { + const input = { old_string: '// old', new_string: '// new' }; + const cases = [['missing file', 'src/nf/missing.js']]; + const big = path.join(trivialRoot, 'src/nf/big.js'); + fs.mkdirSync(path.dirname(big), { recursive: true }); + fs.writeFileSync(big, `// old\n${'x();\n'.repeat(300 * 1024)}`); + cases.push(['file over 1 MiB', 'src/nf/big.js']); + fs.mkdirSync(path.join(trivialRoot, 'src/nf/dir.js'), { recursive: true }); + cases.push(['directory', 'src/nf/dir.js']); + if (process.platform !== 'win32' && spawnSync('mkfifo', [path.join(trivialRoot, 'src/nf/pipe.js')]).status === 0) { + cases.push(['named pipe', 'src/nf/pipe.js']); + } + for (const [label, rel] of cases) { + clearState(); + const started = Date.now(); + const result = runHook({ tool_name: 'Edit', tool_input: { file_path: rel, ...input } }, { CLAUDE_PROJECT_DIR: trivialRoot }); + const hso = (parseOutput(result.stdout) || {}).hookSpecificOutput || {}; + assert.strictEqual(hso.permissionDecision, 'deny', `${label}: ${result.stdout}`); + assert.ok(Date.now() - started < 10000, `${label}: returned promptly`); + } + }) + ) + passed++; + else failed++; + + clearState(); + if ( + test('a comment edit next to closed multi-line constructs still passes', () => { + const rel = 'src/ctx/ok.js'; + const file = path.join(trivialRoot, rel); + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync(file, 'const q = `a\n${b}\n`;\nconst r = /[/"]+/g;\n\n// old\nfoo();\n'); + const out = trivialRun('Edit', { file_path: rel, old_string: '// old', new_string: '// new' }); + assert.ok(out.context.includes(`${TRIVIAL_NOTE} ${rel}`), out.result.stdout); + }) + ) + passed++; + else failed++; + + clearState(); + if ( + test('a Write is never a trivial edit', () => { + const out = trivialRun('Write', { file_path: 'src/w1.js', content: '// only a comment\n' }); + assert.strictEqual(out.decision, 'deny', out.result.stdout); + }) + ) + passed++; + else failed++; + + clearState(); + if ( + test('MultiEdit passes a file only when all of its entries are trivial', () => { + const allTrivial = trivialRun('MultiEdit', { + edits: [ + { file_path: 'src/m1.js', old_string: '// a', new_string: '// b' }, + { file_path: 'src/m1.js', old_string: 'foo(); // c', new_string: 'foo(); // d' } + ] + }); + assert.notStrictEqual(allTrivial.decision, 'deny', allTrivial.result.stdout); + assert.ok(allTrivial.context.includes(`${TRIVIAL_NOTE} src/m1.js`), allTrivial.context); + assert.strictEqual(trivialState().trivial_allows, 1, 'one pass per file'); + clearState(); + const mixed = trivialRun('MultiEdit', { + edits: [ + { file_path: 'src/m2.js', old_string: '// a', new_string: '// b' }, + { file_path: 'src/m2.js', old_string: 'foo(1);', new_string: 'foo(2);' } + ] + }); + assert.strictEqual(mixed.decision, 'deny', 'a non-trivial entry for the file denies it'); + clearState(); + const twoFiles = trivialRun('MultiEdit', { + edits: [ + { file_path: 'src/m3.js', old_string: '// a', new_string: '// b' }, + { file_path: 'src/m4.js', old_string: 'foo(1);', new_string: 'foo(2);' } + ] + }); + assert.strictEqual(twoFiles.decision, 'deny'); + assert.ok(twoFiles.reason.includes('src/m4.js'), 'the non-trivial file is the one denied'); + const state = trivialState(); + assert.ok(!(state.checked || []).includes(trivialKey('src/m3.js')), 'trivial file not marked checked'); + }) + ) + passed++; + else failed++; + + clearState(); + if ( + test('MultiEdit entries without their own path are gated as the call file_path', () => { + const sensitive = trivialRun('MultiEdit', { file_path: '.env', edits: [{ old_string: 'A=1', new_string: 'A=2' }] }); + assert.strictEqual(sensitive.decision, 'deny', sensitive.result.stdout); + assert.ok(sensitive.reason.includes('.env'), sensitive.reason); + clearState(); + const code = trivialRun('MultiEdit', { file_path: 'src/mt1.js', edits: [{ old_string: 'f(1);', new_string: 'f(2);' }] }); + assert.strictEqual(code.decision, 'deny', code.result.stdout); + assert.ok(code.reason.includes('src/mt1.js'), code.reason); + clearState(); + const trivial = trivialRun('MultiEdit', { file_path: 'src/mt2.js', edits: [{ old_string: 'f(); // a', new_string: 'f(); // b' }] }); + assert.ok(trivial.context.includes(`${TRIVIAL_NOTE} src/mt2.js`), trivial.result.stdout); + clearState(); + const input = { file_path: 'config/.env.local', edits: [{ old_string: 'A=1', new_string: 'A=2' }] }; + seedTrivialFiles(input); + const result = runHook({ tool_name: 'MultiEdit', tool_input: input, agent_id: 'sub-1' }, { CLAUDE_PROJECT_DIR: trivialRoot }); + const hso = (parseOutput(result.stdout) || {}).hookSpecificOutput || {}; + assert.strictEqual(hso.permissionDecision, 'deny', `subagent: ${result.stdout}`); + clearState(); + const odd = trivialRun('MultiEdit', { file_path: 'src/mt3.js', edits: [null, 7, { old_string: 'g(1);', new_string: 'g(2);' }] }); + assert.strictEqual(odd.decision, 'deny', `malformed entries: ${odd.result.stdout}`); + }) + ) + passed++; + else failed++; + + clearState(); + if ( + test('the trivial pass comes before the denial cap and never consumes it', () => { + const out = trivialRun('Edit', { file_path: 'src/cap1.js', old_string: '// a', new_string: '// b' }, { GATEGUARD_FACT_FORCE_MAX_DENIALS: '0' }); + assert.ok(out.context.includes(TRIVIAL_NOTE), out.result.stdout); + const state = trivialState(); + assert.strictEqual(state.cap_allows || 0, 0); + assert.strictEqual(state.trivial_allows, 1); + }) + ) + passed++; + else failed++; + + clearState(); + if ( + test('trivial_allows merges by maximum with a concurrent state file', () => { + writeState({ checked: [], last_active: Date.now(), trivial_allows: 5 }); + trivialRun('Edit', { file_path: 'src/merge1.js', old_string: '// a', new_string: '// b' }); + assert.strictEqual(trivialState().trivial_allows, 6); + writeState({ checked: [], last_active: Date.now(), trivial_allows: 'junk' }); + trivialRun('Edit', { file_path: 'src/merge2.js', old_string: '// a', new_string: '// b' }); + assert.strictEqual(trivialState().trivial_allows, 1, 'malformed counter reads as zero'); + }) + ) + passed++; + else failed++; + + clearState(); + if ( + test('scripts/hooks/x.js is code, not instruction', () => { + const reason = classDenialReason('Write', 'scripts/hooks/x.js'); + assert.ok(reason.includes('call this new file'), 'code Write questions'); + assert.ok(!reason.includes('harness/loader'), 'not instruction questions'); + }) + ) + passed++; + else failed++; + + clearState(); + if ( + test('MultiEdit on a .md entry uses the prose Edit variant', () => { + const reason = classDenialReason('MultiEdit', 'docs/multi.md'); + assertFrame(reason, 'editing', 'docs/multi.md'); + assert.ok(reason.includes('reference the section being changed'), 'prose Edit questions'); + assertNoCodeQuestions(reason); + }) + ) + passed++; + else failed++; + + clearState(); + if ( + test('condensed denial for .md after the budget carries the prose hint and ordinal', () => { + const env = { GATEGUARD_FACT_FORCE_FULL_DENIALS: '0' }; + const writeReason = classDenialReason('Write', 'docs/late.md', env); + assert.ok(!writeReason.includes('\n'), 'condensed denial is one line'); + assert.ok(writeReason.includes('(denial #1 this session) First creation of docs/late.md: '), 'keeps ordinal and target'); + assert.ok(writeReason.includes('briefly state what this supersedes, where it is linked from'), 'prose Write hint'); + assert.ok(!writeReason.includes('importers/callers'), 'no code hint'); + assert.ok(writeReason.includes('parallel batch'), 'keeps batch-sibling warning'); + assert.ok(writeReason.includes('GATEGUARD_EXEMPT_GLOBS') && writeReason.includes('ECC_GATEGUARD=off'), 'keeps exemption hint'); + const editReason = classDenialReason('Edit', 'docs/late-edit.md', env); + assert.ok(editReason.includes('(denial #2 this session) First edit of docs/late-edit.md: '), 'ordinal advances'); + assert.ok(editReason.includes('what references the changed section'), 'prose Edit hint'); + const codeReason = classDenialReason('Edit', 'src/late.rb', env); + assert.ok(codeReason.includes('briefly state importers/callers, affected API, data schemas if any'), 'code hint unchanged'); + }) + ) + passed++; + else failed++; + + clearState(); + if ( + test('class denials still sanitize the path', () => { + const reason = classDenialReason('Write', 'docs/hid\u200bden.md'); + assert.ok(!reason.includes('\u200b'), 'zero-width space stripped'); + assert.ok(reason.includes('supersedes or duplicates'), 'still prose questions'); + }) + ) + passed++; + else failed++; + + // --- Prior-search credit in the current human turn --- + const creditRoot = '/proj-credit'; + // An ambient CLAUDE_TRANSCRIPT_PATH must not leak into cases that pass no transcript. + const creditEnv = { CLAUDE_PROJECT_DIR: creditRoot, CLAUDE_TRANSCRIPT_PATH: '' }; + const transcriptDir = fs.mkdtempSync(path.join(tmpRoot, 'gateguard-transcripts-')); + const creditOutputs = []; + let transcriptSeq = 0; + let uuidSeq = 0; + const nextUuid = () => `uuid-${++uuidSeq}`; + const humanRecord = (text, extra = {}) => + ({ type: 'user', uuid: nextUuid(), message: { role: 'user', content: text }, ...extra }); + // Real assistant records carry message.id; a search without one is never credited. + const toolUseRecord = (id, name, input, extra = {}) => ({ + type: 'assistant', + uuid: nextUuid(), + message: { id: `msg_${id}`, role: 'assistant', content: [{ type: 'tool_use', id, name, input }] }, + ...extra + }); + const toolResultRecord = (id, isError = false, extra = {}) => ({ + type: 'user', + uuid: nextUuid(), + message: { role: 'user', content: [{ type: 'tool_result', tool_use_id: id, is_error: isError, content: 'ok' }] }, + ...extra + }); + const searchRecords = (id, name, input) => [toolUseRecord(id, name, input), toolResultRecord(id)]; + // Like Claude Code, a fixture ends with the pending call's own assistant record and runners pass its id. + const pendingToolUseIds = new Map(); + const writeTranscript = (records, { pending = true } = {}) => { + transcriptSeq += 1; + const file = path.join(transcriptDir, `t-${transcriptSeq}.jsonl`); + const all = records.slice(); + if (pending) { + const id = `toolu_pending_${transcriptSeq}`; + all.push({ + type: 'assistant', + uuid: nextUuid(), + message: { id: `msg_pending_${transcriptSeq}`, role: 'assistant', content: [{ type: 'tool_use', id, name: 'Edit', input: {} }] } + }); + pendingToolUseIds.set(file, id); + } + const lines = all.map(r => (typeof r === 'string' ? r : JSON.stringify(r))); + fs.writeFileSync(file, lines.join('\n') + '\n', 'utf8'); + return file; + }; + const creditRun = (toolName, toolInput, transcriptPath, env = {}) => { + const payload = { tool_name: toolName, tool_input: toolInput, cwd: creditRoot }; + if (transcriptPath !== undefined) payload.transcript_path = transcriptPath; + if (pendingToolUseIds.has(transcriptPath)) payload.tool_use_id = pendingToolUseIds.get(transcriptPath); + const result = runHook(payload, { ...creditEnv, ...env }); + creditOutputs.push(result.stdout); + const output = parseOutput(result.stdout); + const hso = output && output.hookSpecificOutput ? output.hookSpecificOutput : {}; + return { result, decision: hso.permissionDecision, context: hso.additionalContext || '', reason: hso.permissionDecisionReason || '' }; + }; + const creditEdit = (file_path, transcriptPath, env) => + creditRun('Edit', { file_path, old_string: 'a', new_string: 'b' }, transcriptPath, env); + const creditWrite = (file_path, transcriptPath, env) => creditRun('Write', { file_path, content: 'x' }, transcriptPath, env); + const readState = () => (fs.existsSync(stateFile) ? JSON.parse(fs.readFileSync(stateFile, 'utf8')) : {}); + const gateCase = (name, fn) => { + clearState(); + if (test(name, fn)) passed++; + else failed++; + }; + const assertNotCredited = (out, label) => { + assert.strictEqual(out.decision, 'deny', `${label}: expected deny`); + assert.ok(!out.context.includes('Prior search seen'), `${label}: no credit note`); + }; + const assertCredited = (out, label) => { + assert.notStrictEqual(out.decision, 'deny', `${label}: must not deny`); + assert.ok(out.context.includes('Prior search seen in this turn'), `${label}: credit note expected, got ${out.result.stdout}`); + }; + + gateCase('Grep stem in this turn credits the first Edit (note, no denial counted)', () => { + const t = writeTranscript([ + humanRecord('please fix the widget factory'), + ...searchRecords('toolu_g1', 'Grep', { pattern: 'widget_factory', path: creditRoot }) + ]); + const out = creditEdit(`${creditRoot}/src/widget_factory.py`, t); + assertCredited(out, 'Grep stem'); + assert.ok(out.context.includes('(Grep widget_factory, 1 tool call ago)'), out.context); + assert.ok(out.context.includes(`first-touch check satisfied for ${creditRoot}/src/widget_factory.py`), out.context); + const state = readState(); + assert.ok(!state.fact_force_denials, 'denial count unchanged'); + assert.strictEqual(state.fact_force_credited, 1, 'credit counted'); + assert.ok(state.checked.includes(stateKey(`${creditRoot}/src/widget_factory.py`)), 'canonical key marked checked'); + const again = creditEdit(`${creditRoot}/src/widget_factory.py`, t); + assert.notStrictEqual(again.decision, 'deny'); + assert.strictEqual(again.context, '', 'already checked: plain allow, no second note'); + assert.strictEqual(readState().fact_force_credited, 1, 'no double credit'); + }); + + gateCase('Glob directory credits a Write of a new file there', () => { + const t = writeTranscript([ + humanRecord('add a component'), + ...searchRecords('toolu_gl', 'Glob', { pattern: 'src/components/*.tsx', path: creditRoot }) + ]); + assertCredited(creditWrite(`${creditRoot}/src/components/NewThing.tsx`, t), 'Glob dir'); + assert.ok(!readState().fact_force_denials); + }); + + gateCase('LS directory credits a Write there', () => { + const t = writeTranscript([humanRecord('add a page'), ...searchRecords('toolu_ls', 'LS', { path: `${creditRoot}/docs` })]); + assertCredited(creditWrite(`${creditRoot}/docs/brand-new-page.md`, t), 'LS dir'); + }); + + gateCase('Bash rg stem credits an Edit', () => { + const t = writeTranscript([humanRecord('fix it'), ...searchRecords('toolu_b1', 'Bash', { command: 'cd /proj-credit && rg foo_bar src' })]); + const out = creditEdit(`${creditRoot}/src/foo_bar.js`, t); + assertCredited(out, 'Bash rg'); + assert.ok(out.context.includes('(Bash rg foo_bar src,'), out.context); + }); + + gateCase('PowerShell Get-ChildItem stem credits an Edit', () => { + const t = writeTranscript([ + humanRecord('fix it'), + ...searchRecords('toolu_p1', 'PowerShell', { command: 'Get-ChildItem -Recurse -Filter *foo_bar*' }) + ]); + assertCredited(creditEdit(`${creditRoot}/src/foo_bar.ps1`, t), 'PowerShell gci'); + }); + + gateCase('git grep qualifies, git log does not', () => { + const ok = writeTranscript([humanRecord('x'), ...searchRecords('toolu_gg', 'Bash', { command: 'git grep -n foo_bar' })]); + assertCredited(creditEdit(`${creditRoot}/src/foo_bar.js`, ok), 'git grep'); + const no = writeTranscript([humanRecord('x'), ...searchRecords('toolu_gl2', 'Bash', { command: 'git log -- src/baz_qux.js' })]); + assertNotCredited(creditEdit(`${creditRoot}/src/baz_qux.js`, no), 'git log'); + }); + + gateCase('stem only in a non-search shell segment does not credit', () => { + const t = writeTranscript([humanRecord('x'), ...searchRecords('toolu_b2', 'Bash', { command: 'echo foo_bar | grep something; ls' })]); + assertNotCredited(creditEdit(`${creditRoot}/src/foo_bar.js`, t), 'stem in echo segment'); + }); + + gateCase('command substitution in a search command is ambiguous (no credit)', () => { + const t = writeTranscript([humanRecord('x'), ...searchRecords('toolu_b3', 'Bash', { command: 'rg $(echo foo_bar) src' })]); + assertNotCredited(creditEdit(`${creditRoot}/src/foo_bar.js`, t), 'substitution'); + }); + + gateCase('cd before a search disables directory credit (stem still counts)', () => { + const t = writeTranscript([humanRecord('x'), ...searchRecords('toolu_cd', 'Bash', { command: 'cd /elsewhere && ls docs' })]); + assertNotCredited(creditWrite(`${creditRoot}/docs/brand-new-page.md`, t), 'cd then ls dir'); + }); + + gateCase('a search in the same assistant batch as the pending call never credits', () => { + const batch = (id, name, input) => ({ + type: 'assistant', + uuid: nextUuid(), + message: { id: 'msg_batch', role: 'assistant', content: [{ type: 'tool_use', id, name, input }] } + }); + const t = writeTranscript([ + humanRecord('x'), + batch('toolu_bg', 'Grep', { pattern: 'widget_factory' }), + batch('toolu_be', 'Edit', { file_path: `${creditRoot}/src/widget_factory.py` }), + toolResultRecord('toolu_bg') + ]); + const input = { file_path: `${creditRoot}/src/widget_factory.py`, old_string: 'a', new_string: 'b' }; + const same = runHook({ tool_name: 'Edit', tool_use_id: 'toolu_be', cwd: creditRoot, transcript_path: t, tool_input: input }, creditEnv); + creditOutputs.push(same.stdout); + assert.strictEqual(parseOutput(same.stdout).hookSpecificOutput.permissionDecision, 'deny', 'same batch denied'); + clearState(); + const later = writeTranscript([ + humanRecord('x'), + batch('toolu_bg2', 'Grep', { pattern: 'widget_factory' }), + toolResultRecord('toolu_bg2'), + { type: 'assistant', uuid: nextUuid(), message: { id: 'msg_next', role: 'assistant', content: [{ type: 'tool_use', id: 'toolu_be2', name: 'Edit', input }] } } + ]); + const next = runHook({ tool_name: 'Edit', tool_use_id: 'toolu_be2', cwd: creditRoot, transcript_path: later, tool_input: input }, creditEnv); + creditOutputs.push(next.stdout); + assert.ok(next.stdout.includes('Prior search seen'), 'search from an earlier message credits'); + }); + + gateCase('no search in the turn -> deny', () => { + const t = writeTranscript([humanRecord('edit it'), ...searchRecords('toolu_r0', 'Read', { file_path: `${creditRoot}/other.js` })]); + const out = creditEdit(`${creditRoot}/src/widget_factory.py`, t); + assertNotCredited(out, 'no search'); + assert.strictEqual(readState().fact_force_denials, 1); + assert.ok(!readState().fact_force_credited, 'no credit counted on deny'); + }); + + gateCase('search tool_use without a tool_result (same batch) -> deny', () => { + const t = writeTranscript([humanRecord('x'), toolUseRecord('toolu_pending', 'Grep', { pattern: 'widget_factory' })]); + assertNotCredited(creditEdit(`${creditRoot}/src/widget_factory.py`, t), 'pending search'); + }); + + gateCase('search whose tool_result is_error -> deny', () => { + const t = writeTranscript([ + humanRecord('x'), + toolUseRecord('toolu_err', 'Grep', { pattern: 'widget_factory' }), + toolResultRecord('toolu_err', true) + ]); + assertNotCredited(creditEdit(`${creditRoot}/src/widget_factory.py`, t), 'errored search'); + }); + + gateCase('search before the latest human message -> deny', () => { + const t = writeTranscript([ + humanRecord('first request'), + ...searchRecords('toolu_old', 'Grep', { pattern: 'widget_factory' }), + humanRecord('second request') + ]); + assertNotCredited(creditEdit(`${creditRoot}/src/widget_factory.py`, t), 'previous turn search'); + }); + + gateCase('tool_result user records do not reset the turn', () => { + const t = writeTranscript([ + humanRecord('x'), + ...searchRecords('toolu_s1', 'Grep', { pattern: 'widget_factory' }), + ...searchRecords('toolu_s2', 'Read', { file_path: `${creditRoot}/a.js` }), + ...searchRecords('toolu_s3', 'Bash', { command: 'npm test' }) + ]); + const out = creditEdit(`${creditRoot}/src/widget_factory.py`, t); + assertCredited(out, 'tool_results between'); + assert.ok(out.context.includes('3 tool calls ago'), out.context); + }); + + gateCase('isMeta and sidechain user records are not turn boundaries', () => { + const t = writeTranscript([ + humanRecord('x'), + ...searchRecords('toolu_m1', 'Grep', { pattern: 'widget_factory' }), + humanRecord('meta', { isMeta: true }), + humanRecord('subagent prompt', { isSidechain: true }) + ]); + assertCredited(creditEdit(`${creditRoot}/src/widget_factory.py`, t), 'meta/sidechain boundary'); + }); + + gateCase('sidechain search records are ignored', () => { + const t = writeTranscript([ + humanRecord('x'), + toolUseRecord('toolu_sc', 'Grep', { pattern: 'widget_factory' }, { isSidechain: true }), + toolResultRecord('toolu_sc', false, { isSidechain: true }) + ]); + assertNotCredited(creditEdit(`${creditRoot}/src/widget_factory.py`, t), 'sidechain search'); + }); + + gateCase('Read of the target never credits an Edit', () => { + const t = writeTranscript([ + humanRecord('x'), + ...searchRecords('toolu_rd', 'Read', { file_path: `${creditRoot}/src/widget_factory.py` }) + ]); + assertNotCredited(creditEdit(`${creditRoot}/src/widget_factory.py`, t), 'Read target'); + }); + + gateCase('directory match does not credit an Edit (stem only)', () => { + const t = writeTranscript([humanRecord('x'), ...searchRecords('toolu_ls2', 'LS', { path: `${creditRoot}/src` })]); + assertNotCredited(creditEdit(`${creditRoot}/src/widget_factory.py`, t), 'LS dir for Edit'); + }); + + gateCase('generic stems (index.js) are not credited', () => { + const t = writeTranscript([humanRecord('x'), ...searchRecords('toolu_ix', 'Grep', { pattern: 'index' })]); + assertNotCredited(creditEdit(`${creditRoot}/src/index.js`, t), 'generic stem'); + }); + + gateCase('stems shorter than 4 characters are not credited', () => { + const t = writeTranscript([humanRecord('x'), ...searchRecords('toolu_ab', 'Grep', { pattern: 'abc' })]); + assertNotCredited(creditEdit(`${creditRoot}/src/abc.js`, t), 'short stem'); + }); + + gateCase('missing or non-file transcript -> deny', () => { + assertNotCredited(creditEdit(`${creditRoot}/src/widget_factory.py`, path.join(transcriptDir, 'nope.jsonl')), 'missing file'); + assertNotCredited(creditEdit(`${creditRoot}/src/other_widget.py`, transcriptDir), 'directory path'); + assertNotCredited(creditEdit(`${creditRoot}/src/third_widget.py`), 'no transcript_path'); + }); + + gateCase('garbage lines -> deny; garbage around a valid turn is skipped', () => { + const junk = writeTranscript(['not json', '{"type":', '{"type":"assistant","message":null}', '[1,2]', 'null']); + assertNotCredited(creditEdit(`${creditRoot}/src/widget_factory.py`, junk), 'garbage only'); + const mixed = writeTranscript([ + 'garbage {', + humanRecord('x'), + '{"broken":', + ...searchRecords('toolu_gm', 'Grep', { pattern: 'other_widget' }), + '\u0000\u0001' + ]); + assertCredited(creditEdit(`${creditRoot}/src/other_widget.py`, mixed), 'garbage around valid turn'); + }); + + const fillerRecords = prefix => { + const filler = []; + for (let i = 0; i < 40; i++) { + const id = `toolu_${prefix}${i}`; + filler.push(toolUseRecord(id, 'Bash', { command: 'npm test' })); + filler.push({ + type: 'user', + message: { role: 'user', content: [{ type: 'tool_result', tool_use_id: id, content: 'y'.repeat(10 * 1024) }] } + }); + } + return filler; + }; + + gateCase('tail truncated, no boundary in window, search + result inside -> credited (turn id null)', () => { + const t = writeTranscript([ + humanRecord('long ago'), + ...fillerRecords('fill'), + ...searchRecords('toolu_late', 'Grep', { pattern: 'widget_factory' }) + ]); + assert.ok(fs.statSync(t).size > 300 * 1024, 'fixture exceeds the tail window'); + const { scanCurrentTurn } = loadDirectHook(); + const scan = scanCurrentTurn(t); + assert.strictEqual(scan.turnId, null, 'no boundary: no turn id'); + assert.ok(scan.searches.some(s => s.name === 'Grep'), 'search inside the window is collected'); + assertCredited(creditEdit(`${creditRoot}/src/widget_factory.py`, t), 'clipped window is the current turn'); + }); + + gateCase('tail truncated, search tool_use before the window, result inside -> not credited', () => { + const t = writeTranscript([ + humanRecord('long ago'), + toolUseRecord('toolu_early', 'Grep', { pattern: 'widget_factory' }), + ...fillerRecords('fill2'), + toolResultRecord('toolu_early') + ]); + assert.ok(fs.statSync(t).size > 300 * 1024, 'fixture exceeds the tail window'); + assertNotCredited(creditEdit(`${creditRoot}/src/widget_factory.py`, t), 'tool_use outside the window'); + }); + + gateCase('whole file scanned with no boundary -> no credit', () => { + const t = writeTranscript([...searchRecords('toolu_nob', 'Grep', { pattern: 'widget_factory' })]); + assertNotCredited(creditEdit(`${creditRoot}/src/widget_factory.py`, t), 'no human record at all'); + }); + + gateCase('MultiEdit credits per entry; first uncredited entry is denied', () => { + const t = writeTranscript([humanRecord('x'), ...searchRecords('toolu_me', 'Grep', { pattern: 'alpha_mod' })]); + const out = creditRun( + 'MultiEdit', + { + edits: [ + { file_path: `${creditRoot}/src/alpha_mod.js`, old_string: 'a', new_string: 'b' }, + { file_path: `${creditRoot}/src/beta_mod.js`, old_string: 'a', new_string: 'b' } + ] + }, + t + ); + assert.strictEqual(out.decision, 'deny'); + assert.ok(out.reason.includes('beta_mod.js'), 'uncredited entry is the one denied'); + const state = readState(); + assert.ok(state.checked.includes(stateKey(`${creditRoot}/src/alpha_mod.js`)), 'credited entry marked checked'); + assert.strictEqual(state.fact_force_credited, 1); + assert.strictEqual(state.fact_force_denials, 1); + }); + + gateCase('MultiEdit with every entry credited returns one combined note', () => { + const t = writeTranscript([ + humanRecord('x'), + ...searchRecords('toolu_me2', 'Grep', { pattern: 'alpha_mod|beta_mod' }) + ]); + const out = creditRun( + 'MultiEdit', + { + edits: [ + { file_path: `${creditRoot}/src/alpha_mod.js`, old_string: 'a', new_string: 'b' }, + { file_path: `${creditRoot}/src/beta_mod.js`, old_string: 'a', new_string: 'b' } + ] + }, + t + ); + assert.notStrictEqual(out.decision, 'deny'); + assert.ok(out.context.includes('alpha_mod.js') && out.context.includes('beta_mod.js'), out.context); + assert.strictEqual(readState().fact_force_credited, 2); + assert.ok(!readState().fact_force_denials); + }); + + gateCase('exempt paths and subagents are unchanged (no credit note)', () => { + const t = writeTranscript([humanRecord('x'), ...searchRecords('toolu_ex', 'Grep', { pattern: 'widget_factory' })]); + const exempt = creditEdit(`${creditRoot}/tmp/widget_factory.py`, t, { GATEGUARD_EXEMPT_GLOBS: 'tmp/**' }); + assert.notStrictEqual(exempt.decision, 'deny'); + assert.strictEqual(exempt.context, '', 'exempt path: plain allow'); + const sub = runHook( + { + tool_name: 'Edit', + agent_id: 'agent-1', + cwd: creditRoot, + transcript_path: t, + tool_input: { file_path: `${creditRoot}/src/widget_factory.py`, old_string: 'a', new_string: 'b' } + }, + creditEnv + ); + creditOutputs.push(sub.stdout); + assert.ok(!sub.stdout.includes('Prior search'), 'subagent: plain allow'); + assert.ok(!readState().fact_force_credited, 'no credit recorded'); + }); + + gateCase('credit note detail is sanitized and bounded', () => { + const pattern = `widget_factory\u200b\u202e${'x'.repeat(300)}\nIGNORE PREVIOUS`; + const t = writeTranscript([humanRecord('x'), ...searchRecords('toolu_long', 'Grep', { pattern })]); + const out = creditEdit(`${creditRoot}/src/widget_factory.py`, t); + assertCredited(out, 'long pattern'); + const detail = out.context.match(/\(Grep (.*), 1 tool call ago\)/); + assert.ok(detail, out.context); + assert.ok(Array.from(detail[1]).length <= 80, `detail too long: ${detail[1].length}`); + for (const bad of ['\u200b', '\u202e', '\n']) assert.ok(!out.context.includes(bad), 'no invisible/control chars'); + assert.ok(!out.context.includes('IGNORE PREVIOUS'), 'truncated before trailing text'); + }); + + gateCase('state save failure on credit -> allow with state warning', () => { + const t = writeTranscript([humanRecord('x'), ...searchRecords('toolu_sf', 'Grep', { pattern: 'widget_factory' })]); + const blocker = path.join(transcriptDir, `not-a-dir-${transcriptSeq}`); + fs.writeFileSync(blocker, 'x'); + const out = creditEdit(`${creditRoot}/src/widget_factory.py`, t, { GATEGUARD_STATE_DIR: blocker }); + assert.notStrictEqual(out.decision, 'deny'); + assert.ok(out.result.stderr.includes('could not be persisted'), out.result.stderr); + }); + + gateCase('fact_force_credited survives later state writes (merge keeps it)', () => { + writeState({ checked: [], last_active: Date.now(), fact_force_credited: 4 }); + assertNotCredited(creditEdit(`${creditRoot}/src/widget_factory.py`), 'deny path'); + assert.strictEqual(readState().fact_force_credited, 4, 'denial write keeps credited count'); + assert.strictEqual(readState().fact_force_denials, 1); + }); + + gateCase('50 MB transcript is scanned from the tail only (<= 256 KiB read)', () => { + const big = path.join(transcriptDir, 'big.jsonl'); + const bigSize = 50 * 1024 * 1024; + const tail = [humanRecord('now'), ...searchRecords('toolu_big', 'Grep', { pattern: 'widget_factory' })]; + for (let i = 0; i < 50; i++) tail.push(...searchRecords(`toolu_t${i}`, 'Read', { file_path: `${creditRoot}/f${i}.js` })); + // Sparse 50 MB body (ftruncate) + the real tail: cheap to build, same shape for the hook. + const fd = fs.openSync(big, 'w'); + try { + fs.writeSync(fd, JSON.stringify(humanRecord('ancient')) + '\n'); + fs.ftruncateSync(fd, bigSize); + fs.writeSync(fd, '\n' + tail.map(r => JSON.stringify(r)).join('\n') + '\n', bigSize); + } finally { + fs.closeSync(fd); + } + const savedRoot = process.env.CLAUDE_PROJECT_DIR; + const savedTranscript = process.env.CLAUDE_TRANSCRIPT_PATH; + const originalOpenSync = fs.openSync; + const originalReadSync = fs.readSync; + const bigFds = new Set(); + let bytesRead = 0; + let result; + let ms; + try { + assert.ok(fs.statSync(big).size > bigSize, 'fixture is > 50 MB'); + const hook = loadDirectHook({ CLAUDE_PROJECT_DIR: creditRoot, CLAUDE_TRANSCRIPT_PATH: '' }); + const payload = { + tool_name: 'Edit', + cwd: creditRoot, + transcript_path: big, + tool_input: { file_path: `${creditRoot}/src/widget_factory.py`, old_string: 'a', new_string: 'b' } + }; + fs.openSync = function countingOpenSync(target) { + const opened = originalOpenSync.apply(fs, arguments); + if (String(target) === big) bigFds.add(opened); + return opened; + }; + fs.readSync = function countingReadSync(readFd) { + const n = originalReadSync.apply(fs, arguments); + if (bigFds.has(readFd)) bytesRead += n; + return n; + }; + const start = process.hrtime.bigint(); + result = hook.run(payload); + ms = Number(process.hrtime.bigint() - start) / 1e6; + } finally { + fs.openSync = originalOpenSync; + fs.readSync = originalReadSync; + if (savedRoot === undefined) delete process.env.CLAUDE_PROJECT_DIR; + else process.env.CLAUDE_PROJECT_DIR = savedRoot; + if (savedTranscript === undefined) delete process.env.CLAUDE_TRANSCRIPT_PATH; + else process.env.CLAUDE_TRANSCRIPT_PATH = savedTranscript; + fs.rmSync(big, { force: true }); + } + console.log(` (run() on 50 MB transcript: ${bytesRead} bytes read, ${ms.toFixed(1)} ms)`); + assert.ok(result && String(result.additionalContext || '').includes('Prior search seen'), 'credited from the tail'); + assert.ok(bigFds.size >= 1 && bytesRead > 0, 'transcript was read through fs.readSync'); + assert.ok(bytesRead <= 256 * 1024, `read ${bytesRead} bytes; must stay within the 256 KiB tail`); + assert.ok(ms < 1000, `wall-clock backstop: run() took ${ms.toFixed(1)} ms`); + }); + + gateCase('no prior-search credit output carries permissionDecision "allow"', () => { + assert.ok(creditOutputs.length > 20, 'collected outputs'); + for (const stdout of creditOutputs) { + const output = parseOutput(stdout); + const decision = output && output.hookSpecificOutput ? output.hookSpecificOutput.permissionDecision : undefined; + assert.notStrictEqual(decision, 'allow', stdout); + } + }); + + // --- Denials name the closest search that did not count --- + const MISS_PREFIX = 'Closest search this turn did not count'; + const missLine = reason => reason.split('\n').find(line => line.startsWith(MISS_PREFIX)) || ''; + + gateCase('a denial names a search that looked outside the file directory', () => { + const t = writeTranscript([humanRecord('x'), ...searchRecords('toolu_o1', 'Grep', { pattern: 'widget_factory', path: `${creditRoot}/docs` })]); + const out = creditEdit(`${creditRoot}/src/widget_factory.py`, t); + assertNotCredited(out, 'out of scope'); + const line = missLine(out.reason); + assert.ok(line.includes('(Grep widget_factory)'), line || out.reason); + assert.ok(line.includes("its search path does not contain this file"), line); + }); + + gateCase('a denial names a search whose filters excluded the file', () => { + const t = writeTranscript([humanRecord('x'), ...searchRecords('toolu_x1', 'Bash', { command: "rg -g '!*.py' widget_factory ." })]); + const out = creditEdit(`${creditRoot}/src/widget_factory.py`, t); + assertNotCredited(out, 'excluded'); + assert.ok(missLine(out.reason).includes('its filters exclude this file'), out.reason); + }); + + gateCase('a denial names a search sent in the same batch', () => { + const batch = (id, name, input) => ({ + type: 'assistant', + uuid: nextUuid(), + message: { id: 'msg_b4batch', role: 'assistant', content: [{ type: 'tool_use', id, name, input }] } + }); + const t = writeTranscript([ + humanRecord('x'), + ...searchRecords('toolu_far', 'Grep', { pattern: 'widget_factory', path: `${creditRoot}/docs` }), + batch('toolu_sb', 'Grep', { pattern: 'widget_factory' }), + batch('toolu_se', 'Edit', { file_path: `${creditRoot}/src/widget_factory.py` }), + toolResultRecord('toolu_sb') + ], { pending: false }); + const input = { file_path: `${creditRoot}/src/widget_factory.py`, old_string: 'a', new_string: 'b' }; + const result = runHook({ tool_name: 'Edit', tool_use_id: 'toolu_se', cwd: creditRoot, transcript_path: t, tool_input: input }, creditEnv); + const reason = parseOutput(result.stdout).hookSpecificOutput.permissionDecisionReason; + const line = missLine(reason); + assert.ok(line.includes('same batch as this call'), `same batch outranks out of scope: ${line}`); + }); + + gateCase('a denial names a search that only read piped input', () => { + const t = writeTranscript([humanRecord('x'), ...searchRecords('toolu_p1', 'Bash', { command: 'git diff | grep widget_factory' })]); + const out = creditEdit(`${creditRoot}/src/widget_factory.py`, t); + assertNotCredited(out, 'stdin'); + assert.ok(missLine(out.reason).includes('it searched piped input, not the tree'), out.reason); + }); + + gateCase('a denial names a Read or a non-search command of the file', () => { + const read = writeTranscript([ + humanRecord('x'), + toolUseRecord('toolu_r1', 'Read', { file_path: `${creditRoot}/src/widget_factory.py` }), + toolResultRecord('toolu_r1') + ]); + const out = creditEdit(`${creditRoot}/src/widget_factory.py`, read); + assertNotCredited(out, 'read'); + const line = missLine(out.reason); + assert.ok(line.includes(`(Read ${creditRoot}/src/widget_factory.py)`), line || out.reason); + assert.ok(line.includes('only Glob, Grep, LS and shell search commands count'), line); + clearState(); + const cat = writeTranscript([humanRecord('x'), ...searchRecords('toolu_c1', 'Bash', { command: 'cat src/widget_factory.py' })]); + assert.ok(missLine(creditEdit(`${creditRoot}/src/widget_factory.py`, cat).reason).includes('(Bash cat src/widget_factory.py)')); + }); + + gateCase('a denial explains that a generic file name never matches', () => { + const t = writeTranscript([humanRecord('x'), ...searchRecords('toolu_g1', 'Grep', { pattern: 'index' })]); + const out = creditEdit(`${creditRoot}/src/index.py`, t); + assertNotCredited(out, 'generic'); + assert.ok(missLine(out.reason).includes('this file name is too generic to match a search'), out.reason); + }); + + gateCase('no closest-search line when nothing in the turn mentions the file', () => { + const t = writeTranscript([humanRecord('x'), ...searchRecords('toolu_n1', 'Grep', { pattern: 'unrelated_thing' })]); + const out = creditEdit(`${creditRoot}/src/widget_factory.py`, t); + assertNotCredited(out, 'unrelated'); + assert.strictEqual(missLine(out.reason), ''); + assert.ok(!out.reason.includes(MISS_PREFIX)); + const none = creditEdit(`${creditRoot}/src/other_file.py`, undefined); + assert.ok(!none.reason.includes(MISS_PREFIX), 'no transcript, no line'); + }); + + gateCase('the closest-search detail is sanitized and bounded', () => { + const noisy = `widget_factory \u202e\u200b${'z'.repeat(200)}\u0007`; + const t = writeTranscript([humanRecord('x'), ...searchRecords('toolu_s1', 'Grep', { pattern: noisy, path: `${creditRoot}/docs` })]); + const out = creditEdit(`${creditRoot}/src/widget_factory.py`, t); + const line = missLine(out.reason); + assert.ok(line, out.reason); + for (const bad of ['\u202e', '\u200b', '\u0007']) assert.ok(!line.includes(bad), `no U+${bad.codePointAt(0).toString(16)}`); + const detail = line.slice(line.indexOf('(Grep ') + 6, line.indexOf('):')); + assert.ok(Array.from(detail).length <= 60, `detail bounded: ${detail.length}`); + assert.ok(detail.endsWith('...'), 'truncation marked'); + }); + + gateCase('sensitive targets never get a closest-search line', () => { + const t = writeTranscript([humanRecord('x'), ...searchRecords('toolu_k1', 'Grep', { pattern: 'login_view', path: `${creditRoot}/docs` })]); + const out = creditEdit(`${creditRoot}/src/auth/login_view.py`, t); + assertNotCredited(out, 'sensitive'); + assert.ok(!out.reason.includes(MISS_PREFIX), out.reason); + }); + + gateCase('condensed denials and MultiEdit denials carry the closest-search line', () => { + const t = writeTranscript([humanRecord('x'), ...searchRecords('toolu_m1', 'Grep', { pattern: 'widget_factory', path: `${creditRoot}/docs` })]); + const condensed = creditEdit(`${creditRoot}/src/widget_factory.py`, t, { GATEGUARD_FACT_FORCE_FULL_DENIALS: '0' }); + assert.ok(!condensed.reason.includes('\n'), 'still one line'); + assert.ok(condensed.reason.includes(`${MISS_PREFIX} (Grep widget_factory)`), condensed.reason); + clearState(); + const multi = creditRun('MultiEdit', { edits: [{ file_path: `${creditRoot}/src/widget_factory.py`, old_string: 'a', new_string: 'b' }] }, t); + assert.ok(missLine(multi.reason).includes('(Grep widget_factory)'), multi.reason); + }); + + // --- Exclusion globs never earn search credit --- + const exclusionTarget = `${creditRoot}/src/widget.py`; + let exclusionSeq = 0; + const exclusionTurn = (name, input) => { + exclusionSeq += 1; + return writeTranscript([humanRecord('fix the widget'), ...searchRecords(`toolu_r2_${exclusionSeq}`, name, input)]); + }; + const exclusionDenied = [ + ['Grep glob !widget.py', 'Grep', { pattern: 'TODO', glob: '!widget.py', path: creditRoot }], + ['Grep glob list with an exclusion', 'Grep', { pattern: 'widget', glob: '*.js,!widget.py', path: creditRoot }], + ['Grep path-style exclusion covering the target', 'Grep', { pattern: 'widget', glob: '!src/**', path: creditRoot }], + ['Grep positive basename glob that misses the target', 'Grep', { pattern: 'widget', glob: '*.md', path: creditRoot }], + ["rg -g '!widget.py'", 'Bash', { command: "rg -g '!widget.py' TODO ." }], + ['rg --glob=!widget.py', 'Bash', { command: 'rg --glob=!widget.py TODO .' }], + ['rg --iglob exclusion, other case', 'Bash', { command: "rg --iglob '!WIDGET.py' TODO ." }], + ['rg -g exclusion of the directory', 'Bash', { command: "rg -g '!src/' widget ." }], + ['rg -g positive glob that misses the target', 'Bash', { command: "rg -g '*.md' widget ." }], + ['grep --exclude=widget.py', 'Bash', { command: 'grep -r --exclude=widget.py TODO .' }], + ['grep --exclude-dir of the target dir', 'Bash', { command: 'grep -rn --exclude-dir=src widget .' }], + ['grep --exclude-from (unknown exclusions)', 'Bash', { command: 'grep -r --exclude-from=skip.txt widget .' }], + ['grep --include that misses the target', 'Bash', { command: 'grep -r --include=*.md widget .' }], + ['find -not -name', 'Bash', { command: 'find . -not -name widget.py' }], + ['find ! -name', 'Bash', { command: 'find . ! -name widget.py -type f' }], + ['find -path ... -prune', 'Bash', { command: 'find . -path ./src/widget.py -prune -o -print' }], + ['find -name that misses the target', 'Bash', { command: 'find . -name widget.md' }], + ['fd -E widget.py', 'Bash', { command: 'fd -E widget.py py src' }], + ['fd --exclude widget.py', 'Bash', { command: 'fd --exclude widget.py py .' }], + ['ls -I widget.py', 'Bash', { command: 'ls -I widget.py src' }], + ["tree -I 'widget*'", 'Bash', { command: "tree -I 'widget*' src" }], + ['Get-ChildItem -Exclude', 'PowerShell', { command: 'Get-ChildItem -Recurse -Exclude widget.py' }], + ['rg brace exclusion', 'Bash', { command: "rg -g '!*.{py,js}' widget ." }], + ['rg deep directory exclusion', 'Bash', { command: "rg -g '!**/src/**' widget ." }], + ['rg character-class exclusion', 'Bash', { command: "rg -g '!w[i]dget.py' TODO ." }], + ['rg exclusion in a short-flag cluster', 'Bash', { command: "rg -ig'!widget.py' TODO ." }], + ['find negated group', 'Bash', { command: 'find . ! ( -name widget.py -o -name x.py ) -print' }], + ['git ls-files -x', 'Bash', { command: 'git ls-files -x widget.py src' }] + ]; + for (const [label, name, input] of exclusionDenied) { + gateCase(`an exclusion never credits the excluded target (${label})`, () => { + assertNotCredited(creditEdit(exclusionTarget, exclusionTurn(name, input)), label); + assert.ok(!readState().fact_force_credited, `${label}: nothing credited`); + }); + } + const exclusionCredited = [ + ["rg -g '*.py' widget", 'Bash', { command: "rg -g '*.py' widget ." }], + ['Grep widget glob *.py', 'Grep', { pattern: 'widget', glob: '*.py', path: creditRoot }], + ["rg -g '!*.md' widget (exclusion does not cover the target)", 'Bash', { command: "rg -g '!*.md' widget ." }], + ['Grep widget glob *.{py,js} (braces are one glob)', 'Grep', { pattern: 'widget', glob: '*.{py,js}', path: creditRoot }], + ['Grep widget glob !node_modules/**', 'Grep', { pattern: 'widget', glob: '!node_modules/**', path: creditRoot }], + ['grep --exclude-dir=node_modules widget', 'Bash', { command: 'grep -r --exclude-dir=node_modules widget .' }], + ['find -name widget.py', 'Bash', { command: 'find . -name widget.py' }], + ['Grep TODO glob widget.py (positive glob names the stem)', 'Grep', { pattern: 'TODO', glob: 'widget.py', path: creditRoot }] + ]; + for (const [label, name, input] of exclusionCredited) { + gateCase(`control still credits (${label})`, () => { + assertCredited(creditEdit(exclusionTarget, exclusionTurn(name, input)), label); + }); + } + + // --- Bracket classes in filter globs match exactly --- + const bracketCases = [ + ['include class of a dot does not admit another character', 'src/fooXjs', "rg -g 'foo[.]js' fooXjs .", false], + ['include class of a dot admits the dot', 'src/widget.py', "rg -g 'widget[.]py' widget .", true], + ['include negated class admits another character', 'src/widget.py', "rg -g 'widge[!x].py' widget .", true], + ['include negated class rejects its member', 'src/widget.py', "rg -g 'widge[!t].py' widget .", false], + ['include caret-negated class rejects its member', 'src/widget.py', "rg -g 'widge[^t].py' widget .", false], + ['include range admits a member', 'src/widget.py', "rg -g 'widge[r-u].py' widget .", true], + ['include range rejects a non-member', 'src/widget.py', "rg -g 'widge[a-f].py' widget .", false], + ['include range is case-insensitive', 'src/widget.py', "rg -g 'widge[R-U].py' widget .", true], + ['include class with a literal ] first', 'src/widget.py', "rg -g 'widge[]t].py' widget .", true], + ['Grep include class of a dot does not admit another character', 'src/fooXjs', { pattern: 'fooXjs', glob: 'foo[.]js' }, false], + ['include with an unclosed class admits nothing', 'src/widget.py', "rg -g 'widget[.py' widget .", false], + ['include with an empty class admits nothing', 'src/widget.py', "rg -g 'widget[].py' widget .", false], + ['exclusion class covering the target blocks it', 'src/widget.py', "rg -g '!widge[t].py' widget .", false], + ['exclusion range covering the target blocks it', 'src/widget.py', "rg -g '!widge[s-u].py' widget .", false], + ['exclusion class of a dot does not cover another character', 'src/widgetXjs', "rg -g '!widget[.]js' widgetXjs .", true], + ['exclusion negated class does not cover its member', 'src/widget.py', "rg -g '!widge[!t].py' widget .", true], + ['exclusion with an unclosed class covers the target', 'src/widget.py', "rg -g '!zzz[' widget .", false], + ['exclusion with an empty negated class covers the target', 'src/widget.py', "rg -g '!zzz[!]' widget .", false] + ]; + for (const [label, rel, search, credited] of bracketCases) { + gateCase(`bracket class: ${label}`, () => { + const input = typeof search === 'string' ? { command: search } : { ...search, path: creditRoot }; + const out = creditEdit(`${creditRoot}/${rel}`, exclusionTurn(typeof search === 'string' ? 'Bash' : 'Grep', input)); + if (credited) assertCredited(out, label); + else assertNotCredited(out, label); + }); + } + + // --- Directory-qualified include globs restrict the search --- + const longInclude = `${'a'.repeat(300)}*.py`; + const pathIncludeCases = [ + ['rg include in another directory', 'lib/fooXjs.js', "rg -g 'src/*.js' fooXjs .", false], + ['rg --glob include in another directory', 'src/widget.py', 'rg --glob=docs/*.py widget .', false], + ['rg recursive include of another directory', 'src/widget.py', "rg -g 'lib/**' widget .", false], + ['rg include anchored at the search root', 'vendor/src/widget.py', "rg -g 'src/*.py' widget .", false], + ['rg include that does not cross a directory', 'src/deep/widget.py', "rg -g 'src/*.py' widget .", false], + ['rg include relative to a narrower search path', 'src/widget.py', "rg -g 'src/*.py' widget src", false], + ['Grep include in another directory', 'src/widget.py', { pattern: 'widget', glob: 'lib/*.py' }, false], + ['grep --include matches base names only', 'src/widget.py', 'grep -r --include=src/*.py widget .', false], + ['find -name with a slash matches nothing', 'src/widget.py', "find . -name 'src/widget.py'", false], + ['PowerShell -Include with a path matches nothing', 'src/widget.py', 'Get-ChildItem -Recurse -Include src/widget.py', false], + ['rg include past the length bound admits nothing', 'src/widget.py', `rg -g '${longInclude}' widget .`, false], + ['rg include of the target directory', 'src/widget.py', "rg -g 'src/*.py' widget .", true], + ['rg include with a leading slash', 'src/widget.py', "rg -g '/src/*.py' widget .", true], + ['rg include with a leading **/', 'vendor/src/widget.py', "rg -g '**/src/*.py' widget .", true], + ['rg recursive include of the target directory', 'src/deep/widget.py', "rg -g 'src/**' widget .", true], + ['rg include relative to the search path', 'src/deep/widget.py', "rg -g 'deep/*.py' widget src", true], + ['Grep include of the target directory', 'src/widget.py', { pattern: 'widget', glob: 'src/**/*.py' }, true] + ]; + for (const [label, rel, search, credited] of pathIncludeCases) { + gateCase(`path include: ${label}`, () => { + const input = typeof search === 'string' ? { command: search } : { ...search, path: creditRoot }; + const tool = typeof search !== 'string' ? 'Grep' : /^Get-ChildItem/.test(search) ? 'PowerShell' : 'Bash'; + const out = creditEdit(`${creditRoot}/${rel}`, exclusionTurn(tool, input)); + if (credited) assertCredited(out, label); + else assertNotCredited(out, label); + }); + } + + // --- The search prefilter never hides a search that names the target --- + const prefilterCases = [ + ["rg wid''get .", true], + ['rg "wid"get .', true], + ['rg WIDGET .', true], + ['rg gadget .', false] + ]; + for (const [command, credited] of prefilterCases) { + gateCase(`search prefilter: ${command}`, () => { + const out = creditEdit(exclusionTarget, exclusionTurn('Bash', { command })); + if (credited) assertCredited(out, command); + else assertNotCredited(out, command); + }); + } + + // --- Every PowerShell spelling of -Exclude is an exclusion --- + const psExcludeDenied = [ + 'Get-ChildItem -Recurse -ex widget.py', + 'Get-ChildItem -Recurse -EXC widget.py', + 'gci -Recurse -Excl widget.py', + 'Get-ChildItem -Recurse -Exclude:widget.py', + 'Get-ChildItem -Recurse -exclu:widget.py', + 'Get-ChildItem -Recurse -Exclude: widget.py', + 'Get-ChildItem -Recurse -Exclude a.py, widget.py', + 'Get-ChildItem -Recurse -Exclude a.py ,widget.py', + 'Get-ChildItem -Recurse -Exclude a.py , widget.py', + 'Get-ChildItem -Recurse -Exclude a.py,widget.py', + 'Get-ChildItem -Recurse -Exclude:a.py, widget.py', + 'Select-String -Pattern TODO -Path src/* -ex widget.py', + 'sls TODO src/* -Exclude a.py, widget.py', + 'Get-ChildItem -Recurse -e:widget.py', + 'Get-ChildItem -Recurse -Nonesuch:widget.py', + 'Get-ChildItem -Recurse -in widget.py' + ]; + for (const command of psExcludeDenied) { + gateCase(`PowerShell exclusion never credits the excluded target (${command})`, () => { + assertNotCredited(creditEdit(exclusionTarget, exclusionTurn('PowerShell', { command })), command); + assert.ok(!readState().fact_force_credited, `${command}: nothing credited`); + }); + } + const psCredited = [ + 'Get-ChildItem -Path src -Recurse -Filter widget.py', + 'Get-ChildItem -Recurse -Filt:widget.py', + 'Get-ChildItem -Recurse -Include a.md, widget.py', + 'Get-ChildItem -Recurse -Exclude a.md, b.md -Filter widget.py', + 'Select-String -Pattern widget -Path src/* -Exclude a.md' + ]; + for (const command of psCredited) { + gateCase(`PowerShell control still credits (${command})`, () => { + assertCredited(creditEdit(exclusionTarget, exclusionTurn('PowerShell', { command })), command); + }); + } + gateCase('PowerShell include lists spread across tokens still filter the target', () => { + const command = 'Get-ChildItem -Recurse -Include a.md, widget.md'; + assertNotCredited(creditEdit(exclusionTarget, exclusionTurn('PowerShell', { command })), command); + }); + + // --- Same-turn sibling creations and counters --- + const projectRoot = fs.realpathSync(fs.mkdtempSync(path.join(tmpRoot, 'gateguard-proj-'))); + const projectEnv = { CLAUDE_PROJECT_DIR: projectRoot, CLAUDE_TRANSCRIPT_PATH: '' }; + const siblingOutputs = []; + const projectRun = (toolName, toolInput, transcriptPath, env = {}) => { + const payload = { tool_name: toolName, tool_input: toolInput, cwd: projectRoot }; + if (transcriptPath !== undefined) payload.transcript_path = transcriptPath; + if (pendingToolUseIds.has(transcriptPath)) payload.tool_use_id = pendingToolUseIds.get(transcriptPath); + const result = runHook(payload, { ...projectEnv, ...env }); + siblingOutputs.push(result.stdout); + const output = parseOutput(result.stdout); + const hso = output && output.hookSpecificOutput ? output.hookSpecificOutput : {}; + return { result, decision: hso.permissionDecision, context: hso.additionalContext || '', reason: hso.permissionDecisionReason || '' }; + }; + const projectWrite = (file_path, transcriptPath, env) => projectRun('Write', { file_path, content: 'x' }, transcriptPath, env); + const projectEdit = (file_path, transcriptPath, env) => + projectRun('Edit', { file_path, old_string: 'a', new_string: 'b' }, transcriptPath, env); + const assertSibling = (out, label) => { + assert.notStrictEqual(out.decision, 'deny', `${label}: must not deny (${out.result.stdout})`); + assert.notStrictEqual(out.decision, 'allow', `${label}: never permissionDecision allow`); + assert.ok(out.context.includes('[Fact-Forcing Gate] Sibling of '), `${label}: sibling note expected, got ${out.result.stdout}`); + }; + const assertDeniedNoSibling = (out, label) => { + assert.strictEqual(out.decision, 'deny', `${label}: expected deny, got ${out.result.stdout}`); + assert.ok(!out.context.includes('Sibling of'), `${label}: no sibling note`); + }; + // dir_gates keys are `\u0000`. + const dirGateKey = (dir, cls = 'code') => `${cls}\u0000${stateKey(dir)}`; + const newTurn = text => { + const human = humanRecord(text); + return { human, transcript: writeTranscript([human]) }; + }; + + gateCase('checked lookups under a simulated Windows root (both spellings share one key)', () => { + const winRoot = 'C:\\Users\\RUNNER~1\\AppData\\Local\\Temp\\gateguard-proj-abc'; + const savedRoot = process.env.CLAUDE_PROJECT_DIR; + const savedTranscript = process.env.CLAUDE_TRANSCRIPT_PATH; + let first; + let second; + try { + // Fixed synthetic paths: keys need no real disk, and the target is absent on any host. + const hook = loadDirectHook({ CLAUDE_PROJECT_DIR: winRoot, CLAUDE_TRANSCRIPT_PATH: '' }); + const write = file_path => hook.run({ tool_name: 'Write', cwd: winRoot, tool_input: { file_path, content: 'x' } }); + first = write(`${winRoot}/src/widgets/w0.js`); + second = write(`${winRoot}\\src\\widgets\\w1.js`); + } finally { + if (savedRoot === undefined) delete process.env.CLAUDE_PROJECT_DIR; + else process.env.CLAUDE_PROJECT_DIR = savedRoot; + if (savedTranscript === undefined) delete process.env.CLAUDE_TRANSCRIPT_PATH; + else process.env.CLAUDE_TRANSCRIPT_PATH = savedTranscript; + } + assert.strictEqual(parseOutput(first.stdout).hookSpecificOutput.permissionDecision, 'deny'); + const state = readState(); + if (process.platform === 'win32') { + assert.ok(second && /Sibling of/.test(String(second.additionalContext || '')), JSON.stringify(second)); + assert.strictEqual(Object.keys(state.dir_gates || {}).length, 1, 'one dir gate for both spellings'); + } else { + assert.strictEqual(parseOutput(second.stdout).hookSpecificOutput.permissionDecision, 'deny', JSON.stringify(second)); + assert.deepStrictEqual(Object.keys(state.dir_gates || {}), [], 'no dir gate for an unresolvable directory'); + } + assert.ok(state.checked.includes(stateKey(`${winRoot}/src/widgets/w0.js`)), `w0 checked: ${JSON.stringify(state.checked)}`); + assert.ok(state.checked.includes(stateKey(`${winRoot}\\src\\widgets\\w1.js`)), 'w1 checked (backslash spelling)'); + }); + + gateCase('8 new files in one dir in the same turn -> 1 deny + 7 sibling notes', () => { + const { human, transcript } = newTurn('scaffold the widgets'); + const first = projectWrite(`${projectRoot}/src/widgets/w0.js`, transcript); + assertDeniedNoSibling(first, 'first file'); + for (let i = 1; i < 8; i++) { + const out = projectWrite(`${projectRoot}/src/widgets/w${i}.js`, transcript); + assertSibling(out, `file ${i}`); + assert.strictEqual( + out.context, + `[Fact-Forcing Gate] Sibling of ${projectRoot}/src/widgets/w0.js (gated earlier at denial #1 this session); proceeding without a repeat denial.` + ); + } + const state = readState(); + assert.strictEqual(state.fact_force_denials, 1, 'one denial'); + assert.strictEqual(state.sibling_allows, 7, 'seven sibling allows'); + assert.ok(state.checked.includes(stateKey(`${projectRoot}/src/widgets/w7.js`)), 'sibling marked checked (canonical key)'); + const gate = state.dir_gates[dirGateKey(`${projectRoot}/src/widgets`)]; + assert.ok(gate, 'dir gate keyed by canonical dir'); + assert.strictEqual(gate.turn, human.uuid); + assert.strictEqual(gate.first, `${projectRoot}/src/widgets/w0.js`); + assert.strictEqual(gate.ordinal, 1); + assert.ok(typeof gate.at === 'number' && gate.at > 0); + const retry = projectWrite(`${projectRoot}/src/widgets/w3.js`, transcript); + assert.strictEqual(retry.decision, undefined); + assert.strictEqual(retry.context, '', 'already checked: plain allow'); + }); + + gateCase('a different directory gets its own denial', () => { + const { transcript } = newTurn('x'); + assertDeniedNoSibling(projectWrite(`${projectRoot}/src/a/one.js`, transcript), 'dir a'); + assertDeniedNoSibling(projectWrite(`${projectRoot}/src/b/two.js`, transcript), 'dir b'); + assertDeniedNoSibling(projectWrite(`${projectRoot}/src/a/nested/three.js`, transcript), 'subdirectory is not a sibling'); + assertSibling(projectWrite(`${projectRoot}/src/b/four.js`, transcript), 'dir b sibling'); + }); + + gateCase('the next human turn in the same dir is denied again', () => { + const turn1 = humanRecord('first'); + const t1 = writeTranscript([turn1]); + assertDeniedNoSibling(projectWrite(`${projectRoot}/src/w/one.js`, t1), 'turn 1'); + const t2 = writeTranscript([turn1, humanRecord('second')]); + assertDeniedNoSibling(projectWrite(`${projectRoot}/src/w/two.js`, t2), 'turn 2'); + assertSibling(projectWrite(`${projectRoot}/src/w/three.js`, t2), 'turn 2 sibling'); + assert.ok(projectWrite(`${projectRoot}/src/w/three.js`, t2).context === '', 'checked'); + assert.strictEqual(readState().dir_gates[dirGateKey(`${projectRoot}/src/w`)].ordinal, 2, 'latest denial ordinal recorded'); + }); + + gateCase('a Write over an existing file in the gated dir is still denied (per-file)', () => { + fs.mkdirSync(path.join(projectRoot, 'src', 'e'), { recursive: true }); + fs.writeFileSync(path.join(projectRoot, 'src', 'e', 'existing.js'), 'old'); + const { transcript } = newTurn('x'); + assertDeniedNoSibling(projectWrite(`${projectRoot}/src/e/new.js`, transcript), 'new file'); + assertDeniedNoSibling(projectWrite(`${projectRoot}/src/e/existing.js`, transcript), 'existing file'); + assert.ok(!readState().sibling_allows, 'no sibling allow'); + }); + + gateCase('an existing-file Write denial does not open a dir gate', () => { + fs.mkdirSync(path.join(projectRoot, 'src', 'f'), { recursive: true }); + fs.writeFileSync(path.join(projectRoot, 'src', 'f', 'present.js'), 'old'); + const { transcript } = newTurn('x'); + assertDeniedNoSibling(projectWrite(`${projectRoot}/src/f/present.js`, transcript), 'existing file'); + assertDeniedNoSibling(projectWrite(`${projectRoot}/src/f/brand_new.js`, transcript), 'new file after existing'); + }); + + gateCase('fs error resolving the target -> treated as existing (no collapse)', () => { + fs.mkdirSync(path.join(projectRoot, 'src'), { recursive: true }); + fs.writeFileSync(path.join(projectRoot, 'src', 'blocker'), 'not a dir'); + const { transcript } = newTurn('x'); + assertDeniedNoSibling(projectWrite(`${projectRoot}/src/blocker/one.js`, transcript), 'ENOTDIR first'); + if (process.platform === 'win32') return; + assertDeniedNoSibling(projectWrite(`${projectRoot}/src/blocker/two.js`, transcript), 'ENOTDIR second'); + assert.ok(!Object.keys(readState().dir_gates || {}).some(k => k.endsWith(`${projectRoot}/src/blocker`)), 'no dir gate recorded'); + }); + + gateCase('Edit and MultiEdit in a gated dir are never collapsed', () => { + const { transcript } = newTurn('x'); + assertDeniedNoSibling(projectWrite(`${projectRoot}/src/m/one.js`, transcript), 'write'); + assertDeniedNoSibling(projectEdit(`${projectRoot}/src/m/two.js`, transcript), 'edit sibling'); + const multi = projectRun( + 'MultiEdit', + { edits: [{ file_path: `${projectRoot}/src/m/three.js`, old_string: 'a', new_string: 'b' }] }, + transcript + ); + assertDeniedNoSibling(multi, 'multiedit sibling'); + assertSibling(projectWrite(`${projectRoot}/src/m/four.js`, transcript), 'write sibling still works'); + }); + + gateCase('an Edit denial does not open a dir gate', () => { + const { transcript } = newTurn('x'); + assertDeniedNoSibling(projectEdit(`${projectRoot}/src/n/one.js`, transcript), 'edit'); + assertDeniedNoSibling(projectWrite(`${projectRoot}/src/n/two.js`, transcript), 'write after edit'); + }); + + gateCase('instruction-class siblings are each denied', () => { + const { transcript } = newTurn('x'); + assertDeniedNoSibling(projectWrite(`${projectRoot}/skills/foo/one.md`, transcript), 'instruction 1'); + assertDeniedNoSibling(projectWrite(`${projectRoot}/skills/foo/two.md`, transcript), 'instruction 2'); + assert.ok(!Object.keys(readState().dir_gates || {}).some(k => k.endsWith(`${projectRoot}/skills/foo`)), 'instruction denial opens no dir gate'); + assertDeniedNoSibling(projectWrite(`${projectRoot}/skills/foo/helper.js`, transcript), 'code file after instruction denials'); + assertDeniedNoSibling(projectWrite(`${projectRoot}/skills/foo/three.md`, transcript), 'instruction after a code dir gate'); + }); + + gateCase('no transcript -> sibling within 120 s, denied after', () => { + assertDeniedNoSibling(projectWrite(`${projectRoot}/src/nt/one.js`), 'first'); + assertSibling(projectWrite(`${projectRoot}/src/nt/two.js`), 'within window'); + const state = readState(); + assert.strictEqual(state.dir_gates[dirGateKey(`${projectRoot}/src/nt`)].turn, null, 'turn null without transcript'); + state.dir_gates[dirGateKey(`${projectRoot}/src/nt`)].at = Date.now() - 121000; + writeState(state); + assertDeniedNoSibling(projectWrite(`${projectRoot}/src/nt/three.js`), 'after window'); + }); + + gateCase('a turn-scoped gate is not matched without a turn id, and vice versa', () => { + const { transcript } = newTurn('x'); + assertDeniedNoSibling(projectWrite(`${projectRoot}/src/tt/one.js`, transcript), 'turn gate'); + assertDeniedNoSibling(projectWrite(`${projectRoot}/src/tt/two.js`), 'no-turn call vs turn gate'); + assertDeniedNoSibling(projectWrite(`${projectRoot}/src/tt/three.js`, transcript), 'turn call vs null-turn gate'); + }); + + gateCase('a prior search credits before the sibling rule', () => { + const human = humanRecord('x'); + const t = writeTranscript([human]); + assertDeniedNoSibling(projectWrite(`${projectRoot}/src/cr/one.js`, t), 'first'); + const t2 = writeTranscript([human, ...searchRecords('toolu_c4g', 'Grep', { pattern: 'widget_factory' })]); + const out = projectWrite(`${projectRoot}/src/cr/widget_factory.js`, t2); + assert.ok(out.context.includes('Prior search seen in this turn'), out.result.stdout); + assert.ok(!out.context.includes('Sibling of')); + assert.ok(!readState().sibling_allows, 'credit is not a sibling allow'); + }); + + gateCase('counters record class names for denial, credit and sibling events', () => { + const human = humanRecord('x'); + const t = writeTranscript([human, ...searchRecords('toolu_c4c', 'Grep', { pattern: 'widget_factory' })]); + assertDeniedNoSibling(projectWrite(`${projectRoot}/src/cc/one.js`, t), 'code deny'); + assertSibling(projectWrite(`${projectRoot}/src/cc/two.js`, t), 'code sibling'); + assertDeniedNoSibling(projectWrite(`${projectRoot}/docs/guide_page.md`, t), 'prose deny'); + assertDeniedNoSibling(projectEdit(`${projectRoot}/tests/a.test.js`, t), 'test deny'); + assertDeniedNoSibling(projectWrite(`${projectRoot}/skills/x/SKILL.md`, t), 'instruction deny'); + assertCredited(projectEdit(`${projectRoot}/lib/widget_factory.js`, t), 'code credit'); + const state = readState(); + assert.deepStrictEqual(state.denials_by_class, { code: 1, prose: 1, test: 1, instruction: 1 }); + assert.deepStrictEqual(state.credited_by_class, { code: 1 }); + assert.strictEqual(state.sibling_allows, 1); + assert.strictEqual(state.fact_force_denials, 4); + assert.strictEqual(state.fact_force_credited, 1); + const json = JSON.stringify({ d: state.denials_by_class, c: state.credited_by_class }); + assert.ok(!json.includes('/'), 'counters hold class names only, never paths'); + }); + + gateCase('a state file without the counter fields loads and gains them', () => { + writeState({ checked: ['/elsewhere/x.js'], last_active: Date.now(), fact_force_denials: 2 }); + const { transcript } = newTurn('x'); + assertDeniedNoSibling(projectWrite(`${projectRoot}/src/old/one.js`, transcript), 'deny'); + assertSibling(projectWrite(`${projectRoot}/src/old/two.js`, transcript), 'sibling'); + const state = readState(); + assert.ok(state.checked.includes('/elsewhere/x.js')); + assert.strictEqual(state.fact_force_denials, 3); + assert.strictEqual(state.dir_gates[dirGateKey(`${projectRoot}/src/old`)].ordinal, 3); + assert.deepStrictEqual(state.denials_by_class, { code: 1 }); + assert.deepStrictEqual(state.credited_by_class, {}); + assert.strictEqual(state.sibling_allows, 1); + assert.strictEqual(state.fact_force_credited, 0); + }); + + gateCase('malformed counter fields are tolerated (treated as empty/zero)', () => { + writeState({ + checked: [], + last_active: Date.now(), + dir_gates: { [dirGateKey(`${projectRoot}/src/bad`)]: 'nope', [dirGateKey(`${projectRoot}/src/bad2`)]: { at: 'x' }, other: [1, 2] }, + denials_by_class: [5], + credited_by_class: { code: -3, prose: 'x', test: 2 }, + sibling_allows: 'many' + }); + assertDeniedNoSibling(projectWrite(`${projectRoot}/src/bad/one.js`), 'malformed gate ignored'); + assertSibling(projectWrite(`${projectRoot}/src/bad/two.js`), 'fresh gate works'); + const state = readState(); + assert.deepStrictEqual(Object.keys(state.dir_gates), [dirGateKey(`${projectRoot}/src/bad`)]); + assert.deepStrictEqual(state.denials_by_class, { code: 1 }); + assert.deepStrictEqual(state.credited_by_class, { test: 2 }); + assert.strictEqual(state.sibling_allows, 1); + writeState({ checked: [], last_active: Date.now(), dir_gates: 'garbage', denials_by_class: null, sibling_allows: -1 }); + assertDeniedNoSibling(projectWrite(`${projectRoot}/src/bad3/one.js`), 'garbage dir_gates'); + assert.strictEqual(readState().sibling_allows, 0); + }); + + gateCase('dir_gates is capped at 50 (oldest evicted)', () => { + const now = Date.now(); + const gates = {}; + for (let i = 0; i < 50; i++) gates[dirGateKey(`/cap/d${i}`)] = { turn: null, at: now - 100000 + i, first: `/cap/d${i}/f.js`, ordinal: i + 1 }; + writeState({ checked: [], last_active: now, dir_gates: gates }); + assertDeniedNoSibling(projectWrite(`${projectRoot}/src/cap/one.js`), 'new gate'); + const state = readState(); + assert.strictEqual(Object.keys(state.dir_gates).length, 50, 'capped at 50'); + assert.ok(state.dir_gates[dirGateKey(`${projectRoot}/src/cap`)], 'newest kept'); + assert.ok(!state.dir_gates[dirGateKey('/cap/d0')], 'oldest evicted'); + assert.ok(state.dir_gates[dirGateKey('/cap/d1')], 'second-oldest kept'); + }); + + gateCase('dir_gate first path is stored sanitized', () => { + const file_path = `${projectRoot}/src/san/evil\u202ename\u200b.js`; + assertDeniedNoSibling(projectWrite(file_path), 'deny'); + const gate = readState().dir_gates[dirGateKey(`${projectRoot}/src/san`)]; + assert.ok(gate && !gate.first.includes('\u202e') && !gate.first.includes('\u200b'), JSON.stringify(gate)); + const out = projectWrite(`${projectRoot}/src/san/ok.js`); + assertSibling(out, 'sibling'); + assert.ok(!out.context.includes('\u202e')); + }); + + gateCase('merges dir_gates and counters written by another process during save', () => { + const hook = loadDirectHook({ CLAUDE_PROJECT_DIR: projectRoot, CLAUDE_TRANSCRIPT_PATH: '' }); + const originalMkdirSync = fs.mkdirSync; + const now = Date.now(); + let injected = false; + fs.mkdirSync = function patchedMkdirSync(target) { + const result = originalMkdirSync.apply(fs, arguments); + if (!injected && path.resolve(String(target)) === path.resolve(stateDir)) { + injected = true; + fs.writeFileSync( + stateFile, + JSON.stringify({ + checked: ['/src/concurrent.js'], + last_active: now, + fact_force_denials: 7, + dir_gates: { + [dirGateKey('/other/dir')]: { turn: 'u-other', at: now, first: '/other/dir/a.js', ordinal: 7 }, + [dirGateKey(`${projectRoot}/src/conc`)]: { turn: 'u-old', at: 1, first: 'stale', ordinal: 1 } + }, + denials_by_class: { code: 5, prose: 2 }, + credited_by_class: { test: 3 }, + sibling_allows: 9 + }), + 'utf8' + ); + } + return result; + }; + const savedRoot = process.env.CLAUDE_PROJECT_DIR; + const savedTranscript = process.env.CLAUDE_TRANSCRIPT_PATH; + try { + const result = hook.run({ tool_name: 'Write', cwd: projectRoot, tool_input: { file_path: `${projectRoot}/src/conc/new.js`, content: 'x' } }); + assert.strictEqual(parseOutput(result.stdout).hookSpecificOutput.permissionDecision, 'deny'); + } finally { + fs.mkdirSync = originalMkdirSync; + if (savedRoot === undefined) delete process.env.CLAUDE_PROJECT_DIR; + else process.env.CLAUDE_PROJECT_DIR = savedRoot; + if (savedTranscript === undefined) delete process.env.CLAUDE_TRANSCRIPT_PATH; + else process.env.CLAUDE_TRANSCRIPT_PATH = savedTranscript; + } + const persisted = JSON.parse(fs.readFileSync(stateFile, 'utf8')); + assert.ok(persisted.dir_gates[dirGateKey('/other/dir')], 'concurrent dir gate preserved'); + const mine = persisted.dir_gates[dirGateKey(`${projectRoot}/src/conc`)]; + assert.ok(mine && mine.first === `${projectRoot}/src/conc/new.js` && mine.turn === null, 'newer in-memory gate wins'); + assert.deepStrictEqual(persisted.denials_by_class, { code: 5, prose: 2 }, 'per-key max(disk, mem)'); + assert.deepStrictEqual(persisted.credited_by_class, { test: 3 }); + assert.strictEqual(persisted.sibling_allows, 9); + assert.ok(persisted.checked.includes('/src/concurrent.js')); + }); + + gateCase('sibling state save failure -> allow with state warning', () => { + assertDeniedNoSibling(projectWrite(`${projectRoot}/src/sf/one.js`), 'deny'); + const hook = loadDirectHook({ CLAUDE_PROJECT_DIR: projectRoot, CLAUDE_TRANSCRIPT_PATH: '' }); + const originalWriteFileSync = fs.writeFileSync; + fs.writeFileSync = function patchedWriteFileSync(target) { + if (String(target).includes('.json.tmp.')) throw Object.assign(new Error('EIO'), { code: 'EIO' }); + return originalWriteFileSync.apply(fs, arguments); + }; + const savedRoot = process.env.CLAUDE_PROJECT_DIR; + const savedTranscript = process.env.CLAUDE_TRANSCRIPT_PATH; + let result; + try { + result = hook.run({ tool_name: 'Write', cwd: projectRoot, tool_input: { file_path: `${projectRoot}/src/sf/two.js`, content: 'x' } }); + } finally { + fs.writeFileSync = originalWriteFileSync; + if (savedRoot === undefined) delete process.env.CLAUDE_PROJECT_DIR; + else process.env.CLAUDE_PROJECT_DIR = savedRoot; + if (savedTranscript === undefined) delete process.env.CLAUDE_TRANSCRIPT_PATH; + else process.env.CLAUDE_TRANSCRIPT_PATH = savedTranscript; + } + assert.ok(!result.stdout, 'no deny and no sibling note'); + assert.ok(String(result.stderr).includes('could not be persisted'), JSON.stringify(result)); + }); + + gateCase('clipped window keeps the promptId turn id; a sibling gate from earlier in the turn still matches', () => { + const { scanCurrentTurn } = loadDirectHook(); + const pid = 'prompt-heavy'; + const human = humanRecord('scaffold', { promptId: pid }); + const early = writeTranscript([human]); + assert.strictEqual(scanCurrentTurn(early).turnId, pid); + const gateDir = `${projectRoot}/src/heavy`; + assertDeniedNoSibling(projectWrite(`${gateDir}/one.js`, early), 'first file, boundary in window'); + assert.strictEqual(readState().dir_gates[dirGateKey(gateDir)].turn, pid, 'gate recorded under the promptId'); + const filler = fillerRecords('pid').map(r => (r.type === 'user' ? { ...r, promptId: pid } : r)); + const late = writeTranscript([human, ...filler]); + assert.ok(fs.statSync(late).size > 300 * 1024, 'boundary scrolled out of the tail'); + assert.strictEqual(scanCurrentTurn(late).turnId, pid, 'turn id stable after the turn outgrows the tail'); + assertSibling(projectWrite(`${gateDir}/two.js`, late), 'sibling after the boundary left the window'); + const next = writeTranscript([humanRecord('next', { promptId: 'prompt-next' })]); + assertDeniedNoSibling(projectWrite(`${gateDir}/three.js`, next), 'next prompt is a new turn'); + }); + + gateCase('a new human turn uses its own promptId even when a late tool_result carries the previous one', () => { + const turnOneHuman = humanRecord('turn one', { promptId: 'P1' }); + const t1 = writeTranscript([turnOneHuman]); + assertDeniedNoSibling(projectWrite(`${projectRoot}/src/late-result/aaa1.py`, t1), 'turn P1'); + const late = { ...toolResultRecord('toolu_n3late'), promptId: 'P1' }; + const t2 = writeTranscript([turnOneHuman, humanRecord('turn two', { promptId: 'P2' }), toolUseRecord('toolu_n3late', 'Bash', { command: 'true' }), late]); + assertDeniedNoSibling(projectWrite(`${projectRoot}/src/late-result/aaa2.py`, t2), 'turn P2 with a late P1 tool_result'); + assertSibling(projectWrite(`${projectRoot}/src/late-result/aaa3.py`, t2), 'sibling within turn P2'); + assert.strictEqual(readState().dir_gates[dirGateKey(`${projectRoot}/src/late-result`)].turn, 'P2', 'gate recorded under the boundary promptId'); + }); + + gateCase('a transcript that yields no turn id never falls back to the no-turn 120 s rule', () => { + const asDir = fs.mkdtempSync(path.join(tmpRoot, 'gateguard-transcript-dir-')); + assertDeniedNoSibling(projectWrite(`${projectRoot}/src/null-scan/one.js`, asDir), 'transcript path is a directory (scan null)'); + assertDeniedNoSibling(projectWrite(`${projectRoot}/src/null-scan/two.js`, asDir), 'no collapse with a null scan'); + const noBoundary = writeTranscript([toolUseRecord('toolu_n4x', 'Bash', { command: 'true' }), toolResultRecord('toolu_n4x')]); + assertDeniedNoSibling(projectWrite(`${projectRoot}/src/no-boundary/one.js`, noBoundary), 'whole file without a boundary (turn id null)'); + assertDeniedNoSibling(projectWrite(`${projectRoot}/src/no-boundary/two.js`, noBoundary), 'no collapse without a turn id'); + const missing = path.join(tmpRoot, 'gateguard-missing-transcript.jsonl'); + assertDeniedNoSibling(projectWrite(`${projectRoot}/src/missing-transcript/one.js`, missing), 'missing transcript file'); + assertDeniedNoSibling(projectWrite(`${projectRoot}/src/missing-transcript/two.js`, missing), 'no collapse with a missing transcript'); + const malformed = path.join(tmpRoot, `gateguard-malformed-transcript-${process.pid}.jsonl`); + fs.writeFileSync(malformed, [humanRecord('t1', { promptId: 'M1' }), humanRecord('t2', { promptId: 'M2' })].map(r => JSON.stringify(r)).join('\n') + '\n{"type":"assistant"}\n'); + assertDeniedNoSibling(projectWrite(`${projectRoot}/src/malformed-record/one.js`, malformed), 'malformed assistant record'); + assertSibling(projectWrite(`${projectRoot}/src/malformed-record/two.js`, malformed), 'malformed record skipped: same turn M2 still collapses'); + assert.strictEqual(readState().dir_gates[dirGateKey(`${projectRoot}/src/malformed-record`)].turn, 'M2'); + fs.rmSync(asDir, { recursive: true, force: true }); + fs.rmSync(malformed, { force: true }); + }); + + gateCase('no sibling-collapse output carries permissionDecision "allow"', () => { + assert.ok(siblingOutputs.length > 20, 'collected outputs'); + for (const stdout of siblingOutputs) { + const output = parseOutput(stdout); + const decision = output && output.hookSpecificOutput ? output.hookSpecificOutput.permissionDecision : undefined; + assert.notStrictEqual(decision, 'allow', stdout); + } + }); + + // --- Prior-search credit: scope and matching rules --- + const scopeRoot = fs.realpathSync(fs.mkdtempSync(path.join(tmpRoot, 'gateguard-scope-proj-'))); + // Shell fixtures use forward slashes: bash treats unquoted backslashes as escapes. + const scopeRootSh = scopeRoot.replace(/\\/g, '/'); + for (const dir of ['src/handlers', 'handlers', 'f']) fs.mkdirSync(path.join(scopeRoot, dir), { recursive: true }); + for (const file of ['src/payment.py', 'src/auth.py', 'src/payment_service.py', 'top.py']) { + fs.writeFileSync(path.join(scopeRoot, file), 'x'); + } + const scopeEnv = { CLAUDE_PROJECT_DIR: scopeRoot, CLAUDE_TRANSCRIPT_PATH: '' }; + const scopeOutputs = []; + let scopeSession = 0; + // Each call gets a fresh session unless the case passes session_id explicitly. + const scopeRun = (toolName, toolInput, transcriptPath, extra = {}) => { + scopeSession += 1; + const payload = { tool_name: toolName, tool_input: toolInput, cwd: scopeRoot, transcript_path: transcriptPath, session_id: `scope-${scopeSession}` }; + if (pendingToolUseIds.has(transcriptPath)) payload.tool_use_id = pendingToolUseIds.get(transcriptPath); + Object.assign(payload, extra); + if (payload.tool_use_id === null) delete payload.tool_use_id; + const result = runHook(payload, scopeEnv); + scopeOutputs.push(result.stdout); + const output = parseOutput(result.stdout); + const hso = output && output.hookSpecificOutput ? output.hookSpecificOutput : {}; + return { result, decision: hso.permissionDecision, context: hso.additionalContext || '', reason: hso.permissionDecisionReason || '' }; + }; + const scopePath = rel => `${scopeRoot}/${rel}`; + const scopeEdit = (rel, t, extra) => scopeRun('Edit', { file_path: scopePath(rel), old_string: 'a', new_string: 'b' }, t, extra); + const scopeWrite = (rel, t, extra) => scopeRun('Write', { file_path: scopePath(rel), content: 'x' }, t, extra); + const scopeTurn = (...records) => writeTranscript([humanRecord('fix'), ...records]); + const bashSearch = (id, command) => searchRecords(id, 'Bash', { command }); + const assertScopeDenied = (out, label) => { + assert.strictEqual(out.decision, 'deny', `${label}: expected deny, got ${out.result.stdout}`); + assert.ok(!out.context.includes('Prior search seen'), `${label}: no credit note`); + }; + const assertScopeCredited = (out, label) => { + assert.notStrictEqual(out.decision, 'deny', `${label}: must not deny (${out.result.stdout})`); + assert.ok(out.context.includes('Prior search seen in this turn'), `${label}: credit note expected, got ${out.result.stdout}`); + }; + const assistantMessage = (id, messageId, name, input) => ({ + type: 'assistant', + uuid: nextUuid(), + message: { id: messageId, role: 'assistant', content: [{ type: 'tool_use', id, name, input }] } + }); + const toolResult = (id, extra) => ({ + type: 'user', + uuid: nextUuid(), + message: { role: 'user', content: [{ type: 'tool_result', tool_use_id: id, content: 'ok', ...extra }] } + }); + + gateCase('a directory listing does not credit a Write that overwrites an existing file', () => { + assertScopeDenied(scopeWrite('src/payment.py', scopeTurn(...searchRecords('toolu_h1a', 'LS', { path: 'src' }))), 'LS src -> overwrite payment.py'); + assertScopeDenied(scopeWrite('top.py', scopeTurn(...bashSearch('toolu_h1b', 'grep -rn TODO .'))), 'grep -rn TODO . -> overwrite top.py'); + assertScopeDenied(scopeWrite('src/handlers/../payment.py', scopeTurn(...searchRecords('toolu_h1c', 'Glob', { pattern: 'src/*.py' }))), 'Glob src/*.py -> overwrite'); + }); + + gateCase('shell patterns and flag values are not directories', () => { + assertScopeDenied(scopeWrite('handlers/zzzz_new.py', scopeTurn(...bashSearch('toolu_h2a', 'grep -r handlers src'))), 'grep pattern as dir'); + assertScopeDenied(scopeWrite('f/qqqq_new.py', scopeTurn(...bashSearch('toolu_h2b', 'find . -type f -name x'))), 'find -type value as dir'); + assertScopeDenied(scopeWrite('handlers/rg_new.py', scopeTurn(...bashSearch('toolu_h2c', 'rg -g handlers TODO src'))), 'rg -g value as dir'); + assertScopeDenied(scopeWrite('handlers/sls_new.py', scopeTurn(...searchRecords('toolu_h2d', 'PowerShell', { command: 'Select-String handlers src/*.py' }))), 'sls pattern'); + }); + + gateCase('bare cwd operands and missing directories give no directory credit', () => { + assertScopeDenied(scopeWrite('zzz_new.py', scopeTurn(...bashSearch('toolu_h2e', 'grep -rn TODO .'))), 'grep -rn TODO .'); + assertScopeDenied(scopeWrite('nothere/new_mod.py', scopeTurn(...bashSearch('toolu_h2f', 'ls nothere'))), 'ls of a missing dir'); + assertScopeDenied(scopeWrite('top.py/new_mod.py', scopeTurn(...bashSearch('toolu_h2g', 'ls top.py'))), 'ls of a file, not a dir'); + }); + + gateCase('Glob with no literal prefix, or no glob characters, names no directory', () => { + assertScopeDenied(scopeWrite('brandnew.py', scopeTurn(...searchRecords('toolu_h2h', 'Glob', { pattern: '**/*' }))), 'Glob **/*'); + assertScopeDenied(scopeWrite('otherzz.py', scopeTurn(...searchRecords('toolu_h2i', 'Glob', { pattern: 'qqq.txt' }))), 'Glob qqq.txt'); + assertScopeDenied(scopeWrite('src/handlers/brand_new.py', scopeTurn(...searchRecords('toolu_h2j', 'Glob', { pattern: 'src/handlers/x.py' }))), 'Glob file lookup'); + }); + + gateCase('stems match on word boundaries only', () => { + assertScopeDenied(scopeEdit('src/auth.py', scopeTurn(...searchRecords('toolu_m1a', 'Grep', { pattern: 'author' }))), 'author vs auth'); + assertScopeDenied(scopeEdit('src/payment.py', scopeTurn(...searchRecords('toolu_m1b', 'Grep', { pattern: 'TODO', glob: '**/payments/**' }))), 'payments glob'); + assertScopeCredited(scopeEdit('src/auth.py', scopeTurn(...searchRecords('toolu_m1c', 'Grep', { pattern: 'def auth(' }))), 'auth( boundary'); + }); + + gateCase('shell segments that only read stdin never credit', () => { + assertScopeDenied(scopeEdit('src/payment.py', scopeTurn(...bashSearch('toolu_m1d', 'echo payment | grep payment'))), 'echo | grep'); + assertScopeDenied(scopeEdit('src/payment.py', scopeTurn(...bashSearch('toolu_m1e', 'cat README | grep -c payment'))), 'cat | grep -c'); + assertScopeDenied(scopeEdit('src/payment.py', scopeTurn(...bashSearch('toolu_m1f', 'echo payment | rg payment'))), 'piped rg with no path'); + }); + + gateCase('a shell segment that only reads the target is not a search', () => { + assertScopeDenied(scopeEdit('src/payment.py', scopeTurn(...bashSearch('toolu_m1g', 'ls src/payment.py'))), 'ls target'); + assertScopeDenied(scopeEdit('src/payment.py', scopeTurn(...bashSearch('toolu_m1h', 'grep -n . src/payment.py'))), 'grep . target'); + assertScopeDenied(scopeEdit('src/payment.py', scopeTurn(...bashSearch('toolu_m1i', 'grep -n payment ./src/payment.py'))), 'grep stem in target only'); + assertScopeDenied(scopeEdit('src/payment.py', scopeTurn(...searchRecords('toolu_m1j', 'Grep', { pattern: 'payment', path: 'src/payment.py' }))), 'Grep path = target'); + }); + + gateCase('Grep/Glob/LS with an explicit path only credit targets inside it', () => { + assertScopeDenied(scopeEdit('src/payment.py', scopeTurn(...searchRecords('toolu_m1k', 'Grep', { pattern: 'payment', path: '/nonexistent/elsewhere' }))), 'Grep elsewhere'); + assertScopeDenied(scopeEdit('src/payment.py', scopeTurn(...searchRecords('toolu_m1l', 'Glob', { pattern: 'payment.md', path: '/nonexistent-dir' }))), 'Glob elsewhere'); + assertScopeDenied(scopeEdit('src/payment.py', scopeTurn(...searchRecords('toolu_m1m', 'Grep', { pattern: 'TODO', path: 'x', glob: 'src/payments/**' }))), 'glob in another directory'); + assertScopeDenied(scopeEdit('src/payment.py', scopeTurn(...searchRecords('toolu_m1n', 'Glob', { pattern: 'lib/**/payment*' }))), 'Glob prefix elsewhere'); + assertScopeDenied(scopeEdit('src/payment.py', scopeTurn(...searchRecords('toolu_m1o', 'Glob', { pattern: 'payment.md', path: scopeRoot }))), 'file lookup in an ancestor dir'); + assertScopeDenied(scopeEdit('src/payment.py', scopeTurn(...searchRecords('toolu_m1p', 'Glob', { pattern: 'src/payment.py' }))), 'lookup of the target itself'); + assertScopeCredited(scopeEdit('src/payment.py', scopeTurn(...searchRecords('toolu_m1q', 'Glob', { pattern: 'src/payment.md' }))), 'file lookup beside the target'); + }); + + gateCase('same-batch searches are excluded when the pending call cannot be located', () => { + const edit = scopePath(`src/payment.py`); + const sameBatch = writeTranscript( + [humanRecord('fix'), assistantMessage('g1', 'msg_B', 'Grep', { pattern: 'payment' }), assistantMessage('e1', 'msg_B', 'Edit', { file_path: edit }), toolResult('g1')], + { pending: false } + ); + assertScopeDenied(scopeEdit('src/payment.py', sameBatch), 'no tool_use_id: newest message excluded'); + assertScopeDenied(scopeEdit('src/payment.py', sameBatch, { tool_use_id: 'e1' }), 'tool_use_id located'); + const notYet = writeTranscript([humanRecord('fix'), assistantMessage('g2', 'msg_C', 'Grep', { pattern: 'payment' }), toolResult('g2')], { pending: false }); + assertScopeDenied(scopeEdit('src/payment.py', notYet, { tool_use_id: 'e2' }), 'tool_use_id not in transcript yet'); + const noMsgId = writeTranscript([ + humanRecord('fix'), + { type: 'assistant', uuid: nextUuid(), message: { role: 'assistant', content: [{ type: 'tool_use', id: 'g3', name: 'Grep', input: { pattern: 'payment' } }] } }, + toolResult('g3') + ]); + assertScopeDenied(scopeEdit('src/payment.py', noMsgId), 'search without message.id'); + const earlier = writeTranscript( + [humanRecord('fix'), assistantMessage('g4', 'msg_D', 'Grep', { pattern: 'payment' }), toolResult('g4'), assistantMessage('e4', 'msg_E', 'Edit', { file_path: edit })], + { pending: false } + ); + assertScopeCredited(scopeEdit('src/payment.py', earlier), 'no tool_use_id: search in an older message still credits'); + }); + + gateCase('a boundary without uuid yields a hashed turn id; siblings do not cross turns', () => { + const { scanCurrentTurn } = loadDirectHook(); + const noUuid = text => ({ type: 'user', message: { role: 'user', content: text } }); + const t1 = writeTranscript([noUuid('turn one')], { pending: false }); + const t2 = writeTranscript([noUuid('turn one'), noUuid('turn TWO new request')], { pending: false }); + const id1 = scanCurrentTurn(t1).turnId; + const id2 = scanCurrentTurn(t2).turnId; + assert.ok(/^h:[0-9a-f]{16}$/.test(id1), `hashed turn id, got ${id1}`); + assert.notStrictEqual(id1, id2, 'different boundary lines, different turn ids'); + const w1 = scopeWrite('src/hashed-turn/hhh1.py', t1, { tool_use_id: null, session_id: 'hashed-turn-session' }); + assert.strictEqual(w1.decision, 'deny', w1.result.stdout); + const w2 = scopeWrite('src/hashed-turn/hhh2.py', t2, { tool_use_id: null, session_id: 'hashed-turn-session' }); + assert.strictEqual(w2.decision, 'deny', `turn 2 sibling must be denied: ${w2.result.stdout}`); + }); + + gateCase('compaction is a turn boundary', () => { + const pre = [humanRecord('orig task'), ...searchRecords('toolu_l2a', 'Grep', { pattern: 'payment' })]; + const summary = { type: 'user', uuid: nextUuid(), isCompactSummary: true, message: { role: 'user', content: 'Summary: ...' } }; + assertScopeDenied(scopeEdit('src/payment.py', writeTranscript([...pre, { type: 'system', subtype: 'compact_boundary' }, summary])), 'summary'); + assertScopeDenied(scopeEdit('src/payment.py', writeTranscript([...pre, { type: 'system', uuid: 'cb-1', subtype: 'compact_boundary' }])), 'boundary only'); + const { scanCurrentTurn } = loadDirectHook(); + assert.strictEqual(scanCurrentTurn(writeTranscript([...pre, summary], { pending: false })).turnId, summary.uuid, 'summary uuid is the turn id'); + }); + + gateCase('error results are recognised without a boolean is_error', () => { + const errored = extra => scopeTurn(toolUseRecord('toolu_l3', 'Grep', { pattern: 'payment' }), toolResult('toolu_l3', extra)); + assertScopeDenied(scopeEdit('src/payment.py', errored({ is_error: 'true', content: 'Error: denied' })), 'is_error "true"'); + assertScopeDenied(scopeEdit('src/payment.py', errored({ content: 'Permission denied' })), 'string content'); + assertScopeDenied(scopeEdit('src/payment.py', errored({ content: [{ type: 'text', text: ' x' }] })), 'text block'); + }); + + gateCase('a tool_use id seen more than once in the turn never qualifies', () => { + const t = writeTranscript([ + humanRecord('x'), + assistantMessage('g', 'msg_1', 'Grep', { pattern: 'unrelated' }), + toolResult('g'), + assistantMessage('g', 'msg_2', 'Grep', { pattern: 'payment' }) + ]); + assertScopeDenied(scopeEdit('src/payment.py', t), 'duplicate id'); + }); + + gateCase('a cd anywhere in the turn disables shell directory credit (stem counts with a known base)', () => { + const t = scopeTurn(...bashSearch('toolu_l7a', 'cd src'), ...bashSearch('toolu_l7b', 'ls handlers')); + assertScopeDenied(scopeWrite('handlers/new_mod.py', t), 'cd then ls handlers'); + const control = scopeTurn(...bashSearch('toolu_l7c', 'ls handlers')); + assertScopeCredited(scopeWrite('handlers/new_mod2.py', control), 'control without cd'); + const stem = scopeTurn(...bashSearch('toolu_l7d', 'cd src'), ...bashSearch('toolu_l7e', 'rg -n payment ..')); + assertScopeDenied(scopeEdit('src/payment.py', stem), 'relative operand after a cd in another call'); + const absolute = scopeTurn(...bashSearch('toolu_l7f', 'cd src'), ...bashSearch('toolu_l7g', `rg -n payment ${scopeRootSh}/src`)); + assertScopeCredited(scopeEdit('src/payment.py', absolute), 'stem after cd with an absolute operand'); + }); + + gateCase('documented search forms still credit', () => { + assertScopeCredited(scopeEdit('src/payment.py', scopeTurn(...bashSearch('toolu_p1', 'rg -n payment src'))), 'rg -n payment src'); + assertScopeCredited(scopeEdit('src/payment_service.py', scopeTurn(...searchRecords('toolu_p2', 'Grep', { pattern: 'payment_service', path: 'src' }))), 'Grep path src'); + assertScopeCredited(scopeWrite('src/handlers/new_handler.py', scopeTurn(...searchRecords('toolu_p3', 'Glob', { pattern: 'src/handlers/*.py' }))), 'Glob dir'); + assertScopeCredited(scopeWrite('src/handlers/ls_new.py', scopeTurn(...searchRecords('toolu_p4', 'LS', { path: 'src/handlers' }))), 'LS dir'); + assertScopeCredited(scopeWrite('src/handlers/x_new.py', scopeTurn(...bashSearch('toolu_p5', 'find src/handlers -name "*.py"'))), 'find dir'); + assertScopeCredited(scopeEdit('src/auth.py', scopeTurn(...bashSearch('toolu_p6', 'grep -r . -e auth'))), '-e supplies the pattern'); + assertScopeCredited(scopeWrite('src/handlers/grep_new.py', scopeTurn(...bashSearch('toolu_p7', 'grep -rn --include "*.py" TODO src/handlers/'))), 'grep dir'); + }); + + gateCase('a shell search only credits targets inside what it searched', () => { + const edit = (id, command) => scopeEdit('src/payment.py', scopeTurn(...bashSearch(id, command))); + assertScopeDenied(edit('toolu_n1a', 'grep -rn payment /usr/share/doc'), 'grep -rn outside the project'); + assertScopeDenied(edit('toolu_n1b', 'rg payment docs'), 'rg in another directory'); + assertScopeDenied(edit('toolu_n1c', 'find /etc -name payment'), 'find outside the project'); + assertScopeDenied(edit('toolu_n1d', 'rg payment < /etc/hostname'), 'rg reading stdin via <'); + assertScopeDenied(edit('toolu_n1e', 'rg payment { + const edit = (id, command) => scopeEdit('src/payment.py', scopeTurn(...bashSearch(id, command))); + assertScopeCredited(edit('toolu_n1p1', 'rg -n payment src'), 'rg -n payment src'); + assertScopeCredited(edit('toolu_n1p2', 'grep -rn payment .'), 'grep -rn payment . (cwd scope)'); + assertScopeCredited(edit('toolu_n1p3', 'find src -name "payment*"'), 'find src'); + assertScopeCredited(edit('toolu_n1p4', 'rg payment'), 'rg with no operand searches the cwd'); + assertScopeCredited(edit('toolu_n1p5', 'find . -name "payment*"'), 'find .'); + assertScopeCredited(edit('toolu_n1p6', 'rg payment docs src'), 'one of several operands contains the target'); + assertScopeCredited(edit('toolu_n1p7', 'rg -n payment src/payment.py src/auth.py'), 'the target itself among other operands'); + assertScopeCredited(edit('toolu_n1p8', 'rg payment src < /dev/null'), 'operand before an input redirection'); + assertScopeCredited(edit('toolu_n1p9', `cd ${scopeRootSh} && rg payment src`), 'relative operand after an absolute cd into the project'); + assertScopeCredited(edit('toolu_n1p10', `grep -rn payment ${scopeRootSh}/src`), 'absolute operand'); + assertScopeCredited(edit('toolu_n1p11', 'rg payment src/*.py'), 'glob operand scopes to its literal directory'); + }); + + gateCase('no credit-scoping output carries permissionDecision "allow"', () => { + assert.ok(scopeOutputs.length > 30, 'collected outputs'); + for (const stdout of scopeOutputs) { + const output = parseOutput(stdout); + const decision = output && output.hookSpecificOutput ? output.hookSpecificOutput.permissionDecision : undefined; + assert.notStrictEqual(decision, 'allow', stdout); + } + }); + + // --- Sibling collapse keyed by target class --- + // The project root sits under an ancestor `tests/` on purpose: `src/a.py` must still be code. + const classBase = fs.realpathSync(fs.mkdtempSync(path.join(tmpRoot, 'gateguard-class-'))); + const classRoot = path.join(classBase, 'tests', 'proj'); + fs.mkdirSync(classRoot, { recursive: true }); + const classEnv = { CLAUDE_PROJECT_DIR: classRoot, CLAUDE_TRANSCRIPT_PATH: '' }; + const classOutputs = []; + const classPath = rel => `${classRoot}/${rel}`; + const classRun = (toolName, toolInput, transcriptPath) => { + const payload = { tool_name: toolName, tool_input: toolInput, cwd: classRoot }; + if (transcriptPath !== undefined) payload.transcript_path = transcriptPath; + if (pendingToolUseIds.has(transcriptPath)) payload.tool_use_id = pendingToolUseIds.get(transcriptPath); + const result = runHook(payload, classEnv); + classOutputs.push(result.stdout); + const output = parseOutput(result.stdout); + const hso = output && output.hookSpecificOutput ? output.hookSpecificOutput : {}; + return { result, decision: hso.permissionDecision, context: hso.additionalContext || '', reason: hso.permissionDecisionReason || '' }; + }; + const classWrite = (rel, t) => classRun('Write', { file_path: classPath(rel), content: 'x' }, t); + const classEdit = (rel, t) => classRun('Edit', { file_path: classPath(rel), old_string: 'a', new_string: 'b' }, t); + const scaffoldTurn = () => writeTranscript([humanRecord('scaffold it')]); + const classGateKey = (cls, relDir) => `${cls}\u0000${stateKey(`${classRoot}/${relDir}`)}`; + const assertClassDenied = (out, label) => { + assert.strictEqual(out.decision, 'deny', `${label}: expected deny, got ${out.result.stdout}`); + assert.ok(!out.context.includes('Sibling of'), `${label}: no sibling note`); + }; + const assertClassSibling = (out, label) => { + assert.notStrictEqual(out.decision, 'deny', `${label}: must not deny (${out.result.stdout})`); + assert.ok(out.context.includes('[Fact-Forcing Gate] Sibling of '), `${label}: sibling note expected, got ${out.result.stdout}`); + }; + + gateCase('a code denial does not collapse other classes in the same dir', () => { + const t = scaffoldTurn(); + assertClassDenied(classWrite('src/newmod.py', t), 'code first'); + assertClassDenied(classWrite('src/.env.production', t), 'config sibling of code'); + assertClassDenied(classWrite('src/newmod.test.py', t), 'test sibling of code'); + assertClassDenied(classWrite('src/README.md', t), 'prose sibling of code'); + assertClassDenied(classWrite('src/settings.yaml', t), 'config sibling of code'); + assertClassDenied(classWrite('src/.env.local', t), '.env sibling of code'); + assertClassSibling(classWrite('src/other.py', t), 'code sibling of code still collapses'); + assertClassSibling(classWrite('src/other.test.py', t), 'test sibling of test collapses'); + assertClassSibling(classWrite('src/CHANGES.md', t), 'prose sibling of prose collapses'); + }); + + gateCase('harness, instruction and config paths never collapse', () => { + const t = scaffoldTurn(); + assertClassDenied(classWrite('.github/notes.md', t), '.github prose'); + assertClassDenied(classWrite('.github/more.md', t), '.github prose never collapses'); + const instructionOut = classWrite('.github/copilot-instructions.md', t); + assertClassDenied(instructionOut, 'copilot-instructions'); + assert.ok(instructionOut.reason.includes('harness/loader'), 'copilot-instructions.md is instruction class'); + assertClassDenied(classWrite('READMEX.md', t), 'root prose'); + assertClassDenied(classWrite('AGENT.md', t), 'AGENT.md'); + assertClassDenied(classWrite('.cursorrules', t), '.cursorrules'); + assertClassDenied(classWrite('.windsurfrules', t), '.windsurfrules'); + assertClassDenied(classWrite('.mcp.json', t), '.mcp.json'); + assertClassSibling(classWrite('NOTES.md', t), 'ordinary prose sibling at root still collapses'); + assertClassDenied(classWrite('.claude/hooks/a.sh', t), '.claude/hooks'); + assertClassDenied(classWrite('.claude/hooks/evil.sh', t), '.claude/hooks sibling'); + assertClassDenied(classWrite('.husky/pre-commit', t), '.husky'); + assertClassDenied(classWrite('.husky/pre-push', t), '.husky sibling'); + assertClassDenied(classWrite('config/app.yaml', t), 'config'); + assertClassDenied(classWrite('config/db.yaml', t), 'config sibling'); + assertClassDenied(classWrite('lib/.hidden.js', t), 'dotfile'); + assertClassDenied(classWrite('lib/.other.js', t), 'dotfile sibling'); + const gates = readState().dir_gates || {}; + for (const key of Object.keys(gates)) { + assert.ok(!/\.github|\.claude|\.husky|^config|^instruction/.test(key), `no gate recorded for ${JSON.stringify(key)}`); + } + }); + + gateCase('classification uses the project-relative path (ancestor tests/ dir, worktrees)', () => { + const t = scaffoldTurn(); + const code = classWrite('src/a.py', t); + assertClassDenied(code, 'code'); + assert.ok(code.reason.includes('Name the file(s) and line(s) that will call this new file'), 'code questions'); + const state = readState(); + assert.deepStrictEqual(state.denials_by_class, { code: 1 }, 'counter uses the same class as the questions'); + assert.ok(state.dir_gates[classGateKey('code', 'src')], `gate key uses the same class: ${JSON.stringify(Object.keys(state.dir_gates))}`); + assert.ok(classEdit('lib/b.py', t).reason.includes('List the call sites in this file or its module'), 'Edit code questions'); + fs.mkdirSync(classPath('.claude/worktrees/feat-x'), { recursive: true }); + fs.writeFileSync(classPath('.claude/worktrees/feat-x/.git'), 'gitdir: ../../../.git/worktrees/feat-x\n'); + const wt = classWrite('.claude/worktrees/feat-x/docs/guide.md', t); + assertClassDenied(wt, 'worktree prose'); + assert.ok(wt.reason.includes('supersedes or duplicates'), `worktree .md is prose: ${wt.reason}`); + assertClassSibling(classWrite('.claude/worktrees/feat-x/docs/guide2.md', t), 'worktree prose sibling'); + const wtHook = classWrite('.claude/worktrees/feat-x/.claude/hooks/x.md', t); + assertClassDenied(wtHook, 'worktree harness'); + assert.ok(wtHook.reason.includes('which harness loads this file'), `worktree .claude/hooks/*.md is instruction: ${wtHook.reason}`); + assertClassDenied(classWrite('.claude/worktrees/feat-x/.claude/hooks/y.md', t), 'worktree harness sibling'); + }); + + gateCase('sibling note wording and class-prefixed gate key', () => { + const t = scaffoldTurn(); + assertClassDenied(classWrite('src/w/one.js', t), 'first'); + const out = classWrite('src/w/two.js', t); + assertClassSibling(out, 'sibling'); + assert.strictEqual( + out.context, + `[Fact-Forcing Gate] Sibling of ${classRoot}/src/w/one.js (gated earlier at denial #1 this session); proceeding without a repeat denial.` + ); + const gates = readState().dir_gates; + assert.deepStrictEqual(Object.keys(gates), [classGateKey('code', 'src/w')]); + }); + + gateCase('a future `at` or a zero ordinal is never honored', () => { + const now = Date.now(); + const gate = (at, ordinal, turn = null) => ({ turn, at, first: '/forged.js', ordinal }); + writeState({ + checked: [], + last_active: now, + dir_gates: { + [classGateKey('code', 'src/fut')]: gate(now + 3600 * 1000, 1), + [classGateKey('code', 'src/zero')]: gate(now - 1000, 0), + [classGateKey('code', 'src/ok')]: gate(now - 1000, 1) + } + }); + assertClassDenied(classWrite('src/fut/a.js'), 'future at (no turn)'); + assertClassDenied(classWrite('src/zero/a.js'), 'ordinal 0'); + assertClassSibling(classWrite('src/ok/a.js'), 'control: valid gate still matches'); + const human = humanRecord('x'); + const t = writeTranscript([human]); + writeState({ checked: [], last_active: now, dir_gates: { [classGateKey('code', 'src/futt')]: gate(now + 3600 * 1000, 1, human.uuid) } }); + assertClassDenied(classWrite('src/futt/a.js', t), 'future at (same turn)'); + }); + + gateCase('old-shape dir_gates keys (no class prefix) are ignored and dropped', () => { + const now = Date.now(); + writeState({ checked: [], last_active: now, dir_gates: { [`${classRoot}/src/legacy`]: { turn: null, at: now - 1000, first: 'forged', ordinal: 1 } } }); + assertClassDenied(classWrite('src/legacy/a.js'), 'old-shape gate ignored'); + const keys = Object.keys(readState().dir_gates); + assert.deepStrictEqual(keys, [classGateKey('code', 'src/legacy')], 'old-shape key dropped, new key recorded'); + writeState({ checked: [], last_active: now, dir_gates: { [`bogus\u0000${classRoot}/src/b`]: { turn: null, at: now - 1000, first: 'x', ordinal: 1 } } }); + assertClassDenied(classWrite('src/b/a.js'), 'unknown class prefix ignored'); + }); + + gateCase('prototype keys in state are rejected and never pollute', () => { + const now = Date.now(); + const legacy = classGateKey('code', 'src/pp'); + fs.mkdirSync(stateDir, { recursive: true }); + fs.writeFileSync( + stateFile, + '{"checked":[],"last_active":' + now + ',"dir_gates":{"__proto__":{"turn":null,"at":1,"first":"x","ordinal":1},' + + '"constructor":{"turn":null,"at":' + (now - 1000) + ',"first":"x","ordinal":1},' + + '"prototype":{"turn":null,"at":' + (now - 1000) + ',"first":"x","ordinal":1},' + + JSON.stringify(legacy) + ':{"turn":null,"at":' + (now - 1000) + ',"first":"forged","ordinal":"NaN"}},' + + '"denials_by_class":{"__proto__":{"polluted":1},"constructor":5,"prototype":4,"code":7},' + + '"credited_by_class":{"constructor":2},"sibling_allows":0}', + 'utf8' + ); + assertClassDenied(classWrite('src/pp/a.js'), 'NaN ordinal gate not honored'); + assert.strictEqual({}.polluted, undefined, 'Object.prototype not polluted'); + const raw = fs.readFileSync(stateFile, 'utf8'); + const state = JSON.parse(raw); + for (const field of ['dir_gates', 'denials_by_class', 'credited_by_class']) { + for (const bad of ['__proto__', 'constructor', 'prototype']) { + assert.ok(!Object.prototype.hasOwnProperty.call(state[field], bad), `${field} drops ${bad}`); + } + } + assert.strictEqual(state.denials_by_class.code, 8, 'code counter incremented from 7'); + assert.deepStrictEqual(state.credited_by_class, {}); + }); + + gateCase('second writes into dot-directories are denied', () => { + const t = scaffoldTurn(); + for (const [a, b] of [ + ['.devcontainer/a.sh', '.devcontainer/post-create.sh'], + ['.githooks/a', '.githooks/pre-commit'], + ['.kiro/steering/a.md', '.kiro/steering/b.md'], + ['.clinerules/a.md', '.clinerules/b.md'], + ['.idea/a.xml', '.idea/run.xml'], + ['.continue/prompts/a.prompt', '.continue/prompts/b.prompt'] + ]) { + assertClassDenied(classWrite(a, t), `${a} first`); + assertClassDenied(classWrite(b, t), `${b} second`); + } + assert.deepStrictEqual(Object.keys(readState().dir_gates || {}), [], 'no dir gate recorded for any dot-directory'); + }); + + gateCase('a symlinked directory is screened (and keyed) by its real location', () => { + const t = scaffoldTurn(); + fs.mkdirSync(classPath('.claude/hooks'), { recursive: true }); + fs.mkdirSync(classPath('src/realdir'), { recursive: true }); + try { + fs.symlinkSync(classPath('.claude/hooks'), classPath('src/tools'), 'dir'); + fs.symlinkSync(classPath('src/realdir'), classPath('src/aliasdir'), 'dir'); + } catch (e) { + if (process.platform === 'win32' && e.code === 'EPERM') return; // no symlink privilege + throw e; + } + assertClassDenied(classWrite('src/tools/a.sh', t), 'first write through the symlink'); + assertClassDenied(classWrite('src/tools/evil.sh', t), 'second write lands in .claude/hooks'); + assertClassDenied(classWrite('src/realdir/one.js', t), 'real dir first'); + assertClassSibling(classWrite('src/aliasdir/two.js', t), 'alias of the same real dir shares its gate'); + assert.deepStrictEqual(Object.keys(readState().dir_gates), [classGateKey('code', 'src/realdir')], 'gate keyed by the real directory'); + }); + + gateCase('a .claude/worktrees// prefix without a .git is not stripped', () => { + const t = scaffoldTurn(); + fs.mkdirSync(classPath('.claude/worktrees/fake/src'), { recursive: true }); + assertClassDenied(classWrite('.claude/worktrees/fake/src/a.py', t), 'fake worktree first'); + assertClassDenied(classWrite('.claude/worktrees/fake/src/b.py', t), 'fake worktree sibling'); + assertClassDenied(classWrite('.claude/worktrees/fake/a.sh', t), 'fake worktree root first'); + assertClassDenied(classWrite('.claude/worktrees/fake/statusline.sh', t), 'fake worktree root sibling'); + const md = classWrite('.claude/worktrees/fake/docs/g.md', t); + assertClassDenied(md, 'fake worktree .md'); + assert.ok(md.reason.includes('which harness loads this file') || md.reason.includes('harness/loader'), `stays instruction: ${md.reason}`); + }); + + gateCase('no class-keyed collapse output carries permissionDecision "allow"', () => { + assert.ok(classOutputs.length > 30, 'collected outputs'); + for (const stdout of classOutputs) { + const output = parseOutput(stdout); + const decision = output && output.hookSpecificOutput ? output.hookSpecificOutput.permissionDecision : undefined; + assert.notStrictEqual(decision, 'allow', stdout); + } + }); + + // --- Opt-in session cap on first-touch denials --- + const capEnvName = 'GATEGUARD_FACT_FORCE_MAX_DENIALS'; + const capOutputs = []; + const capRun = (toolName, toolInput, transcriptPath, cap) => { + const out = projectRun(toolName, toolInput, transcriptPath, cap === undefined ? {} : { [capEnvName]: cap }); + capOutputs.push(out.result.stdout); + return out; + }; + const capAssertPassThrough = (out, toolName, label) => { + const output = parseOutput(out.result.stdout); + assert.ok(output, `${label}: valid JSON`); + assert.ok(!output.hookSpecificOutput, `${label}: pass-through expected, got ${out.result.stdout}`); + assert.strictEqual(output.tool_name, toolName, `${label}: pass-through preserves input`); + }; + const capWarning = value => + `[Fact-Forcing Gate] ignoring malformed ${capEnvName}=${value}; the denial cap is not active.`; + // Same-process run with stderr captured; restores the env var that loadDirectHook sets. + const capInProcess = (fn) => { + const saved = process.env[capEnvName]; + const originalStderrWrite = process.stderr.write.bind(process.stderr); + const captured = []; + process.stderr.write = chunk => { + const s = typeof chunk === 'string' ? chunk : chunk.toString(); + if (s.includes(capEnvName)) captured.push(s.replace(/\n$/, '')); + return true; + }; + try { + return fn(captured); + } finally { + process.stderr.write = originalStderrWrite; + if (saved === undefined) delete process.env[capEnvName]; + else process.env[capEnvName] = saved; + } + }; + const capDirectEdit = (hook, file) => + parseOutput(hook.run(JSON.stringify({ tool_name: 'Edit', cwd: projectRoot, tool_input: { file_path: `${projectRoot}/${file}` } })).stdout); + + for (const malformed of ['3.9', '+3', 'abc', '-1', '3oops', '0x3', ' 3 oops', '99999999999999999999']) { + gateCase(`malformed cap ${JSON.stringify(malformed)} warns once and leaves the gate uncapped`, () => { + writeState({ checked: [], last_active: Date.now(), fact_force_denials: 9 }); + capInProcess(captured => { + const hook = loadDirectHook({ ...projectEnv, [capEnvName]: malformed }); + const first = capDirectEdit(hook, 'src/cap-malformed-a.py'); + const second = capDirectEdit(hook, 'src/cap-malformed-b.py'); + assert.strictEqual(first.hookSpecificOutput.permissionDecision, 'deny', 'uncapped: first denied'); + assert.strictEqual(second.hookSpecificOutput.permissionDecision, 'deny', 'uncapped: second denied'); + assert.deepStrictEqual(captured, [capWarning(malformed.trim())], 'exactly one warning with the sanitized value'); + }); + assert.ok(!readState().cap_allows, 'no cap allow recorded'); + }); + } + + gateCase('a different malformed cap warns again; valid, empty and unset values never warn', () => { + capInProcess(captured => { + const hook = loadDirectHook({ ...projectEnv, [capEnvName]: 'abc' }); + capDirectEdit(hook, 'src/cap-warn-1.py'); + process.env[capEnvName] = 'xyz'; + capDirectEdit(hook, 'src/cap-warn-2.py'); + capDirectEdit(hook, 'src/cap-warn-3.py'); + assert.deepStrictEqual(captured, [capWarning('abc'), capWarning('xyz')]); + process.env[capEnvName] = '50'; + capDirectEdit(hook, 'src/cap-warn-4.py'); + process.env[capEnvName] = ''; + capDirectEdit(hook, 'src/cap-warn-5.py'); + delete process.env[capEnvName]; + capDirectEdit(hook, 'src/cap-warn-6.py'); + assert.strictEqual(captured.length, 2, `no further warnings: ${JSON.stringify(captured)}`); + }); + }); + + gateCase('the warning value is sanitized and bounded', () => { + capInProcess(captured => { + const hook = loadDirectHook({ ...projectEnv, [capEnvName]: `3\u202e\u200b${'9'.repeat(200)}x` }); + capDirectEdit(hook, 'src/cap-warn-sanitize.py'); + assert.strictEqual(captured.length, 1, JSON.stringify(captured)); + assert.ok(!/[\u202e\u200b]/.test(captured[0]), 'no invisible characters'); + assert.ok(captured[0].length < 200, `bounded: ${captured[0].length}`); + }); + }); + + gateCase('a malformed cap also warns from the spawned hook (stderr), stdout still denies', () => { + const out = capRun('Edit', { file_path: `${projectRoot}/src/cap-spawn.py` }, undefined, 'abc'); + assert.strictEqual(out.decision, 'deny'); + assert.ok(out.result.stderr.includes(capWarning('abc')), out.result.stderr); + }); + + gateCase('surrounding whitespace is trimmed (" 2 " caps at 2, no warning)', () => { + writeState({ checked: [], last_active: Date.now(), fact_force_denials: 2 }); + const out = capRun('Edit', { file_path: `${projectRoot}/src/cap-trim.py` }, undefined, ' 2 '); + capAssertPassThrough(out, 'Edit', 'trimmed cap'); + assert.ok(!out.result.stderr.includes(capEnvName), out.result.stderr); + }); + + gateCase('a cap pass marks the target checked, counts cap_allows, never counts a denial', () => { + writeState({ checked: [], last_active: Date.now(), fact_force_denials: 2 }); + const a = `${projectRoot}/src/cap-counted-a.py`; + capAssertPassThrough(capRun('Edit', { file_path: a }, undefined, '2'), 'Edit', 'first past cap'); + let state = readState(); + assert.strictEqual(state.cap_allows, 1, 'cap_allows counted'); + assert.strictEqual(state.fact_force_denials, 2, 'denials unchanged'); + assert.ok(state.checked.includes(stateKey(a)), 'cap-passed target marked checked'); + capAssertPassThrough(capRun('Edit', { file_path: a }, undefined, '2'), 'Edit', 'retry'); + assert.strictEqual(readState().cap_allows, 1, 'a retry of a checked target is not recounted'); + capAssertPassThrough(capRun('Write', { file_path: `${projectRoot}/src/cap-counted-b.py`, content: 'x' }, undefined, '2'), 'Write', 'second'); + state = readState(); + assert.strictEqual(state.cap_allows, 2); + assert.strictEqual(state.fact_force_denials, 2); + }); + + gateCase('cap_allows loads from old or malformed state and survives later writes', () => { + writeState({ checked: [], last_active: Date.now(), fact_force_denials: 1, cap_allows: 'garbage' }); + capRun('Edit', { file_path: `${projectRoot}/src/cap-old-a.py` }, undefined, '1'); + assert.strictEqual(readState().cap_allows, 1, 'malformed counter treated as zero'); + writeState({ ...readState(), cap_allows: 7 }); + capRun('Edit', { file_path: `${projectRoot}/src/cap-old-b.py` }, undefined, '1'); + assert.strictEqual(readState().cap_allows, 8, 'increments the persisted count'); + capRun('Edit', { file_path: `${projectRoot}/src/cap-old-c.py` }); + assert.strictEqual(readState().cap_allows, 8, 'kept across an unrelated state write'); + }); + + gateCase('cap 0 passes the first new path without a denial', () => { + capAssertPassThrough(capRun('Edit', { file_path: `${projectRoot}/src/cap-zero.py` }, undefined, '0'), 'Edit', 'cap 0'); + const state = readState(); + assert.ok(!state.fact_force_denials, 'no denial counted'); + assert.strictEqual(state.cap_allows, 1); + }); + + gateCase('credit and sibling allows run before the cap and do not consume it', () => { + const t = writeTranscript([ + humanRecord('build the gadget and fix the widget factory'), + ...searchRecords('toolu_cap_g1', 'Grep', { pattern: 'widget_factory', path: projectRoot }), + ...searchRecords('toolu_cap_g2', 'Grep', { pattern: 'gadget_maker', path: projectRoot }) + ]); + const credited = capRun('Edit', { file_path: `${projectRoot}/src/widget_factory.py`, old_string: 'a', new_string: 'b' }, t, '1'); + assert.ok(credited.context.includes('Prior search seen in this turn'), credited.result.stdout); + assert.ok(!readState().fact_force_denials, 'credit consumes no denial'); + const first = capRun('Write', { file_path: `${projectRoot}/src/capgen/g0.js`, content: 'x' }, t, '1'); + assert.strictEqual(first.decision, 'deny', 'the one denial the cap allows'); + const sibling = capRun('Write', { file_path: `${projectRoot}/src/capgen/g1.js`, content: 'x' }, t, '1'); + assert.ok(sibling.context.includes('[Fact-Forcing Gate] Sibling of '), `sibling note, not a cap pass: ${sibling.result.stdout}`); + const creditedAfterCap = capRun('Edit', { file_path: `${projectRoot}/src/gadget_maker.py`, old_string: 'a', new_string: 'b' }, t, '1'); + assert.ok(creditedAfterCap.context.includes('Prior search seen in this turn'), 'credit still wins once the cap is reached'); + let state = readState(); + assert.strictEqual(state.fact_force_denials, 1); + assert.strictEqual(state.sibling_allows, 1); + assert.strictEqual(state.fact_force_credited, 2); + assert.ok(!state.cap_allows, 'no cap pass yet'); + const capped = capRun('Edit', { file_path: `${projectRoot}/src/unrelated_module.py`, old_string: 'a', new_string: 'b' }, t, '1'); + capAssertPassThrough(capped, 'Edit', 'uncredited target past the cap'); + state = readState(); + assert.strictEqual(state.cap_allows, 1); + assert.strictEqual(state.fact_force_denials, 1); + }); + + gateCase('MultiEdit past the cap passes every entry and counts each', () => { + writeState({ checked: [], last_active: Date.now(), fact_force_denials: 1 }); + const files = ['src/cap-multi-a.py', 'src/cap-multi-b.py', 'src/cap-multi-c.py'].map(f => `${projectRoot}/${f}`); + const out = capRun('MultiEdit', { edits: files.map(file_path => ({ file_path, old_string: 'a', new_string: 'b' })) }, undefined, '1'); + capAssertPassThrough(out, 'MultiEdit', 'MultiEdit past cap'); + const state = readState(); + assert.strictEqual(state.cap_allows, 3); + assert.strictEqual(state.fact_force_denials, 1); + for (const f of files) assert.ok(state.checked.includes(stateKey(f)), `${f} checked`); + }); + + gateCase('destructive and routine Bash stay gated once the cap is reached', () => { + writeState({ checked: [], last_active: Date.now(), fact_force_denials: 5 }); + const env = { ...projectEnv, [capEnvName]: '0' }; + const destructive = parseOutput(runBashHook({ tool_name: 'Bash', tool_input: { command: 'rm -rf build' } }, env).stdout); + assert.strictEqual(destructive.hookSpecificOutput.permissionDecision, 'deny', 'destructive gate unchanged'); + const routine = parseOutput(runBashHook({ tool_name: 'Bash', tool_input: { command: 'npm test' } }, env).stdout); + assert.strictEqual(routine.hookSpecificOutput.permissionDecision, 'deny', 'routine gate unchanged'); + assert.ok(!readState().cap_allows, 'Bash never counts a cap pass'); + }); + + gateCase('no cap output carries permissionDecision "allow"', () => { + assert.ok(capOutputs.length > 10, `collected outputs: ${capOutputs.length}`); + for (const stdout of capOutputs) { + const output = parseOutput(stdout); + const decision = output && output.hookSpecificOutput ? output.hookSpecificOutput.permissionDecision : undefined; + assert.notStrictEqual(decision, 'allow', stdout); + } + }); + + // --- Sensitive targets are never auto-passed --- + const sensLine = 'Sensitive target: prior-search credit, sibling collapse, and the denial cap do not apply.'; + const sensTargets = [ + '.env', '.env.local', 'config/.env.production', 'certs/server.pem', 'certs/server.key', 'certs/client.p12', + 'certs/client.pfx', 'keys/id_rsa', 'keys/id_rsa.pub', 'keys/id_ed25519', 'keys/id_ecdsa', 'keys/id_dsa', + '.netrc', 'home/.pgpass', 'config/credentials.json', + 'config/secrets.yaml', 'src/auth/login_flow.py', 'src/authn/token_check.py', 'src/authz/policy_rules.py', + 'src/security/hardening.py', 'src/secrets/vault_client.py', 'src/payment/charge_card.py', + 'src/payments/refund_flow.py', 'src/billing/invoice_maker.py', 'db/migrations/0001_initial.py', + '.github/workflows/release.yml', 'SRC/Auth/Upper_Case.py' + ]; + const sensOutputs = []; + const sensRun = (toolName, toolInput, transcriptPath, env = {}) => { + const out = projectRun(toolName, toolInput, transcriptPath, env); + sensOutputs.push(out.result.stdout); + return out; + }; + const sensAbs = rel => `${projectRoot}/${rel}`; + const sensDir = rel => path.posix.dirname(sensAbs(rel)); + const sensStem = rel => path.posix.basename(rel).replace(/\.[^.]*$/, '') || path.posix.basename(rel); + const sensAssertDenied = (out, label) => { + assert.strictEqual(out.decision, 'deny', `${label}: expected deny, got ${out.result.stdout}`); + assert.ok(!out.context, `${label}: no credit/sibling note`); + assert.ok(out.reason.includes(sensLine), `${label}: sensitive line expected: ${out.reason}`); + }; + // Search evidence that credits a Write of a new file in `rel`'s directory, and an Edit of its stem. + const sensCreditTranscript = (rel, n) => writeTranscript([ + humanRecord('update it'), + ...searchRecords(`toolu_sens_ls_${n}`, 'LS', { path: sensDir(rel) }), + ...searchRecords(`toolu_sens_gr_${n}`, 'Grep', { pattern: sensStem(rel), path: projectRoot }), + ...searchRecords(`toolu_sens_ctl_${n}`, 'LS', { path: sensAbs('src/plain') }) + ]); + + gateCase('each sensitive target is denied on first Write despite a qualifying search (control credited)', () => { + sensTargets.forEach((rel, n) => { + clearState(); + const t = sensCreditTranscript(rel, n); + sensAssertDenied(sensRun('Write', { file_path: sensAbs(rel), content: 'x' }, t), `Write ${rel}`); + const control = sensRun('Write', { file_path: sensAbs(`src/plain/ctl_${n}.py`), content: 'x' }, t); + assert.ok(control.context.includes('Prior search seen in this turn'), `control credited for ${rel}: ${control.result.stdout}`); + const state = readState(); + assert.strictEqual(state.fact_force_denials, 1, `${rel}: the denial counts`); + assert.strictEqual(state.fact_force_credited, 1, `${rel}: only the control was credited`); + }); + }); + + gateCase('sensitive Edit and MultiEdit targets are denied despite a stem search', () => { + const t = writeTranscript([ + humanRecord('fix the billing'), + ...searchRecords('toolu_sens_e1', 'Grep', { pattern: 'invoice_maker|login_flow|plain_helper', path: projectRoot }) + ]); + sensAssertDenied(sensRun('Edit', { file_path: sensAbs('src/billing/invoice_maker.py'), old_string: 'a', new_string: 'b' }, t), 'Edit'); + const multi = sensRun('MultiEdit', { + edits: [ + { file_path: sensAbs('src/plain/plain_helper.py'), old_string: 'a', new_string: 'b' }, + { file_path: sensAbs('src/auth/login_flow.py'), old_string: 'a', new_string: 'b' } + ] + }, t); + sensAssertDenied(multi, 'MultiEdit sensitive entry'); + const state = readState(); + assert.strictEqual(state.fact_force_credited, 1, 'the ordinary entry was credited'); + assert.strictEqual(state.fact_force_denials, 2); + }); + + gateCase('a sensitive new file never joins a same-dir sibling gate', () => { + const t = writeTranscript([humanRecord('scaffold keys')]); + assertDeniedNoSibling(sensRun('Write', { file_path: sensAbs('src/keys/loader.py'), content: 'x' }, t), 'ordinary first'); + for (const rel of ['src/keys/server.key', 'src/keys/server.pem', 'src/keys/credentials.py', 'src/keys/secrets.py', 'src/keys/id_rsa_test_fixture.py']) { + sensAssertDenied(sensRun('Write', { file_path: sensAbs(rel), content: 'x' }, t), rel); + } + assertSibling(sensRun('Write', { file_path: sensAbs('src/keys/other.py'), content: 'x' }, t), 'ordinary sibling still collapses'); + assert.strictEqual(readState().sibling_allows, 1); + }); + + gateCase('a sensitive denial opens no sibling gate; sensitive dirs never collapse', () => { + const t = writeTranscript([humanRecord('scaffold auth')]); + sensAssertDenied(sensRun('Write', { file_path: sensAbs('src/vault/api.key'), content: 'x' }, t), 'sensitive first'); + assertDeniedNoSibling(sensRun('Write', { file_path: sensAbs('src/vault/client.py'), content: 'x' }, t), 'ordinary after sensitive'); + sensAssertDenied(sensRun('Write', { file_path: sensAbs('src/auth/a.py'), content: 'x' }, t), 'auth first'); + sensAssertDenied(sensRun('Write', { file_path: sensAbs('src/auth/b.py'), content: 'x' }, t), 'auth second'); + const gates = Object.keys(readState().dir_gates || {}); + assert.ok(!gates.some(k => k.endsWith(stateKey(sensAbs('src/auth')))), `no auth gate: ${JSON.stringify(gates)}`); + assert.deepStrictEqual(gates, [dirGateKey(sensAbs('src/vault'))], 'only the ordinary denial opened a gate'); + }); + + gateCase('sensitive targets are denied after the denial cap is reached (and still count)', () => { + writeState({ checked: [], last_active: Date.now(), fact_force_denials: 5 }); + const env = { GATEGUARD_FACT_FORCE_MAX_DENIALS: '1' }; + sensTargets.forEach((rel, n) => { + const tool = n % 2 ? 'Edit' : 'Write'; + const input = tool === 'Edit' ? { file_path: sensAbs(rel), old_string: 'a', new_string: 'b' } : { file_path: sensAbs(rel), content: 'x' }; + sensAssertDenied(sensRun(tool, input, undefined, env), `${tool} ${rel} past cap`); + }); + const multi = sensRun('MultiEdit', { edits: [ + { file_path: sensAbs('src/plain/capped.py'), old_string: 'a', new_string: 'b' }, + { file_path: sensAbs('src/payments/capped.py'), old_string: 'a', new_string: 'b' } + ] }, undefined, env); + sensAssertDenied(multi, 'MultiEdit sensitive entry past cap'); + const control = sensRun('Edit', { file_path: sensAbs('src/plain/also_capped.py'), old_string: 'a', new_string: 'b' }, undefined, env); + assert.ok(!parseOutput(control.result.stdout).hookSpecificOutput, 'ordinary target passes the cap'); + const state = readState(); + assert.strictEqual(state.fact_force_denials, 5 + sensTargets.length + 1, 'every sensitive denial counts'); + assert.strictEqual(state.cap_allows, 2); + }); + + gateCase('the sensitive line also appears in condensed denials; ordinary denials are unchanged', () => { + writeState({ checked: [], last_active: Date.now(), fact_force_denials: 9 }); + const condensed = sensRun('Edit', { file_path: sensAbs('src/auth/condensed.py'), old_string: 'a', new_string: 'b' }); + sensAssertDenied(condensed, 'condensed'); + assert.ok(condensed.reason.includes('(denial #10 this session)'), condensed.reason); + clearState(); + const full = sensRun('Edit', { file_path: sensAbs('src/auth/full.py'), old_string: 'a', new_string: 'b' }); + sensAssertDenied(full, 'full'); + assert.ok(full.reason.includes(`${sensLine}\n\nPresent the facts, then retry the same operation.`), full.reason); + clearState(); + const ordinary = sensRun('Edit', { file_path: sensAbs('src/author.py'), old_string: 'a', new_string: 'b' }); + assert.strictEqual(ordinary.decision, 'deny'); + assert.ok(!ordinary.reason.includes('Sensitive target'), 'segment-exact: author.py is ordinary'); + for (const rel of ['docs/authoring.md', 'lib/paymentutils.py']) { + clearState(); + const t = writeTranscript([humanRecord('go'), ...searchRecords(`toolu_ord_${rel.length}`, 'LS', { path: sensDir(rel) })]); + const out = sensRun('Write', { file_path: sensAbs(rel), content: 'x' }, t); + assert.ok(out.context.includes('Prior search seen in this turn'), `${rel} is ordinary and credited: ${out.result.stdout}`); + } + }); + + // --- A symlinked directory cannot launder a sensitive real location --- + // symlinked/src/tools -> symlinked/src/auth (a real directory inside the project). + const aliasReady = (() => { + fs.mkdirSync(sensAbs('symlinked/src/auth'), { recursive: true }); + fs.writeFileSync(sensAbs('symlinked/src/auth/login_flow.py'), 'x'); + fs.mkdirSync(sensAbs('symlinked/src/plain'), { recursive: true }); + fs.writeFileSync(sensAbs('symlinked/src/plain/plain_login.py'), 'x'); + try { + fs.symlinkSync(sensAbs('symlinked/src/auth'), sensAbs('symlinked/src/tools'), 'dir'); + return true; + } catch (e) { + if (process.platform === 'win32' && e.code === 'EPERM') return false; // no symlink privilege + throw e; + } + })(); + const aliasEdit = (rel, t, env) => sensRun('Edit', { file_path: sensAbs(rel), old_string: 'a', new_string: 'b' }, t, env); + const assertAliasCredited = (out, label) => + assert.ok(out.context.includes('Prior search seen in this turn'), `${label}: credit expected, got ${out.result.stdout}`); + + gateCase('an Edit through a symlink into auth/ is denied despite a crediting search (direct control credited)', () => { + if (!aliasReady) return; + const t = writeTranscript([ + humanRecord('fix login'), + ...searchRecords('toolu_r1_g1', 'Grep', { pattern: 'login_flow|plain_login|new_login', path: projectRoot }) + ]); + sensAssertDenied(aliasEdit('symlinked/src/tools/login_flow.py', t), 'Edit via the alias'); + sensAssertDenied(sensRun('Write', { file_path: sensAbs('symlinked/src/tools/new_login.py'), content: 'x' }, t), 'Write via the alias'); + assertAliasCredited(aliasEdit('symlinked/src/plain/plain_login.py', t), 'direct non-sensitive path'); + const state = readState(); + assert.strictEqual(state.fact_force_credited, 1); + assert.strictEqual(state.fact_force_denials, 2); + }); + + gateCase('a new file through a symlink into auth/ never joins a same-turn sibling gate', () => { + if (!aliasReady) return; + const { human, transcript } = newTurn('scaffold handlers'); + const gate = { turn: human.uuid, at: Date.now() - 1000, first: 'seeded.py', ordinal: 1 }; + writeState({ + checked: [], + last_active: Date.now(), + fact_force_denials: 1, + dir_gates: { + [dirGateKey(sensAbs('symlinked/src/auth'))]: gate, + [dirGateKey(sensAbs('symlinked/src/tools'))]: gate, + [dirGateKey(sensAbs('symlinked/src/plain'))]: gate + } + }); + sensAssertDenied(sensRun('Write', { file_path: sensAbs('symlinked/src/tools/new_handler.py'), content: 'x' }, transcript), 'sibling via alias'); + assertSibling(sensRun('Write', { file_path: sensAbs('symlinked/src/plain/new_handler.py'), content: 'x' }, transcript), 'control sibling'); + }); + + gateCase('an Edit through a symlink into auth/ is denied past the denial cap (control passes)', () => { + if (!aliasReady) return; + const env = { GATEGUARD_FACT_FORCE_MAX_DENIALS: '0' }; + sensAssertDenied(aliasEdit('symlinked/src/tools/login_flow.py', undefined, env), 'alias past cap 0'); + const control = aliasEdit('symlinked/src/plain/plain_login.py', undefined, env); + assert.ok(!parseOutput(control.result.stdout).hookSpecificOutput, `control passes the cap: ${control.result.stdout}`); + }); + + gateCase('no sensitive output carries permissionDecision "allow"', () => { + assert.ok(sensOutputs.length > 60, `collected outputs: ${sensOutputs.length}`); + for (const stdout of sensOutputs) { + const output = parseOutput(stdout); + const decision = output && output.hookSpecificOutput ? output.hookSpecificOutput.permissionDecision : undefined; + assert.notStrictEqual(decision, 'allow', stdout); + } + }); + + // --- Read-only first shell command --- + const roDecision = result => { + const output = parseOutput(result.stdout); + return output && output.hookSpecificOutput ? output.hookSpecificOutput.permissionDecision : undefined; + }; + const roReason = result => { + const output = parseOutput(result.stdout); + return output && output.hookSpecificOutput ? String(output.hookSpecificOutput.permissionDecisionReason) : ''; + }; + const roBash = (command, extra = {}, env = {}) => runBashHook({ tool_name: 'Bash', tool_input: { command }, ...extra }, env); + const roPs = command => runPowerShellHook({ tool_name: 'PowerShell', tool_input: { command } }); + + gateCase('a read-only first Bash command passes without using the routine gate', () => { + const first = roBash('ls -la src'); + assert.strictEqual(roDecision(first), undefined, first.stdout); + assert.ok(!first.stdout.includes('permissionDecision'), first.stdout); + const state = readState(); + assert.strictEqual(state.routine_readonly_passes, 1); + assert.ok(!(state.checked || []).includes('__bash_session__'), 'routine gate must stay unchecked'); + const second = roBash('git status --short | head'); + assert.strictEqual(roDecision(second), undefined, second.stdout); + assert.strictEqual(readState().routine_readonly_passes, 2); + const routine = roBash('npm test'); + assert.strictEqual(roDecision(routine), 'deny'); + assert.ok(roReason(routine).includes('current user request')); + assert.strictEqual(roDecision(roBash('npm test')), undefined, 'retry after the routine gate passes'); + }); + + gateCase('read-only commands after the routine gate is checked are not counted', () => { + writeState({ checked: ['__bash_session__'], last_active: Date.now() }); + assert.strictEqual(roDecision(roBash('ls')), undefined); + assert.ok(!readState().routine_readonly_passes, 'no read-only pass counted once the gate is checked'); + }); + + gateCase('a read-only first PowerShell command passes and the next cmdlet is still gated', () => { + assert.strictEqual(roDecision(roPs('Get-ChildItem -Recurse -Filter *.js')), undefined); + const routine = roPs('Get-Date'); + assert.strictEqual(roDecision(routine), 'deny'); + assert.ok(roReason(routine).includes('Before the first PowerShell command')); + }); + + gateCase('commands that write, substitute or run unknown programs still draw the routine gate', () => { + for (const command of ['ls > out.txt', 'ls | tee out.txt', 'cat $(pwd)/a', 'find . -exec ls {} +', 'find . -name x -delete', 'git log | xargs echo', 'FOO=1 ls', 'sed -i s/a/b/ f', 'git commit -m x']) { + clearState(); + const result = roBash(command); + assert.strictEqual(roDecision(result), 'deny', `${command}: ${result.stdout}`); + assert.ok(roReason(result).includes('current user request'), `${command}: routine gate expected`); + assert.ok(!readState().routine_readonly_passes, `${command}: no read-only pass`); + } + for (const command of ['Get-ChildItem | Out-File a.txt', 'Get-Content a | Set-Content b', 'pwsh -Command Get-ChildItem', 'Get-Content $env:X']) { + clearState(); + assert.strictEqual(roDecision(roPs(command)), 'deny', command); + } + }); + + gateCase('destructive commands are gated before the read-only check', () => { + for (const command of ['find . -name x -exec rm {} +', 'rm -rf build', 'git branch -D old']) { + clearState(); + const result = roBash(command); + assert.strictEqual(roDecision(result), 'deny', command); + assert.ok(roReason(result).includes('Destructive command detected'), `${command}: destructive gate expected`); + } + clearState(); + const ps = roPs('Remove-Item -Recurse -Force C:/tmp/demo'); + assert.strictEqual(roDecision(ps), 'deny'); + assert.ok(roReason(ps).includes('Destructive command detected')); + }); + + gateCase('a read-only first command in a subagent passes and other commands stay gated', () => { + assert.strictEqual(roDecision(roBash('pwd', { agent_id: 'agent-ro' })), undefined); + const routine = roBash('npm test', { agent_id: 'agent-ro' }); + assert.strictEqual(roDecision(routine), 'deny'); + assert.ok(roReason(routine).includes('current user request')); + }); + + // --- Idle window follows the session key --- + const idleEnvNoIds = { CLAUDE_SESSION_ID: '', ECC_SESSION_ID: '', CLAUDE_TRANSCRIPT_PATH: '' }; + const idleStateFiles = () => fs.readdirSync(stateDir).filter(f => f.startsWith('state-') && f.endsWith('.json')); + const idleSeed = (fileName, ageMs) => { + fs.mkdirSync(stateDir, { recursive: true }); + const file = path.join(stateDir, fileName); + fs.writeFileSync(file, JSON.stringify({ checked: ['__bash_session__'], last_active: Date.now() - ageMs }), 'utf8'); + return file; + }; + const HOUR = 60 * 60 * 1000; + + gateCase('state keyed by a session id survives two idle hours', () => { + writeState({ checked: ['__bash_session__'], last_active: Date.now() - 2 * HOUR }); + assert.strictEqual(roDecision(roBash('npm test')), undefined); + }); + + gateCase('state keyed by a session id expires after eight idle hours', () => { + writeState({ checked: ['__bash_session__'], last_active: Date.now() - 8 * HOUR - 60 * 1000 }); + assert.strictEqual(roDecision(roBash('npm test')), 'deny'); + }); + + gateCase('state keyed by a transcript path survives two idle hours', () => { + const transcript = path.join(stateDir, 'idle-session.jsonl'); + roBash('npm test', { transcript_path: transcript }, idleEnvNoIds); + const [file] = idleStateFiles(); + assert.ok(/^state-tx-/.test(file), file); + const state = JSON.parse(fs.readFileSync(path.join(stateDir, file), 'utf8')); + fs.writeFileSync(path.join(stateDir, file), JSON.stringify({ ...state, last_active: Date.now() - 2 * HOUR }), 'utf8'); + assert.strictEqual(roDecision(roBash('npm test', { transcript_path: transcript }, idleEnvNoIds)), undefined); + }); + + gateCase('state keyed by the project fallback still expires after 30 idle minutes', () => { + const env = { ...idleEnvNoIds, CLAUDE_PROJECT_DIR: path.join(stateDir, 'idle-project') }; + roBash('npm test', {}, env); + const [file] = idleStateFiles(); + assert.ok(/^state-proj-/.test(file), file); + idleSeed(file, 31 * 60 * 1000); + assert.strictEqual(roDecision(roBash('npm test', {}, env)), 'deny'); + }); + + gateCase('module-load pruning keeps session files for the long window and project files for the short one', () => { + const age = (file, ms) => fs.utimesSync(file, new Date(Date.now() - ms), new Date(Date.now() - ms)); + const recentSession = idleSeed('state-recent-session.json', 0); + const oldSession = idleSeed('state-old-session.json', 0); + const recentTx = idleSeed(`state-tx-${'a'.repeat(24)}.json`, 0); + const oldProj = idleSeed(`state-proj-${'b'.repeat(24)}.json`, 0); + const oldTmp = path.join(stateDir, 'state-recent-session.json.tmp.1.abcd'); + fs.writeFileSync(oldTmp, '{}', 'utf8'); + age(recentSession, 2 * HOUR); + age(recentTx, 2 * HOUR); + age(oldSession, 17 * HOUR); + age(oldProj, 61 * 60 * 1000); + age(oldTmp, 61 * 60 * 1000); + loadDirectHook(); + assert.ok(fs.existsSync(recentSession), 'session file inside the long window is kept'); + assert.ok(fs.existsSync(recentTx), 'transcript file inside the long window is kept'); + assert.ok(!fs.existsSync(oldSession), 'session file past the long window is pruned'); + assert.ok(!fs.existsSync(oldProj), 'project fallback file past the short window is pruned'); + assert.ok(!fs.existsSync(oldTmp), 'stale temp file is pruned on the short window'); + }); + + // --- Sensitive targets in subagents --- + const subRun = (tool, toolInput, extra = {}) => + runHook({ tool_name: tool, tool_input: toolInput, session_id: 'subagent-sensitive-session', ...extra }, { CLAUDE_SESSION_ID: '', ECC_SESSION_ID: '' }); + const subEdit = (file_path, extra) => subRun('Edit', { file_path, old_string: 'a', new_string: 'b' }, extra); + const subAgent = { agent_id: 'agent-sens' }; + + gateCase('a subagent Edit of a sensitive file is denied once, then its retry passes', () => { + const first = subEdit('/src/.env', subAgent); + assert.strictEqual(roDecision(first), 'deny', first.stdout); + assert.ok(roReason(first).includes(sensLine)); + assert.strictEqual(roDecision(subEdit('/src/.env', subAgent)), undefined, 'subagent retry passes'); + }); + + gateCase('a subagent pass on a sensitive file does not unlock it for the parent', () => { + subEdit('/src/auth/login.js', subAgent); + assert.strictEqual(roDecision(subEdit('/src/auth/login.js', subAgent)), undefined); + const parent = subEdit('/src/auth/login.js'); + assert.strictEqual(roDecision(parent), 'deny', 'parent first touch is still gated'); + }); + + gateCase('a sensitive file the parent already gated passes in a subagent', () => { + assert.strictEqual(roDecision(subEdit('/src/secrets.json')), 'deny'); + assert.strictEqual(roDecision(subEdit('/src/secrets.json', { parent_tool_use_id: 'toolu_parent' })), undefined); + }); + + gateCase('subagent Write and MultiEdit of sensitive targets are denied; ordinary targets still pass', () => { + const write = subRun('Write', { file_path: '/repo/.github/workflows/ci.yml', content: 'x' }, subAgent); + assert.strictEqual(roDecision(write), 'deny'); + const multi = subRun('MultiEdit', { + edits: [ + { file_path: '/src/plain.js', old_string: 'a', new_string: 'b' }, + { file_path: '/src/payments/charge.js', old_string: 'a', new_string: 'b' } + ] + }, subAgent); + assert.strictEqual(roDecision(multi), 'deny'); + assert.ok(roReason(multi).includes('charge.js')); + assert.strictEqual(roDecision(subEdit('/src/ordinary.js', subAgent)), undefined); + assert.strictEqual(roDecision(subRun('Write', { file_path: '/src/new-file.js', content: 'x' }, subAgent)), undefined); + }); + + gateCase('subagent sensitive denials ignore prior-search credit and the denial cap', () => { + const transcript = writeTranscript([humanRecord('fix it'), ...searchRecords('toolu_sub1', 'Grep', { pattern: 'login', path: '/src' })]); + const result = subRun('Edit', { file_path: '/src/auth/login.js', old_string: 'a', new_string: 'b' }, { ...subAgent, transcript_path: transcript }); + assert.strictEqual(roDecision(result), 'deny'); + clearState(); + const capped = runHook( + { tool_name: 'Edit', tool_input: { file_path: '/src/.env.local', old_string: 'a', new_string: 'b' }, session_id: 'subagent-sensitive-session', ...subAgent }, + { CLAUDE_SESSION_ID: '', ECC_SESSION_ID: '', GATEGUARD_FACT_FORCE_MAX_DENIALS: '0' } + ); + assert.strictEqual(roDecision(capped), 'deny'); + }); + + // --- NotebookEdit --- + const notebookInput = (rel, extra = {}) => ({ notebook_path: sensAbs(rel), new_source: 'x = 1', cell_id: 'c1', ...extra }); + const notebookRun = (rel, transcriptPath, env, extra) => projectRun('NotebookEdit', notebookInput(rel, extra), transcriptPath, env); + const FULL_CODE_QUESTIONS = [ + 'List ALL files that import/require this file', + 'List the public functions/classes affected by this change', + 'If this file reads/writes data files', + "Quote the user's current instruction verbatim" + ]; + + gateCase('a first NotebookEdit is denied with the full code questions, then its retry passes', () => { + const first = notebookRun('nb/analysis.ipynb'); + assert.strictEqual(first.decision, 'deny', first.result.stdout); + assert.ok(first.reason.includes(`Before editing ${sensAbs('nb/analysis.ipynb')}`), first.reason); + for (const line of FULL_CODE_QUESTIONS) assert.ok(first.reason.includes(line), `${line}: ${first.reason}`); + assert.ok(!first.reason.includes(sensLine), 'ordinary notebook has no sensitive line'); + const retry = notebookRun('nb/analysis.ipynb'); + assert.strictEqual(retry.decision, undefined, retry.result.stdout); + assert.strictEqual(readState().fact_force_denials, 1); + }); + + gateCase('NotebookEdit and Edit share one checked key for a notebook', () => { + assert.strictEqual(notebookRun('nb/shared.ipynb').decision, 'deny'); + assert.strictEqual(projectEdit(sensAbs('nb/shared.ipynb')).decision, undefined, 'Edit after a NotebookEdit denial passes'); + assert.strictEqual(projectEdit(sensAbs('nb/other.ipynb')).decision, 'deny'); + assert.strictEqual(notebookRun('nb/other.ipynb').decision, undefined, 'NotebookEdit after an Edit denial passes'); + }); + + gateCase('NotebookEdit is gated by notebook_path, not by a file_path field', () => { + projectEdit(sensAbs('src/checked.py')); + const out = notebookRun('nb/target.ipynb', undefined, {}, { file_path: sensAbs('src/checked.py') }); + assert.strictEqual(out.decision, 'deny', out.result.stdout); + assert.ok(out.reason.includes('target.ipynb'), out.reason); + const exempt = notebookRun('nb/exempt-by-file-path.ipynb', undefined, { GATEGUARD_EXEMPT_GLOBS: '**/other.py' }, { file_path: sensAbs('src/other.py') }); + assert.strictEqual(exempt.decision, 'deny', 'an exempt file_path does not exempt the notebook'); + }); + + gateCase('a NotebookEdit is never a trivial pass', () => { + const out = notebookRun('nb/comment.ipynb', undefined, {}, { new_source: '# just a comment', edit_mode: 'replace' }); + assert.strictEqual(out.decision, 'deny', out.result.stdout); + assert.ok(!out.context.includes('Comment or whitespace-only'), out.context); + assert.ok(!readState().trivial_allows, 'no trivial pass counted'); + }); + + gateCase('a notebook under tests/ gets the test questions', () => { + const out = notebookRun('tests/notebooks/check.ipynb'); + assert.strictEqual(out.decision, 'deny'); + assert.ok(out.reason.includes('Name what behaviour is under test'), out.reason); + }); + + gateCase('a search naming the notebook credits its first NotebookEdit', () => { + const t = writeTranscript([humanRecord('update the notebook'), ...searchRecords('toolu_nb1', 'Grep', { pattern: 'forecast', path: projectRoot })]); + const out = notebookRun('nb/forecast.ipynb', t); + assert.notStrictEqual(out.decision, 'deny', out.result.stdout); + assert.ok(out.context.includes('Prior search seen in this turn'), out.result.stdout); + }); + + gateCase('a sensitive notebook is denied despite a crediting search and past the denial cap', () => { + const t = writeTranscript([humanRecord('update it'), ...searchRecords('toolu_nb2', 'Grep', { pattern: 'tokens', path: projectRoot })]); + sensAssertDenied(notebookRun('src/auth/tokens.ipynb', t), 'credited sensitive notebook'); + clearState(); + sensAssertDenied(notebookRun('src/auth/tokens.ipynb', undefined, { GATEGUARD_FACT_FORCE_MAX_DENIALS: '0' }), 'sensitive notebook past cap 0'); + const control = notebookRun('nb/capped.ipynb', undefined, { GATEGUARD_FACT_FORCE_MAX_DENIALS: '0' }); + assert.strictEqual(control.decision, undefined, 'ordinary notebook passes the cap like Edit'); + }); + + gateCase('NotebookEdit honours exempt globs', () => { + const out = notebookRun('scratch/play.ipynb', undefined, { GATEGUARD_EXEMPT_GLOBS: '**/scratch/**' }); + assert.strictEqual(out.decision, undefined, out.result.stdout); + assert.strictEqual(out.context, ''); + }); + + gateCase('a subagent NotebookEdit passes for an ordinary notebook and is denied for a sensitive one', () => { + const agent = { agent_id: 'agent-notebook' }; + assert.strictEqual(roDecision(subRun('NotebookEdit', { notebook_path: '/src/nb/plain.ipynb', new_source: 'x' }, agent)), undefined); + const sensitive = subRun('NotebookEdit', { notebook_path: '/src/payments/ledger.ipynb', new_source: 'x' }, agent); + assert.strictEqual(roDecision(sensitive), 'deny'); + assert.ok(roReason(sensitive).includes(sensLine)); + }); + + gateCase('a lower-case notebookedit tool name is gated the same way', () => { + assert.strictEqual(projectRun('notebookedit', notebookInput('nb/lower.ipynb')).decision, 'deny'); + }); + + // --- Hard-linked targets --- + const linkedLine = 'Hard-linked target: prior-search credit, sibling collapse, and the denial cap do not apply.'; + const linkPair = (rel, alias, content = '// old\nfoo();\n') => { + fs.mkdirSync(path.dirname(sensAbs(rel)), { recursive: true }); + fs.mkdirSync(path.dirname(sensAbs(alias)), { recursive: true }); + fs.writeFileSync(sensAbs(rel), content); + try { + fs.linkSync(sensAbs(rel), sensAbs(alias)); + return true; + } catch (e) { + if (['EPERM', 'ENOTSUP', 'EXDEV', 'ENOSYS', 'EOPNOTSUPP'].includes(e.code)) return false; + throw e; + } + }; + const linksReady = linkPair('linked/src/report.js', 'linked/vendor/report.js') + && linkPair('linked/src/auth/token.js', 'linked/vendor/token.js') + && linkPair('linked/src/sub.js', 'linked/vendor/sub.js') + && linkPair('linked/scratch/tmp.js', 'linked/vendor/tmp.js'); + for (const rel of ['linked/src/plain.js', 'linked/src/plain_two.js']) fs.writeFileSync(sensAbs(rel), '// old\nfoo();\n'); + const assertLinkedDenied = (out, label) => { + assert.strictEqual(out.decision, 'deny', `${label}: expected deny, got ${out.result.stdout}`); + assert.ok(!out.context, `${label}: no credit, trivial or sibling note`); + assert.ok(out.reason.includes(linkedLine), `${label}: hard-link line expected: ${out.reason}`); + assert.ok(!out.reason.includes(sensLine), `${label}: no sensitive wording`); + }; + const linkedSearch = n => writeTranscript([ + humanRecord('update the report'), + ...searchRecords(`toolu_linked_${n}`, 'Grep', { pattern: 'report|plain|tmp', path: projectRoot }) + ]); + + gateCase('an Edit of a hard-linked file is denied despite a crediting search (control credited)', () => { + if (!linksReady) return; + const t = linkedSearch(1); + assertLinkedDenied(projectEdit(sensAbs('linked/src/report.js'), t), 'original name'); + assertLinkedDenied(projectEdit(sensAbs('linked/vendor/report.js'), t), 'second name'); + assertCredited(projectEdit(sensAbs('linked/src/plain.js'), t), 'single-link control'); + const state = readState(); + assert.strictEqual(state.fact_force_denials, 2); + assert.strictEqual(state.fact_force_credited, 1); + }); + + gateCase('the full code questions are asked for a hard-linked file', () => { + if (!linksReady) return; + const out = projectRun('Edit', { file_path: sensAbs('linked/src/report.js'), old_string: 'foo();', new_string: 'bar();' }); + assertLinkedDenied(out, 'body-only change'); + for (const line of FULL_CODE_QUESTIONS) assert.ok(out.reason.includes(line), `${line}: ${out.reason}`); + }); + + gateCase('a comment-only Edit of a hard-linked file is denied (control passes as trivial)', () => { + if (!linksReady) return; + const input = rel => ({ file_path: sensAbs(rel), old_string: '// old', new_string: '// new' }); + assertLinkedDenied(projectRun('Edit', input('linked/src/report.js')), 'Edit'); + const multi = projectRun('MultiEdit', { file_path: sensAbs('linked/vendor/tmp.js'), edits: [{ old_string: '// old', new_string: '// new' }] }); + assertLinkedDenied(multi, 'MultiEdit'); + const control = projectRun('Edit', input('linked/src/plain_two.js')); + assert.notStrictEqual(control.decision, 'deny', control.result.stdout); + assert.ok(control.context.includes('Comment or whitespace-only change'), control.context); + }); + + gateCase('a hard-linked file is denied past the denial cap (control passes)', () => { + if (!linksReady) return; + const env = { GATEGUARD_FACT_FORCE_MAX_DENIALS: '0' }; + assertLinkedDenied(projectEdit(sensAbs('linked/src/report.js'), undefined, env), 'Edit past cap 0'); + const multi = projectRun('MultiEdit', { edits: [ + { file_path: sensAbs('linked/src/plain.js'), old_string: 'foo();', new_string: 'bar();' }, + { file_path: sensAbs('linked/vendor/tmp.js'), old_string: 'foo();', new_string: 'bar();' } + ] }, undefined, env); + assertLinkedDenied(multi, 'MultiEdit entry past cap 0'); + assert.ok(multi.reason.includes('tmp.js'), multi.reason); + const control = projectEdit(sensAbs('linked/src/plain_two.js'), undefined, env); + assert.strictEqual(control.decision, undefined, control.result.stdout); + }); + + gateCase('a MultiEdit with a hard-linked entry is denied despite a crediting search', () => { + if (!linksReady) return; + const t = linkedSearch(2); + const multi = projectRun('MultiEdit', { edits: [ + { file_path: sensAbs('linked/src/plain.js'), old_string: 'foo();', new_string: 'bar();' }, + { file_path: sensAbs('linked/src/report.js'), old_string: 'foo();', new_string: 'bar();' } + ] }, t); + assertLinkedDenied(multi, 'MultiEdit'); + assert.ok(multi.reason.includes('report.js'), multi.reason); + }); + + gateCase('a subagent Edit of a hard-linked file is denied once; single-link targets still pass', () => { + if (!linksReady) return; + const agent = { agent_id: 'agent-linked', cwd: projectRoot }; + const first = subEdit(sensAbs('linked/vendor/sub.js'), agent); + assert.strictEqual(roDecision(first), 'deny', first.stdout); + assert.ok(roReason(first).includes(linkedLine), roReason(first)); + assert.strictEqual(roDecision(subEdit(sensAbs('linked/vendor/sub.js'), agent)), undefined, 'subagent retry passes'); + assert.strictEqual(roDecision(subEdit(sensAbs('linked/src/plain.js'), agent)), undefined); + }); + + gateCase('a hard-linked sensitive file carries the sensitive wording', () => { + if (!linksReady) return; + const out = projectEdit(sensAbs('linked/src/auth/token.js')); + sensAssertDenied(out, 'sensitive and hard-linked'); + assert.ok(!out.reason.includes(linkedLine), out.reason); + const alias = projectEdit(sensAbs('linked/vendor/token.js')); + assertLinkedDenied(alias, 'ordinary name of a hard link into auth/'); + }); + + gateCase('a new file next to a hard-linked file keeps its allowances', () => { + if (!linksReady) return; + const t = writeTranscript([humanRecord('add a module'), ...searchRecords('toolu_linked_ls', 'LS', { path: sensAbs('linked/src') })]); + const out = projectWrite(sensAbs('linked/src/new_module.js'), t); + assertCredited(out, 'missing new file'); + }); + + gateCase('an exempt glob still exempts a hard-linked file', () => { + if (!linksReady) return; + const out = projectEdit(sensAbs('linked/scratch/tmp.js'), undefined, { GATEGUARD_EXEMPT_GLOBS: 'linked/scratch/**' }); + assert.strictEqual(out.decision, undefined, out.result.stdout); + }); + + gateCase('no hard-link output carries permissionDecision "allow"', () => { + for (const stdout of siblingOutputs) { + const output = parseOutput(stdout); + assert.notStrictEqual(output && output.hookSpecificOutput ? output.hookSpecificOutput.permissionDecision : undefined, 'allow', stdout); + } + }); + + fs.rmSync(classBase, { recursive: true, force: true }); + fs.rmSync(scopeRoot, { recursive: true, force: true }); + + fs.rmSync(projectRoot, { recursive: true, force: true }); + + fs.rmSync(transcriptDir, { recursive: true, force: true }); + // Cleanup only the temp directory created by this test file. try { if (fs.existsSync(stateDir)) { @@ -4220,6 +7432,39 @@ function runTests() { failed++; } + // --- Modules load only on the paths that need them --- + const loadedLibs = (payload, setup = []) => { + const dir = fs.mkdtempSync(path.join(tmpRoot, 'gateguard-lazy-')); + const env = { PATH: process.env.PATH, HOME: dir, GATEGUARD_STATE_DIR: path.join(dir, 'state'), CLAUDE_PROJECT_DIR: dir }; + const probe = [ + 'const hook = require(process.argv[1]);', + 'hook.run(process.argv[2]);', + "const names = Object.keys(require.cache).map(f => require('path').basename(f, '.js'));", + "const libs = names.filter(n => /^(gateguard-|transcript-context$|file-tail$)/.test(n) && !/^gateguard-(fact-force|heredoc)$/.test(n));", + 'process.stdout.write(JSON.stringify(libs.sort()));' + ].join('\n'); + try { + for (const step of setup) spawnSync(process.execPath, ['-e', probe, hookScript, JSON.stringify(step)], { env }); + const out = spawnSync(process.execPath, ['-e', probe, hookScript, JSON.stringify(payload)], { encoding: 'utf8', env }); + return JSON.parse(out.stdout); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } + }; + const lazyEdit = { session_id: 'lazy', tool_name: 'Edit', tool_input: { file_path: '/tmp/gateguard-lazy/src/widget.js', old_string: 'a', new_string: 'b' } }; + const lazyBash = command => ({ session_id: 'lazy', tool_name: 'Bash', tool_input: { command } }); + const lazyCases = [ + ['a first shell command loads only the read-only check', lazyBash('npm test'), [], ['gateguard-readonly-shell', 'gateguard-state']], + ['a shell command after the routine gate loads only the state helpers', lazyBash('npm run build'), [lazyBash('npm test')], ['gateguard-state']], + ['an edit of a checked file loads only the target classification', lazyEdit, [lazyEdit], ['gateguard-state', 'gateguard-target-class']], + ['a first touch without a transcript loads no search matching', lazyEdit, [], ['file-tail', 'gateguard-change-profile', 'gateguard-code-lexer', 'gateguard-file-context', 'gateguard-state', 'gateguard-target-class', 'gateguard-turn-scan']], + ['a first touch of an instruction file skips the change profile', { ...lazyEdit, tool_input: { ...lazyEdit.tool_input, file_path: '/tmp/gateguard-lazy/CLAUDE.md' } }, [], ['file-tail', 'gateguard-state', 'gateguard-target-class', 'gateguard-turn-scan']] + ]; + for (const [label, payload, setup, expected] of lazyCases) { + if (test(`lazy loading: ${label}`, () => assert.deepStrictEqual(loadedLibs(payload, setup), expected))) passed++; + else failed++; + } + const ddResults = runDdRegressionTests(); passed += ddResults.passed; failed += ddResults.failed; diff --git a/tests/hooks/gateguard-metrics.test.js b/tests/hooks/gateguard-metrics.test.js new file mode 100644 index 000000000..1e58caa39 --- /dev/null +++ b/tests/hooks/gateguard-metrics.test.js @@ -0,0 +1,402 @@ +/** + * Tests for opt-in GateGuard decision metrics written by scripts/hooks/gateguard-fact-force.js + */ + +'use strict'; + +const assert = require('assert'); +const crypto = require('crypto'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const { spawnSync } = require('child_process'); + +const runner = path.join(__dirname, '..', '..', 'scripts', 'hooks', 'run-with-flags.js'); +const SESSION_ID = 'gateguard-metrics-session'; +const SESSION_HASH = crypto.createHash('sha256').update(SESSION_ID).digest('hex').slice(0, 12); +const tmpBase = fs.mkdtempSync(path.join(os.tmpdir(), 'gateguard-metrics-')); +const projectRoot = path.join(tmpBase, 'fixture-project-zqx'); +const stateDir = path.join(tmpBase, 'state'); +const metricsFile = path.join(stateDir, 'metrics.jsonl'); +const transcriptDir = path.join(tmpBase, 'transcripts'); +fs.mkdirSync(path.join(projectRoot, 'src', 'widgets'), { recursive: true }); +fs.mkdirSync(transcriptDir, { recursive: true }); + +const FIXTURE_STRINGS = [ + 'fixture-project-zqx', + 'secret-widget-alpha', + 'quiet-helper-beta', + 'credited-gamma', + 'nearmiss-delta', + 'scaffold-epsilon', + 'scaffold-zeta', + 'capped-eta', + 'exempt-theta', + 'subagent-iota', + 'multi-kappa', + 'multi-lambda', + 'token-mu-echo', + 'token-nu-rm', + 'token-xi-ls', + 'token-omicron-ps', + 'CODE-BODY-PI', + 'COMMENT-BODY-RHO' +]; + +let passed = 0; +let failed = 0; + +function test(name, fn) { + try { + fn(); + console.log(` ✓ ${name}`); + passed++; + } catch (error) { + console.log(` ✗ ${name}`); + console.log(` Error: ${error.message}`); + failed++; + } +} + +function resetState() { + fs.rmSync(stateDir, { recursive: true, force: true }); + fs.mkdirSync(stateDir, { recursive: true }); +} + +function runHook(hookId, payload, env = {}) { + const result = spawnSync('node', [runner, hookId, 'scripts/hooks/gateguard-fact-force.js', 'standard,strict'], { + input: JSON.stringify({ cwd: projectRoot, ...payload }), + encoding: 'utf8', + env: { + ...process.env, + ECC_HOOK_PROFILE: 'standard', + GATEGUARD_STATE_DIR: stateDir, + CLAUDE_SESSION_ID: SESSION_ID, + CLAUDE_PROJECT_DIR: projectRoot, + CLAUDE_TRANSCRIPT_PATH: '', + GATEGUARD_METRICS: '1', + ...env + }, + timeout: 15000 + }); + let decision = 'pass'; + try { + const output = JSON.parse(result.stdout); + if (output && output.hookSpecificOutput && output.hookSpecificOutput.permissionDecision) { + decision = output.hookSpecificOutput.permissionDecision; + } + } catch (_) { + decision = 'pass'; + } + return { status: result.status, decision, stdout: result.stdout || '' }; +} + +function edit(filePath, oldString = 'const a = 1;', newString = 'const a = 2;', extra = {}, env = {}) { + return runHook( + 'pre:edit-write:gateguard-fact-force', + { tool_name: 'Edit', tool_input: { file_path: filePath, old_string: oldString, new_string: newString }, ...extra }, + env + ); +} + +function write(filePath, content, extra = {}, env = {}) { + return runHook('pre:edit-write:gateguard-fact-force', { tool_name: 'Write', tool_input: { file_path: filePath, content }, ...extra }, env); +} + +function bash(command, env = {}) { + return runHook('pre:bash:gateguard-fact-force', { tool_name: 'Bash', tool_input: { command } }, env); +} + +function powershell(command, env = {}) { + return runHook('pre:powershell:gateguard-fact-force', { tool_name: 'PowerShell', tool_input: { command } }, env); +} + +function readEvents() { + if (!fs.existsSync(metricsFile)) return []; + return fs + .readFileSync(metricsFile, 'utf8') + .split('\n') + .filter(Boolean) + .map(line => JSON.parse(line)); +} + +function lastEvent() { + const events = readEvents(); + return events[events.length - 1]; +} + +let transcriptSeq = 0; +function writeTranscript(searches) { + transcriptSeq += 1; + const records = [{ type: 'user', uuid: `u-${transcriptSeq}-0`, message: { role: 'user', content: 'fix it' } }]; + searches.forEach(([name, input], index) => { + const id = `toolu_${transcriptSeq}_${index}`; + records.push({ + type: 'assistant', + uuid: `u-${transcriptSeq}-a${index}`, + message: { id: `msg_${id}`, role: 'assistant', content: [{ type: 'tool_use', id, name, input }] } + }); + records.push({ + type: 'user', + uuid: `u-${transcriptSeq}-r${index}`, + message: { role: 'user', content: [{ type: 'tool_result', tool_use_id: id, is_error: false, content: 'ok' }] } + }); + }); + const pendingId = `toolu_pending_${transcriptSeq}`; + records.push({ + type: 'assistant', + uuid: `u-${transcriptSeq}-p`, + message: { id: `msg_${pendingId}`, role: 'assistant', content: [{ type: 'tool_use', id: pendingId, name: 'Edit', input: {} }] } + }); + const file = path.join(transcriptDir, `t-${transcriptSeq}.jsonl`); + fs.writeFileSync(file, records.map(r => JSON.stringify(r)).join('\n') + '\n', 'utf8'); + return { transcript_path: file, tool_use_id: pendingId }; +} + +const src = name => path.join(projectRoot, 'src', name); + +function seed(file, content) { + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync(file, content); +} + +console.log('\n=== Testing GateGuard decision metrics ===\n'); + +test('writes no metrics file when GATEGUARD_METRICS is unset', () => { + resetState(); + assert.strictEqual(edit(src('secret-widget-alpha.js'), 'a', 'b', {}, { GATEGUARD_METRICS: '' }).decision, 'deny'); + assert.strictEqual(bash('echo token-mu-echo', { GATEGUARD_METRICS: '' }).decision, 'deny'); + assert.strictEqual(fs.existsSync(metricsFile), false); +}); + +test('writes no metrics file for values outside the truthy set', () => { + resetState(); + for (const value of ['0', 'false', 'off', 'nope']) { + edit(src(`secret-widget-alpha-${value}.js`), 'a', 'b', {}, { GATEGUARD_METRICS: value }); + } + assert.strictEqual(fs.existsSync(metricsFile), false); +}); + +test('accepts the truthy spellings used by other GateGuard switches', () => { + for (const value of ['1', 'true', ' Yes ', 'on', 'enabled', 'enable']) { + resetState(); + edit(src('secret-widget-alpha.js'), 'a', 'b', {}, { GATEGUARD_METRICS: value }); + assert.strictEqual(readEvents().length, 1, `value ${JSON.stringify(value)}`); + } +}); + +test('records a first-touch denial with schema, session hash, class, questions and profile', () => { + resetState(); + assert.strictEqual(edit(src('secret-widget-alpha.js'), 'const a = 1;', 'const a = 2;').decision, 'deny'); + const events = readEvents(); + assert.strictEqual(events.length, 1); + const event = events[0]; + assert.deepStrictEqual(Object.keys(event).sort(), ['class', 'decision', 'profile', 'questions', 'reason', 'sensitive', 'session', 'tool', 'ts', 'v']); + assert.strictEqual(event.v, 1); + assert.ok(!Number.isNaN(Date.parse(event.ts))); + assert.strictEqual(event.session, SESSION_HASH); + assert.strictEqual(event.tool, 'Edit'); + assert.strictEqual(event.class, 'code'); + assert.strictEqual(event.decision, 'deny'); + assert.strictEqual(event.reason, 'first-touch'); + assert.strictEqual(event.sensitive, false); + assert.deepStrictEqual(event.questions, ['local-callers', 'quote-instruction']); + assert.deepStrictEqual(event.profile, { known: true, language: 'js', touchesPublicSurface: false, touchesData: false, trivial: false }); +}); + +test('records a pass for an already checked file', () => { + edit(src('secret-widget-alpha.js')); + const event = lastEvent(); + assert.strictEqual(event.decision, 'pass-checked'); + assert.strictEqual(event.reason, 'checked'); + assert.strictEqual(event.class, 'code'); + assert.strictEqual(event.questions, null); + assert.strictEqual(event.profile, null); +}); + +test('records a sensitive denial with the full question set', () => { + resetState(); + assert.strictEqual(edit(path.join(projectRoot, '.env.local'), 'A=1', 'A=2').decision, 'deny'); + const event = lastEvent(); + assert.strictEqual(event.decision, 'deny'); + assert.strictEqual(event.reason, 'sensitive'); + assert.strictEqual(event.sensitive, true); + assert.strictEqual(event.class, 'config'); + assert.deepStrictEqual(event.questions, ['config-reader', 'config-effect', 'no-plaintext-secrets', 'quote-instruction']); +}); + +test('records a comment-only edit as trivial', () => { + resetState(); + seed(src('quiet-helper-beta.js'), 'run(); // COMMENT-BODY-RHO old\n'); + const result = edit(src('quiet-helper-beta.js'), 'run(); // COMMENT-BODY-RHO old', 'run(); // COMMENT-BODY-RHO new'); + assert.strictEqual(result.decision, 'pass'); + const event = lastEvent(); + assert.strictEqual(event.decision, 'trivial'); + assert.strictEqual(event.reason, 'comment-whitespace'); + assert.strictEqual(event.profile.trivial, true); +}); + +test('records prior-search credit', () => { + resetState(); + const turn = writeTranscript([['Grep', { pattern: 'credited-gamma', path: path.join(projectRoot, 'src') }]]); + assert.strictEqual(edit(src('credited-gamma.js'), 'a', 'b', turn).decision, 'pass'); + const event = lastEvent(); + assert.strictEqual(event.decision, 'credit'); + assert.strictEqual(event.reason, 'prior-search'); + assert.strictEqual(event.class, 'code'); +}); + +test('records a near-miss denial with the miss code', () => { + resetState(); + const turn = writeTranscript([['Grep', { pattern: 'nearmiss-delta', path: path.join(projectRoot, 'docs') }]]); + assert.strictEqual(edit(src('nearmiss-delta.js'), 'a', 'b', turn).decision, 'deny'); + const event = lastEvent(); + assert.strictEqual(event.decision, 'deny'); + assert.strictEqual(event.reason, 'near-miss:out-of-scope'); +}); + +test('records sibling collapse for a second new file in the same directory', () => { + resetState(); + assert.strictEqual(write(path.join(projectRoot, 'src', 'widgets', 'scaffold-epsilon.js'), 'CODE-BODY-PI').decision, 'deny'); + assert.strictEqual(write(path.join(projectRoot, 'src', 'widgets', 'scaffold-zeta.js'), 'CODE-BODY-PI').decision, 'pass'); + const events = readEvents(); + assert.deepStrictEqual(events.map(e => e.decision), ['deny', 'sibling']); + assert.deepStrictEqual(events[0].questions, ['callers', 'no-duplicate', 'quote-instruction']); + assert.strictEqual(events[1].reason, 'same-turn-dir'); +}); + +test('records the denial cap', () => { + resetState(); + assert.strictEqual(edit(src('capped-eta.js'), 'a', 'b', {}, { GATEGUARD_FACT_FORCE_MAX_DENIALS: '0' }).decision, 'pass'); + const event = lastEvent(); + assert.strictEqual(event.decision, 'cap'); + assert.strictEqual(event.reason, 'max-denials'); +}); + +test('records exempt and subagent passes', () => { + resetState(); + edit(src('exempt-theta.js'), 'a', 'b', {}, { GATEGUARD_EXEMPT_GLOBS: 'src/exempt-*.js' }); + edit(src('subagent-iota.js'), 'a', 'b', { agent_id: 'agent-1' }); + const events = readEvents(); + assert.deepStrictEqual( + events.map(e => [e.decision, e.reason]), + [ + ['pass-exempt', 'exempt-glob'], + ['pass-subagent', 'subagent'] + ] + ); +}); + +test('records a subagent denial on a sensitive target', () => { + resetState(); + assert.strictEqual(edit(path.join(projectRoot, 'src', 'auth', 'subagent-iota.js'), 'a', 'b', { agent_id: 'agent-1' }).decision, 'deny'); + const event = lastEvent(); + assert.strictEqual(event.decision, 'deny'); + assert.strictEqual(event.reason, 'subagent-sensitive'); + assert.strictEqual(event.sensitive, true); +}); + +test('records one line per file decided by a MultiEdit call', () => { + resetState(); + seed(src('multi-kappa.js'), 'x(); // a\ny(); // a\n'); + const result = runHook('pre:edit-write:gateguard-fact-force', { + tool_name: 'MultiEdit', + tool_input: { + edits: [ + { file_path: src('multi-kappa.js'), old_string: 'x(); // a', new_string: 'x(); // b' }, + { file_path: src('multi-kappa.js'), old_string: 'y(); // a', new_string: 'y(); // b' }, + { file_path: src('multi-lambda.js'), old_string: 'a', new_string: 'b' } + ] + } + }); + assert.strictEqual(result.decision, 'deny'); + const events = readEvents(); + assert.deepStrictEqual(events.map(e => [e.tool, e.decision]), [ + ['MultiEdit', 'trivial'], + ['MultiEdit', 'deny'] + ]); +}); + +test('records shell decisions for Bash and PowerShell', () => { + resetState(); + assert.strictEqual(bash('git status').decision, 'pass'); + assert.strictEqual(bash('ls token-xi-ls').decision, 'pass'); + assert.strictEqual(bash('rm -rf /tmp/token-nu-rm').decision, 'deny'); + assert.strictEqual(bash('rm -rf /tmp/token-nu-rm').decision, 'pass'); + assert.strictEqual(bash('echo token-mu-echo > out.txt').decision, 'deny'); + assert.strictEqual(bash('echo token-mu-echo > out.txt').decision, 'pass'); + assert.strictEqual(powershell('Get-ChildItem token-omicron-ps').decision, 'pass'); + assert.strictEqual(bash('echo token-mu-echo', { GATEGUARD_BASH_ROUTINE_DISABLED: '1' }).decision, 'pass'); + const events = readEvents(); + assert.deepStrictEqual( + events.map(e => [e.tool, e.decision, e.reason]), + [ + ['Bash', 'pass', 'readonly-git'], + ['Bash', 'routine-readonly', 'readonly'], + ['Bash', 'destructive-deny', 'destructive'], + ['Bash', 'pass-checked', 'destructive-retry'], + ['Bash', 'routine-deny', 'first-command'], + ['Bash', 'pass-checked', 'routine-checked'], + ['PowerShell', 'pass-checked', 'routine-checked'], + ['Bash', 'pass-exempt', 'routine-disabled'] + ] + ); + for (const event of events) { + assert.strictEqual(event.class, null); + assert.strictEqual(event.sensitive, false); + assert.strictEqual(event.questions, null); + assert.strictEqual(event.profile, null); + } +}); + +test('records a read-only first PowerShell command', () => { + resetState(); + assert.strictEqual(powershell('Get-ChildItem token-omicron-ps').decision, 'pass'); + assert.deepStrictEqual([lastEvent().tool, lastEvent().decision], ['PowerShell', 'routine-readonly']); +}); + +test('never writes paths, commands, content or session ids to the metrics file', () => { + resetState(); + edit(src('secret-widget-alpha.js'), 'CODE-BODY-PI', 'CODE-BODY-PI 2'); + edit(path.join(projectRoot, '.env.local'), 'A=1', 'A=2'); + edit(src('quiet-helper-beta.js'), 'run(); // COMMENT-BODY-RHO old', 'run(); // COMMENT-BODY-RHO new'); + const turn = writeTranscript([['Grep', { pattern: 'nearmiss-delta', path: path.join(projectRoot, 'docs') }]]); + edit(src('nearmiss-delta.js'), 'a', 'b', turn); + write(path.join(projectRoot, 'src', 'widgets', 'scaffold-epsilon.js'), 'CODE-BODY-PI'); + write(path.join(projectRoot, 'src', 'widgets', 'scaffold-zeta.js'), 'CODE-BODY-PI'); + bash('rm -rf /tmp/token-nu-rm'); + bash('echo token-mu-echo > out.txt'); + powershell('Get-ChildItem token-omicron-ps'); + const combined = fs.readFileSync(metricsFile, 'utf8'); + assert.ok(combined.split('\n').filter(Boolean).length >= 9); + for (const fixture of [...FIXTURE_STRINGS, SESSION_ID, tmpBase, '/tmp', '.env', 'rm -rf', 'Grep']) { + assert.ok(!combined.includes(fixture), `metrics must not contain ${fixture}`); + } +}); + +test('rotates the file past 1 MiB and replaces the previous rotation', () => { + resetState(); + fs.writeFileSync(`${metricsFile}.1`, 'stale\n', 'utf8'); + const filler = `${JSON.stringify({ v: 1, filler: 'x'.repeat(200) })}\n`; + fs.writeFileSync(metricsFile, filler.repeat(Math.ceil((1024 * 1024) / filler.length)), 'utf8'); + const before = fs.statSync(metricsFile).size; + assert.ok(before >= 1024 * 1024); + edit(src('secret-widget-alpha.js')); + assert.strictEqual(fs.statSync(`${metricsFile}.1`).size, before); + assert.strictEqual(readEvents().length, 1); + assert.strictEqual(lastEvent().decision, 'deny'); +}); + +test('keeps the gate decision when the metrics file cannot be written', () => { + resetState(); + fs.mkdirSync(metricsFile); + const result = edit(src('secret-widget-alpha.js')); + assert.strictEqual(result.status, 0); + assert.strictEqual(result.decision, 'deny'); + assert.strictEqual(bash('echo token-mu-echo').decision, 'deny'); +}); + +fs.rmSync(tmpBase, { recursive: true, force: true }); + +console.log(`\n ${passed} passed, ${failed} failed\n`); +process.exit(failed > 0 ? 1 : 0); diff --git a/tests/hooks/gateguard-scenarios.test.js b/tests/hooks/gateguard-scenarios.test.js new file mode 100644 index 000000000..2530262ad --- /dev/null +++ b/tests/hooks/gateguard-scenarios.test.js @@ -0,0 +1,84 @@ +/** + * Scenario corpus for scripts/hooks/gateguard-fact-force.js, run against the working tree. + */ + +'use strict'; + +const assert = require('assert'); +const path = require('path'); +const { loadCorpus, runCorpus, summarize } = require('../../scripts/dev/gateguard-eval'); + +const hookFile = path.join(__dirname, '..', '..', 'scripts', 'hooks', 'gateguard-fact-force.js'); + +let passed = 0; +let failed = 0; + +function test(name, fn) { + try { + fn(); + console.log(` ✓ ${name}`); + passed++; + } catch (error) { + console.log(` ✗ ${name}`); + console.log(` Error: ${error.message}`); + failed++; + } +} + +async function main() { + console.log('\n=== Testing gateguard scenario corpus ===\n'); + + const scenarios = loadCorpus(); + const runs = await runCorpus(hookFile, scenarios); + const summary = summarize(runs); + const stepCount = scenarios.reduce((sum, scenario) => sum + scenario.steps.length, 0); + + test('the corpus holds at least twelve scenarios and eighty steps', () => { + assert.ok(scenarios.length >= 12, `${scenarios.length} scenarios`); + assert.ok(stepCount >= 80, `${stepCount} steps`); + }); + + for (const run of runs) { + if (run.skipped) { + console.log(` - ${run.scenario.name} skipped (${run.skipped})`); + continue; + } + test(`${run.scenario.name}: every step gets its expected decision`, () => { + const wrong = run.results + .map((result, index) => ({ result, step: run.scenario.steps[index] })) + .filter(({ result, step }) => result.decision !== step.expect) + .map(({ result, step }) => `${step.id} expected ${step.expect}, got ${result.decision} (${result.kind})`); + assert.deepStrictEqual(wrong, []); + }); + } + + test('no must-deny step is allowed', () => { + const bypasses = summary.steps.filter(step => step.mustDeny && step.decision !== 'deny').map(step => `${step.scenario}/${step.step}`); + assert.deepStrictEqual(bypasses, []); + }); + + test('no step emits an explicit allow decision or throws', () => { + assert.strictEqual(summary.totals.explicitAllows, 0); + assert.strictEqual(summary.totals.errors, 0); + }); + + test('every question in a code denial maps to a known question id', () => { + assert.strictEqual(summary.totals.unmappedQuestions, 0); + }); + + test('only scenarios that need symlinks or hard links may be skipped', () => { + const skipped = runs.filter(run => run.skipped && !run.scenario.symlinks && !run.scenario.hardlinks).map(run => run.scenario.name); + assert.deepStrictEqual(skipped, []); + }); + + console.log(`\nPassed: ${passed}`); + console.log(`Failed: ${failed}`); + process.exitCode = failed > 0 ? 1 : 0; +} + +main().catch(error => { + console.log(` ✗ scenario corpus run failed: ${error.message}`); + console.log('\nPassed: 0'); + console.log('Failed: 1'); + process.exitCode = 1; +}); diff --git a/tests/hooks/hooks.test.js b/tests/hooks/hooks.test.js index 9b4f8b940..899b05a69 100644 --- a/tests/hooks/hooks.test.js +++ b/tests/hooks/hooks.test.js @@ -2812,6 +2812,23 @@ async function runTests() { passed++; else failed++; + if ( + test('hooks.json routes NotebookEdit to the edit-write GateGuard entry only', () => { + const hooks = readHooksConfig(path.join(__dirname, '..', '..', 'hooks', 'hooks.json')); + const gateRoute = hooks.hooks.PreToolUse.find(entry => entry.id === 'pre:edit-write:gateguard-fact-force'); + assert.ok(gateRoute, 'edit-write GateGuard route should exist'); + assert.deepStrictEqual(gateRoute.matcher.split('|').sort(), ['Edit', 'MultiEdit', 'NotebookEdit', 'Write']); + assert.strictEqual(gateRoute.hooks.length, 1, 'the GateGuard entry runs no other hook'); + assert.ok(gateRoute.hooks[0].command.includes('pre:edit-write:gateguard-fact-force')); + const others = hooks.hooks.PreToolUse.filter(entry => entry !== gateRoute && entry.matcher !== '.*'); + for (const entry of others) { + assert.ok(!entry.matcher.split('|').includes('NotebookEdit'), `${entry.id} should not start matching NotebookEdit`); + } + }) + ) + passed++; + else failed++; + if ( test('configured PowerShell routes enforce denial and emit redacted governance evidence', () => { const root = path.join(__dirname, '..', '..'); diff --git a/tests/lib/file-tail.test.js b/tests/lib/file-tail.test.js new file mode 100644 index 000000000..c5aa68a7c --- /dev/null +++ b/tests/lib/file-tail.test.js @@ -0,0 +1,65 @@ +'use strict'; +/** + * Tests for scripts/lib/file-tail.js. + * + * Run with: node tests/lib/file-tail.test.js + */ + +const assert = require('assert'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); + +const { readFileTail, DEFAULT_TRANSCRIPT_TAIL_BYTES } = require(path.join(__dirname, '..', '..', 'scripts', 'lib', 'file-tail.js')); + +console.log('=== Testing file-tail.js ===\n'); + +let passed = 0; +let failed = 0; + +function test(desc, fn) { + try { + fn(); + console.log(` ✓ ${desc}`); + passed++; + } catch (e) { + console.log(` ✗ ${desc}: ${e.message}`); + failed++; + } +} + +const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'file-tail-')); +const file = path.join(dir, 'f.txt'); +fs.writeFileSync(file, 'abcdef'); + +test('the default tail is 256 KiB', () => { + assert.strictEqual(DEFAULT_TRANSCRIPT_TAIL_BYTES, 256 * 1024); +}); + +test('a file smaller than the tail is read whole', () => { + assert.deepStrictEqual(readFileTail(file, 1024), { text: 'abcdef', truncated: false }); +}); + +test('a larger file is cut to its last bytes and marked truncated', () => { + assert.deepStrictEqual(readFileTail(file, 2), { text: 'ef', truncated: true }); +}); + +test('an empty file gives empty text', () => { + const empty = path.join(dir, 'empty.txt'); + fs.writeFileSync(empty, ''); + assert.deepStrictEqual(readFileTail(empty, 10), { text: '', truncated: false }); +}); + +test('a missing file gives null', () => { + assert.strictEqual(readFileTail(path.join(dir, 'missing.txt'), 10), null); +}); + +test('transcript-context still exports the same reader', () => { + const context = require(path.join(__dirname, '..', '..', 'scripts', 'lib', 'transcript-context.js')); + assert.strictEqual(context.readFileTail, readFileTail); + assert.strictEqual(context.DEFAULT_TRANSCRIPT_TAIL_BYTES, DEFAULT_TRANSCRIPT_TAIL_BYTES); +}); + +fs.rmSync(dir, { recursive: true, force: true }); +console.log(`\nResults: Passed: ${passed}, Failed: ${failed}`); +process.exit(failed > 0 ? 1 : 0); diff --git a/tests/lib/gateguard-change-profile.test.js b/tests/lib/gateguard-change-profile.test.js new file mode 100644 index 000000000..dc19cbf08 --- /dev/null +++ b/tests/lib/gateguard-change-profile.test.js @@ -0,0 +1,772 @@ +'use strict'; +/** + * Tests for scripts/lib/gateguard-change-profile.js. + * + * Run with: node tests/lib/gateguard-change-profile.test.js + */ + +const assert = require('assert'); +const path = require('path'); + +const { profileChange, languageFor, UNKNOWN_PROFILE, MAX_SIDE_BYTES } = require( + path.join(__dirname, '..', '..', 'scripts', 'lib', 'gateguard-change-profile.js') +); + +console.log('=== Testing gateguard-change-profile.js ===\n'); + +let passed = 0; +let failed = 0; + +function test(desc, fn) { + try { + fn(); + console.log(` ✓ ${desc}`); + passed++; + } catch (e) { + console.log(` ✗ ${desc}: ${e.message}`); + failed++; + } +} + +const edit = (filePath, oldString, newString) => + profileChange({ filePath, tool: 'Edit', edits: [{ old_string: oldString, new_string: newString }], fileText: oldString }); +const inFile = (filePath, fileText, oldString, newString, replaceAll = false) => + profileChange({ filePath, tool: 'Edit', edits: [{ old_string: oldString, new_string: newString, replace_all: replaceAll }], fileText }).trivial; +const write = (filePath, content) => profileChange({ filePath, tool: 'Write', content }); +const surface = (filePath, oldString, newString) => edit(filePath, oldString, newString).touchesPublicSurface; +const data = (filePath, oldString, newString) => edit(filePath, oldString, newString).touchesData; +const trivial = (filePath, oldString, newString) => edit(filePath, oldString, newString).trivial; + +// ── languages and bounds ── +console.log('languages and bounds:'); + +test('supported extensions map to a language; others do not', () => { + const expected = { + 'a.js': 'js', 'a.mjs': 'js', 'a.cjs': 'js', 'a.jsx': 'js', 'a.ts': 'js', 'a.tsx': 'js', 'a.mts': 'js', 'a.cts': 'js', + 'a.py': 'python', 'a.pyi': 'python', 'a.go': 'go', 'a.rs': 'rust', 'a.java': 'java', 'a.kt': 'kotlin', 'a.kts': 'kotlin', + 'a.cs': 'csharp', 'a.c': 'c', 'a.h': 'c', 'a.cc': 'cpp', 'a.cpp': 'cpp', 'a.cxx': 'cpp', 'a.hpp': 'cpp', 'a.hh': 'cpp', + 'SRC\\A.PY': 'python', 'a.sh': 'shell', 'a.bash': 'shell', 'a.zsh': 'shell', 'a.ps1': 'powershell', 'a.psm1': 'powershell', + 'a.bat': 'batch', 'a.cmd': 'batch', 'B.CMD': 'batch' + }; + for (const [file, lang] of Object.entries(expected)) assert.strictEqual(languageFor(file), lang, file); + for (const file of ['a.rb', 'a.fish', 'a.psd1', 'Makefile', 'a.mk', 'a.yaml', 'a.md', 'a', 'a.swift', '', null]) { + assert.strictEqual(languageFor(file), null, String(file)); + } +}); + +test('unknown language, bad shapes, and over-bound input give the unknown profile', () => { + const big = 'x'.repeat(MAX_SIDE_BYTES + 1); + const cases = [ + edit('a.rb', '# a', '# b'), + edit('Makefile', '# a', '# b'), + profileChange({ filePath: 'a.js', tool: 'Edit', edits: [] }), + profileChange({ filePath: 'a.js', tool: 'Edit', edits: [{ old_string: 1, new_string: 'x' }] }), + profileChange({ filePath: 'a.js', tool: 'Edit', edits: 'nope' }), + profileChange({ filePath: 'a.js', tool: 'Write', content: 42 }), + profileChange({ filePath: 'a.js', tool: 'Read', content: 'x' }), + profileChange(null), + profileChange({ filePath: 'a.js', tool: 'Edit', edits: Array.from({ length: 65 }, () => ({ old_string: 'a', new_string: 'b' })) }), + edit('a.js', big, '// x'), + edit('a.js', '// x', big), + write('a.js', big), + edit('a.js', 'é'.repeat(MAX_SIDE_BYTES / 2 + 1), 'x') + ]; + for (const [index, profile] of cases.entries()) { + assert.deepStrictEqual(profile, UNKNOWN_PROFILE, `case ${index}`); + } + assert.deepStrictEqual(UNKNOWN_PROFILE, { known: false, language: null, touchesPublicSurface: true, touchesData: true, trivial: false }); + assert.ok(Object.isFrozen(UNKNOWN_PROFILE)); +}); + +test('a combined MultiEdit over the total bound is unknown', () => { + const chunk = 'y'.repeat(MAX_SIDE_BYTES - 16); + const edits = Array.from({ length: 5 }, () => ({ old_string: chunk, new_string: chunk })); + assert.deepStrictEqual(profileChange({ filePath: 'a.js', tool: 'Edit', edits }), UNKNOWN_PROFILE); +}); + +// ── public surface ── +console.log('\npublic surface:'); + +test('JS/TS: export, module.exports, exports., and public members touch the surface', () => { + assert.strictEqual(surface('a.js', 'export function f(a) {', 'export function f(a, b) {'), true); + assert.strictEqual(surface('a.ts', 'const x = 1;', 'export const x = 1;'), true); + assert.strictEqual(surface('a.js', 'module.exports = { a };', 'module.exports = { a, b };'), true); + assert.strictEqual(surface('a.js', 'exports.a = a;', 'exports.a = b;'), true); + assert.strictEqual(surface('a.ts', ' public run(): void {', ' public run(x: number): void {'), true); + assert.strictEqual(surface('a.ts', 'export default class A {}', 'export default class B {}'), true); + assert.strictEqual(surface('types.d.ts', 'type A = string;', 'type A = number;'), true, 'declaration files are all surface'); +}); + +test('JS/TS: body-only changes and look-alike words do not touch the surface', () => { + assert.strictEqual(surface('a.js', ' return a + 1;', ' return a + 2;'), false); + assert.strictEqual(surface('a.js', 'const exported = 1;', 'const exported = 2;'), false); + assert.strictEqual(surface('a.js', 'function exporter() {}', 'function exporter(a) {}'), false); + assert.strictEqual(surface('a.js', 'const publicKey = k;', 'const publicKey = j;'), false); +}); + +test('Python: public def/class, dunders, __all__ and __init__.py touch the surface', () => { + assert.strictEqual(surface('a.py', 'def load(path):', 'def load(path, strict):'), true); + assert.strictEqual(surface('a.py', ' async def fetch(self):', ' async def fetch(self, x):'), true); + assert.strictEqual(surface('a.py', 'class Loader:', 'class Loader(Base):'), true); + assert.strictEqual(surface('a.py', ' def __init__(self):', ' def __init__(self, x):'), true); + assert.strictEqual(surface('a.py', "__all__ = ['a']", "__all__ = ['a', 'b']"), true); + assert.strictEqual(surface('pkg/__init__.py', 'x = 1', 'x = 2'), true, '__init__.py is all surface'); + assert.strictEqual(surface('a.py', 'x = 1\nLIMIT = 3', 'x = 1\nLIMIT = 4'), true, 'module-level public name'); +}); + +test('Python: a column-0 constant touches the surface even on the first line of a snippet', () => { + assert.strictEqual(surface('a.py', 'BASE = "https://a.test/#home"', 'BASE = "https://a.test/#admin"'), true); + assert.strictEqual(surface('a.py', 'MAX_RETRIES = 3', 'MAX_RETRIES = 4'), true); + assert.strictEqual(surface('a.py', 'TIMEOUT: float = 1.5', 'TIMEOUT: float = 2.5'), true); + assert.strictEqual(surface('a.py', 'x = 1\nretries = 3', 'x = 1\nretries = 4'), true, 'public name after the first line'); +}); + +test('Python: private, indented, lowercase first-line and comparison lines stay local', () => { + assert.strictEqual(surface('a.py', '_BASE = 1', '_BASE = 2'), false, 'private constant'); + assert.strictEqual(surface('a.py', ' LIMIT = 3', ' LIMIT = 4'), false, 'indented'); + assert.strictEqual(surface('a.py', 'result = compute(a)', 'result = compute(b)'), false, 'first line may start mid-line'); + assert.strictEqual(surface('a.py', 'LIMIT == 3', 'LIMIT == 4'), false, 'comparison'); + assert.strictEqual(surface('a.py', 'X1 = 1', 'X1 = 2'), true, 'digits allowed after the first letter'); +}); + +test('Python: private definitions and bodies do not touch the surface', () => { + assert.strictEqual(surface('a.py', 'def _helper(a):', 'def _helper(a, b):'), false); + assert.strictEqual(surface('a.py', ' return a + 1', ' return a + 2'), false); + assert.strictEqual(surface('a.py', ' x = 1', ' x = 2'), false, 'indented assignment'); + assert.strictEqual(surface('a.py', 'x = 1\n_limit = 3', 'x = 1\n_limit = 4'), false, 'module-level private name'); +}); + +test('Go: capitalised declarations touch the surface; lowercase ones do not', () => { + assert.strictEqual(surface('a.go', 'func Load(p string) error {', 'func Load(p string, s bool) error {'), true); + assert.strictEqual(surface('a.go', 'func (s *Store) Get(k string) {', 'func (s *Store) Get(k int) {'), true); + assert.strictEqual(surface('a.go', 'type Config struct {', 'type Config struct { // x'), true); + assert.strictEqual(surface('a.go', '\tName string', '\tName int'), true, 'exported field'); + assert.strictEqual(surface('a.go', 'func load(p string) {', 'func load(p int) {'), false); + assert.strictEqual(surface('a.go', 'func (s *store) get() {', 'func (s *store) get(k int) {'), false); + assert.strictEqual(surface('a.go', '\treturn x + 1', '\treturn x + 2'), false); +}); + +test('Rust: pub items, impl/trait blocks and macro exports touch the surface', () => { + assert.strictEqual(surface('a.rs', 'pub fn load() {', 'pub fn load(x: u8) {'), true); + assert.strictEqual(surface('a.rs', 'pub(crate) fn load() {', 'pub(crate) fn load(x: u8) {'), true); + assert.strictEqual(surface('a.rs', 'impl Display for A {', 'impl Debug for A {'), true); + assert.strictEqual(surface('a.rs', '#[macro_export]\nmacro_rules! m {', '#[macro_export]\nmacro_rules! n {'), true); + assert.strictEqual(surface('a.rs', 'fn load() {', 'fn load(x: u8) {'), false); + assert.strictEqual(surface('a.rs', ' x + 1', ' x + 2'), false); + assert.strictEqual(surface('a.rs', 'let publish = 1;', 'let publish = 2;'), false); +}); + +test('Rust: derive, repr, no_mangle and extern items touch the surface', () => { + assert.strictEqual(surface('a.rs', '#[derive(Clone)]', '#[derive(Clone, Copy)]'), true); + assert.strictEqual(surface('a.rs', '#[repr(C)]', '#[repr(packed)]'), true); + assert.strictEqual(surface('a.rs', '#[no_mangle]', '#[export_name = "f"]'), true); + assert.strictEqual(surface('a.rs', 'extern "C" fn f() {}', 'extern "C" fn f(x: i32) {}'), true); + assert.strictEqual(surface('a.rs', '#[inline]', '#[inline(always)]'), false); +}); + +test('members of exported containers touch the surface when the file shows the container', () => { + const inside = (filePath, fileText, oldString, newString) => + profileChange({ filePath, tool: 'Edit', edits: [{ old_string: oldString, new_string: newString }], fileText }).touchesPublicSurface; + const hits = [ + ['a.js', 'module.exports = {\n parse,\n format,\n};\n', ' format,\n', ''], + ['a.js', 'exports.defaults = {\n retries: 3,\n};\n', ' retries: 3,', ' retries: 5,'], + ['a.ts', 'export interface Options {\n timeout: number;\n}\n', ' timeout: number;', ' timeout?: number;'], + ['a.ts', 'export enum Color {\n Red,\n Green,\n}\n', ' Green,\n', ''], + ['a.ts', 'export type Mode =\n | "a"\n | "b";\n', ' | "b";', ' | "c";'], + ['a.ts', 'export declare namespace N {\n const x: number;\n}\n', ' const x: number;', ' const x: string;'], + ['a.js', 'export const defaults = {\n retries: 3,\n};\n', ' retries: 3,', ' retries: 5,'], + ['a.js', 'export {\n parse,\n format,\n};\n', ' format,\n', ''], + ['a.ts', 'export class Client {\n constructor(private url: string) {}\n\n get(path: string) {\n return 1;\n }\n}\n', ' get(path: string) {', ' get(path: string, opts?: object) {'], + ['a.ts', 'export default class Client {\n static retries = 3;\n}\n', ' static retries = 3;', ' static retries = 5;'], + ['a.py', '__all__ = [\n "a",\n "b",\n]\n', ' "b",\n', ''], + ['a.py', '@dataclass\nclass Options:\n timeout: int = 5\n', ' timeout: int = 5', ' timeout: float = 5.0'], + ['a.py', 'class Options:\n """Doc."""\n retries = 3\n', ' retries = 3', ' retries = 5'], + ['a.rs', 'pub enum E {\n A,\n B,\n}\n', ' B,\n', ''], + ['a.rs', 'pub struct S {\n a: u8,\n}\n', ' a: u8,', ' a: u16,'], + ['a.rs', 'pub trait T {\n fn f(&self);\n}\n', ' fn f(&self);', ' fn f(&self, x: u8);'], + ['a.rs', 'pub use crate::{\n a,\n b,\n};\n', ' b,\n', ''] + ]; + for (const [file, text, before, after] of hits) assert.strictEqual(inside(file, text, before, after), true, `${file}: ${JSON.stringify(before)}`); + const misses = [ + ['a.js', 'export function f() {\n return 1;\n}\n', ' return 1;', ' return 2;'], + ['a.js', 'export const handler = async (req) => {\n return 1;\n};\n', ' return 1;', ' return 2;'], + ['a.js', 'module.exports = function (x) {\n return x;\n};\n', ' return x;', ' return x + 1;'], + ['a.ts', 'export class Client {\n get(path: string) {\n return 1;\n }\n}\n', ' return 1;', ' return 2;'], + ['a.js', 'function local() {\n return 1;\n}\nmodule.exports = { local };\n', ' return 1;', ' return 2;'], + ['a.py', 'class Options:\n def run(self):\n x = 1\n', ' x = 1', ' x = 2'], + ['a.py', 'class _Private:\n x = 1\n', ' x = 1', ' x = 2'], + ['a.py', 'def f():\n x = 1\n', ' x = 1', ' x = 2'], + ['a.rs', 'pub struct S {\n a: u8,\n}\n\nfn f() {\n x();\n}\n', ' x();', ' y();'], + ['a.rs', 'impl S {\n fn helper(&self) {\n x();\n }\n}\n', ' x();', ' y();'] + ]; + for (const [file, text, before, after] of misses) assert.strictEqual(inside(file, text, before, after), false, `${file}: ${JSON.stringify(before)}`); +}); + +test('Java, Kotlin, C#, C and C++ always count as touching the surface', () => { + for (const file of ['a.java', 'a.kt', 'a.cs', 'a.c', 'a.cpp']) { + const profile = edit(file, 'x = 1;', 'x = 2;'); + assert.strictEqual(profile.known, true, file); + assert.strictEqual(profile.touchesPublicSurface, true, file); + } +}); + +test('Write always touches the surface', () => { + const profile = write('a.js', 'const x = 1;\n'); + assert.strictEqual(profile.known, true); + assert.strictEqual(profile.touchesPublicSurface, true); + assert.strictEqual(profile.trivial, false); +}); + +// ── shell scripts ── +console.log('\nshell scripts:'); + +test('shell: function definitions and exported variables touch the surface', () => { + const hits = [ + ['a.sh', 'deploy() {', 'deploy() {\n set -e'], + ['a.sh', 'function deploy {', 'function deploy_all {'], + ['a.bash', 'function deploy() {', 'function deploy(){'], + ['a.zsh', 'my-helper () {', 'my-helper () { :'], + ['a.sh', 'export PATH="$HOME/bin:$PATH"', 'export PATH="$HOME/.local/bin:$PATH"'], + ['a.sh', 'declare -x MODE=a', 'declare -x MODE=b'], + ['a.ps1', 'function Get-Item2 {', 'function Get-Item2 { param($x)'], + ['a.psm1', 'Export-ModuleMember -Function Get-A', 'Export-ModuleMember -Function Get-A, Get-B'], + ['a.ps1', 'param([string]$Path)', 'param([string]$Path, [switch]$Force)'], + ['a.ps1', '[CmdletBinding()]', '[CmdletBinding(SupportsShouldProcess)]'], + ['a.ps1', 'FILTER Only-Odd { }', 'FILTER Only-Odd { $_ }'], + ['a.bat', 'set X=1', 'set X=2'] + ]; + for (const [file, before, after] of hits) assert.strictEqual(surface(file, before, after), true, `${file}: ${before}`); +}); + +test('shell: bodies and local variables do not touch the surface', () => { + const misses = [ + ['a.sh', ' echo "building"', ' echo "building all"'], + ['a.sh', ' local dir=$1', ' local dir=$2'], + ['a.sh', 'count=1', 'count=2'], + ['a.sh', ' exporter --run', ' exporter --run --fast'], + ['a.ps1', ' Write-Output $x', ' Write-Output $y'], + ['a.ps1', ' $functionName = 1', ' $functionName = 2'] + ]; + for (const [file, before, after] of misses) assert.strictEqual(surface(file, before, after), false, `${file}: ${before}`); +}); + +test('shell: redirection to files, HTTP clients, jq and CSV touch data', () => { + const hits = [ + ['a.sh', 'echo "$line" >> "$OUT"'], + ['a.sh', 'sort < input.txt'], + ['a.sh', 'cmd &> run.log'], + ['a.sh', 'curl -s "$URL"'], + ['a.sh', 'wget -q "$URL"'], + ['a.sh', "jq -r '.items[]'"], + ['a.sh', 'cut -d, -f2 report.csv'], + ['a.ps1', '$r = Invoke-RestMethod $u'], + ['a.ps1', 'Get-Content $p | Out-File out.txt'], + ['a.ps1', 'Import-Csv rows.csv'], + ['a.ps1', 'Set-Content -Path $p -Value $v'], + ['a.bat', 'type a.txt > b.txt'] + ]; + for (const [file, text] of hits) assert.strictEqual(data(file, 'x=1', text), true, `${file}: ${text}`); +}); + +test('shell: descriptor duplication and null devices do not touch data', () => { + const misses = [ + ['a.sh', 'make build 2>&1'], + ['a.sh', 'command -v git >/dev/null 2>&1'], + ['a.sh', 'echo "done" >&2'], + ['a.sh', 'if [ "$a" -gt 1 ]; then echo hi; fi'], + ['a.ps1', 'git fetch > $null'], + ['a.ps1', 'Write-Output ($a -gt 1)'], + ['a.bat', 'del tmp >nul 2>&1'] + ]; + for (const [file, text] of misses) assert.strictEqual(data(file, 'x=1', text), false, `${file}: ${text}`); +}); + +test('shell: comment-only and whitespace-only edits are trivial', () => { + const cases = [ + ['a.sh', '# build\n', '# Build all targets.\n'], + ['a.sh', 'make all # old', 'make all # new'], + ['a.sh', 'make all', 'make all'], + ['a.bash', 'n=${#arr[@]} # old', 'n=${#arr[@]} # new'], + ['a.sh', 'echo "$#" ; # old', 'echo "$#" ; # new'], + ['a.sh', 'echo "${name}" # old', 'echo "${name}" # new'], + ['a.zsh', 'if true; then\n x\nfi', 'if true; then\n x\nfi'], + ['a.ps1', '# old\nGet-Item a', '# new\nGet-Item a'], + ['a.ps1', '<# old\nhelp #>\nGet-Item a', '<# new\nhelp #>\nGet-Item a'], + ['a.ps1', "Write-Output 'a''b' # old", "Write-Output 'a''b' # new"], + ['a.bat', 'REM old\necho a', 'REM new\necho a'], + ['a.cmd', '@rem old\necho a', '@REM new\necho a'], + ['a.bat', 'echo a\n\necho b', 'echo a\necho b'] + ]; + for (const [file, before, after] of cases) { + assert.strictEqual(trivial(file, before, after), true, `${file}: ${JSON.stringify(before)} -> ${JSON.stringify(after)}`); + } +}); + +test('shell: # inside strings, words, heredocs and substitutions is never a comment', () => { + const cases = [ + ['a.sh', 'echo "a # b"', 'echo "a # c"'], + ['a.sh', "echo 'a # b'", "echo 'a # c'"], + ['a.sh', 'echo a#b', 'echo a#c'], + ['a.sh', 'x=#old', 'x=#new'], + ['a.sh', 'cat < ${JSON.stringify(after)}`); + } +}); + +test('shell: directives, continuations and significant whitespace are never trivial', () => { + const cases = [ + ['a.sh', '#!/bin/bash\nset -e', '#!/bin/sh\nset -e'], + ['a.sh', 'make \\\n all', 'make \\\n# note\n all'], + ['a.sh', 'echo a # old', 'echo a\\ # new'], + ['a.ps1', '#Requires -Version 5\nGet-Item a', '#Requires -Version 7\nGet-Item a'], + ['a.ps1', '#requires -RunAsAdministrator', '# requires nothing'], + ['a.ps1', 'Get-Item `\n a', 'Get-Item `\n# note\n a'], + ['a.ps1', '<# a <# b #> #>', '<# a <# c #> #>'], + ['a.ps1', 'Get-Item a <# old #>', 'Get-Item a<# new #>'], + ['a.bat', 'echo a b', 'echo a b'], + ['a.bat', ':: old\necho a', ':: new\necho a'], + ['a.bat', 'REM 100%\necho a', 'REM 50%\necho a'], + ['a.bat', 'echo a ^\nREM old', 'echo a ^\nREM new'], + ['a.bat', 'echo a & REM old', 'echo a & REM new'], + ['a.bat', 'REMARK old', 'REMARK new'], + ['a.sh', 'echo a\r#b', 'echo a\r#c'], + ['a.sh', 'echo a\vb', 'echo a b'], + ['a.ps1', 'Write-Output a\u00a0#b', 'Write-Output a\u00a0#c'], + ['a.bat', '\vREM old\necho a', '\vREM new\necho a'], + ['a.bat', '(\n REM old)\n echo a\n)', '(\n REM new)\n echo a\n)'], + ['a.bat', 'REM a > b', 'REM a > c'] + ]; + for (const [file, before, after] of cases) { + assert.strictEqual(trivial(file, before, after), false, `${file}: ${JSON.stringify(before)} -> ${JSON.stringify(after)}`); + } +}); + +// ── data ── +console.log('\ndata:'); + +test('file I/O, serialisation, SQL, schema and date handling touch data', () => { + const hits = [ + ['a.js', 'const cfg = JSON.parse(raw);'], + ['a.js', "const text = fs.readFileSync(p, 'utf8');"], + ['a.ts', 'await writeFile(out, body);'], + ['a.py', "with open(path) as fh:"], + ['a.py', 'rows = csv.reader(fh)'], + ['a.py', 'doc = yaml.safe_load(fh)'], + ['a.py', "stamp = datetime.now().strftime('%Y')"], + ['a.js', 'const when = new Date(ts).toISOString();'], + ['a.go', 'err := json.Unmarshal(b, &v)'], + ['a.go', 'f, err := os.Open(p)'], + ['a.rs', 'let s = std::fs::read_to_string(p)?;'], + ['a.rs', 'let v: T = serde_json::from_str(&s)?;'], + ['a.java', 'rs = stmt.executeQuery("select id from users");'], + ['a.py', 'cur.execute("INSERT INTO t VALUES (1)")'], + ['a.ts', 'const schema = z.object({});'], + ['a.py', 'df = pd.read_parquet(p)'], + ['a.cs', 'var created = DateTime.UtcNow;'], + ['a.kt', 'val ts = Instant.now().toEpochMilli() // timestamp'] + ]; + for (const [file, text] of hits) { + assert.strictEqual(edit(file, 'x = 1', `${text}\n`).touchesData, true, `${file}: ${text}`); + } +}); + +test('clock reads, encodings, browser storage and data stores touch data', () => { + const hits = [ + ['a.go', 't := time.Now()'], + ['a.go', 'd, err := time.ParseDuration(s)'], + ['a.py', 'started = time.time()'], + ['a.rs', 'let now = SystemTime::now();'], + ['a.rs', 'let t = chrono::Local::now();'], + ['a.py', 'zone = ZoneInfo("UTC")'], + ['a.js', 'const raw = Buffer.from(s, "base64");'], + ['a.js', 'localStorage.setItem("k", v);'], + ['a.js', 'document.cookie = v;'], + ['a.py', 'packed = msgpack.packb(v)'], + ['a.js', 'await prisma.user.findMany();'], + ['a.py', 'engine = sqlalchemy.create_engine(url)'], + ['a.js', 'const text = new TextDecoder(encoding).decode(bytes);'] + ]; + for (const [file, text] of hits) assert.strictEqual(data(file, 'x = 1', text), true, `${file}: ${text}`); + const misses = [['a.js', 'setTimeout(run, 10);'], ['a.go', 'ctx, cancel := context.WithTimeout(ctx, d)'], ['a.py', 'runtime = compute()']]; + for (const [file, text] of misses) assert.strictEqual(data(file, 'x = 1', text), false, `${file}: ${text}`); +}); + +test('ordinary logic and look-alike words do not touch data', () => { + const misses = [ + ['a.js', 'return a + b;'], + ['a.js', 'const updated = validate(candidate);'], + ['a.py', 'total = sum(values) / count'], + ['a.go', 'if n > limit { return errTooMany }'], + ['a.rs', 'let reopened = retry(opener);'], + ['a.js', 'button.opened = true;'] + ]; + for (const [file, text] of misses) { + assert.strictEqual(data(file, 'x = 1', text), false, `${file}: ${text}`); + } +}); + +test('data is judged on both sides of an Edit and on Write content', () => { + assert.strictEqual(data('a.js', 'JSON.parse(x)', 'parse(x)'), true, 'removed data handling still counts'); + assert.strictEqual(write('a.py', 'import json\n').touchesData, true); + assert.strictEqual(write('a.py', 'def f():\n return 1\n').touchesData, false); +}); + +// ── trivial ── +console.log('\ntrivial:'); + +test('comment-only and whitespace-only edits are trivial in every supported language', () => { + const cases = [ + ['a.js', '// old note\nfoo();', '// new note\nfoo();'], + ['a.js', 'foo(); // old', 'foo(); // new'], + ['a.js', '/* a\n * b\n */\nfoo();', '/* a\n * c\n */\nfoo();'], + ['a.js', 'if (x) {\n foo();\n}', 'if (x) {\n foo();\n}'], + ['a.js', 'foo(a, b);', 'foo(a, b); '], + ['a.js', 'foo();\n\nbar();', 'foo();\nbar();'], + ['a.js', 'foo();', '// added\nfoo();'], + ['a.ts', "const s = 'x'; // why", "const s = 'x'; // because"], + ['a.py', '# old\nx = 1', '# new\nx = 1'], + ['a.py', 'x = 1 # old', 'x = 1 # new'], + ['a.py', "s = 'a#b' # old", "s = 'a#b' # new"], + ['a.go', '\t// Load loads.\n\treturn nil', '\t// Load reads.\n\treturn nil'], + ['a.rs', "fn f<'a>(x: &'a str) {} // old", "fn f<'a>(x: &'a str) {} // new"], + ['a.rs', "let c = '\"'; // old", "let c = '\"'; // new"], + ['a.java', 'int x = 1; // old', 'int x = 1; // new'], + ['a.kt', 'val x = "a" // old', 'val x = "a" // new'], + ['a.cs', 'var x = "a"; // old', 'var x = "a"; // new'], + ['a.c', '#include \n/* old */', '#include \n/* new */'], + ['a.cpp', "char c = 'x'; // old", "char c = 'x'; // new"] + ]; + for (const [file, before, after] of cases) { + assert.strictEqual(trivial(file, before, after), true, `${file}: ${JSON.stringify(before)} -> ${JSON.stringify(after)}`); + } +}); + +test('a comment change that also edits code is not trivial', () => { + assert.strictEqual(trivial('a.js', '// old\nfoo(1);', '// new\nfoo(2);'), false); + assert.strictEqual(trivial('a.py', 'x = 1 # old', 'x = 2 # new'), false); + assert.strictEqual(trivial('a.js', 'foo();', 'foo();\nbar();'), false); + assert.strictEqual(trivial('a.js', 'foo(); bar();', 'foo();\nbar();'), false, 'line structure is code'); +}); + +test('whitespace inside strings and between tokens is code', () => { + assert.strictEqual(trivial('a.js', "s = 'a b';", "s = 'a b';"), false); + assert.strictEqual(trivial('a.js', 'x = a+b;', 'x = a + b;'), false); + assert.strictEqual(trivial('a.py', 'x = f"a"', 'x = f "a"'), false); + assert.strictEqual(trivial('a.c', 'int x;', 'intx;'), false); + assert.strictEqual(trivial('a.c', 'a/* c */b', 'ab'), false); +}); + +test('indentation changes are never trivial in Python', () => { + assert.strictEqual(trivial('a.py', 'if x:\n y()', 'if x:\n y()'), false); + assert.strictEqual(trivial('a.py', 'if x:\n y()', 'if x:\n\ty()'), false); + assert.strictEqual(trivial('a.py', 'if x:\n y()\n # note', 'if x:\n y()\n # note moved'), true, 'comment lines carry no indentation'); +}); + +test('C preprocessor lines are code', () => { + assert.strictEqual(trivial('a.c', '#define LIMIT 1', '#define LIMIT 2'), false); + assert.strictEqual(trivial('a.h', '#include "a.h"', '#include "b.h"'), false); + assert.strictEqual(trivial('a.c', '#if 0\nx();\n#endif', '#if 1\nx();\n#endif'), false); +}); + +test('multi-line strings, templates and raw strings are never trivial', () => { + const cases = [ + ['a.js', 'const s = `\n// a\n`;', 'const s = `\n// b\n`;'], + ['a.js', '// a\n`;', '// b\n`;'], + ['a.go', 'q := `\n// a\n`', 'q := `\n// b\n`'], + ['a.py', 'x = """\n# a\n"""', 'x = """\n# b\n"""'], + ['a.py', "x = '''# a'''", "x = '''# b'''"], + ['a.py', 'x = f"{d["#"]}" # old', 'x = f"{d["#"]}" # new'], + ['a.java', 'String s = """\n// a\n""";', 'String s = """\n// b\n""";'], + ['a.kt', 'val s = "${m["//"]}" // a', 'val s = "${m["//"]}" // b'], + ['a.cs', 'var s = @"a\n// a";', 'var s = @"a\n// b";'], + ['a.cs', 'var s = $"{d["//"]}"; // a', 'var s = $"{d["//"]}"; // b'], + ['a.rs', 'let s = r#"// a"#;', 'let s = r#"// b"#;'], + ['a.rs', 'let s = "line\n// a";', 'let s = "line\n// b";'], + ['a.cpp', 'auto s = R"(// a)";', 'auto s = R"(// b)";'], + ['a.c', 'char *s = "a\\\n// a";', 'char *s = "a\\\n// b";'] + ]; + for (const [file, before, after] of cases) { + assert.strictEqual(trivial(file, before, after), false, `${file}: ${JSON.stringify(before)}`); + } +}); + +test('ambiguous JS syntax is never trivial', () => { + assert.strictEqual(trivial('a.js', "x = /'/; y = '//'", "x = /'/; y = '//x'"), false, 'regex literal'); + assert.strictEqual(trivial('a.js', 'x = a / b; // old', 'x = a / b; // new'), false, 'division'); + assert.strictEqual(trivial('a.jsx', '

// old

', '

// new

'), false, 'JSX text'); + assert.strictEqual(trivial('a.js', 'x = 1;\n--> old', 'x = 1;\n--> new'), false, 'HTML close comment'); +}); + +test('comment tricks that can hide code are never trivial', () => { + assert.strictEqual(trivial('a.c', '// note\nx();', '// note \\\nx();'), false, 'backslash continues a C line comment'); + assert.strictEqual(trivial('a.c', '// note\nx();', '// note ??/\nx();'), false, 'trigraph continuation'); + assert.strictEqual(trivial('a.rs', '/* a /* b */ */', '/* a /* c */ */'), false, 'nested block comment'); + assert.strictEqual(trivial('a.js', '/* open', '/* still open'), false, 'unterminated block comment'); + assert.strictEqual(trivial('a.js', "s = 'open", "s = 'open2"), false, 'unterminated string'); + assert.strictEqual(trivial('a.py', 'x = a \\\n + b', 'x = a \\ \n + b'), false, 'whitespace after a line continuation'); +}); + +test('a code line ending in a line continuation is never trivial', () => { + const cases = [ + ['a.c', '#define A 1 \\\n// c\nint x;', '#define A 1 \\\nint x;'], + ['a.c', '#define A 1 \\\nint x;', '#define A 1 \\\n// c\nint x;'], + ['a.cpp', '#define F(x) \\\n /* a */ (x)', '#define F(x) \\\n /* b */ (x)'], + ['a.h', 'int x; \\', 'int x; \\ '], + ['a.py', 'y = 1 + \\\n 2\n# c', 'y = 1 + \\\n 2'], + ['a.py', 'y = 1 + \\', 'y = 1 + \\'], + ['a.js', "s = 'a' + \\\n// c", "s = 'a' + \\\n// d"] + ]; + for (const [file, before, after] of cases) { + assert.strictEqual(trivial(file, before, after), false, `${file}: ${JSON.stringify(before)} -> ${JSON.stringify(after)}`); + } +}); + +test('directive comments are code: shebangs, encodings, type, lint and build directives', () => { + const cases = [ + ['a.py', '#!/usr/bin/env python3\nprint(1)', '#!/usr/bin/env -S python3 -X dev\nprint(1)'], + ['a.py', '# -*- coding: utf-8 -*-\ns = 1', '# -*- coding: latin-1 -*-\ns = 1'], + ['a.py', '# vim: set fileencoding=utf-8 :\ns = 1', '# vim: set fileencoding=latin-1 :\ns = 1'], + ['a.py', 'x = f() # type: ignore', 'x = f() # ok'], + ['a.py', 'import os # noqa: F401', 'import os # unused'], + ['a.py', 'x = 1 # pylint: disable=invalid-name', 'x = 1 # fine'], + ['a.py', 'call(cmd, shell=True) # nosec', 'call(cmd, shell=True) # reviewed'], + ['a.py', 'def f(): # pragma: no cover\n pass', 'def f(): # rarely hit\n pass'], + ['a.py', 'x = 1', 'x = 1 # fmt: skip'], + ['a.ts', '// @ts-expect-error\nconst n: number = s;', '// expected\nconst n: number = s;'], + ['a.ts', 'const n = s; // @ts-ignore', 'const n = s; // ignore'], + ['a.ts', '/// \nx();', '/// \nx();'], + ['a.js', '/* eslint-disable no-eval */\neval(x);', '/* note */\neval(x);'], + ['a.js', 'eval(x); // eslint-disable-line', 'eval(x); // reviewed'], + ['a.js', 'const m = import(/* webpackChunkName: "a" */ "./a");', 'const m = import(/* webpackChunkName: "b" */ "./a");'], + ['a.js', 'const v = /*#__PURE__*/ make();', 'const v = /* pure */ make();'], + ['a.js', '// @flow\nx();', '// flow\nx();'], + ['a.js', '/** @type {number} */\nlet n;', '/** @type {string} */\nlet n;'], + ['a.js', '/* global jQuery */\njQuery();', '/* jQuery */\njQuery();'], + ['a.js', 'x(); // istanbul ignore next', 'x(); // rarely'], + ['a.js', 'x(); // prettier-ignore', 'x(); // keep'], + ['a.go', '//go:build linux\n\npackage o', '//go:build ignore\n\npackage o'], + ['a.go', '// +build linux\n\npackage o', '// +build ignore\n\npackage o'], + ['a.go', '//go:embed a.txt\nvar s string', '//go:embed b.txt\nvar s string'], + ['a.go', '//go:generate stringer -type=A\ntype A int', '//go:generate rm -rf .\ntype A int'], + ['a.go', '//export Add\nfunc Add() {}', '// Add adds\nfunc Add() {}'], + ['a.go', 'x := f() //nolint:errcheck', 'x := f() // fine'], + ['a.go', '// Code generated by protoc. DO NOT EDIT.\npackage p', '// Written by hand.\npackage p'], + ['a.rs', '/// ```\n/// assert!(true);\n/// ```\nfn f() {}', '/// ```\n/// assert!(false);\n/// ```\nfn f() {}'], + ['a.rs', '//! assert!(true);\nfn f() {}', '//! assert!(false);\nfn f() {}'], + ['a.rs', '/** assert!(true); */\nfn f() {}', '/** assert!(false); */\nfn f() {}'], + ['a.c', 'case 1: x(); /* fallthrough */\ncase 2: y();', 'case 1: x(); /* next */\ncase 2: y();'], + ['a.cpp', 'int x = f(); // NOLINT', 'int x = f(); // ok'], + ['a.java', 'int x = f(); // NOSONAR', 'int x = f(); // ok'], + ['a.cs', 'var x = f(); // ReSharper disable once All', 'var x = f(); // ok'], + ['a.kt', 'val x = f() // ktlint-disable', 'val x = f() // ok'], + ['a.sh', '# shellcheck disable=SC2086\necho $x', '# quoting is fine\necho $x'], + ['a.sh', 'echo a\n#!/bin/sh', 'echo a\n#!/bin/bash'], + ['a.ps1', '# PSScriptAnalyzer suppression\nGet-Item a', '# note\nGet-Item a'], + ['a.bat', 'REM vim: set ff=dos :\necho a', 'REM note\necho a'], + ['a.py', 'API_KEY = load() # pragma: allowlist secret', 'API_KEY = load() # loaded'], + ['a.js', 'const k = "x"; // gitleaks:allow', 'const k = "x"; // ok'], + ['a.go', 'fmt.Println(42)\n// Output: 42', 'fmt.Println(42)\n// Output: 43'], + ['a.zsh', '#compdef _git git\nx', '#compdef _hg hg\nx'] + ]; + for (const [file, before, after] of cases) { + assert.strictEqual(trivial(file, before, after), false, `${file}: ${JSON.stringify(before)} -> ${JSON.stringify(after)}`); + } +}); + +test('an unchanged directive does not stop a nearby comment edit from being trivial', () => { + assert.strictEqual(trivial('a.py', '#!/usr/bin/env python3\n# old\nx = 1', '#!/usr/bin/env python3\n# new\nx = 1'), true); + assert.strictEqual(trivial('a.ts', '// @ts-check\n// old\nx();', '// @ts-check\n// new\nx();'), true); + assert.strictEqual(trivial('a.sh', '#!/bin/sh\n# old\nmake', '#!/bin/sh\n# new\nmake'), true); +}); + +test('Write, unknown languages and MultiEdit with any non-trivial entry are not trivial', () => { + assert.strictEqual(write('a.js', '// only a comment\n').trivial, false); + assert.strictEqual(trivial('a.rb', '# a', '# b'), false); + assert.strictEqual(trivial('a.fish', '# a', '# b'), false); + const multi = edits => profileChange({ filePath: 'a.js', tool: 'Edit', edits, fileText: '// a\nfoo();\n// c\nf(1);\n' }); + assert.strictEqual(multi([{ old_string: '// a', new_string: '// b' }, { old_string: '// c', new_string: '// d' }]).trivial, true); + assert.strictEqual(multi([{ old_string: '// a', new_string: '// b' }, { old_string: 'f(1)', new_string: 'f(2)' }]).trivial, false); +}); + +// ── file context ── +console.log('\nfile context:'); + +test('an edit is trivial only against the file it applies to', () => { + const change = { filePath: 'a.js', tool: 'Edit', edits: [{ old_string: '// a', new_string: '// b' }] }; + assert.strictEqual(profileChange(change).trivial, false, 'no file text'); + assert.strictEqual(profileChange({ ...change, fileText: null }).trivial, false, 'unreadable file'); + assert.strictEqual(profileChange({ ...change, fileText: 'x();\n' }).trivial, false, 'old_string not in the file'); + assert.strictEqual(profileChange({ ...change, fileText: '// a\n// a\n' }).trivial, false, 'ambiguous without replace_all'); + assert.strictEqual(profileChange({ ...change, fileText: 'x();\n// a\ny();\n' }).trivial, true); + assert.strictEqual(inFile('a.js', 'x();\n', '', '// a'), false, 'empty old_string'); + assert.strictEqual(inFile('a.js', '// a\n// a\n', '// a', '// b', true), true, 'replace_all over comments'); + assert.strictEqual(inFile('a.js', `// a\n${'x();\n'.repeat(10)}`, '// a', '// b'), true); + assert.strictEqual(inFile('a.js', `// a\n${'x'.repeat(2 * 1024 * 1024)}`, '// a', '// b'), false, 'file over the bound'); + for (const pattern of ["$'", '$`', '$&', '$$', '$1', '$']) { + assert.strictEqual(inFile('a.js', 'x();\n// a\ny();\n', '// a', `// b ${pattern}`), false, `replacement pattern ${pattern}`); + } + assert.strictEqual(inFile('a.js', 'x();\n// a\ny();\n', '// a', '// costs $5 or $x'), false, 'a dollar digit reads as a pattern'); + assert.strictEqual(inFile('a.js', 'x();\n// a\ny();\n', '// a', '// costs $ x'), true); +}); + +test('comment-looking text inside a multi-line construct of the file is not trivial', () => { + const cases = [ + ['a.js', 'const q = `\n // hint\n SELECT 1\n`;\n', ' // hint', ' // other'], + ['a.js', 'const q = html`\n

${x}

\n // a\n`;\n', ' // a', ' // b'], + ['a.js', "const s = 'a\\\n// a';\n", "// a';", "// b';"], + ['a.js', 'const s = "abc // q";\n', '// q"', '// r"'], + ['a.js', 'x = "// a"; // a\n', '// a', '// b', true], + ['a.js', '/* start\nmiddle\n// a */ x(1);\n', 'x(1)', 'x(2)'], + ['a.ts', 'const t = `\n${a}\n// a\n`;\n', '// a', '// b'], + ['a.py', 'Q = """\n# limit 10\nSELECT 1\n"""\n', '# limit 10', '# limit 99'], + ['a.py', "Q = f'''\n{x}\n# a\n'''\n", '# a', '# b'], + ['a.py', 'Q = (\n "a"\n)\nS = """x\ny\n# a\n"""\n', '# a', '# b'], + ['a.go', 'var q = `\nfirst\n// a\n`\n', '// a', '// b'], + ['a.rs', 'let s = "line\nnext\n// a";\n', '// a";', '// b";'], + ['a.rs', 'let s = r#"\nx\n// a\n"#;\n', '// a', '// b'], + ['a.rs', '/* a /* b */\nx\n// c */ y(1);\n', 'y(1)', 'y(2)'], + ['a.cpp', 'auto s = R"x(\nfirst\n// a\n)x";\n', '// a', '// b'], + ['a.cs', 'var s = @"\nfirst\n// a";\n', '// a";', '// b";'], + ['a.java', 'String s = """\nfirst\n// a\n""";\n', '// a', '// b'], + ['a.kt', 'val s = """\nfirst\n// a\n"""\n', '// a', '// b'], + ['a.sh', 'cat > colors.txt < Get-Item a\n', 'Get-Item a', 'Remove-Item a'] + ]; + for (const [file, text, before, after, all] of cases) { + assert.strictEqual(inFile(file, text, before, after, all), false, `${file}: ${JSON.stringify(text)}`); + } +}); + +test('an edit next to a continued line is not trivial', () => { + const cases = [ + ['a.c', '#define A 1 \\\n// c\nint x;\n', '// c\n', ''], + ['a.c', '#define A(x) \\\n (x) + \\\n 1\n// c\n', '// c', '// d'], + ['a.py', 'y = 1 + \\\n 2\n# c\nz()\n', '# c\n', ''], + ['a.sh', 'make \\\n all\n# c\nrm x\n', '# c\n', ''], + ['a.ps1', 'Get-Item `\n a\n# c\nRemove-Item b\n', '# c\n', ''], + ['a.bat', 'echo a ^\nb\nREM c\necho d\n', 'REM c\n', ''] + ]; + for (const [file, text, before, after] of cases) { + assert.strictEqual(inFile(file, text, before, after), false, `${file}: ${JSON.stringify(text)}`); + } +}); + +test('an edit that joins or splits a line across the snippet boundary is not trivial', () => { + assert.strictEqual(inFile('a.js', 'a();\n// c\nb();\n', '// c\n', '// c '), false, 'next line joins the comment'); + assert.strictEqual(inFile('a.js', 'a(); // x\nb();\n', '\nb();', ' b();'), false, 'line joins the previous comment'); + assert.strictEqual(inFile('a.py', 'x = 1\n# c\nimport os\n', '# c\n', '# c'), false); + assert.strictEqual(inFile('a.sh', 'echo a\n# c\nrm -rf b\n', '# c\n', '# c '), false); +}); + +test('comment edits after closed multi-line constructs stay trivial', () => { + const cases = [ + ['a.js', 'const q = `a\n${b}\nc`;\nconst r = /[/"]+/g;\nconst d = x / 2;\n\n// old\nfoo();\n', '// old', '// new'], + ['a.js', '#!/usr/bin/env node\nconst s = "a // b";\n\n// old\nfoo();\n', '// old', '// new'], + ['a.ts', 'function f(x: Array): T {\n return x[0];\n}\n\n// old\nfoo();\n', '// old', '// new'], + ['a.py', 'def f():\n """Doc with # hash\n and more.\n """\n return f"{x!r}"\n\n# old\nfoo()\n', '# old', '# new'], + ['a.go', 'var q = `raw\nstring`\n\n// old\nfunc f() {}\n', '// old', '// new'], + ['a.rs', "fn f<'a>(s: &'a str) -> char {\n let r = r#\"raw \" quote\"#;\n '\"'\n}\n\n// old\nfn g() {}\n", '// old', '// new'], + ['a.cpp', 'auto s = R"x(\nraw )" still\n)x";\n\n// old\nint y;\n', '// old', '// new'], + ['a.cs', 'var s = @"a\n""b""";\nvar t = $"{name} is {age:D2}";\n\n// old\nint y;\n', '// old', '// new'], + ['a.java', 'String s = """\ntext\n""";\n\n// old\nint y;\n', '// old', '// new'], + ['a.sh', "cat <<'EOF'\n# body\nEOF\necho 'a\nb'\n\n# old\nmake\n", '# old', '# new'], + ['a.ps1', '$s = @"\nbody # x\n"@\n<# help\n#>\n\n# old\nGet-Item a\n', '# old', '# new'], + ['a.bat', 'echo a ^\nb\n\nREM old\necho c\n', 'REM old', 'REM new'] + ]; + for (const [file, text, before, after] of cases) { + assert.strictEqual(inFile(file, text, before, after), true, `${file}: ${JSON.stringify(text)}`); + } +}); + +test('file context work stays linear on hostile files', () => { + const size = 512 * 1024; + const texts = ['`'.repeat(size), '/*'.repeat(size / 2), '"\\'.repeat(size / 2), '< { + const profile = edit('a.js', '// export JSON later\nfoo();', '// export JSON soon\nfoo();'); + assert.strictEqual(profile.trivial, true); + assert.strictEqual(profile.known, true); +}); + +// ── bounded work ── +console.log('\nbounded work:'); + +test('64 KiB adversarial inputs profile in linear time', () => { + const size = MAX_SIDE_BYTES; + const inputs = [ + '/*'.repeat(size / 2), + '//'.repeat(size / 2), + "'".repeat(size), + '"\\'.repeat(size / 2), + '\\\n'.repeat(size / 2), + 'a'.repeat(size), + ' '.repeat(size - 1) + 'x', + '\n'.repeat(size), + 'export '.repeat(Math.floor(size / 7)), + 'def _'.repeat(Math.floor(size / 5)), + 'aA'.repeat(size / 2), + '# \\'.repeat(Math.floor(size / 3)), + 'func ('.repeat(Math.floor(size / 6)), + 'select from '.repeat(Math.floor(size / 12)), + '"${'.repeat(Math.floor(size / 3)) + '}', + '<# '.repeat(Math.floor(size / 3)), + "'' ".repeat(Math.floor(size / 3)), + '> '.repeat(size / 2) + ]; + for (const [index, input] of inputs.entries()) { + for (const file of ['a.js', 'a.py', 'a.go', 'a.rs', 'a.c', 'a.sh', 'a.ps1', 'a.bat']) { + const start = process.hrtime.bigint(); + edit(file, input, `${input} `); + write(file, input); + const ms = Number(process.hrtime.bigint() - start) / 1e6; + assert.ok(ms < 250, `input ${index} in ${file} took ${ms.toFixed(1)} ms`); + } + } +}); + +test('never throws on hostile shapes', () => { + const hostile = [ + { filePath: 'a.js', tool: 'Edit', edits: [null, { old_string: 'a', new_string: 'b' }] }, + { filePath: 'a.js', tool: 'Edit', edits: [{ get old_string() { throw new Error('boom'); }, new_string: 'b' }] }, + { filePath: { toString() { throw new Error('boom'); } }, tool: 'Edit', edits: [] }, + { filePath: 'a.js', tool: 'Write', get content() { throw new Error('boom'); } } + ]; + for (const input of hostile) assert.deepStrictEqual(profileChange(input), UNKNOWN_PROFILE); +}); + +console.log(`\nResults: Passed: ${passed}, Failed: ${failed}`); +process.exit(failed > 0 ? 1 : 0); diff --git a/tests/lib/gateguard-code-lexer.test.js b/tests/lib/gateguard-code-lexer.test.js new file mode 100644 index 000000000..8b9e9de6b --- /dev/null +++ b/tests/lib/gateguard-code-lexer.test.js @@ -0,0 +1,99 @@ +'use strict'; +/** + * Tests for scripts/lib/gateguard-code-lexer.js. + * + * Run with: node tests/lib/gateguard-code-lexer.test.js + */ + +const assert = require('assert'); +const path = require('path'); + +const { isTrivialEdit, shCodeLines, psCodeLines, batchCodeLines, normalizeShellLine } = require( + path.join(__dirname, '..', '..', 'scripts', 'lib', 'gateguard-code-lexer.js') +); + +console.log('=== Testing gateguard-code-lexer.js ===\n'); + +let passed = 0; +let failed = 0; + +function test(desc, fn) { + try { + fn(); + console.log(` ✓ ${desc}`); + passed++; + } catch (e) { + console.log(` ✗ ${desc}: ${e.message}`); + failed++; + } +} + +const JS = { lineComment: '//', blockComment: true, quotes: '"\'', indentSensitive: false, flavor: 'js', forbiddenCode: '`/', jsx: true }; +const PY = { lineComment: '#', blockComment: false, quotes: '"\'', indentSensitive: true, tripleQuotes: true, fStrings: true }; + +test('a comment-only change to C-family code is trivial', () => { + assert.strictEqual(isTrivialEdit('a(); // x', 'a(); // y', JS), true); +}); + +test('comment markers inside a string are code', () => { + assert.strictEqual(isTrivialEdit('const s = "a // b";', 'const s = "a // c";', JS), false); + assert.strictEqual(isTrivialEdit("const s = 'x /* y */ z';", "const s = 'x /* q */ z';", JS), false); +}); + +test('an escaped quote does not end a string', () => { + assert.strictEqual(isTrivialEdit('const s = "a\\" // b";', 'const s = "a\\" // c";', JS), false); +}); + +test('a long block comment with stars and slashes stays a comment', () => { + const before = 'a();\n/* one * two / three\n * four ** five\n */\nb();'; + const after = 'a();\n/* one * two / 3\n * 4 ** five\n */\nb();'; + assert.strictEqual(isTrivialEdit(before, after, JS), true); +}); + +test('a line comment ending in CRLF keeps the next line as code', () => { + assert.strictEqual(isTrivialEdit('a(); // x\r\nb();', 'a(); // y\r\nc();', JS), false); + assert.strictEqual(isTrivialEdit('a(); // x\r\nb();', 'a(); // y\r\nb();', JS), true); +}); + +test('a code change is not trivial', () => { + assert.strictEqual(isTrivialEdit('a();', 'b();', JS), false); +}); + +test('a directive comment counts as code', () => { + assert.strictEqual(isTrivialEdit('// eslint-disable-next-line', '// note', JS), false); +}); + +test('an indentation change is not trivial in an indentation-sensitive language', () => { + assert.strictEqual(isTrivialEdit(' x = 1', 'x = 1', PY), false); +}); + +test('a trailing comment change in Python is trivial', () => { + assert.strictEqual(isTrivialEdit('x = 1 # a', 'x = 1 # b', PY), true); +}); + +test('shell code lines drop comments', () => { + assert.deepStrictEqual(shCodeLines('echo hi # c\nls'), ['echo hi ', 'ls']); +}); + +test('shell heredocs are not lexed', () => { + assert.strictEqual(shCodeLines('cat < { + assert.deepStrictEqual(psCodeLines('Get-X # c\n<# b #>\nY'), ['Get-X ', ' ', 'Y']); +}); + +test('batch code lines drop REM lines', () => { + assert.deepStrictEqual(batchCodeLines('@echo off\nREM x\nset a=1'), ['@echo off', '', 'set a=1']); +}); + +test('a batch line continuation is not lexed', () => { + assert.strictEqual(batchCodeLines('echo ^'), null); +}); + +test('shell line normalization collapses blanks but keeps escaped ones', () => { + assert.strictEqual(normalizeShellLine('a b\\ c', '\\'), 'a b\\ c'); +}); + +console.log(`\nResults: Passed: ${passed}, Failed: ${failed}`); +process.exit(failed > 0 ? 1 : 0); diff --git a/tests/lib/gateguard-file-context.test.js b/tests/lib/gateguard-file-context.test.js new file mode 100644 index 000000000..3266aa1a8 --- /dev/null +++ b/tests/lib/gateguard-file-context.test.js @@ -0,0 +1,69 @@ +'use strict'; +/** + * Tests for scripts/lib/gateguard-file-context.js. + * + * Run with: node tests/lib/gateguard-file-context.test.js + */ + +const assert = require('assert'); +const path = require('path'); + +const { shCodeLines, normalizeShellLine } = require(path.join(__dirname, '..', '..', 'scripts', 'lib', 'gateguard-code-lexer.js')); +const { trivialInFile, cFamilyStartsFresh, pythonStartsFresh, shStartsFresh, lineStartOf, lineEndAt } = require( + path.join(__dirname, '..', '..', 'scripts', 'lib', 'gateguard-file-context.js') +); + +console.log('=== Testing gateguard-file-context.js ===\n'); + +let passed = 0; +let failed = 0; + +function test(desc, fn) { + try { + fn(); + console.log(` ✓ ${desc}`); + passed++; + } catch (e) { + console.log(` ✗ ${desc}: ${e.message}`); + failed++; + } +} + +const JS = { lineComment: '//', blockComment: true, quotes: '"\'', indentSensitive: false, flavor: 'js', forbiddenCode: '`/', jsx: true, startsFresh: cFamilyStartsFresh }; +const PY = { lineComment: '#', blockComment: false, quotes: '"\'', indentSensitive: true, tripleQuotes: true, fStrings: true, startsFresh: pythonStartsFresh }; +const SH = { lexer: shCodeLines, normalize: line => normalizeShellLine(line, '\\'), startsFresh: shStartsFresh }; + +test('a comment edit between statements is trivial in its file', () => { + assert.strictEqual(trivialInFile([['// old', '// new']], 'const a = 1;\n// old\nconst b = 2;\n', JS), true); +}); + +test('comment-looking text inside a template literal is code', () => { + assert.strictEqual(trivialInFile([['// old', '// new']], 'const s = `\n// old\n`;\n', JS), false); +}); + +test('comment-looking text inside a Python triple-quoted string is code', () => { + assert.strictEqual(trivialInFile([['# old', '# new']], 'x = """\n# old\n"""\n', PY), false); +}); + +test('comment-looking text inside a shell heredoc is code', () => { + assert.strictEqual(trivialInFile([['# a', '# b']], 'cat < { + assert.strictEqual(trivialInFile([['// a', '// $& b']], '// a\n', JS), false); +}); + +test('a file prefix ends fresh only outside strings and comments', () => { + assert.strictEqual(cFamilyStartsFresh('const a = 1;\n', JS), true); + assert.strictEqual(cFamilyStartsFresh('const s = "abc', JS), false); + assert.strictEqual(pythonStartsFresh('x = 1\n'), true); + assert.strictEqual(pythonStartsFresh('x = """\n'), false); +}); + +test('line bounds are found around an offset', () => { + assert.strictEqual(lineStartOf('ab\ncd', 4), 3); + assert.strictEqual(lineEndAt('ab\ncd', 0), 2); +}); + +console.log(`\nResults: Passed: ${passed}, Failed: ${failed}`); +process.exit(failed > 0 ? 1 : 0); diff --git a/tests/lib/gateguard-metrics.test.js b/tests/lib/gateguard-metrics.test.js new file mode 100644 index 000000000..dd3272090 --- /dev/null +++ b/tests/lib/gateguard-metrics.test.js @@ -0,0 +1,178 @@ +/** + * Tests for scripts/lib/gateguard-metrics.js + */ + +'use strict'; + +const assert = require('assert'); +const crypto = require('crypto'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); + +const { + DECISIONS, + METRICS_FILE_NAME, + METRICS_MAX_BYTES, + metricsEvent, + appendMetrics, + isMetricsEvent +} = require('../../scripts/lib/gateguard-metrics'); + +let passed = 0; +let failed = 0; + +function test(name, fn) { + try { + fn(); + console.log(` \u2713 ${name}`); + passed++; + } catch (error) { + console.log(` \u2717 ${name}`); + console.log(` Error: ${error.message}`); + failed++; + } +} + +const NOW = Date.parse('2026-09-01T10:00:00.000Z'); +const base = { sessionKey: 'sess-1', tool: 'Edit', decision: 'deny', reason: 'first-touch', now: NOW }; + +console.log('\n=== Testing gateguard-metrics ===\n'); + +test('builds a schema 1 event with a 12-character session digest', () => { + const event = metricsEvent({ + ...base, + cls: 'code', + questions: ['importers', 'quote-instruction'], + sensitive: false, + profile: { known: true, language: 'js', touchesPublicSurface: true, touchesData: false, trivial: false, extra: 'x' } + }); + assert.deepStrictEqual(event, { + v: 1, + ts: '2026-09-01T10:00:00.000Z', + session: crypto.createHash('sha256').update('sess-1').digest('hex').slice(0, 12), + tool: 'Edit', + class: 'code', + decision: 'deny', + reason: 'first-touch', + questions: ['importers', 'quote-instruction'], + sensitive: false, + profile: { known: true, language: 'js', touchesPublicSurface: true, touchesData: false, trivial: false } + }); + assert.ok(isMetricsEvent(event)); +}); + +test('returns null for unknown decisions or tools', () => { + assert.strictEqual(metricsEvent({ ...base, decision: 'allow' }), null); + assert.strictEqual(metricsEvent({ ...base, tool: 'Read' }), null); +}); + +test('drops any class, reason, question or language that is not a short code', () => { + const event = metricsEvent({ + ...base, + cls: '/src/app.js', + reason: 'rm -rf /', + questions: ['importers', '/etc/passwd', 'x'.repeat(100)], + profile: { known: true, language: 'C:\\x' } + }); + assert.strictEqual(event.class, null); + assert.strictEqual(event.reason, null); + assert.deepStrictEqual(event.questions, ['importers']); + assert.strictEqual(event.profile.language, null); +}); + +test('records NotebookEdit decisions and the hard-linked reason codes', () => { + const event = metricsEvent({ ...base, tool: 'NotebookEdit', reason: 'hard-linked' }); + assert.strictEqual(event.tool, 'NotebookEdit'); + assert.strictEqual(event.reason, 'hard-linked'); + assert.ok(isMetricsEvent(event)); + assert.strictEqual(metricsEvent({ ...base, reason: 'subagent-hard-linked' }).reason, 'subagent-hard-linked'); +}); + +test('keeps near-miss reason codes', () => { + assert.strictEqual(metricsEvent({ ...base, reason: 'near-miss:out-of-scope' }).reason, 'near-miss:out-of-scope'); +}); + +test('lists every decision the hook records', () => { + assert.deepStrictEqual([...DECISIONS].sort(), [ + 'cap', + 'credit', + 'deny', + 'destructive-deny', + 'pass', + 'pass-checked', + 'pass-exempt', + 'pass-subagent', + 'routine-deny', + 'routine-readonly', + 'sibling', + 'trivial' + ]); +}); + +test('rejects events with the wrong shape', () => { + const good = metricsEvent(base); + assert.ok(isMetricsEvent(good)); + for (const bad of [ + null, + [], + { ...good, v: 2 }, + { ...good, session: 'ABC' }, + { ...good, decision: 'allow' }, + { ...good, tool: 'Read' }, + { ...good, reason: '/x' }, + { ...good, class: 'a b' }, + { ...good, questions: ['ok', '/x'] }, + { ...good, sensitive: 'no' }, + { ...good, ts: 5 } + ]) { + assert.strictEqual(isMetricsEvent(bad), false, JSON.stringify(bad)); + } +}); + +test('appends one line per event in a single write and rotates past the cap', () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gateguard-metrics-lib-')); + try { + const file = path.join(dir, 'nested', METRICS_FILE_NAME); + assert.strictEqual(appendMetrics(path.dirname(file), [metricsEvent(base), null, metricsEvent({ ...base, decision: 'credit' })]), true); + assert.strictEqual(fs.readFileSync(file, 'utf8').split('\n').filter(Boolean).length, 2); + fs.writeFileSync(`${file}.1`, 'old\n'); + fs.writeFileSync(file, 'x'.repeat(METRICS_MAX_BYTES)); + assert.strictEqual(appendMetrics(path.dirname(file), [metricsEvent(base)]), true); + assert.strictEqual(fs.statSync(`${file}.1`).size, METRICS_MAX_BYTES); + assert.strictEqual(fs.readFileSync(file, 'utf8').split('\n').filter(Boolean).length, 1); + assert.strictEqual(appendMetrics(path.dirname(file), [null]), false); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); + +test('reports failure instead of throwing when the file cannot be written', () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gateguard-metrics-lib-')); + try { + fs.mkdirSync(path.join(dir, METRICS_FILE_NAME)); + assert.strictEqual(appendMetrics(dir, [metricsEvent(base)]), false); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); + +test('never writes through a symlink in place of the metrics file', () => { + if (process.platform === 'win32') return; + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gateguard-metrics-lib-')); + try { + const victim = path.join(dir, 'victim.txt'); + fs.writeFileSync(victim, 'keep\n'); + fs.symlinkSync(victim, path.join(dir, METRICS_FILE_NAME)); + assert.strictEqual(appendMetrics(dir, [metricsEvent(base)]), false); + assert.strictEqual(fs.readFileSync(victim, 'utf8'), 'keep\n'); + fs.writeFileSync(victim, 'x'.repeat(METRICS_MAX_BYTES)); + assert.strictEqual(appendMetrics(dir, [metricsEvent(base)]), false); + assert.strictEqual(fs.statSync(victim).size, METRICS_MAX_BYTES, 'a symlink is not rotated onto'); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); + +console.log(`\n ${passed} passed, ${failed} failed\n`); +process.exit(failed > 0 ? 1 : 0); diff --git a/tests/lib/gateguard-readonly-shell.test.js b/tests/lib/gateguard-readonly-shell.test.js new file mode 100644 index 000000000..5ff77a26c --- /dev/null +++ b/tests/lib/gateguard-readonly-shell.test.js @@ -0,0 +1,240 @@ +'use strict'; +/** + * Tests for scripts/lib/gateguard-readonly-shell.js. + * + * Run with: node tests/lib/gateguard-readonly-shell.test.js + */ + +const assert = require('assert'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); + +const hookPath = path.join(__dirname, '..', '..', 'scripts', 'hooks', 'gateguard-fact-force.js'); + +console.log('=== Testing gateguard-readonly-shell.js ===\n'); + +let passed = 0; +let failed = 0; + +function test(desc, fn) { + try { + fn(); + console.log(` ✓ ${desc}`); + passed++; + } catch (e) { + console.log(` ✗ ${desc}: ${e.message}`); + failed++; + } +} + +function loadHook() { + const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gateguard-readonly-shell-state-')); + const savedStateDir = process.env.GATEGUARD_STATE_DIR; + process.env.GATEGUARD_STATE_DIR = stateDir; + try { + return require(hookPath); + } finally { + if (savedStateDir === undefined) delete process.env.GATEGUARD_STATE_DIR; + else process.env.GATEGUARD_STATE_DIR = savedStateDir; + fs.rmSync(stateDir, { recursive: true, force: true }); + } +} + +const { isReadOnlyShellCommand } = loadHook(); + +function expectReadOnly(tool, commands) { + for (const command of commands) { + assert.strictEqual(isReadOnlyShellCommand(tool, command), true, `${tool}: ${command} should be read-only`); + } +} + +function expectNotReadOnly(tool, commands) { + for (const command of commands) { + assert.strictEqual(isReadOnlyShellCommand(tool, command), false, `${tool}: ${command} should not be read-only`); + } +} + +test('plain introspection commands are read-only', () => { + expectReadOnly('Bash', [ + 'ls', + 'ls -la src', + 'pwd', + 'cat README.md', + 'head -n 20 src/a.js', + 'tail -5 log.txt', + 'wc -l src/a.js src/b.js', + 'rg -n "foo bar" src', + "grep -rn 'x|y' .", + 'find . -name "*.js" -type f', + 'fd -e js src', + 'tree -L 2 src' + ]); +}); + +test('read-only git subcommands are read-only', () => { + expectReadOnly('Bash', [ + 'git status', + 'git status --short --branch', + 'git log --oneline -n 5', + 'git log --oneline -20 -- src/a.js', + 'git diff', + 'git diff --cached --stat', + 'git diff HEAD~1 -- src', + 'git show HEAD --stat', + 'git branch --show-current', + 'git branch -a', + 'git rev-parse --show-toplevel', + 'git ls-files src' + ]); +}); + +test('pipes and lists of read-only commands are read-only', () => { + expectReadOnly('Bash', ['ls src | head', 'git log --oneline | head -n 3', 'pwd && ls', 'cat a.txt; wc -l a.txt', 'ls x || ls y']); +}); + +test('PowerShell introspection cmdlets are read-only', () => { + expectReadOnly('PowerShell', [ + 'Get-ChildItem -Recurse -Filter *.js', + 'get-childitem src', + 'gci C:\\repo\\src', + 'Get-Content -Path .\\README.md -TotalCount 20', + 'Select-String -Path src\\*.js -Pattern foo', + 'Get-ChildItem src | Select-String foo', + 'Get-Location', + 'git status --short' + ]); +}); + +test('redirection to a file is not read-only', () => { + expectNotReadOnly('Bash', ['ls > out.txt', 'cat a >> b', 'git log 2>err.txt', 'cat < a.txt', 'cat <| out']); + expectNotReadOnly('PowerShell', ['Get-ChildItem > out.txt', 'Get-Content a 2>&1', 'Get-Content a *> b']); +}); + +test('tee and other writers in a pipe are not read-only', () => { + expectNotReadOnly('Bash', ['ls | tee out.txt', 'cat a | sort -o b', 'git log | xargs rm', 'ls | sh', 'cat a.txt | node']); + expectNotReadOnly('PowerShell', ['Get-ChildItem | Out-File a.txt', 'Get-Content a | Set-Content b', 'Get-ChildItem | Remove-Item', 'Get-Content a | Tee-Object b']); +}); + +test('command substitution, backticks and process substitution are not read-only', () => { + expectNotReadOnly('Bash', ['ls $(pwd)', 'cat `which node`', 'cat <(ls)', 'ls "$(rm -rf x)"', 'cat "`id`"', 'ls $HOME', 'ls "${x:=y}"', 'diff <(ls a) <(ls b)']); + expectNotReadOnly('PowerShell', ['Get-Content $(Remove-Item a)', 'Get-ChildItem $env:TEMP', 'Get-Content "$x"', 'Get-Content @args', 'Get-Content (Get-Item a)']); +}); + +test('find actions that execute, delete or write are not read-only', () => { + expectNotReadOnly('Bash', [ + 'find . -name "*.tmp" -exec echo {} +', + 'find . -execdir ls +', + 'find . -ok echo +', + 'find . -okdir echo +', + 'find . -name x -delete', + 'find . -fprint out.txt', + 'find . -fprintf out.txt %p', + 'find . -fls out.txt' + ]); +}); + +test('fd, rg and tree options that execute or write are not read-only', () => { + expectNotReadOnly('Bash', [ + 'fd -x echo', + 'fd -HX echo', + 'fd --exec echo', + 'fd --exec-batch echo', + 'rg --pre ./script foo', + 'rg --pre=cat foo', + 'rg --hostname-bin=./x foo', + 'rg -z foo', + 'rg -iz foo', + 'rg --search-zip foo', + 'tree -o out.txt', + 'tree -ao out.txt', + 'tree -R' + ]); +}); + +test('xargs, sed, awk and other unknown commands are not read-only', () => { + expectNotReadOnly('Bash', ['xargs ls', 'sed -i s/a/b/ f', 'sed -n 1p f', 'awk 1 f', 'echo hi', 'npm test', 'node -e 1', 'sort f', 'less f', 'touch a']); +}); + +test('environment assignments and wrappers are not read-only', () => { + expectNotReadOnly('Bash', ['FOO=1 ls', 'PAGER=sh git log', 'env ls', 'sudo ls', 'command ls', 'nice ls', 'timeout 5 ls', 'exec ls', 'sh -c ls', 'bash -c "ls"']); +}); + +test('commands named by path or quoted into another name are not read-only', () => { + expectNotReadOnly('Bash', ['./ls', '/bin/ls', 'bin/cat a', 'l? a', 'LS']); + expectNotReadOnly('PowerShell', ['Microsoft.PowerShell.Management\\Get-ChildItem', '.\\ls.ps1', '& Get-ChildItem', '. .\\x.ps1']); +}); + +test('git subcommands outside the read-only set are not read-only', () => { + expectNotReadOnly('Bash', [ + 'git commit -m x', + 'git checkout main', + 'git stash', + 'git fetch', + 'git pull', + 'git branch new-feature', + 'git branch -d old', + 'git branch -D old', + 'git branch -m a b', + 'git config user.name x', + 'git grep foo', + 'git remote -v', + 'git tag v1', + 'git worktree list' + ]); +}); + +test('git options that write, execute or reconfigure are not read-only', () => { + expectNotReadOnly('Bash', [ + 'git -c core.pager=sh log', + 'git -C /elsewhere status', + 'git --git-dir=/x log', + 'git -p log', + 'git diff --output=patch.txt', + 'git diff --out=patch.txt', + 'git log --output=log.txt', + 'git diff --ext-diff', + 'git log --ext-diff -p', + 'git diff --textconv', + 'git show --output=x HEAD' + ]); +}); + +test('PowerShell escapes, call operators and stop-parsing are not read-only', () => { + expectNotReadOnly('PowerShell', [ + 'Get-Content a`; Remove-Item b', + 'git log --% ; x', + 'Get-Content a & Remove-Item b', + 'Get-ChildItem {Remove-Item a}', + 'Get-Content [IO.File]::x', + 'pwsh -Command Get-ChildItem', + 'powershell -EncodedCommand AAAA', + 'Invoke-Expression "ls"', + 'Get-ChildItem | ForEach-Object { Remove-Item $_ }', + "Get-Content 'a\u2019; Remove-Item b", + 'Set-Location src', + 'Remove-Item a' + ]); +}); + +test('backslashes, background jobs, newlines and odd characters are not read-only', () => { + expectNotReadOnly('Bash', ['ls a\\;rm b', "cat 'a\\'; rm b", 'ls &', 'ls & rm x', 'ls\nrm x', 'ls\rrm x', 'ls \u200b', 'ls caf\u00e9', 'ls "unterminated', "cat 'unterminated"]); +}); + +test('empty, non-string and oversized commands are not read-only', () => { + assert.strictEqual(isReadOnlyShellCommand('Bash', ''), false); + assert.strictEqual(isReadOnlyShellCommand('Bash', ' '), false); + assert.strictEqual(isReadOnlyShellCommand('Bash', ';'), false); + assert.strictEqual(isReadOnlyShellCommand('Bash', null), false); + assert.strictEqual(isReadOnlyShellCommand('Bash', { toString: () => 'ls' }), false); + assert.strictEqual(isReadOnlyShellCommand('Bash', `ls ${'a'.repeat(5000)}`), false); + assert.strictEqual(isReadOnlyShellCommand('Other', 'ls'), false); +}); + +test('bash-only commands are not read-only under PowerShell and vice versa', () => { + expectNotReadOnly('PowerShell', ['find . -name x', 'tree', 'head a', 'fd x']); + expectNotReadOnly('Bash', ['Get-ChildItem', 'gci', 'Select-String foo']); +}); + +console.log(`\nResults: Passed: ${passed}, Failed: ${failed}`); +process.exit(failed > 0 ? 1 : 0); diff --git a/tests/lib/gateguard-search-evidence.test.js b/tests/lib/gateguard-search-evidence.test.js new file mode 100644 index 000000000..bf20ed82c --- /dev/null +++ b/tests/lib/gateguard-search-evidence.test.js @@ -0,0 +1,163 @@ +'use strict'; +/** + * Tests for scripts/lib/gateguard-search-evidence.js. + * + * Run with: node tests/lib/gateguard-search-evidence.test.js + */ + +const assert = require('assert'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); + +const hookPath = path.join(__dirname, '..', '..', 'scripts', 'hooks', 'gateguard-fact-force.js'); +const { scanCurrentTurn } = require(path.join(__dirname, '..', '..', 'scripts', 'lib', 'gateguard-turn-scan.js')); + +console.log('=== Testing gateguard-search-evidence.js ===\n'); + +let passed = 0; +let failed = 0; + +function test(desc, fn) { + try { + fn(); + console.log(` ✓ ${desc}`); + passed++; + } catch (e) { + console.log(` ✗ ${desc}: ${e.message}`); + failed++; + } +} + +function loadHook() { + const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gateguard-search-evidence-state-')); + const savedStateDir = process.env.GATEGUARD_STATE_DIR; + process.env.GATEGUARD_STATE_DIR = stateDir; + try { + return require(hookPath); + } finally { + if (savedStateDir === undefined) delete process.env.GATEGUARD_STATE_DIR; + else process.env.GATEGUARD_STATE_DIR = savedStateDir; + fs.rmSync(stateDir, { recursive: true, force: true }); + } +} + +const root = '/proj-search'; +const transcriptDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gateguard-search-evidence-')); +let seq = 0; +let uuidSeq = 0; +const nextUuid = () => `uuid-${++uuidSeq}`; +const human = text => ({ type: 'user', uuid: nextUuid(), message: { role: 'user', content: text } }); +const toolUse = (id, name, input) => ({ + type: 'assistant', + uuid: nextUuid(), + message: { id: `msg_${id}`, role: 'assistant', content: [{ type: 'tool_use', id, name, input }] } +}); +const toolResult = id => ({ + type: 'user', + uuid: nextUuid(), + message: { role: 'user', content: [{ type: 'tool_result', tool_use_id: id, is_error: false, content: 'ok' }] } +}); +const search = (id, name, input) => [toolUse(id, name, input), toolResult(id)]; +const writeTranscript = records => { + seq += 1; + const file = path.join(transcriptDir, `t-${seq}.jsonl`); + fs.writeFileSync(file, records.map(r => JSON.stringify(r)).join('\n') + '\n', 'utf8'); + return file; +}; + +test('credits by Glob literal-prefix directory and stem, folding case only for Windows paths', () => { + const { findCreditingSearch } = loadHook(); + const boundary = human('go'); + const t = writeTranscript([human('old'), boundary, ...search('toolu_u1', 'Glob', { pattern: '**/*.md' }), toolUse('toolu_u2', 'Grep', { pattern: 'pending' })]); + const scan = scanCurrentTurn(t); + assert.strictEqual(scan.turnId, boundary.uuid, 'turnId is the boundary uuid'); + assert.deepStrictEqual(scan.searches.map(s => [s.name, s.callsAgo]), [['Glob', 2]]); + assert.strictEqual(scanCurrentTurn(path.join(transcriptDir, 'missing.jsonl')), null); + const data = { cwd: root }; + assert.strictEqual(findCreditingSearch(scan, `${root}/new-notes.md`, true, data), null, 'Glob **/*.md gives no dir credit'); + const docsScan = scanCurrentTurn( + writeTranscript([human('go'), ...search('toolu_u3', 'Glob', { pattern: 'docs/*.md' }), toolUse('toolu_u4', 'Grep', { pattern: 'x' })]) + ); + assert.ok(findCreditingSearch(docsScan, `${root}/docs/new-notes.md`, true, data), 'Glob literal prefix names the dir'); + assert.strictEqual(findCreditingSearch(docsScan, `${root}/docs/sub/new-notes.md`, true, data), null, 'not a subdirectory'); + assert.strictEqual(findCreditingSearch(scan, `${root}/new-notes.md`, false, data), null, 'Edit needs a stem match'); + assert.strictEqual(findCreditingSearch(null, `${root}/x.md`, true, data), null); + const winScan = { turnId: null, searches: [{ name: 'LS', input: { path: 'C:\\Proj\\Src' }, callsAgo: 1, messageId: 'msg_w' }] }; + assert.ok(findCreditingSearch(winScan, 'c:/proj/src/new_file.js', true, { cwd: 'C:\\proj' }), 'win32 dir match folds case'); + const posixScan = { turnId: null, searches: [{ name: 'LS', input: { path: '/Proj/Src' }, callsAgo: 1, messageId: 'msg_p' }] }; + assert.strictEqual(findCreditingSearch(posixScan, '/proj/src/new_file.js', true, { cwd: '/proj' }), null, 'posix is case-sensitive'); +}); + +test('findClosestMiss reports a reason code for the nearest non-qualifying search', () => { + const { findClosestMiss } = loadHook(); + const data = { cwd: root }; + const target = `${root}/src/widget_factory.py`; + const scanOf = (searches, extra = {}) => ({ turnId: null, searches, batchIds: new Map(), newestMessageId: null, shellCommands: [], reads: [], ...extra }); + const s = (name, input, messageId = 'msg_1') => ({ name, input, callsAgo: 1, messageId }); + const reason = (scan, allowDir = false, file = target) => (findClosestMiss(scan, file, allowDir, data) || {}).reason || null; + assert.strictEqual(reason(scanOf([s('Grep', { pattern: 'widget_factory', path: `${root}/docs` })])), 'out-of-scope'); + assert.strictEqual(reason(scanOf([s('Grep', { pattern: 'widget_factory', glob: '!*.py' })])), 'excluded'); + assert.strictEqual(reason(scanOf([s('Grep', { pattern: 'widget_factory', glob: '*.md' })])), 'excluded', 'include globs that miss'); + assert.strictEqual(reason(scanOf([s('Grep', { pattern: 'widget_factory' }, 'msg_b')], { newestMessageId: 'msg_b' })), 'same-batch'); + assert.strictEqual(reason(scanOf([s('Bash', { command: 'git diff | grep widget_factory' })])), 'stdin-only'); + assert.strictEqual(reason(scanOf([s('Bash', { command: 'cat src/widget_factory.py' })])), 'not-a-search'); + assert.strictEqual(reason(scanOf([], { reads: [{ name: 'Read', path: 'src/widget_factory.py', callsAgo: 1 }] })), 'not-a-search'); + assert.strictEqual(reason(scanOf([s('Grep', { pattern: 'index' })]), false, `${root}/src/index.py`), 'generic-stem'); + assert.strictEqual(reason(scanOf([s('Grep', { pattern: 'unrelated' })])), null); + assert.strictEqual(reason(scanOf([s('Grep', { pattern: 'widget_factory' })])), null, 'a crediting search is not a miss'); + assert.strictEqual(reason(scanOf([s('LS', { path: `${root}/src` })]), true, `${root}/src/brand_new.py`), null, 'dir credit is not a miss'); + assert.strictEqual( + reason(scanOf([s('Grep', { pattern: 'widget_factory', path: `${root}/docs` }), s('Grep', { pattern: 'widget_factory', glob: '!*.py' })])), + 'excluded', + 'excluded outranks out-of-scope' + ); + const miss = findClosestMiss(scanOf([s('Grep', { pattern: 'widget_factory', path: `${root}/docs` })]), target, false, data); + assert.deepStrictEqual(Object.keys(miss).sort(), ['detail', 'name', 'reason']); + for (const bad of [null, {}, { searches: 'x' }, scanOf([null, { name: 'Grep' }]), scanOf([], { reads: [null, { path: 5 }] })]) { + assert.strictEqual(findClosestMiss(bad, target, false, data), null); + } + assert.strictEqual(findClosestMiss(scanOf([s('Grep', { pattern: 'widget_factory', path: '/elsewhere' })]), '', false, data), null); +}); + +test('a test target is credited by its module stem once the test affix is stripped', () => { + const { findCreditingSearch } = loadHook(); + const data = { cwd: root }; + const scanOf = searches => ({ turnId: null, searches, batchIds: new Map(), newestMessageId: null, shellCommands: [], reads: [] }); + const s = (name, input) => ({ name, input, callsAgo: 1, messageId: 'msg_1' }); + const credits = (search, file) => Boolean(findCreditingSearch(scanOf([search]), `${root}/${file}`, false, data)); + assert.ok(credits(s('Bash', { command: 'rg -n "tokenizer" src tests' }), 'tests/parser/tokenizer.test.js'), '.test'); + assert.ok(credits(s('Grep', { pattern: 'tokenizer', path: `${root}/tests` }), 'tests/parser/tokenizer.spec.ts'), '.spec'); + assert.ok(credits(s('Grep', { pattern: 'widget_factory' }), 'tests/test_widget_factory.py'), 'test_ prefix'); + assert.ok(credits(s('Grep', { pattern: 'widget_factory' }), 'pkg/widget_factory_test.py'), '_test.py suffix'); + assert.ok(credits(s('Grep', { pattern: 'codec' }), 'internal/codec/codec_test.go'), '_test.go suffix'); + assert.ok(credits(s('Grep', { pattern: 'tokenizer.test' }), 'tests/parser/tokenizer.test.js'), 'the full stem still names it'); +}); + +test('stripped test stems keep the generic, length, word-boundary and scope rules', () => { + const { findCreditingSearch, findClosestMiss } = loadHook(); + const data = { cwd: root }; + const scanOf = searches => ({ turnId: null, searches, batchIds: new Map(), newestMessageId: null, shellCommands: [], reads: [] }); + const s = (name, input) => ({ name, input, callsAgo: 1, messageId: 'msg_1' }); + const credits = (search, file) => Boolean(findCreditingSearch(scanOf([search]), `${root}/${file}`, false, data)); + assert.strictEqual(credits(s('Grep', { pattern: 'index' }), 'tests/index.test.js'), false, 'index is generic'); + assert.strictEqual(credits(s('Grep', { pattern: 'app' }), 'src/app.spec.ts'), false, 'app is generic'); + assert.strictEqual(credits(s('Grep', { pattern: 'util' }), 'tests/test_util.py'), false, 'util is generic'); + assert.strictEqual(credits(s('Grep', { pattern: 'main' }), 'cmd/main_test.go'), false, 'main is generic'); + assert.strictEqual(credits(s('Grep', { pattern: 'abc' }), 'tests/abc.test.js'), false, 'too short'); + assert.strictEqual(credits(s('Grep', { pattern: 'tokenizers' }), 'tests/tokenizer.test.js'), false, 'word boundary'); + assert.strictEqual(credits(s('Grep', { pattern: 'tokenizer', path: `${root}/src` }), 'tests/tokenizer.test.js'), false, 'scope'); + assert.strictEqual(credits(s('Grep', { pattern: 'tokenizer', glob: '!*.test.js' }), 'tests/tokenizer.test.js'), false, 'excluded'); + assert.strictEqual(credits(s('Grep', { pattern: 'x', glob: '!tokenizer*' }), 'tests/tokenizer.test.js'), false, 'exclusion names the stem'); + assert.strictEqual(credits(s('Grep', { pattern: 'widget' }), 'tests/widget.test.test.js'), false, 'one affix only'); + assert.strictEqual(credits(s('Grep', { pattern: 'helpers' }), 'src/test_helpers.js'), false, 'test_ prefix only for Python'); + assert.strictEqual(credits(s('Grep', { pattern: 'widget' }), 'src/widget_test.js'), false, '_test suffix only for Python and Go'); + assert.strictEqual(credits(s('Grep', { pattern: 'tokenizer' }), 'skills/tokenizer.test.md'), false, 'instruction targets keep the full stem'); + const miss = findClosestMiss(scanOf([s('Grep', { pattern: 'index' })]), `${root}/tests/index.test.js`, false, data); + assert.strictEqual(miss && miss.reason, 'generic-stem'); +}); + +fs.rmSync(transcriptDir, { recursive: true, force: true }); + +console.log(`\nResults: Passed: ${passed}, Failed: ${failed}`); +process.exit(failed > 0 ? 1 : 0); diff --git a/tests/lib/gateguard-search-filters.test.js b/tests/lib/gateguard-search-filters.test.js new file mode 100644 index 000000000..1222e6c22 --- /dev/null +++ b/tests/lib/gateguard-search-filters.test.js @@ -0,0 +1,90 @@ +'use strict'; +/** + * Tests for scripts/lib/gateguard-search-filters.js. + * + * Run with: node tests/lib/gateguard-search-filters.test.js + */ + +const assert = require('assert'); +const path = require('path'); + +const { grepToolFilters, shellSearchFilters, filtersAdmitTarget, includesAdmitTarget, powershellFilterArg } = require( + path.join(__dirname, '..', '..', 'scripts', 'lib', 'gateguard-search-filters.js') +); + +console.log('=== Testing gateguard-search-filters.js ===\n'); + +let passed = 0; +let failed = 0; + +function test(desc, fn) { + try { + fn(); + console.log(` ✓ ${desc}`); + passed++; + } catch (e) { + console.log(` ✗ ${desc}: ${e.message}`); + failed++; + } +} + +const ROOT = '/proj'; +const ctxFor = rel => ({ targetKey: `${ROOT}/${rel}`, resolveDir: (...parts) => path.posix.resolve(ROOT, ...parts) }); +const rgItem = (args, scope = '.') => ({ ...shellSearchFilters('rg', args), scopes: [[scope]], pathIncludes: true }); +const admits = (item, rel) => includesAdmitTarget(item, ctxFor(rel)); + +test('Grep tool globs split into includes and exclusions', () => { + const filters = grepToolFilters('*.py,!tests/**,src/*.js'); + assert.deepStrictEqual(filters.includes, ['*.py', 'src/*.js']); + assert.deepStrictEqual(filters.exclusions, ['tests/**']); +}); + +test('rg -g keeps directory-qualified includes', () => { + assert.deepStrictEqual(shellSearchFilters('rg', ['-g', 'src/*.js', 'foo', '.']).includes, ['src/*.js']); +}); + +test('a basename include matches the file name anywhere', () => { + assert.strictEqual(admits(rgItem(['-g', '*.py']), 'a/b/widget.py'), true); + assert.strictEqual(admits(rgItem(['-g', '*.md']), 'a/b/widget.py'), false); +}); + +test('a directory-qualified include is anchored at the search root', () => { + const item = rgItem(['-g', 'src/*.js']); + assert.strictEqual(admits(item, 'src/foo.js'), true); + assert.strictEqual(admits(item, 'lib/foo.js'), false); + assert.strictEqual(admits(item, 'vendor/src/foo.js'), false); + assert.strictEqual(admits(item, 'src/deep/foo.js'), false); +}); + +test('a leading **/ lets a directory-qualified include start anywhere', () => { + assert.strictEqual(admits(rgItem(['-g', '**/src/*.js']), 'vendor/src/foo.js'), true); +}); + +test('a directory-qualified include is relative to a narrower search path', () => { + assert.strictEqual(admits(rgItem(['-g', 'deep/*.js'], 'src'), 'src/deep/foo.js'), true); + assert.strictEqual(admits(rgItem(['-g', 'src/*.js'], 'src'), 'src/foo.js'), false); +}); + +test('tools that match names only never admit a directory-qualified include', () => { + const item = { ...shellSearchFilters('grep', ['-r', '--include=src/*.py', 'widget', '.']), scopes: [['.']], pathIncludes: false }; + assert.strictEqual(admits(item, 'src/widget.py'), false); +}); + +test('an include past the length bound admits nothing', () => { + assert.strictEqual(admits(rgItem(['-g', `${'a'.repeat(300)}*.py`]), 'src/widget.py'), false); +}); + +test('an exclusion that covers the target blocks it', () => { + const item = rgItem(['-g', '!src/**']); + assert.strictEqual(filtersAdmitTarget(item, ctxFor('src/widget.py'), null), false); + assert.strictEqual(filtersAdmitTarget(item, ctxFor('lib/widget.py'), null), true); +}); + +test('PowerShell -Exclude binds from an unambiguous prefix', () => { + const filter = powershellFilterArg('get-childitem', ['-Recurse', '-excl', 'a.py,', 'widget.py'], 1); + assert.strictEqual(filter.name, 'exclude'); + assert.deepStrictEqual(filter.globs, ['a.py', 'widget.py']); +}); + +console.log(`\nResults: Passed: ${passed}, Failed: ${failed}`); +process.exit(failed > 0 ? 1 : 0); diff --git a/tests/lib/gateguard-state.test.js b/tests/lib/gateguard-state.test.js new file mode 100644 index 000000000..d774cc21d --- /dev/null +++ b/tests/lib/gateguard-state.test.js @@ -0,0 +1,95 @@ +'use strict'; +/** + * Tests for scripts/lib/gateguard-state.js. + * + * Run with: node tests/lib/gateguard-state.test.js + */ + +const assert = require('assert'); +const path = require('path'); + +const { + getDenialCount, + getCapAllowCount, + getTrivialAllowCount, + getRoutineReadonlyPassCount, + getClassCounts, + mergeClassCounts, + incrementClassCount, + dirGateKey, + getDirGates, + mergeDirGates, + capDirGates +} = require(path.join(__dirname, '..', '..', 'scripts', 'lib', 'gateguard-state.js')); + +console.log('=== Testing gateguard-state.js ===\n'); + +let passed = 0; +let failed = 0; + +function test(desc, fn) { + try { + fn(); + console.log(` ✓ ${desc}`); + passed++; + } catch (e) { + console.log(` ✗ ${desc}: ${e.message}`); + failed++; + } +} + +test('trivial_allows reads as a non-negative integer', () => { + assert.strictEqual(getTrivialAllowCount({ trivial_allows: 3 }), 3); + for (const bad of [undefined, null, -1, 'x', NaN, Infinity, {}]) { + assert.strictEqual(getTrivialAllowCount({ trivial_allows: bad }), 0, String(bad)); + } + assert.strictEqual(getTrivialAllowCount(null), 0); +}); + +test('routine_readonly_passes reads as a non-negative integer', () => { + assert.strictEqual(getRoutineReadonlyPassCount({ routine_readonly_passes: 4 }), 4); + for (const bad of [undefined, null, -1, 'x', NaN, Infinity, {}]) { + assert.strictEqual(getRoutineReadonlyPassCount({ routine_readonly_passes: bad }), 0, String(bad)); + } + assert.strictEqual(getRoutineReadonlyPassCount(null), 0); +}); + +test('malformed or missing counters read as zero', () => { + assert.strictEqual(getDenialCount(null), 0); + assert.strictEqual(getDenialCount({ fact_force_denials: 'x' }), 0); + assert.strictEqual(getDenialCount({ fact_force_denials: -3 }), 0); + assert.strictEqual(getDenialCount({ fact_force_denials: 2.9 }), 2); + assert.strictEqual(getCapAllowCount({ cap_allows: Infinity }), 0); +}); + +test('class counts reject prototype keys and non-numeric values', () => { + const counts = getClassCounts(JSON.parse('{"denials_by_class":{"__proto__":5,"code":2,"test":"3","prose":0}}'), 'denials_by_class'); + assert.deepStrictEqual(Object.entries(counts), [['code', 2]]); + assert.strictEqual(Object.getPrototypeOf(counts), null); + assert.strictEqual(incrementClassCount(counts, 'constructor'), counts); + assert.strictEqual(incrementClassCount(counts, 'code').code, 3); + assert.strictEqual(mergeClassCounts(counts, { code: 1, test: 4 }).test, 4); +}); + +test('dir gates keep valid class-prefixed entries, newest wins on merge, capped at 50', () => { + const key = dirGateKey('code', '/p/src'); + const gates = getDirGates({ + dir_gates: { + [key]: { turn: 't1', at: 10, first: '/p/src/a.js', ordinal: 1 }, + '/p/old-shape': { turn: null, at: 10, first: '/p/old-shape/a.js', ordinal: 1 }, + [dirGateKey('config', '/p/cfg')]: { turn: null, at: 10, first: '/p/cfg/a.json', ordinal: 1 }, + [dirGateKey('test', '/p/bad')]: { at: 'soon', first: '/p/bad/a.js' } + } + }); + assert.deepStrictEqual(Object.keys(gates), [key]); + const merged = mergeDirGates(gates, { [key]: { turn: 't2', at: 20, first: '/p/src/b.js', ordinal: 2 } }); + assert.strictEqual(merged[key].turn, 't2'); + const many = {}; + for (let i = 0; i < 60; i++) many[dirGateKey('code', `/p/d${i}`)] = { turn: null, at: i, first: `/p/d${i}/a.js`, ordinal: 1 }; + const capped = capDirGates(many); + assert.strictEqual(Object.keys(capped).length, 50); + assert.ok(!(dirGateKey('code', '/p/d0') in capped), 'oldest evicted'); +}); + +console.log(`\nResults: Passed: ${passed}, Failed: ${failed}`); +process.exit(failed > 0 ? 1 : 0); diff --git a/tests/lib/gateguard-target-class.test.js b/tests/lib/gateguard-target-class.test.js new file mode 100644 index 000000000..47a2b2602 --- /dev/null +++ b/tests/lib/gateguard-target-class.test.js @@ -0,0 +1,648 @@ +'use strict'; +/** + * Tests for scripts/lib/gateguard-target-class.js: target classification, + * question tables, sensitive targets and sibling-collapse eligibility. + * + * Run with: node tests/lib/gateguard-target-class.test.js + */ + +const assert = require('assert'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); + +const libPath = path.join(__dirname, '..', '..', 'scripts', 'lib', 'gateguard-target-class.js'); +const hookPath = path.join(__dirname, '..', '..', 'scripts', 'hooks', 'gateguard-fact-force.js'); +const { + CLASS_QUESTIONS, + CLASS_CONDENSED_HINTS, + QUOTE_INSTRUCTION, + questionIdsFor, + questionsUseProfile, + questionText, + condensedHintFor, + condensedQuestionPhrase, + COLLAPSIBLE_CLASSES, + canonicalPathKey, + classifyTarget, + classifyTargetFor, + isCollapsibleTarget, + collapseGateDir, + isSensitiveTarget, + isSensitiveTargetFor, + isHardLinkedTargetFor +} = require(libPath); + +console.log('=== Testing gateguard-target-class.js ===\n'); + +let passed = 0; +let failed = 0; + +function test(desc, fn) { + try { + fn(); + console.log(` ✓ ${desc}`); + passed++; + } catch (e) { + console.log(` ✗ ${desc}: ${e.message}`); + failed++; + } +} + +function withProjectDir(value, fn) { + const saved = process.env.CLAUDE_PROJECT_DIR; + try { + if (value === undefined) delete process.env.CLAUDE_PROJECT_DIR; + else process.env.CLAUDE_PROJECT_DIR = value; + fn(); + } finally { + if (saved === undefined) delete process.env.CLAUDE_PROJECT_DIR; + else process.env.CLAUDE_PROJECT_DIR = saved; + } +} + +// ── classifyTarget ── +console.log('classifyTarget:'); + +test('classifyTarget table (normalized, lowercased, first match wins)', () => { + assert.strictEqual(typeof classifyTarget, 'function', 'classifyTarget is exported'); + const cases = [ + ['C:\\Repo\\Skills\\Foo\\Notes.MD', 'instruction'], + ['/repo/AGENTS.md', 'instruction'], + ['/repo/commands/plan.md', 'instruction'], + ['/repo/rules/common/x.txt', 'instruction'], + ['/repo/hooks/hooks.json', 'config'], + ['/repo/skills/x/helper.js', 'code'], + ['/repo/tests/fixtures/readme.md', 'test'], + ['/repo/pkg/foo_test.go', 'test'], + ['/repo/test_x.py', 'test'], + ['/repo/x_test.py', 'test'], + ['/repo/src/Button.spec.tsx', 'test'], + ['/repo/src/__tests__/a.js', 'test'], + ['/repo/README.md', 'prose'], + ['/repo/docs/a.rst', 'prose'], + ['/repo/.github/workflows/ci.yml', 'config'], + ['/repo/pyproject.toml', 'config'], + ['/repo/.env', 'config'], + ['/repo/src/main.go', 'code'], + ['', 'code'] + ]; + for (const [input, expected] of cases) { + assert.strictEqual(classifyTarget(input), expected, `${input} -> ${expected}`); + } +}); + +test('classifyTarget covers the extended instruction and config rules', () => { + const cases = [ + ['/repo/.cursor/rules/x.mdc', 'instruction'], + ['/repo/docs/x.mdc', 'instruction'], + ['/repo/.cursorrules', 'instruction'], + ['/repo/.windsurfrules', 'instruction'], + ['/repo/AGENT.md', 'instruction'], + ['/repo/.github/copilot-instructions.md', 'instruction'], + ['/repo/.github/instructions/py.instructions.md', 'instruction'], + ['/repo/.github/notes.md', 'prose'], + ['/repo/docs/instructions.md', 'prose'], + ['/repo/.env', 'config'], + ['/repo/.env.local', 'config'], + ['/repo/.env.example.ts', 'code'], + ['/repo/.env.config.js', 'code'], + ['/repo/.envrc', 'code'], + ['/repo/.mcp.json', 'config'] + ]; + for (const [input, expected] of cases) { + assert.strictEqual(classifyTarget(input), expected, `${input} -> ${expected}`); + } +}); + +test('non-string input classifies as code', () => { + assert.strictEqual(classifyTarget(undefined), 'code'); + assert.strictEqual(classifyTarget(null), 'code'); + assert.strictEqual(classifyTarget(42), 'code'); +}); + +// ── classifyTargetFor ── +console.log('\nclassifyTargetFor:'); + +test('classifyTargetFor classifies project-relative paths', () => { + assert.strictEqual(typeof classifyTargetFor, 'function', 'classifyTargetFor is exported'); + const saved = process.env.CLAUDE_PROJECT_DIR; + try { + process.env.CLAUDE_PROJECT_DIR = '/work/tests/proj'; + const data = { cwd: '/work/tests/proj' }; + assert.strictEqual(classifyTargetFor('/work/tests/proj/src/a.py', data), 'code'); + assert.strictEqual(classifyTargetFor('src/a.py', data), 'code', 'relative target'); + assert.strictEqual(classifyTargetFor('/work/tests/proj/tests/a.py', data), 'test'); + // No .git in the synthetic worktree, so the prefix stays and the path is under .claude. + assert.strictEqual(classifyTargetFor('/work/tests/proj/.claude/worktrees/w1/docs/g.md', data), 'instruction', 'unverified worktree'); + assert.strictEqual(classifyTargetFor('/work/tests/proj/.claude/worktrees/w1/skills/x/SKILL.md', data), 'instruction'); + assert.strictEqual(classifyTargetFor('/work/tests/proj/.claude/agents/a.md', data), 'instruction', 'not a worktree'); + assert.strictEqual(classifyTargetFor('/elsewhere/tests/a.py', data), 'test', 'outside the root: absolute path'); + assert.strictEqual(classifyTargetFor('/work/tests/project2/src/a.py', data), 'test', 'sibling dir is outside the root'); + process.env.CLAUDE_PROJECT_DIR = 'C:\\Work\\Tests\\Proj'; + assert.strictEqual(classifyTargetFor('src\\a.py', { cwd: 'C:\\Work\\Tests\\Proj' }), 'code', 'win32 relative'); + assert.strictEqual(classifyTargetFor('c:/work/tests/proj/src/a.py', { cwd: 'C:\\Work\\Tests\\Proj' }), 'code', 'win32 case-folded'); + delete process.env.CLAUDE_PROJECT_DIR; + assert.strictEqual(classifyTargetFor('src/a.py', { cwd: '/work/tests/proj' }), 'code', 'cwd fallback root'); + } finally { + if (saved === undefined) delete process.env.CLAUDE_PROJECT_DIR; + else process.env.CLAUDE_PROJECT_DIR = saved; + } +}); + +// ── canonicalPathKey ── +console.log('\ncanonicalPathKey:'); + +test('resolves relative targets against the tool cwd and folds Windows keys', () => { + withProjectDir('/proj', () => { + assert.strictEqual(canonicalPathKey('src/a.py', { cwd: '/proj/sub' }), '/proj/sub/src/a.py'); + assert.strictEqual(canonicalPathKey('./a.py', { cwd: '/proj' }), '/proj/a.py'); + assert.strictEqual(canonicalPathKey('/abs/A.py', { cwd: '/proj' }), '/abs/A.py', 'posix keeps case'); + }); + withProjectDir(undefined, () => { + assert.strictEqual(canonicalPathKey('src\\A.py', { cwd: 'C:\\Proj' }), 'c:/proj/src/a.py'); + assert.strictEqual(canonicalPathKey('a.py', { cwd: 'relative/base' }), 'a.py', 'non-absolute base: raw path'); + }); +}); + +// ── isCollapsibleTarget ── +console.log('\nisCollapsibleTarget:'); + +test('only code/test/prose outside dotfiles and harness dirs may collapse', () => { + assert.deepStrictEqual([...COLLAPSIBLE_CLASSES].sort(), ['code', 'prose', 'test']); + withProjectDir('/proj', () => { + const data = { cwd: '/proj' }; + assert.strictEqual(isCollapsibleTarget('/proj/src/a.js', data, 'code'), true); + assert.strictEqual(isCollapsibleTarget('/proj/docs/a.md', data, 'prose'), true); + assert.strictEqual(isCollapsibleTarget('/proj/app.yaml', data, 'config'), false, 'config'); + assert.strictEqual(isCollapsibleTarget('/proj/skills/x/SKILL.md', data, 'instruction'), false, 'instruction'); + assert.strictEqual(isCollapsibleTarget('/proj/src/.hidden.js', data, 'code'), false, 'dotfile'); + assert.strictEqual(isCollapsibleTarget('/proj/.github/scripts/a.js', data, 'code'), false, 'harness dir'); + assert.strictEqual(isCollapsibleTarget('/proj/.husky/pre-commit.js', data, 'code'), false, 'hook dir'); + }); +}); + +test('a new file under any dot-directory never collapses', () => { + withProjectDir('/proj', () => { + const data = { cwd: '/proj' }; + for (const rel of [ + '.devcontainer/post-create.sh', + '.githooks/pre-commit', + '.kiro/steering/b.md', + '.clinerules/b.md', + '.idea/run.xml', + '.continue/prompts/b.prompt', + 'src/.generated/a.js', + '.vscode/tasks.js' + ]) { + assert.strictEqual(isCollapsibleTarget(`/proj/${rel}`, data, classifyTargetFor(`/proj/${rel}`, data)), false, rel); + assert.strictEqual(isCollapsibleTarget(`/proj/${rel}`, data, 'code'), false, `${rel} (as code)`); + } + assert.strictEqual(isCollapsibleTarget('/proj/src/gen/a.js', data, 'code'), true, 'control'); + }); +}); + +test('Windows name tricks: trailing dots/spaces, streams and 8.3 short names', () => { + withProjectDir(undefined, () => { + const data = { cwd: 'C:\\proj' }; + for (const p of ['C:\\proj\\CLAUDE.md.', 'C:\\proj\\CLAUDE.md ', 'C:\\proj\\CLAUDE.md::$DATA', 'C:\\proj\\CLAUDE.md:x', 'C:\\proj\\CLAUDE.md. .::$DATA']) { + assert.strictEqual(classifyTargetFor(p, data), 'instruction', JSON.stringify(p)); + } + assert.strictEqual(classifyTargetFor('C:\\proj\\.claude.\\hooks\\x.md', data), 'instruction', 'trailing dot on a directory'); + assert.strictEqual(classifyTargetFor('C:\\proj\\src\\a.js', data), 'code', 'control'); + const shortName = 'C:\\proj\\CLAUDE~1\\hooks\\x.ps1'; + assert.strictEqual(isCollapsibleTarget(shortName, data, classifyTargetFor(shortName, data)), false, '8.3 short name'); + assert.strictEqual(isCollapsibleTarget('C:\\proj\\src\\x.ps1', data, 'code'), true, 'control'); + }); + withProjectDir('/proj', () => { + const data = { cwd: '/proj' }; + assert.strictEqual(classifyTargetFor('/proj/CLAUDE.md::$DATA', data), 'instruction', 'posix segment with ::$'); + assert.strictEqual(classifyTargetFor('/proj/notes.md.', data), 'code', 'posix trailing dot is a real name'); + }); +}); + +// ── collapseGateDir (real directories) ── +console.log('\ncollapseGateDir:'); + +function tempProject() { + const root = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'gateguard-collapse-'))); + fs.mkdirSync(path.join(root, 'src', 'real'), { recursive: true }); + fs.mkdirSync(path.join(root, '.claude', 'hooks'), { recursive: true }); + return root; +} + +function trySymlink(target, link, type = 'dir') { + try { + fs.symlinkSync(target, link, type); + return true; + } catch (e) { + if (process.platform === 'win32' && e.code === 'EPERM') return false; + throw e; + } +} + +test('the worktree prefix is stripped only for a real worktree (.git present)', () => { + const root = tempProject(); + try { + withProjectDir(root, () => { + const data = { cwd: root }; + const wt = rel => path.join(root, '.claude', 'worktrees', 'w1', rel); + fs.mkdirSync(wt('docs'), { recursive: true }); + assert.strictEqual(classifyTargetFor(wt('docs/g.md'), data), 'instruction', 'no .git: stays under .claude'); + assert.strictEqual(classifyTargetFor(wt('src/a.py'), data), 'code'); + assert.strictEqual(isCollapsibleTarget(wt('src/a.py'), data, 'code'), false, 'no .git: never collapses'); + fs.writeFileSync(wt('.git'), 'gitdir: ../../../.git/worktrees/w1\n'); + assert.strictEqual(classifyTargetFor(wt('docs/g.md'), data), 'prose', '.git file: worktree'); + assert.strictEqual(isCollapsibleTarget(wt('src/a.py'), data, 'code'), true, '.git file: collapses like the project'); + assert.strictEqual(isCollapsibleTarget(wt('.claude/hooks/x.sh'), data, 'code'), false, 'harness dir inside a worktree'); + }); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +}); + +test('collapse is screened on the real directory (symlinks, missing parents, fs errors)', () => { + const root = tempProject(); + try { + withProjectDir(root, () => { + const data = { cwd: root }; + const at = rel => path.join(root, rel); + const key = p => (process.platform === 'win32' ? p.replace(/\\/g, '/').toLowerCase() : p); + assert.strictEqual(collapseGateDir(at('src/real/a.js'), data, 'code'), key(at('src/real')), 'plain directory'); + assert.strictEqual(collapseGateDir(at('src/new/deeper/a.js'), data, 'code'), key(at('src/new/deeper')), 'missing parents'); + assert.strictEqual(collapseGateDir(at('.claude/hooks/a.sh'), data, 'code'), null, 'dot-directory'); + assert.strictEqual(collapseGateDir(at('src/real/a.js'), data, 'config'), null, 'non-collapsible class'); + fs.writeFileSync(at('src/file'), 'x'); + assert.strictEqual(collapseGateDir(at('src/file/a.js'), data, 'code'), null, 'parent is a file (ENOTDIR)'); + if (!trySymlink(at('.claude/hooks'), at('src/tools'))) return; + assert.strictEqual(collapseGateDir(at('src/tools/evil.sh'), data, 'code'), null, 'symlink into .claude/hooks'); + assert.strictEqual(collapseGateDir(at('src/tools/sub/evil.sh'), data, 'code'), null, 'missing dir under that symlink'); + trySymlink(at('src/real'), at('src/alias')); + assert.strictEqual(collapseGateDir(at('src/alias/b.js'), data, 'code'), key(at('src/real')), 'in-project symlink keys by the real dir'); + trySymlink(at('tests'), at('src/t-alias')); + fs.mkdirSync(at('tests'), { recursive: true }); + assert.strictEqual(collapseGateDir(at('src/t-alias/b.js'), data, 'code'), null, 'real location has another class'); + const outside = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'gateguard-outside-'))); + try { + trySymlink(outside, at('src/out')); + assert.strictEqual(collapseGateDir(at('src/out/b.js'), data, 'code'), null, 'symlink out of the project'); + } finally { + fs.rmSync(outside, { recursive: true, force: true }); + } + trySymlink(at('nowhere'), at('src/dangling')); + assert.strictEqual(collapseGateDir(at('src/dangling/b.js'), data, 'code'), null, 'dangling symlink'); + }); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +}); + +test('a path of the other platform style is never collapsed', () => { + const other = process.platform === 'win32' ? '/proj' : 'C:\\proj'; + withProjectDir(other, () => { + const target = process.platform === 'win32' ? '/proj/src/a.js' : 'C:\\proj\\src\\a.js'; + assert.strictEqual(collapseGateDir(target, { cwd: other }, 'code'), null); + }); +}); + +// ── question tables ── +console.log('\nquestion tables:'); + +test('every non-code class has questions and a condensed hint; code has neither', () => { + for (const cls of ['instruction', 'test', 'prose', 'config']) { + for (const isWrite of [true, false]) { + const questions = CLASS_QUESTIONS[cls](isWrite); + assert.ok(Array.isArray(questions) && questions.length >= 2, `${cls} questions`); + assert.ok(CLASS_CONDENSED_HINTS[cls](isWrite).endsWith('then retry.'), `${cls} hint`); + } + } + assert.strictEqual(CLASS_QUESTIONS.code, undefined); + assert.strictEqual(CLASS_CONDENSED_HINTS.code, undefined); + assert.notDeepStrictEqual(CLASS_QUESTIONS.prose(true), CLASS_QUESTIONS.prose(false), 'prose Write and Edit differ'); +}); + +test('questionIdsFor gives stable ids per class, action and change profile', () => { + const known = (touchesPublicSurface, touchesData) => ({ known: true, language: 'js', touchesPublicSurface, touchesData, trivial: false }); + const table = [ + ['code', false, null, ['importers', 'public-api', 'data-schema', 'external-contract', 'quote-instruction']], + ['code', true, null, ['callers', 'no-duplicate', 'data-schema', 'external-contract', 'quote-instruction']], + ['code', false, { known: false, touchesPublicSurface: false, touchesData: false }, ['importers', 'public-api', 'data-schema', 'external-contract', 'quote-instruction']], + ['code', false, known(true, true), ['importers', 'public-api', 'data-schema', 'external-contract', 'quote-instruction']], + ['code', false, known(true, false), ['importers', 'public-api', 'quote-instruction']], + ['code', false, known(false, true), ['local-callers', 'data-schema', 'external-contract', 'quote-instruction']], + ['code', false, known(false, false), ['local-callers', 'quote-instruction']], + ['code', true, known(false, false), ['callers', 'no-duplicate', 'quote-instruction']], + ['code', true, known(true, true), ['callers', 'no-duplicate', 'data-schema', 'external-contract', 'quote-instruction']], + ['instruction', false, known(false, false), ['loader', 'behaviour-change', 'no-duplicate-instruction', 'quote-instruction']], + ['test', true, known(false, false), ['under-test', 'existing-tests', 'quote-instruction']], + ['prose', true, null, ['supersedes', 'linked-from', 'why-new-file', 'quote-instruction']], + ['prose', false, null, ['references', 'corrects-or-adds', 'quote-instruction']], + ['config', false, known(false, false), ['config-reader', 'config-effect', 'no-plaintext-secrets', 'quote-instruction']], + ['__proto__', false, null, ['importers', 'public-api', 'data-schema', 'external-contract', 'quote-instruction']] + ]; + for (const [cls, isWrite, profile, expected] of table) { + const ids = questionIdsFor(cls, isWrite, profile); + assert.deepStrictEqual(ids, expected, `${cls} ${isWrite ? 'Write' : 'Edit'} ${JSON.stringify(profile)}`); + for (const id of ids) assert.ok(questionText(id), `${id} has text`); + } + assert.strictEqual(questionText('constructor'), ''); + assert.strictEqual(questionText('missing'), ''); +}); + +test('class question texts are unchanged by the ids', () => { + assert.deepStrictEqual(questionIdsFor('prose', true).slice(0, -1).map(questionText), CLASS_QUESTIONS.prose(true)); + assert.deepStrictEqual(questionIdsFor('config', false).slice(0, -1).map(questionText), CLASS_QUESTIONS.config(false)); + assert.strictEqual(questionText('quote-instruction'), QUOTE_INSTRUCTION); +}); + +test('condensedHintFor matches the question set', () => { + const local = { known: true, touchesPublicSurface: false, touchesData: false }; + assert.ok(condensedHintFor('code', false, local).includes('call sites in this file or its module')); + assert.strictEqual(condensedHintFor('test', false, local), CLASS_CONDENSED_HINTS.test(false)); +}); + +test('code condensed hints name exactly the questions of the change profile', () => { + const known = (touchesPublicSurface, touchesData) => ({ known: true, language: 'js', touchesPublicSurface, touchesData, trivial: false }); + const ids = ['importers', 'public-api', 'local-callers', 'callers', 'no-duplicate', 'data-schema', 'external-contract', 'quote-instruction']; + for (const id of ids) assert.ok(condensedQuestionPhrase(id), `${id} has a phrase`); + for (const isWrite of [false, true]) { + for (const [s, d] of [[true, true], [true, false], [false, true], [false, false]]) { + const profile = known(s, d); + const hint = condensedHintFor('code', isWrite, profile); + const asked = questionIdsFor('code', isWrite, profile); + for (const id of ids) { + assert.strictEqual(hint.includes(condensedQuestionPhrase(id)), asked.includes(id), `${isWrite ? 'Write' : 'Edit'} ${s}/${d} ${id}`); + } + assert.ok(hint.startsWith('briefly state ') && hint.endsWith(', then retry.'), hint); + } + } +}); + +test('a class that skips profiling asks the same questions under every profile', () => { + const profiles = [null, { known: false }]; + for (const s of [true, false]) for (const d of [true, false]) profiles.push({ known: true, language: 'js', touchesPublicSurface: s, touchesData: d, trivial: false }); + const classes = ['code', 'test', 'prose', 'instruction', 'config', 'unknown-class']; + for (const cls of classes) { + for (const isWrite of [false, true]) { + const asked = profiles.map(profile => JSON.stringify(questionIdsFor(cls, isWrite, profile))); + const hints = profiles.map(profile => condensedHintFor(cls, isWrite, profile)); + if (!questionsUseProfile(cls)) { + assert.strictEqual(new Set(asked).size, 1, `${cls} ${isWrite} questions vary with the profile`); + assert.strictEqual(new Set(hints).size, 1, `${cls} ${isWrite} hints vary with the profile`); + } + } + } + assert.strictEqual(questionsUseProfile('code'), true); + for (const cls of ['test', 'prose', 'instruction', 'config']) assert.strictEqual(questionsUseProfile(cls), false, cls); +}); + +test('code condensed hints read as one sentence', () => { + const known = (touchesPublicSurface, touchesData) => ({ known: true, language: 'js', touchesPublicSurface, touchesData, trivial: false }); + assert.strictEqual( + condensedHintFor('code', false, known(false, false)), + "briefly state the call sites in this file or its module that rely on the change and the user's verbatim instruction, then retry." + ); + assert.strictEqual( + condensedHintFor('code', false, known(true, true)), + "briefly state the files that import this file, the public functions/classes affected, the data schemas it reads or writes, what outside the repository fixes the format or semantics, and the user's verbatim instruction, then retry." + ); + assert.strictEqual( + condensedHintFor('code', true, known(true, false)), + "briefly state the file(s) and line(s) that will call it, that no existing file serves the same purpose, and the user's verbatim instruction, then retry." + ); +}); + +test('code condensed hints keep the data and duplicate checks when the profile asks for them', () => { + const known = (touchesPublicSurface, touchesData) => ({ known: true, language: 'py', touchesPublicSurface, touchesData, trivial: false }); + assert.ok(condensedHintFor('code', false, known(false, true)).includes(condensedQuestionPhrase('data-schema'))); + assert.ok(condensedHintFor('code', false, known(true, false)).includes(condensedQuestionPhrase('importers'))); + for (const profile of [known(false, false), known(true, true)]) { + assert.ok(condensedHintFor('code', true, profile).includes(condensedQuestionPhrase('no-duplicate')), 'Write asks for duplicates'); + assert.ok(condensedHintFor('code', true, profile).includes(condensedQuestionPhrase('callers')), 'Write asks for callers'); + } +}); + +test('code condensed Edit hint for an unknown profile names every question asked', () => { + const expected = "briefly state importers/callers, affected API, data schemas if any, what outside the repository fixes the format or semantics, and the user's verbatim instruction, then retry."; + for (const profile of [null, undefined, { known: false, touchesPublicSurface: false, touchesData: false }, 'x']) { + assert.strictEqual(condensedHintFor('code', false, profile), expected); + assert.strictEqual(condensedHintFor('__proto__', false, profile), expected); + } +}); + +test('code condensed Write hint for an unknown profile asks every creation question', () => { + for (const profile of [null, undefined, { known: false, touchesPublicSurface: false, touchesData: false }, 'x']) { + assert.strictEqual( + condensedHintFor('code', true, profile), + "briefly state the file(s) and line(s) that will call it, that no existing file serves the same purpose, the data schemas it reads or writes, what outside the repository fixes the format or semantics, and the user's verbatim instruction, then retry." + ); + } + assert.strictEqual(condensedQuestionPhrase('constructor'), ''); + assert.strictEqual(condensedQuestionPhrase('loader'), ''); +}); + +// ── isSensitiveTarget ── + +test('isSensitiveTarget matches every listed basename, segment and prefix rule', () => { + const sensitive = [ + '.env', '.env.local', '.env.production.local', 'config/.env', 'config/.env.test', + 'certs/server.pem', 'certs/server.key', 'certs/client.p12', 'certs/client.pfx', + 'home/id_rsa', 'home/id_rsa.pub', 'id_ed25519', 'home/id_ed25519.pub', + 'keys/id_ecdsa', 'keys/id_ecdsa.pub', 'keys/id_dsa', '.netrc', 'home/.pgpass', + 'credentials', 'config/credentials.json', 'aws/credentials', 'config/secrets.yaml', 'secrets.json', + 'src/auth/login.py', 'src/authn/x.ts', 'src/authz/policy.go', 'lib/security/x.js', + 'src/secrets/vault.py', 'src/payment/charge.py', 'src/payments/x.py', 'app/billing/invoice.rb', + 'db/migrations/0001_init.sql', 'auth/readme.md', 'src/auth', + '.github/workflows/ci.yml', '.github/workflows/sub/x.yaml', './.github/workflows/ci.yml', + '.claude/worktrees/fake/.github/workflows/ci.yml', '/elsewhere/repo/.github/workflows/ci.yml', + 'SRC/Auth/Login.py', '.ENV', 'Certs/Server.PEM', 'src\\billing\\x.py' + ]; + for (const p of sensitive) assert.strictEqual(isSensitiveTarget(p), true, p); +}); + +test('isSensitiveTarget is segment-exact and basename-exact (no substring matches)', () => { + const ordinary = [ + 'src/author.py', 'docs/authoring.md', 'lib/paymentutils.py', 'src/authentication/x.py', + 'src/securityutils/x.py', 'src/billingreport.py', 'src/migration.py', 'src/environment.py', + 'envoy.yaml', 'src/keyboard.py', 'src/key.py', 'src/secretsmanager.py', 'src/my_credentials.py', + 'docs/workflows/ci.md', 'github/workflows/ci.yml', '.github/ci.yml', 'src/app.js', 'README.md', 'src/.envrc', + 'src/netrc.py', '.netrc.bak', 'src/pgpass_reader.py' + ]; + for (const p of ordinary) assert.strictEqual(isSensitiveTarget(p), false, p); +}); + +test('isSensitiveTarget fails safe (any error => sensitive)', () => { + const hostile = { toString() { throw new Error('boom'); } }; + assert.strictEqual(isSensitiveTarget(hostile), true); + assert.strictEqual(isSensitiveTarget(Symbol('x')), true); +}); + +test('isSensitiveTargetFor uses the project-relative class path', () => { + const root = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'gateguard-sensitive-'))); + try { + // The project itself sits under an ancestor named `security`: only project-relative segments count. + const proj = path.join(root, 'security', 'proj'); + fs.mkdirSync(proj, { recursive: true }); + withProjectDir(proj, () => { + const data = { cwd: proj }; + assert.strictEqual(isSensitiveTargetFor(`${proj}/src/app.py`, data), false, 'ancestor segment ignored'); + assert.strictEqual(isSensitiveTargetFor(`${proj}/src/auth/app.py`, data), true); + assert.strictEqual(isSensitiveTargetFor('src/billing/x.py', data), true, 'relative to cwd'); + assert.strictEqual(isSensitiveTargetFor(`${proj}/.github/workflows/ci.yml`, data), true); + fs.mkdirSync(path.join(proj, '.claude', 'worktrees', 'w1'), { recursive: true }); + fs.writeFileSync(path.join(proj, '.claude', 'worktrees', 'w1', '.git'), 'gitdir: x\n'); + assert.strictEqual(isSensitiveTargetFor(`${proj}/.claude/worktrees/w1/.github/workflows/ci.yml`, data), true); + assert.strictEqual(isSensitiveTargetFor(`${proj}/.claude/worktrees/w1/src/app.py`, data), false); + assert.strictEqual(isSensitiveTargetFor(`${proj}/.env.`, data), true); + }); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +}); + +test('isSensitiveTargetFor also judges the real (symlink-resolved) path', () => { + const root = tempProject(); + const outside = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'gateguard-outside-'))); + try { + withProjectDir(root, () => { + const data = { cwd: root }; + const at = rel => path.join(root, rel); + fs.mkdirSync(at('src/auth'), { recursive: true }); + fs.writeFileSync(at('src/auth/login.py'), 'x'); + if (!trySymlink(at('src/auth'), at('src/tools'))) return; + assert.strictEqual(isSensitiveTargetFor(at('src/tools/login.py'), data), true, 'existing file through a dir symlink'); + assert.strictEqual(isSensitiveTargetFor(at('src/tools/new_handler.py'), data), true, 'new file through a dir symlink'); + assert.strictEqual(isSensitiveTargetFor(at('src/tools/deeper/x.py'), data), true, 'missing dir under the symlink'); + assert.strictEqual(isSensitiveTargetFor('src/tools/login.py', data), true, 'relative spelling'); + trySymlink(at('src/real'), at('src/alias')); + assert.strictEqual(isSensitiveTargetFor(at('src/alias/a.py'), data), false, 'alias of an ordinary dir'); + assert.strictEqual(isSensitiveTargetFor(at('src/real/a.py'), data), false, 'direct ordinary path'); + fs.mkdirSync(path.join(outside, 'secrets'), { recursive: true }); + trySymlink(path.join(outside, 'secrets'), at('src/ext')); + assert.strictEqual(isSensitiveTargetFor(at('src/ext/a.py'), data), true, 'real location outside the project is judged too'); + trySymlink(outside, at('src/ext-plain')); + assert.strictEqual(isSensitiveTargetFor(at('src/ext-plain/a.py'), data), false, 'ordinary location outside the project'); + trySymlink(at('nowhere'), at('src/dangling')); + assert.strictEqual(isSensitiveTargetFor(at('src/dangling/a.py'), data), true, 'dangling symlink fails closed'); + fs.writeFileSync(at('src/plainfile'), 'x'); + assert.strictEqual(isSensitiveTargetFor(at('src/plainfile/a.py'), data), true, 'ENOTDIR fails closed'); + try { + fs.symlinkSync(at('src/auth/login.py'), at('src/real/login_link.py'), 'file'); + assert.strictEqual(isSensitiveTargetFor(at('src/real/login_link.py'), data), true, 'file symlink into auth/'); + } catch (e) { + if (!(process.platform === 'win32' && e.code === 'EPERM')) throw e; + } + }); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + fs.rmSync(outside, { recursive: true, force: true }); + } +}); + +test('collapseGateDir never keys a gate for a sensitive real location', () => { + const root = tempProject(); + try { + withProjectDir(root, () => { + const data = { cwd: root }; + const at = rel => path.join(root, rel); + fs.mkdirSync(at('src/payments'), { recursive: true }); + if (!trySymlink(at('src/payments'), at('src/tools'))) return; + assert.strictEqual(collapseGateDir(at('src/tools/a.py'), data, 'code'), null); + }); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +}); + +test('isSensitiveTargetFor fails safe on bad input', () => { + assert.strictEqual(isSensitiveTargetFor(undefined, undefined), true); + assert.strictEqual(isSensitiveTargetFor({ toString() { throw new Error('x'); } }, {}), true); +}); + +function tryHardLink(existing, link) { + try { + fs.linkSync(existing, link); + return true; + } catch (e) { + if (['EPERM', 'ENOTSUP', 'EXDEV', 'ENOSYS', 'EOPNOTSUPP'].includes(e.code)) return false; + throw e; + } +} + +test('isHardLinkedTargetFor is true for a file with more than one link', () => { + const root = tempProject(); + try { + withProjectDir(root, () => { + const data = { cwd: root }; + const at = rel => path.join(root, rel); + fs.mkdirSync(at('src/lib'), { recursive: true }); + fs.writeFileSync(at('src/lib/report.py'), 'x'); + fs.writeFileSync(at('src/lib/single.py'), 'x'); + if (!tryHardLink(at('src/lib/report.py'), at('src/report_alias.py'))) return; + assert.strictEqual(isHardLinkedTargetFor(at('src/lib/report.py'), data), true, 'original name'); + assert.strictEqual(isHardLinkedTargetFor('src/report_alias.py', data), true, 'second name, relative spelling'); + assert.strictEqual(isHardLinkedTargetFor(at('src/lib/single.py'), data), false, 'single link'); + assert.strictEqual(isHardLinkedTargetFor(at('src/lib/new_file.py'), data), false, 'a missing new file'); + assert.strictEqual(isHardLinkedTargetFor(at('src/lib'), data), false, 'a directory'); + fs.writeFileSync(at('src/plainfile'), 'x'); + assert.strictEqual(isHardLinkedTargetFor(at('src/plainfile/a.py'), data), false, 'ENOTDIR is a missing file'); + if (trySymlink(at('src/lib/report.py'), at('src/report_symlink.py'), 'file')) { + assert.strictEqual(isHardLinkedTargetFor(at('src/report_symlink.py'), data), true, 'symlink to a hard-linked file'); + } + if (trySymlink(at('src/lib/single.py'), at('src/single_symlink.py'))) { + assert.strictEqual(isHardLinkedTargetFor(at('src/single_symlink.py'), data), false, 'symlink to a single-link file'); + } + }); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +}); + +test('isHardLinkedTargetFor fails closed on stat errors other than a missing file', () => { + const root = tempProject(); + const savedLstat = fs.lstatSync; + try { + withProjectDir(root, () => { + const data = { cwd: root }; + const target = path.join(root, 'src', 'a.py'); + for (const code of ['EACCES', 'EIO', 'ELOOP', 'EPERM']) { + fs.lstatSync = () => { + const error = new Error(code); + error.code = code; + throw error; + }; + assert.strictEqual(isHardLinkedTargetFor(target, data), true, code); + } + fs.lstatSync = () => { + throw new Error('no code'); + }; + assert.strictEqual(isHardLinkedTargetFor(target, data), true, 'error without a code'); + fs.lstatSync = savedLstat; + assert.strictEqual(isHardLinkedTargetFor(undefined, data), true, 'non-string path'); + assert.strictEqual(isHardLinkedTargetFor(target, { cwd: 'relative/dir' }), true, 'unresolvable target'); + }); + } finally { + fs.lstatSync = savedLstat; + fs.rmSync(root, { recursive: true, force: true }); + } +}); + +test('the hook re-exports the lib classification functions', () => { + // The hook prunes stale state files on load: point it at a scratch dir. + const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gateguard-target-class-')); + const savedStateDir = process.env.GATEGUARD_STATE_DIR; + process.env.GATEGUARD_STATE_DIR = stateDir; + let hook; + try { + hook = require(hookPath); + } finally { + if (savedStateDir === undefined) delete process.env.GATEGUARD_STATE_DIR; + else process.env.GATEGUARD_STATE_DIR = savedStateDir; + fs.rmSync(stateDir, { recursive: true, force: true }); + } + assert.strictEqual(hook.classifyTarget, classifyTarget); + assert.strictEqual(hook.classifyTargetFor, classifyTargetFor); +}); + +console.log(`\nResults: Passed: ${passed}, Failed: ${failed}`); +process.exit(failed > 0 ? 1 : 0); diff --git a/tests/lib/gateguard-turn-scan.test.js b/tests/lib/gateguard-turn-scan.test.js new file mode 100644 index 000000000..3ffd69ffe --- /dev/null +++ b/tests/lib/gateguard-turn-scan.test.js @@ -0,0 +1,392 @@ +'use strict'; +/** + * Tests for scripts/lib/gateguard-turn-scan.js: turn boundaries, turn ids, + * result pairing, error results, duplicate ids and same-batch exclusion. + * + * Run with: node tests/lib/gateguard-turn-scan.test.js + */ + +const assert = require('assert'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); + +const libPath = path.join(__dirname, '..', '..', 'scripts', 'lib', 'gateguard-turn-scan.js'); +const hookPath = path.join(__dirname, '..', '..', 'scripts', 'hooks', 'gateguard-fact-force.js'); +const { + isErrorToolResult, + isToolResultRecord, + isCompactionBoundary, + scanCurrentTurn, + excludedBatchId, + createTurnScanner, + currentTurnId, + transcriptPathFor +} = require(libPath); + +console.log('=== Testing gateguard-turn-scan.js ===\n'); + +let passed = 0; +let failed = 0; + +function test(desc, fn) { + try { + fn(); + console.log(` ✓ ${desc}`); + passed++; + } catch (e) { + console.log(` ✗ ${desc}: ${e.message}`); + failed++; + } +} + +// Transcript fixtures in the shape Claude Code writes (one JSON record per line). +const transcriptDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gateguard-turn-scan-')); +let seq = 0; +let uuidSeq = 0; +const nextUuid = () => `uuid-${++uuidSeq}`; +const human = (text, extra = {}) => ({ type: 'user', uuid: nextUuid(), message: { role: 'user', content: text }, ...extra }); +const toolUse = (id, name, input, extra = {}) => ({ + type: 'assistant', + uuid: nextUuid(), + message: { id: `msg_${id}`, role: 'assistant', content: [{ type: 'tool_use', id, name, input }] }, + ...extra +}); +const toolResult = (id, isError = false, extra = {}) => ({ + type: 'user', + uuid: nextUuid(), + message: { role: 'user', content: [{ type: 'tool_result', tool_use_id: id, is_error: isError, content: 'ok' }] }, + ...extra +}); +const search = (id, name, input) => [toolUse(id, name, input), toolResult(id)]; +// Like Claude Code, a fixture ends with the pending call's own assistant record by default. +const writeTranscript = (records, { pending = true } = {}) => { + seq += 1; + const file = path.join(transcriptDir, `t-${seq}.jsonl`); + const all = records.slice(); + if (pending) { + all.push({ + type: 'assistant', + uuid: nextUuid(), + message: { id: `msg_pending_${seq}`, role: 'assistant', content: [{ type: 'tool_use', id: `toolu_pending_${seq}`, name: 'Edit', input: {} }] } + }); + } + const lines = all.map(r => (typeof r === 'string' ? r : JSON.stringify(r))); + fs.writeFileSync(file, lines.join('\n') + '\n', 'utf8'); + return file; +}; +// ~400 KiB of tool traffic: pushes earlier records out of the 256 KiB tail. +const filler = prefix => { + const records = []; + for (let i = 0; i < 40; i++) { + const id = `toolu_${prefix}${i}`; + records.push(toolUse(id, 'Bash', { command: 'npm test' })); + records.push({ + type: 'user', + message: { role: 'user', content: [{ type: 'tool_result', tool_use_id: id, content: 'y'.repeat(10 * 1024) }] } + }); + } + return records; +}; + +// ── turn id ── +console.log('turn id:'); + +test('boundary in window with promptId -> turnId is the promptId', () => { + const t = writeTranscript([ + human('old', { promptId: 'prompt-old' }), + human('now', { promptId: 'prompt-now' }), + ...search('toolu_pid1', 'Grep', { pattern: 'widget_factory' }).map(r => (r.type === 'user' ? { ...r, promptId: 'prompt-now' } : r)) + ]); + assert.strictEqual(scanCurrentTurn(t).turnId, 'prompt-now'); + const overlong = human('x', { promptId: 'p'.repeat(129) }); + assert.strictEqual(scanCurrentTurn(writeTranscript([overlong])).turnId, overlong.uuid, 'overlong promptId ignored'); +}); + +test('records without promptId keep the uuid / hash / null turn id', () => { + const boundary = human('now'); + assert.strictEqual(scanCurrentTurn(writeTranscript([boundary])).turnId, boundary.uuid); + const noUuid = writeTranscript([{ type: 'user', message: { role: 'user', content: 'x' } }]); + assert.ok(/^h:[0-9a-f]{16}$/.test(scanCurrentTurn(noUuid).turnId)); + const clipped = writeTranscript([human('long ago'), ...filler('nopid')]); + assert.strictEqual(scanCurrentTurn(clipped).turnId, null, 'clipped window without promptId'); +}); + +test('compaction summary and compact_boundary records start a turn', () => { + const pre = [human('orig task'), ...search('toolu_c1', 'Grep', { pattern: 'payment' })]; + const summary = { type: 'user', uuid: nextUuid(), isCompactSummary: true, message: { role: 'user', content: 'Summary' } }; + const afterSummary = scanCurrentTurn(writeTranscript([...pre, summary], { pending: false })); + assert.strictEqual(afterSummary.turnId, summary.uuid); + assert.deepStrictEqual(afterSummary.searches, [], 'searches before compaction do not count'); + const marker = scanCurrentTurn(writeTranscript([...pre, { type: 'system', uuid: 'cb-1', subtype: 'compact_boundary' }], { pending: false })); + assert.strictEqual(marker.turnId, 'cb-1'); + assert.deepStrictEqual(marker.searches, []); + assert.strictEqual(isCompactionBoundary({ ...summary, isSidechain: true }), false, 'sidechain summaries are ignored'); +}); + +test('meta and sidechain user records are not boundaries; tool_result records are not either', () => { + const boundary = human('go'); + const t = writeTranscript( + [ + boundary, + ...search('toolu_m1', 'Grep', { pattern: 'alpha' }), + human('meta note', { isMeta: true }), + human('side prompt', { isSidechain: true }) + ], + { pending: false } + ); + const scan = scanCurrentTurn(t); + assert.strictEqual(scan.turnId, boundary.uuid); + assert.deepStrictEqual(scan.searches.map(s => s.name), ['Grep']); + assert.strictEqual(isToolResultRecord(toolResult('x')), true); + assert.strictEqual(isToolResultRecord(human('x')), false); +}); + +// ── which searches qualify ── +console.log('\nsearches:'); + +test('a search qualifies only with a paired, non-error, unique result', () => { + const t = writeTranscript( + [ + human('go'), + ...search('toolu_ok', 'Grep', { pattern: 'ok' }), + toolUse('toolu_noresult', 'Grep', { pattern: 'pending' }), + toolUse('toolu_err', 'Grep', { pattern: 'err' }), + toolResult('toolu_err', true), + ...search('toolu_dup', 'Glob', { pattern: 'a/*' }), + ...search('toolu_dup', 'Glob', { pattern: 'b/*' }), + ...search('toolu_read', 'Read', { file_path: '/x.js' }) + ], + { pending: false } + ); + const scan = scanCurrentTurn(t); + assert.deepStrictEqual(scan.searches.map(s => s.input.pattern), ['ok']); +}); + +test('error results are recognised without a boolean is_error', () => { + assert.strictEqual(isErrorToolResult({ is_error: true }), true); + assert.strictEqual(isErrorToolResult({ is_error: 'true' }), true); + assert.strictEqual(isErrorToolResult({ content: ' denied' }), true); + assert.strictEqual(isErrorToolResult({ content: [{ type: 'text', text: 'x' }] }), true); + assert.strictEqual(isErrorToolResult({ is_error: false, content: 'found 3 files' }), false); +}); + +test('callsAgo counts back from the newest call, skipping the pending call', () => { + const t = writeTranscript([human('go'), ...search('toolu_a', 'Grep', { pattern: 'a' }), ...search('toolu_b', 'Bash', { command: 'ls' })]); + const scan = scanCurrentTurn(t, `toolu_pending_${seq}`); + assert.deepStrictEqual(scan.searches.map(s => [s.name, s.callsAgo]), [['Bash', 1], ['Grep', 2]]); + assert.deepStrictEqual(scan.shellCommands, ['ls']); +}); + +test('the scan lists the turn Read calls newest first, skipping the pending call', () => { + const t = writeTranscript([ + human('go'), + toolUse('toolu_r1', 'Read', { file_path: 'src/a.js' }), + toolResult('toolu_r1'), + ...search('toolu_g', 'Grep', { pattern: 'a' }), + toolUse('toolu_r2', 'Read', { file_path: 'src/b.js' }), + toolResult('toolu_r2'), + toolUse('toolu_r3', 'Read', { file_path: 42 }) + ]); + const scan = scanCurrentTurn(t, `toolu_pending_${seq}`); + assert.deepStrictEqual(scan.reads, [ + { name: 'Read', path: 'src/b.js', callsAgo: 2 }, + { name: 'Read', path: 'src/a.js', callsAgo: 4 } + ]); + assert.deepStrictEqual(scan.searches.map(s => s.name), ['Grep'], 'reads are never searches'); + const before = writeTranscript([toolUse('toolu_r0', 'Read', { file_path: 'x' }), toolResult('toolu_r0'), human('next')]); + assert.deepStrictEqual(scanCurrentTurn(before).reads, [], 'reads before the turn are not listed'); +}); + +test('no transcript, a directory, or a whole file without a boundary -> no searches', () => { + assert.strictEqual(scanCurrentTurn(''), null); + assert.strictEqual(scanCurrentTurn(path.join(transcriptDir, 'missing.jsonl')), null); + assert.strictEqual(scanCurrentTurn(transcriptDir), null); + const noBoundary = scanCurrentTurn(writeTranscript(search('toolu_nb', 'Grep', { pattern: 'x' }), { pending: false })); + assert.deepStrictEqual(noBoundary.searches, []); + assert.strictEqual(noBoundary.turnId, null); +}); + +test('boundary in window -> the boundary record\'s own promptId, not a newer record\'s', () => { + const late = { ...toolResult('toolu_n3a'), promptId: 'P1' }; + const t = writeTranscript([human('one', { promptId: 'P1' }), human('two', { promptId: 'P2' }), toolUse('toolu_n3a', 'Bash', { command: 'true' }), late]); + assert.strictEqual(scanCurrentTurn(t).turnId, 'P2', 'late tool_result stamped with the previous promptId'); + const noPid = human('two'); + const t2 = writeTranscript([noPid, toolUse('toolu_n3b', 'Bash', { command: 'true' }), { ...toolResult('toolu_n3b'), promptId: 'P9' }]); + assert.strictEqual(scanCurrentTurn(t2).turnId, noPid.uuid, 'boundary without promptId falls back to its uuid'); +}); + +test('clipped window without a boundary -> promptId only when all user records agree', () => { + const stamp = (records, pid) => records.map(r => (r.type === 'user' ? { ...r, promptId: pid } : r)); + const agree = writeTranscript([ + human('long ago', { promptId: 'PA' }), + ...stamp([...filler('agree'), ...search('toolu_agree', 'Grep', { pattern: 'payment' })], 'PA') + ]); + const agreeScan = scanCurrentTurn(agree); + assert.strictEqual(agreeScan.turnId, 'PA'); + assert.ok(agreeScan.searches.some(s => s.name === 'Grep'), 'agreeing clipped window still credits'); + const mixed = filler('mixed'); + const disagree = writeTranscript([ + human('long ago', { promptId: 'PA' }), + ...stamp(mixed.slice(0, 60), 'PA'), + ...stamp([...mixed.slice(60), ...search('toolu_dis', 'Grep', { pattern: 'payment' })], 'PB') + ]); + const disagreeScan = scanCurrentTurn(disagree); + assert.strictEqual(disagreeScan.turnId, null, 'two promptIds in the window'); + assert.deepStrictEqual(disagreeScan.searches, [], 'unattributable clipped window gives no credit'); + const metaNew = writeTranscript([ + human('long ago', { promptId: 'PA' }), + ...stamp([...filler('meta'), ...search('toolu_meta', 'Grep', { pattern: 'payment' })], 'PA'), + { type: 'user', uuid: nextUuid(), isMeta: true, promptId: 'PN', message: { role: 'user', content: '' } } + ]); + const metaScan = scanCurrentTurn(metaNew); + assert.strictEqual(metaScan.turnId, null, 'a meta record from another prompt also disagrees'); + assert.deepStrictEqual(metaScan.searches, [], 'previous-turn search behind an unrecognised turn start is not credited'); + const partial = writeTranscript([human('long ago', { promptId: 'PA' }), ...filler('partial').map((r, i) => (r.type === 'user' && i > 70 ? { ...r, promptId: 'PA' } : r))]); + assert.strictEqual(scanCurrentTurn(partial).turnId, 'PA', 'records without a promptId do not disagree'); +}); + +const MALFORMED_RECORDS = [ + '{"type":"assistant"}', + '{"type":"assistant","message":null}', + '{"type":"assistant","message":"str"}', + '{"type":"assistant","message":{"content":"str"}}', + '{"type":"assistant","message":{"content":[null,5,"x",{"type":"tool_use"}]}}', + '{"type":"user","message":{"content":[{"type":"tool_result","tool_use_id":"q","content":{"toString":1}}]}}', + '{"type":"user","message":{"content":[{"type":"tool_result","tool_use_id":"q","content":[null,{"type":"text","text":5}]}]}}', + 'null', + '5', + '"str"', + '[]' +]; +const MALFORMED_INPUTS = [ + undefined, + null, + 0, + 1, + '', + 'x', + [], + {}, + { message: null }, + { message: 'str' }, + { type: 'assistant' }, + { type: 'user', message: { content: {} } }, + { cwd: 5 }, + { tool_use_id: 5 }, + { batchIds: null, newestMessageId: 5 }, + { searches: null }, + new Proxy({}, { + get() { + throw new Error('trap'); + } + }) +]; + +test('every exported function tolerates malformed inputs without throwing', () => { + const lib = require(libPath); + const fns = Object.entries(lib).filter(([, fn]) => typeof fn === 'function'); + assert.ok(fns.length >= 9, `exported functions: ${fns.map(([name]) => name).join(', ')}`); + for (const [name, fn] of fns) { + MALFORMED_INPUTS.forEach((a, i) => { + MALFORMED_INPUTS.forEach((b, j) => { + try { + const result = fn(a, b); + if (typeof result === 'function') result(); + } catch (e) { + assert.fail(`${name}(MALFORMED_INPUTS[${i}], MALFORMED_INPUTS[${j}]) threw: ${e.message}`); + } + }); + }); + } +}); + +test('scanCurrentTurn skips each malformed record and keeps the rest of the turn', () => { + for (const bad of MALFORMED_RECORDS) { + const t = writeTranscript([human('go', { promptId: 'PM' }), ...search('toolu_m1', 'Grep', { pattern: 'payment' }), bad]); + let scan; + assert.doesNotThrow(() => { + scan = scanCurrentTurn(t, `toolu_pending_${seq}`); + }, bad); + assert.ok(scan, `${bad}: scan result`); + assert.strictEqual(scan.turnId, 'PM', `${bad}: turn id`); + assert.deepStrictEqual(scan.searches.map(s => s.name), ['Grep'], `${bad}: search kept`); + } + for (const bad of ['{"type":"user"}', '{"type":"user","message":null}', '{"type":"user","message":{"content":{}}}']) { + const t = writeTranscript([human('go', { promptId: 'PM' }), ...search('toolu_m2', 'Grep', { pattern: 'payment' }), bad]); + const scan = scanCurrentTurn(t, `toolu_pending_${seq}`); + assert.ok(scan && /^h:/.test(scan.turnId), `${bad}: boundary turn id`); + assert.deepStrictEqual(scan.searches, [], `${bad}: searches before it do not count`); + } +}); + +// ── same-batch exclusion ── +console.log('\nsame batch:'); + +test('excludedBatchId: the pending call message when located, else the newest message', () => { + const t = writeTranscript([human('go'), ...search('toolu_s1', 'Grep', { pattern: 'a' })]); + const pendingId = `toolu_pending_${seq}`; + const scan = scanCurrentTurn(t, pendingId); + assert.strictEqual(excludedBatchId(scan, { tool_use_id: pendingId }), `msg_pending_${seq}`); + assert.strictEqual(excludedBatchId(scan, {}), scan.newestMessageId); + assert.strictEqual(excludedBatchId(scan, { tool_use_id: 'toolu_unknown' }), `msg_pending_${seq}`, 'unknown id: newest message'); +}); + +// ── scanner wrapper ── +console.log('\nscanner:'); + +test('createTurnScanner reads the transcript once and currentTurnId guards the shape', () => { + const boundary = human('go'); + const t = writeTranscript([boundary]); + const saved = process.env.CLAUDE_TRANSCRIPT_PATH; + try { + process.env.CLAUDE_TRANSCRIPT_PATH = ''; + const getScan = createTurnScanner({ transcript_path: t }); + const first = getScan(); + fs.writeFileSync(t, JSON.stringify(human('changed')) + '\n', 'utf8'); + assert.strictEqual(getScan(), first, 'cached for the rest of run()'); + assert.strictEqual(currentTurnId(first), boundary.uuid); + assert.strictEqual(currentTurnId(null), null); + assert.strictEqual(currentTurnId({ turnId: '' }), null); + assert.strictEqual(transcriptPathFor({ transcriptPath: '/x.jsonl' }), '/x.jsonl'); + assert.strictEqual(createTurnScanner({})(), null, 'no transcript: null scan'); + } finally { + if (saved === undefined) delete process.env.CLAUDE_TRANSCRIPT_PATH; + else process.env.CLAUDE_TRANSCRIPT_PATH = saved; + } +}); + +test('the hook re-exports scanCurrentTurn', () => { + // The hook prunes stale state files on load: point it at a scratch dir. + const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gateguard-turn-scan-state-')); + const savedStateDir = process.env.GATEGUARD_STATE_DIR; + process.env.GATEGUARD_STATE_DIR = stateDir; + try { + assert.strictEqual(require(hookPath).scanCurrentTurn, scanCurrentTurn); + } finally { + if (savedStateDir === undefined) delete process.env.GATEGUARD_STATE_DIR; + else process.env.GATEGUARD_STATE_DIR = savedStateDir; + fs.rmSync(stateDir, { recursive: true, force: true }); + } +}); + +test('searchesMayNameTarget finds the stem in any search input', () => { + const { searchesMayNameTarget } = require(libPath); + const scanOf = (...inputs) => ({ searches: inputs.map(input => ({ name: 'Bash', input })) }); + assert.strictEqual(searchesMayNameTarget(scanOf({ command: 'rg widget .' }), 'src/widget.py'), true); + assert.strictEqual(searchesMayNameTarget(scanOf({ command: 'rg other .' }, { pattern: 'x', glob: '*.md' }), 'src/widget.py'), false); + assert.strictEqual(searchesMayNameTarget(scanOf({ command: "rg wid''get ." }), 'src/widget.py'), true, 'quotes cannot split the stem'); + assert.strictEqual(searchesMayNameTarget(scanOf({ command: 'rg "wid\\get" .' }), 'src/widget.py'), true, 'backslashes cannot split the stem'); + assert.strictEqual(searchesMayNameTarget(scanOf({ command: 'rg WIDGET .' }), 'src/widget.py'), true, 'case-insensitive'); + assert.strictEqual(searchesMayNameTarget(scanOf({ pattern: 'widget' }), 'tests/test_widget.py'), true, 'test prefix stripped'); + assert.strictEqual(searchesMayNameTarget(scanOf({ pattern: 'widget' }), 'src/widget.test.js'), true, 'test suffix stripped'); + assert.strictEqual(searchesMayNameTarget(scanOf({ pattern: 'widget' }), 'pkg/widget_test.go'), true, 'go test suffix stripped'); + assert.strictEqual(searchesMayNameTarget({ searches: [] }, 'src/widget.py'), false); + assert.strictEqual(searchesMayNameTarget(null, 'src/widget.py'), false); + assert.strictEqual(searchesMayNameTarget(scanOf({ command: 'ls' }), 'dir/'), true, 'no stem: never skipped'); +}); + +fs.rmSync(transcriptDir, { recursive: true, force: true }); + +console.log(`\nResults: Passed: ${passed}, Failed: ${failed}`); +process.exit(failed > 0 ? 1 : 0); diff --git a/tests/lib/transcript-context.test.js b/tests/lib/transcript-context.test.js index b5a3addf6..003d0cc9a 100644 --- a/tests/lib/transcript-context.test.js +++ b/tests/lib/transcript-context.test.js @@ -18,6 +18,8 @@ const { DEFAULT_CONTEXT_THRESHOLD_STANDARD, DEFAULT_CONTEXT_THRESHOLD_LARGE, DEFAULT_CONTEXT_INTERVAL_TOKENS, + DEFAULT_TRANSCRIPT_TAIL_BYTES, + readFileTail, readLatestContextTokens, resolveContextWindowTokens, resolveContextThreshold, @@ -323,6 +325,20 @@ test('returns -1 for non-finite token counts', () => { assert.strictEqual(computeContextBucket(NaN, 160000, 60000), -1); }); +// ── readFileTail ── +console.log('\nreadFileTail:'); + +test('is exported and returns the trailing bytes with a truncation flag', () => { + assert.strictEqual(typeof readFileTail, 'function'); + assert.strictEqual(DEFAULT_TRANSCRIPT_TAIL_BYTES, 256 * 1024); + const file = tracked(writeTranscript(['first-line', 'second', 'third'])); + const whole = readFileTail(file, 1024); + assert.deepStrictEqual(whole, { text: 'first-line\nsecond\nthird\n', truncated: false }); + const tail = readFileTail(file, 6); + assert.deepStrictEqual(tail, { text: 'third\n', truncated: true }); + assert.strictEqual(readFileTail(path.join(os.tmpdir(), `missing-${process.pid}.jsonl`), 10), null); +}); + // ── formatWindowLabel ── console.log('\nformatWindowLabel:'); diff --git a/tests/scripts/gateguard-eval.test.js b/tests/scripts/gateguard-eval.test.js new file mode 100644 index 000000000..690c888e8 --- /dev/null +++ b/tests/scripts/gateguard-eval.test.js @@ -0,0 +1,277 @@ +/** + * Tests for scripts/dev/gateguard-eval.js argument parsing and SARIF output. + */ + +'use strict'; + +const assert = require('assert'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const { pathToFileURL } = require('url'); +const { + parseArgs, renderSarif, renderMarkdown, loadCorpus, runCorpus, summarize, measureColdLatency, coldSummary, gateFails +} = require('../../scripts/dev/gateguard-eval'); + +let passed = 0; +let failed = 0; + +function test(name, fn) { + try { + fn(); + console.log(` ✓ ${name}`); + passed++; + } catch (error) { + console.log(` ✗ ${name}`); + console.log(` Error: ${error.message}`); + failed++; + } +} + +function step(overrides) { + return { + scenario: 'sensitive-targets', + file: '09-sensitive-targets.json', + step: 'edit-env', + expect: 'deny', + decision: 'deny', + kind: 'deny', + mustDeny: true, + redundant: false, + ...overrides + }; +} + +function summary(steps, totals = {}) { + return { + totals: { steps: steps.length, denials: 0, mustDenyBypasses: 0, mismatches: 0, explicitAllows: 0, errors: 0, skippedScenarios: 0, ...totals }, + latency: { p50Ms: 1.234, p90Ms: 4.321, p95Ms: 5.678 }, + perScenario: [], + steps + }; +} + +function report(workingSteps, baselineTotals = { mustDenyBypasses: 8 }) { + return { + corpus: { scenarios: 1, steps: workingSteps.length }, + hooks: [ + { label: 'working tree', ref: null, summary: summary(workingSteps) }, + { label: 'upstream/main', ref: 'upstream/main', summary: summary([], baselineTotals) } + ] + }; +} + +function results(log) { + return log.runs[0].results; +} + +console.log('\n=== Testing gateguard-eval ===\n'); + +test('--sarif takes a file path and keeps the other options', () => { + const options = parseArgs(['--json', '--sarif', 'out/gate.sarif', '--baseline', 'main']); + assert.strictEqual(options.format, 'json'); + assert.strictEqual(options.sarif, path.resolve('out/gate.sarif')); + assert.deepStrictEqual(options.baselines, ['main']); +}); + +test('--sarif without a value is rejected', () => { + assert.throws(() => parseArgs(['--sarif']), /unknown or incomplete argument: --sarif/); +}); + +test('a clean working tree produces a valid SARIF log with no results', () => { + const log = JSON.parse(renderSarif(report([step({}), step({ step: 'read-only', mustDeny: false, expect: 'allow', decision: 'allow', kind: 'pass' })]))); + assert.strictEqual(log.version, '2.1.0'); + assert.strictEqual(log.runs.length, 1); + assert.strictEqual(log.runs[0].tool.driver.name, 'gateguard-eval'); + assert.deepStrictEqual(results(log), []); +}); + +test('every rule declares a default level', () => { + const rules = JSON.parse(renderSarif(report([]))).runs[0].tool.driver.rules; + assert.deepStrictEqual( + rules.map(rule => [rule.id, rule.defaultConfiguration.level]), + [ + ['gateguard/must-deny-bypass', 'error'], + ['gateguard/explicit-allow', 'error'], + ['gateguard/hook-error', 'error'], + ['gateguard/expectation-mismatch', 'warning'] + ] + ); +}); + +test('a must-deny step that was allowed is an error on its scenario file', () => { + const log = JSON.parse(renderSarif(report([step({ decision: 'allow', kind: 'credit' })]))); + assert.strictEqual(results(log).length, 1); + const [result] = results(log); + assert.strictEqual(result.ruleId, 'gateguard/must-deny-bypass'); + assert.strictEqual(result.level, 'error'); + assert.strictEqual(result.locations[0].physicalLocation.artifactLocation.uri, 'tests/fixtures/gateguard-scenarios/09-sensitive-targets.json'); + assert.strictEqual(result.locations[0].physicalLocation.artifactLocation.uriBaseId, '%SRCROOT%'); + assert.match(result.message.text, /sensitive-targets \/ edit-env: expected deny, got allow \(credit\)/); +}); + +test('a finding from another corpus inside the repository links to that corpus', () => { + const failing = report([step({ decision: 'allow', kind: 'credit', file: 'custom.json' })]); + failing.corpusDir = path.join(__dirname, '..', 'fixtures', 'other-scenarios'); + const location = results(JSON.parse(renderSarif(failing)))[0].locations[0].physicalLocation.artifactLocation; + assert.deepStrictEqual(location, { uri: 'tests/fixtures/other-scenarios/custom.json', uriBaseId: '%SRCROOT%' }); +}); + +test('a finding from a corpus outside the repository links to its absolute file', () => { + const failing = report([step({ decision: 'allow', kind: 'credit', file: 'custom.json' })]); + failing.corpusDir = path.join(os.tmpdir(), 'gateguard-corpus'); + const location = results(JSON.parse(renderSarif(failing)))[0].locations[0].physicalLocation.artifactLocation; + assert.deepStrictEqual(location, { uri: pathToFileURL(path.join(os.tmpdir(), 'gateguard-corpus', 'custom.json')).href }); +}); + +test('an explicit allow and a hook error are reported as errors', () => { + const log = JSON.parse( + renderSarif( + report([ + step({ step: 'a', mustDeny: false, expect: 'allow', decision: 'allow', kind: 'pass', explicitAllow: true }), + step({ step: 'b', mustDeny: false, error: 'boom' }) + ]) + ) + ); + assert.deepStrictEqual(results(log).map(result => [result.ruleId, result.level]), [ + ['gateguard/explicit-allow', 'error'], + ['gateguard/hook-error', 'error'] + ]); +}); + +test('a wrong decision that is not a bypass is a warning', () => { + const log = JSON.parse(renderSarif(report([step({ mustDeny: false, expect: 'allow', decision: 'deny', kind: 'deny' })]))); + assert.deepStrictEqual(results(log).map(result => [result.ruleId, result.level]), [['gateguard/expectation-mismatch', 'warning']]); +}); + +test('a bypass is not reported a second time as a mismatch', () => { + const log = JSON.parse(renderSarif(report([step({ decision: 'allow', kind: 'pass' })]))); + assert.deepStrictEqual(results(log).map(result => result.ruleId), ['gateguard/must-deny-bypass']); +}); + +test('baseline totals are recorded without their failures becoming results', () => { + const log = JSON.parse(renderSarif(report([step({})]))); + const hooks = log.runs[0].properties.hooks; + assert.deepStrictEqual(hooks.map(hook => [hook.label, hook.totals.mustDenyBypasses]), [ + ['working tree', 0], + ['upstream/main', 8] + ]); + assert.deepStrictEqual(results(log), []); +}); + +test('output leaves out timing so reruns are byte-identical', () => { + const text = renderSarif(report([step({ latencyMs: 3.21 })])); + assert.ok(!/latency|p50|p95|Time/i.test(text), 'timing leaked into SARIF'); + const slower = report([step({ latencyMs: 9.87 })]); + slower.hooks[0].summary.latency = { p50Ms: 9, p90Ms: 90, p95Ms: 99 }; + assert.strictEqual(renderSarif(slower), text); +}); + +test('--no-cold turns off the fresh-process pass', () => { + assert.strictEqual(parseArgs([]).cold, true); + assert.strictEqual(parseArgs(['--no-cold']).cold, false); +}); + +// A fresh-process failure is recorded as an allow. Where the worker also allowed the +// step it is no disagreement, so before failures were counted on their own it left +// no trace in the report, the latency or the exit status. +test('a fresh-process failure on a step the worker allows is still counted', () => { + const cold = coldSummary( + [{ scenario: 's', step: 'a', latencyMs: null, decision: 'allow', error: 'killed' }], + [{ scenario: 's', step: 'a', decision: 'allow' }] + ); + assert.strictEqual(cold.disagreements, 0); + assert.strictEqual(cold.errors, 1); + assert.strictEqual(cold.samples, 0); + assert.strictEqual(cold.p50Ms, null, 'no measured step must not read as a 0 ms latency'); +}); + +test('a fresh-process failure fails the gate when nothing else does', () => { + const clean = report([step()]); + clean.hooks[0].summary.cold = { p50Ms: 1, p90Ms: 1, p95Ms: 1, samples: 1, errors: 0, disagreements: 0 }; + assert.strictEqual(gateFails(clean), false); + const failed = report([step()]); + failed.hooks[0].summary.cold = { p50Ms: null, p90Ms: null, p95Ms: null, samples: 0, errors: 1, disagreements: 0 }; + assert.strictEqual(gateFails(failed), true); +}); + +test('the report names fresh-process failures and invents no latency', () => { + // summarize([]) is the real summary shape with every count at zero, so this keeps + // working as metrics are added, which a hand-built totals object would not. + const working = { ...summarize([]), cold: { p50Ms: null, p90Ms: null, p95Ms: null, samples: 0, errors: 1, disagreements: 0 } }; + const failed = { + corpus: { scenarios: 0, steps: 0 }, + hooks: [ + { label: 'working tree', ref: null, summary: working }, + { label: 'upstream/main', ref: 'upstream/main', summary: summarize([]) } + ] + }; + const text = renderMarkdown(failed); + assert.match(text, /1 step\(s\) failed in a fresh process/); + assert.match(text, /no valid sample/); + assert.ok(!/fresh process \(ms\)[^\n]*\b0\.00\b/.test(text), 'a latency was reported with no measured step'); +}); + +async function coldTests() { + const corpusDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gateguard-eval-corpus-')); + try { + fs.copyFileSync( + path.join(__dirname, '..', 'fixtures', 'gateguard-scenarios', '11-first-shell-commands.json'), + path.join(corpusDir, 'shell.json') + ); + const scenarios = loadCorpus(corpusDir); + const hookFile = path.join(__dirname, '..', '..', 'scripts', 'hooks', 'gateguard-fact-force.js'); + const warm = summarize(await runCorpus(hookFile, scenarios)); + const cold = measureColdLatency(hookFile, scenarios); + test('the fresh-process pass times every step', () => { + assert.strictEqual(cold.length, scenarios[0].steps.length); + assert.ok(cold.every(step => Number.isFinite(step.latencyMs) && step.latencyMs > 0)); + }); + test('fresh processes decide every step as the worker does', () => { + assert.deepStrictEqual(cold.map(step => step.decision), warm.steps.map(step => step.decision)); + }); + + // A hook that throws used to end the fresh-process pass, so neither the report + // nor the SARIF output was written for a run that had hit one bad step. + const throwingDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gateguard-eval-throw-')); + try { + const throwingHook = path.join(throwingDir, 'throwing-hook.js'); + fs.writeFileSync(throwingHook, "'use strict';\nmodule.exports = { run() { throw new Error('boom'); } };\n"); + const warmThrow = summarize(await runCorpus(throwingHook, scenarios)); + let coldThrow = null; + let escaped = null; + try { + coldThrow = measureColdLatency(throwingHook, scenarios); + } catch (error) { + escaped = error; + } + test('a hook that throws does not end the fresh-process pass', () => { + assert.strictEqual(escaped, null, escaped && escaped.message); + assert.strictEqual(coldThrow.length, scenarios[0].steps.length); + assert.ok(coldThrow.every(step => step.error === 'boom'), JSON.stringify(coldThrow.map(step => step.error))); + }); + test('a throwing step is decided as the worker decides it, so it is no disagreement', () => { + assert.deepStrictEqual(coldThrow.map(step => step.decision), warmThrow.steps.map(step => step.decision)); + }); + test('every throwing step is reported as a fresh-process failure', () => { + assert.strictEqual(coldSummary(coldThrow, warmThrow.steps).errors, coldThrow.length); + }); + } finally { + fs.rmSync(throwingDir, { recursive: true, force: true }); + } + } finally { + fs.rmSync(corpusDir, { recursive: true, force: true }); + } +} + +coldTests().then(finish, error => { + console.log(` ✗ fresh-process pass failed: ${error.message}`); + failed++; + finish(); +}); + +function finish() { + console.log(`\nPassed: ${passed}`); + console.log(`Failed: ${failed}`); + process.exitCode = failed > 0 ? 1 : 0; +} diff --git a/tests/scripts/gateguard-latency.test.js b/tests/scripts/gateguard-latency.test.js new file mode 100644 index 000000000..7743b61ee --- /dev/null +++ b/tests/scripts/gateguard-latency.test.js @@ -0,0 +1,89 @@ +/** + * Tests for scripts/dev/gateguard-latency.js. + */ + +'use strict'; + +const assert = require('assert'); +const path = require('path'); +const { quantile, bootstrapDifference, verdict, summarize, measure, parseArgs } = require('../../scripts/dev/gateguard-latency'); + +let passed = 0; +let failed = 0; + +function test(name, fn) { + try { + fn(); + console.log(` ✓ ${name}`); + passed++; + } catch (error) { + console.log(` ✗ ${name}`); + console.log(` Error: ${error.message}`); + failed++; + } +} + +console.log('\n=== Testing gateguard-latency ===\n'); + +test('quantiles interpolate between ranks', () => { + assert.strictEqual(quantile([1, 2, 3, 4], 0.5), 2.5); + assert.strictEqual(quantile([4, 1, 3, 2], 0), 1); + assert.strictEqual(quantile([4, 1, 3, 2], 1), 4); + assert.ok(Math.abs(quantile([1, 2, 3, 4, 5, 6, 7, 8, 9, 10], 0.9) - 9.1) < 1e-9); + assert.ok(Number.isNaN(quantile([], 0.5))); +}); + +const slow = Array.from({ length: 40 }, (_, i) => 20 + (i % 5)); +const fast = Array.from({ length: 40 }, (_, i) => 10 + (i % 5)); +const same = Array.from({ length: 40 }, (_, i) => 20 + ((i * 7) % 5)); + +test('a clearly faster sample is reported faster at p50 and p90', () => { + assert.strictEqual(verdict(bootstrapDifference(slow, fast, 0.5)), 'faster'); + assert.strictEqual(verdict(bootstrapDifference(slow, fast, 0.9)), 'faster'); +}); + +test('a clearly slower sample is reported slower', () => { + assert.strictEqual(verdict(bootstrapDifference(fast, slow, 0.5)), 'slower'); +}); + +test('samples from the same distribution show no measurable change', () => { + assert.strictEqual(verdict(bootstrapDifference(slow, same, 0.5)), 'no measurable change'); + assert.strictEqual(verdict(bootstrapDifference(slow, same, 0.9)), 'no measurable change'); +}); + +test('the interval contains the observed difference and is reproducible', () => { + const interval = bootstrapDifference(slow, fast, 0.5); + const observed = quantile(fast, 0.5) - quantile(slow, 0.5); + assert.ok(interval[0] <= observed && observed <= interval[1], `${observed} outside ${interval}`); + assert.deepStrictEqual(bootstrapDifference(slow, fast, 0.5), interval); +}); + +test('summaries compare the working tree with every baseline', () => { + const rows = summarize({ call: { 'working tree': fast, main: slow } }, ['working tree', 'main']); + assert.strictEqual(rows.length, 1); + assert.strictEqual(rows[0].comparisons[0].baseline, 'main'); + assert.strictEqual(rows[0].comparisons[0].p50Verdict, 'faster'); + assert.strictEqual(rows[0].quantiles.main.p50, quantile(slow, 0.5)); +}); + +test('arguments default to upstream/main and validate --runs', () => { + assert.deepStrictEqual(parseArgs([]).baselines, ['upstream/main']); + assert.strictEqual(parseArgs(['--runs', '5']).runs, 5); + assert.throws(() => parseArgs(['--runs', '0']), /incomplete argument/); + assert.throws(() => parseArgs(['--runs']), /incomplete argument/); +}); + +test('one round times every call type in fresh processes', () => { + const hookFile = path.join(__dirname, '..', '..', 'scripts', 'hooks', 'gateguard-fact-force.js'); + const samples = measure([{ label: 'working tree', file: hookFile }], { runs: 1 }); + const calls = Object.keys(samples); + assert.strictEqual(calls.length, 7); + for (const call of calls) { + assert.strictEqual(samples[call]['working tree'].length, 1); + assert.ok(samples[call]['working tree'][0] > 0, call); + } +}); + +console.log(`\nPassed: ${passed}`); +console.log(`Failed: ${failed}`); +process.exitCode = failed > 0 ? 1 : 0; diff --git a/tests/scripts/gateguard-report.test.js b/tests/scripts/gateguard-report.test.js new file mode 100644 index 000000000..d2111a7b4 --- /dev/null +++ b/tests/scripts/gateguard-report.test.js @@ -0,0 +1,182 @@ +/** + * Tests for scripts/gateguard-report.js + */ + +'use strict'; + +const assert = require('assert'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const { spawnSync } = require('child_process'); + +const SCRIPT = path.join(__dirname, '..', '..', 'scripts', 'gateguard-report.js'); +const FIXTURE_DIR = path.join(__dirname, '..', 'fixtures', 'gateguard-metrics'); +const { loadMetrics, summarize, formatReport, parseArgs } = require('../../scripts/gateguard-report'); + +let passed = 0; +let failed = 0; + +function test(name, fn) { + try { + fn(); + console.log(` ✓ ${name}`); + passed++; + } catch (error) { + console.log(` ✗ ${name}`); + console.log(` Error: ${error.message}`); + failed++; + } +} + +function run(args, env = {}) { + const result = spawnSync('node', [SCRIPT, ...args], { + encoding: 'utf8', + env: { ...process.env, ...env }, + timeout: 10000 + }); + return { status: result.status, stdout: result.stdout || '', stderr: result.stderr || '' }; +} + +console.log('\n=== Testing gateguard-report ===\n'); + +const loaded = loadMetrics(FIXTURE_DIR); +const report = summarize(loaded.events); + +test('reads the rotated file first, then the current file', () => { + assert.strictEqual(loaded.events.length, 12); + assert.strictEqual(loaded.events[0].ts, '2026-09-01T10:00:00.000Z'); + assert.strictEqual(loaded.events[11].decision, 'pass-checked'); + assert.deepStrictEqual(loaded.files.map(f => path.basename(f)), ['metrics.jsonl.1', 'metrics.jsonl']); +}); + +test('skips malformed, unknown-version, unknown-decision and non-code lines', () => { + assert.strictEqual(loaded.skipped, 6); + assert.ok(loaded.events.every(e => e.session === 'aaaaaaaaaaaa' || e.session === 'bbbbbbbbbbbb')); + assert.ok(!loaded.events.some(e => String(e.reason).includes('/'))); +}); + +test('counts decisions by type', () => { + assert.deepStrictEqual(report.total.decisions, { + deny: 4, + cap: 1, + credit: 1, + 'destructive-deny': 1, + 'pass-checked': 1, + 'routine-deny': 1, + 'routine-readonly': 1, + sibling: 1, + trivial: 1 + }); + assert.strictEqual(report.total.events, 12); +}); + +test('breaks denials down by class and reason', () => { + assert.strictEqual(report.total.denials.total, 6); + assert.deepStrictEqual(report.total.denials.byClass, { code: 3, shell: 2, config: 1 }); + assert.deepStrictEqual(report.total.denials.byReason, { + 'first-touch': 2, + destructive: 1, + 'first-command': 1, + 'near-miss:out-of-scope': 1, + sensitive: 1 + }); +}); + +test('reports the near-miss share of first-touch denials', () => { + assert.deepStrictEqual(report.total.nearMiss, { count: 1, of: 4, share: 0.25 }); +}); + +test('reports first-touch outcome rates', () => { + const { firstTouch } = report.total; + assert.strictEqual(firstTouch.total, 8); + assert.deepStrictEqual(firstTouch.counts, { deny: 4, credit: 1, sibling: 1, cap: 1, trivial: 1 }); + assert.strictEqual(firstTouch.rates.deny, 0.5); + assert.strictEqual(firstTouch.rates.trivial, 0.125); +}); + +test('counts routine read-only passes and ranks the most-asked questions', () => { + assert.strictEqual(report.total.routineReadonlyPasses, 1); + assert.deepStrictEqual(report.total.topQuestions.slice(0, 3), [ + ['quote-instruction', 4], + ['callers', 1], + ['config-effect', 1] + ]); +}); + +test('summarizes each session separately in order of first appearance', () => { + assert.deepStrictEqual(Object.keys(report.sessions), ['aaaaaaaaaaaa', 'bbbbbbbbbbbb']); + const a = report.sessions.aaaaaaaaaaaa; + assert.strictEqual(a.events, 6); + assert.strictEqual(a.denials.total, 3); + assert.deepStrictEqual(a.nearMiss, { count: 1, of: 2, share: 0.5 }); + assert.strictEqual(a.firstTouch.total, 4); + assert.strictEqual(a.first, '2026-09-01T10:00:00.000Z'); + assert.strictEqual(a.last, '2026-09-01T10:05:00.000Z'); + const b = report.sessions.bbbbbbbbbbbb; + assert.strictEqual(b.events, 6); + assert.deepStrictEqual(b.denials.byClass, { code: 1, config: 1, shell: 1 }); + assert.strictEqual(b.routineReadonlyPasses, 0); +}); + +test('reports empty rates as null instead of dividing by zero', () => { + const empty = summarize([]); + assert.strictEqual(empty.total.events, 0); + assert.strictEqual(empty.total.nearMiss.share, null); + assert.strictEqual(empty.total.firstTouch.rates.deny, null); +}); + +test('formats a readable text report', () => { + const text = formatReport({ ...loaded, report }); + assert.ok(text.includes('12 events in 2 sessions (6 malformed lines skipped)')); + assert.ok(text.includes('Total')); + assert.ok(text.includes('Session aaaaaaaaaaaa')); + assert.ok(text.includes('near-miss share of first-touch denials: 25.0% (1 of 4)')); + assert.ok(text.includes('routine read-only passes: 1')); + assert.ok(text.includes('quote-instruction 4')); +}); + +test('parses --dir, --dir=, --json and --help', () => { + assert.deepStrictEqual(parseArgs(['--dir', '/x', '--json']), { dir: '/x', json: true, help: false }); + assert.deepStrictEqual(parseArgs(['--dir=/y']), { dir: '/y', json: false, help: false }); + assert.strictEqual(parseArgs(['-h']).help, true); + assert.throws(() => parseArgs(['--dir']), /Missing value for --dir/); + assert.throws(() => parseArgs(['--bogus']), /Unknown argument: --bogus/); +}); + +test('CLI prints JSON with --json', () => { + const result = run(['--dir', FIXTURE_DIR, '--json']); + assert.strictEqual(result.status, 0, result.stderr); + const parsed = JSON.parse(result.stdout); + assert.strictEqual(parsed.events, 12); + assert.strictEqual(parsed.skipped, 6); + assert.strictEqual(parsed.total.denials.total, 6); + assert.deepStrictEqual(Object.keys(parsed.sessions), ['aaaaaaaaaaaa', 'bbbbbbbbbbbb']); +}); + +test('CLI reads GATEGUARD_STATE_DIR when --dir is not given', () => { + const result = run([], { GATEGUARD_STATE_DIR: FIXTURE_DIR }); + assert.strictEqual(result.status, 0, result.stderr); + assert.ok(result.stdout.includes('12 events in 2 sessions')); +}); + +test('CLI explains how to enable metrics when none are found', () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gateguard-report-')); + try { + const result = run(['--dir', dir]); + assert.strictEqual(result.status, 0, result.stderr); + assert.ok(result.stdout.includes('No GateGuard metrics')); + assert.ok(result.stdout.includes('GATEGUARD_METRICS=1')); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); + +test('CLI exits 1 on an unknown argument', () => { + const result = run(['--bogus']); + assert.strictEqual(result.status, 1); + assert.ok(result.stderr.includes('Unknown argument: --bogus')); +}); + +console.log(`\n ${passed} passed, ${failed} failed\n`); +process.exit(failed > 0 ? 1 : 0); diff --git a/tests/scripts/npm-publish-surface.test.js b/tests/scripts/npm-publish-surface.test.js index 5a810ef04..1985307f8 100644 --- a/tests/scripts/npm-publish-surface.test.js +++ b/tests/scripts/npm-publish-surface.test.js @@ -108,6 +108,7 @@ function buildExpectedPublishPaths(repoRoot) { "scripts/welcome.js", "scripts/gemini-adapt-agents.js", "scripts/qwen-adapt-agents.js", + "scripts/gateguard-report.js", "scripts/sync-ecc-to-codex.sh", "scripts/codex/legacy-sync-state.js", "scripts/codex/install-global-git-hooks.sh",