From f19425dbf160217adcfaaa8680bbf0426428be2d Mon Sep 17 00:00:00 2001 From: Jeffrey Montoya Date: Wed, 30 Sep 2026 00:23:08 -0400 Subject: [PATCH] docs(gateguard): move change-profile notes into their own file --- docs/gateguard/change-profile.md | 296 ++++++++++++++++ docs/gateguard/design-notes.md | 319 ++---------------- docs/gateguard/evaluation.md | 2 +- manifests/context-packs/skill-triggers@1.json | 2 +- skills/gateguard/SKILL.md | 2 +- 5 files changed, 320 insertions(+), 301 deletions(-) create mode 100644 docs/gateguard/change-profile.md diff --git a/docs/gateguard/change-profile.md b/docs/gateguard/change-profile.md new file mode 100644 index 000000000..45a03cb5a --- /dev/null +++ b/docs/gateguard/change-profile.md @@ -0,0 +1,296 @@ +# 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 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. + +## 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 index 0279f8f54..c9ad6a202 100644 --- a/docs/gateguard/design-notes.md +++ b/docs/gateguard/design-notes.md @@ -3,7 +3,8 @@ 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. +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 @@ -15,11 +16,11 @@ headings stable, since they are the anchors. [Target resolution](#target-resolution), [Target classes](#target-classes), [Worktree prefix](#worktree-prefix), [Windows name normalization](#windows-name-normalization). -- Right question: [Change profile](#change-profile), - [Public surface](#public-surface), [Data handling](#data-handling), - [Shell scripts](#shell-scripts), [File context](#file-context), - [Questions from the change profile](#questions-from-the-change-profile), - [Trivial edits](#trivial-edits), [Directive comments](#directive-comments), +- 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), @@ -46,7 +47,11 @@ headings stable, since they are the anchors. - `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()`. + 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 @@ -62,12 +67,19 @@ headings stable, since they are the anchors. 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)). + [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 @@ -183,7 +195,7 @@ 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](#questions-from-the-change-profile)); other +[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 @@ -388,295 +400,6 @@ 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. -## 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 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. - -## 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. - ## Closest search that did not count When an ordinary (not sensitive or hard-linked) first touch is denied and the diff --git a/docs/gateguard/evaluation.md b/docs/gateguard/evaluation.md index 63028bb8e..56053c3b1 100644 --- a/docs/gateguard/evaluation.md +++ b/docs/gateguard/evaluation.md @@ -21,7 +21,7 @@ with the decision the gate should make and why it matters: | `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](design-notes.md#questions-from-the-change-profile)). | +| `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: diff --git a/manifests/context-packs/skill-triggers@1.json b/manifests/context-packs/skill-triggers@1.json index 247d31253..e115cf4e5 100644 --- a/manifests/context-packs/skill-triggers@1.json +++ b/manifests/context-packs/skill-triggers@1.json @@ -1 +1 @@ -{"coverage":{"skills":293,"withTriggers":32},"generatedAt":"2026-09-24T23:51:22.784Z","id":"skill-triggers@1","model":{"effort":null,"id":"hand-seeded","source":"manual-curation-pending-regeneration"},"registryDigest":"99577fd3a841c82e0c254b85cb4746034372169e750a3e0977862f13656e1a8b","schemaVersion":1,"triggers":{"skill:api-connector-builder":["add api integration","new provider connector","match existing integration pattern"],"skill:api-design":["rest endpoint design","pagination api","status codes","api versioning","rate limiting api","resource naming","filtering api","api error responses","offset pagination","limit query parameter","pagination defaults"],"skill:backend-patterns":["express api","node backend architecture","nextjs api routes","server side patterns","data access layer","static file server","url path handling","file server"],"skill:browser-qa":["deployed feature test","visual regression screenshots","core web vitals check","axe accessibility audit","ship do not ship","staging verification"],"skill:canary-watch":["post deploy monitoring","smoke test url","production url check","console errors production","sse stream check","after deploy verification"],"skill:code-tour":["onboarding walkthrough","explain subsystem","architecture tour","pr walkthrough","rca tour"],"skill:coding-standards":["code review standards","naming conventions","readability review","immutability conventions","fix naming typo","export naming","consistent exports"],"skill:content-hash-cache-pattern":["cache file processing","content addressed cache","sha256 hash cache"],"skill:database-migrations":["zero downtime migration","schema change production","add column large table","backfill data","expand contract","concurrent index","migration rollback","prisma migration","django migration"],"skill:deployment-patterns":["ci cd setup","dockerize app","health checks","rollback strategy","production readiness","deploy pipeline","containerize application"],"skill:design-system":["design tokens","visual consistency audit","css custom properties","ui audit","design system bootstrap"],"skill:django-patterns":["django orm","drf api","django rest framework","django caching","django signals","django middleware"],"skill:django-security":["django authentication","csrf protection","sql injection prevention","xss prevention","django deployment security","role based access control","authorization middleware","permissions checks"],"skill:docker-patterns":["dockerfile review","docker compose setup","container security","multi service orchestration"],"skill:error-handling":["error types","retry logic","circuit breaker","user facing errors","exception handling patterns","typed errors","error boundaries","go error handling","custom error class","error codes","config validation"],"skill:evm-token-decimals":["token decimals","wei conversion","erc20 balance off","bridge token precision"],"skill:frontend-a11y":["aria attributes","screen reader support","focus management","semantic html","form labeling","keyboard navigation react","a11y lint errors"],"skill:git-workflow":["merge vs rebase","commit conventions","resolve merge conflict","branching strategy","clean up commits","pull request cleanup","git history tidy"],"skill:hexagonal-architecture":["ports and adapters","dependency injection boundaries","decouple domain from io"],"skill:kubernetes-patterns":["kubernetes manifests","kubectl debugging","pod probes","k8s rbac","autoscaling config","configmap secrets"],"skill:orch-fix-defect":["fix a bug","broken behavior","regression fix","reproduce bug","defect repair"],"skill:postgres-patterns":["slow postgres query","query optimization","index design","rls policies","supabase schema","postgres indexing","database performance","schema design postgres","postgres driver","node postgres","query planner"],"skill:python-patterns":["pythonic code","pep 8","type hints python","python code review","idiomatic python"],"skill:python-testing":["pytest fixtures","mocking python","parametrized tests","coverage python","tdd python"],"skill:redis-patterns":["cache aside pattern","distributed lock","redis rate limiting","cache invalidation"],"skill:regex-vs-llm-structured-text":["parse invoice","extract receipt data","text extraction pipeline","parse form fields","cheap document parser","extract table data","parse log lines","parse access logs","common log format","log line parsing"],"skill:rust-patterns":["rust ownership","borrow checker","rust error handling","traits rust","rust concurrency","idiomatic rust"],"skill:search-first":["find existing library","npm package research","before writing custom code","evaluate existing tools","add dependency research"],"skill:security-review":["security audit","authentication review","sanitize user input","secrets handling","payment security checklist","prevent injection attacks","secure api endpoints","authn authz review","vulnerability checklist","input validation security","parameterized queries","sql injection"],"skill:security-scan":["audit claude config","claudemd security","mcp server audit","agentshield scan","hook configuration audit","settings json security"],"skill:tdd-workflow":["write test first","failing test","red green refactor","test driven development","regression test first","write a regression test"],"skill:verification-loop":["pre pr checks","verification report","quality gates","build lint test coverage","before creating a pr"]},"triggersDigest":"25b97a9e06fc336c7cf95ab854ed1a41033a54bcd6e1fb1cf69dc906332462aa"} +{"coverage":{"skills":293,"withTriggers":32},"generatedAt":"2026-09-24T23:51:22.784Z","id":"skill-triggers@1","model":{"effort":null,"id":"hand-seeded","source":"manual-curation-pending-regeneration"},"registryDigest":"bab549d067bfce68ece481fd1bd148cf2cdb175e50a5584e1ca285443e273838","schemaVersion":1,"triggers":{"skill:api-connector-builder":["add api integration","new provider connector","match existing integration pattern"],"skill:api-design":["rest endpoint design","pagination api","status codes","api versioning","rate limiting api","resource naming","filtering api","api error responses","offset pagination","limit query parameter","pagination defaults"],"skill:backend-patterns":["express api","node backend architecture","nextjs api routes","server side patterns","data access layer","static file server","url path handling","file server"],"skill:browser-qa":["deployed feature test","visual regression screenshots","core web vitals check","axe accessibility audit","ship do not ship","staging verification"],"skill:canary-watch":["post deploy monitoring","smoke test url","production url check","console errors production","sse stream check","after deploy verification"],"skill:code-tour":["onboarding walkthrough","explain subsystem","architecture tour","pr walkthrough","rca tour"],"skill:coding-standards":["code review standards","naming conventions","readability review","immutability conventions","fix naming typo","export naming","consistent exports"],"skill:content-hash-cache-pattern":["cache file processing","content addressed cache","sha256 hash cache"],"skill:database-migrations":["zero downtime migration","schema change production","add column large table","backfill data","expand contract","concurrent index","migration rollback","prisma migration","django migration"],"skill:deployment-patterns":["ci cd setup","dockerize app","health checks","rollback strategy","production readiness","deploy pipeline","containerize application"],"skill:design-system":["design tokens","visual consistency audit","css custom properties","ui audit","design system bootstrap"],"skill:django-patterns":["django orm","drf api","django rest framework","django caching","django signals","django middleware"],"skill:django-security":["django authentication","csrf protection","sql injection prevention","xss prevention","django deployment security","role based access control","authorization middleware","permissions checks"],"skill:docker-patterns":["dockerfile review","docker compose setup","container security","multi service orchestration"],"skill:error-handling":["error types","retry logic","circuit breaker","user facing errors","exception handling patterns","typed errors","error boundaries","go error handling","custom error class","error codes","config validation"],"skill:evm-token-decimals":["token decimals","wei conversion","erc20 balance off","bridge token precision"],"skill:frontend-a11y":["aria attributes","screen reader support","focus management","semantic html","form labeling","keyboard navigation react","a11y lint errors"],"skill:git-workflow":["merge vs rebase","commit conventions","resolve merge conflict","branching strategy","clean up commits","pull request cleanup","git history tidy"],"skill:hexagonal-architecture":["ports and adapters","dependency injection boundaries","decouple domain from io"],"skill:kubernetes-patterns":["kubernetes manifests","kubectl debugging","pod probes","k8s rbac","autoscaling config","configmap secrets"],"skill:orch-fix-defect":["fix a bug","broken behavior","regression fix","reproduce bug","defect repair"],"skill:postgres-patterns":["slow postgres query","query optimization","index design","rls policies","supabase schema","postgres indexing","database performance","schema design postgres","postgres driver","node postgres","query planner"],"skill:python-patterns":["pythonic code","pep 8","type hints python","python code review","idiomatic python"],"skill:python-testing":["pytest fixtures","mocking python","parametrized tests","coverage python","tdd python"],"skill:redis-patterns":["cache aside pattern","distributed lock","redis rate limiting","cache invalidation"],"skill:regex-vs-llm-structured-text":["parse invoice","extract receipt data","text extraction pipeline","parse form fields","cheap document parser","extract table data","parse log lines","parse access logs","common log format","log line parsing"],"skill:rust-patterns":["rust ownership","borrow checker","rust error handling","traits rust","rust concurrency","idiomatic rust"],"skill:search-first":["find existing library","npm package research","before writing custom code","evaluate existing tools","add dependency research"],"skill:security-review":["security audit","authentication review","sanitize user input","secrets handling","payment security checklist","prevent injection attacks","secure api endpoints","authn authz review","vulnerability checklist","input validation security","parameterized queries","sql injection"],"skill:security-scan":["audit claude config","claudemd security","mcp server audit","agentshield scan","hook configuration audit","settings json security"],"skill:tdd-workflow":["write test first","failing test","red green refactor","test driven development","regression test first","write a regression test"],"skill:verification-loop":["pre pr checks","verification report","quality gates","build lint test coverage","before creating a pr"]},"triggersDigest":"25b97a9e06fc336c7cf95ab854ed1a41033a54bcd6e1fb1cf69dc906332462aa"} diff --git a/skills/gateguard/SKILL.md b/skills/gateguard/SKILL.md index 53566ec59..e6ebcc1f7 100644 --- a/skills/gateguard/SKILL.md +++ b/skills/gateguard/SKILL.md @@ -129,7 +129,7 @@ and hard-linked targets and NotebookEdit calls always get the full questions. Ot 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/design-notes.md`. +`docs/gateguard/change-profile.md`. ### Questions by target class