mirror of
https://github.com/affaan-m/everything-claude-code.git
synced 2026-05-14 02:10:07 +08:00
879 lines
26 KiB
JavaScript
879 lines
26 KiB
JavaScript
#!/usr/bin/env node
|
|
/**
|
|
* PreToolUse Hook: GateGuard Fact-Forcing Gate
|
|
*
|
|
* Forces Claude to investigate before editing files or running commands.
|
|
* Instead of asking "are you sure?" (which LLMs always answer "yes"),
|
|
* this hook demands concrete facts: importers, public API, data schemas.
|
|
*
|
|
* The act of investigation creates awareness that self-evaluation never did.
|
|
*
|
|
* Gates:
|
|
* - Edit/Write: list importers, affected API, verify data schemas, quote instruction
|
|
* - Bash (destructive): list targets, rollback plan, quote instruction
|
|
* - Bash (routine): quote current instruction (once per session)
|
|
*
|
|
* Compatible with run-with-flags.js via module.exports.run().
|
|
* Cross-platform (Windows, macOS, Linux).
|
|
*
|
|
* Full package with config support: pip install gateguard-ai
|
|
* Repo: https://github.com/zunoworks/gateguard
|
|
*/
|
|
|
|
'use strict';
|
|
|
|
const crypto = require('crypto');
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
|
|
// Session state — scoped per session to avoid cross-session races.
|
|
const STATE_DIR = process.env.GATEGUARD_STATE_DIR || path.join(process.env.HOME || process.env.USERPROFILE || '/tmp', '.gateguard');
|
|
let activeStateFile = null;
|
|
|
|
// State expires after 30 minutes of inactivity
|
|
const SESSION_TIMEOUT_MS = 30 * 60 * 1000;
|
|
const READ_HEARTBEAT_MS = 60 * 1000;
|
|
|
|
// Maximum checked entries to prevent unbounded growth
|
|
const MAX_CHECKED_ENTRIES = 500;
|
|
const MAX_SESSION_KEYS = 50;
|
|
const ROUTINE_BASH_SESSION_KEY = '__bash_session__';
|
|
const EDIT_WRITE_HOOK_ID = 'pre:edit-write:gateguard-fact-force';
|
|
const BASH_HOOK_ID = 'pre:bash:gateguard-fact-force';
|
|
const ECC_DISABLE_VALUES = new Set(['0', 'false', 'off', 'disabled', 'disable']);
|
|
|
|
// SQL-keyword + dd patterns stay as a single regex — they are stable
|
|
// phrases without shell-flag ordering concerns. Quoted strings are
|
|
// stripped before this regex runs so a commit message mentioning
|
|
// "drop table" no longer triggers a false positive.
|
|
const DESTRUCTIVE_SQL_DD = /\b(drop\s+table|delete\s+from|truncate|dd\s+if=)\b/i;
|
|
|
|
/**
|
|
* Strip the contents of single- and double-quoted strings so phrases
|
|
* mentioned inside a commit message or echoed argument do not trigger
|
|
* the destructive detector. Command substitutions are scanned separately
|
|
* before this runs because they execute even inside double quotes.
|
|
*
|
|
* @param {string} input
|
|
* @returns {string}
|
|
*/
|
|
function stripQuotedStrings(input) {
|
|
return input
|
|
.replace(/'(?:[^'\\]|\\.)*'/g, "''")
|
|
.replace(/"(?:[^"\\]|\\.)*"/g, '""');
|
|
}
|
|
|
|
/**
|
|
* Promote subshell delimiters to top-level segment separators so the
|
|
* destructive check applies inside `$(...)` and backtick subshells.
|
|
* Without this, `echo y | $(rm -rf /tmp)` and ``echo y | `rm -rf /tmp` ``
|
|
* slip past the segment splitter because the destructive command lives
|
|
* inside a sub-expression. Run iteratively to handle a layer of nesting.
|
|
*
|
|
* @param {string} input
|
|
* @returns {string}
|
|
*/
|
|
function explodeSubshells(input) {
|
|
let out = input;
|
|
for (let i = 0; i < 4; i += 1) {
|
|
const before = out;
|
|
out = out.replace(/\$\(([^()`]*)\)/g, ';$1;');
|
|
out = out.replace(/`([^`]*)`/g, ';$1;');
|
|
if (out === before) break;
|
|
}
|
|
return out;
|
|
}
|
|
|
|
/**
|
|
* Extract executable command-substitution bodies from a shell line. Single
|
|
* quotes are literal, so substitutions inside them are ignored; double quotes
|
|
* still permit substitutions, so those bodies are scanned before quoted text
|
|
* is stripped.
|
|
*
|
|
* @param {string} input
|
|
* @returns {string[]}
|
|
*/
|
|
function extractCommandSubstitutions(input) {
|
|
const source = String(input || '');
|
|
const substitutions = [];
|
|
let inSingle = false;
|
|
let inDouble = false;
|
|
|
|
for (let i = 0; i < source.length; i++) {
|
|
const ch = source[i];
|
|
const prev = source[i - 1];
|
|
|
|
if (ch === '\\' && !inSingle) {
|
|
i += 1;
|
|
continue;
|
|
}
|
|
|
|
if (ch === "'" && !inDouble && prev !== '\\') {
|
|
inSingle = !inSingle;
|
|
continue;
|
|
}
|
|
|
|
if (ch === '"' && !inSingle && prev !== '\\') {
|
|
inDouble = !inDouble;
|
|
continue;
|
|
}
|
|
|
|
if (inSingle) {
|
|
continue;
|
|
}
|
|
|
|
if (ch === '`') {
|
|
let body = '';
|
|
i += 1;
|
|
while (i < source.length) {
|
|
const inner = source[i];
|
|
if (inner === '\\') {
|
|
body += inner;
|
|
if (i + 1 < source.length) {
|
|
body += source[i + 1];
|
|
i += 2;
|
|
continue;
|
|
}
|
|
}
|
|
if (inner === '`') {
|
|
break;
|
|
}
|
|
body += inner;
|
|
i += 1;
|
|
}
|
|
if (body.trim()) {
|
|
substitutions.push(body);
|
|
substitutions.push(...extractCommandSubstitutions(body));
|
|
}
|
|
continue;
|
|
}
|
|
|
|
if (ch === '$' && source[i + 1] === '(') {
|
|
let depth = 1;
|
|
let body = '';
|
|
i += 2;
|
|
while (i < source.length && depth > 0) {
|
|
const inner = source[i];
|
|
if (inner === '\\') {
|
|
body += inner;
|
|
if (i + 1 < source.length) {
|
|
body += source[i + 1];
|
|
i += 2;
|
|
continue;
|
|
}
|
|
}
|
|
if (inner === '(') {
|
|
depth += 1;
|
|
} else if (inner === ')') {
|
|
depth -= 1;
|
|
if (depth === 0) {
|
|
break;
|
|
}
|
|
}
|
|
body += inner;
|
|
i += 1;
|
|
}
|
|
if (body.trim()) {
|
|
substitutions.push(body);
|
|
substitutions.push(...extractCommandSubstitutions(body));
|
|
}
|
|
}
|
|
}
|
|
|
|
return substitutions;
|
|
}
|
|
|
|
/**
|
|
* Split a command line into top-level segments at unquoted shell
|
|
* separators (`;`, `|`, `&`, `&&`, `||`) and across subshells
|
|
* (`$(...)` / backticks). Quoted strings are stripped first so
|
|
* separators inside quotes are not split on. Per-segment comments
|
|
* are also stripped.
|
|
*
|
|
* @param {string} input
|
|
* @returns {string[]}
|
|
*/
|
|
function splitCommandSegments(input) {
|
|
const stripped = explodeSubshells(stripQuotedStrings(input));
|
|
return stripped
|
|
.split(/[;|&]+/)
|
|
.map(segment => segment.replace(/(^|\s)#.*/, '$1').trim())
|
|
.filter(Boolean);
|
|
}
|
|
|
|
/**
|
|
* Tokenize a single command segment by whitespace. Quoted strings
|
|
* are already collapsed to empty quotes by `stripQuotedStrings`, so
|
|
* naive whitespace splitting is sufficient.
|
|
*
|
|
* @param {string} segment
|
|
* @returns {string[]}
|
|
*/
|
|
function tokenize(segment) {
|
|
return segment.split(/\s+/).filter(Boolean);
|
|
}
|
|
|
|
/**
|
|
* Strip a leading path and trailing `.exe` from a command token so
|
|
* `/usr/bin/git`, `git.exe`, and `GIT` all normalize to `git`.
|
|
*
|
|
* @param {string} token
|
|
* @returns {string}
|
|
*/
|
|
function commandBasename(token) {
|
|
if (!token) return '';
|
|
return token.replace(/^.*[\\/]/, '').replace(/\.exe$/i, '').toLowerCase();
|
|
}
|
|
|
|
/**
|
|
* Detect `rm` invocations that recursively force-delete files. Handles
|
|
* combined (`-rf`, `-fr`, `-Rf`) and split (`-r -f`) flag forms.
|
|
*
|
|
* @param {string[]} tokens
|
|
* @returns {boolean}
|
|
*/
|
|
function isDestructiveRm(tokens) {
|
|
if (tokens.length === 0 || commandBasename(tokens[0]) !== 'rm') return false;
|
|
let hasR = false;
|
|
let hasF = false;
|
|
for (const t of tokens.slice(1)) {
|
|
if (t === '--recursive') {
|
|
hasR = true;
|
|
continue;
|
|
}
|
|
if (t === '--force') {
|
|
hasF = true;
|
|
continue;
|
|
}
|
|
if (!t.startsWith('-') || t.startsWith('--')) continue;
|
|
const body = t.slice(1);
|
|
if (/[rR]/.test(body)) hasR = true;
|
|
if (/f/.test(body)) hasF = true;
|
|
}
|
|
return hasR && hasF;
|
|
}
|
|
|
|
/**
|
|
* Locate the git subcommand within a token list, skipping over git's
|
|
* global options like `-c key=value`, `-C <path>`, `--git-dir=...`,
|
|
* `--work-tree=...`, `--namespace=...`, `--super-prefix=...`.
|
|
*
|
|
* @param {string[]} tokens
|
|
* @returns {{ command: string, rest: string[] } | null}
|
|
*/
|
|
function findGitSubcommand(tokens) {
|
|
if (tokens.length === 0 || commandBasename(tokens[0]) !== 'git') return null;
|
|
const valueConsumingShort = new Set(['-c', '-C']);
|
|
const valueConsumingLong = new Set(['--git-dir', '--work-tree', '--namespace', '--super-prefix']);
|
|
let i = 1;
|
|
while (i < tokens.length) {
|
|
const t = tokens[i];
|
|
if (valueConsumingShort.has(t) || valueConsumingLong.has(t)) {
|
|
i += 2;
|
|
continue;
|
|
}
|
|
if (t.startsWith('--git-dir=') || t.startsWith('--work-tree=') || t.startsWith('--namespace=') || t.startsWith('--super-prefix=')) {
|
|
i += 1;
|
|
continue;
|
|
}
|
|
if (t.startsWith('-')) {
|
|
// Unknown global option — skip without consuming a value.
|
|
i += 1;
|
|
continue;
|
|
}
|
|
return { command: t.toLowerCase(), rest: tokens.slice(i + 1) };
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Detect destructive `git` invocations: `reset --hard`, `checkout --`,
|
|
* `clean -f...`, `push --force` (but not `--force-with-lease`),
|
|
* `commit --amend`, `rm -rf`.
|
|
*
|
|
* @param {string[]} tokens
|
|
* @returns {boolean}
|
|
*/
|
|
function isDestructiveGit(tokens) {
|
|
const sub = findGitSubcommand(tokens);
|
|
if (!sub) return false;
|
|
const { command, rest } = sub;
|
|
|
|
if (command === 'reset') {
|
|
return rest.includes('--hard');
|
|
}
|
|
|
|
if (command === 'checkout') {
|
|
return rest.includes('--');
|
|
}
|
|
|
|
if (command === 'clean') {
|
|
// `git clean -f`, `-fd`, `-fdx`, `-df`, `--force`
|
|
return rest.some(t => {
|
|
if (t === '--force') return true;
|
|
if (!t.startsWith('-') || t.startsWith('--')) return false;
|
|
return t.slice(1).includes('f');
|
|
});
|
|
}
|
|
|
|
if (command === 'push') {
|
|
// Only `--force-with-lease` qualifies as a safety-checked force.
|
|
// `--force-if-includes` is a no-op when used WITHOUT
|
|
// `--force-with-lease` (per git-scm.com/docs/git-push), and when
|
|
// combined with a bare `--force` the bare force is still in effect.
|
|
// So `--force --force-if-includes` must be treated as destructive.
|
|
//
|
|
// A `+` refspec prefix (e.g. `git push origin +main`,
|
|
// `+refs/heads/main:refs/heads/main`) also forces a non-fast-forward
|
|
// update of that ref and is destructive on its own.
|
|
let withLease = false;
|
|
let bareForce = false;
|
|
let plusRefspecForce = false;
|
|
for (const t of rest) {
|
|
if (t === '--force-with-lease' || t.startsWith('--force-with-lease=')) {
|
|
withLease = true;
|
|
continue;
|
|
}
|
|
if (t === '--force' || t.startsWith('--force=')) {
|
|
bareForce = true;
|
|
continue;
|
|
}
|
|
if (t.startsWith('-') && !t.startsWith('--') && t.slice(1).includes('f')) {
|
|
bareForce = true;
|
|
continue;
|
|
}
|
|
// Refspec prefix: `+<src>[:<dst>]`. Match tokens like `+main`,
|
|
// `+refs/heads/main`, `+HEAD:branch`, `+:branch`. Exclude bare
|
|
// `+` and numeric-only `+123` which are not refspecs.
|
|
if (t.startsWith('+') && t.length > 1 && /^\+(?:[a-zA-Z_/.:]|HEAD)/.test(t)) {
|
|
plusRefspecForce = true;
|
|
}
|
|
}
|
|
return bareForce || (plusRefspecForce && !withLease);
|
|
}
|
|
|
|
if (command === 'commit') {
|
|
return rest.includes('--amend');
|
|
}
|
|
|
|
if (command === 'rm') {
|
|
// `git rm -r` / `-rf` / `-r -f` — destructive within the index too.
|
|
let hasR = false;
|
|
for (const t of rest) {
|
|
if (!t.startsWith('-') || t.startsWith('--')) continue;
|
|
if (/[rR]/.test(t.slice(1))) hasR = true;
|
|
}
|
|
return hasR;
|
|
}
|
|
|
|
if (command === 'switch') {
|
|
// `git switch` can discard local working-tree changes in three forms:
|
|
// --discard-changes explicit discard
|
|
// --force / -f ignore conflicts and overwrite
|
|
// -C <branch> force-create (overwrites existing branch)
|
|
return rest.some(t => {
|
|
if (t === '--discard-changes' || t === '--force') return true;
|
|
if (!t.startsWith('-') || t.startsWith('--')) return false;
|
|
// Short combined form: -f, -fC, -Cf, -C
|
|
const body = t.slice(1);
|
|
return /[fC]/.test(body);
|
|
});
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Decide whether a bash command line contains a destructive action
|
|
* the fact-forcing gate should challenge. Combines SQL-keyword
|
|
* detection (regex on quote-stripped input) with per-segment shell
|
|
* tokenization for shell commands.
|
|
*
|
|
* @param {string} command
|
|
* @returns {boolean}
|
|
*/
|
|
function isDestructiveBash(command) {
|
|
// The SQL/dd phrases live in command bodies, not as flag-bearing
|
|
// arguments, so we still match them by regex — but on the input
|
|
// after quoting AND subshell delimiters are normalized so phrases
|
|
// inside `$(...)` or backticks are also caught.
|
|
const raw = String(command || '');
|
|
const flattened = explodeSubshells(stripQuotedStrings(raw));
|
|
if (DESTRUCTIVE_SQL_DD.test(flattened)) return true;
|
|
|
|
const segments = [raw, ...extractCommandSubstitutions(raw)].flatMap(splitCommandSegments);
|
|
for (const segment of segments) {
|
|
if (DESTRUCTIVE_SQL_DD.test(stripQuotedStrings(segment))) return true;
|
|
const tokens = tokenize(segment);
|
|
if (isDestructiveRm(tokens)) return true;
|
|
if (isDestructiveGit(tokens)) return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
// --- State management (per-session, atomic writes, bounded) ---
|
|
|
|
function normalizeEnvValue(value) {
|
|
return String(value || '').trim().toLowerCase();
|
|
}
|
|
|
|
function isGateGuardDisabled() {
|
|
if (normalizeEnvValue(process.env.GATEGUARD_DISABLED) === '1') {
|
|
return true;
|
|
}
|
|
|
|
return ECC_DISABLE_VALUES.has(normalizeEnvValue(process.env.ECC_GATEGUARD));
|
|
}
|
|
|
|
function sanitizeSessionKey(value) {
|
|
const raw = String(value || '').trim();
|
|
if (!raw) {
|
|
return '';
|
|
}
|
|
|
|
const sanitized = raw.replace(/[^a-zA-Z0-9_-]/g, '_');
|
|
if (sanitized && sanitized.length <= 64) {
|
|
return sanitized;
|
|
}
|
|
|
|
return hashSessionKey('sid', raw);
|
|
}
|
|
|
|
function hashSessionKey(prefix, value) {
|
|
return `${prefix}-${crypto.createHash('sha256').update(String(value)).digest('hex').slice(0, 24)}`;
|
|
}
|
|
|
|
function resolveSessionKey(data) {
|
|
const directCandidates = [data && data.session_id, data && data.sessionId, data && data.session && data.session.id, process.env.CLAUDE_SESSION_ID, process.env.ECC_SESSION_ID];
|
|
|
|
for (const candidate of directCandidates) {
|
|
const sanitized = sanitizeSessionKey(candidate);
|
|
if (sanitized) {
|
|
return sanitized;
|
|
}
|
|
}
|
|
|
|
const transcriptPath = (data && (data.transcript_path || data.transcriptPath)) || process.env.CLAUDE_TRANSCRIPT_PATH;
|
|
if (transcriptPath && String(transcriptPath).trim()) {
|
|
return hashSessionKey('tx', path.resolve(String(transcriptPath).trim()));
|
|
}
|
|
|
|
const projectFingerprint = process.env.CLAUDE_PROJECT_DIR || process.cwd();
|
|
return hashSessionKey('proj', path.resolve(projectFingerprint));
|
|
}
|
|
|
|
function getStateFile(data) {
|
|
if (!activeStateFile) {
|
|
const sessionKey = resolveSessionKey(data);
|
|
activeStateFile = path.join(STATE_DIR, `state-${sessionKey}.json`);
|
|
}
|
|
return activeStateFile;
|
|
}
|
|
|
|
function loadState() {
|
|
const stateFile = getStateFile();
|
|
try {
|
|
if (fs.existsSync(stateFile)) {
|
|
const state = JSON.parse(fs.readFileSync(stateFile, 'utf8'));
|
|
const lastActive = state.last_active || 0;
|
|
if (Date.now() - lastActive > SESSION_TIMEOUT_MS) {
|
|
try {
|
|
fs.unlinkSync(stateFile);
|
|
} catch (_) {
|
|
/* ignore */
|
|
}
|
|
return { checked: [], last_active: Date.now() };
|
|
}
|
|
return state;
|
|
}
|
|
} catch (_) {
|
|
/* ignore */
|
|
}
|
|
return { checked: [], last_active: Date.now() };
|
|
}
|
|
|
|
function pruneCheckedEntries(checked) {
|
|
if (checked.length <= MAX_CHECKED_ENTRIES) {
|
|
return checked;
|
|
}
|
|
|
|
const preserved = checked.includes(ROUTINE_BASH_SESSION_KEY) ? [ROUTINE_BASH_SESSION_KEY] : [];
|
|
const sessionKeys = checked.filter(k => k.startsWith('__') && k !== ROUTINE_BASH_SESSION_KEY);
|
|
const fileKeys = checked.filter(k => !k.startsWith('__'));
|
|
const remainingSessionSlots = Math.max(MAX_SESSION_KEYS - preserved.length, 0);
|
|
const cappedSession = sessionKeys.slice(-remainingSessionSlots);
|
|
const remainingFileSlots = Math.max(MAX_CHECKED_ENTRIES - preserved.length - cappedSession.length, 0);
|
|
const cappedFiles = fileKeys.slice(-remainingFileSlots);
|
|
return [...preserved, ...cappedSession, ...cappedFiles];
|
|
}
|
|
|
|
function saveState(state) {
|
|
const stateFile = getStateFile();
|
|
let tmpFile = null;
|
|
try {
|
|
fs.mkdirSync(STATE_DIR, { recursive: true });
|
|
|
|
let mergedChecked = Array.isArray(state.checked) ? state.checked : [];
|
|
let mergedLastActive = typeof state.last_active === 'number' ? state.last_active : 0;
|
|
|
|
try {
|
|
if (fs.existsSync(stateFile)) {
|
|
const diskState = JSON.parse(fs.readFileSync(stateFile, 'utf8'));
|
|
if (Array.isArray(diskState.checked)) {
|
|
mergedChecked = Array.from(new Set([...diskState.checked, ...mergedChecked]));
|
|
}
|
|
if (typeof diskState.last_active === 'number') {
|
|
mergedLastActive = Math.max(mergedLastActive, diskState.last_active);
|
|
}
|
|
}
|
|
} catch (_) {
|
|
/* ignore malformed or transient disk state */
|
|
}
|
|
|
|
const finalState = {
|
|
checked: pruneCheckedEntries(mergedChecked),
|
|
last_active: Math.max(mergedLastActive, Date.now())
|
|
};
|
|
|
|
// Atomic write: temp file + rename prevents partial reads
|
|
tmpFile = `${stateFile}.tmp.${process.pid}.${crypto.randomBytes(4).toString('hex')}`;
|
|
fs.writeFileSync(tmpFile, JSON.stringify(finalState, null, 2), 'utf8');
|
|
try {
|
|
fs.renameSync(tmpFile, stateFile);
|
|
} catch (error) {
|
|
if (error && (error.code === 'EEXIST' || error.code === 'EPERM')) {
|
|
try {
|
|
fs.unlinkSync(stateFile);
|
|
} catch (_) {
|
|
/* ignore */
|
|
}
|
|
fs.renameSync(tmpFile, stateFile);
|
|
} else {
|
|
throw error;
|
|
}
|
|
}
|
|
tmpFile = null;
|
|
return true;
|
|
} catch (_) {
|
|
if (tmpFile) {
|
|
try {
|
|
fs.unlinkSync(tmpFile);
|
|
} catch (_) {
|
|
/* ignore */
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
}
|
|
|
|
function markChecked(key) {
|
|
const state = loadState();
|
|
if (!state.checked.includes(key)) {
|
|
state.checked.push(key);
|
|
return saveState(state);
|
|
}
|
|
return true;
|
|
}
|
|
|
|
function isChecked(key) {
|
|
const state = loadState();
|
|
const found = state.checked.includes(key);
|
|
if (found && Date.now() - (state.last_active || 0) > READ_HEARTBEAT_MS) {
|
|
saveState(state);
|
|
}
|
|
return found;
|
|
}
|
|
|
|
// Prune stale session files older than 1 hour
|
|
(function pruneStaleFiles() {
|
|
try {
|
|
const files = fs.readdirSync(STATE_DIR);
|
|
const now = Date.now();
|
|
for (const f of files) {
|
|
const isStateFile = f.startsWith('state-') && (f.endsWith('.json') || f.includes('.json.tmp.'));
|
|
if (!isStateFile) continue;
|
|
const fp = path.join(STATE_DIR, f);
|
|
try {
|
|
const stat = fs.statSync(fp);
|
|
if (now - stat.mtimeMs > SESSION_TIMEOUT_MS * 2) {
|
|
fs.unlinkSync(fp);
|
|
}
|
|
} catch (_) {
|
|
// Ignore files that disappear between readdir/stat/unlink.
|
|
}
|
|
}
|
|
} catch (_) {
|
|
/* ignore */
|
|
}
|
|
})();
|
|
|
|
// --- Sanitize file path against injection ---
|
|
|
|
function sanitizePath(filePath) {
|
|
// Strip control chars (including null), bidi overrides, and newlines
|
|
let sanitized = '';
|
|
for (const char of String(filePath || '')) {
|
|
const code = char.codePointAt(0);
|
|
const isAsciiControl = code <= 0x1f || code === 0x7f;
|
|
const isBidiOverride = (code >= 0x200e && code <= 0x200f) || (code >= 0x202a && code <= 0x202e) || (code >= 0x2066 && code <= 0x2069);
|
|
sanitized += isAsciiControl || isBidiOverride ? ' ' : char;
|
|
}
|
|
return sanitized.trim().slice(0, 500);
|
|
}
|
|
|
|
function normalizeForMatch(value) {
|
|
return String(value || '')
|
|
.replace(/\\/g, '/')
|
|
.toLowerCase();
|
|
}
|
|
|
|
function isClaudeSettingsPath(filePath) {
|
|
const normalized = normalizeForMatch(filePath);
|
|
return /(^|\/)\.claude\/settings(?:\.[^/]+)?\.json$/.test(normalized);
|
|
}
|
|
|
|
function isReadOnlyGitIntrospection(command) {
|
|
const trimmed = String(command || '').trim();
|
|
if (!trimmed || /[\r\n;&|><`$()]/.test(trimmed)) {
|
|
return false;
|
|
}
|
|
|
|
const tokens = trimmed.split(/\s+/);
|
|
if (tokens[0] !== 'git' || tokens.length < 2) {
|
|
return false;
|
|
}
|
|
|
|
const subcommand = tokens[1].toLowerCase();
|
|
const args = tokens.slice(2);
|
|
|
|
if (subcommand === 'status') {
|
|
return args.every(arg => ['--porcelain', '--short', '--branch'].includes(arg));
|
|
}
|
|
|
|
if (subcommand === 'diff') {
|
|
return args.length <= 1 && args.every(arg => ['--name-only', '--name-status'].includes(arg));
|
|
}
|
|
|
|
if (subcommand === 'log') {
|
|
return args.every(arg => arg === '--oneline' || /^--max-count=\d+$/.test(arg));
|
|
}
|
|
|
|
if (subcommand === 'show') {
|
|
return args.length === 1 && !args[0].startsWith('--') && /^[a-zA-Z0-9._:/-]+$/.test(args[0]);
|
|
}
|
|
|
|
if (subcommand === 'branch') {
|
|
return args.length === 1 && args[0] === '--show-current';
|
|
}
|
|
|
|
if (subcommand === 'rev-parse') {
|
|
return args.length === 2 && args[0] === '--abbrev-ref' && /^head$/i.test(args[1]);
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
// --- Gate messages ---
|
|
|
|
function editGateMsg(filePath) {
|
|
const safe = sanitizePath(filePath);
|
|
return [
|
|
'[Fact-Forcing Gate]',
|
|
'',
|
|
`Before editing ${safe}, present these facts:`,
|
|
'',
|
|
'1. List ALL files that import/require this file (use Grep)',
|
|
'2. List the public functions/classes affected by this change',
|
|
'3. If this file reads/writes data files, show field names, structure, and date format (use redacted or synthetic values, not raw production data)',
|
|
"4. Quote the user's current instruction verbatim",
|
|
'',
|
|
'Present the facts, then retry the same operation.'
|
|
].join('\n');
|
|
}
|
|
|
|
function writeGateMsg(filePath) {
|
|
const safe = sanitizePath(filePath);
|
|
return [
|
|
'[Fact-Forcing Gate]',
|
|
'',
|
|
`Before creating ${safe}, present these facts:`,
|
|
'',
|
|
'1. Name the file(s) and line(s) that will call this new file',
|
|
'2. Confirm no existing file serves the same purpose (use Glob)',
|
|
'3. If this file reads/writes data files, show field names, structure, and date format (use redacted or synthetic values, not raw production data)',
|
|
"4. Quote the user's current instruction verbatim",
|
|
'',
|
|
'Present the facts, then retry the same operation.'
|
|
].join('\n');
|
|
}
|
|
|
|
function destructiveBashMsg() {
|
|
return [
|
|
'[Fact-Forcing Gate]',
|
|
'',
|
|
'Destructive command detected. Before running, present:',
|
|
'',
|
|
'1. List all files/data this command will modify or delete',
|
|
'2. Write a one-line rollback procedure',
|
|
"3. Quote the user's current instruction verbatim",
|
|
'',
|
|
'Present the facts, then retry the same operation.'
|
|
].join('\n');
|
|
}
|
|
|
|
function routineBashMsg() {
|
|
return [
|
|
'[Fact-Forcing Gate]',
|
|
'',
|
|
'Before the first Bash command this session, present these facts:',
|
|
'',
|
|
'1. The current user request in one sentence',
|
|
'2. What this specific command verifies or produces',
|
|
'',
|
|
'Present the facts, then retry the same operation.'
|
|
].join('\n');
|
|
}
|
|
|
|
function withRecoveryHint(message, hookIds = [EDIT_WRITE_HOOK_ID]) {
|
|
const disableTargets = hookIds.map(hookId => `\`${hookId}\``).join(' or ');
|
|
return [
|
|
message,
|
|
'',
|
|
`Recovery: if GateGuard is blocking setup or repair work, run this session with \`ECC_GATEGUARD=off\` or add ${disableTargets} to \`ECC_DISABLED_HOOKS\`.`
|
|
].join('\n');
|
|
}
|
|
|
|
function isSubagentInvocation(data) {
|
|
if (!data || typeof data !== 'object') {
|
|
return false;
|
|
}
|
|
|
|
const candidates = [
|
|
data.agent_id,
|
|
data.agentId,
|
|
data.parent_tool_use_id,
|
|
data.parentToolUseId
|
|
];
|
|
|
|
return candidates.some(candidate => typeof candidate === 'string' && candidate.trim());
|
|
}
|
|
|
|
// --- Deny helper ---
|
|
|
|
function denyResult(reason, options = {}) {
|
|
const includeRecoveryHint = options.includeRecoveryHint !== false;
|
|
const hookIds = Array.isArray(options.hookIds) && options.hookIds.length > 0 ? options.hookIds : [EDIT_WRITE_HOOK_ID];
|
|
return {
|
|
stdout: JSON.stringify({
|
|
hookSpecificOutput: {
|
|
hookEventName: 'PreToolUse',
|
|
permissionDecision: 'deny',
|
|
permissionDecisionReason: includeRecoveryHint ? withRecoveryHint(reason, hookIds) : reason
|
|
}
|
|
}),
|
|
exitCode: 0
|
|
};
|
|
}
|
|
|
|
function allowWithStateWarning() {
|
|
return {
|
|
stderr: '[Fact-Forcing Gate] GateGuard state could not be persisted; allowing this operation to avoid a permanent retry loop. Check GATEGUARD_STATE_DIR or filesystem permissions.',
|
|
exitCode: 0
|
|
};
|
|
}
|
|
|
|
// --- Core logic (exported for run-with-flags.js) ---
|
|
|
|
function run(rawInput) {
|
|
let data;
|
|
try {
|
|
data = typeof rawInput === 'string' ? JSON.parse(rawInput) : rawInput;
|
|
} catch (_) {
|
|
return rawInput; // allow on parse error
|
|
}
|
|
|
|
if (isGateGuardDisabled()) {
|
|
return rawInput;
|
|
}
|
|
|
|
activeStateFile = null;
|
|
getStateFile(data);
|
|
|
|
const rawToolName = data.tool_name || '';
|
|
const toolInput = data.tool_input || {};
|
|
// Normalize: case-insensitive matching via lookup map
|
|
const TOOL_MAP = { edit: 'Edit', write: 'Write', multiedit: 'MultiEdit', bash: 'Bash' };
|
|
const toolName = TOOL_MAP[rawToolName.toLowerCase()] || rawToolName;
|
|
const inSubagent = isSubagentInvocation(data);
|
|
|
|
if (toolName === 'Edit' || toolName === 'Write') {
|
|
const filePath = toolInput.file_path || '';
|
|
if (!filePath || isClaudeSettingsPath(filePath)) {
|
|
return rawInput; // allow
|
|
}
|
|
|
|
if (inSubagent) {
|
|
return rawInput; // parent session already passed the first-touch file gate
|
|
}
|
|
|
|
if (!isChecked(filePath)) {
|
|
if (!markChecked(filePath)) {
|
|
return allowWithStateWarning();
|
|
}
|
|
return denyResult(toolName === 'Edit' ? editGateMsg(filePath) : writeGateMsg(filePath));
|
|
}
|
|
|
|
return rawInput; // allow
|
|
}
|
|
|
|
if (toolName === 'MultiEdit') {
|
|
if (inSubagent) {
|
|
return rawInput; // parent session already passed the first-touch file gate
|
|
}
|
|
|
|
const edits = toolInput.edits || [];
|
|
for (const edit of edits) {
|
|
const filePath = edit.file_path || '';
|
|
if (filePath && !isClaudeSettingsPath(filePath) && !isChecked(filePath)) {
|
|
if (!markChecked(filePath)) {
|
|
return allowWithStateWarning();
|
|
}
|
|
return denyResult(editGateMsg(filePath));
|
|
}
|
|
}
|
|
return rawInput; // allow
|
|
}
|
|
|
|
if (toolName === 'Bash') {
|
|
const command = toolInput.command || '';
|
|
if (isReadOnlyGitIntrospection(command)) {
|
|
return rawInput;
|
|
}
|
|
|
|
if (isDestructiveBash(command)) {
|
|
// Gate destructive commands on first attempt; allow retry after facts presented
|
|
const key = '__destructive__' + crypto.createHash('sha256').update(command).digest('hex').slice(0, 16);
|
|
if (!isChecked(key)) {
|
|
if (!markChecked(key)) {
|
|
return allowWithStateWarning();
|
|
}
|
|
return denyResult(destructiveBashMsg(), { includeRecoveryHint: false });
|
|
}
|
|
return rawInput; // allow retry after facts presented
|
|
}
|
|
|
|
if (!isChecked(ROUTINE_BASH_SESSION_KEY)) {
|
|
if (!markChecked(ROUTINE_BASH_SESSION_KEY)) {
|
|
return allowWithStateWarning();
|
|
}
|
|
return denyResult(routineBashMsg(), { hookIds: [BASH_HOOK_ID] });
|
|
}
|
|
|
|
return rawInput; // allow
|
|
}
|
|
|
|
return rawInput; // allow
|
|
}
|
|
|
|
module.exports = { run };
|