feat(gateguard): integrate evidence-aware first-touch questions

This commit is contained in:
haelyra
2026-10-10 17:46:23 -04:00
73 changed files with 29239 additions and 165 deletions
+41
View File
@@ -0,0 +1,41 @@
'use strict';
const fs = require('fs');
const DEFAULT_TRANSCRIPT_TAIL_BYTES = 256 * 1024;
/** Last `tailBytes` of a file as UTF-8 text, with whether earlier bytes were skipped; null when unreadable. */
function readFileTail(filePath, tailBytes) {
let fd;
try {
fd = fs.openSync(filePath, 'r');
} catch {
return null;
}
try {
const size = fs.fstatSync(fd).size;
const start = Math.max(0, size - tailBytes);
const length = size - start;
if (length <= 0) {
return { text: '', truncated: false };
}
const buffer = Buffer.alloc(length);
const bytesRead = fs.readSync(fd, buffer, 0, length, start);
return {
text: buffer.toString('utf8', 0, bytesRead),
truncated: start > 0
};
} catch {
return null;
} finally {
try {
fs.closeSync(fd);
} catch {
/* ignore */
}
}
}
module.exports = { DEFAULT_TRANSCRIPT_TAIL_BYTES, readFileTail };
+457
View File
@@ -0,0 +1,457 @@
'use strict';
const { isSpace, isIdentChar, isLetter, shCodeLines, psCodeLines, batchCodeLines, normalizeShellLine } = require('./gateguard-code-lexer');
const { MAX_FILE_CHARS, lineEndAt, lineStartOf, cFamilyStartsFresh, pythonStartsFresh, shStartsFresh, psStartsFresh, batchStartsFresh, trivialInFile } = require('./gateguard-file-context');
// see docs/gateguard/change-profile.md#change-profile
const MAX_SIDE_BYTES = 64 * 1024;
const MAX_TOTAL_BYTES = 256 * 1024;
const MAX_EDITS = 64;
const UNKNOWN_PROFILE = Object.freeze({ known: false, language: null, touchesPublicSurface: true, touchesData: true, trivial: false });
const LANGUAGE_BY_EXT = new Map([
['.js', 'js'], ['.mjs', 'js'], ['.cjs', 'js'], ['.jsx', 'js'], ['.ts', 'js'], ['.tsx', 'js'], ['.mts', 'js'], ['.cts', 'js'],
['.py', 'python'], ['.pyi', 'python'],
['.go', 'go'],
['.rs', 'rust'],
['.java', 'java'],
['.kt', 'kotlin'], ['.kts', 'kotlin'],
['.cs', 'csharp'],
['.c', 'c'], ['.h', 'c'],
['.cc', 'cpp'], ['.cpp', 'cpp'], ['.cxx', 'cpp'], ['.hpp', 'cpp'], ['.hh', 'cpp'], ['.hxx', 'cpp'],
['.sh', 'shell'], ['.bash', 'shell'], ['.zsh', 'shell'],
['.ps1', 'powershell'], ['.psm1', 'powershell'],
['.bat', 'batch'], ['.cmd', 'batch']
]);
// --- Lexing ---
// see docs/gateguard/change-profile.md#trivial-edits
const C_FAMILY = { lineComment: '//', blockComment: true, quotes: '"\'', indentSensitive: false };
const C_CONTEXT = { ...C_FAMILY, startsFresh: cFamilyStartsFresh };
const LEX_SPECS = {
js: { ...C_CONTEXT, flavor: 'js', forbiddenCode: '`/', jsx: true },
go: { ...C_CONTEXT, flavor: 'go', forbiddenCode: '`' },
rust: { ...C_CONTEXT, flavor: 'rust', quotes: '"', rustChars: true, rawPrefixes: 'r#', rustDocs: true },
java: { ...C_CONTEXT, flavor: 'java', tripleQuotes: true },
kotlin: { ...C_CONTEXT, flavor: 'kotlin', tripleQuotes: true, noDollarInStrings: true },
csharp: { ...C_CONTEXT, flavor: 'csharp', tripleQuotes: true, rawPrefixes: '@$' },
c: { ...C_CONTEXT, flavor: 'c', rawPrefixes: 'R' },
cpp: { ...C_CONTEXT, flavor: 'c', rawPrefixes: 'R' },
python: { lineComment: '#', blockComment: false, quotes: '"\'', indentSensitive: true, tripleQuotes: true, fStrings: true, startsFresh: pythonStartsFresh },
shell: { lexer: shCodeLines, normalize: line => normalizeShellLine(line, '\\'), startsFresh: shStartsFresh },
powershell: { lexer: psCodeLines, normalize: line => normalizeShellLine(line, '`'), startsFresh: psStartsFresh },
batch: { lexer: batchCodeLines, normalize: line => line, startsFresh: batchStartsFresh }
};
const TS_EXTS = new Set(['.ts', '.mts', '.cts']);
const TS_SPEC = { ...LEX_SPECS.js, jsx: false };
function lexSpecFor(filePath, language) {
if (language !== 'js') return LEX_SPECS[language];
const base = baseName(filePath);
return TS_EXTS.has(base.slice(base.lastIndexOf('.'))) ? TS_SPEC : LEX_SPECS.js;
}
// --- Public surface ---
function leadingWord(text, word) {
return text.startsWith(word) && !isIdentChar(text[word.length]);
}
function containsWord(text, word) {
for (let at = text.indexOf(word); at !== -1; at = text.indexOf(word, at + 1)) {
if (!isIdentChar(text[at - 1]) && !isIdentChar(text[at + word.length])) return true;
}
return false;
}
function identifierAt(text, start) {
let end = start;
while (end < text.length && isIdentChar(text[end])) end++;
return text.slice(start, end);
}
function skipSpaces(text, at) {
while (at < text.length && isSpace(text[at])) at++;
return at;
}
function isUpper(ch) {
return ch !== undefined && ch >= 'A' && ch <= 'Z';
}
function jsLineIsSurface(line) {
const t = line.slice(skipSpaces(line, 0));
return leadingWord(t, 'export') || containsWord(t, 'exports') || leadingWord(t, 'public') || leadingWord(t, 'declare');
}
function pythonNameIsPublic(name) {
if (!name) return false;
return !name.startsWith('_') || (name.length > 4 && name.startsWith('__') && name.endsWith('__'));
}
function pythonLineIsSurface(line, index) {
const start = skipSpaces(line, 0);
const t = line.slice(start);
if (t.includes('__all__')) return true;
for (const keyword of ['def', 'class']) {
if (leadingWord(t, keyword)) return pythonNameIsPublic(identifierAt(t, skipSpaces(t, keyword.length)));
}
if (leadingWord(t, 'async')) {
const rest = t.slice(skipSpaces(t, 5));
if (leadingWord(rest, 'def')) return pythonNameIsPublic(identifierAt(rest, skipSpaces(rest, 3)));
}
if (start !== 0) return false;
const name = identifierAt(t, 0);
const rest = skipSpaces(t, name.length);
const after = t[rest];
const assigned = (after === '=' && t[rest + 1] !== '=') || (after === ':' && skipSpaces(t, rest + 1) < t.length);
if (!name || !isLetter(name[0]) || !assigned) return false;
return index > 0 ? pythonNameIsPublic(name) : name === name.toUpperCase();
}
function goLineIsSurface(line) {
const t = line.slice(skipSpaces(line, 0));
if (isUpper(t[0])) return true;
if (leadingWord(t, 'package')) return true;
for (const keyword of ['type', 'var', 'const']) {
if (leadingWord(t, keyword)) return isUpper(t[skipSpaces(t, keyword.length)]);
}
if (!leadingWord(t, 'func')) return false;
let at = skipSpaces(t, 4);
if (t[at] === '(') {
const close = t.indexOf(')', at);
if (close === -1) return true;
at = skipSpaces(t, close + 1);
}
return isUpper(t[at]);
}
const RUST_SURFACE_ATTRIBUTES = ['#[macro_export', '#[derive', '#[repr', '#[no_mangle', '#[unsafe(no_mangle', '#[export_name', '#[unsafe(export_name'];
function rustLineIsSurface(line) {
const t = line.slice(skipSpaces(line, 0));
return leadingWord(t, 'pub') || leadingWord(t, 'impl') || leadingWord(t, 'trait') || leadingWord(t, 'extern') ||
RUST_SURFACE_ATTRIBUTES.some(attribute => t.startsWith(attribute));
}
function shellFunctionName(t) {
let end = 0;
while (end < t.length && (isIdentChar(t[end]) || t[end] === '-' || t[end] === ':' || t[end] === '.')) end++;
if (end === 0) return false;
const open = skipSpaces(t, end);
return t[open] === '(' && t[skipSpaces(t, open + 1)] === ')';
}
function shellLineIsSurface(line) {
const t = line.slice(skipSpaces(line, 0));
if (leadingWord(t, 'export') || leadingWord(t, 'function')) return true;
if (leadingWord(t, 'declare') || leadingWord(t, 'typeset')) {
const flags = t.slice(skipSpaces(t, 7));
return flags[0] === '-' && identifierAt(flags, 1).includes('x');
}
return shellFunctionName(t);
}
const POWERSHELL_SURFACE_WORDS = ['function', 'filter', 'workflow', 'class', 'enum', 'param', 'export-modulemember', '[cmdletbinding', '$global:'];
function powershellLineIsSurface(line) {
const t = line.slice(skipSpaces(line, 0)).toLowerCase();
return POWERSHELL_SURFACE_WORDS.some(word => t.startsWith(word) && (word.endsWith(':') || !isIdentChar(t[word.length])));
}
const SURFACE_BY_LANGUAGE = {
js: jsLineIsSurface,
python: pythonLineIsSurface,
go: goLineIsSurface,
rust: rustLineIsSurface,
shell: shellLineIsSurface,
powershell: powershellLineIsSurface
};
function baseName(filePath) {
return String(filePath).replace(/\\/g, '/').split('/').pop().toLowerCase();
}
function wholeFileIsSurface(filePath, language) {
const base = baseName(filePath);
return (language === 'js' && (base.endsWith('.d.ts') || base.endsWith('.d.mts') || base.endsWith('.d.cts'))) ||
(language === 'python' && base === '__init__.py');
}
function textTouchesSurface(text, lineIsSurface) {
let index = 0;
let start = 0;
for (let i = 0; i <= text.length; i++) {
if (i === text.length || text[i] === '\n' || text[i] === '\r') {
if (lineIsSurface(text.slice(start, i), index)) return true;
index++;
start = i + 1;
}
}
return false;
}
// --- Container members ---
// see docs/gateguard/change-profile.md#public-surface
const MAX_OPENER_SCAN = 4 * 1024 * 1024;
const JS_BODY_KINDS = ['function', 'async'];
const JS_MEMBER_KINDS = ['class', 'abstract'];
const JS_CONTAINER_KINDS = ['interface', 'enum', 'type', 'namespace', 'module', 'const', 'let', 'var', 'declare', '{'];
const RUST_CONTAINER_KINDS = ['enum', 'struct', 'union', 'trait', 'use'];
function indentOf(line) {
return line.slice(0, skipSpaces(line, 0));
}
function lineAt(text, start) {
const end = lineEndAt(text, start);
return { text: text.slice(start, end), next: end + (text[end] === '\r' && text[end + 1] === '\n' ? 2 : 1) };
}
function isCommentLine(t) {
return t.startsWith('//') || t.startsWith('/*') || t.startsWith('*') || t.startsWith('#') || t.startsWith('@');
}
function enclosingOpener(text, at, budget) {
let end = lineStartOf(text, at);
while (end > 0) {
let last = end - 1;
if (text[last] === '\n' && text[last - 1] === '\r') last--;
const start = lineStartOf(text, last);
budget.left -= end - start;
if (budget.left < 0) return null;
const line = text.slice(start, last);
end = start;
if (!line.trim() || isSpace(line[0])) continue;
if ('}])'.includes(line[0])) return null;
if (isCommentLine(line)) continue;
return { line, start };
}
return null;
}
function jsContainerKind(opener) {
const t = opener.trimEnd();
const bodyLike = t.includes('=>') || containsWord(t, 'function');
if (containsWord(t, 'exports')) return !bodyLike && /[{[(]$/.test(t) ? 'all' : null;
if (!leadingWord(t, 'export')) return null;
let rest = t.slice(skipSpaces(t, 6));
if (leadingWord(rest, 'default')) rest = rest.slice(skipSpaces(rest, 7));
if (JS_BODY_KINDS.some(word => leadingWord(rest, word))) return null;
if (JS_MEMBER_KINDS.some(word => leadingWord(rest, word))) return 'members';
if (rest.startsWith('{')) return 'all';
if (!JS_CONTAINER_KINDS.some(word => leadingWord(rest, word))) return null;
return bodyLike ? null : 'all';
}
function containerKind(opener, language) {
if (language === 'js') return jsContainerKind(opener);
if (language === 'python') {
if (opener.includes('__all__')) return 'all';
return leadingWord(opener, 'class') && pythonNameIsPublic(identifierAt(opener, skipSpaces(opener, 5))) ? 'fields' : null;
}
if (language === 'rust' && leadingWord(opener, 'pub')) {
const words = opener.split(/[^A-Za-z0-9_]+/);
return RUST_CONTAINER_KINDS.some(word => words.includes(word)) ? 'members' : null;
}
return null;
}
function memberIndent(text, opener) {
let at = lineAt(text, opener.start).next;
while (at < text.length) {
const line = lineAt(text, at);
if (line.text.trim()) return indentOf(line.text);
at = line.next;
}
return null;
}
function isMemberLine(line, indent, kind) {
if (!indent || indentOf(line) !== indent) return false;
const t = line.slice(indent.length);
if (!t || '}])'.includes(t[0]) || isCommentLine(t)) return false;
if (kind !== 'fields') return true;
const name = identifierAt(t, 0);
const after = t[skipSpaces(t, name.length)];
return pythonNameIsPublic(name) && isLetter(name[0]) && (after === ':' || (after === '=' && t[skipSpaces(t, name.length) + 1] !== '='));
}
function containerTouchesSurface(pairs, fileText, language) {
if (typeof fileText !== 'string' || fileText.length > MAX_FILE_CHARS) return false;
const budget = { left: MAX_OPENER_SCAN };
for (const [oldString, newString] of pairs) {
const at = oldString ? fileText.indexOf(oldString) : -1;
if (at === -1) continue;
const opener = enclosingOpener(fileText, at, budget);
if (budget.left < 0) return true;
const kind = opener ? containerKind(opener.line, language) : null;
if (!kind) continue;
if (kind === 'all') return true;
const indent = memberIndent(fileText, opener);
const start = lineStartOf(fileText, at);
const end = lineEndAt(fileText, at + oldString.length);
const before = fileText.slice(start, end);
const after = `${fileText.slice(start, at)}${newString}${fileText.slice(at + oldString.length, end)}`;
if (`${before}\n${after}`.split(/\r\n|\r|\n/).some(line => isMemberLine(line, indent, kind))) return true;
}
return false;
}
// --- Data handling ---
const DATA_WORDS = new Set([
'json', 'jsonl', 'ndjson', 'yaml', 'yml', 'csv', 'tsv', 'xml', 'toml', 'ini', 'parquet', 'avro', 'arrow', 'pickle', 'protobuf',
'serde', 'serialize', 'serializer', 'serialise', 'deserialize', 'deserializer', 'deserialise', 'marshal', 'unmarshal',
'schema', 'schemas', 'sql', 'sqlite', 'sqlite3', 'postgres', 'mysql', 'mongo', 'mongodb', 'database', 'db', 'cursor',
'migration', 'migrations', 'orm', 'dataframe', 'pandas',
'date', 'dates', 'datetime', 'timestamp', 'timestamps', 'strftime', 'strptime', 'isoformat', 'iso', 'iso8601', 'timezone',
'tz', 'utc', 'epoch', 'dayjs', 'moment', 'luxon', 'instant',
'fs', 'fopen', 'fread', 'fwrite', 'fgets', 'ioutil', 'bufio', 'pathlib', 'shutil', 'readfile', 'writefile', 'fstream',
'ifstream', 'ofstream',
'chrono', 'zoneinfo', 'pytz', 'tzinfo', 'temporal', 'base64', 'encoding', 'charset', 'codec', 'msgpack', 'bson', 'cbor',
'xlsx', 'storage', 'cookie', 'cookies', 'redis', 'prisma', 'knex', 'sequelize', 'mongoose', 'typeorm', 'drizzle',
'sqlalchemy', 'jdbc', 'firestore', 'dynamodb'
]);
const DATA_PAIRS = [
['read', 'file'], ['write', 'file'], ['append', 'file'], ['open', 'file'], ['read', 'text'], ['write', 'text'],
['read', 'bytes'], ['write', 'bytes'], ['read', 'to'], ['read', 'lines'], ['write', 'lines'], ['read', 'all'],
['file', 'reader'], ['file', 'writer'], ['file', 'stream'], ['read', 'csv'], ['to', 'csv'],
['time', 'now'], ['time', 'parse'], ['time', 'unix'], ['time', 'since'], ['time', 'format'], ['time', 'time'],
['time', 'duration'], ['time', 'zone'], ['time', 'stamp'], ['system', 'time']
];
const SQL_PAIRS = [['select', 'from'], ['insert', 'into'], ['delete', 'from'], ['create', 'table'], ['alter', 'table'], ['drop', 'table'], ['update', 'set']];
function* words(text) {
let word = '';
let prev = '';
for (let i = 0; i <= text.length; i++) {
const ch = text[i];
const alnum = ch !== undefined && ((ch >= 'a' && ch <= 'z') || (ch >= 'A' && ch <= 'Z') || (ch >= '0' && ch <= '9'));
const boundary = alnum && word && (
(isUpper(ch) && !isUpper(prev) && !(prev >= '0' && prev <= '9')) ||
(isUpper(ch) && isUpper(prev) && text[i + 1] >= 'a' && text[i + 1] <= 'z')
);
if (!alnum || boundary) {
if (word) yield { word: word.toLowerCase(), call: !alnum && text[skipSpaces(text, i)] === '(' };
word = alnum ? ch : '';
} else {
word += ch;
}
prev = ch;
}
}
const SHELL_LANGUAGES = new Set(['shell', 'powershell', 'batch']);
const SHELL_DATA_WORDS = new Set(['curl', 'wget', 'jq', 'yq', 'xmllint', 'psql', 'tee', 'iwr', 'irm', 'clixml']);
const SHELL_DATA_PAIRS = [['out', 'file'], ['get', 'content'], ['set', 'content'], ['add', 'content'], ['web', 'request'], ['rest', 'method']];
const NULL_TARGETS = ['/dev/null', '/dev/stdout', '/dev/stderr', '$null', 'nul'];
function redirectTargetIsFile(text, at) {
const start = skipSpaces(text, at);
const ch = text[start];
if (ch === undefined || ch === '\n' || ch === '\r' || ch === '&') return false;
const rest = text.slice(start, start + 12).toLowerCase();
return !NULL_TARGETS.some(target => rest.startsWith(target) && !isIdentChar(rest[target.length]) && rest[target.length] !== '.');
}
function shellRedirectsFile(text) {
for (let i = 0; i < text.length; i++) {
const ch = text[i];
if (ch === '>') {
const prev = text[i - 1];
if (prev === '-' || prev === '=' || prev === '>') continue;
let at = i + 1;
if (text[at] === '>' || text[at] === '|') at++;
if (text[at] === '=' || text[at] === '&') continue;
if (redirectTargetIsFile(text, at)) return true;
} else if (ch === '<') {
const next = text[i + 1];
if (text[i - 1] === '<' || next === '<' || next === '(' || next === '&' || next === '#' || next === '=') continue;
if (redirectTargetIsFile(text, i + 1)) return true;
}
}
return false;
}
function textTouchesData(text, language) {
const shell = SHELL_LANGUAGES.has(language);
if (shell && shellRedirectsFile(text)) return true;
const seen = new Set();
let previous = '';
for (const { word, call } of words(text)) {
if (DATA_WORDS.has(word)) return true;
if (shell && (SHELL_DATA_WORDS.has(word) || SHELL_DATA_PAIRS.some(([a, b]) => previous === a && word === b))) return true;
if (word === 'open' && call) return true;
if (DATA_PAIRS.some(([a, b]) => previous === a && word === b)) return true;
seen.add(word);
previous = word;
}
return SQL_PAIRS.some(([a, b]) => seen.has(a) && seen.has(b));
}
// --- Profile ---
/** Language of a target by extension, or null when unsupported. */
function languageFor(filePath) {
if (typeof filePath !== 'string' || !filePath) return null;
const base = baseName(filePath);
const dot = base.lastIndexOf('.');
if (dot <= 0) return null;
return LANGUAGE_BY_EXT.get(base.slice(dot)) || null;
}
function changeTexts(input) {
if (input.tool === 'Write') {
return typeof input.content === 'string' ? { sides: [input.content], pairs: [] } : null;
}
if (input.tool !== 'Edit' || !Array.isArray(input.edits) || input.edits.length === 0 || input.edits.length > MAX_EDITS) return null;
const pairs = [];
for (const entry of input.edits) {
if (!entry || typeof entry !== 'object') return null;
const oldString = entry.old_string;
const newString = entry.new_string;
if (typeof oldString !== 'string' || typeof newString !== 'string') return null;
pairs.push([oldString, newString, entry.replace_all === true]);
}
return { sides: pairs.flatMap(([oldString, newString]) => [oldString, newString]), pairs };
}
function withinBounds(sides) {
let total = 0;
for (const side of sides) {
if (side.length > MAX_SIDE_BYTES) return false;
const bytes = Buffer.byteLength(side, 'utf8');
if (bytes > MAX_SIDE_BYTES) return false;
total += bytes;
if (total > MAX_TOTAL_BYTES) return false;
}
return true;
}
/** Profile of one target's change from Edit/MultiEdit entries or Write content, judged against `fileText` when given; unknown on any doubt. */
function profileChange(input) {
try {
if (!input || typeof input !== 'object') return UNKNOWN_PROFILE;
const language = languageFor(input.filePath);
if (!language) return UNKNOWN_PROFILE;
const texts = changeTexts(input);
if (!texts || !withinBounds(texts.sides)) return UNKNOWN_PROFILE;
const lineIsSurface = SURFACE_BY_LANGUAGE[language];
const touchesPublicSurface = input.tool === 'Write' || !lineIsSurface || wholeFileIsSurface(input.filePath, language) ||
texts.sides.some(side => textTouchesSurface(side, lineIsSurface)) || containerTouchesSurface(texts.pairs, input.fileText, language);
const touchesData = texts.sides.some(side => textTouchesData(side, language));
const trivial = input.tool === 'Edit' && trivialInFile(texts.pairs, input.fileText, lexSpecFor(input.filePath, language));
return Object.freeze({ known: true, language, touchesPublicSurface, touchesData, trivial });
} catch (_) {
return UNKNOWN_PROFILE;
}
}
module.exports = { MAX_SIDE_BYTES, MAX_TOTAL_BYTES, MAX_EDITS, UNKNOWN_PROFILE, languageFor, profileChange };
+560
View File
@@ -0,0 +1,560 @@
'use strict';
// --- Code lines ---
// see docs/gateguard/change-profile.md#trivial-edits
function isSpace(ch) {
return ch === ' ' || ch === '\t' || ch === '\f' || ch === '\v';
}
function isIdentChar(ch) {
return ch !== undefined && ((ch >= 'a' && ch <= 'z') || (ch >= 'A' && ch <= 'Z') || (ch >= '0' && ch <= '9') || ch === '_' || ch === '$');
}
function isLetter(ch) {
return ch !== undefined && ((ch >= 'a' && ch <= 'z') || (ch >= 'A' && ch <= 'Z'));
}
function rustCharEnd(text, i) {
if (text[i + 1] === '\\') {
if (text[i + 2] === "'") return text[i + 3] === "'" ? i + 3 : -1;
const end = text.indexOf("'", i + 3);
return end !== -1 && end <= i + 12 ? end : -1;
}
const cp = text.codePointAt(i + 1);
if (cp === undefined || text[i + 1] === '\n' || text[i + 1] === '\r' || text[i + 1] === "'") return -1;
const end = i + (cp > 0xffff ? 3 : 2);
return text[end] === "'" ? end : -1;
}
function stringPrefixIsUnsafe(code, spec) {
const last = code[code.length - 1];
if (spec.rawPrefixes && last !== undefined && spec.rawPrefixes.includes(last)) return true;
if (!spec.fStrings || !isLetter(last)) return false;
const prefix = isLetter(code[code.length - 2]) ? code.slice(-2) : last;
return prefix.toLowerCase().includes('f');
}
// --- Directive comments ---
// see docs/gateguard/change-profile.md#directive-comments
const DIRECTIVE_LEADS = ['@', '#', '<', '+', '!', 'type:', 'global ', 'globals ', 'exported ', 'requires'];
const DIRECTIVE_RAW_LEADS = ['!', '/', 'go:', 'export ', 'extern ', 'line '];
const DIRECTIVE_WORDS = [
'lint', 'jshint', 'ts-', 'noqa', 'nosec', 'semgrep', 'sonar', 'noinspection', 'pragma', 'coding:', 'coding=', 'fmt:', 'isort:',
'mypy:', 'pyright:', 'pyre-', 'yapf:', 'ruff:', 'flake8', 'bandit', 'no cover', 'nocover', 'istanbul', 'c8 ', 'v8 ',
'prettier-', 'biome-', 'deno-', 'dprint-', 'rome-', 'webpack', 'vite-', '__pure__', '__no_side_effects__',
'__inline__', '@preserve', '@license', 'sourcemappingurl', 'sourceurl', '+build', 'cgo', 'clang-', 'cppcheck',
'coverity', 'iwyu', 'fallthrough', 'fall through', 'fall-through', 'fallthru', 'fall thru', 'shellcheck',
'psscriptanalyzer', 'suppress', 'jscs', 'checkstyle', 'spotbugs', 'findbugs', 'nopmd', 'codeql', 'lgtm', 'gitleaks',
'trufflehog', 'detect-secrets', 'allowlist', 'vim:', 'vi:', ' ex:', '-*-', 'code generated', 'do not edit',
'rubocop', 'swiftlint', 'resharper', '@ts-', '@type', '@typedef', '@template', '@satisfies', '@import', '@callback',
'@overload', '@enum', '@this', '@implements', '@extends', '@augments', '@jsx', '@flow', '@noflow', '@generated',
'compdef', 'autoload', 'output:'
];
function isDirectiveComment(body, spec) {
if (spec.rustDocs && (body[0] === '/' || body[0] === '!' || body[0] === '*')) return true;
if (DIRECTIVE_RAW_LEADS.some(lead => body.startsWith(lead))) return true;
const lower = body.toLowerCase();
let start = 0;
while (start < lower.length && (isSpace(lower[start]) || lower[start] === '*' || lower[start] === '/')) start++;
const lead = lower.slice(start);
if (DIRECTIVE_LEADS.some(word => lead.startsWith(word))) return true;
if (DIRECTIVE_WORDS.some(word => lower.includes(word))) return true;
return lower.includes('@') && lower.includes('{');
}
function directiveMarker(body, spec) {
return isDirectiveComment(body, spec) ? ` \u0001${Buffer.from(body, 'utf8').toString('hex')}\u0001 ` : '';
}
function endsWithContinuation(code) {
let end = code.length;
while (end > 0 && isSpace(code[end - 1])) end--;
return code[end - 1] === '\\';
}
const CODE_SPECIALS = new WeakMap();
function escapeClass(chars) {
return chars.replace(/[\\\]^-]/g, ch => `\\${ch}`);
}
function codeSpecials(spec) {
let specials = CODE_SPECIALS.get(spec);
if (!specials) {
const chars = `\n\r/${spec.lineComment === '#' ? '#' : ''}${spec.quotes || ''}${spec.forbiddenCode || ''}${spec.jsx !== undefined ? '<-' : ''}${spec.rustChars ? "'" : ''}`;
specials = { pattern: new RegExp(`[${escapeClass(chars)}]`, 'g'), chars: new Set(chars) };
CODE_SPECIALS.set(spec, specials);
}
return specials;
}
function nextSpecial(pattern, text, i) {
pattern.lastIndex = i;
const match = pattern.exec(text);
return match ? match.index : text.length;
}
const LINE_BREAK = /[\n\r]/g;
const BLOCK_SPECIALS = /[\n\r*/]/g;
const STRING_SPECIALS = new Map();
function stringSpecials(quote) {
let pattern = STRING_SPECIALS.get(quote);
if (!pattern) {
pattern = new RegExp(`[\\n\\r\\\\${escapeClass(quote)}]`, 'g');
STRING_SPECIALS.set(quote, pattern);
}
return pattern;
}
function lexCodeLines(text, spec) {
const specials = codeSpecials(spec);
const lines = [];
let code = '';
let state = 'code';
let quote = '';
let stringBody = '';
let commentStart = 0;
let tail = '';
const append = piece => {
code += piece;
tail = (tail + piece).slice(-2);
};
const endComment = end => {
const marker = directiveMarker(text.slice(commentStart, end), spec);
if (marker) append(marker);
};
const endLine = () => {
if (endsWithContinuation(code)) return false;
lines.push(code);
code = '';
tail = '';
return true;
};
if (spec.jsx !== undefined && text.startsWith('#!')) state = 'line';
for (let i = 0; i < text.length; i++) {
const ch = text[i];
if (ch === '\n' || ch === '\r') {
if (state === 'string') return null;
if (state === 'line') {
if (text.slice(commentStart, i).trimEnd().endsWith('\\')) return null;
endComment(i);
state = 'code';
}
if (state === 'block') {
lines.push(code);
code = '';
tail = '';
} else if (!endLine()) {
return null;
}
if (ch === '\r' && text[i + 1] === '\n') i++;
continue;
}
if (state === 'line') {
i = nextSpecial(LINE_BREAK, text, i) - 1;
continue;
}
if (state === 'block') {
if (ch !== '*' && ch !== '/') {
i = nextSpecial(BLOCK_SPECIALS, text, i + 1) - 1;
continue;
}
if (ch === '*' && text[i + 1] === '/') {
endComment(i);
state = 'code';
append(' ');
i++;
} else if (ch === '/' && text[i + 1] === '*') {
return null;
}
continue;
}
if (state === 'string') {
if (ch !== '\\' && ch !== quote) {
const end = nextSpecial(stringSpecials(quote), text, i + 1);
const run = text.slice(i, end);
append(run);
stringBody += run;
i = end - 1;
continue;
}
append(ch);
if (ch === '\\') {
const next = text[i + 1];
if (next === undefined || next === '\n' || next === '\r') return null;
append(next);
i++;
} else if (ch === quote) {
if (spec.noDollarInStrings && stringBody.includes('$')) return null;
state = 'code';
} else {
stringBody += ch;
}
continue;
}
if (!specials.chars.has(ch)) {
const end = nextSpecial(specials.pattern, text, i + 1);
append(text.slice(i, end));
i = end - 1;
continue;
}
if (spec.lineComment === '//' && ch === '/' && text[i + 1] === '/') {
state = 'line';
commentStart = i + 2;
i++;
continue;
}
if (spec.lineComment === '#' && ch === '#') {
state = 'line';
commentStart = i + 1;
continue;
}
if (spec.blockComment && ch === '/' && text[i + 1] === '*') {
state = 'block';
commentStart = i + 2;
i++;
continue;
}
if (spec.forbiddenCode && spec.forbiddenCode.includes(ch)) return null;
if (spec.jsx && ch === '<' && (isLetter(text[i + 1]) || text[i + 1] === '/' || text[i + 1] === '>' || text[i + 1] === '!')) return null;
if (spec.jsx !== undefined && ch === '-' && text[i + 1] === '-' && text[i + 2] === '>') return null;
if (ch === "'" && spec.rustChars) {
const end = rustCharEnd(text, i);
append(end === -1 ? ch : text.slice(i, end + 1));
if (end !== -1) i = end;
continue;
}
if (spec.quotes.includes(ch)) {
if (spec.tripleQuotes && text[i + 1] === ch && text[i + 2] === ch) return null;
if (stringPrefixIsUnsafe(tail, spec)) return null;
state = 'string';
quote = ch;
stringBody = '';
append(ch);
continue;
}
append(ch);
}
if (state === 'string' || state === 'block') return null;
if (state === 'line') {
if (text.slice(commentStart).trimEnd().endsWith('\\')) return null;
endComment(text.length);
}
return endLine() ? lines : null;
}
function normalizeCodeLine(line, spec) {
let out = '';
let pendingSpace = false;
let quote = '';
let indent = '';
let leading = true;
for (let i = 0; i < line.length; i++) {
const ch = line[i];
if (quote) {
out += ch;
if (ch === '\\' && i + 1 < line.length) out += line[++i];
else if (ch === quote) quote = '';
continue;
}
if (isSpace(ch)) {
if (leading) indent += ch;
else pendingSpace = true;
continue;
}
leading = false;
if (pendingSpace && out) out += ' ';
pendingSpace = false;
if (ch === "'" && spec.rustChars) {
const end = rustCharEnd(line, i);
out += end === -1 ? ch : line.slice(i, end + 1);
if (end !== -1) i = end;
continue;
}
if (spec.quotes.includes(ch)) quote = ch;
out += ch;
}
if (!out) return '';
return spec.indentSensitive ? `${indent}\u0000${out}` : out;
}
function hasSpaceAfterContinuation(line) {
let end = line.length;
while (end > 0 && isSpace(line[end - 1])) end--;
return end < line.length && line[end - 1] === '\\';
}
function codeSignature(text, spec) {
const lines = spec.lexer ? spec.lexer(text) : lexCodeLines(text, spec);
if (lines === null) return null;
const signature = [];
for (const line of lines) {
if (hasSpaceAfterContinuation(line)) return null;
const normalized = spec.normalize ? spec.normalize(line) : normalizeCodeLine(line, spec);
if (normalized === null) return null;
if (normalized) signature.push(normalized);
}
return signature;
}
function isTrivialEdit(oldString, newString, spec) {
if (spec.lineComment === '//' && (oldString.includes('??/') || newString.includes('??/'))) return false;
const before = codeSignature(oldString, spec);
if (before === null) return false;
const after = codeSignature(newString, spec);
if (after === null || before.length !== after.length) return false;
return before.every((line, index) => line === after[index]);
}
// --- Shell lexing ---
// see docs/gateguard/change-profile.md#shell-scripts
const SHELL_SPEC = Object.freeze({});
function isLineStart(text, i) {
return i === 0 || text[i - 1] === '\n' || text[i - 1] === '\r';
}
function hashStartsComment(text, i) {
if (isLineStart(text, i)) return true;
const prev = text[i - 1];
if (prev === ' ' || prev === '\t' || prev === ';') return true;
return '()|&<>'.includes(prev) ? null : false;
}
function shDoubleQuoteEnd(text, i) {
for (let j = i + 1; j < text.length; j++) {
const ch = text[j];
if (ch === '"') return j;
if (ch === '\n' || ch === '\r' || ch === '`') return -1;
if (ch === '\\') {
if (j + 1 >= text.length || text[j + 1] === '\n' || text[j + 1] === '\r') return -1;
j++;
} else if (ch === '$' && text[j + 1] === '(') {
return -1;
} else if (ch === '$' && text[j + 1] === '{') {
const close = text.indexOf('}', j + 2);
if (close === -1) return -1;
for (let k = j + 2; k < close; k++) {
if ('"\'\n\r'.includes(text[k])) return -1;
}
j = close;
}
}
return -1;
}
function hasLoneCarriageReturn(text) {
for (let at = text.indexOf('\r'); at !== -1; at = text.indexOf('\r', at + 1)) {
if (text[at + 1] !== '\n') return true;
}
return false;
}
function isBlank(ch) {
return ch === ' ' || ch === '\t';
}
function shCodeLines(text) {
if (text.includes('<<') || text.includes('`') || hasLoneCarriageReturn(text)) return null;
const lines = [];
let code = '';
for (let i = 0; i < text.length; i++) {
const ch = text[i];
if (ch === '\n' || ch === '\r') {
lines.push(code);
code = '';
if (ch === '\r' && text[i + 1] === '\n') i++;
continue;
}
if (ch === '\\') {
const next = text[i + 1];
if (next === undefined || next === '\n' || next === '\r') return null;
code += ch + next;
i++;
continue;
}
if (ch === "'") {
if (text[i - 1] === '$') return null;
const end = text.indexOf("'", i + 1);
if (end === -1) return null;
const body = text.slice(i, end + 1);
if (body.includes('\n') || body.includes('\r')) return null;
code += body;
i = end;
continue;
}
if (ch === '"') {
const end = shDoubleQuoteEnd(text, i);
if (end === -1) return null;
code += text.slice(i, end + 1);
i = end;
continue;
}
if (ch === '#') {
const starts = hashStartsComment(text, i);
if (starts === null) return null;
if (starts) {
const start = i + 1;
while (i + 1 < text.length && text[i + 1] !== '\n' && text[i + 1] !== '\r') i++;
code += directiveMarker(text.slice(start, i + 1), SHELL_SPEC);
continue;
}
}
code += ch;
}
lines.push(code);
return lines;
}
const PS_SMART_QUOTES = /[\u2018\u2019\u201a\u201b\u201c\u201d\u201e]/;
function psHashStartsComment(text, i) {
if (isLineStart(text, i)) return true;
const prev = text[i - 1];
return prev === ' ' || prev === '\t' || prev === ';';
}
function psQuoteEnd(text, i) {
const quote = text[i];
for (let j = i + 1; j < text.length; j++) {
const ch = text[j];
if (ch === '\n' || ch === '\r') return -1;
if (quote === '"' && ch === '`') {
if (j + 1 >= text.length || text[j + 1] === '\n' || text[j + 1] === '\r') return -1;
j++;
} else if (quote === '"' && ch === '$' && text[j + 1] === '(') {
return -1;
} else if (ch === quote) {
if (text[j + 1] !== quote) return j;
j++;
}
}
return -1;
}
function psCodeLines(text) {
if (text.includes('@"') || text.includes("@'") || PS_SMART_QUOTES.test(text)) return null;
const lines = [];
let code = '';
let block = false;
let blockStart = 0;
for (let i = 0; i < text.length; i++) {
const ch = text[i];
if (ch === '\n' || ch === '\r') {
lines.push(code);
code = '';
if (ch === '\r' && text[i + 1] === '\n') i++;
continue;
}
if (block) {
if (ch === '<' && text[i + 1] === '#') return null;
if (ch === '#' && text[i + 1] === '>') {
block = false;
code += directiveMarker(text.slice(blockStart, i), SHELL_SPEC) || ' ';
i++;
}
continue;
}
if (ch === '`') {
const next = text[i + 1];
if (next === undefined || next === '\n' || next === '\r') return null;
code += ch + next;
i++;
continue;
}
if (ch === "'" || ch === '"') {
const end = psQuoteEnd(text, i);
if (end === -1) return null;
code += text.slice(i, end + 1);
i = end;
continue;
}
if (ch === '<' && text[i + 1] === '#') {
if (!psHashStartsComment(text, i)) return null;
block = true;
blockStart = i + 2;
i++;
continue;
}
if (ch === '#') {
if (!psHashStartsComment(text, i)) return null;
const start = i + 1;
while (i + 1 < text.length && text[i + 1] !== '\n' && text[i + 1] !== '\r') i++;
code += directiveMarker(text.slice(start, i + 1), SHELL_SPEC);
continue;
}
code += ch;
}
if (block) return null;
lines.push(code);
return lines;
}
function batchCodeLines(text) {
const lines = [];
for (const line of text.split(/\r\n|\r|\n/)) {
if (line.endsWith('^')) return null;
let start = 0;
while (isBlank(line[start])) start++;
if (line[start] === '@') start++;
while (isBlank(line[start])) start++;
const word = line.slice(start, start + 3).toLowerCase();
const after = line[start + 3];
if (word === 'rem' && (after === undefined || isBlank(after))) {
if (/[%^&|<>()]/.test(line)) return null;
lines.push(directiveMarker(line.slice(start + 4), SHELL_SPEC));
} else {
lines.push(line.trim() ? line : '');
}
}
return lines;
}
function normalizeShellLine(line, escape) {
let out = '';
let pendingSpace = false;
for (let i = 0; i < line.length; i++) {
const ch = line[i];
if (isBlank(ch)) {
pendingSpace = true;
continue;
}
if (pendingSpace && out) out += ' ';
pendingSpace = false;
if (ch === escape && i + 1 < line.length) {
out += ch + line[++i];
} else if (ch === "'" || ch === '"') {
const end = escape === '`' ? psQuoteEnd(line, i) : ch === "'" ? line.indexOf("'", i + 1) : shDoubleQuoteEnd(line, i);
if (end === -1) return null;
out += line.slice(i, end + 1);
i = end;
} else {
out += ch;
}
}
return out;
}
module.exports = {
isSpace,
isIdentChar,
isLetter,
rustCharEnd,
isBlank,
hashStartsComment,
hasLoneCarriageReturn,
PS_SMART_QUOTES,
psHashStartsComment,
isTrivialEdit,
shCodeLines,
psCodeLines,
batchCodeLines,
normalizeShellLine
};
+735
View File
@@ -0,0 +1,735 @@
'use strict';
const { isSpace, isIdentChar, isLetter, rustCharEnd, isBlank, hashStartsComment, hasLoneCarriageReturn, PS_SMART_QUOTES, psHashStartsComment, isTrivialEdit } = require('./gateguard-code-lexer');
// --- File context ---
// see docs/gateguard/change-profile.md#file-context
const REPLACEMENT_PATTERN = /\$[$&'`<0-9]/;
const MAX_FILE_CHARS = 1024 * 1024;
const MAX_WINDOW_CHARS = 128 * 1024;
const MAX_CONTEXT_WORK = 8 * 1024 * 1024;
const JS_REGEX_KEYWORDS = new Set(['return', 'typeof', 'instanceof', 'in', 'of', 'new', 'delete', 'void', 'throw', 'case', 'do', 'else', 'yield', 'await']);
const JS_CONTROL_HEADERS = new Set(['if', 'while', 'for', 'with']);
const CPP_RAW_PREFIXES = new Set(['R', 'u8R', 'uR', 'UR', 'LR']);
function isNewline(ch) {
return ch === '\n' || ch === '\r';
}
function lineEndAt(text, i) {
let j = i;
while (j < text.length && !isNewline(text[j])) j++;
return j;
}
function quotedEnd(text, i, quote, multiline) {
for (let j = i + 1; j < text.length; j++) {
const ch = text[j];
if (ch === '\\') {
j++;
if (text[j] === '\r' && text[j + 1] === '\n') j++;
} else if (ch === quote) {
return j + 1;
} else if (isNewline(ch) && !multiline) {
return -1;
}
}
return -1;
}
function regexEnd(text, i) {
let inClass = false;
for (let j = i + 1; j < text.length; j++) {
const ch = text[j];
if (isNewline(ch)) return -1;
if (ch === '\\') {
if (j + 1 >= text.length || isNewline(text[j + 1])) return -1;
j++;
} else if (inClass) {
if (ch === ']') inClass = false;
} else if (ch === '[') {
inClass = true;
} else if (ch === '/') {
let k = j + 1;
while (isIdentChar(text[k])) k++;
return k;
}
}
return -1;
}
function nestedBlockEnd(text, i) {
let depth = 0;
for (let j = i; j + 1 < text.length; j++) {
if (text[j] === '/' && text[j + 1] === '*') {
depth++;
j++;
} else if (text[j] === '*' && text[j + 1] === '/') {
depth--;
j++;
if (depth === 0) return j + 1;
}
}
return -1;
}
function textBlockEnd(text, i, escapes) {
for (let j = i + 3; j < text.length; j++) {
if (escapes && text[j] === '\\') {
j++;
} else if (!escapes && text[j] === '$' && text[j + 1] === '{') {
return -1;
} else if (text.startsWith('"""', j)) {
let end = j + 3;
while (text[end] === '"') end++;
return end;
}
}
return -1;
}
function kotlinStringEnd(text, i) {
for (let j = i + 1; j < text.length; j++) {
const ch = text[j];
if (ch === '\\') j++;
else if (ch === '$' && text[j + 1] === '{') return -1;
else if (ch === '"') return j + 1;
else if (isNewline(ch)) return -1;
}
return -1;
}
function csharpStringEnd(text, i) {
let j = i;
let verbatim = false;
let interpolated = false;
while (text[j] === '@' || text[j] === '$') {
if (text[j] === '@') verbatim = true;
else interpolated = true;
j++;
}
if (text.startsWith('"""', j)) return -1;
for (let k = j + 1; k < text.length; k++) {
const ch = text[k];
if (ch === '"') {
if (verbatim && text[k + 1] === '"') {
k++;
continue;
}
return k + 1;
}
if (!verbatim && ch === '\\') {
k++;
} else if (!verbatim && isNewline(ch)) {
return -1;
} else if (interpolated && ch === '{') {
if (text[k + 1] === '{') {
k++;
continue;
}
let m = k + 1;
while (m < text.length && text[m] !== '}') {
if ('"\'{/\n\r'.includes(text[m])) return -1;
m++;
}
if (m >= text.length) return -1;
k = m;
}
}
return -1;
}
function cppRawEnd(text, quote) {
const open = text.indexOf('(', quote + 1);
if (open === -1 || open - quote - 1 > 16) return -1;
const delimiter = text.slice(quote + 1, open);
for (const ch of delimiter) {
if (isSpace(ch) || isNewline(ch) || '()\\"'.includes(ch)) return -1;
}
const end = text.indexOf(`)${delimiter}"`, open + 1);
return end === -1 ? -1 : end + delimiter.length + 2;
}
function rustRawEnd(text, i) {
let j = i + (text[i] === 'r' ? 1 : 2);
let hashes = '';
while (text[j] === '#') {
hashes += '#';
j++;
}
if (text[j] !== '"') return null;
const end = text.indexOf(`"${hashes}`, j + 1);
return end === -1 ? -1 : end + 1 + hashes.length;
}
function wordStart(text, i) {
let start = i;
while (start > 0 && isIdentChar(text[start - 1])) start--;
return start;
}
function cFamilyStringEnd(text, i, spec) {
const ch = text[i];
const flavor = spec.flavor;
if (ch === '`') {
if (flavor === 'kotlin') return quotedEnd(text, i, '`', false);
if (flavor !== 'go') return -1;
const end = text.indexOf('`', i + 1);
return end === -1 ? -1 : end + 1;
}
if (ch === "'") {
if (flavor === 'rust') {
const end = rustCharEnd(text, i);
return end === -1 ? null : end + 1;
}
if (flavor === 'c' && isIdentChar(text[i - 1]) && isIdentChar(text[i + 1])) {
const first = text[wordStart(text, i)];
if (first >= '0' && first <= '9') return null;
}
return quotedEnd(text, i, "'", false);
}
if (flavor === 'c') {
const start = wordStart(text, i);
const prefix = text.slice(start, i);
if (CPP_RAW_PREFIXES.has(prefix)) return cppRawEnd(text, i);
if (prefix.endsWith('R')) return -1;
}
if (flavor === 'java' && text.startsWith('"""', i)) return textBlockEnd(text, i, true);
if (flavor === 'kotlin') return text.startsWith('"""', i) ? textBlockEnd(text, i, false) : kotlinStringEnd(text, i);
if (flavor === 'csharp' && text.startsWith('"""', i)) return -1;
return quotedEnd(text, i, '"', flavor === 'rust');
}
function cFamilyStartsFresh(text, spec) {
const js = spec.flavor === 'js';
const frames = [{ template: false, depth: 0 }];
const parens = [];
let lastWord = '';
let lastSig = 'op';
let lineEnd = '';
let continued = false;
let i = js && text.startsWith('#!') ? lineEndAt(text, 0) : 0;
while (i < text.length) {
const frame = frames[frames.length - 1];
const ch = text[i];
const next = text[i + 1];
if (frame.template) {
if (ch === '\\') {
i += 2;
} else if (ch === '`') {
frames.pop();
lastSig = 'value';
i++;
} else if (ch === '$' && next === '{') {
frames.push({ template: false, depth: 0 });
lastSig = 'op';
i += 2;
} else {
i++;
}
continue;
}
if (isNewline(ch)) {
continued = lineEnd === '\\';
lineEnd = '';
i++;
continue;
}
if (isSpace(ch)) {
i++;
continue;
}
if (ch === '/' && next === '/') {
const end = lineEndAt(text, i);
if (text.slice(i, end).trimEnd().endsWith('\\')) return false;
i = end;
continue;
}
if (ch === '/' && next === '*') {
const end = spec.rustDocs ? nestedBlockEnd(text, i) : text.indexOf('*/', i + 2);
if (end === -1) return false;
i = spec.rustDocs ? end : end + 2;
lineEnd = '';
continue;
}
lineEnd = ch;
if (js) {
if (ch === '`') {
frames.push({ template: true, depth: 0 });
i++;
continue;
}
if (ch === '/') {
if (lastSig === 'op') {
const end = regexEnd(text, i);
if (end === -1) return false;
lineEnd = text[end - 1];
lastSig = 'value';
i = end;
continue;
}
if (lastSig === 'close' && /[/'"`]/.test(text.slice(i + 1, lineEndAt(text, i)))) return false;
lastSig = 'op';
i++;
continue;
}
if (ch === '<' && (next === '!' || (spec.jsx && (isLetter(next) || next === '/' || next === '>')))) return false;
if (ch === '-' && next === '-' && text[i + 2] === '>') return false;
if (frames.length > 1 && ch === '{') frame.depth++;
if (frames.length > 1 && ch === '}') {
if (frame.depth === 0) {
frames.pop();
i++;
continue;
}
frame.depth--;
}
}
if (spec.flavor === 'csharp' && (ch === '@' || ch === '$') && /^[@$]{1,3}"/.test(text.slice(i, i + 4))) {
const end = csharpStringEnd(text, i);
if (end === -1) return false;
lineEnd = '"';
i = end;
continue;
}
if (spec.flavor === 'rust' && (ch === 'r' || ((ch === 'b' || ch === 'c') && next === 'r')) && !isIdentChar(text[i - 1])) {
const end = rustRawEnd(text, i);
if (end === -1) return false;
if (end !== null) {
lineEnd = text[end - 1];
lastSig = 'value';
i = end;
continue;
}
}
if (ch === '"' || ch === "'" || ch === '`') {
const end = cFamilyStringEnd(text, i, spec);
if (end === -1) return false;
if (end !== null) {
lineEnd = text[end - 1];
lastSig = 'value';
i = end;
continue;
}
i++;
continue;
}
if (isIdentChar(ch)) {
let end = i;
while (end < text.length && isIdentChar(text[end])) end++;
lastWord = text.slice(i, end);
lastSig = js && JS_REGEX_KEYWORDS.has(lastWord) ? 'op' : 'value';
lineEnd = text[end - 1];
i = end;
continue;
}
if (ch === '(') {
parens.push(lastSig === 'value' && JS_CONTROL_HEADERS.has(lastWord));
lastSig = 'op';
} else if (ch === ')') {
lastSig = parens.pop() ? 'op' : 'value';
} else {
lastSig = ch === '}' ? 'close' : ch === ']' ? 'value' : 'op';
}
lastWord = '';
i++;
}
return frames.length === 1 && !continued;
}
function pythonStringEnd(text, i, prefix) {
const quote = text[i];
const triple = text[i + 1] === quote && text[i + 2] === quote;
const fString = prefix.toLowerCase().includes('f');
let depth = 0;
for (let j = i + (triple ? 3 : 1); j < text.length; j++) {
const ch = text[j];
if (fString && depth > 0) {
if (ch === '{') {
depth++;
} else if (ch === '}') {
depth--;
} else if ((ch === '"' || ch === "'") && ch !== quote) {
let k = j + 1;
while (k < text.length && text[k] !== ch) {
if (text[k] === quote || text[k] === '\\' || isNewline(text[k])) return -1;
k++;
}
if (k >= text.length) return -1;
j = k;
} else if (ch === quote || ch === '#' || ch === '\\' || isNewline(ch)) {
return -1;
}
continue;
}
if (ch === '\\') {
j++;
if (text[j] === '\r' && text[j + 1] === '\n') j++;
} else if (fString && ch === '{') {
if (text[j + 1] === '{') j++;
else depth = 1;
} else if (isNewline(ch) && !triple) {
return -1;
} else if (ch === quote && (!triple || (text[j + 1] === quote && text[j + 2] === quote))) {
return j + (triple ? 3 : 1);
}
}
return -1;
}
function pythonStartsFresh(text) {
let lineEnd = '';
let continued = false;
let i = 0;
while (i < text.length) {
const ch = text[i];
if (isNewline(ch)) {
continued = lineEnd === '\\';
lineEnd = '';
i++;
} else if (isSpace(ch)) {
i++;
} else if (ch === '#') {
i = lineEndAt(text, i);
} else if (ch === '"' || ch === "'") {
const start = wordStart(text, i);
const prefix = text.slice(start, i);
if (prefix.length > 2 || /[^rbuf]/i.test(prefix)) return false;
const end = pythonStringEnd(text, i, prefix);
if (end === -1) return false;
lineEnd = ch;
i = end;
} else {
lineEnd = ch;
i++;
}
}
return !continued;
}
function heredocDelimiter(text, i) {
let j = i + 2;
const stripTabs = text[j] === '-';
if (stripTabs) j++;
while (isBlank(text[j])) j++;
let word = '';
while (j < text.length && !isBlank(text[j]) && !isNewline(text[j]) && !';|&<>()'.includes(text[j])) {
if (!'\'"\\'.includes(text[j])) word += text[j];
j++;
}
return word ? { word, stripTabs, end: j } : null;
}
function skipHeredocs(text, i, pending) {
let at = i;
for (const doc of pending) {
for (;;) {
if (at >= text.length) return -1;
const end = lineEndAt(text, at);
let line = text.slice(at, end);
if (doc.stripTabs) line = line.replace(/^\t+/, '');
at = end + (text[end] === '\r' && text[end + 1] === '\n' ? 2 : 1);
if (line === doc.word) break;
}
}
return at;
}
const MAX_NESTING = 8;
function shSingleQuoteEnd(text, i) {
if (text[i - 1] === '$') return quotedEnd(text, i, "'", true);
return text.indexOf("'", i + 1) + 1 || -1;
}
function shSubstitutionEnd(text, i, level) {
if (level > MAX_NESTING) return -1;
let depth = 0;
for (let j = i + 1; j < text.length; j++) {
const ch = text[j];
if (ch === '\\') {
j++;
} else if (ch === '(') {
depth++;
} else if (ch === ')') {
depth--;
if (depth === 0) return j + 1;
} else if (ch === "'" || ch === '"') {
const end = ch === "'" ? shSingleQuoteEnd(text, j) : shDoubleQuoteScan(text, j, level + 1);
if (end === -1) return -1;
j = end - 1;
} else if (ch === '`' || (ch === '<' && text[j + 1] === '<') || (ch === '#' && hashStartsComment(text, j) !== false)) {
return -1;
} else if (ch === 'c' && text.startsWith('case', j) && !isIdentChar(text[j - 1]) && !isIdentChar(text[j + 4])) {
return -1;
}
}
return -1;
}
function shDoubleQuoteScan(text, i, level) {
for (let j = i + 1; j < text.length; j++) {
const ch = text[j];
if (ch === '"') return j + 1;
if (ch === '\\') {
j++;
} else if (ch === '`') {
return -1;
} else if (ch === '$' && text[j + 1] === '(') {
const end = shSubstitutionEnd(text, j, level);
if (end === -1) return -1;
j = end - 1;
} else if (ch === '$' && text[j + 1] === '{') {
const close = text.indexOf('}', j + 2);
if (close === -1 || /["'\n\r]/.test(text.slice(j + 2, close))) return -1;
j = close;
}
}
return -1;
}
function shStartsFresh(text) {
if (hasLoneCarriageReturn(text)) return false;
const pending = [];
let lineEnd = '';
let continued = false;
let i = 0;
while (i < text.length) {
const ch = text[i];
if (isNewline(ch)) {
continued = lineEnd === '\\';
lineEnd = '';
i += ch === '\r' && text[i + 1] === '\n' ? 2 : 1;
if (pending.length > 0) {
i = skipHeredocs(text, i, pending);
if (i === -1) return false;
pending.length = 0;
}
continue;
}
if (isBlank(ch)) {
i++;
continue;
}
lineEnd = ch;
if (ch === '\\') {
i += isNewline(text[i + 1]) ? 1 : 2;
} else if (ch === "'") {
const end = shSingleQuoteEnd(text, i);
if (end === -1) return false;
i = end;
} else if (ch === '"') {
const end = shDoubleQuoteScan(text, i, 0);
if (end === -1) return false;
i = end;
} else if (ch === '`') {
const end = text.indexOf('`', i + 1);
if (end === -1 || /['"\\#\n\r]/.test(text.slice(i + 1, end))) return false;
i = end + 1;
} else if (ch === '#') {
const starts = hashStartsComment(text, i);
if (starts === null) return false;
i = starts ? lineEndAt(text, i) : i + 1;
if (starts) lineEnd = '';
} else if (ch === '<' && text[i + 1] === '<') {
if (text[i + 2] === '<') {
i += 3;
continue;
}
const doc = heredocDelimiter(text, i);
if (!doc) return false;
pending.push(doc);
i = doc.end;
} else {
i++;
}
}
return !continued && pending.length === 0;
}
function psSingleQuoteEnd(text, i) {
for (let j = i + 1; j < text.length; j++) {
if (text[j] !== "'") continue;
if (text[j + 1] !== "'") return j + 1;
j++;
}
return -1;
}
function psSubexpressionEnd(text, i, level) {
if (level > MAX_NESTING) return -1;
let depth = 0;
for (let j = i + 1; j < text.length; j++) {
const ch = text[j];
if (ch === '`') {
j++;
} else if (ch === '(') {
depth++;
} else if (ch === ')') {
depth--;
if (depth === 0) return j + 1;
} else if (ch === "'" || ch === '"') {
const end = ch === "'" ? psSingleQuoteEnd(text, j) : psDoubleQuoteScan(text, j, level + 1);
if (end === -1) return -1;
j = end - 1;
} else if (ch === '#' || (ch === '@' && (text[j + 1] === '"' || text[j + 1] === "'"))) {
return -1;
}
}
return -1;
}
function psDoubleQuoteScan(text, i, level) {
for (let j = i + 1; j < text.length; j++) {
const ch = text[j];
if (ch === '`') {
j++;
} else if (ch === '$' && text[j + 1] === '(') {
const end = psSubexpressionEnd(text, j, level);
if (end === -1) return -1;
j = end - 1;
} else if (ch === '"') {
if (text[j + 1] !== '"') return j + 1;
j++;
}
}
return -1;
}
function psStartsFresh(text) {
if (PS_SMART_QUOTES.test(text)) return false;
let lineEnd = '';
let continued = false;
let i = 0;
while (i < text.length) {
const ch = text[i];
const next = text[i + 1];
if (isNewline(ch)) {
continued = lineEnd === '`';
lineEnd = '';
i++;
continue;
}
if (isBlank(ch)) {
i++;
continue;
}
lineEnd = ch;
if (ch === '`') {
i += isNewline(next) ? 1 : 2;
} else if (ch === '@' && (next === '"' || next === "'")) {
const open = lineEndAt(text, i);
if (text.slice(i + 2, open).trim() !== '') return false;
let at = open;
let found = -1;
while (at < text.length) {
at += text[at] === '\r' && text[at + 1] === '\n' ? 2 : 1;
if (text.startsWith(`${next}@`, at)) {
found = at + 2;
break;
}
at = lineEndAt(text, at);
}
if (found === -1) return false;
i = found;
} else if (ch === "'" || ch === '"') {
const end = ch === "'" ? psSingleQuoteEnd(text, i) : psDoubleQuoteScan(text, i, 0);
if (end === -1) return false;
i = end;
} else if (ch === '<' && next === '#') {
if (!psHashStartsComment(text, i)) return false;
const end = text.indexOf('#>', i + 2);
if (end === -1) return false;
i = end + 2;
lineEnd = '';
} else if (ch === '#') {
if (psHashStartsComment(text, i)) {
i = lineEndAt(text, i);
lineEnd = '';
} else if (isIdentChar(text[i - 1]) || '-.:'.includes(text[i - 1])) {
i++;
} else {
return false;
}
} else {
i++;
}
}
return !continued;
}
function batchStartsFresh(text) {
const lines = text.split(/\r\n|\r|\n/);
const last = lines.length > 1 ? lines[lines.length - 2] : '';
return !last.endsWith('^');
}
function lineStartOf(text, i) {
let start = i;
while (start > 0 && !isNewline(text[start - 1])) start--;
return start;
}
function windowStart(text, at) {
const start = lineStartOf(text, at);
if (start === 0) return 0;
let end = start - 1;
if (text[end] === '\n' && text[end - 1] === '\r') end--;
return lineStartOf(text, end);
}
function windowEnd(text, at) {
const end = lineEndAt(text, at);
if (end >= text.length) return text.length;
return end + (text[end] === '\r' && text[end + 1] === '\n' ? 2 : 1);
}
function occurrences(text, needle, replaceAll) {
const first = text.indexOf(needle);
if (first === -1) return null;
let last = first;
for (let at = text.indexOf(needle, first + needle.length); at !== -1; at = text.indexOf(needle, at + needle.length)) {
if (!replaceAll) return null;
last = at;
}
return { first, last };
}
function trivialInFile(pairs, fileText, spec) {
if (typeof fileText !== 'string' || fileText.length > MAX_FILE_CHARS || !spec.startsFresh) return false;
if (spec.flavor === 'go' && fileText.includes('"C"')) return false;
let text = fileText;
let work = 0;
for (const [oldString, newString, replaceAll] of pairs) {
if (!oldString || REPLACEMENT_PATTERN.test(newString)) return false;
const found = occurrences(text, oldString, replaceAll);
if (!found) return false;
const start = windowStart(text, found.first);
const end = windowEnd(text, found.last + oldString.length);
work += start + 2 * (end - start);
if (end - start > MAX_WINDOW_CHARS || work > MAX_CONTEXT_WORK) return false;
const before = text.slice(start, end);
const after = replaceAll
? before.split(oldString).join(newString)
: `${text.slice(start, found.first)}${newString}${text.slice(found.first + oldString.length, end)}`;
// see docs/gateguard/change-profile.md#file-context
if (!isTrivialEdit(before, after, spec) || !spec.startsFresh(text.slice(0, start), spec)) return false;
text = `${text.slice(0, start)}${after}${text.slice(end)}`;
if (text.length > MAX_FILE_CHARS) return false;
}
return true;
}
module.exports = {
MAX_FILE_CHARS,
lineEndAt,
lineStartOf,
cFamilyStartsFresh,
pythonStartsFresh,
shStartsFresh,
psStartsFresh,
batchStartsFresh,
trivialInFile
};
+133
View File
@@ -0,0 +1,133 @@
'use strict';
const crypto = require('crypto');
const fs = require('fs');
const path = require('path');
const METRICS_SCHEMA_VERSION = 1;
const METRICS_FILE_NAME = 'metrics.jsonl';
const METRICS_MAX_BYTES = 1024 * 1024;
const SESSION_DIGEST_LENGTH = 12;
const CODE_MAX_LENGTH = 48;
const CODE_PATTERN = /^[a-z][a-z0-9-]*(?::[a-z][a-z0-9-]*)?$/;
const SESSION_PATTERN = /^[0-9a-f]{12}$/;
const APPEND_FLAGS = fs.constants.O_WRONLY | fs.constants.O_APPEND | fs.constants.O_CREAT | (fs.constants.O_NOFOLLOW || 0);
const DECISIONS = Object.freeze([
'deny',
'credit',
'sibling',
'cap',
'trivial',
'pass-checked',
'pass-exempt',
'pass-subagent',
'routine-deny',
'routine-readonly',
'destructive-deny',
'pass'
]);
const DECISION_SET = new Set(DECISIONS);
const TOOLS = new Set(['Edit', 'Write', 'MultiEdit', 'NotebookEdit', 'Bash', 'PowerShell']);
function isCode(value) {
return typeof value === 'string' && value.length <= CODE_MAX_LENGTH && CODE_PATTERN.test(value);
}
function codeOrNull(value) {
return isCode(value) ? value : null;
}
function sessionDigest(sessionKey) {
return crypto.createHash('sha256').update(String(sessionKey || '')).digest('hex').slice(0, SESSION_DIGEST_LENGTH);
}
function profileFields(profile) {
if (!profile || typeof profile !== 'object') return null;
return {
known: profile.known === true,
language: codeOrNull(profile.language),
touchesPublicSurface: profile.touchesPublicSurface === true,
touchesData: profile.touchesData === true,
trivial: profile.trivial === true
};
}
/** One metrics line for a gate decision, or null when the decision or tool is not recorded. */
function metricsEvent({ sessionKey, tool, cls, decision, reason, questions, sensitive, profile, now = Date.now() }) {
if (!DECISION_SET.has(decision) || !TOOLS.has(tool)) return null;
return {
v: METRICS_SCHEMA_VERSION,
ts: new Date(now).toISOString(),
session: sessionDigest(sessionKey),
tool,
class: codeOrNull(cls),
decision,
reason: codeOrNull(reason),
questions: Array.isArray(questions) ? questions.filter(isCode) : null,
sensitive: sensitive === true,
profile: profileFields(profile)
};
}
/** Whether a parsed line has the shape `metricsEvent` writes. */
function isMetricsEvent(value) {
if (!value || typeof value !== 'object' || Array.isArray(value)) return false;
if (value.v !== METRICS_SCHEMA_VERSION || typeof value.ts !== 'string') return false;
if (typeof value.session !== 'string' || !SESSION_PATTERN.test(value.session)) return false;
if (!TOOLS.has(value.tool) || !DECISION_SET.has(value.decision)) return false;
if (value.class !== null && !isCode(value.class)) return false;
if (value.reason !== null && !isCode(value.reason)) return false;
if (value.questions !== null && !(Array.isArray(value.questions) && value.questions.every(isCode))) return false;
return typeof value.sensitive === 'boolean';
}
/** Append events to `<dir>/metrics.jsonl` in one write, rotating to `.1` past the size cap; never throws. */
function appendMetrics(dir, events) {
let fd = null;
try {
const lines = events
.filter(Boolean)
.map(event => `${JSON.stringify(event)}\n`)
.join('');
if (!lines) return false;
const file = path.join(dir, METRICS_FILE_NAME);
fs.mkdirSync(dir, { recursive: true });
let size = 0;
try {
const stat = fs.lstatSync(file);
if (!stat.isFile()) return false;
size = stat.size;
} catch (_) {
size = 0;
}
if (size > 0 && size + Buffer.byteLength(lines) > METRICS_MAX_BYTES) {
fs.renameSync(file, `${file}.1`);
}
fd = fs.openSync(file, APPEND_FLAGS, 0o600);
if (!fs.fstatSync(fd).isFile()) return false;
fs.writeSync(fd, lines);
return true;
} catch (_) {
return false;
} finally {
if (fd !== null) {
try {
fs.closeSync(fd);
} catch (_) {
/* ignore */
}
}
}
}
module.exports = {
DECISIONS,
METRICS_FILE_NAME,
METRICS_MAX_BYTES,
METRICS_SCHEMA_VERSION,
appendMetrics,
isMetricsEvent,
metricsEvent,
sessionDigest
};
+186
View File
@@ -0,0 +1,186 @@
'use strict';
// see docs/gateguard/design-notes.md#read-only-first-shell-command
const MAX_COMMAND_CHARS = 4096;
// --- character screen ---
const BASH_UNQUOTED_REJECT = new Set(['\\', '`', '$', '<', '>', '(', ')', '{', '}']);
const BASH_DOUBLE_QUOTED_REJECT = new Set(['\\', '`', '$']);
const BASH_SINGLE_QUOTED_REJECT = new Set(['\\']);
const PS_UNQUOTED_REJECT = new Set(['`', '$', '<', '>', '(', ')', '{', '}', '@', '[', ']']);
const PS_DOUBLE_QUOTED_REJECT = new Set(['`', '$']);
const PS_SINGLE_QUOTED_REJECT = new Set();
const DIALECTS = Object.freeze({
Bash: { unquoted: BASH_UNQUOTED_REJECT, double: BASH_DOUBLE_QUOTED_REJECT, single: BASH_SINGLE_QUOTED_REJECT },
PowerShell: { unquoted: PS_UNQUOTED_REJECT, double: PS_DOUBLE_QUOTED_REJECT, single: PS_SINGLE_QUOTED_REJECT }
});
function isPlainAscii(code) {
return code === 0x09 || (code >= 0x20 && code < 0x7f);
}
function passesCharacterScreen(command, dialect) {
let quote = null;
for (let index = 0; index < command.length; index++) {
const ch = command[index];
if (!isPlainAscii(ch.charCodeAt(0))) return false;
if (quote === "'") {
if (dialect.single.has(ch)) return false;
if (ch === "'") quote = null;
continue;
}
if (quote === '"') {
if (dialect.double.has(ch)) return false;
if (ch === '"') quote = null;
continue;
}
if (ch === "'" || ch === '"') {
quote = ch;
continue;
}
if (dialect.unquoted.has(ch)) return false;
if (ch === '&') {
if (command[index + 1] !== '&') return false;
index++;
}
}
return quote === null;
}
// --- per-command rules ---
const anyArgs = () => true;
const noArg = pattern => args => !args.some(arg => pattern.test(arg));
const FIND_ACTIONS = /^-(exec|ok|delete|fprint|fls)/;
const FD_EXEC = /^(--exec|-[^-]*[xX])/;
const RG_EXEC = /^(--(pre|hostname-bin|search-zip)|-[^-]*z)/;
const TREE_WRITE = /^(-[^-]*[oR]|--o)/;
const BASH_COMMANDS = new Map([
['ls', anyArgs],
['pwd', anyArgs],
['cat', anyArgs],
['head', anyArgs],
['tail', anyArgs],
['wc', anyArgs],
['grep', anyArgs],
['rg', noArg(RG_EXEC)],
['find', noArg(FIND_ACTIONS)],
['fd', noArg(FD_EXEC)],
['tree', noArg(TREE_WRITE)],
['git', isReadOnlyGit]
]);
const POWERSHELL_COMMANDS = new Map([
['get-childitem', anyArgs],
['gci', anyArgs],
['dir', anyArgs],
['ls', anyArgs],
['get-content', anyArgs],
['gc', anyArgs],
['cat', anyArgs],
['type', anyArgs],
['select-string', anyArgs],
['sls', anyArgs],
['get-location', anyArgs],
['gl', anyArgs],
['pwd', anyArgs],
['rg', noArg(RG_EXEC)],
['git', isReadOnlyGit]
]);
// --- git ---
const GIT_LOG_VALUE_FLAGS = /^--(max-count=\d+|since=.*|until=.*|author=.*|grep=.*|format=.*|pretty=.*|date=.*)$/;
const GIT_DIFF_VALUE_FLAGS = /^(-U\d+|--unified=\d+)$/;
const GIT_SUBCOMMANDS = new Map([
['status', { positional: true, flags: ['--short', '-s', '--branch', '-b', '--porcelain', '--porcelain=v1', '--porcelain=v2', '-uno', '-unormal', '-uall'] }],
[
'log',
{
positional: true,
flags: ['--oneline', '--stat', '--name-only', '--name-status', '--graph', '--decorate', '--all', '-p', '--patch', '--no-color', '--reverse', '--first-parent', '--no-merges', '--merges', '--follow', '--abbrev-commit', '-n'],
pattern: value => /^-\d+$/.test(value) || GIT_LOG_VALUE_FLAGS.test(value)
}
],
[
'diff',
{
positional: true,
flags: ['--name-only', '--name-status', '--cached', '--staged', '--stat', '--numstat', '--shortstat', '--no-color', '-w', '--ignore-all-space', '--word-diff', '--merge-base', '--check'],
pattern: value => GIT_DIFF_VALUE_FLAGS.test(value)
}
],
[
'show',
{
positional: true,
flags: ['--stat', '--name-only', '--name-status', '--oneline', '--no-color', '-p', '--patch', '--no-patch', '-s'],
pattern: value => /^--(format|pretty)=/.test(value)
}
],
['ls-files', { positional: true, flags: ['--others', '-o', '--exclude-standard', '--cached', '-c', '--modified', '-m', '--deleted', '-d', '--stage', '-s'] }],
['rev-parse', { positional: true, flags: ['--abbrev-ref', '--show-toplevel', '--git-dir', '--short', '--verify', '--is-inside-work-tree', '--show-prefix', '--symbolic-full-name'] }],
['branch', { positional: false, flags: ['--show-current', '-a', '--all', '-r', '--remotes', '--list', '-v', '-vv', '--no-color'] }]
]);
function isReadOnlyGit(args) {
if (args.length === 0) return false;
const rule = GIT_SUBCOMMANDS.get(args[0]);
if (!rule) return false;
let pathspec = false;
for (const arg of args.slice(1)) {
if (pathspec) continue;
if (arg === '--') {
if (!rule.positional) return false;
pathspec = true;
continue;
}
if (arg.startsWith('-')) {
if (rule.flags.includes(arg) || (rule.pattern && rule.pattern(arg))) continue;
return false;
}
if (!rule.positional) return false;
}
return true;
}
// --- entry ---
function createReadOnlyShell({ quoteAwareSegments }) {
function segmentsFor(command, toolName) {
return quoteAwareSegments(toolName === 'PowerShell' ? command.replace(/\\/g, '\\\\') : command);
}
function isReadOnlySegment(words, toolName) {
const [name, ...args] = words;
if (!name || /[=/\\*?[]/.test(name)) return false;
const table = toolName === 'PowerShell' ? POWERSHELL_COMMANDS : BASH_COMMANDS;
const rule = table.get(toolName === 'PowerShell' ? name.toLowerCase() : name);
if (!rule) return false;
if (toolName === 'PowerShell' && args.includes('--%')) return false;
return rule(args);
}
/** True only when every segment of the command is allowlisted read-only introspection. */
function isReadOnlyShellCommand(toolName, command) {
try {
const dialect = Object.hasOwn(DIALECTS, toolName) ? DIALECTS[toolName] : null;
if (!dialect || typeof command !== 'string' || command.length > MAX_COMMAND_CHARS) return false;
if (!passesCharacterScreen(command, dialect)) return false;
const segments = segmentsFor(command, toolName);
return segments.length > 0 && segments.every(words => isReadOnlySegment(words, toolName));
} catch (_) {
return false;
}
}
return { isReadOnlyShellCommand };
}
module.exports = { createReadOnlyShell };
+534
View File
@@ -0,0 +1,534 @@
'use strict';
const fs = require('fs');
const path = require('path');
const { WINDOWS_PATH_PATTERN, classifyTargetFor } = require('./gateguard-target-class');
const { excludedBatchId } = require('./gateguard-turn-scan');
const {
POWERSHELL_SEARCHES,
isInsideDir,
valueFlagsFor,
powershellFilterArg,
grepToolFilters,
shellSearchFilters,
filtersAdmitTarget,
includesAdmitTarget
} = require('./gateguard-search-filters');
// --- Search commands and flags ---
const SHELL_SEARCH_COMMANDS = new Set([
'rg',
'grep',
'egrep',
'fgrep',
'git',
'find',
'fd',
'ls',
'tree',
'get-childitem',
'gci',
'select-string',
'sls'
]);
const GIT_SEARCH_SUBCOMMANDS = new Set(['grep', 'ls-files']);
const DIRECTORY_CHANGE_COMMANDS = new Set(['cd', 'pushd', 'popd', 'chdir', 'set-location', 'sl', 'push-location', 'pop-location']);
const PATTERN_FIRST_SEARCHES = new Set(['grep', 'egrep', 'fgrep', 'rg', 'git grep', 'select-string', 'sls', 'fd']);
const RECURSIVE_BY_DEFAULT = new Set(['rg', 'git grep', 'git ls-files', 'find', 'fd', 'tree']);
const GREP_FAMILY = new Set(['grep', 'egrep', 'fgrep']);
const PATTERN_VALUE_FLAGS = new Set(['-e', '-f', '--regexp', '--file']);
const PATTERN_FLAG_SEARCHES = new Set(['grep', 'egrep', 'fgrep', 'rg', 'git grep']);
const POWERSHELL_VALUE_FLAGS = new Set(['-path', '-literalpath', '-depth', '-pattern']);
const POWERSHELL_PATH_FLAGS = new Set(['-path', '-literalpath']);
const GENERIC_STEMS = new Set([
'index',
'main',
'init',
'__init__',
'utils',
'util',
'types',
'readme',
'test',
'tests',
'config',
'mod',
'lib',
'setup',
'app',
'spec',
'helpers',
'common'
]);
const MIN_STEM_LENGTH = 4;
const MAX_SEARCH_COMMAND_CHARS = 8192;
// see docs/gateguard/design-notes.md#ambiguous-shell-is-not-evidence
const AMBIGUOUS_SHELL_PATTERN = /`|\$\(|[<>]\(|<</;
const GLOB_CHARS_PATTERN = /[*?[{]/;
// see docs/gateguard/design-notes.md#test-stems
function bareStem(filePath, data) {
const base = (String(filePath).split(/[\\/]/).pop() || '').toLowerCase();
const ext = path.posix.extname(base);
const stem = ext ? base.slice(0, -ext.length) : base;
if (classifyTargetFor(filePath, data) !== 'test') return stem;
if (stem.endsWith('.test') || stem.endsWith('.spec')) return stem.slice(0, -5);
if (ext === '.py' && stem.startsWith('test_')) return stem.slice(5);
if ((ext === '.py' || ext === '.go') && stem.endsWith('_test')) return stem.slice(0, -5);
return stem;
}
function eligibleStem(filePath, data) {
const stem = bareStem(filePath, data);
return stem.length >= MIN_STEM_LENGTH && !GENERIC_STEMS.has(stem) ? stem : null;
}
// see docs/gateguard/design-notes.md#search-prefilter
function inputMayMention(input, word) {
if (!input || typeof input !== 'object') return false;
let text = '';
for (const value of Object.values(input)) {
if (typeof value === 'string') text += `\n${value}`;
}
return text.toLowerCase().replace(/["'\\]/g, '').includes(word);
}
// see docs/gateguard/design-notes.md#stem-matching
function stemMatcher(stem) {
if (!stem) return null;
return { test: text => containsWord(String(text), stem) };
}
function isStemWordChar(ch) {
return ch !== undefined && ((ch >= 'a' && ch <= 'z') || (ch >= '0' && ch <= '9'));
}
function containsWord(text, word) {
for (let at = text.indexOf(word); at !== -1; at = text.indexOf(word, at + 1)) {
if (!isStemWordChar(text[at - 1]) && !isStemWordChar(text[at + word.length])) return true;
}
return false;
}
function dirContext(targetPath, data) {
const base = (data && typeof data.cwd === 'string' && data.cwd) || process.env.CLAUDE_PROJECT_DIR || process.cwd();
const isWin = WINDOWS_PATH_PATTERN.test(base) || WINDOWS_PATH_PATTERN.test(targetPath);
const paths = isWin ? path.win32 : path.posix;
if (!paths.isAbsolute(base)) return null;
const resolveNative = (...parts) => paths.resolve(base, ...parts);
const resolveDir = (...parts) => {
const dir = resolveNative(...parts).replace(/\\/g, '/');
return isWin ? dir.toLowerCase() : dir;
};
const targetKey = resolveDir(targetPath);
return { resolveDir, resolveNative, targetKey, targetDir: path.posix.dirname(targetKey) };
}
function isDirectoryNow(nativePath) {
try {
return fs.statSync(nativePath).isDirectory();
} catch (_) {
return false;
}
}
function globLiteralPrefix(pattern) {
const segments = pattern.split(/[\\/]/);
const firstGlob = segments.findIndex(segment => GLOB_CHARS_PATTERN.test(segment));
const literal = firstGlob === -1 ? segments.slice(0, -1) : segments.slice(0, firstGlob);
const joined = literal.join('/');
return joined || (literal.length > 0 && pattern.startsWith('/') ? '/' : '');
}
function stringsOf(...values) {
return values.filter(value => typeof value === 'string' && value);
}
function baseAfterDirectoryChange(base, lead, tokens) {
if (!['cd', 'chdir', 'set-location', 'sl'].includes(lead) || tokens.length !== 2) return null;
const dir = tokens[1];
if (!dir || /^[-$~]/.test(dir) || GLOB_CHARS_PATTERN.test(dir)) return null;
if (isAbsoluteShellPath(dir)) return [dir];
return base === null ? null : base.concat(dir);
}
function isAbsoluteShellPath(arg) {
return path.posix.isAbsolute(arg) || WINDOWS_PATH_PATTERN.test(arg);
}
function operandScopePath(arg) {
if (!GLOB_CHARS_PATTERN.test(arg)) return arg;
const segments = arg.split(/[\\/]/);
const literal = segments.slice(0, segments.findIndex(segment => GLOB_CHARS_PATTERN.test(segment)));
if (literal.length === 0) return '.';
return literal.join('/') || '/';
}
function shellSearchScopes(parsed, base) {
if (parsed.operands.length === 0) return base === null ? [] : [base];
const scopes = [];
for (const operand of parsed.operands) {
if (!operand || operand === '-' || /^[$~]/.test(operand)) continue;
const scoped = operandScopePath(operand);
if (isAbsoluteShellPath(scoped)) scopes.push([scoped]);
else if (base !== null) scopes.push(base.concat(scoped));
}
return scopes;
}
function isRecursiveFlag(kind, arg) {
const lower = arg.toLowerCase();
if (POWERSHELL_SEARCHES.has(kind)) return (kind === 'get-childitem' || kind === 'gci') && lower.startsWith('-rec');
if (arg === '--recursive' || arg === '--dereference-recursive') return GREP_FAMILY.has(kind) || kind === 'ls';
if (!/^-[a-zA-Z]+$/.test(arg)) return false;
if (GREP_FAMILY.has(kind)) return /[rR]/.test(arg);
return kind === 'ls' && arg.includes('R');
}
function flagEffect(kind, args, i) {
const arg = args[i];
if (POWERSHELL_SEARCHES.has(kind)) {
const lower = arg.toLowerCase();
const filter = powershellFilterArg(kind, args, i);
const consumes = filter ? filter.end - i : POWERSHELL_VALUE_FLAGS.has(lower) ? 1 : 0;
return { consumes, patternFlag: lower === '-pattern', pathFlag: POWERSHELL_PATH_FLAGS.has(lower) };
}
const values = valueFlagsFor(kind);
const takesValue = flag => values.has(flag) || (kind === 'rg' && (flag === '-r' || flag === '--replace'));
const isPatternFlag = flag => PATTERN_FLAG_SEARCHES.has(kind) && PATTERN_VALUE_FLAGS.has(flag);
const name = arg.split('=')[0];
if (arg.startsWith('--') || kind === 'find' || takesValue(arg)) {
return { consumes: !arg.includes('=') && takesValue(name) ? 1 : 0, patternFlag: isPatternFlag(name), pathFlag: false };
}
for (let i = 1; i < arg.length; i++) {
const flag = `-${arg[i]}`;
if (takesValue(flag)) return { consumes: i === arg.length - 1 ? 1 : 0, patternFlag: isPatternFlag(flag), pathFlag: false };
}
return { consumes: 0, patternFlag: false, pathFlag: false };
}
function parseSearchArgs(kind, args) {
const positionals = [];
const pathValues = [];
let recursive = RECURSIVE_BY_DEFAULT.has(kind);
let patternGiven = false;
let expressionStarted = false;
let inputRedirect = false;
for (let i = 0; i < args.length; i++) {
const arg = args[i];
if (/^\d*[<>]/.test(arg)) {
inputRedirect = /^\d*</.test(arg);
break;
}
if (arg.includes('<')) {
inputRedirect = true;
break;
}
if (arg.length > 1 && arg.startsWith('-')) {
expressionStarted = true;
if (isRecursiveFlag(kind, arg)) recursive = true;
const effect = flagEffect(kind, args, i);
if (effect.patternFlag) patternGiven = true;
if (effect.pathFlag && i + 1 < args.length) pathValues.push(args[i + 1]);
i += effect.consumes;
continue;
}
if (kind === 'find' && (expressionStarted || arg === '(' || arg === '!')) {
expressionStarted = true;
continue;
}
positionals.push(arg);
}
const dirOperands = PATTERN_FIRST_SEARCHES.has(kind) && !patternGiven ? positionals.slice(1) : positionals;
const operands = dirOperands.concat(pathValues);
return { operands, dirOperands, recursive, stdin: inputRedirect && operands.length === 0 };
}
function shellDirCandidate(arg) {
if (!arg || /^[$~]/.test(arg)) return '';
const dir = arg.replace(/(?:[\\/]\*\*|[\\/]\*|[\\/])+$/, '');
if (!dir || dir === '.' || GLOB_CHARS_PATTERN.test(dir)) return '';
return dir;
}
function evidenceInScope(item, ctx) {
if (item.scopes) {
const covers = parts => {
const scope = ctx.resolveDir(...parts);
return scope === ctx.targetKey || isInsideDir(ctx.targetKey, scope);
};
if (!item.scopes.some(covers)) return false;
}
if (item.scope) {
const scope = ctx.resolveDir(...item.scope);
if (scope === ctx.targetKey || !isInsideDir(ctx.targetKey, scope)) return false;
if (item.scopeExact && ctx.targetDir !== scope) return false;
}
// see docs/gateguard/design-notes.md#search-scope
if (item.operands && item.operands.length > 0 && item.operands.every(parts => ctx.resolveDir(...parts) === ctx.targetKey)) {
return false;
}
return true;
}
function evidenceNamesDir(item, ctx) {
return item.dirs.some(
parts => ctx.resolveDir(...parts) === ctx.targetDir && (!item.dirsMustExist || isDirectoryNow(ctx.resolveNative(...parts)))
);
}
// --- Prior-search credit ---
const MIN_GENERIC_MISS_STEM = 3;
const MISS_RANK = Object.freeze({ 'same-batch': 0, excluded: 1, 'out-of-scope': 2, 'stdin-only': 3, 'not-a-search': 4, 'generic-stem': 5 });
/** Build the prior-search matcher around the hook's shell segmenter. */
function createSearchEvidence({ quoteAwareSegments, commandBasename, SHELL_SEGMENT_SEPARATORS }) {
function searchEvidence(search, shellDirsTrusted, misses) {
const input = search.input;
if (search.name === 'Glob') {
if (typeof input.pattern !== 'string' || !input.pattern) return [];
const explicit = typeof input.path === 'string' && input.path ? input.path : '';
const prefix = globLiteralPrefix(input.pattern);
const scope = [explicit || '.', prefix || '.'];
if (!GLOB_CHARS_PATTERN.test(input.pattern)) {
const lookup = [explicit || '.', input.pattern];
return [{ texts: [input.pattern], dirs: [], scope, scopeExact: true, operands: [lookup], detail: input.pattern }];
}
const dirs = prefix || explicit ? [scope] : [];
return [{ texts: [input.pattern], dirs, scope, detail: input.pattern }];
}
if (search.name === 'Grep') {
if (typeof input.pattern !== 'string' || !input.pattern) return [];
const explicit = typeof input.path === 'string' && input.path ? input.path : '';
const detail = typeof input.glob === 'string' && input.glob ? `${input.pattern} --glob ${input.glob}` : input.pattern;
const filters = grepToolFilters(input.glob);
return [{
texts: stringsOf(input.pattern, ...filters.positives),
dirs: explicit ? [[explicit]] : [],
scope: [explicit || '.'],
detail,
pathIncludes: true,
...filters
}];
}
if (search.name === 'LS') {
return stringsOf(input.path).map(dir => ({ texts: [], dirs: [[dir]], scope: [dir], detail: dir }));
}
return shellSearchEvidence(input.command, shellDirsTrusted, misses);
}
function segmentPipeFlags(input) {
const flags = [];
let quote = null;
let escaped = false;
let hasContent = false;
let piped = false;
for (const ch of String(input || '')) {
if (escaped) {
escaped = false;
} else if (ch === '\\') {
escaped = true;
hasContent = true;
} else if (quote) {
if (ch === quote) quote = null;
} else if (ch === '"' || ch === "'") {
quote = ch;
hasContent = true;
} else if (SHELL_SEGMENT_SEPARATORS.has(ch)) {
if (hasContent) {
flags.push(piped);
piped = false;
hasContent = false;
}
piped = piped || ch === '|';
} else if (!/\s/.test(ch)) {
hasContent = true;
}
}
if (hasContent) flags.push(piped);
return flags;
}
function shellSearchEvidence(command, dirsTrusted, misses) {
if (typeof command !== 'string' || !command || command.length > MAX_SEARCH_COMMAND_CHARS) return [];
if (AMBIGUOUS_SHELL_PATTERN.test(command)) return [];
const pipeFlags = segmentPipeFlags(command);
const evidence = [];
let trusted = dirsTrusted;
// see docs/gateguard/design-notes.md#directory-changes
let base = dirsTrusted ? [] : null;
quoteAwareSegments(command).forEach((tokens, index) => {
const lead = commandBasename(tokens[0]);
if (DIRECTORY_CHANGE_COMMANDS.has(lead)) {
trusted = false;
base = baseAfterDirectoryChange(base, lead, tokens);
return;
}
const miss = reason => {
if (misses) misses.push({ texts: [tokens.join(' ')], detail: tokens.join(' '), reason });
};
if (!SHELL_SEARCH_COMMANDS.has(lead) || (lead === 'git' && !GIT_SEARCH_SUBCOMMANDS.has(tokens[1]))) {
miss('not-a-search');
return;
}
const kind = lead === 'git' ? `git ${tokens[1]}` : lead;
const args = tokens.slice(lead === 'git' ? 2 : 1);
const parsed = parseSearchArgs(kind, args);
// see docs/gateguard/design-notes.md#stdin-is-not-a-tree-search
if (parsed.stdin || (parsed.operands.length === 0 && (!parsed.recursive || pipeFlags[index] !== false))) {
miss('stdin-only');
return;
}
const scopes = shellSearchScopes(parsed, base);
if (scopes.length === 0) return;
const filters = shellSearchFilters(kind, args);
// see docs/gateguard/design-notes.md#search-filters
const stemText = tokens.slice(0, tokens.length - args.length).concat(args.filter((_, i) => !filters.dropped.has(i))).join(' ');
const dirs = trusted ? parsed.dirOperands.map(shellDirCandidate).filter(Boolean).map(dir => [dir]) : [];
evidence.push({
texts: [stemText],
dirs,
dirsMustExist: true,
scopes,
operands: parsed.operands.map(op => [op]),
detail: tokens.join(' '),
exclusions: filters.exclusions,
includes: filters.includes,
pathIncludes: kind === 'rg',
opaque: filters.opaque
});
});
return evidence;
}
const evidenceCache = new WeakMap();
const trustCache = new WeakMap();
function cachedEvidence(search, shellDirsTrusted) {
if (!search || typeof search !== 'object') return { items: [], misses: [] };
let byTrust = evidenceCache.get(search);
if (!byTrust) {
byTrust = new Map();
evidenceCache.set(search, byTrust);
}
let entry = byTrust.get(shellDirsTrusted);
if (!entry) {
const misses = [];
entry = { items: searchEvidence(search, shellDirsTrusted, misses), misses };
byTrust.set(shellDirsTrusted, entry);
}
return entry;
}
function shellDirsTrustedFor(scan) {
if (!trustCache.has(scan)) trustCache.set(scan, !turnChangesDirectory(scan.shellCommands));
return trustCache.get(scan);
}
function turnChangesDirectory(shellCommands) {
if (!Array.isArray(shellCommands)) return false;
return shellCommands.some(command => {
if (typeof command !== 'string') return false;
if (command.length > MAX_SEARCH_COMMAND_CHARS) return true;
return quoteAwareSegments(command).some(tokens => DIRECTORY_CHANGE_COMMANDS.has(commandBasename(tokens[0])));
});
}
/** First completed search of the current turn that credits the target, else null. */
function findCreditingSearch(scan, targetPath, allowDirMatch, data) {
try {
if (!scan || !Array.isArray(scan.searches) || scan.searches.length === 0) return null;
if (typeof targetPath !== 'string' || !targetPath) return null;
const ctx = dirContext(targetPath, data);
if (!ctx) return null;
const eligible = eligibleStem(targetPath, data);
if (eligible === null && !allowDirMatch) return null;
const stem = stemMatcher(eligible);
// see docs/gateguard/design-notes.md#same-batch-searches
const excluded = excludedBatchId(scan, data);
let shellDirsTrusted = null;
for (const search of scan.searches) {
if (typeof search.messageId !== 'string' || !search.messageId || search.messageId === excluded) continue;
if (!allowDirMatch && !inputMayMention(search.input, eligible)) continue;
if (shellDirsTrusted === null) shellDirsTrusted = shellDirsTrustedFor(scan);
for (const item of cachedEvidence(search, shellDirsTrusted).items) {
if (!evidenceInScope(item, ctx) || !filtersAdmitTarget(item, ctx, stem)) continue;
const byStem = stem !== null && includesAdmitTarget(item, ctx) && item.texts.some(text => stem.test(text.toLowerCase()));
const byDir = Boolean(allowDirMatch) && evidenceNamesDir(item, ctx);
if (byStem || byDir) {
return { name: search.name, detail: item.detail, callsAgo: search.callsAgo };
}
}
}
return null;
} catch (_) {
return null;
}
}
// --- Closest non-qualifying search ---
// see docs/gateguard/design-notes.md#closest-search-that-did-not-count
function mentionsTarget(texts, stem, rawStem) {
const matcher = stem || rawStem;
return Boolean(matcher) && texts.some(text => typeof text === 'string' && matcher.test(text.toLowerCase()));
}
function itemMiss(item, ctx, stem, rawStem, allowDirMatch, sameBatch) {
if (stem === null) return mentionsTarget(item.texts, null, rawStem) ? 'generic-stem' : null;
const byDir = Boolean(allowDirMatch) && evidenceNamesDir(item, ctx);
const mentioned = mentionsTarget(item.texts, stem, null);
if (!mentioned && !byDir) return null;
const inScope = evidenceInScope(item, ctx);
const admitted = filtersAdmitTarget(item, ctx, stem);
const included = includesAdmitTarget(item, ctx);
if (inScope && admitted && (byDir || included)) return sameBatch ? 'same-batch' : null;
if (!inScope) return 'out-of-scope';
return 'excluded';
}
/** Closest search of the current turn that did not credit the target, with a reason code, else null. */
function findClosestMiss(scan, targetPath, allowDirMatch, data) {
try {
if (!scan || !Array.isArray(scan.searches) || typeof targetPath !== 'string' || !targetPath) return null;
const ctx = dirContext(targetPath, data);
if (!ctx) return null;
const eligible = eligibleStem(targetPath, data);
const stem = stemMatcher(eligible);
const bare = bareStem(targetPath, data);
const rawStem = eligible === null && bare.length >= MIN_GENERIC_MISS_STEM ? stemMatcher(bare) : null;
const excluded = excludedBatchId(scan, data);
let shellDirsTrusted = null;
let best = null;
const consider = (name, detail, reason) => {
if (reason && (best === null || MISS_RANK[reason] < MISS_RANK[best.reason])) best = { name, detail, reason };
};
for (const search of scan.searches) {
if (!search || typeof search.messageId !== 'string' || !search.messageId) continue;
if (!allowDirMatch && (!bare || !inputMayMention(search.input, bare))) continue;
if (shellDirsTrusted === null) shellDirsTrusted = shellDirsTrustedFor(scan);
const { items, misses } = cachedEvidence(search, shellDirsTrusted);
for (const item of items) {
consider(search.name, item.detail, itemMiss(item, ctx, stem, rawStem, allowDirMatch, search.messageId === excluded));
}
for (const miss of misses) {
if (mentionsTarget(miss.texts, stem, rawStem)) consider(search.name, miss.detail, stem === null ? 'generic-stem' : miss.reason);
}
}
for (const read of Array.isArray(scan.reads) ? scan.reads : []) {
if (!read || typeof read.path !== 'string' || !read.path) continue;
const readsTarget = ctx.resolveDir(read.path) === ctx.targetKey || mentionsTarget([read.path], stem, null);
if (readsTarget) consider('Read', read.path, 'not-a-search');
}
return best;
} catch (_) {
return null;
}
}
return { findCreditingSearch, findClosestMiss };
}
module.exports = { createSearchEvidence };
+459
View File
@@ -0,0 +1,459 @@
'use strict';
// --- Search flags ---
const POWERSHELL_SEARCHES = new Set(['get-childitem', 'gci', 'select-string', 'sls']);
const VALUE_FLAGS = new Set([
'-e', '-f', '-g', '-t', '-T', '-m', '-A', '-B', '-C',
'--regexp', '--file', '--type', '--glob', '--iglob', '--include', '--exclude', '--exclude-dir', '--exclude-from',
'--ignore-file', '--max-count', '--replace',
'-name', '-iname', '-path', '-ipath', '-type', '-maxdepth', '-mindepth', '-newer', '-regex', '-size', '-user', '-group', '-perm'
]);
const LS_VALUE_FLAGS = new Set(['-I', '--ignore', '--hide']);
const TREE_VALUE_FLAGS = new Set(['-L', '-P', '-I', '-o']);
const FD_VALUE_FLAGS = new Set([...VALUE_FLAGS, '-E']);
const POWERSHELL_FILTER_PARAMS = new Set(['filter', 'include', 'exclude']);
const POWERSHELL_COMMON_PARAMS = [
'verbose', 'debug', 'erroraction', 'warningaction', 'informationaction', 'progressaction',
'errorvariable', 'warningvariable', 'informationvariable', 'outvariable', 'outbuffer', 'pipelinevariable'
];
const POWERSHELL_CHILDITEM_PARAMS = [
'path', 'literalpath', 'filter', 'include', 'exclude', 'recurse', 'depth', 'force', 'name',
'attributes', 'directory', 'file', 'hidden', 'readonly', 'system', 'followsymlink'
];
const POWERSHELL_SELECTSTRING_PARAMS = [
'pattern', 'path', 'literalpath', 'inputobject', 'simplematch', 'casesensitive', 'quiet', 'list', 'noemphasis',
'include', 'exclude', 'notmatch', 'allmatches', 'encoding', 'context', 'raw', 'culture'
];
const POWERSHELL_PARAM_ALIASES = new Set([
's', 'ad', 'd', 'af', 'ah', 'h', 'ar', 'as', 'lp', 'pspath',
'vb', 'db', 'ea', 'wa', 'infa', 'proga', 'ev', 'wv', 'iv', 'ov', 'ob', 'pv'
]);
function isInsideDir(targetKey, dirKey) {
return targetKey.startsWith(dirKey.endsWith('/') ? dirKey : `${dirKey}/`);
}
function valueFlagsFor(kind) {
if (kind === 'ls' || kind === 'git ls-files') return LS_VALUE_FLAGS;
if (kind === 'tree') return TREE_VALUE_FLAGS;
if (kind === 'fd') return FD_VALUE_FLAGS;
return VALUE_FLAGS;
}
// --- Search filters ---
// see docs/gateguard/design-notes.md#search-filters
const MAX_FILTER_GLOB_CHARS = 256;
const MAX_BRACE_ALTERNATIVES = 32;
const MAX_FILTERS_PER_SEARCH = 32;
const FIND_NAME_FLAGS = new Set(['-name', '-iname', '-path', '-ipath', '-wholename', '-iwholename', '-regex', '-iregex']);
const FIND_BASENAME_FLAGS = new Set(['-name', '-iname']);
const OPAQUE_FILTER_FLAGS = new Set(['--exclude-from', '--ignore-file', '--exclude-per-directory']);
const EXCLUDE_LONG_FLAGS = new Set(['--exclude', '--exclude-dir', '--ignore', '--hide']);
const LS_ONLY_EXCLUDE_FLAGS = new Set(['--ignore', '--hide']);
const GIT_EXCLUDE_PATHSPEC = /^:(?:[!^]|\([^)]*\bexclude\b[^)]*\))/;
function splitTopLevelCommas(value) {
const parts = [];
let depth = 0;
let current = '';
for (const ch of String(value)) {
if (ch === '{') depth += 1;
else if (ch === '}' && depth > 0) depth -= 1;
if (ch === ',' && depth === 0) {
parts.push(current);
current = '';
} else {
current += ch;
}
}
parts.push(current);
return parts.map(part => part.trim()).filter(Boolean);
}
function expandBraces(glob) {
const open = glob.indexOf('{');
if (open === -1) return [glob];
let depth = 0;
for (let i = open; i < glob.length; i++) {
if (glob[i] === '{') depth += 1;
else if (glob[i] === '}' && --depth === 0) {
const head = glob.slice(0, open);
const tail = glob.slice(i + 1);
const results = [];
for (const option of splitTopLevelCommas(glob.slice(open + 1, i)).concat(glob[open + 1] === ',' ? [''] : [])) {
const expanded = expandBraces(`${head}${option}${tail}`);
if (!expanded) return null;
results.push(...expanded);
if (results.length > MAX_BRACE_ALTERNATIVES) return null;
}
return results;
}
}
return [glob];
}
function globTokens(glob) {
const tokens = [];
for (let i = 0; i < glob.length; i++) {
const ch = glob[i];
if (ch === '*' && glob[i + 1] === '*') {
const dirs = glob[i + 2] === '/';
tokens.push({ type: dirs ? 'dirs' : 'deep' });
i += dirs ? 2 : 1;
} else if (ch === '*') {
tokens.push({ type: 'star' });
} else if (ch === '?') {
tokens.push({ type: 'one' });
} else if (ch === '[') {
const parsed = bracketClass(glob, i);
if (!parsed) return null;
tokens.push(parsed.token);
i = parsed.end;
} else {
tokens.push({ type: 'lit', ch });
}
}
return tokens;
}
function bracketClass(glob, open) {
let i = open + 1;
const negated = glob[i] === '!' || glob[i] === '^';
if (negated) i += 1;
const ranges = [];
for (let first = true; i < glob.length && (first || glob[i] !== ']'); first = false) {
if (glob[i + 1] === '-' && i + 2 < glob.length && glob[i + 2] !== ']') {
ranges.push([glob[i], glob[i + 2]]);
i += 3;
} else {
ranges.push([glob[i], glob[i]]);
i += 1;
}
}
if (i >= glob.length) return null;
return { token: { type: 'class', negated, ranges }, end: i };
}
function classMatches(token, ch) {
if (ch === '/') return false;
const forms = [ch, ch.toLowerCase(), ch.toUpperCase()];
const member = token.ranges.some(([lo, hi]) => forms.some(form => form >= lo && form <= hi));
return member !== token.negated;
}
// see docs/gateguard/design-notes.md#glob-matching-without-regexp
function wildcardMatch(glob, text) {
const tokens = globTokens(glob);
if (!tokens) return null;
const n = text.length;
let next = new Array(n + 1).fill(false);
next[n] = true;
for (let t = tokens.length - 1; t >= 0; t--) {
const token = tokens[t];
const row = new Array(n + 1).fill(false);
let afterSlash = false;
for (let j = n; j >= 0; j--) {
if (token.type === 'lit') row[j] = j < n && text[j] === token.ch && next[j + 1];
else if (token.type === 'one') row[j] = j < n && text[j] !== '/' && next[j + 1];
else if (token.type === 'class') row[j] = j < n && classMatches(token, text[j]) && next[j + 1];
else if (token.type === 'star') row[j] = next[j] || (j < n && text[j] !== '/' && row[j + 1]);
else if (token.type === 'deep') row[j] = next[j] || (j < n && row[j + 1]);
else {
if (j < n && text[j] === '/' && next[j + 1]) afterSlash = true;
row[j] = next[j] || afterSlash;
}
}
next = row;
}
return next[0];
}
function normalizeFilterGlob(glob) {
return String(glob).toLowerCase().replace(/\\/g, '/').replace(/^(?:\.\/)+/, '').replace(/^\/+/, '').replace(/\/+$/, '');
}
function globMatches(glob, text, malformed) {
if (glob.length > MAX_FILTER_GLOB_CHARS) return null;
const alternatives = expandBraces(glob);
if (!alternatives) return null;
return alternatives.some(alternative => {
const matched = wildcardMatch(alternative, text);
return matched === null ? malformed : matched;
});
}
function targetSegments(ctx) {
return ctx.targetKey.toLowerCase().split('/').filter(Boolean);
}
function exclusionCoversTarget(exclusion, segments, stem) {
const glob = normalizeFilterGlob(exclusion);
if (!glob) return true;
if (stem !== null && stem.test(glob)) return true;
for (let start = 0; start < segments.length; start++) {
for (let end = start + 1; end <= segments.length; end++) {
const matched = globMatches(glob, segments.slice(start, end).join('/'), true);
if (matched !== false) return true;
}
}
return false;
}
function filtersAdmitTarget(item, ctx, stem) {
if (item.opaque) return false;
const exclusions = Array.isArray(item.exclusions) ? item.exclusions : [];
if (exclusions.length === 0) return true;
if (exclusions.length > MAX_FILTERS_PER_SEARCH) return false;
const segments = targetSegments(ctx);
return !exclusions.some(exclusion => exclusionCoversTarget(exclusion, segments, stem));
}
function scopeRelativeSegments(item, ctx) {
const scopes = item.scopes || (item.scope ? [item.scope] : []);
const relatives = [];
for (const parts of scopes) {
const scope = ctx.resolveDir(...parts);
if (isInsideDir(ctx.targetKey, scope)) relatives.push(ctx.targetKey.slice(scope.replace(/\/+$/, '').length + 1).toLowerCase().split('/'));
}
return relatives;
}
function includeAdmitsTarget(include, item, ctx) {
const glob = normalizeFilterGlob(include);
const unanchored = glob.replace(/^(?:\*\*\/)+/, '');
if (!unanchored) return false;
if (isBasenameGlob(unanchored)) {
const segments = targetSegments(ctx);
return globMatches(unanchored, segments[segments.length - 1] || '', false) === true;
}
if (!item.pathIncludes) return false;
return scopeRelativeSegments(item, ctx).some(segments => {
const starts = unanchored === glob ? [0] : segments.map((_, start) => start);
return starts.some(start => globMatches(unanchored, segments.slice(start).join('/'), false) === true);
});
}
// see docs/gateguard/design-notes.md#include-filters
function includesAdmitTarget(item, ctx) {
const includes = Array.isArray(item.includes) ? item.includes : [];
if (includes.length === 0) return true;
if (includes.length > MAX_FILTERS_PER_SEARCH) return false;
return includes.some(include => includeAdmitsTarget(include, item, ctx));
}
function isBasenameGlob(glob) {
return !/[\\/]/.test(String(glob).replace(/^(?:\*\*[\\/])+/, ''));
}
function grepToolFilters(glob) {
const filters = { positives: [], includes: [], exclusions: [], opaque: false };
if (typeof glob !== 'string' || !glob) return filters;
for (const part of splitTopLevelCommas(glob)) {
if (part.startsWith('!')) filters.exclusions.push(part.slice(1));
else {
filters.positives.push(part);
filters.includes.push(part);
}
}
return filters;
}
function shellSearchFilters(kind, args) {
const filters = { exclusions: [], includes: [], opaque: false, dropped: new Set() };
if (kind === 'find') return findSearchFilters(args, filters);
if (POWERSHELL_SEARCHES.has(kind)) return powershellSearchFilters(kind, args, filters);
for (let i = 0; i < args.length; i++) {
const arg = args[i];
if (/^\d*[<>]/.test(arg)) break;
const consumed = applyShellFilterArg(kind, args, i, filters);
i += consumed;
}
return filters;
}
function applyShellFilterArg(kind, args, i, filters) {
const arg = args[i];
const exclude = (value, indexes, separator) => {
for (const glob of separator ? String(value).split(separator) : [value]) {
if (glob) filters.exclusions.push(glob);
}
indexes.forEach(index => filters.dropped.add(index));
};
const include = value => {
if (value) filters.includes.push(value);
};
if (kind.startsWith('git ') && GIT_EXCLUDE_PATHSPEC.test(arg)) {
exclude(arg.replace(GIT_EXCLUDE_PATHSPEC, ''), [i]);
return 0;
}
if (arg.startsWith('--')) {
const eq = arg.indexOf('=');
const name = eq > 0 ? arg.slice(0, eq) : arg;
const hasNext = eq <= 0 && i + 1 < args.length;
const value = eq > 0 ? arg.slice(eq + 1) : hasNext ? args[i + 1] : '';
const indexes = eq > 0 || !hasNext ? [i] : [i, i + 1];
const consumed = indexes.length - 1;
if (OPAQUE_FILTER_FLAGS.has(name)) {
filters.opaque = true;
return consumed;
}
if (name === '--glob' || name === '--iglob') {
if (value.startsWith('!')) exclude(value.slice(1), indexes);
else include(value);
return consumed;
}
if (name === '--include') {
include(value);
return consumed;
}
if (EXCLUDE_LONG_FLAGS.has(name) && (kind === 'ls' || !LS_ONLY_EXCLUDE_FLAGS.has(name))) {
exclude(value, indexes);
return consumed;
}
return 0;
}
if (!/^-[A-Za-z]/.test(arg)) return 0;
const letters = shortFilterLetters(kind);
const valueFlags = valueFlagsFor(kind);
for (let k = 1; k < arg.length; k++) {
const letter = arg[k];
if (!letters.has(letter)) {
if (valueFlags.has(`-${letter}`)) return k === arg.length - 1 ? 1 : 0;
continue;
}
const attached = arg.slice(k + 1);
const value = attached || args[i + 1] || '';
const indexes = attached || i + 1 >= args.length ? [i] : [i, i + 1];
const consumed = indexes.length - 1;
applyShortFilter(kind, letter, value, indexes, { filters, exclude, include });
return consumed;
}
return 0;
}
function shortFilterLetters(kind) {
if (kind === 'rg') return new Set(['g']);
if (kind === 'fd') return new Set(['E']);
if (kind === 'ls') return new Set(['I']);
if (kind === 'tree') return new Set(['I', 'P']);
if (kind === 'git ls-files') return new Set(['x', 'X']);
return new Set();
}
function applyShortFilter(kind, letter, value, indexes, { filters, exclude, include }) {
if (kind === 'rg') {
if (value.startsWith('!')) exclude(value.slice(1), indexes);
else include(value);
} else if (kind === 'tree' && letter === 'P') {
value.split('|').forEach(include);
} else if (kind === 'git ls-files' && letter === 'X') {
filters.opaque = true;
} else {
exclude(value, indexes, kind === 'tree' ? '|' : '');
}
}
function findSearchFilters(args, filters) {
let negateNext = false;
let negatedDepth = 0;
let depth = 0;
for (let i = 0; i < args.length; i++) {
const arg = args[i];
if (/^\d*[<>]/.test(arg)) break;
if (arg === '-not' || arg === '!') {
negateNext = true;
filters.dropped.add(i);
continue;
}
if (arg === '(') {
depth += 1;
if (negateNext && negatedDepth === 0) negatedDepth = depth;
negateNext = false;
continue;
}
if (arg === ')') {
if (negatedDepth === depth) negatedDepth = 0;
depth = Math.max(depth - 1, 0);
continue;
}
if (FIND_NAME_FLAGS.has(arg) && i + 1 < args.length) {
const value = args[i + 1];
if (negateNext || negatedDepth > 0 || args[i + 2] === '-prune') {
filters.exclusions.push(value);
filters.dropped.add(i);
filters.dropped.add(i + 1);
} else if (FIND_BASENAME_FLAGS.has(arg)) {
filters.includes.push(value);
}
i += 1;
}
negateNext = false;
}
return filters;
}
function powershellParam(kind, arg) {
const colon = arg.indexOf(':');
const spelled = (colon === -1 ? arg.slice(1) : arg.slice(1, colon)).toLowerCase();
const inline = colon === -1 ? null : arg.slice(colon + 1);
const own = kind === 'get-childitem' || kind === 'gci' ? POWERSHELL_CHILDITEM_PARAMS : POWERSHELL_SELECTSTRING_PARAMS;
const params = own.concat(POWERSHELL_COMMON_PARAMS);
if (!spelled) return { name: null, inline };
if (params.includes(spelled) || POWERSHELL_PARAM_ALIASES.has(spelled)) return { name: spelled, inline };
const matches = params.filter(param => param.startsWith(spelled));
return { name: matches.length === 1 ? matches[0] : null, inline };
}
function powershellFilterArg(kind, args, i) {
if (!/^-[A-Za-z]/.test(args[i])) return null;
const { name, inline } = powershellParam(kind, args[i]);
if (!POWERSHELL_FILTER_PARAMS.has(name)) return null;
const first = inline ? i : i + 1;
if (first >= args.length) return { name, globs: [], end: i };
let end = first;
while (end + 1 < args.length && (args[end].endsWith(',') || args[end + 1].startsWith(','))) end += 1;
const parts = [inline || args[first]].concat(args.slice(first + 1, end + 1));
return { name, globs: splitTopLevelCommas(parts.join(',')), end };
}
function powershellSearchFilters(kind, args, filters) {
for (let i = 0; i < args.length; i++) {
const filter = powershellFilterArg(kind, args, i);
if (filter) {
if (filter.name === 'exclude') {
filters.exclusions.push(...filter.globs);
for (let k = i; k <= filter.end; k++) filters.dropped.add(k);
} else {
filter.globs.filter(Boolean).forEach(glob => filters.includes.push(glob));
}
i = filter.end;
} else if (/^-[A-Za-z]/.test(args[i]) && powershellParam(kind, args[i]).name === null) {
// see docs/gateguard/design-notes.md#powershell-parameter-binding
filters.dropped.add(i);
if (!args[i].includes(':') && i + 1 < args.length && !args[i + 1].startsWith('-')) filters.dropped.add(i + 1);
}
}
return filters;
}
module.exports = {
POWERSHELL_SEARCHES,
isInsideDir,
valueFlagsFor,
powershellFilterArg,
grepToolFilters,
shellSearchFilters,
filtersAdmitTarget,
includesAdmitTarget
};
+143
View File
@@ -0,0 +1,143 @@
'use strict';
// --- Counters ---
// see docs/gateguard/design-notes.md#state-file-is-untrusted
function toCount(value) {
const n = Number(value);
return Number.isFinite(n) && n >= 0 ? Math.floor(n) : 0;
}
function getDenialCount(state) {
return toCount(state && state.fact_force_denials);
}
function getCreditedCount(state) {
return toCount(state && state.fact_force_credited);
}
function getCapAllowCount(state) {
return toCount(state && state.cap_allows);
}
function getTrivialAllowCount(state) {
return toCount(state && state.trivial_allows);
}
function getRoutineReadonlyPassCount(state) {
return toCount(state && state.routine_readonly_passes);
}
// --- Sibling dir gates ---
const MAX_DIR_GATES = 50;
const DIR_GATE_KEY_SEPARATOR = '\u0000';
const UNSAFE_MAP_KEYS = new Set(['__proto__', 'constructor', 'prototype']);
function isPlainObject(value) {
return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
}
function isSafeKey(key) {
return typeof key === 'string' && key !== '' && !UNSAFE_MAP_KEYS.has(key);
}
function nullMap(entries) {
const map = Object.create(null);
for (const [key, value] of entries) map[key] = value;
return map;
}
function withEntry(map, key, value) {
return nullMap([...Object.entries(map), [key, value]]);
}
function getClassCounts(state, field) {
const raw = state && state[field];
if (!isPlainObject(raw)) return nullMap([]);
const entries = Object.keys(raw)
.filter(cls => isSafeKey(cls) && typeof raw[cls] === 'number' && toCount(raw[cls]) > 0)
.map(cls => [cls, toCount(raw[cls])]);
return nullMap(entries);
}
function mergeClassCounts(a, b) {
const merged = nullMap(Object.entries(a));
for (const [cls, n] of Object.entries(b)) {
merged[cls] = Math.max(Object.hasOwn(merged, cls) ? merged[cls] : 0, n);
}
return merged;
}
function incrementClassCount(counts, cls) {
if (!isSafeKey(cls)) return counts;
return withEntry(counts, cls, (Object.hasOwn(counts, cls) ? counts[cls] : 0) + 1);
}
function getSiblingAllowCount(state) {
return toCount(state && state.sibling_allows);
}
function dirGateKey(cls, dir) {
return `${cls}${DIR_GATE_KEY_SEPARATOR}${dir}`;
}
function isDirGateKey(key) {
if (!isSafeKey(key)) return false;
const at = key.indexOf(DIR_GATE_KEY_SEPARATOR);
return at > 0 && at < key.length - 1 && require('./gateguard-target-class').COLLAPSIBLE_CLASSES.has(key.slice(0, at));
}
function isDirGateEntry(entry) {
return (
isPlainObject(entry) &&
typeof entry.at === 'number' &&
Number.isFinite(entry.at) &&
typeof entry.first === 'string' &&
(entry.turn === null || entry.turn === undefined || typeof entry.turn === 'string')
);
}
function getDirGates(state) {
const raw = state && state.dir_gates;
if (!isPlainObject(raw)) return nullMap([]);
const entries = Object.keys(raw)
.filter(key => isDirGateKey(key) && isDirGateEntry(raw[key]))
.map(key => {
const entry = raw[key];
return [key, { turn: entry.turn || null, at: entry.at, first: entry.first, ordinal: toCount(entry.ordinal) }];
});
return nullMap(entries);
}
/** Merge two dir-gate maps; the newer `at` wins per key. */
function mergeDirGates(older, newer) {
const merged = nullMap(Object.entries(older));
for (const [key, entry] of Object.entries(newer)) {
if (!Object.hasOwn(merged, key) || entry.at >= merged[key].at) merged[key] = entry;
}
return merged;
}
function capDirGates(gates) {
const entries = Object.entries(gates);
if (entries.length <= MAX_DIR_GATES) return gates;
return nullMap(entries.sort((a, b) => b[1].at - a[1].at).slice(0, MAX_DIR_GATES));
}
module.exports = {
getDenialCount,
getCreditedCount,
getCapAllowCount,
getTrivialAllowCount,
getRoutineReadonlyPassCount,
getSiblingAllowCount,
getClassCounts,
mergeClassCounts,
incrementClassCount,
withEntry,
dirGateKey,
getDirGates,
mergeDirGates,
capDirGates
};
+442
View File
@@ -0,0 +1,442 @@
'use strict';
const fs = require('fs');
const path = require('path');
const WINDOWS_PATH_PATTERN = /^[a-z]:[\\/]|^\\\\/i;
/** Canonical checked-state key for a target. */
function canonicalPathKey(filePath, data) {
try {
const target = resolveTargetPath(filePath, data);
if (!target) return filePath;
const key = target.resolved.replace(/\\/g, '/');
return target.isWin ? key.toLowerCase() : key;
} catch (_) {
return filePath;
}
}
/** Resolve a target to the file the tool will touch. */
// see docs/gateguard/design-notes.md#target-resolution
function resolveTargetPath(filePath, data) {
const base = (data && data.cwd) || process.env.CLAUDE_PROJECT_DIR || process.cwd();
if (typeof base !== 'string' || typeof filePath !== 'string') return null;
const isWin = WINDOWS_PATH_PATTERN.test(base) || WINDOWS_PATH_PATTERN.test(filePath);
const paths = isWin ? path.win32 : path.posix;
if (!paths.isAbsolute(base)) return null;
return { resolved: paths.resolve(base, filePath), isWin };
}
// --- Target classes ---
const INSTRUCTION_BASENAMES = new Set([
'claude.md',
'agents.md',
'agent.md',
'gemini.md',
'skill.md',
'copilot-instructions.md',
'.cursorrules',
'.windsurfrules'
]);
const INSTRUCTION_ANYWHERE_EXTS = new Set(['.mdc']);
const INSTRUCTION_DIRS = new Set(['.claude', 'agents', 'commands', 'skills', 'rules', 'hooks', '.cursor', '.codex', '.opencode']);
const INSTRUCTION_EXTS = new Set(['.md', '.mdx', '.txt']);
const TEST_DIRS = new Set(['tests', 'test', '__tests__']);
const TEST_BASENAME_PATTERN = /\.(test|spec)\.|^test_.*\.py$|_test\.(py|go)$/;
const PROSE_EXTS = new Set(['.md', '.mdx', '.txt', '.rst', '.adoc']);
const CONFIG_EXTS = new Set(['.json', '.jsonc', '.yaml', '.yml', '.toml', '.ini']);
const ENV_BASENAME_PATTERN = /^\.env($|\.)/;
const CODE_EXTS = new Set(['.ts', '.js', '.mjs', '.cjs', '.py', '.go', '.rs', '.java', '.rb']);
/** Classify a path; first match wins: instruction, test, prose, config, code (the fallback). */
function classifyTarget(filePath) {
try {
const normalized = String(filePath || '').replace(/\\/g, '/').toLowerCase();
const segments = normalized.split('/').filter(Boolean);
const base = segments.pop() || '';
const ext = path.posix.extname(base);
if (INSTRUCTION_BASENAMES.has(base) || INSTRUCTION_ANYWHERE_EXTS.has(ext)) return 'instruction';
if (INSTRUCTION_EXTS.has(ext) && segments.some(segment => INSTRUCTION_DIRS.has(segment))) return 'instruction';
if (ext === '.md' && base.includes('instructions') && segments.includes('.github')) return 'instruction';
if (TEST_BASENAME_PATTERN.test(base) || segments.some(segment => TEST_DIRS.has(segment))) return 'test';
if (PROSE_EXTS.has(ext)) return 'prose';
if (CONFIG_EXTS.has(ext) || (ENV_BASENAME_PATTERN.test(base) && !CODE_EXTS.has(ext))) return 'config';
return 'code';
} catch (_) {
return 'code';
}
}
const WORKTREE_PREFIX_PATTERN = /^\.claude\/worktrees\/([^/]+)\//;
function isHostPathStyle(isWin) {
return isWin === (process.platform === 'win32');
}
function foldKey(nativePath, isWin) {
const key = nativePath.replace(/\\/g, '/');
return isWin ? key.toLowerCase() : key;
}
function canonicalProjectRoot(data) {
const root = process.env.CLAUDE_PROJECT_DIR || (data && data.cwd) || process.cwd();
if (typeof root !== 'string') return null;
const isWin = WINDOWS_PATH_PATTERN.test(root);
const paths = isWin ? path.win32 : path.posix;
if (!paths.isAbsolute(root)) return null;
const native = paths.resolve(root);
return { key: foldKey(native, isWin), native, isWin };
}
// see docs/gateguard/design-notes.md#worktree-prefix
function isRealWorktree(rootNative, isWin, name) {
if (!isHostPathStyle(isWin)) return false;
const paths = isWin ? path.win32 : path.posix;
return fs.existsSync(paths.join(rootNative, '.claude', 'worktrees', name, '.git'));
}
const STREAM_MARKER = '::$';
// see docs/gateguard/design-notes.md#windows-name-normalization
function normalizeWindowsSegments(classPath, isWin) {
const segments = classPath.split('/');
const last = segments.length - 1;
return segments
.map((segment, index) => {
if (!isWin && !segment.includes(STREAM_MARKER)) return segment;
let name = segment;
const colon = name.indexOf(':');
if (index === last && colon > 0) name = name.slice(0, colon);
name = name.replace(/[. ]+$/, '');
return name || segment;
})
.join('/');
}
function projectRelativeClassPath(key, root, isWin) {
const target = isWin ? key.toLowerCase() : key;
const rootKey = isWin ? root.key.toLowerCase() : root.key;
const prefix = rootKey.endsWith('/') ? rootKey : `${rootKey}/`;
if (!target.startsWith(prefix)) return null;
let relative = key.slice(prefix.length);
const worktree = relative.match(WORKTREE_PREFIX_PATTERN);
if (worktree && isRealWorktree(root.native, root.isWin, worktree[1])) relative = relative.slice(worktree[0].length);
return normalizeWindowsSegments(relative, isWin);
}
/** Project-relative path a target is classified by. */
function classPathFor(filePath, data) {
try {
const key = canonicalPathKey(filePath, data);
const root = canonicalProjectRoot(data);
const isWin = Boolean(root && root.isWin) || WINDOWS_PATH_PATTERN.test(key);
if (!root) return normalizeWindowsSegments(key, isWin);
const relative = projectRelativeClassPath(key, root, isWin);
return relative === null ? normalizeWindowsSegments(key, isWin) : relative;
} catch (_) {
return filePath;
}
}
/** Class of a hook target, judged on its project-relative path. */
function classifyTargetFor(filePath, data) {
return classifyTarget(classPathFor(filePath, data));
}
const SEARCH_THE_TREE = '(search the tree — Glob/Grep, or find/grep via Bash)';
const QUOTE_INSTRUCTION = "Quote the user's current instruction verbatim";
const QUESTION_TEXT = Object.freeze({
importers: `List ALL files that import/require this file ${SEARCH_THE_TREE}`,
'public-api': 'List the public functions/classes affected by this change',
'local-callers': `List the call sites in this file or its module that rely on the changed behaviour ${SEARCH_THE_TREE}`,
callers: 'Name the file(s) and line(s) that will call this new file',
'no-duplicate': `Confirm no existing file serves the same purpose ${SEARCH_THE_TREE}`,
'data-schema':
'If this file reads/writes data files, show field names, structure, and date format (use redacted or synthetic values, not raw production data)',
'external-contract':
'Name what outside this repository decides the format, units, timezone or protocol semantics here (the consumer, the producer, or a stated convention), or state that the choice is unconstrained',
loader: 'Name the harness/loader that reads this file (Claude Code, Codex, Cursor, OpenCode, …) and when it loads it',
'behaviour-change': 'Describe what agent behaviour changes as a result',
'no-duplicate-instruction': `Confirm no existing instruction, skill, or agent file already covers this ${SEARCH_THE_TREE}`,
'under-test': 'Name what behaviour is under test and which module/function it exercises',
'existing-tests': `Name the existing test file(s) covering this module, or confirm none exist ${SEARCH_THE_TREE}`,
supersedes: `Name any existing doc this supersedes or duplicates ${SEARCH_THE_TREE}`,
'linked-from': 'State where it will be linked or referenced from',
'why-new-file': 'Explain why a new file rather than editing an existing one',
references: `List other docs or code that reference the section being changed ${SEARCH_THE_TREE}`,
'corrects-or-adds': 'State what the change corrects or adds',
'config-reader': 'Name which process/tool reads this file and when',
'config-effect': 'Describe the effect of the change',
'no-plaintext-secrets': 'Confirm no secrets or credentials are being written in plain text',
'quote-instruction': QUOTE_INSTRUCTION
});
const CLASS_QUESTION_IDS = {
instruction: () => ['loader', 'behaviour-change', 'no-duplicate-instruction'],
test: () => ['under-test', 'existing-tests'],
prose: isWrite => (isWrite ? ['supersedes', 'linked-from', 'why-new-file'] : ['references', 'corrects-or-adds']),
config: () => ['config-reader', 'config-effect', 'no-plaintext-secrets']
};
const CLASS_QUESTIONS = Object.fromEntries(
Object.entries(CLASS_QUESTION_IDS).map(([cls, ids]) => [cls, isWrite => ids(isWrite).map(id => QUESTION_TEXT[id])])
);
// see docs/gateguard/change-profile.md#questions-from-the-change-profile
function codeQuestionIds(isWrite, profile) {
const known = Boolean(profile) && profile.known === true;
const surface = !known || profile.touchesPublicSurface !== false;
const data = !known || profile.touchesData !== false;
const opening = isWrite ? ['callers', 'no-duplicate'] : surface ? ['importers', 'public-api'] : ['local-callers'];
// A change that handles data is usually constrained by something outside the
// tree - a consumer's dialect, a reporting timezone, a protocol's semantics -
// and the repository cannot settle it. data-schema asks what the change
// touches; external-contract asks who decides what it has to be.
return data ? [...opening, 'data-schema', 'external-contract'] : opening;
}
/** True when a class's questions depend on the change profile. */
function questionsUseProfile(cls) {
return !Object.hasOwn(CLASS_QUESTION_IDS, cls);
}
/** Stable ids of the first-touch questions for a class, action and change profile. */
function questionIdsFor(cls, isWrite, profile) {
const ids = Object.hasOwn(CLASS_QUESTION_IDS, cls) ? CLASS_QUESTION_IDS[cls](Boolean(isWrite)) : codeQuestionIds(Boolean(isWrite), profile);
return [...ids, 'quote-instruction'];
}
/** Question text for an id from `questionIdsFor`. */
function questionText(id) {
return Object.hasOwn(QUESTION_TEXT, id) ? QUESTION_TEXT[id] : '';
}
const CODE_CONDENSED_HINT =
"briefly state importers/callers, affected API, data schemas if any, what outside the repository fixes the format or semantics, and the user's verbatim instruction, then retry.";
const CONDENSED_PHRASES = Object.freeze({
importers: 'the files that import this file',
'public-api': 'the public functions/classes affected',
'local-callers': 'the call sites in this file or its module that rely on the change',
callers: 'the file(s) and line(s) that will call it',
'no-duplicate': 'that no existing file serves the same purpose',
'data-schema': 'the data schemas it reads or writes',
'external-contract': 'what outside the repository fixes the format or semantics',
'quote-instruction': "the user's verbatim instruction"
});
/** Condensed-hint phrase for a code question id, or '' when it has none. */
function condensedQuestionPhrase(id) {
return Object.hasOwn(CONDENSED_PHRASES, id) ? CONDENSED_PHRASES[id] : '';
}
function joinPhrases(phrases) {
if (phrases.length <= 2) return phrases.join(' and ');
return `${phrases.slice(0, -1).join(', ')}, and ${phrases[phrases.length - 1]}`;
}
const CLASS_CONDENSED_HINTS = {
instruction: () =>
"briefly state which harness loads this file, the agent behaviour it changes, that no existing instruction file covers it, and the user's verbatim instruction, then retry.",
test: () =>
"briefly state the behaviour and module under test, existing test files for it (or none), and the user's verbatim instruction, then retry.",
prose: isWrite =>
isWrite
? "briefly state what this supersedes, where it is linked from, and the user's verbatim instruction, then retry."
: "briefly state what references the changed section, what the change corrects or adds, and the user's verbatim instruction, then retry.",
config: () =>
"briefly state which process reads this file, the effect of the change, that no secrets are written in plain text, and the user's verbatim instruction, then retry."
};
/** One-line condensed hint matching `questionIdsFor`. */
function condensedHintFor(cls, isWrite, profile) {
if (Object.hasOwn(CLASS_CONDENSED_HINTS, cls)) return CLASS_CONDENSED_HINTS[cls](Boolean(isWrite));
if (!isWrite && !(profile && profile.known === true)) return CODE_CONDENSED_HINT;
const phrases = questionIdsFor(cls, isWrite, profile).map(condensedQuestionPhrase).filter(Boolean);
return `briefly state ${joinPhrases(phrases)}, then retry.`;
}
// --- Sensitive targets ---
// see docs/gateguard/design-notes.md#sensitive-targets
const SENSITIVE_EXTS = new Set(['.pem', '.key', '.p12', '.pfx']);
const SENSITIVE_BASENAME_PATTERN = /^(\.env($|\.)|id_rsa|id_ed25519|id_ecdsa|id_dsa|\.netrc$|\.pgpass$|credentials|secrets\.)/;
const SENSITIVE_SEGMENTS = new Set(['auth', 'authn', 'authz', 'security', 'secrets', 'payment', 'payments', 'billing', 'migrations']);
const SENSITIVE_PREFIX = '.github/workflows/';
/** True for a sensitive class path; any error or non-string input is sensitive. */
function isSensitiveTarget(classPath) {
try {
if (typeof classPath !== 'string' || !classPath) return true;
const normalized = classPath.replace(/\\/g, '/').toLowerCase();
const segments = normalized.split('/').filter(Boolean);
const base = segments[segments.length - 1] || '';
if (SENSITIVE_BASENAME_PATTERN.test(base) || SENSITIVE_EXTS.has(path.posix.extname(base))) return true;
if (segments.some(segment => SENSITIVE_SEGMENTS.has(segment))) return true;
return `/${segments.join('/')}`.includes(`/${SENSITIVE_PREFIX}`);
} catch (_) {
return true;
}
}
/** Sensitive on the lexical or the real (symlink-resolved) path; any error is sensitive. */
function isSensitiveTargetFor(filePath, data) {
try {
if (typeof filePath !== 'string' || !filePath) return true;
if (isSensitiveTarget(classPathFor(filePath, data))) return true;
return isSensitiveRealTarget(filePath, data);
} catch (_) {
return true;
}
}
function isSensitiveRealTarget(filePath, data) {
const target = resolveTargetPath(filePath, data);
if (!target || !isHostPathStyle(target.isWin)) return false;
const paths = target.isWin ? path.win32 : path.posix;
const real = realTargetPath(target.resolved, paths);
if (!real) return true;
const realKey = foldKey(real, target.isWin);
if (realKey === foldKey(target.resolved, target.isWin)) return false;
const root = canonicalProjectRoot(data);
if (root && root.isWin === target.isWin) {
const realRoot = realpathOfNearestAncestor(root.native, paths);
if (!realRoot) return true;
const realRootInfo = { ...root, key: foldKey(realRoot, target.isWin), native: realRoot };
const realClassPath = projectRelativeClassPath(realKey, realRootInfo, target.isWin);
if (realClassPath !== null) return isSensitiveTarget(realClassPath);
}
return isSensitiveTarget(normalizeWindowsSegments(realKey, target.isWin));
}
// --- Hard-linked targets ---
// see docs/gateguard/design-notes.md#hard-linked-targets
const MISSING_TARGET_CODES = new Set(['ENOENT', 'ENOTDIR']);
function hasOtherLinks(stat) {
return !stat.isDirectory() && stat.nlink > 1;
}
/** True when the target file has more than one hard link; a stat error other than a missing file counts as linked. */
function isHardLinkedTargetFor(filePath, data) {
try {
const target = resolveTargetPath(filePath, data);
if (!target) return true;
if (!isHostPathStyle(target.isWin)) return false;
const stat = fs.lstatSync(target.resolved);
if (!stat.isSymbolicLink()) return hasOtherLinks(stat);
return hasOtherLinks(fs.statSync(target.resolved));
} catch (error) {
return !(error && MISSING_TARGET_CODES.has(error.code));
}
}
// --- Sibling collapse eligibility ---
// see docs/gateguard/design-notes.md#sibling-collapse
const COLLAPSIBLE_CLASSES = new Set(['code', 'test', 'prose']);
const SHORT_NAME_PATTERN = /~\d/;
function isCollapsibleClassPath(classPath, cls) {
if (!COLLAPSIBLE_CLASSES.has(cls)) return false;
const segments = String(classPath).toLowerCase().split('/').filter(Boolean);
if (segments.length === 0) return false;
return !segments.some(segment => segment.startsWith('.') || SHORT_NAME_PATTERN.test(segment));
}
/** Lexical collapse screen; `collapseGateDir` adds the real-directory screen. */
function isCollapsibleTarget(filePath, data, cls) {
return isCollapsibleClassPath(classPathFor(filePath, data), cls);
}
function realpathOfNearestAncestor(nativePath, paths) {
const realpath = fs.realpathSync.native || fs.realpathSync;
const missing = [];
let current = nativePath;
for (;;) {
try {
const real = realpath(current);
if (!fs.statSync(real).isDirectory()) return null;
return missing.length > 0 ? paths.join(real, ...missing.reverse()) : real;
} catch (error) {
if (!error || error.code !== 'ENOENT' || !isMissingEntry(current)) return null;
const parent = paths.dirname(current);
if (parent === current) return null;
missing.push(paths.basename(current));
current = parent;
}
}
}
function realTargetPath(resolved, paths) {
const realpath = fs.realpathSync.native || fs.realpathSync;
try {
return realpath(resolved);
} catch (error) {
if (!error || error.code !== 'ENOENT' || !isMissingEntry(resolved)) return null;
}
const realDir = realpathOfNearestAncestor(paths.dirname(resolved), paths);
return realDir ? paths.join(realDir, paths.basename(resolved)) : null;
}
function isMissingEntry(nativePath) {
try {
fs.lstatSync(nativePath);
return false;
} catch (error) {
return Boolean(error) && error.code === 'ENOENT';
}
}
/** Real directory a new file's sibling gate is keyed by, or null when it may not collapse. */
function collapseGateDir(filePath, data, cls) {
try {
if (!isCollapsibleTarget(filePath, data, cls)) return null;
const target = resolveTargetPath(filePath, data);
if (!target || !isHostPathStyle(target.isWin)) return null;
const paths = target.isWin ? path.win32 : path.posix;
const lexicalDir = paths.dirname(target.resolved);
const realDir = realpathOfNearestAncestor(lexicalDir, paths);
if (!realDir) return null;
const realKey = foldKey(realDir, target.isWin);
if (realKey === foldKey(lexicalDir, target.isWin)) return realKey;
const root = canonicalProjectRoot(data);
if (!root || root.isWin !== target.isWin) return null;
const realRoot = realpathOfNearestAncestor(root.native, paths);
if (!realRoot) return null;
const realTargetKey = foldKey(paths.join(realDir, paths.basename(target.resolved)), target.isWin);
const realClassPath = projectRelativeClassPath(realTargetKey, { ...root, key: foldKey(realRoot, target.isWin), native: realRoot }, target.isWin);
if (realClassPath === null || classifyTarget(realClassPath) !== cls) return null;
if (isSensitiveTarget(realClassPath)) return null;
return isCollapsibleClassPath(realClassPath, cls) ? realKey : null;
} catch (_) {
return null;
}
}
module.exports = {
questionsUseProfile,
WINDOWS_PATH_PATTERN,
COLLAPSIBLE_CLASSES,
CLASS_QUESTIONS,
CLASS_CONDENSED_HINTS,
QUOTE_INSTRUCTION,
questionIdsFor,
questionText,
condensedHintFor,
condensedQuestionPhrase,
resolveTargetPath,
canonicalPathKey,
classifyTarget,
classPathFor,
classifyTargetFor,
isCollapsibleTarget,
collapseGateDir,
isSensitiveTarget,
isSensitiveTargetFor,
isHardLinkedTargetFor
};
+280
View File
@@ -0,0 +1,280 @@
'use strict';
const crypto = require('crypto');
const fs = require('fs');
const path = require('path');
const { readFileTail, DEFAULT_TRANSCRIPT_TAIL_BYTES } = require('./file-tail');
const TRANSCRIPT_SCAN_MAX_LINES = 2000;
// see docs/gateguard/design-notes.md#read-is-not-evidence
const SEARCH_TOOL_NAMES = new Set(['Glob', 'Grep', 'LS', 'Bash', 'PowerShell']);
const SHELL_TOOL_NAMES = new Set(['Bash', 'PowerShell']);
const TOOL_USE_ERROR_PREFIX = '<tool_use_error>';
function isObject(value) {
return Boolean(value) && typeof value === 'object';
}
// see docs/gateguard/design-notes.md#fail-to-deny
function neverThrows(fn, fallback) {
return (...args) => {
try {
return fn(...args);
} catch (_) {
return fallback;
}
};
}
/** Transcript path from the hook payload or CLAUDE_TRANSCRIPT_PATH; '' when none. */
function transcriptPathFor(data) {
const candidate = (isObject(data) && (data.transcript_path || data.transcriptPath)) || process.env.CLAUDE_TRANSCRIPT_PATH;
return typeof candidate === 'string' ? candidate : '';
}
function pendingToolUseId(data) {
return isObject(data) && typeof data.tool_use_id === 'string' ? data.tool_use_id : '';
}
function recordContent(record) {
const message = isObject(record) ? record.message : undefined;
return isObject(message) ? message.content : undefined;
}
function isSkippedTranscriptRecord(record) {
return (
!isObject(record) ||
Array.isArray(record) ||
record.isSidechain === true ||
record.isMeta === true ||
(record.type !== 'user' && record.type !== 'assistant')
);
}
/** True for a compaction summary or boundary record. */
function isCompactionBoundary(record) {
return (
Boolean(record) &&
typeof record === 'object' &&
record.isSidechain !== true &&
(record.isCompactSummary === true || (record.type === 'system' && record.subtype === 'compact_boundary'))
);
}
const MAX_PROMPT_ID_CHARS = 128;
function recordPromptId(record) {
const id = isObject(record) ? record.promptId : undefined;
return typeof id === 'string' && id && id.length <= MAX_PROMPT_ID_CHARS ? id : null;
}
function boundaryTurnId(record, line) {
if (typeof record.uuid === 'string' && record.uuid) return record.uuid;
return `h:${crypto.createHash('sha256').update(line).digest('hex').slice(0, 16)}`;
}
/** True for a user record made only of `tool_result` blocks. */
// see docs/gateguard/design-notes.md#turn-boundaries
function isToolResultRecord(record) {
const content = recordContent(record);
return (
Array.isArray(content) &&
content.length > 0 &&
content.every(block => block && typeof block === 'object' && block.type === 'tool_result')
);
}
function toolResultText(content) {
if (typeof content === 'string') return content;
if (!Array.isArray(content)) return '';
const first = content.find(block => block && typeof block === 'object' && block.type === 'text');
return first && typeof first.text === 'string' ? first.text : '';
}
function isErrorToolResult(block) {
if (!isObject(block)) return false;
if (block.is_error === true || block.is_error === 'true') return true;
return toolResultText(block.content).trimStart().startsWith(TOOL_USE_ERROR_PREFIX);
}
/** Scan the transcript tail back to the start of the current turn; null when unavailable. */
function scanCurrentTurn(transcriptPath, pendingId = '') {
if (typeof transcriptPath !== 'string' || !transcriptPath) return null;
let stat;
try {
stat = fs.statSync(transcriptPath);
} catch (_) {
return null;
}
if (!stat.isFile()) return null;
const tail = readFileTail(transcriptPath, DEFAULT_TRANSCRIPT_TAIL_BYTES);
if (!tail) return null;
const lines = tail.text.split('\n');
const firstLine = Math.max(tail.truncated ? 1 : 0, lines.length - TRANSCRIPT_SCAN_MAX_LINES);
const okResultIds = new Set();
const toolUses = [];
const batchIds = new Map();
let newestMessageId = null;
const windowPromptIds = new Set();
const finish = turnId => ({
turnId,
searches: collectTurnSearches(toolUses, okResultIds, pendingId),
batchIds,
newestMessageId,
shellCommands: collectShellCommands(toolUses),
reads: collectTurnReads(toolUses, pendingId)
});
for (let i = lines.length - 1; i >= firstLine; i--) {
const line = lines[i].trim();
if (!line) continue;
let record;
try {
record = JSON.parse(line);
} catch (_) {
continue;
}
if (isCompactionBoundary(record)) return finish(recordPromptId(record) || boundaryTurnId(record, line));
if (isObject(record) && record.type === 'user' && record.isSidechain !== true) {
const id = recordPromptId(record);
if (id) windowPromptIds.add(id);
}
if (isSkippedTranscriptRecord(record)) continue;
if (record.type === 'user') {
if (!isToolResultRecord(record)) return finish(recordPromptId(record) || boundaryTurnId(record, line));
for (const block of recordContent(record)) {
if (typeof block.tool_use_id === 'string' && !isErrorToolResult(block)) {
okResultIds.add(block.tool_use_id);
}
}
continue;
}
if (!isObject(record.message)) continue;
const content = recordContent(record);
const messageId = typeof record.message.id === 'string' && record.message.id ? record.message.id : null;
if (newestMessageId === null && messageId) newestMessageId = messageId;
if (!Array.isArray(content)) continue;
for (let j = content.length - 1; j >= 0; j--) {
const block = content[j];
if (!block || typeof block !== 'object' || block.type !== 'tool_use') continue;
toolUses.push({ block, messageId });
if (messageId && typeof block.id === 'string') batchIds.set(block.id, messageId);
}
}
// see docs/gateguard/design-notes.md#turn-identity
if ((tail.truncated || firstLine > 0) && windowPromptIds.size <= 1) {
return finish(windowPromptIds.size === 1 ? [...windowPromptIds][0] : null);
}
return { turnId: null, searches: [], batchIds: new Map(), newestMessageId: null, shellCommands: [], reads: [] };
}
function collectTurnSearches(toolUsesNewestFirst, okResultIds, pendingId) {
const idCounts = new Map();
for (const { block } of toolUsesNewestFirst) {
if (typeof block.id === 'string') idCounts.set(block.id, (idCounts.get(block.id) || 0) + 1);
}
const searches = [];
const priorCalls = toolUsesNewestFirst.filter(({ block }) => !pendingId || block.id !== pendingId);
priorCalls.forEach(({ block, messageId }, index) => {
if (typeof block.id !== 'string' || !okResultIds.has(block.id)) return;
// see docs/gateguard/design-notes.md#tool-result-pairing
if (idCounts.get(block.id) !== 1) return;
if (!SEARCH_TOOL_NAMES.has(block.name)) return;
if (!block.input || typeof block.input !== 'object') return;
searches.push({ name: block.name, input: block.input, callsAgo: index + 1, messageId });
});
return searches;
}
function collectTurnReads(toolUsesNewestFirst, pendingId) {
const reads = [];
toolUsesNewestFirst
.filter(({ block }) => !pendingId || block.id !== pendingId)
.forEach(({ block }, index) => {
if (block.name !== 'Read' || !isObject(block.input) || typeof block.input.file_path !== 'string') return;
reads.push({ name: 'Read', path: block.input.file_path, callsAgo: index + 1 });
});
return reads;
}
function collectShellCommands(toolUses) {
return toolUses
.filter(({ block }) => SHELL_TOOL_NAMES.has(block.name) && block.input && typeof block.input === 'object')
.map(({ block }) => (typeof block.input.command === 'string' ? block.input.command : ''));
}
/** Message id of the pending call's batch, whose searches never credit. */
function excludedBatchId(scan, data) {
if (!isObject(scan)) return null;
const pendingId = pendingToolUseId(data);
const located = pendingId && scan.batchIds instanceof Map ? scan.batchIds.get(pendingId) : undefined;
if (located) return located;
return typeof scan.newestMessageId === 'string' ? scan.newestMessageId : null;
}
/** Lazily scans once per hook invocation, shared by every MultiEdit entry. */
function createTurnScanner(data) {
let scanned = false;
let scan = null;
return () => {
if (!scanned) {
scanned = true;
try {
scan = scanCurrentTurn(transcriptPathFor(data), pendingToolUseId(data));
} catch (_) {
scan = null;
}
}
return scan;
};
}
// see docs/gateguard/design-notes.md#search-prefilter
function targetNeedle(filePath) {
const base = (String(filePath).split(/[\\/]/).pop() || '').toLowerCase();
const ext = path.posix.extname(base);
let stem = ext ? base.slice(0, -ext.length) : base;
if (stem.endsWith('.test') || stem.endsWith('.spec')) stem = stem.slice(0, -5);
if (stem.startsWith('test_')) stem = stem.slice(5);
if (stem.endsWith('_test')) stem = stem.slice(0, -5);
return stem;
}
/** False only when no search of the turn can name the target's stem, so matching can be skipped. */
function searchesMayNameTarget(scan, filePath) {
if (!scan || !Array.isArray(scan.searches) || scan.searches.length === 0) return false;
const needle = targetNeedle(filePath);
if (!needle) return true;
return scan.searches.some(search => {
const input = search && search.input;
if (!isObject(input)) return false;
let text = '';
for (const value of Object.values(input)) {
if (typeof value === 'string') text += `\n${value}`;
}
return text.toLowerCase().replace(/["'\\]/g, '').includes(needle);
});
}
function currentTurnId(scan) {
return scan && typeof scan.turnId === 'string' && scan.turnId ? scan.turnId : null;
}
module.exports = {
TRANSCRIPT_SCAN_MAX_LINES,
SEARCH_TOOL_NAMES,
SHELL_TOOL_NAMES,
transcriptPathFor: neverThrows(transcriptPathFor, ''),
pendingToolUseId: neverThrows(pendingToolUseId, ''),
isCompactionBoundary: neverThrows(isCompactionBoundary, false),
isToolResultRecord: neverThrows(isToolResultRecord, false),
isErrorToolResult: neverThrows(isErrorToolResult, false),
scanCurrentTurn: neverThrows(scanCurrentTurn, null),
excludedBatchId: neverThrows(excludedBatchId, null),
createTurnScanner: neverThrows(createTurnScanner, () => null),
currentTurnId: neverThrows(currentTurnId, null),
searchesMayNameTarget: neverThrows(searchesMayNameTarget, true)
};
+2 -34
View File
@@ -17,14 +17,13 @@
* keeping the PreToolUse hook fast even for very large sessions.
*/
const fs = require('fs');
const { readFileTail, DEFAULT_TRANSCRIPT_TAIL_BYTES } = require('./file-tail');
const STANDARD_CONTEXT_WINDOW_TOKENS = 200000;
const LARGE_CONTEXT_WINDOW_TOKENS = 1000000;
const DEFAULT_CONTEXT_THRESHOLD_STANDARD = 160000;
const DEFAULT_CONTEXT_THRESHOLD_LARGE = 250000;
const DEFAULT_CONTEXT_INTERVAL_TOKENS = 60000;
const DEFAULT_TRANSCRIPT_TAIL_BYTES = 256 * 1024;
const MAX_TOKEN_SETTING = 10000000;
const LARGE_WINDOW_MODEL_MARKER = '[1m]';
@@ -59,38 +58,6 @@ function isKnownModelFamilyMatch(model, familyId) {
* Read the trailing `tailBytes` of a file as UTF-8.
* Returns null when the file is missing or unreadable.
*/
function readFileTail(filePath, tailBytes) {
let fd;
try {
fd = fs.openSync(filePath, 'r');
} catch {
return null;
}
try {
const size = fs.fstatSync(fd).size;
const start = Math.max(0, size - tailBytes);
const length = size - start;
if (length <= 0) {
return { text: '', truncated: false };
}
const buffer = Buffer.alloc(length);
const bytesRead = fs.readSync(fd, buffer, 0, length, start);
return {
text: buffer.toString('utf8', 0, bytesRead),
truncated: start > 0
};
} catch {
return null;
} finally {
try {
fs.closeSync(fd);
} catch {
/* ignore */
}
}
}
/**
* Extract the context token total from a transcript record's usage block.
@@ -281,6 +248,7 @@ module.exports = {
DEFAULT_CONTEXT_THRESHOLD_LARGE,
DEFAULT_CONTEXT_INTERVAL_TOKENS,
DEFAULT_TRANSCRIPT_TAIL_BYTES,
readFileTail,
readLatestContextTokens,
resolveContextWindow,
resolveContextWindowTokens,