mirror of
https://github.com/affaan-m/ECC.git
synced 2026-11-07 16:45:53 +00:00
330 lines
19 KiB
Markdown
330 lines
19 KiB
Markdown
# GateGuard change profile notes
|
|
|
|
Rationale for how GateGuard reads the change itself: which questions a
|
|
denial asks and which edits pass as trivial
|
|
(`scripts/lib/gateguard-change-profile.js`, `gateguard-code-lexer.js` and
|
|
`gateguard-file-context.js`). Code comments point here as
|
|
`docs/gateguard/change-profile.md#<anchor>`; keep headings stable, since they
|
|
are the anchors. The other decisions are in [design-notes.md](design-notes.md).
|
|
## Change profile
|
|
|
|
`scripts/lib/gateguard-change-profile.js` reads the change the hook already
|
|
receives (Edit `old_string`/`new_string`, each MultiEdit entry for the target,
|
|
Write `content`) and derives three booleans: `touchesPublicSurface`,
|
|
`touchesData` and `trivial`. It is string analysis of those texts and, for
|
|
`trivial`, of the current file text the hook passes in (see
|
|
[File context](#file-context)): no reads of its own, no `RegExp` built from
|
|
input, and every loop is a single linear pass.
|
|
|
|
The profile only ever removes questions, so anything uncertain yields the
|
|
unknown profile (every question asked, never trivial): an unsupported
|
|
extension, a non-string side, no entries or more than 64, a side over 64 KiB
|
|
(UTF-8), more than 256 KiB in total, or any exception. Supported languages, by
|
|
extension: JS/TS (`.js .mjs .cjs .jsx .ts .tsx .mts .cts`), Python
|
|
(`.py .pyi`), Go, Rust, Java, Kotlin (`.kt .kts`), C# and C/C++ (`.c .h .cc
|
|
.cpp .cxx .hpp .hh .hxx`), and shell scripts: POSIX shells (`.sh .bash .zsh`),
|
|
PowerShell (`.ps1 .psm1`) and batch (`.bat .cmd`). Ruby, Makefiles, Swift,
|
|
fish, PowerShell data files and everything else are unknown.
|
|
|
|
## Public surface
|
|
|
|
A Write always touches the public surface (a new or rewritten module's
|
|
surface is all new). For an Edit, any line on either side that declares or
|
|
exposes a public name counts, whether it changed or is only context: context
|
|
around a public declaration means the edit is inside that symbol.
|
|
|
|
- JS/TS: a line starting with the word `export`, `public` or `declare`, or
|
|
containing the word `exports` (`module.exports`, `exports.x`). `.d.ts`,
|
|
`.d.mts` and `.d.cts` files are all surface.
|
|
- Python: `def`, `async def` or `class` whose name does not start with `_`
|
|
(dunders such as `__init__` are public), any `__all__`, and a column-0
|
|
assignment or annotation of a public name (a module constant or variable
|
|
that importers can read). The first line of a snippet may start mid-line
|
|
(an Edit's `old_string` can begin after the indentation), so there only an
|
|
all-uppercase name counts, the constant convention that function locals
|
|
rarely use. `__init__.py` is all surface.
|
|
- Go: `func` (after an optional receiver), `type`, `var` or `const` with a
|
|
capitalised name, a `package` line, and any line that starts with a
|
|
capital letter (exported fields, interface methods, grouped declarations).
|
|
- Rust: lines starting with `pub` (any `pub(...)`), `impl`, `trait`,
|
|
`extern`, or an ABI or trait attribute (`#[macro_export`, `#[derive`,
|
|
`#[repr`, `#[no_mangle`, `#[export_name`, and their `#[unsafe(...)]`
|
|
forms).
|
|
- POSIX shells: a function definition (`name() {`, `function name`), an
|
|
`export`, or `declare -x`/`typeset -x`, at any indentation. A sourced
|
|
script's functions and exported variables are what other scripts use.
|
|
- PowerShell (case-insensitive): lines starting with `function`, `filter`,
|
|
`workflow`, `class`, `enum`, `param` (a script's parameters are its
|
|
interface), `Export-ModuleMember`, `[CmdletBinding` or `$global:`.
|
|
- Java, Kotlin, C#, C, C++ and batch files always touch the surface:
|
|
package-private Java, Kotlin's public default, C# partial classes, C linkage
|
|
and batch labels reachable by `call :label` make a lexical answer
|
|
unreliable.
|
|
|
|
An Edit that changes a function body without its declaration line in the
|
|
snippet reads as not touching the surface; the local question still asks for
|
|
the call sites that rely on the behaviour.
|
|
|
|
A member line can be public surface even though its container's declaration
|
|
is not in the snippet: a property of an exported interface, a variant of a
|
|
public enum, a name in an export list. So, when the hook passes the file text,
|
|
the nearest line above the first occurrence that starts at column 0 (skipping
|
|
blank lines, comments and decorators; a closing `}`, `]` or `)` there means
|
|
the edit is at top level) is read as the enclosing opener:
|
|
|
|
- JS/TS: `module.exports`/`exports` assigned an object, array or call, and
|
|
`export` (or `export default`) of an interface, enum, type, namespace,
|
|
module, `declare`, `{ ... }` list, or `const`/`let`/`var` that is not a
|
|
function or arrow: every line inside is surface. An exported class: lines
|
|
at the class's member indentation (the first non-blank line after the
|
|
opener) are surface; method bodies are not. An exported `function` or
|
|
`async function` is a body, as before.
|
|
- Python: `__all__` makes every line surface; a public `class` makes an
|
|
assignment or annotation of a public name at member indentation (class
|
|
attributes, dataclass fields) surface.
|
|
- Rust: a `pub` enum, struct, union, trait or `use` makes lines at member
|
|
indentation surface.
|
|
|
|
The upward scan stops after 4 MiB in total and then counts as surface. The
|
|
opener is found by indentation, not by parsing, so it can over-match (a method
|
|
body inside an exported object literal counts), which only keeps the importer
|
|
question.
|
|
|
|
## Data handling
|
|
|
|
`touchesData` looks at every side (both sides of an Edit) as words: identifiers
|
|
are split on non-alphanumerics, `_` and camelCase boundaries and lowercased,
|
|
so `validate` and `updated` never match `date`. It is true for a data word
|
|
(formats such as `json`, `csv`, `yaml`, `parquet`; serialisation; `schema`;
|
|
SQL and database words; date/time words such as `date`, `datetime`,
|
|
`timestamp`, `strftime`, `utc`, `chrono`, `zoneinfo`; encodings such as
|
|
`base64`, `encoding`, `msgpack`; browser storage and cookies; data-store
|
|
clients such as `redis`, `prisma`, `sqlalchemy`; file I/O such as `fs`,
|
|
`fopen`, `pathlib`), for `open(`, for adjacent pairs such as `read file`,
|
|
`time now`, `time parse`, `system time`,
|
|
`write text`, `read to`, and for SQL keyword pairs anywhere in the text
|
|
(`select`+`from`, `insert`+`into`, `create`+`table`, ...). Over-matching only
|
|
keeps the data question.
|
|
|
|
Shell scripts also touch data with a file redirection (`>`, `>>`, `>|`, `&>`
|
|
or `<` followed by a target other than `/dev/null`, `/dev/stdout`,
|
|
`/dev/stderr`, `$null` or `nul`; descriptor duplication such as `2>&1` and
|
|
`>&2`, heredocs and process substitution do not count), an HTTP or document
|
|
tool (`curl`, `wget`, `jq`, `yq`, `xmllint`, `psql`, `tee`, `iwr`, `irm`),
|
|
or a PowerShell file or web cmdlet (`Out-File`, `Get-Content`,
|
|
`Set-Content`, `Add-Content`, `Invoke-WebRequest`, `Invoke-RestMethod`,
|
|
`*-Clixml`; `Import-Csv` matches `csv`). Redirection is found without
|
|
reading quotes, so a `>` inside a string also counts.
|
|
|
|
## External-contract question
|
|
|
|
`GATEGUARD_EXTERNAL_CONTRACT_QUESTION` (`1`, `true`, `on`, `enabled`,
|
|
`enable` or `yes`) adds `external-contract` right after every `data-schema`
|
|
in the table above: "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". The condensed
|
|
hint and the decision metrics name it too. It is off by default because it
|
|
has no evidence behind it: it was designed after inspecting the
|
|
hidden-intent corpus, and in the rerun that added it the gated arm did not
|
|
improve (see
|
|
[question-effectiveness.md](question-effectiveness.md#adding-an-external-contract-question-did-not-help)).
|
|
`questionIdsFor(class, isWrite, profile, { externalContract: true })` returns
|
|
the ids with it.
|
|
|
|
## Trivial edits
|
|
|
|
An Edit is trivial when, for every entry, old and new have the same code once
|
|
comments are removed, blank and comment-only lines are dropped, and runs of
|
|
whitespace between tokens are folded to one space. Line structure, whitespace
|
|
inside strings, and the presence of whitespace between tokens stay code
|
|
(`a+b` to `a + b` is not trivial). For Python, leading whitespace of each code
|
|
line is code; comment-only lines carry no indentation.
|
|
|
|
Comments are `//` and non-nesting `/* */` in the C family and `#` in Python;
|
|
C preprocessor lines are code. Strings are single-line `"..."` and `'...'`
|
|
(Rust: `"..."` only, with `'x'` char literals told apart from lifetimes).
|
|
Anything the lexer cannot read with confidence makes the entry non-trivial:
|
|
|
|
- any multi-line string form: JS/Go backticks, Python/Java/Kotlin/C# triple
|
|
quotes, Rust raw strings (`r"`, `r#"`), C++ raw strings (`R"`), C# verbatim
|
|
and interpolated strings (`@"`, `$"`), Kotlin strings containing `$`, Python
|
|
f-strings, and any string that runs to the end of a line;
|
|
- in JS/TS, any `/` outside a comment (division and regex literals cannot be
|
|
told apart), JSX-like `<x`, `</`, `<>`, `<!`, and `-->`;
|
|
- a line comment ending in `\` (it continues onto the next line in C and
|
|
Make), the trigraph `??/`, a `/*` inside a block comment, and an
|
|
unterminated block comment or string;
|
|
- U+2028, U+2029 or U+0085 anywhere in the text: JavaScript ends a line at
|
|
the first two and C# at all three, so code after one inside a `//` comment
|
|
runs, while the lexer splits lines only at `\n` and `\r`;
|
|
- a code line ending in a line continuation `\` (trailing blanks included):
|
|
a C macro, a Python explicit continuation or a string continued onto the
|
|
next line joins that line, so adding or removing a comment-only line after
|
|
it changes code.
|
|
|
|
## Directive comments
|
|
|
|
Some comments are read by the language, the build or a tool, so their text is
|
|
code. A directive comment is kept in the compared code verbatim: changing,
|
|
adding or removing one makes the entry non-trivial, while an unchanged
|
|
directive next to an edited ordinary comment does not.
|
|
|
|
- A comment whose text starts with `!` (shebangs, Rust inner docs, `/*!`),
|
|
`/` (`///` doc comments and TypeScript triple-slash references), `go:`,
|
|
`export` or `extern` followed by a blank (cgo), or `line` followed by a
|
|
blank (Go line directives), with no blank after the comment marker.
|
|
- A comment whose first word, after blanks, `*` and `/`, starts with `@`, `#`,
|
|
`<`, `+`, `!`, `type:` or `requires`, or is `global`, `globals` or
|
|
`exported` followed by a blank
|
|
(JSDoc and TypeScript pragmas, `//# sourceMappingURL`, `// +build`, Python
|
|
type comments, ESLint globals, PowerShell `#Requires`).
|
|
- A comment containing a tool or encoding marker such as `lint`, `ts-`,
|
|
`noqa`, `nosec`, `pragma`, `coding:`, `fmt:`, `istanbul`, `prettier-`,
|
|
`webpack`, `__PURE__`, `fallthrough`, `shellcheck`, `suppress`, `sonar`,
|
|
`gitleaks`, `allowlist`, `vim:`, `-*-`, `DO NOT EDIT`, `#compdef` or Go's
|
|
example `// Output:`, or a JSDoc type
|
|
(`@` together with `{`). The list is in `DIRECTIVE_WORDS`; matching is
|
|
case-insensitive and substring-based, so it over-matches prose that happens
|
|
to contain a marker, which only keeps an edit gated.
|
|
- Every Rust doc comment (`///`, `//!`, `/**`, `/*!`): doc tests compile and
|
|
run.
|
|
|
|
Suppression markers (`nosec`, `NOSONAR`, `gitleaks:allow`,
|
|
`pragma: allowlist secret`, `eslint-disable`) matter most: removing a finding
|
|
from a security scanner is not a comment edit.
|
|
|
|
## Shell scripts
|
|
|
|
Shell comments are lexed by their own rules, and the lexer gives up (the entry
|
|
is not trivial) wherever a comment could be code:
|
|
|
|
- POSIX shells: `#` starts a comment only at the start of a line or after a
|
|
space, tab or `;`; after a letter, `$`, `{`, `=` and the like it is a word
|
|
character (`a#b`, `$#`, `${#x}`, `${x#y}`). After `(`, `)`, `|`, `&`, `<` or
|
|
`>` the entry is not trivial (zsh glob flags such as `(#i)`). Single quotes
|
|
are raw and must close on the line; `$'...'` is not trivial; double quotes
|
|
may hold `\` escapes and a `${...}` without quotes, but `$(` inside them,
|
|
any backtick, any `<<` (heredocs, here-strings, shifts), a
|
|
backslash before a line break, and a lone carriage return (bash reads it
|
|
as a word character) make the entry not trivial. Only spaces and tabs
|
|
between words are folded; `\` escapes stay code.
|
|
- PowerShell: `#` and `<# ... #>` are comments only at the start of a line or
|
|
after a space, tab or `;`; elsewhere the entry is not trivial. `#Requires`
|
|
and `#!` are [directive comments](#directive-comments), as in every
|
|
language. Here-strings (`@"`, `@'`), typographic
|
|
quotes (PowerShell accepts them as quote marks), `$(` inside a double-quoted
|
|
string, a backtick before a line break, and a nested `<#` are not trivial.
|
|
- Batch: only `REM` lines (after optional blanks and `@`, followed by a blank
|
|
or the end of the line) are comments; `::` labels are code, since they
|
|
change the parser's behaviour inside blocks. A `REM` line holding `%`, `^`,
|
|
`&`, `|`, `<`, `>`, `(` or `)`, and any line ending in `^`, are not trivial.
|
|
Code lines are compared exactly: `echo` keeps its spacing.
|
|
|
|
Write is never trivial. In the hook, a trivial Edit (or a MultiEdit whose
|
|
entries for that file are all trivial) of an unchecked code, test or prose
|
|
target passes with an `additionalContext` note after prior-search credit and
|
|
before sibling collapse and the denial cap. It is not marked checked and does
|
|
not touch the denial count or ordinal, so the next change that alters code is
|
|
gated as a first touch; `trivial_allows` counts the passes (merged by maximum
|
|
like the other counters). The trivial pass never marks the target checked and
|
|
never applies to sensitive, hard-linked, instruction or config targets or to
|
|
a NotebookEdit, so the next
|
|
non-trivial change still meets the full gate.
|
|
|
|
## File context
|
|
|
|
A snippet alone cannot show whether a comment-looking line is a comment: it
|
|
may sit inside a template literal, a docstring, a raw string or a heredoc
|
|
that opens above it, follow a line that continues onto it, or lose its line
|
|
break so the next line joins it. So an Edit is trivial only when checked
|
|
against the file it applies to.
|
|
|
|
- The hook reads the current target (never a sensitive or hard-linked one) itself: the path
|
|
is resolved like every other target, opened read-only and non-blocking,
|
|
and used only if `fstat` says it is a regular file of at most 1 MiB; CRLF
|
|
is folded to LF, as the Edit tool does. A missing, unreadable, larger or
|
|
special file (directory, FIFO, device) gives no file text and the edit is
|
|
not trivial. The text is never logged, stored or put in a message.
|
|
- Each entry is applied in order, as the tool applies it, to the evolving
|
|
text. `old_string` must be non-empty and occur exactly once, or at least
|
|
once with `replace_all`; otherwise the tool would fail and the entry is not
|
|
trivial. Over 1 MiB of text after an edit, more than 8 MiB of scanning for
|
|
one call, or an edit window over 128 KiB is not trivial. A `new_string`
|
|
holding a `String.prototype.replace` pattern (`$$`, `$&`, `` $` ``, `$'`,
|
|
`$<`, `$` and a digit) is not trivial, in case the tool expands it.
|
|
- The edit window runs from the start of the line before the first
|
|
occurrence to the end of the line holding the last occurrence's end
|
|
(through the next line when `old_string` ends in a line break), so a line
|
|
that loses or gains a break is compared joined. The window before and
|
|
after the replacement is compared with the lexer rules above.
|
|
- The text before the window must end in plain code: not inside a comment,
|
|
string, template literal (including `${...}`), text block, raw string,
|
|
heredoc or here-string, and not after a line continuation (`\` in C-family,
|
|
Python and POSIX shells, a backtick in PowerShell, `^` in batch). A
|
|
per-language scanner decides; anything it cannot follow makes the edit not
|
|
trivial: a JS `/` after `}` with another `/` or quote later on the line
|
|
(regex or division), JSX in `.js`/`.jsx`/`.tsx` (`.ts`, `.mts` and `.cts`
|
|
allow `<` generics), `<!--` and `-->`, a Python f-string whose
|
|
replacement field holds its own quote, a comment or a line break, C#
|
|
raw strings and interpolation holes with quotes or braces, Kotlin `${`
|
|
inside strings, shell command substitution holding `case`, a heredoc or
|
|
a comment, a heredoc whose terminator never comes, and nesting deeper than
|
|
eight levels.
|
|
- Go files that import `"C"` are never trivial: the comment before the import
|
|
is C code.
|
|
- The window comparison runs before the prefix scan. Both must pass, so the
|
|
order changes no result, but most first-touch edits change code and fail
|
|
the window comparison, which spares them a scan of everything above the
|
|
edit.
|
|
|
|
The scanner was checked against real tokenizers over this repository: every
|
|
line start it calls plain code in 801 JS files (espree) and 146 Python files
|
|
(`tokenize`) is outside strings, templates, regex literals and comments and
|
|
not a backslash continuation.
|
|
|
|
## When a change is profiled
|
|
|
|
The profile is computed only when its result is used: for classes whose
|
|
questions depend on it (code), for Edit and MultiEdit calls on the classes
|
|
that can pass as trivial (code, test, prose), and for every call when
|
|
decision metrics are on, so the metrics lines keep their profile fields.
|
|
Instruction and config targets, and new test or prose files, skip it and the
|
|
lexer modules it needs.
|
|
|
|
## Questions from the change profile
|
|
|
|
Every question has a stable id, and `questionIdsFor(class, isWrite, profile)`
|
|
returns the ids in the order asked, so later measurement can record which
|
|
questions were asked without storing text.
|
|
|
|
| Target | Profile | Question ids |
|
|
|---|---|---|
|
|
| code Edit/MultiEdit | unknown | `importers`, `public-api`, `data-schema`, `quote-instruction` |
|
|
| code Edit/MultiEdit | public surface | `importers`, `public-api`, [`data-schema`], `quote-instruction` |
|
|
| code Edit/MultiEdit | no public surface | `local-callers`, [`data-schema`], `quote-instruction` |
|
|
| code Write | unknown | `callers`, `no-duplicate`, `data-schema`, `quote-instruction` |
|
|
| code Write | known | `callers`, `no-duplicate`, [`data-schema`], `quote-instruction` |
|
|
| instruction | any | `loader`, `behaviour-change`, `no-duplicate-instruction`, `quote-instruction` |
|
|
| test | any | `under-test`, `existing-tests`, `quote-instruction` |
|
|
| prose Write | any | `supersedes`, `linked-from`, `why-new-file`, `quote-instruction` |
|
|
| prose Edit | any | `references`, `corrects-or-adds`, `quote-instruction` |
|
|
| config | any | `config-reader`, `config-effect`, `no-plaintext-secrets`, `quote-instruction` |
|
|
|
|
`[data-schema]` is asked only when `touchesData`. With an unknown profile the
|
|
code text is byte-identical to the fixed four questions, unless the opt-in
|
|
[external-contract question](#external-contract-question) is on. 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. An Edit with an unknown profile keeps the original hint
|
|
byte for byte ("importers/callers, affected API, data schemas if any") when
|
|
the external-contract question is off. Opting in adds its external-contract
|
|
phrase to that hint. A Write with an unknown profile, sensitive Writes included,
|
|
names `callers`, `no-duplicate` and `data-schema` like its full denial.
|