mirror of
https://github.com/affaan-m/ECC.git
synced 2026-10-14 12:15:29 +02:00
feat(gateguard): improve first-touch signal quality and session efficiency
- Ask class-specific first-touch questions (instruction, test, prose, config, code); code questions are unchanged. - Credit a completed Glob/Grep/LS or shell search from the current human turn that covered the target; Read and exclusion globs never count. - Collapse same-class new-file creations in one directory within a turn. - Add opt-in GATEGUARD_FACT_FORCE_MAX_DENIALS session cap (from #2755). - Always gate sensitive targets, judged on lexical and real paths. - Canonicalize checked-path keys. Closes #2608 Co-authored-by: SulimanAbdulrazzaq <suliman9000a@gmail.com>
This commit is contained in:
co-authored by
SulimanAbdulrazzaq
parent
c70874fae9
commit
e98a9da288
@@ -44,7 +44,7 @@
|
||||
},
|
||||
{
|
||||
"id": "pre:edit-write:gateguard-fact-force",
|
||||
"description": "Fact-forcing gate: block first Edit/Write/MultiEdit per file and demand investigation (importers, data schemas, user instruction) before allowing",
|
||||
"description": "Fact-forcing gate: deny the first Edit/Write/MultiEdit of each file with class-specific questions (code, test, prose, config, instruction); a completed search naming the file in the current turn, or a same-turn sibling of a new file already gated, passes with a note instead; sensitive targets (secrets, keys, auth/payment code, migrations, CI workflows) are always gated",
|
||||
"fingerprint": "32c4a312b4c2"
|
||||
}
|
||||
],
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,378 @@
|
||||
/**
|
||||
* GateGuard target identity and classification for first-touch
|
||||
* Edit/Write/MultiEdit targets: canonical path keys, target classes and their
|
||||
* questions, sensitive-target detection, and sibling-collapse eligibility.
|
||||
* Stateless; filesystem access is limited to worktree `.git` checks and
|
||||
* realpath/lstat of the target's parent chain.
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const WINDOWS_PATH_PATTERN = /^[a-z]:[\\/]|^\\\\/i;
|
||||
|
||||
/** Canonical checked-state key, so `a.py`, `./a.py` and the absolute path share one gate. */
|
||||
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. Relative paths resolve
|
||||
* against the tool's `cwd` first; this deliberately differs from
|
||||
* `isExemptPath`, whose globs are project-relative.
|
||||
*/
|
||||
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'
|
||||
]);
|
||||
// Cursor rule files are instructions wherever they live.
|
||||
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($|\.)/;
|
||||
// `.env.example.ts` and friends are source files that happen to start with `.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\/([^/]+)\//;
|
||||
|
||||
/** A Windows-style path has no real location on a POSIX host, and vice versa. */
|
||||
function isHostPathStyle(isWin) {
|
||||
return isWin === (process.platform === 'win32');
|
||||
}
|
||||
|
||||
function foldKey(nativePath, isWin) {
|
||||
const key = nativePath.replace(/\\/g, '/');
|
||||
return isWin ? key.toLowerCase() : key;
|
||||
}
|
||||
|
||||
/** Project root, resolved the way `isExemptPath` resolves it (CLAUDE_PROJECT_DIR first). */
|
||||
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 };
|
||||
}
|
||||
|
||||
/** Only a real worktree (its `.git` exists) has its prefix stripped; otherwise the path stays under `.claude`. */
|
||||
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'));
|
||||
}
|
||||
|
||||
// Windows ignores trailing dots/spaces and treats `name:stream` as `name`.
|
||||
const STREAM_MARKER = '::$';
|
||||
|
||||
/** Normalize segments as Windows resolves them, so `CLAUDE.md.` and `CLAUDE.md::$DATA` name `CLAUDE.md`. */
|
||||
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('/');
|
||||
}
|
||||
|
||||
/** Project-relative class path for a canonical key, or null when it is outside the root. */
|
||||
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);
|
||||
}
|
||||
|
||||
/**
|
||||
* The path a target is classified by: project-relative, so an ancestor such as
|
||||
* `/home/me/tests/proj` never leaks into the class, with a real worktree prefix
|
||||
* stripped. Targets outside the root keep their absolute path.
|
||||
*/
|
||||
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 CLASS_QUESTIONS = {
|
||||
instruction: () => [
|
||||
'Name the harness/loader that reads this file (Claude Code, Codex, Cursor, OpenCode, …) and when it loads it',
|
||||
'Describe what agent behaviour changes as a result',
|
||||
`Confirm no existing instruction, skill, or agent file already covers this ${SEARCH_THE_TREE}`
|
||||
],
|
||||
test: () => [
|
||||
'Name what behaviour is under test and which module/function it exercises',
|
||||
`Name the existing test file(s) covering this module, or confirm none exist ${SEARCH_THE_TREE}`
|
||||
],
|
||||
prose: isWrite =>
|
||||
isWrite
|
||||
? [
|
||||
`Name any existing doc this supersedes or duplicates ${SEARCH_THE_TREE}`,
|
||||
'State where it will be linked or referenced from',
|
||||
'Explain why a new file rather than editing an existing one'
|
||||
]
|
||||
: [
|
||||
`List other docs or code that reference the section being changed ${SEARCH_THE_TREE}`,
|
||||
'State what the change corrects or adds'
|
||||
],
|
||||
config: () => [
|
||||
'Name which process/tool reads this file and when',
|
||||
'Describe the effect of the change',
|
||||
'Confirm no secrets or credentials are being written in plain text'
|
||||
]
|
||||
};
|
||||
|
||||
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."
|
||||
};
|
||||
|
||||
// --- Sensitive targets ---
|
||||
// Secrets, keys, auth/payment code, migrations and CI workflows always draw the
|
||||
// first-touch denial. Basename and segment rules are exact, never substrings.
|
||||
|
||||
const SENSITIVE_EXTS = new Set(['.pem', '.key', '.p12', '.pfx']);
|
||||
const SENSITIVE_BASENAME_PATTERN = /^(\.env($|\.)|id_rsa|id_ed25519|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;
|
||||
// Also matched below an ancestor (unverified worktree, target outside the root): fail safe.
|
||||
return `/${segments.join('/')}`.includes(`/${SENSITIVE_PREFIX}`);
|
||||
} catch (_) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sensitive on the lexical path or on the real (symlink-resolved) location, so
|
||||
* `src/tools -> ../auth` cannot launder `src/tools/login.py`. 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;
|
||||
}
|
||||
}
|
||||
|
||||
/** Unresolvable real locations (dangling symlink, ENOTDIR, EACCES, loops) count as sensitive. */
|
||||
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));
|
||||
}
|
||||
|
||||
// --- Sibling collapse eligibility ---
|
||||
|
||||
const COLLAPSIBLE_CLASSES = new Set(['code', 'test', 'prose']);
|
||||
// 8.3 short names (`CLAUDE~1`) can alias any directory, including dot-directories.
|
||||
const SHORT_NAME_PATTERN = /~\d/;
|
||||
|
||||
/** Harness and tooling directories are dot-directories, so any dot segment blocks collapse. */
|
||||
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);
|
||||
}
|
||||
|
||||
/** Realpath of a directory via its nearest existing ancestor; null on any error but a plain missing entry. */
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Real path of a file target, or of its nearest existing ancestor plus the missing tail. */
|
||||
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;
|
||||
}
|
||||
|
||||
/** True when nothing (not even a dangling symlink) exists at the path. */
|
||||
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. A symlinked directory must keep the class and pass the same screen,
|
||||
* so `src/tools -> .claude/hooks` never collapses.
|
||||
*/
|
||||
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 = {
|
||||
WINDOWS_PATH_PATTERN,
|
||||
COLLAPSIBLE_CLASSES,
|
||||
CLASS_QUESTIONS,
|
||||
CLASS_CONDENSED_HINTS,
|
||||
QUOTE_INSTRUCTION,
|
||||
resolveTargetPath,
|
||||
canonicalPathKey,
|
||||
classifyTarget,
|
||||
classPathFor,
|
||||
classifyTargetFor,
|
||||
isCollapsibleTarget,
|
||||
collapseGateDir,
|
||||
isSensitiveTarget,
|
||||
isSensitiveTargetFor
|
||||
};
|
||||
@@ -0,0 +1,255 @@
|
||||
/**
|
||||
* GateGuard current-turn transcript scan: one bounded tail read of a Claude
|
||||
* Code JSONL transcript, walked back to the latest human message or
|
||||
* compaction. Yields the turn id and the turn's completed, non-error search
|
||||
* calls. Shell parsing stays in the hook, which shares it with the
|
||||
* destructive-command gate.
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
const crypto = require('crypto');
|
||||
const fs = require('fs');
|
||||
const { readFileTail, DEFAULT_TRANSCRIPT_TAIL_BYTES } = require('./transcript-context');
|
||||
|
||||
const TRANSCRIPT_SCAN_MAX_LINES = 2000;
|
||||
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';
|
||||
}
|
||||
|
||||
/** Exports never throw: an error here must not escape into the hook's credit or sibling logic. */
|
||||
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')
|
||||
);
|
||||
}
|
||||
|
||||
/** Compaction discards the earlier turn: its summary (or boundary marker) starts a new one. */
|
||||
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;
|
||||
|
||||
// Claude Code stamps every user record of a turn with the same `promptId`, so it
|
||||
// identifies the turn even when the boundary has scrolled out of the tail window.
|
||||
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)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Only a non-empty array of `tool_result` blocks is a tool-result record; any
|
||||
* other user record is a turn boundary, which can only shrink the credit window.
|
||||
*/
|
||||
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 latest human turn. Returns null when the
|
||||
* transcript is unavailable, else the turn id, the turn's searches (newest
|
||||
* first), batch ids, and shell commands. The tail is bounded to keep the hook fast.
|
||||
*/
|
||||
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)
|
||||
});
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
// No boundary: a clipped window is the current turn only if its promptIds agree;
|
||||
// two or more mean an unrecognised turn start. An unclipped file has no turn.
|
||||
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: [] };
|
||||
}
|
||||
|
||||
/** Completed, non-error search calls whose id occurs once, newest first. */
|
||||
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;
|
||||
if (idCounts.get(block.id) !== 1) return; // a reused id cannot be tied to one result
|
||||
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 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 : ''));
|
||||
}
|
||||
|
||||
/** Assistant message whose searches share the pending call's batch (their results were not yet seen). */
|
||||
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;
|
||||
};
|
||||
}
|
||||
|
||||
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)
|
||||
};
|
||||
@@ -281,6 +281,7 @@ module.exports = {
|
||||
DEFAULT_CONTEXT_THRESHOLD_LARGE,
|
||||
DEFAULT_CONTEXT_INTERVAL_TOKENS,
|
||||
DEFAULT_TRANSCRIPT_TAIL_BYTES,
|
||||
readFileTail,
|
||||
readLatestContextTokens,
|
||||
resolveContextWindow,
|
||||
resolveContextWindowTokens,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,455 @@
|
||||
'use strict';
|
||||
/**
|
||||
* Tests for scripts/lib/gateguard-target-class.js: target classification,
|
||||
* question tables, sensitive targets and sibling-collapse eligibility.
|
||||
*
|
||||
* Run with: node tests/lib/gateguard-target-class.test.js
|
||||
*/
|
||||
|
||||
const assert = require('assert');
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
|
||||
const libPath = path.join(__dirname, '..', '..', 'scripts', 'lib', 'gateguard-target-class.js');
|
||||
const hookPath = path.join(__dirname, '..', '..', 'scripts', 'hooks', 'gateguard-fact-force.js');
|
||||
const {
|
||||
CLASS_QUESTIONS,
|
||||
CLASS_CONDENSED_HINTS,
|
||||
COLLAPSIBLE_CLASSES,
|
||||
canonicalPathKey,
|
||||
classifyTarget,
|
||||
classifyTargetFor,
|
||||
isCollapsibleTarget,
|
||||
collapseGateDir,
|
||||
isSensitiveTarget,
|
||||
isSensitiveTargetFor
|
||||
} = require(libPath);
|
||||
|
||||
console.log('=== Testing gateguard-target-class.js ===\n');
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
|
||||
function test(desc, fn) {
|
||||
try {
|
||||
fn();
|
||||
console.log(` ✓ ${desc}`);
|
||||
passed++;
|
||||
} catch (e) {
|
||||
console.log(` ✗ ${desc}: ${e.message}`);
|
||||
failed++;
|
||||
}
|
||||
}
|
||||
|
||||
function withProjectDir(value, fn) {
|
||||
const saved = process.env.CLAUDE_PROJECT_DIR;
|
||||
try {
|
||||
if (value === undefined) delete process.env.CLAUDE_PROJECT_DIR;
|
||||
else process.env.CLAUDE_PROJECT_DIR = value;
|
||||
fn();
|
||||
} finally {
|
||||
if (saved === undefined) delete process.env.CLAUDE_PROJECT_DIR;
|
||||
else process.env.CLAUDE_PROJECT_DIR = saved;
|
||||
}
|
||||
}
|
||||
|
||||
// ── classifyTarget ──
|
||||
console.log('classifyTarget:');
|
||||
|
||||
test('classifyTarget table (normalized, lowercased, first match wins)', () => {
|
||||
assert.strictEqual(typeof classifyTarget, 'function', 'classifyTarget is exported');
|
||||
const cases = [
|
||||
['C:\\Repo\\Skills\\Foo\\Notes.MD', 'instruction'],
|
||||
['/repo/AGENTS.md', 'instruction'],
|
||||
['/repo/commands/plan.md', 'instruction'],
|
||||
['/repo/rules/common/x.txt', 'instruction'],
|
||||
['/repo/hooks/hooks.json', 'config'],
|
||||
['/repo/skills/x/helper.js', 'code'],
|
||||
['/repo/tests/fixtures/readme.md', 'test'],
|
||||
['/repo/pkg/foo_test.go', 'test'],
|
||||
['/repo/test_x.py', 'test'],
|
||||
['/repo/x_test.py', 'test'],
|
||||
['/repo/src/Button.spec.tsx', 'test'],
|
||||
['/repo/src/__tests__/a.js', 'test'],
|
||||
['/repo/README.md', 'prose'],
|
||||
['/repo/docs/a.rst', 'prose'],
|
||||
['/repo/.github/workflows/ci.yml', 'config'],
|
||||
['/repo/pyproject.toml', 'config'],
|
||||
['/repo/.env', 'config'],
|
||||
['/repo/src/main.go', 'code'],
|
||||
['', 'code']
|
||||
];
|
||||
for (const [input, expected] of cases) {
|
||||
assert.strictEqual(classifyTarget(input), expected, `${input} -> ${expected}`);
|
||||
}
|
||||
});
|
||||
|
||||
test('classifyTarget covers the extended instruction and config rules', () => {
|
||||
const cases = [
|
||||
['/repo/.cursor/rules/x.mdc', 'instruction'],
|
||||
['/repo/docs/x.mdc', 'instruction'],
|
||||
['/repo/.cursorrules', 'instruction'],
|
||||
['/repo/.windsurfrules', 'instruction'],
|
||||
['/repo/AGENT.md', 'instruction'],
|
||||
['/repo/.github/copilot-instructions.md', 'instruction'],
|
||||
['/repo/.github/instructions/py.instructions.md', 'instruction'],
|
||||
['/repo/.github/notes.md', 'prose'],
|
||||
['/repo/docs/instructions.md', 'prose'],
|
||||
['/repo/.env', 'config'],
|
||||
['/repo/.env.local', 'config'],
|
||||
['/repo/.env.example.ts', 'code'],
|
||||
['/repo/.env.config.js', 'code'],
|
||||
['/repo/.envrc', 'code'],
|
||||
['/repo/.mcp.json', 'config']
|
||||
];
|
||||
for (const [input, expected] of cases) {
|
||||
assert.strictEqual(classifyTarget(input), expected, `${input} -> ${expected}`);
|
||||
}
|
||||
});
|
||||
|
||||
test('non-string input classifies as code', () => {
|
||||
assert.strictEqual(classifyTarget(undefined), 'code');
|
||||
assert.strictEqual(classifyTarget(null), 'code');
|
||||
assert.strictEqual(classifyTarget(42), 'code');
|
||||
});
|
||||
|
||||
// ── classifyTargetFor ──
|
||||
console.log('\nclassifyTargetFor:');
|
||||
|
||||
test('classifyTargetFor classifies project-relative paths', () => {
|
||||
assert.strictEqual(typeof classifyTargetFor, 'function', 'classifyTargetFor is exported');
|
||||
const saved = process.env.CLAUDE_PROJECT_DIR;
|
||||
try {
|
||||
process.env.CLAUDE_PROJECT_DIR = '/work/tests/proj';
|
||||
const data = { cwd: '/work/tests/proj' };
|
||||
assert.strictEqual(classifyTargetFor('/work/tests/proj/src/a.py', data), 'code');
|
||||
assert.strictEqual(classifyTargetFor('src/a.py', data), 'code', 'relative target');
|
||||
assert.strictEqual(classifyTargetFor('/work/tests/proj/tests/a.py', data), 'test');
|
||||
// No .git in the synthetic worktree, so the prefix stays and the path is under .claude.
|
||||
assert.strictEqual(classifyTargetFor('/work/tests/proj/.claude/worktrees/w1/docs/g.md', data), 'instruction', 'unverified worktree');
|
||||
assert.strictEqual(classifyTargetFor('/work/tests/proj/.claude/worktrees/w1/skills/x/SKILL.md', data), 'instruction');
|
||||
assert.strictEqual(classifyTargetFor('/work/tests/proj/.claude/agents/a.md', data), 'instruction', 'not a worktree');
|
||||
assert.strictEqual(classifyTargetFor('/elsewhere/tests/a.py', data), 'test', 'outside the root: absolute path');
|
||||
assert.strictEqual(classifyTargetFor('/work/tests/project2/src/a.py', data), 'test', 'sibling dir is outside the root');
|
||||
process.env.CLAUDE_PROJECT_DIR = 'C:\\Work\\Tests\\Proj';
|
||||
assert.strictEqual(classifyTargetFor('src\\a.py', { cwd: 'C:\\Work\\Tests\\Proj' }), 'code', 'win32 relative');
|
||||
assert.strictEqual(classifyTargetFor('c:/work/tests/proj/src/a.py', { cwd: 'C:\\Work\\Tests\\Proj' }), 'code', 'win32 case-folded');
|
||||
delete process.env.CLAUDE_PROJECT_DIR;
|
||||
assert.strictEqual(classifyTargetFor('src/a.py', { cwd: '/work/tests/proj' }), 'code', 'cwd fallback root');
|
||||
} finally {
|
||||
if (saved === undefined) delete process.env.CLAUDE_PROJECT_DIR;
|
||||
else process.env.CLAUDE_PROJECT_DIR = saved;
|
||||
}
|
||||
});
|
||||
|
||||
// ── canonicalPathKey ──
|
||||
console.log('\ncanonicalPathKey:');
|
||||
|
||||
test('resolves relative targets against the tool cwd and folds Windows keys', () => {
|
||||
withProjectDir('/proj', () => {
|
||||
assert.strictEqual(canonicalPathKey('src/a.py', { cwd: '/proj/sub' }), '/proj/sub/src/a.py');
|
||||
assert.strictEqual(canonicalPathKey('./a.py', { cwd: '/proj' }), '/proj/a.py');
|
||||
assert.strictEqual(canonicalPathKey('/abs/A.py', { cwd: '/proj' }), '/abs/A.py', 'posix keeps case');
|
||||
});
|
||||
withProjectDir(undefined, () => {
|
||||
assert.strictEqual(canonicalPathKey('src\\A.py', { cwd: 'C:\\Proj' }), 'c:/proj/src/a.py');
|
||||
assert.strictEqual(canonicalPathKey('a.py', { cwd: 'relative/base' }), 'a.py', 'non-absolute base: raw path');
|
||||
});
|
||||
});
|
||||
|
||||
// ── isCollapsibleTarget ──
|
||||
console.log('\nisCollapsibleTarget:');
|
||||
|
||||
test('only code/test/prose outside dotfiles and harness dirs may collapse', () => {
|
||||
assert.deepStrictEqual([...COLLAPSIBLE_CLASSES].sort(), ['code', 'prose', 'test']);
|
||||
withProjectDir('/proj', () => {
|
||||
const data = { cwd: '/proj' };
|
||||
assert.strictEqual(isCollapsibleTarget('/proj/src/a.js', data, 'code'), true);
|
||||
assert.strictEqual(isCollapsibleTarget('/proj/docs/a.md', data, 'prose'), true);
|
||||
assert.strictEqual(isCollapsibleTarget('/proj/app.yaml', data, 'config'), false, 'config');
|
||||
assert.strictEqual(isCollapsibleTarget('/proj/skills/x/SKILL.md', data, 'instruction'), false, 'instruction');
|
||||
assert.strictEqual(isCollapsibleTarget('/proj/src/.hidden.js', data, 'code'), false, 'dotfile');
|
||||
assert.strictEqual(isCollapsibleTarget('/proj/.github/scripts/a.js', data, 'code'), false, 'harness dir');
|
||||
assert.strictEqual(isCollapsibleTarget('/proj/.husky/pre-commit.js', data, 'code'), false, 'hook dir');
|
||||
});
|
||||
});
|
||||
|
||||
test('a new file under any dot-directory never collapses', () => {
|
||||
withProjectDir('/proj', () => {
|
||||
const data = { cwd: '/proj' };
|
||||
for (const rel of [
|
||||
'.devcontainer/post-create.sh',
|
||||
'.githooks/pre-commit',
|
||||
'.kiro/steering/b.md',
|
||||
'.clinerules/b.md',
|
||||
'.idea/run.xml',
|
||||
'.continue/prompts/b.prompt',
|
||||
'src/.generated/a.js',
|
||||
'.vscode/tasks.js'
|
||||
]) {
|
||||
assert.strictEqual(isCollapsibleTarget(`/proj/${rel}`, data, classifyTargetFor(`/proj/${rel}`, data)), false, rel);
|
||||
assert.strictEqual(isCollapsibleTarget(`/proj/${rel}`, data, 'code'), false, `${rel} (as code)`);
|
||||
}
|
||||
assert.strictEqual(isCollapsibleTarget('/proj/src/gen/a.js', data, 'code'), true, 'control');
|
||||
});
|
||||
});
|
||||
|
||||
test('Windows name tricks: trailing dots/spaces, streams and 8.3 short names', () => {
|
||||
withProjectDir(undefined, () => {
|
||||
const data = { cwd: 'C:\\proj' };
|
||||
for (const p of ['C:\\proj\\CLAUDE.md.', 'C:\\proj\\CLAUDE.md ', 'C:\\proj\\CLAUDE.md::$DATA', 'C:\\proj\\CLAUDE.md:x', 'C:\\proj\\CLAUDE.md. .::$DATA']) {
|
||||
assert.strictEqual(classifyTargetFor(p, data), 'instruction', JSON.stringify(p));
|
||||
}
|
||||
assert.strictEqual(classifyTargetFor('C:\\proj\\.claude.\\hooks\\x.md', data), 'instruction', 'trailing dot on a directory');
|
||||
assert.strictEqual(classifyTargetFor('C:\\proj\\src\\a.js', data), 'code', 'control');
|
||||
const shortName = 'C:\\proj\\CLAUDE~1\\hooks\\x.ps1';
|
||||
assert.strictEqual(isCollapsibleTarget(shortName, data, classifyTargetFor(shortName, data)), false, '8.3 short name');
|
||||
assert.strictEqual(isCollapsibleTarget('C:\\proj\\src\\x.ps1', data, 'code'), true, 'control');
|
||||
});
|
||||
withProjectDir('/proj', () => {
|
||||
const data = { cwd: '/proj' };
|
||||
assert.strictEqual(classifyTargetFor('/proj/CLAUDE.md::$DATA', data), 'instruction', 'posix segment with ::$');
|
||||
assert.strictEqual(classifyTargetFor('/proj/notes.md.', data), 'code', 'posix trailing dot is a real name');
|
||||
});
|
||||
});
|
||||
|
||||
// ── collapseGateDir (real directories) ──
|
||||
console.log('\ncollapseGateDir:');
|
||||
|
||||
function tempProject() {
|
||||
const root = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'gateguard-collapse-')));
|
||||
fs.mkdirSync(path.join(root, 'src', 'real'), { recursive: true });
|
||||
fs.mkdirSync(path.join(root, '.claude', 'hooks'), { recursive: true });
|
||||
return root;
|
||||
}
|
||||
|
||||
function trySymlink(target, link) {
|
||||
try {
|
||||
fs.symlinkSync(target, link, 'dir');
|
||||
return true;
|
||||
} catch (e) {
|
||||
if (process.platform === 'win32' && e.code === 'EPERM') return false;
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
test('the worktree prefix is stripped only for a real worktree (.git present)', () => {
|
||||
const root = tempProject();
|
||||
try {
|
||||
withProjectDir(root, () => {
|
||||
const data = { cwd: root };
|
||||
const wt = rel => path.join(root, '.claude', 'worktrees', 'w1', rel);
|
||||
fs.mkdirSync(wt('docs'), { recursive: true });
|
||||
assert.strictEqual(classifyTargetFor(wt('docs/g.md'), data), 'instruction', 'no .git: stays under .claude');
|
||||
assert.strictEqual(classifyTargetFor(wt('src/a.py'), data), 'code');
|
||||
assert.strictEqual(isCollapsibleTarget(wt('src/a.py'), data, 'code'), false, 'no .git: never collapses');
|
||||
fs.writeFileSync(wt('.git'), 'gitdir: ../../../.git/worktrees/w1\n');
|
||||
assert.strictEqual(classifyTargetFor(wt('docs/g.md'), data), 'prose', '.git file: worktree');
|
||||
assert.strictEqual(isCollapsibleTarget(wt('src/a.py'), data, 'code'), true, '.git file: collapses like the project');
|
||||
assert.strictEqual(isCollapsibleTarget(wt('.claude/hooks/x.sh'), data, 'code'), false, 'harness dir inside a worktree');
|
||||
});
|
||||
} finally {
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('collapse is screened on the real directory (symlinks, missing parents, fs errors)', () => {
|
||||
const root = tempProject();
|
||||
try {
|
||||
withProjectDir(root, () => {
|
||||
const data = { cwd: root };
|
||||
const at = rel => path.join(root, rel);
|
||||
const key = p => (process.platform === 'win32' ? p.replace(/\\/g, '/').toLowerCase() : p);
|
||||
assert.strictEqual(collapseGateDir(at('src/real/a.js'), data, 'code'), key(at('src/real')), 'plain directory');
|
||||
assert.strictEqual(collapseGateDir(at('src/new/deeper/a.js'), data, 'code'), key(at('src/new/deeper')), 'missing parents');
|
||||
assert.strictEqual(collapseGateDir(at('.claude/hooks/a.sh'), data, 'code'), null, 'dot-directory');
|
||||
assert.strictEqual(collapseGateDir(at('src/real/a.js'), data, 'config'), null, 'non-collapsible class');
|
||||
fs.writeFileSync(at('src/file'), 'x');
|
||||
assert.strictEqual(collapseGateDir(at('src/file/a.js'), data, 'code'), null, 'parent is a file (ENOTDIR)');
|
||||
if (!trySymlink(at('.claude/hooks'), at('src/tools'))) return;
|
||||
assert.strictEqual(collapseGateDir(at('src/tools/evil.sh'), data, 'code'), null, 'symlink into .claude/hooks');
|
||||
assert.strictEqual(collapseGateDir(at('src/tools/sub/evil.sh'), data, 'code'), null, 'missing dir under that symlink');
|
||||
trySymlink(at('src/real'), at('src/alias'));
|
||||
assert.strictEqual(collapseGateDir(at('src/alias/b.js'), data, 'code'), key(at('src/real')), 'in-project symlink keys by the real dir');
|
||||
trySymlink(at('tests'), at('src/t-alias'));
|
||||
fs.mkdirSync(at('tests'), { recursive: true });
|
||||
assert.strictEqual(collapseGateDir(at('src/t-alias/b.js'), data, 'code'), null, 'real location has another class');
|
||||
const outside = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'gateguard-outside-')));
|
||||
try {
|
||||
trySymlink(outside, at('src/out'));
|
||||
assert.strictEqual(collapseGateDir(at('src/out/b.js'), data, 'code'), null, 'symlink out of the project');
|
||||
} finally {
|
||||
fs.rmSync(outside, { recursive: true, force: true });
|
||||
}
|
||||
trySymlink(at('nowhere'), at('src/dangling'));
|
||||
assert.strictEqual(collapseGateDir(at('src/dangling/b.js'), data, 'code'), null, 'dangling symlink');
|
||||
});
|
||||
} finally {
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('a path of the other platform style is never collapsed', () => {
|
||||
const other = process.platform === 'win32' ? '/proj' : 'C:\\proj';
|
||||
withProjectDir(other, () => {
|
||||
const target = process.platform === 'win32' ? '/proj/src/a.js' : 'C:\\proj\\src\\a.js';
|
||||
assert.strictEqual(collapseGateDir(target, { cwd: other }, 'code'), null);
|
||||
});
|
||||
});
|
||||
|
||||
// ── question tables ──
|
||||
console.log('\nquestion tables:');
|
||||
|
||||
test('every non-code class has questions and a condensed hint; code has neither', () => {
|
||||
for (const cls of ['instruction', 'test', 'prose', 'config']) {
|
||||
for (const isWrite of [true, false]) {
|
||||
const questions = CLASS_QUESTIONS[cls](isWrite);
|
||||
assert.ok(Array.isArray(questions) && questions.length >= 2, `${cls} questions`);
|
||||
assert.ok(CLASS_CONDENSED_HINTS[cls](isWrite).endsWith('then retry.'), `${cls} hint`);
|
||||
}
|
||||
}
|
||||
assert.strictEqual(CLASS_QUESTIONS.code, undefined);
|
||||
assert.strictEqual(CLASS_CONDENSED_HINTS.code, undefined);
|
||||
assert.notDeepStrictEqual(CLASS_QUESTIONS.prose(true), CLASS_QUESTIONS.prose(false), 'prose Write and Edit differ');
|
||||
});
|
||||
|
||||
// ── isSensitiveTarget ──
|
||||
|
||||
test('isSensitiveTarget matches every listed basename, segment and prefix rule', () => {
|
||||
const sensitive = [
|
||||
'.env', '.env.local', '.env.production.local', 'config/.env', 'config/.env.test',
|
||||
'certs/server.pem', 'certs/server.key', 'certs/client.p12', 'certs/client.pfx',
|
||||
'home/id_rsa', 'home/id_rsa.pub', 'id_ed25519', 'home/id_ed25519.pub',
|
||||
'credentials', 'config/credentials.json', 'aws/credentials', 'config/secrets.yaml', 'secrets.json',
|
||||
'src/auth/login.py', 'src/authn/x.ts', 'src/authz/policy.go', 'lib/security/x.js',
|
||||
'src/secrets/vault.py', 'src/payment/charge.py', 'src/payments/x.py', 'app/billing/invoice.rb',
|
||||
'db/migrations/0001_init.sql', 'auth/readme.md', 'src/auth',
|
||||
'.github/workflows/ci.yml', '.github/workflows/sub/x.yaml', './.github/workflows/ci.yml',
|
||||
// Below an ancestor (a fake worktree prefix, or an absolute path outside the root).
|
||||
'.claude/worktrees/fake/.github/workflows/ci.yml', '/elsewhere/repo/.github/workflows/ci.yml',
|
||||
// Case-insensitive, and Windows-style separators.
|
||||
'SRC/Auth/Login.py', '.ENV', 'Certs/Server.PEM', 'src\\billing\\x.py'
|
||||
];
|
||||
for (const p of sensitive) assert.strictEqual(isSensitiveTarget(p), true, p);
|
||||
});
|
||||
|
||||
test('isSensitiveTarget is segment-exact and basename-exact (no substring matches)', () => {
|
||||
const ordinary = [
|
||||
'src/author.py', 'docs/authoring.md', 'lib/paymentutils.py', 'src/authentication/x.py',
|
||||
'src/securityutils/x.py', 'src/billingreport.py', 'src/migration.py', 'src/environment.py',
|
||||
'envoy.yaml', 'src/keyboard.py', 'src/key.py', 'src/secretsmanager.py', 'src/my_credentials.py',
|
||||
'docs/workflows/ci.md', 'github/workflows/ci.yml', '.github/ci.yml', 'src/app.js', 'README.md', 'src/.envrc'
|
||||
];
|
||||
for (const p of ordinary) assert.strictEqual(isSensitiveTarget(p), false, p);
|
||||
});
|
||||
|
||||
test('isSensitiveTarget fails safe (any error => sensitive)', () => {
|
||||
const hostile = { toString() { throw new Error('boom'); } };
|
||||
assert.strictEqual(isSensitiveTarget(hostile), true);
|
||||
assert.strictEqual(isSensitiveTarget(Symbol('x')), true);
|
||||
});
|
||||
|
||||
test('isSensitiveTargetFor uses the project-relative class path', () => {
|
||||
const root = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'gateguard-sensitive-')));
|
||||
try {
|
||||
// The project itself sits under an ancestor named `security`: only project-relative segments count.
|
||||
const proj = path.join(root, 'security', 'proj');
|
||||
fs.mkdirSync(proj, { recursive: true });
|
||||
withProjectDir(proj, () => {
|
||||
const data = { cwd: proj };
|
||||
assert.strictEqual(isSensitiveTargetFor(`${proj}/src/app.py`, data), false, 'ancestor segment ignored');
|
||||
assert.strictEqual(isSensitiveTargetFor(`${proj}/src/auth/app.py`, data), true);
|
||||
assert.strictEqual(isSensitiveTargetFor('src/billing/x.py', data), true, 'relative to cwd');
|
||||
assert.strictEqual(isSensitiveTargetFor(`${proj}/.github/workflows/ci.yml`, data), true);
|
||||
// A real worktree mirrors the project: its prefix is stripped before the rules apply.
|
||||
fs.mkdirSync(path.join(proj, '.claude', 'worktrees', 'w1'), { recursive: true });
|
||||
fs.writeFileSync(path.join(proj, '.claude', 'worktrees', 'w1', '.git'), 'gitdir: x\n');
|
||||
assert.strictEqual(isSensitiveTargetFor(`${proj}/.claude/worktrees/w1/.github/workflows/ci.yml`, data), true);
|
||||
assert.strictEqual(isSensitiveTargetFor(`${proj}/.claude/worktrees/w1/src/app.py`, data), false);
|
||||
// Windows spellings that name the same file are normalized first.
|
||||
assert.strictEqual(isSensitiveTargetFor(`${proj}/.env.`, data), true);
|
||||
});
|
||||
} finally {
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('isSensitiveTargetFor also judges the real (symlink-resolved) path', () => {
|
||||
const root = tempProject();
|
||||
const outside = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'gateguard-r1-outside-')));
|
||||
try {
|
||||
withProjectDir(root, () => {
|
||||
const data = { cwd: root };
|
||||
const at = rel => path.join(root, rel);
|
||||
fs.mkdirSync(at('src/auth'), { recursive: true });
|
||||
fs.writeFileSync(at('src/auth/login.py'), 'x');
|
||||
if (!trySymlink(at('src/auth'), at('src/tools'))) return;
|
||||
assert.strictEqual(isSensitiveTargetFor(at('src/tools/login.py'), data), true, 'existing file through a dir symlink');
|
||||
assert.strictEqual(isSensitiveTargetFor(at('src/tools/new_handler.py'), data), true, 'new file through a dir symlink');
|
||||
assert.strictEqual(isSensitiveTargetFor(at('src/tools/deeper/x.py'), data), true, 'missing dir under the symlink');
|
||||
assert.strictEqual(isSensitiveTargetFor('src/tools/login.py', data), true, 'relative spelling');
|
||||
trySymlink(at('src/real'), at('src/alias'));
|
||||
assert.strictEqual(isSensitiveTargetFor(at('src/alias/a.py'), data), false, 'alias of an ordinary dir');
|
||||
assert.strictEqual(isSensitiveTargetFor(at('src/real/a.py'), data), false, 'direct ordinary path');
|
||||
fs.mkdirSync(path.join(outside, 'secrets'), { recursive: true });
|
||||
trySymlink(path.join(outside, 'secrets'), at('src/ext'));
|
||||
assert.strictEqual(isSensitiveTargetFor(at('src/ext/a.py'), data), true, 'real location outside the project is judged too');
|
||||
trySymlink(outside, at('src/ext-plain'));
|
||||
assert.strictEqual(isSensitiveTargetFor(at('src/ext-plain/a.py'), data), false, 'ordinary location outside the project');
|
||||
trySymlink(at('nowhere'), at('src/dangling'));
|
||||
assert.strictEqual(isSensitiveTargetFor(at('src/dangling/a.py'), data), true, 'dangling symlink fails closed');
|
||||
fs.writeFileSync(at('src/plainfile'), 'x');
|
||||
assert.strictEqual(isSensitiveTargetFor(at('src/plainfile/a.py'), data), true, 'ENOTDIR fails closed');
|
||||
try {
|
||||
fs.symlinkSync(at('src/auth/login.py'), at('src/real/login_link.py'), 'file');
|
||||
assert.strictEqual(isSensitiveTargetFor(at('src/real/login_link.py'), data), true, 'file symlink into auth/');
|
||||
} catch (e) {
|
||||
if (!(process.platform === 'win32' && e.code === 'EPERM')) throw e;
|
||||
}
|
||||
});
|
||||
} finally {
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
fs.rmSync(outside, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('collapseGateDir never keys a gate for a sensitive real location', () => {
|
||||
const root = tempProject();
|
||||
try {
|
||||
withProjectDir(root, () => {
|
||||
const data = { cwd: root };
|
||||
const at = rel => path.join(root, rel);
|
||||
fs.mkdirSync(at('src/payments'), { recursive: true });
|
||||
if (!trySymlink(at('src/payments'), at('src/tools'))) return;
|
||||
assert.strictEqual(collapseGateDir(at('src/tools/a.py'), data, 'code'), null);
|
||||
});
|
||||
} finally {
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('isSensitiveTargetFor fails safe on bad input', () => {
|
||||
assert.strictEqual(isSensitiveTargetFor(undefined, undefined), true);
|
||||
assert.strictEqual(isSensitiveTargetFor({ toString() { throw new Error('x'); } }, {}), true);
|
||||
});
|
||||
|
||||
test('the hook re-exports the lib classification functions', () => {
|
||||
// The hook prunes stale state files on load: point it at a scratch dir.
|
||||
const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gateguard-target-class-'));
|
||||
const savedStateDir = process.env.GATEGUARD_STATE_DIR;
|
||||
process.env.GATEGUARD_STATE_DIR = stateDir;
|
||||
let hook;
|
||||
try {
|
||||
hook = require(hookPath);
|
||||
} finally {
|
||||
if (savedStateDir === undefined) delete process.env.GATEGUARD_STATE_DIR;
|
||||
else process.env.GATEGUARD_STATE_DIR = savedStateDir;
|
||||
fs.rmSync(stateDir, { recursive: true, force: true });
|
||||
}
|
||||
assert.strictEqual(hook.classifyTarget, classifyTarget);
|
||||
assert.strictEqual(hook.classifyTargetFor, classifyTargetFor);
|
||||
});
|
||||
|
||||
console.log(`\nResults: Passed: ${passed}, Failed: ${failed}`);
|
||||
process.exit(failed > 0 ? 1 : 0);
|
||||
@@ -0,0 +1,360 @@
|
||||
'use strict';
|
||||
/**
|
||||
* Tests for scripts/lib/gateguard-turn-scan.js: turn boundaries, turn ids,
|
||||
* result pairing, error results, duplicate ids and same-batch exclusion.
|
||||
*
|
||||
* Run with: node tests/lib/gateguard-turn-scan.test.js
|
||||
*/
|
||||
|
||||
const assert = require('assert');
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
|
||||
const libPath = path.join(__dirname, '..', '..', 'scripts', 'lib', 'gateguard-turn-scan.js');
|
||||
const hookPath = path.join(__dirname, '..', '..', 'scripts', 'hooks', 'gateguard-fact-force.js');
|
||||
const {
|
||||
isErrorToolResult,
|
||||
isToolResultRecord,
|
||||
isCompactionBoundary,
|
||||
scanCurrentTurn,
|
||||
excludedBatchId,
|
||||
createTurnScanner,
|
||||
currentTurnId,
|
||||
transcriptPathFor
|
||||
} = require(libPath);
|
||||
|
||||
console.log('=== Testing gateguard-turn-scan.js ===\n');
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
|
||||
function test(desc, fn) {
|
||||
try {
|
||||
fn();
|
||||
console.log(` ✓ ${desc}`);
|
||||
passed++;
|
||||
} catch (e) {
|
||||
console.log(` ✗ ${desc}: ${e.message}`);
|
||||
failed++;
|
||||
}
|
||||
}
|
||||
|
||||
// Transcript fixtures in the shape Claude Code writes (one JSON record per line).
|
||||
const transcriptDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gateguard-turn-scan-'));
|
||||
let seq = 0;
|
||||
let uuidSeq = 0;
|
||||
const nextUuid = () => `uuid-${++uuidSeq}`;
|
||||
const human = (text, extra = {}) => ({ type: 'user', uuid: nextUuid(), message: { role: 'user', content: text }, ...extra });
|
||||
const toolUse = (id, name, input, extra = {}) => ({
|
||||
type: 'assistant',
|
||||
uuid: nextUuid(),
|
||||
message: { id: `msg_${id}`, role: 'assistant', content: [{ type: 'tool_use', id, name, input }] },
|
||||
...extra
|
||||
});
|
||||
const toolResult = (id, isError = false, extra = {}) => ({
|
||||
type: 'user',
|
||||
uuid: nextUuid(),
|
||||
message: { role: 'user', content: [{ type: 'tool_result', tool_use_id: id, is_error: isError, content: 'ok' }] },
|
||||
...extra
|
||||
});
|
||||
const search = (id, name, input) => [toolUse(id, name, input), toolResult(id)];
|
||||
// Like Claude Code, a fixture ends with the pending call's own assistant record by default.
|
||||
const writeTranscript = (records, { pending = true } = {}) => {
|
||||
seq += 1;
|
||||
const file = path.join(transcriptDir, `t-${seq}.jsonl`);
|
||||
const all = records.slice();
|
||||
if (pending) {
|
||||
all.push({
|
||||
type: 'assistant',
|
||||
uuid: nextUuid(),
|
||||
message: { id: `msg_pending_${seq}`, role: 'assistant', content: [{ type: 'tool_use', id: `toolu_pending_${seq}`, name: 'Edit', input: {} }] }
|
||||
});
|
||||
}
|
||||
const lines = all.map(r => (typeof r === 'string' ? r : JSON.stringify(r)));
|
||||
fs.writeFileSync(file, lines.join('\n') + '\n', 'utf8');
|
||||
return file;
|
||||
};
|
||||
// ~400 KiB of tool traffic: pushes earlier records out of the 256 KiB tail.
|
||||
const filler = prefix => {
|
||||
const records = [];
|
||||
for (let i = 0; i < 40; i++) {
|
||||
const id = `toolu_${prefix}${i}`;
|
||||
records.push(toolUse(id, 'Bash', { command: 'npm test' }));
|
||||
records.push({
|
||||
type: 'user',
|
||||
message: { role: 'user', content: [{ type: 'tool_result', tool_use_id: id, content: 'y'.repeat(10 * 1024) }] }
|
||||
});
|
||||
}
|
||||
return records;
|
||||
};
|
||||
|
||||
// ── turn id ──
|
||||
console.log('turn id:');
|
||||
|
||||
// Every user record of a turn (prompt and tool_results) carries the same promptId.
|
||||
test('boundary in window with promptId -> turnId is the promptId', () => {
|
||||
const t = writeTranscript([
|
||||
human('old', { promptId: 'prompt-old' }),
|
||||
human('now', { promptId: 'prompt-now' }),
|
||||
...search('toolu_pid1', 'Grep', { pattern: 'widget_factory' }).map(r => (r.type === 'user' ? { ...r, promptId: 'prompt-now' } : r))
|
||||
]);
|
||||
assert.strictEqual(scanCurrentTurn(t).turnId, 'prompt-now');
|
||||
const overlong = human('x', { promptId: 'p'.repeat(129) });
|
||||
assert.strictEqual(scanCurrentTurn(writeTranscript([overlong])).turnId, overlong.uuid, 'overlong promptId ignored');
|
||||
});
|
||||
|
||||
test('records without promptId keep the uuid / hash / null turn id', () => {
|
||||
const boundary = human('now');
|
||||
assert.strictEqual(scanCurrentTurn(writeTranscript([boundary])).turnId, boundary.uuid);
|
||||
const noUuid = writeTranscript([{ type: 'user', message: { role: 'user', content: 'x' } }]);
|
||||
assert.ok(/^h:[0-9a-f]{16}$/.test(scanCurrentTurn(noUuid).turnId));
|
||||
const clipped = writeTranscript([human('long ago'), ...filler('nopid')]);
|
||||
assert.strictEqual(scanCurrentTurn(clipped).turnId, null, 'clipped window without promptId');
|
||||
});
|
||||
|
||||
test('compaction summary and compact_boundary records start a turn', () => {
|
||||
const pre = [human('orig task'), ...search('toolu_c1', 'Grep', { pattern: 'payment' })];
|
||||
const summary = { type: 'user', uuid: nextUuid(), isCompactSummary: true, message: { role: 'user', content: 'Summary' } };
|
||||
const afterSummary = scanCurrentTurn(writeTranscript([...pre, summary], { pending: false }));
|
||||
assert.strictEqual(afterSummary.turnId, summary.uuid);
|
||||
assert.deepStrictEqual(afterSummary.searches, [], 'searches before compaction do not count');
|
||||
const marker = scanCurrentTurn(writeTranscript([...pre, { type: 'system', uuid: 'cb-1', subtype: 'compact_boundary' }], { pending: false }));
|
||||
assert.strictEqual(marker.turnId, 'cb-1');
|
||||
assert.deepStrictEqual(marker.searches, []);
|
||||
assert.strictEqual(isCompactionBoundary({ ...summary, isSidechain: true }), false, 'sidechain summaries are ignored');
|
||||
});
|
||||
|
||||
test('meta and sidechain user records are not boundaries; tool_result records are not either', () => {
|
||||
const boundary = human('go');
|
||||
const t = writeTranscript(
|
||||
[
|
||||
boundary,
|
||||
...search('toolu_m1', 'Grep', { pattern: 'alpha' }),
|
||||
human('meta note', { isMeta: true }),
|
||||
human('side prompt', { isSidechain: true })
|
||||
],
|
||||
{ pending: false }
|
||||
);
|
||||
const scan = scanCurrentTurn(t);
|
||||
assert.strictEqual(scan.turnId, boundary.uuid);
|
||||
assert.deepStrictEqual(scan.searches.map(s => s.name), ['Grep']);
|
||||
assert.strictEqual(isToolResultRecord(toolResult('x')), true);
|
||||
assert.strictEqual(isToolResultRecord(human('x')), false);
|
||||
});
|
||||
|
||||
// ── which searches qualify ──
|
||||
console.log('\nsearches:');
|
||||
|
||||
test('a search qualifies only with a paired, non-error, unique result', () => {
|
||||
const t = writeTranscript(
|
||||
[
|
||||
human('go'),
|
||||
...search('toolu_ok', 'Grep', { pattern: 'ok' }),
|
||||
toolUse('toolu_noresult', 'Grep', { pattern: 'pending' }),
|
||||
toolUse('toolu_err', 'Grep', { pattern: 'err' }),
|
||||
toolResult('toolu_err', true),
|
||||
...search('toolu_dup', 'Glob', { pattern: 'a/*' }),
|
||||
...search('toolu_dup', 'Glob', { pattern: 'b/*' }),
|
||||
...search('toolu_read', 'Read', { file_path: '/x.js' })
|
||||
],
|
||||
{ pending: false }
|
||||
);
|
||||
const scan = scanCurrentTurn(t);
|
||||
assert.deepStrictEqual(scan.searches.map(s => s.input.pattern), ['ok']);
|
||||
});
|
||||
|
||||
test('error results are recognised without a boolean is_error', () => {
|
||||
assert.strictEqual(isErrorToolResult({ is_error: true }), true);
|
||||
assert.strictEqual(isErrorToolResult({ is_error: 'true' }), true);
|
||||
assert.strictEqual(isErrorToolResult({ content: ' <tool_use_error>denied</tool_use_error>' }), true);
|
||||
assert.strictEqual(isErrorToolResult({ content: [{ type: 'text', text: '<tool_use_error>x' }] }), true);
|
||||
assert.strictEqual(isErrorToolResult({ is_error: false, content: 'found 3 files' }), false);
|
||||
});
|
||||
|
||||
test('callsAgo counts back from the newest call, skipping the pending call', () => {
|
||||
const t = writeTranscript([human('go'), ...search('toolu_a', 'Grep', { pattern: 'a' }), ...search('toolu_b', 'Bash', { command: 'ls' })]);
|
||||
const scan = scanCurrentTurn(t, `toolu_pending_${seq}`);
|
||||
assert.deepStrictEqual(scan.searches.map(s => [s.name, s.callsAgo]), [['Bash', 1], ['Grep', 2]]);
|
||||
assert.deepStrictEqual(scan.shellCommands, ['ls']);
|
||||
});
|
||||
|
||||
test('no transcript, a directory, or a whole file without a boundary -> no searches', () => {
|
||||
assert.strictEqual(scanCurrentTurn(''), null);
|
||||
assert.strictEqual(scanCurrentTurn(path.join(transcriptDir, 'missing.jsonl')), null);
|
||||
assert.strictEqual(scanCurrentTurn(transcriptDir), null);
|
||||
const noBoundary = scanCurrentTurn(writeTranscript(search('toolu_nb', 'Grep', { pattern: 'x' }), { pending: false }));
|
||||
assert.deepStrictEqual(noBoundary.searches, []);
|
||||
assert.strictEqual(noBoundary.turnId, null);
|
||||
});
|
||||
|
||||
// The turn id is the boundary's own promptId; a clipped window needs agreeing promptIds.
|
||||
test('boundary in window -> the boundary record\'s own promptId, not a newer record\'s', () => {
|
||||
const late = { ...toolResult('toolu_n3a'), promptId: 'P1' };
|
||||
const t = writeTranscript([human('one', { promptId: 'P1' }), human('two', { promptId: 'P2' }), toolUse('toolu_n3a', 'Bash', { command: 'true' }), late]);
|
||||
assert.strictEqual(scanCurrentTurn(t).turnId, 'P2', 'late tool_result stamped with the previous promptId');
|
||||
const noPid = human('two');
|
||||
const t2 = writeTranscript([noPid, toolUse('toolu_n3b', 'Bash', { command: 'true' }), { ...toolResult('toolu_n3b'), promptId: 'P9' }]);
|
||||
assert.strictEqual(scanCurrentTurn(t2).turnId, noPid.uuid, 'boundary without promptId falls back to its uuid');
|
||||
});
|
||||
|
||||
test('clipped window without a boundary -> promptId only when all user records agree', () => {
|
||||
const stamp = (records, pid) => records.map(r => (r.type === 'user' ? { ...r, promptId: pid } : r));
|
||||
const agree = writeTranscript([
|
||||
human('long ago', { promptId: 'PA' }),
|
||||
...stamp([...filler('agree'), ...search('toolu_agree', 'Grep', { pattern: 'payment' })], 'PA')
|
||||
]);
|
||||
const agreeScan = scanCurrentTurn(agree);
|
||||
assert.strictEqual(agreeScan.turnId, 'PA');
|
||||
assert.ok(agreeScan.searches.some(s => s.name === 'Grep'), 'agreeing clipped window still credits');
|
||||
const mixed = filler('mixed');
|
||||
const disagree = writeTranscript([
|
||||
human('long ago', { promptId: 'PA' }),
|
||||
...stamp(mixed.slice(0, 60), 'PA'),
|
||||
...stamp([...mixed.slice(60), ...search('toolu_dis', 'Grep', { pattern: 'payment' })], 'PB')
|
||||
]);
|
||||
const disagreeScan = scanCurrentTurn(disagree);
|
||||
assert.strictEqual(disagreeScan.turnId, null, 'two promptIds in the window');
|
||||
assert.deepStrictEqual(disagreeScan.searches, [], 'unattributable clipped window gives no credit');
|
||||
const metaNew = writeTranscript([
|
||||
human('long ago', { promptId: 'PA' }),
|
||||
...stamp([...filler('meta'), ...search('toolu_meta', 'Grep', { pattern: 'payment' })], 'PA'),
|
||||
{ type: 'user', uuid: nextUuid(), isMeta: true, promptId: 'PN', message: { role: 'user', content: '<command-message>' } }
|
||||
]);
|
||||
const metaScan = scanCurrentTurn(metaNew);
|
||||
assert.strictEqual(metaScan.turnId, null, 'a meta record from another prompt also disagrees');
|
||||
assert.deepStrictEqual(metaScan.searches, [], 'previous-turn search behind an unrecognised turn start is not credited');
|
||||
const partial = writeTranscript([human('long ago', { promptId: 'PA' }), ...filler('partial').map((r, i) => (r.type === 'user' && i > 70 ? { ...r, promptId: 'PA' } : r))]);
|
||||
assert.strictEqual(scanCurrentTurn(partial).turnId, 'PA', 'records without a promptId do not disagree');
|
||||
});
|
||||
|
||||
// Malformed records and inputs never throw; the record is skipped.
|
||||
const MALFORMED_RECORDS = [
|
||||
'{"type":"assistant"}',
|
||||
'{"type":"assistant","message":null}',
|
||||
'{"type":"assistant","message":"str"}',
|
||||
'{"type":"assistant","message":{"content":"str"}}',
|
||||
'{"type":"assistant","message":{"content":[null,5,"x",{"type":"tool_use"}]}}',
|
||||
'{"type":"user","message":{"content":[{"type":"tool_result","tool_use_id":"q","content":{"toString":1}}]}}',
|
||||
'{"type":"user","message":{"content":[{"type":"tool_result","tool_use_id":"q","content":[null,{"type":"text","text":5}]}]}}',
|
||||
'null',
|
||||
'5',
|
||||
'"str"',
|
||||
'[]'
|
||||
];
|
||||
const MALFORMED_INPUTS = [
|
||||
undefined,
|
||||
null,
|
||||
0,
|
||||
1,
|
||||
'',
|
||||
'x',
|
||||
[],
|
||||
{},
|
||||
{ message: null },
|
||||
{ message: 'str' },
|
||||
{ type: 'assistant' },
|
||||
{ type: 'user', message: { content: {} } },
|
||||
{ cwd: 5 },
|
||||
{ tool_use_id: 5 },
|
||||
{ batchIds: null, newestMessageId: 5 },
|
||||
{ searches: null },
|
||||
new Proxy({}, {
|
||||
get() {
|
||||
throw new Error('trap');
|
||||
}
|
||||
})
|
||||
];
|
||||
|
||||
test('every exported function tolerates malformed inputs without throwing', () => {
|
||||
const lib = require(libPath);
|
||||
const fns = Object.entries(lib).filter(([, fn]) => typeof fn === 'function');
|
||||
assert.ok(fns.length >= 9, `exported functions: ${fns.map(([name]) => name).join(', ')}`);
|
||||
for (const [name, fn] of fns) {
|
||||
MALFORMED_INPUTS.forEach((a, i) => {
|
||||
MALFORMED_INPUTS.forEach((b, j) => {
|
||||
try {
|
||||
const result = fn(a, b);
|
||||
if (typeof result === 'function') result();
|
||||
} catch (e) {
|
||||
assert.fail(`${name}(MALFORMED_INPUTS[${i}], MALFORMED_INPUTS[${j}]) threw: ${e.message}`);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test('scanCurrentTurn skips each malformed record and keeps the rest of the turn', () => {
|
||||
for (const bad of MALFORMED_RECORDS) {
|
||||
const t = writeTranscript([human('go', { promptId: 'PM' }), ...search('toolu_m1', 'Grep', { pattern: 'payment' }), bad]);
|
||||
let scan;
|
||||
assert.doesNotThrow(() => {
|
||||
scan = scanCurrentTurn(t, `toolu_pending_${seq}`);
|
||||
}, bad);
|
||||
assert.ok(scan, `${bad}: scan result`);
|
||||
assert.strictEqual(scan.turnId, 'PM', `${bad}: turn id`);
|
||||
assert.deepStrictEqual(scan.searches.map(s => s.name), ['Grep'], `${bad}: search kept`);
|
||||
}
|
||||
// A user record without usable content is a turn boundary, which can only shrink the window.
|
||||
for (const bad of ['{"type":"user"}', '{"type":"user","message":null}', '{"type":"user","message":{"content":{}}}']) {
|
||||
const t = writeTranscript([human('go', { promptId: 'PM' }), ...search('toolu_m2', 'Grep', { pattern: 'payment' }), bad]);
|
||||
const scan = scanCurrentTurn(t, `toolu_pending_${seq}`);
|
||||
assert.ok(scan && /^h:/.test(scan.turnId), `${bad}: boundary turn id`);
|
||||
assert.deepStrictEqual(scan.searches, [], `${bad}: searches before it do not count`);
|
||||
}
|
||||
});
|
||||
|
||||
// ── same-batch exclusion ──
|
||||
console.log('\nsame batch:');
|
||||
|
||||
test('excludedBatchId: the pending call message when located, else the newest message', () => {
|
||||
const t = writeTranscript([human('go'), ...search('toolu_s1', 'Grep', { pattern: 'a' })]);
|
||||
const pendingId = `toolu_pending_${seq}`;
|
||||
const scan = scanCurrentTurn(t, pendingId);
|
||||
assert.strictEqual(excludedBatchId(scan, { tool_use_id: pendingId }), `msg_pending_${seq}`);
|
||||
assert.strictEqual(excludedBatchId(scan, {}), scan.newestMessageId);
|
||||
assert.strictEqual(excludedBatchId(scan, { tool_use_id: 'toolu_unknown' }), `msg_pending_${seq}`, 'unknown id: newest message');
|
||||
});
|
||||
|
||||
// ── scanner wrapper ──
|
||||
console.log('\nscanner:');
|
||||
|
||||
test('createTurnScanner reads the transcript once and currentTurnId guards the shape', () => {
|
||||
const boundary = human('go');
|
||||
const t = writeTranscript([boundary]);
|
||||
const saved = process.env.CLAUDE_TRANSCRIPT_PATH;
|
||||
try {
|
||||
process.env.CLAUDE_TRANSCRIPT_PATH = '';
|
||||
const getScan = createTurnScanner({ transcript_path: t });
|
||||
const first = getScan();
|
||||
fs.writeFileSync(t, JSON.stringify(human('changed')) + '\n', 'utf8');
|
||||
assert.strictEqual(getScan(), first, 'cached for the rest of run()');
|
||||
assert.strictEqual(currentTurnId(first), boundary.uuid);
|
||||
assert.strictEqual(currentTurnId(null), null);
|
||||
assert.strictEqual(currentTurnId({ turnId: '' }), null);
|
||||
assert.strictEqual(transcriptPathFor({ transcriptPath: '/x.jsonl' }), '/x.jsonl');
|
||||
assert.strictEqual(createTurnScanner({})(), null, 'no transcript: null scan');
|
||||
} finally {
|
||||
if (saved === undefined) delete process.env.CLAUDE_TRANSCRIPT_PATH;
|
||||
else process.env.CLAUDE_TRANSCRIPT_PATH = saved;
|
||||
}
|
||||
});
|
||||
|
||||
test('the hook re-exports scanCurrentTurn', () => {
|
||||
// The hook prunes stale state files on load: point it at a scratch dir.
|
||||
const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gateguard-turn-scan-state-'));
|
||||
const savedStateDir = process.env.GATEGUARD_STATE_DIR;
|
||||
process.env.GATEGUARD_STATE_DIR = stateDir;
|
||||
try {
|
||||
assert.strictEqual(require(hookPath).scanCurrentTurn, scanCurrentTurn);
|
||||
} finally {
|
||||
if (savedStateDir === undefined) delete process.env.GATEGUARD_STATE_DIR;
|
||||
else process.env.GATEGUARD_STATE_DIR = savedStateDir;
|
||||
fs.rmSync(stateDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
fs.rmSync(transcriptDir, { recursive: true, force: true });
|
||||
|
||||
console.log(`\nResults: Passed: ${passed}, Failed: ${failed}`);
|
||||
process.exit(failed > 0 ? 1 : 0);
|
||||
@@ -18,6 +18,8 @@ const {
|
||||
DEFAULT_CONTEXT_THRESHOLD_STANDARD,
|
||||
DEFAULT_CONTEXT_THRESHOLD_LARGE,
|
||||
DEFAULT_CONTEXT_INTERVAL_TOKENS,
|
||||
DEFAULT_TRANSCRIPT_TAIL_BYTES,
|
||||
readFileTail,
|
||||
readLatestContextTokens,
|
||||
resolveContextWindowTokens,
|
||||
resolveContextThreshold,
|
||||
@@ -323,6 +325,20 @@ test('returns -1 for non-finite token counts', () => {
|
||||
assert.strictEqual(computeContextBucket(NaN, 160000, 60000), -1);
|
||||
});
|
||||
|
||||
// ── readFileTail ──
|
||||
console.log('\nreadFileTail:');
|
||||
|
||||
test('is exported and returns the trailing bytes with a truncation flag', () => {
|
||||
assert.strictEqual(typeof readFileTail, 'function');
|
||||
assert.strictEqual(DEFAULT_TRANSCRIPT_TAIL_BYTES, 256 * 1024);
|
||||
const file = tracked(writeTranscript(['first-line', 'second', 'third']));
|
||||
const whole = readFileTail(file, 1024);
|
||||
assert.deepStrictEqual(whole, { text: 'first-line\nsecond\nthird\n', truncated: false });
|
||||
const tail = readFileTail(file, 6);
|
||||
assert.deepStrictEqual(tail, { text: 'third\n', truncated: true });
|
||||
assert.strictEqual(readFileTail(path.join(os.tmpdir(), `missing-${process.pid}.jsonl`), 10), null);
|
||||
});
|
||||
|
||||
// ── formatWindowLabel ──
|
||||
console.log('\nformatWindowLabel:');
|
||||
|
||||
|
||||
Reference in New Issue
Block a user