Assessor_2.0/.claude/hooks/core-guard.mjs

174 lines
7.5 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/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}).`,
);
}
});