174 lines
7.5 KiB
JavaScript
174 lines
7.5 KiB
JavaScript
#!/usr/bin/env node
|
||
// Core Engine change gate (AGENTS.md, "Core Engine and engines").
|
||
// PreToolUse hook: Edit/Write/NotebookEdit or a writing shell command on a Core path is blocked
|
||
// unless the user released that Core area in the current message ("Core-Freigabe: <area>").
|
||
// Engine paths (Context Engine, Report Engine) and documentation pass silently.
|
||
// Errors fail closed: the change is blocked with an explanation.
|
||
|
||
import fs from 'node:fs';
|
||
import path from 'node:path';
|
||
import {
|
||
RELEASE_FILE, ALL_AREAS, WHOLE_REPOSITORY, classifyPath, isReleased, relativeToProject,
|
||
} from './core-areas.mjs';
|
||
|
||
// Repository-wide operations that rewrite tracked files regardless of path.
|
||
const TREE_WIDE_WRITES = [
|
||
/\bgit\s+(reset\s+--hard|clean\b|stash\b(?!\s+list)|checkout\s+(--\s+)?\.(\s|$)|restore\s+(--\S+\s+)*\.(\s|$)|apply\b|am\b|merge\b|rebase\b|pull\b|cherry-pick\b|revert\b)/i,
|
||
];
|
||
|
||
// A command segment that writes; only the paths inside such a segment are write candidates.
|
||
// Running, reading or testing a Core file (node tests/x.mjs, cat, grep, vitest) is never a write.
|
||
const SEGMENT_WRITE = new RegExp([
|
||
String.raw`\bsed\s+(-\S*\s+)*-i`, String.raw`\bperl\s+-\S*i`, String.raw`\btee\b`,
|
||
String.raw`(^|[\s(])(mv|cp|rm|rmdir|touch|truncate|ln|install|patch|unzip|tar)(\s|$)`, String.raw`\bgit\s+(checkout|restore|rm|mv)\b`,
|
||
String.raw`\b(Set-Content|Add-Content|Out-File|Clear-Content|Remove-Item|Move-Item|Copy-Item|New-Item|Rename-Item)\b`,
|
||
String.raw`\b(writeFile|writeFileSync|appendFile|appendFileSync|rmSync|unlinkSync|renameSync|copyFileSync)\b`,
|
||
String.raw`--write\b`,
|
||
].join('|'), 'i');
|
||
|
||
// Package-manager writes change package.json/package-lock.json without naming them.
|
||
const PACKAGE_WRITE = /\bnpm\s+(install|i|ci|uninstall|update|version|pkg\s+set)\b/i;
|
||
|
||
// Splits a command into segments at ; && || | and newlines outside quotes, and collects
|
||
// the targets of output redirections outside quotes (never /dev/null, $null or &N).
|
||
function scanShell(command) {
|
||
const segments = [];
|
||
const redirects = [];
|
||
let current = '';
|
||
let quote = null;
|
||
for (let index = 0; index < command.length; index += 1) {
|
||
const char = command[index];
|
||
if (quote) {
|
||
if (char === quote) quote = null;
|
||
current += char;
|
||
continue;
|
||
}
|
||
if (char === '"' || char === "'") {
|
||
quote = char;
|
||
current += char;
|
||
continue;
|
||
}
|
||
if (char === ';' || char === '\n' || char === '|' || (char === '&' && command[index + 1] === '&')) {
|
||
segments.push(current);
|
||
current = '';
|
||
if (command[index + 1] === char) index += 1;
|
||
continue;
|
||
}
|
||
if (char === '>') {
|
||
let cursor = index + 1;
|
||
if (command[cursor] === '>') cursor += 1;
|
||
while (command[cursor] === ' ') cursor += 1;
|
||
const target = command.slice(cursor).match(/^("[^"]*"|'[^']*'|[^\s;&|<>()]+)/)?.[0] ?? '';
|
||
const bare = target.replace(/^["']|["']$/g, '');
|
||
if (bare && !/^(&\d*|\/dev\/null|\$null|nul)$/i.test(bare)) redirects.push(bare);
|
||
current += command.slice(index, cursor + target.length);
|
||
index = cursor + target.length - 1;
|
||
continue;
|
||
}
|
||
current += char;
|
||
}
|
||
segments.push(current);
|
||
return { segments, redirects };
|
||
}
|
||
|
||
function firstCoreToken(text) {
|
||
const tokens = text.match(/[A-Za-z0-9_.\-/:]+/g) ?? [];
|
||
for (const token of tokens) {
|
||
const candidate = token.replace(/^\.\//, '');
|
||
if (!/[/.]/.test(candidate)) continue;
|
||
const area = classifyPath(candidate.includes(':') ? null : candidate);
|
||
if (area) return { area, target: token };
|
||
}
|
||
return null;
|
||
}
|
||
|
||
function classifyShell(rawCommand, projectDir) {
|
||
// Absolute project paths become repository-relative so they classify like relative ones.
|
||
const projectPrefix = projectDir.replace(/\\/g, '/').replace(/\/?$/, '/').toLowerCase();
|
||
const command = rawCommand.replace(/\\/g, '/').toLowerCase().split(projectPrefix).join('');
|
||
if (TREE_WIDE_WRITES.some((pattern) => pattern.test(command))) {
|
||
return { area: WHOLE_REPOSITORY, target: 'repositoryweite Git-Operation' };
|
||
}
|
||
const { segments, redirects } = scanShell(command);
|
||
for (const target of redirects) {
|
||
const hit = firstCoreToken(target);
|
||
if (hit) return hit;
|
||
}
|
||
for (const segment of segments) {
|
||
if (PACKAGE_WRITE.test(segment)) return { area: classifyPath('package-lock.json'), target: 'package.json/package-lock.json' };
|
||
if (!SEGMENT_WRITE.test(segment)) continue;
|
||
const hit = firstCoreToken(segment);
|
||
if (hit) return hit;
|
||
}
|
||
return null;
|
||
}
|
||
|
||
function readRelease(projectDir, sessionId) {
|
||
try {
|
||
const release = JSON.parse(fs.readFileSync(path.join(projectDir, RELEASE_FILE), 'utf8'));
|
||
if (release.sessionId && sessionId && release.sessionId !== sessionId) return [];
|
||
return Array.isArray(release.areas) ? release.areas : [];
|
||
} catch {
|
||
return [];
|
||
}
|
||
}
|
||
|
||
function deny(reasonForClaude, noticeForUser) {
|
||
process.stdout.write(JSON.stringify({
|
||
systemMessage: noticeForUser,
|
||
hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'deny', permissionDecisionReason: reasonForClaude },
|
||
}));
|
||
}
|
||
|
||
function main(raw) {
|
||
const input = JSON.parse(raw);
|
||
const projectDir = process.env.CLAUDE_PROJECT_DIR || input.cwd || process.cwd();
|
||
const toolInput = input.tool_input ?? {};
|
||
const isShell = input.tool_name === 'Bash' || input.tool_name === 'PowerShell';
|
||
const command = isShell ? String(toolInput.command ?? '') : '';
|
||
const filePath = isShell ? null : toolInput.file_path ?? toolInput.notebook_path;
|
||
const relative = filePath ? relativeToProject(String(filePath), projectDir) : null;
|
||
|
||
// The release file arises only from the user's message; Claude never touches it.
|
||
if (relative === RELEASE_FILE || command.toLowerCase().includes('core-release.json')) {
|
||
deny(
|
||
'The Core release file is written only by the user-message hook. Do not create, edit or delete it; ask the user to write "Core-Freigabe: <area>" instead.',
|
||
'⚠ CORE-FREIGABEDATEI GESCHÜTZT – Claude darf die Freigabe nicht selbst setzen. Eine Freigabe entsteht nur durch deine Nachricht.',
|
||
);
|
||
return;
|
||
}
|
||
|
||
const hit = isShell ? classifyShell(command, projectDir) : (() => {
|
||
const area = classifyPath(relative);
|
||
return area ? { area, target: relative } : null;
|
||
})();
|
||
if (!hit) return;
|
||
|
||
const released = readRelease(projectDir, input.session_id);
|
||
const allowed = hit.area === WHOLE_REPOSITORY ? released.includes(ALL_AREAS) : isReleased(hit.area, released);
|
||
const phrase = hit.area === WHOLE_REPOSITORY ? 'Core-Freigabe: alle' : `Core-Freigabe: ${hit.area}`;
|
||
if (allowed) {
|
||
process.stdout.write(JSON.stringify({ systemMessage: `🔓 Core-Änderung mit Freigabe – Bereich ${hit.area}: ${hit.target}` }));
|
||
return;
|
||
}
|
||
deny(
|
||
`Blocked: this changes the Core Engine (area ${hit.area}: ${hit.target}) without Core consent. Stop this part, tell the user which Core area is affected and why, and ask them to reply with "${phrase}" if they want it. Do not work around this block through another tool, path or engine.`,
|
||
`⚠ CORE-ÄNDERUNG GESPERRT – Bereich ${hit.area}: ${hit.target}. Freigabe nur durch deine Nachricht mit „${phrase}“.`,
|
||
);
|
||
}
|
||
|
||
let raw = '';
|
||
process.stdin.setEncoding('utf8');
|
||
process.stdin.on('data', (chunk) => { raw += chunk; });
|
||
process.stdin.on('end', () => {
|
||
try {
|
||
main(raw);
|
||
} catch (error) {
|
||
const detail = error instanceof Error ? error.message : 'unknown error';
|
||
deny(
|
||
`Core guard failed (${detail}); the change is blocked. Tell the user the guard could not check this change.`,
|
||
`⚠ CORE-WÄCHTER FEHLER – Änderung vorsorglich gesperrt (${detail}).`,
|
||
);
|
||
}
|
||
});
|