feat(gateguard): profile shell scripts and Python module constants

This commit is contained in:
Jeffrey Montoya
2026-09-29 21:50:22 -04:00
parent 0301055327
commit 21ea5f96a8
7 changed files with 618 additions and 41 deletions
+54 -7
View File
@@ -286,8 +286,9 @@ 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`). Shell, Ruby, Makefiles, Swift and everything else
are unknown.
.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
@@ -301,16 +302,26 @@ around a public declaration means the edit is inside that symbol.
`.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 on a line after the first (the
first line of a snippet may start mid-line). `__init__.py` is all surface.
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`, or
`#[macro_export`.
- Java, Kotlin, C#, C and C++ always touch the surface: package-private Java,
Kotlin's public default, C# partial classes and C linkage make a lexical
answer unreliable.
- 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
@@ -329,6 +340,16 @@ for `open(`, for adjacent pairs such as `read file`,
(`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
@@ -353,6 +374,32 @@ Anything the lexer cannot read with confidence makes the entry non-trivial:
Make), the trigraph `??/`, whitespace after a line-continuation `\`, a `/*`
inside a block comment, and an unterminated block comment or string.
## 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 `#!` line, 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`
is a directive and never a comment. 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
File diff suppressed because one or more lines are too long
+307 -15
View File
@@ -17,7 +17,10 @@ const LANGUAGE_BY_EXT = new Map([
['.kt', 'kotlin'], ['.kts', 'kotlin'],
['.cs', 'csharp'],
['.c', 'c'], ['.h', 'c'],
['.cc', 'cpp'], ['.cpp', 'cpp'], ['.cxx', 'cpp'], ['.hpp', 'cpp'], ['.hh', 'cpp'], ['.hxx', 'cpp']
['.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 ---
@@ -34,7 +37,10 @@ const LEX_SPECS = {
csharp: { ...C_FAMILY, tripleQuotes: true, rawPrefixes: '@$' },
c: { ...C_FAMILY, rawPrefixes: 'R' },
cpp: { ...C_FAMILY, rawPrefixes: 'R' },
python: { lineComment: '#', blockComment: false, quotes: '"\'', indentSensitive: true, tripleQuotes: true, fStrings: true }
python: { lineComment: '#', blockComment: false, quotes: '"\'', indentSensitive: true, tripleQuotes: true, fStrings: true },
shell: { lexer: shCodeLines, normalize: line => normalizeShellLine(line, '\\') },
powershell: { lexer: psCodeLines, normalize: line => normalizeShellLine(line, '`') },
batch: { lexer: batchCodeLines, normalize: line => line }
};
function isSpace(ch) {
@@ -205,12 +211,13 @@ function hasSpaceAfterContinuation(line) {
}
function codeSignature(text, spec) {
const lines = lexCodeLines(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 = normalizeCodeLine(line, spec);
const normalized = spec.normalize ? spec.normalize(line) : normalizeCodeLine(line, spec);
if (normalized === null) return null;
if (normalized) signature.push(normalized);
}
return signature;
@@ -225,6 +232,230 @@ function isTrivialEdit(oldString, newString, spec) {
return before.every((line, index) => line === after[index]);
}
// --- Shell lexing ---
// see docs/gateguard/design-notes.md#shell-scripts
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) {
if (isLineStart(text, i) && text[i + 1] === '!') return null;
while (i + 1 < text.length && text[i + 1] !== '\n' && text[i + 1] !== '\r') i++;
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;
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 += ' ';
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;
i++;
continue;
}
if (ch === '#') {
if (!psHashStartsComment(text, i)) return null;
if (text.slice(i + 1, i + 9).toLowerCase() === 'requires') return null;
while (i + 1 < text.length && text[i + 1] !== '\n' && text[i + 1] !== '\r') i++;
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('');
} 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;
}
// --- Public surface ---
function leadingWord(text, word) {
@@ -274,14 +505,13 @@ function pythonLineIsSurface(line, index) {
const rest = t.slice(skipSpaces(t, 5));
if (leadingWord(rest, 'def')) return pythonNameIsPublic(identifierAt(rest, skipSpaces(rest, 3)));
}
if (index > 0 && start === 0) {
const name = identifierAt(t, 0);
const after = t[skipSpaces(t, name.length)];
const rest = skipSpaces(t, name.length);
const assigned = (after === '=' && t[rest + 1] !== '=') || (after === ':' && skipSpaces(t, rest + 1) < t.length);
return Boolean(name) && isLetter(name[0]) && assigned && pythonNameIsPublic(name);
}
return false;
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) {
@@ -306,11 +536,38 @@ function rustLineIsSurface(line) {
return leadingWord(t, 'pub') || leadingWord(t, 'impl') || leadingWord(t, 'trait') || t.startsWith('#[macro_export');
}
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
rust: rustLineIsSurface,
shell: shellLineIsSurface,
powershell: powershellLineIsSurface
};
function baseName(filePath) {
@@ -375,11 +632,46 @@ function* words(text) {
}
}
function textTouchesData(text) {
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);
@@ -438,7 +730,7 @@ function profileChange(input) {
const lineIsSurface = SURFACE_BY_LANGUAGE[language];
const touchesPublicSurface = input.tool === 'Write' || !lineIsSurface || wholeFileIsSurface(input.filePath, language) ||
texts.sides.some(side => textTouchesSurface(side, lineIsSurface));
const touchesData = texts.sides.some(textTouchesData);
const touchesData = texts.sides.some(side => textTouchesData(side, language));
const spec = LEX_SPECS[language];
const trivial = input.tool === 'Edit' && texts.pairs.every(([oldString, newString]) => isTrivialEdit(oldString, newString, spec));
return Object.freeze({ known: true, language, touchesPublicSurface, touchesData, trivial });
+18 -9
View File
@@ -95,19 +95,24 @@ For **code** targets the gate reads the change it is about to allow (Edit
- **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 (`__init__.py` always); Go: capitalised `func`/`type`/`var`/`const`
names, `package`, lines starting with a capital; Rust: `pub`, `impl`, `trait`,
`#[macro_export]`. Java, Kotlin, C#, C and C++, and every Write, always count
as touching it. Touching it keeps questions 1 and 2; otherwise both are
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`, `#[macro_export]`; shell: function definitions,
`export`, `declare -x`; PowerShell: `function`, `filter`, `param`,
`Export-ModuleMember`, `[CmdletBinding`. 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, schemas, SQL and dates on either side. Without them the data
serialisation, schemas, SQL and dates 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`. Any other extension, a
.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
targets always get the full questions. Other classes keep their class
@@ -404,8 +409,10 @@ 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) or `#` (Python); C preprocessor
lines are code; whitespace inside strings, line breaks between code, and
- comments are `//` and `/* */` (C family), `#` (Python), `#` at the start of
a word (shell), `#` and `<# #>` (PowerShell, not `#Requires`), or `REM`
lines (batch); C preprocessor lines, shebang lines and batch `::` labels
are code; 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;
@@ -413,7 +420,9 @@ entry reads cleanly:
quotes, `r"`, `R"`, `@"`, `$"`, f-strings, Kotlin `$`), a string running to
the end of a line, a line comment ending in `\`, `??/`, a nested or
unterminated block comment; in JS/TS any `/` outside a comment (regex versus
division), JSX-like tags, and `-->`;
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, and sensitive targets never pass this way.
Only the snippet is read, so a comment-looking line inside a multi-line string
@@ -344,7 +344,7 @@
},
{
"id": "unknown-extension-comment",
"note": "Shell has no change profile, so it is never trivial.",
"note": "A comment-only change to a shell script; # starts a comment only at the start of a word.",
"transcript": [
{
"type": "user",
@@ -402,9 +402,9 @@
},
"tool_use_id": "toolu_commentonlyedits_006"
},
"expect": "deny",
"expect": "allow",
"mustDeny": false,
"redundant": false,
"redundant": true,
"relevantQuestions": [
"quote-instruction"
]
+66 -1
View File
@@ -4552,6 +4552,44 @@ function runTests() {
passed++;
else failed++;
clearState();
if (
test('shell scripts ask the questions their change warrants', () => {
const local = b2Reason('Edit', { file_path: 'scripts/q1.sh', old_string: ' echo "a"', new_string: ' echo "b"' });
assert.deepStrictEqual(b2Questions(local), [B2_TEXT.localCallers, B2_TEXT.quote]);
clearState();
const fn = b2Reason('Edit', { file_path: 'scripts/q2.sh', old_string: 'deploy() {', new_string: 'deploy_all() {' });
assert.deepStrictEqual(b2Questions(fn), [B2_TEXT.importers, B2_TEXT.publicApi, B2_TEXT.quote]);
clearState();
const exported = b2Reason('Edit', { file_path: 'scripts/q3.sh', old_string: 'export MODE=a', new_string: 'export MODE=b' });
assert.deepStrictEqual(b2Questions(exported), [B2_TEXT.importers, B2_TEXT.publicApi, B2_TEXT.quote]);
clearState();
const fetch = b2Reason('Edit', { file_path: 'scripts/q4.sh', old_string: ' run', new_string: ' curl -s "$URL" > out.json' });
assert.deepStrictEqual(b2Questions(fetch), [B2_TEXT.localCallers, B2_TEXT.dataSchema, B2_TEXT.quote]);
clearState();
const ps = b2Reason('Edit', { file_path: 'scripts/q5.ps1', old_string: 'function Get-A {', new_string: 'function Get-B {' });
assert.deepStrictEqual(b2Questions(ps), [B2_TEXT.importers, B2_TEXT.publicApi, B2_TEXT.quote]);
clearState();
const bat = b2Reason('Edit', { file_path: 'scripts/q6.bat', old_string: 'echo a', new_string: 'echo b' });
assert.deepStrictEqual(b2Questions(bat), [B2_TEXT.importers, B2_TEXT.publicApi, B2_TEXT.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 = b2Reason('Edit', { file_path: 'tools/q7.py', old_string: 'BASE = "https://a.test/#home"', new_string: 'BASE = "https://a.test/#admin"' });
assert.deepStrictEqual(b2Questions(reason), [B2_TEXT.importers, B2_TEXT.publicApi, B2_TEXT.quote]);
clearState();
const local = b2Reason('Edit', { file_path: 'tools/q8.py', old_string: 'result = compute(a)', new_string: 'result = compute(b)' });
assert.deepStrictEqual(b2Questions(local), [B2_TEXT.localCallers, B2_TEXT.quote]);
})
)
passed++;
else failed++;
clearState();
if (
test('condensed denials follow the change profile', () => {
@@ -4626,6 +4664,26 @@ function runTests() {
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 = b3Run('Edit', input);
assert.notStrictEqual(out.decision, 'deny', out.result.stdout);
assert.ok(out.context.includes(`${B3_NOTE} ${input.file_path}`), out.context);
assert.ok(!(b3State().checked || []).includes(b3Key(input.file_path)), 'not marked checked');
}
assert.strictEqual(b3State().trivial_allows, 3);
})
)
passed++;
else failed++;
clearState();
if (
test('trivial-looking edits are still denied where the pass does not apply', () => {
@@ -4639,7 +4697,14 @@ function runTests() {
['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' }]
['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 <<EOF\n# old\nEOF', new_string: 'cat <<EOF\n# new\nEOF' }],
['shell quoted hash', { file_path: 'scripts/d11.sh', old_string: 'echo "a # old"', new_string: 'echo "a # new"' }],
['shell shebang', { file_path: 'scripts/d12.sh', old_string: '#!/bin/bash', new_string: '#!/bin/sh' }],
['powershell requires', { file_path: 'scripts/d13.ps1', old_string: '#Requires -Version 5', new_string: '#Requires -Version 7' }],
['powershell here-string', { file_path: 'scripts/d14.ps1', old_string: '@"\n# old\n"@', new_string: '@"\n# new\n"@' }]
];
for (const [label, input] of cases) {
clearState();
+169 -5
View File
@@ -42,10 +42,11 @@ test('supported extensions map to a language; others do not', () => {
'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'
'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.sh', 'Makefile', 'a.mk', 'a.yaml', 'a.md', 'a', 'a.swift', '', null]) {
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));
}
});
@@ -110,6 +111,21 @@ test('Python: public def/class, dunders, __all__ and __init__.py touch the surfa
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);
@@ -152,6 +168,150 @@ test('Write always touches the surface', () => {
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 <<EOF\n# a\nEOF', 'cat <<EOF\n# b\nEOF'],
['a.sh', 'cat <<-EOF\n\t# a\n\tEOF', 'cat <<-EOF\n\t# b\n\tEOF'],
['a.sh', "cat <<'EOF'\n# a\nEOF", "cat <<'EOF'\n# b\nEOF"],
['a.bash', 'grep x <<< "# a"', 'grep x <<< "# b"'],
['a.sh', 'echo "$(printf " #a")"', 'echo "$(printf " #b")"'],
['a.sh', 'echo "${x:-" #a"}"', 'echo "${x:-" #b"}"'],
['a.sh', 'echo `echo #a`', 'echo `echo #b`'],
['a.bash', "echo $'it\\'s' # a", "echo $'it\\'s' # b"],
['a.sh', "echo 'a\\' ' x '", "echo 'a\\' ' x '"],
['a.sh', 'echo a\\ b', 'echo a\\ b'],
['a.zsh', 'print -l *(#q.) # a', 'print -l *(#q.) # b'],
['a.sh', 'echo "multi\n# a"', 'echo "multi\n# b"'],
['a.ps1', 'Write-Output "a # b"', 'Write-Output "a # c"'],
['a.ps1', "Write-Output 'a # b'", "Write-Output 'a # c'"],
['a.ps1', 'Write-Output a#b', 'Write-Output a#c'],
['a.ps1', '$s = @"\n# a\n"@', '$s = @"\n# b\n"@'],
['a.ps1', "$s = @'\n# a\n'@", "$s = @'\n# b\n'@"],
['a.ps1', 'Write-Output "$(Get-X " #a")"', 'Write-Output "$(Get-X " #b")"'],
['a.ps1', 'Write-Output \u201ca # b\u201d', 'Write-Output \u201ca # c\u201d']
];
for (const [file, before, after] of cases) {
assert.strictEqual(trivial(file, before, after), false, `${file}: ${JSON.stringify(before)} -> ${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:');
@@ -299,7 +459,7 @@ test('comment tricks that can hide code are never trivial', () => {
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.sh', '# a', '# b'), false);
assert.strictEqual(trivial('a.fish', '# a', '# b'), false);
const multi = edits => profileChange({ filePath: 'a.js', tool: 'Edit', edits });
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);
@@ -330,10 +490,14 @@ test('64 KiB adversarial inputs profile in linear time', () => {
'aA'.repeat(size / 2),
'# \\'.repeat(Math.floor(size / 3)),
'func ('.repeat(Math.floor(size / 6)),
'select from '.repeat(Math.floor(size / 12))
'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']) {
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);