Files
ECC/scripts/lib/gateguard-target-class.js
T
montjeffrey f7f47679b6 fix(gateguard): make the external-contract question opt-in
- questionIdsFor and condensedHintFor take an optional
  { externalContract: true }; without it a code change that handles data
  asks data-schema only, and an unknown profile gets the original four
  questions and condensed hint.
- The hook reads GATEGUARD_EXTERNAL_CONTRACT_QUESTION with the same
  spellings as GATEGUARD_METRICS and passes it to the full denial, the
  condensed denial and the metrics record, so all three agree.
- The effectiveness harness's arm patch forwards the option, so an arm
  never drops it silently.

Docs: change-profile.md gains an External-contract question section and
qualifies "byte-identical to the fixed four questions"; design-notes.md,
SKILL.md (switch table, data rule, word-for-word set) and CHANGELOG say
the fifth question is opt-in; question-effectiveness.md and the harness
README note that the runner clears GATEGUARD_* variables, so a gate arm
from a current tree measures the gate without the question.
skill-triggers@1.json: registryDigest refreshed for the SKILL.md body
change; the description and triggers are unchanged.
2026-10-11 00:45:01 -04:00

458 lines
20 KiB
JavaScript

'use strict';
const fs = require('fs');
const path = require('path');
const WINDOWS_PATH_PATTERN = /^[a-z]:[\\/]|^\\\\/i;
/** Canonical checked-state key for a target. */
function canonicalPathKey(filePath, data) {
try {
const target = resolveTargetPath(filePath, data);
if (!target) return filePath;
const key = target.resolved.replace(/\\/g, '/');
return target.isWin ? key.toLowerCase() : key;
} catch (_) {
return filePath;
}
}
/** Resolve a target to the file the tool will touch. */
// see docs/gateguard/design-notes.md#target-resolution
function resolveTargetPath(filePath, data) {
const base = (data && data.cwd) || process.env.CLAUDE_PROJECT_DIR || process.cwd();
if (typeof base !== 'string' || typeof filePath !== 'string') return null;
const isWin = WINDOWS_PATH_PATTERN.test(base) || WINDOWS_PATH_PATTERN.test(filePath);
const paths = isWin ? path.win32 : path.posix;
if (!paths.isAbsolute(base)) return null;
return { resolved: paths.resolve(base, filePath), isWin };
}
// --- Target classes ---
const INSTRUCTION_BASENAMES = new Set([
'claude.md',
'agents.md',
'agent.md',
'gemini.md',
'skill.md',
'copilot-instructions.md',
'.cursorrules',
'.windsurfrules'
]);
const INSTRUCTION_ANYWHERE_EXTS = new Set(['.mdc']);
const INSTRUCTION_DIRS = new Set(['.claude', 'agents', 'commands', 'skills', 'rules', 'hooks', '.cursor', '.codex', '.opencode']);
const INSTRUCTION_EXTS = new Set(['.md', '.mdx', '.txt']);
const TEST_DIRS = new Set(['tests', 'test', '__tests__']);
const TEST_BASENAME_PATTERN = /\.(test|spec)\.|^test_.*\.py$|_test\.(py|go)$/;
const PROSE_EXTS = new Set(['.md', '.mdx', '.txt', '.rst', '.adoc']);
const CONFIG_EXTS = new Set(['.json', '.jsonc', '.yaml', '.yml', '.toml', '.ini']);
const ENV_BASENAME_PATTERN = /^\.env($|\.)/;
const CODE_EXTS = new Set(['.ts', '.js', '.mjs', '.cjs', '.py', '.go', '.rs', '.java', '.rb']);
/** Classify a path; first match wins: instruction, test, prose, config, code (the fallback). */
function classifyTarget(filePath) {
try {
const normalized = String(filePath || '').replace(/\\/g, '/').toLowerCase();
const segments = normalized.split('/').filter(Boolean);
const base = segments.pop() || '';
const ext = path.posix.extname(base);
if (INSTRUCTION_BASENAMES.has(base) || INSTRUCTION_ANYWHERE_EXTS.has(ext)) return 'instruction';
if (INSTRUCTION_EXTS.has(ext) && segments.some(segment => INSTRUCTION_DIRS.has(segment))) return 'instruction';
if (ext === '.md' && base.includes('instructions') && segments.includes('.github')) return 'instruction';
if (TEST_BASENAME_PATTERN.test(base) || segments.some(segment => TEST_DIRS.has(segment))) return 'test';
if (PROSE_EXTS.has(ext)) return 'prose';
if (CONFIG_EXTS.has(ext) || (ENV_BASENAME_PATTERN.test(base) && !CODE_EXTS.has(ext))) return 'config';
return 'code';
} catch (_) {
return 'code';
}
}
const WORKTREE_PREFIX_PATTERN = /^\.claude\/worktrees\/([^/]+)\//;
function isHostPathStyle(isWin) {
return isWin === (process.platform === 'win32');
}
function foldKey(nativePath, isWin) {
const key = nativePath.replace(/\\/g, '/');
return isWin ? key.toLowerCase() : key;
}
function canonicalProjectRoot(data) {
const root = process.env.CLAUDE_PROJECT_DIR || (data && data.cwd) || process.cwd();
if (typeof root !== 'string') return null;
const isWin = WINDOWS_PATH_PATTERN.test(root);
const paths = isWin ? path.win32 : path.posix;
if (!paths.isAbsolute(root)) return null;
const native = paths.resolve(root);
return { key: foldKey(native, isWin), native, isWin };
}
// see docs/gateguard/design-notes.md#worktree-prefix
function isRealWorktree(rootNative, isWin, name) {
if (!isHostPathStyle(isWin)) return false;
const paths = isWin ? path.win32 : path.posix;
return fs.existsSync(paths.join(rootNative, '.claude', 'worktrees', name, '.git'));
}
const STREAM_MARKER = '::$';
// see docs/gateguard/design-notes.md#windows-name-normalization
function normalizeWindowsSegments(classPath, isWin) {
const segments = classPath.split('/');
const last = segments.length - 1;
return segments
.map((segment, index) => {
if (!isWin && !segment.includes(STREAM_MARKER)) return segment;
let name = segment;
const colon = name.indexOf(':');
if (index === last && colon > 0) name = name.slice(0, colon);
name = name.replace(/[. ]+$/, '');
return name || segment;
})
.join('/');
}
function projectRelativeClassPath(key, root, isWin) {
const target = isWin ? key.toLowerCase() : key;
const rootKey = isWin ? root.key.toLowerCase() : root.key;
const prefix = rootKey.endsWith('/') ? rootKey : `${rootKey}/`;
if (!target.startsWith(prefix)) return null;
let relative = key.slice(prefix.length);
const worktree = relative.match(WORKTREE_PREFIX_PATTERN);
if (worktree && isRealWorktree(root.native, root.isWin, worktree[1])) relative = relative.slice(worktree[0].length);
return normalizeWindowsSegments(relative, isWin);
}
/** Project-relative path a target is classified by. */
function classPathFor(filePath, data) {
try {
const key = canonicalPathKey(filePath, data);
const root = canonicalProjectRoot(data);
const isWin = Boolean(root && root.isWin) || WINDOWS_PATH_PATTERN.test(key);
if (!root) return normalizeWindowsSegments(key, isWin);
const relative = projectRelativeClassPath(key, root, isWin);
return relative === null ? normalizeWindowsSegments(key, isWin) : relative;
} catch (_) {
return filePath;
}
}
/** Class of a hook target, judged on its project-relative path. */
function classifyTargetFor(filePath, data) {
return classifyTarget(classPathFor(filePath, data));
}
const SEARCH_THE_TREE = '(search the tree — Glob/Grep, or find/grep via Bash)';
const QUOTE_INSTRUCTION = "Quote the user's current instruction verbatim";
const QUESTION_TEXT = Object.freeze({
importers: `List ALL files that import/require this file ${SEARCH_THE_TREE}`,
'public-api': 'List the public functions/classes affected by this change',
'local-callers': `List the call sites in this file or its module that rely on the changed behaviour ${SEARCH_THE_TREE}`,
callers: 'Name the file(s) and line(s) that will call this new file',
'no-duplicate': `Confirm no existing file serves the same purpose ${SEARCH_THE_TREE}`,
'data-schema':
'If this file reads/writes data files, show field names, structure, and date format (use redacted or synthetic values, not raw production data)',
'external-contract':
'Name what outside this repository decides the format, units, timezone or protocol semantics here (the consumer, the producer, or a stated convention), or state that the choice is unconstrained',
loader: 'Name the harness/loader that reads this file (Claude Code, Codex, Cursor, OpenCode, …) and when it loads it',
'behaviour-change': 'Describe what agent behaviour changes as a result',
'no-duplicate-instruction': `Confirm no existing instruction, skill, or agent file already covers this ${SEARCH_THE_TREE}`,
'under-test': 'Name what behaviour is under test and which module/function it exercises',
'existing-tests': `Name the existing test file(s) covering this module, or confirm none exist ${SEARCH_THE_TREE}`,
supersedes: `Name any existing doc this supersedes or duplicates ${SEARCH_THE_TREE}`,
'linked-from': 'State where it will be linked or referenced from',
'why-new-file': 'Explain why a new file rather than editing an existing one',
references: `List other docs or code that reference the section being changed ${SEARCH_THE_TREE}`,
'corrects-or-adds': 'State what the change corrects or adds',
'config-reader': 'Name which process/tool reads this file and when',
'config-effect': 'Describe the effect of the change',
'no-plaintext-secrets': 'Confirm no secrets or credentials are being written in plain text',
'quote-instruction': QUOTE_INSTRUCTION
});
const CLASS_QUESTION_IDS = {
instruction: () => ['loader', 'behaviour-change', 'no-duplicate-instruction'],
test: () => ['under-test', 'existing-tests'],
prose: isWrite => (isWrite ? ['supersedes', 'linked-from', 'why-new-file'] : ['references', 'corrects-or-adds']),
config: () => ['config-reader', 'config-effect', 'no-plaintext-secrets']
};
const CLASS_QUESTIONS = Object.fromEntries(
Object.entries(CLASS_QUESTION_IDS).map(([cls, ids]) => [cls, isWrite => ids(isWrite).map(id => QUESTION_TEXT[id])])
);
// see docs/gateguard/change-profile.md#questions-from-the-change-profile
function codeQuestionIds(isWrite, profile, externalContract) {
const known = Boolean(profile) && profile.known === true;
const surface = !known || profile.touchesPublicSurface !== false;
const data = !known || profile.touchesData !== false;
const opening = isWrite ? ['callers', 'no-duplicate'] : surface ? ['importers', 'public-api'] : ['local-callers'];
// A change that handles data is usually constrained by something outside the
// tree - a consumer's dialect, a reporting timezone, a protocol's semantics -
// and the repository cannot settle it. data-schema asks what the change
// touches; external-contract asks who decides what it has to be. It is
// opt-in: see docs/gateguard/change-profile.md#external-contract-question.
if (!data) return opening;
return externalContract ? [...opening, 'data-schema', 'external-contract'] : [...opening, 'data-schema'];
}
function asksExternalContract(options) {
return Boolean(options) && options.externalContract === true;
}
/** True when a class's questions depend on the change profile. */
function questionsUseProfile(cls) {
return !Object.hasOwn(CLASS_QUESTION_IDS, cls);
}
/**
* Stable ids of the first-touch questions for a class, action and change profile.
* `options.externalContract === true` adds the opt-in external-contract question.
*/
function questionIdsFor(cls, isWrite, profile, options) {
const ids = Object.hasOwn(CLASS_QUESTION_IDS, cls)
? CLASS_QUESTION_IDS[cls](Boolean(isWrite))
: codeQuestionIds(Boolean(isWrite), profile, asksExternalContract(options));
return [...ids, 'quote-instruction'];
}
/** Question text for an id from `questionIdsFor`. */
function questionText(id) {
return Object.hasOwn(QUESTION_TEXT, id) ? QUESTION_TEXT[id] : '';
}
const CODE_CONDENSED_HINT =
"briefly state importers/callers, affected API, data schemas if any, and the user's verbatim instruction, then retry.";
const CODE_CONDENSED_HINT_EXTERNAL_CONTRACT =
"briefly state importers/callers, affected API, data schemas if any, what outside the repository fixes the format or semantics, and the user's verbatim instruction, then retry.";
const CONDENSED_PHRASES = Object.freeze({
importers: 'the files that import this file',
'public-api': 'the public functions/classes affected',
'local-callers': 'the call sites in this file or its module that rely on the change',
callers: 'the file(s) and line(s) that will call it',
'no-duplicate': 'that no existing file serves the same purpose',
'data-schema': 'the data schemas it reads or writes',
'external-contract': 'what outside the repository fixes the format or semantics',
'quote-instruction': "the user's verbatim instruction"
});
/** Condensed-hint phrase for a code question id, or '' when it has none. */
function condensedQuestionPhrase(id) {
return Object.hasOwn(CONDENSED_PHRASES, id) ? CONDENSED_PHRASES[id] : '';
}
function joinPhrases(phrases) {
if (phrases.length <= 2) return phrases.join(' and ');
return `${phrases.slice(0, -1).join(', ')}, and ${phrases[phrases.length - 1]}`;
}
const CLASS_CONDENSED_HINTS = {
instruction: () =>
"briefly state which harness loads this file, the agent behaviour it changes, that no existing instruction file covers it, and the user's verbatim instruction, then retry.",
test: () =>
"briefly state the behaviour and module under test, existing test files for it (or none), and the user's verbatim instruction, then retry.",
prose: isWrite =>
isWrite
? "briefly state what this supersedes, where it is linked from, and the user's verbatim instruction, then retry."
: "briefly state what references the changed section, what the change corrects or adds, and the user's verbatim instruction, then retry.",
config: () =>
"briefly state which process reads this file, the effect of the change, that no secrets are written in plain text, and the user's verbatim instruction, then retry."
};
/** One-line condensed hint matching `questionIdsFor`. */
function condensedHintFor(cls, isWrite, profile, options) {
if (Object.hasOwn(CLASS_CONDENSED_HINTS, cls)) return CLASS_CONDENSED_HINTS[cls](Boolean(isWrite));
if (!isWrite && !(profile && profile.known === true)) {
return asksExternalContract(options) ? CODE_CONDENSED_HINT_EXTERNAL_CONTRACT : CODE_CONDENSED_HINT;
}
const phrases = questionIdsFor(cls, isWrite, profile, options).map(condensedQuestionPhrase).filter(Boolean);
return `briefly state ${joinPhrases(phrases)}, then retry.`;
}
// --- Sensitive targets ---
// see docs/gateguard/design-notes.md#sensitive-targets
const SENSITIVE_EXTS = new Set(['.pem', '.key', '.p12', '.pfx']);
const SENSITIVE_BASENAME_PATTERN = /^(\.env($|\.)|id_rsa|id_ed25519|id_ecdsa|id_dsa|\.netrc$|\.pgpass$|credentials|secrets\.)/;
const SENSITIVE_SEGMENTS = new Set(['auth', 'authn', 'authz', 'security', 'secrets', 'payment', 'payments', 'billing', 'migrations']);
const SENSITIVE_PREFIX = '.github/workflows/';
/** True for a sensitive class path; any error or non-string input is sensitive. */
function isSensitiveTarget(classPath) {
try {
if (typeof classPath !== 'string' || !classPath) return true;
const normalized = classPath.replace(/\\/g, '/').toLowerCase();
const segments = normalized.split('/').filter(Boolean);
const base = segments[segments.length - 1] || '';
if (SENSITIVE_BASENAME_PATTERN.test(base) || SENSITIVE_EXTS.has(path.posix.extname(base))) return true;
if (segments.some(segment => SENSITIVE_SEGMENTS.has(segment))) return true;
return `/${segments.join('/')}`.includes(`/${SENSITIVE_PREFIX}`);
} catch (_) {
return true;
}
}
/** Sensitive on the lexical or the real (symlink-resolved) path; any error is sensitive. */
function isSensitiveTargetFor(filePath, data) {
try {
if (typeof filePath !== 'string' || !filePath) return true;
if (isSensitiveTarget(classPathFor(filePath, data))) return true;
return isSensitiveRealTarget(filePath, data);
} catch (_) {
return true;
}
}
function isSensitiveRealTarget(filePath, data) {
const target = resolveTargetPath(filePath, data);
if (!target || !isHostPathStyle(target.isWin)) return false;
const paths = target.isWin ? path.win32 : path.posix;
const real = realTargetPath(target.resolved, paths);
if (!real) return true;
const realKey = foldKey(real, target.isWin);
if (realKey === foldKey(target.resolved, target.isWin)) return false;
const root = canonicalProjectRoot(data);
if (root && root.isWin === target.isWin) {
const realRoot = realpathOfNearestAncestor(root.native, paths);
if (!realRoot) return true;
const realRootInfo = { ...root, key: foldKey(realRoot, target.isWin), native: realRoot };
const realClassPath = projectRelativeClassPath(realKey, realRootInfo, target.isWin);
if (realClassPath !== null) return isSensitiveTarget(realClassPath);
}
return isSensitiveTarget(normalizeWindowsSegments(realKey, target.isWin));
}
// --- Hard-linked targets ---
// see docs/gateguard/design-notes.md#hard-linked-targets
const MISSING_TARGET_CODES = new Set(['ENOENT', 'ENOTDIR']);
function hasOtherLinks(stat) {
return !stat.isDirectory() && stat.nlink > 1;
}
/** True when the target file has more than one hard link; a stat error other than a missing file counts as linked. */
function isHardLinkedTargetFor(filePath, data) {
try {
const target = resolveTargetPath(filePath, data);
if (!target) return true;
if (!isHostPathStyle(target.isWin)) return false;
const stat = fs.lstatSync(target.resolved);
if (!stat.isSymbolicLink()) return hasOtherLinks(stat);
return hasOtherLinks(fs.statSync(target.resolved));
} catch (error) {
return !(error && MISSING_TARGET_CODES.has(error.code));
}
}
// --- Sibling collapse eligibility ---
// see docs/gateguard/design-notes.md#sibling-collapse
const COLLAPSIBLE_CLASSES = new Set(['code', 'test', 'prose']);
const SHORT_NAME_PATTERN = /~\d/;
function isCollapsibleClassPath(classPath, cls) {
if (!COLLAPSIBLE_CLASSES.has(cls)) return false;
const segments = String(classPath).toLowerCase().split('/').filter(Boolean);
if (segments.length === 0) return false;
return !segments.some(segment => segment.startsWith('.') || SHORT_NAME_PATTERN.test(segment));
}
/** Lexical collapse screen; `collapseGateDir` adds the real-directory screen. */
function isCollapsibleTarget(filePath, data, cls) {
return isCollapsibleClassPath(classPathFor(filePath, data), cls);
}
function realpathOfNearestAncestor(nativePath, paths) {
const realpath = fs.realpathSync.native || fs.realpathSync;
const missing = [];
let current = nativePath;
for (;;) {
try {
const real = realpath(current);
if (!fs.statSync(real).isDirectory()) return null;
return missing.length > 0 ? paths.join(real, ...missing.reverse()) : real;
} catch (error) {
if (!error || error.code !== 'ENOENT' || !isMissingEntry(current)) return null;
const parent = paths.dirname(current);
if (parent === current) return null;
missing.push(paths.basename(current));
current = parent;
}
}
}
function realTargetPath(resolved, paths) {
const realpath = fs.realpathSync.native || fs.realpathSync;
try {
return realpath(resolved);
} catch (error) {
if (!error || error.code !== 'ENOENT' || !isMissingEntry(resolved)) return null;
}
const realDir = realpathOfNearestAncestor(paths.dirname(resolved), paths);
return realDir ? paths.join(realDir, paths.basename(resolved)) : null;
}
function isMissingEntry(nativePath) {
try {
fs.lstatSync(nativePath);
return false;
} catch (error) {
return Boolean(error) && error.code === 'ENOENT';
}
}
/** Real directory a new file's sibling gate is keyed by, or null when it may not collapse. */
function collapseGateDir(filePath, data, cls) {
try {
if (!isCollapsibleTarget(filePath, data, cls)) return null;
const target = resolveTargetPath(filePath, data);
if (!target || !isHostPathStyle(target.isWin)) return null;
const paths = target.isWin ? path.win32 : path.posix;
const lexicalDir = paths.dirname(target.resolved);
const realDir = realpathOfNearestAncestor(lexicalDir, paths);
if (!realDir) return null;
const realKey = foldKey(realDir, target.isWin);
if (realKey === foldKey(lexicalDir, target.isWin)) return realKey;
const root = canonicalProjectRoot(data);
if (!root || root.isWin !== target.isWin) return null;
const realRoot = realpathOfNearestAncestor(root.native, paths);
if (!realRoot) return null;
const realTargetKey = foldKey(paths.join(realDir, paths.basename(target.resolved)), target.isWin);
const realClassPath = projectRelativeClassPath(realTargetKey, { ...root, key: foldKey(realRoot, target.isWin), native: realRoot }, target.isWin);
if (realClassPath === null || classifyTarget(realClassPath) !== cls) return null;
if (isSensitiveTarget(realClassPath)) return null;
return isCollapsibleClassPath(realClassPath, cls) ? realKey : null;
} catch (_) {
return null;
}
}
module.exports = {
questionsUseProfile,
WINDOWS_PATH_PATTERN,
COLLAPSIBLE_CLASSES,
CLASS_QUESTIONS,
CLASS_CONDENSED_HINTS,
QUOTE_INSTRUCTION,
questionIdsFor,
questionText,
condensedHintFor,
condensedQuestionPhrase,
resolveTargetPath,
canonicalPathKey,
classifyTarget,
classPathFor,
classifyTargetFor,
isCollapsibleTarget,
collapseGateDir,
isSensitiveTarget,
isSensitiveTargetFor,
isHardLinkedTargetFor
};