Files
ECC/scripts/lib/gateguard-turn-scan.js
T

281 lines
10 KiB
JavaScript

'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)
};