governance: Core-Guard und Core-Freigabe-Hooks
Projekt-Hooks fuer die Core/Engine-Regel aus AGENTS.md: core-release.mjs liest "Core-Freigabe: <Bereich>" aus der Nutzernachricht, core-guard.mjs sperrt Schreibzugriffe auf Core-Pfade ohne Freigabe, core-areas.mjs ordnet Pfade den Bereichen zu. Tests, Builds und Lesebefehle werden nicht gesperrt. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
e933a9b421
commit
d8cb73fb37
|
|
@ -0,0 +1,87 @@
|
|||
// Core Engine area map shared by core-guard.mjs and core-release.mjs (AGENTS.md, "Core Engine and engines").
|
||||
// Paths are repository-relative and lowercase; the first matching prefix wins.
|
||||
|
||||
import path from 'node:path';
|
||||
|
||||
export const RELEASE_FILE = '.claude/core-release.json';
|
||||
export const ALL_AREAS = 'alle';
|
||||
export const GOVERNANCE = 'Governance';
|
||||
export const WHOLE_REPOSITORY = 'Gesamtes Repository';
|
||||
|
||||
// Engine paths precede the Core areas they sit in.
|
||||
export const ENGINE_PATHS = [
|
||||
['Context Engine', ['src/assessment-context/', 'src/features/context-layer/', 'tests/assessment-context/',
|
||||
'tests/features/finding-context-layer.test.tsx', 'tests/features/pathfinder-context-layer.test.tsx',
|
||||
'tests/features/context-cluster-facet.test.tsx', 'tests/context-toggle-browser-check.mjs']],
|
||||
['Report Engine', ['src/reporting/', 'server/reporting/', 'tests/reporting/', 'tests/server/report-archive.test.ts',
|
||||
'tests/postgres/report-read-only-runtime.test.ts']],
|
||||
];
|
||||
|
||||
export const CORE_AREAS = [
|
||||
[GOVERNANCE, ['agents.md', 'claude.md', '.claude/hooks/', '.claude/settings.json']],
|
||||
['Disclosure', ['src/domain/result-db-disclosure.ts', 'src/exports/']],
|
||||
['Ingest', ['src/importers/', 'src/recognition/', 'src/profiles/', 'src/analysis/', 'src/gpo/', 'server/gpo/',
|
||||
'server/import-executor.ts', 'server/import-worker-client.ts', 'server/import-worker.ts',
|
||||
'server/node-import-runtime-adapters.ts', 'server/artifacts/']],
|
||||
['Normalization', ['src/domain/']],
|
||||
['Evaluation', ['src/evaluation/', 'src/comparison/', 'src/scoring/']],
|
||||
['Store', ['src/persistence/', 'server/postgres/', 'server/repository-factory.ts', 'docker-compose.postgres.yml']],
|
||||
['Reference Catalogs', ['src/references/', 'server/reference-data/']],
|
||||
['Shared Kernel', ['src/shared/', 'src/vite-env.d.ts']],
|
||||
['Presentation', ['src/app/', 'src/features/', 'src/components/', 'src/styles/', 'src/hooks/', 'src/assets/',
|
||||
'src/main.tsx', 'index.html']],
|
||||
['Platform', ['server/', 'src/api/', 'src/platform/', 'src/legacy/', 'package.json', 'package-lock.json',
|
||||
'vite.config.ts', 'vite.client.config.ts', 'vite.server.config.ts', 'vitest.config.ts', 'tsconfig.json',
|
||||
'tsconfig.app.json', 'tsconfig.node.json', 'tsconfig.server.json', 'dockerfile', 'docker-compose.deploy.yml',
|
||||
'deploy/', 'scripts/']],
|
||||
['Core Tests', ['tests/', 'fixtures/']],
|
||||
// Any other source path stays Core until it is registered as an engine above.
|
||||
['Unclassified Source', ['src/']],
|
||||
];
|
||||
|
||||
// Spellings accepted after "Core-Freigabe:", mapped to the area names above.
|
||||
const AREA_ALIASES = new Map([
|
||||
['alle', ALL_AREAS], ['all', ALL_AREAS], ['core', ALL_AREAS],
|
||||
['governance', GOVERNANCE],
|
||||
['disclosure', 'Disclosure'], ['result db', 'Disclosure'],
|
||||
['ingest', 'Ingest'], ['import', 'Ingest'],
|
||||
['normalization', 'Normalization'], ['normalisierung', 'Normalization'],
|
||||
['evaluation', 'Evaluation'], ['bewertung', 'Evaluation'], ['scoring', 'Evaluation'],
|
||||
['store', 'Store'], ['persistenz', 'Store'], ['datenbank', 'Store'],
|
||||
['reference catalogs', 'Reference Catalogs'], ['referenzkataloge', 'Reference Catalogs'],
|
||||
['shared kernel', 'Shared Kernel'],
|
||||
['presentation', 'Presentation'], ['präsentation', 'Presentation'], ['praesentation', 'Presentation'], ['ui', 'Presentation'],
|
||||
['platform', 'Platform'], ['plattform', 'Platform'],
|
||||
['core tests', 'Core Tests'], ['tests', 'Core Tests'],
|
||||
['unclassified source', 'Unclassified Source'],
|
||||
]);
|
||||
|
||||
export function resolveAreaName(raw) {
|
||||
return AREA_ALIASES.get(raw.trim().toLowerCase().replace(/\s+/g, ' ')) ?? null;
|
||||
}
|
||||
|
||||
export function relativeToProject(filePath, projectDir) {
|
||||
const normalized = path.resolve(projectDir, filePath);
|
||||
const relative = path.relative(projectDir, normalized).split(path.sep).join('/');
|
||||
if (relative.startsWith('..') || path.isAbsolute(relative)) return null;
|
||||
return relative.toLowerCase();
|
||||
}
|
||||
|
||||
function matchPrefix(relative, table) {
|
||||
for (const [name, prefixes] of table) {
|
||||
if (prefixes.some((prefix) => relative === prefix || relative.startsWith(prefix))) return name;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function classifyPath(relative) {
|
||||
if (relative === null) return null;
|
||||
if (matchPrefix(relative, ENGINE_PATHS)) return null;
|
||||
return matchPrefix(relative, CORE_AREAS);
|
||||
}
|
||||
|
||||
export function isReleased(area, releasedAreas) {
|
||||
if (releasedAreas.includes(area)) return true;
|
||||
// "alle" never covers Governance: the guard and these rules must always be named.
|
||||
return area !== GOVERNANCE && releasedAreas.includes(ALL_AREAS);
|
||||
}
|
||||
|
|
@ -0,0 +1,173 @@
|
|||
#!/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}).`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
|
@ -0,0 +1,88 @@
|
|||
#!/usr/bin/env node
|
||||
// UserPromptSubmit hook: records an explicit "Core-Freigabe: <area>[, <area>]" from the user's message.
|
||||
// The release covers only this message; every new message replaces or removes it.
|
||||
// Only the user's own prompt is read, never tool output or file content.
|
||||
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { RELEASE_FILE, ALL_AREAS, resolveAreaName } from './core-areas.mjs';
|
||||
|
||||
const RELEASE_PHRASE = /core[- ]?freigabe\s*:?\s*/gi;
|
||||
const SEPARATOR = /^(\s*(,|;|\/|&|\+|\bund\b|\band\b)\s*|\s+)/i;
|
||||
|
||||
// Reads area names from the start of the text after the phrase; trailing prose ends the list.
|
||||
// Any character other than a letter or space (dash, colon, digit, punctuation, newline) ends a name,
|
||||
// so "Core-Freigabe: Shared Kernel – füge ..." releases Shared Kernel.
|
||||
function readAreas(tail, unknown) {
|
||||
const areas = [];
|
||||
let rest = tail;
|
||||
while (rest.length > 0) {
|
||||
const word = rest.match(/^[\p{L}][\p{L} ]*?(?=$|[^\p{L} ]|\s+(und|and)\b)/u)?.[0] ?? '';
|
||||
const candidates = word.split(' ').map((_, index, parts) => parts.slice(0, parts.length - index).join(' '));
|
||||
const match = candidates.find((candidate) => resolveAreaName(candidate));
|
||||
if (!match) {
|
||||
if (areas.length === 0 && word) unknown.push(word.trim());
|
||||
break;
|
||||
}
|
||||
areas.push(resolveAreaName(match));
|
||||
rest = rest.slice(match.length);
|
||||
const separator = rest.match(SEPARATOR);
|
||||
if (!separator) break;
|
||||
rest = rest.slice(separator[0].length);
|
||||
}
|
||||
return areas;
|
||||
}
|
||||
|
||||
function parseRelease(prompt) {
|
||||
const areas = new Set();
|
||||
const unknown = [];
|
||||
for (const match of prompt.matchAll(RELEASE_PHRASE)) {
|
||||
const tail = prompt.slice(match.index + match[0].length);
|
||||
for (const area of readAreas(tail, unknown)) areas.add(area);
|
||||
}
|
||||
return { areas: [...areas], unknown };
|
||||
}
|
||||
|
||||
function label(area) {
|
||||
return area === ALL_AREAS ? 'alle Core-Bereiche ausser Governance' : area;
|
||||
}
|
||||
|
||||
function main(raw) {
|
||||
const input = JSON.parse(raw);
|
||||
const projectDir = process.env.CLAUDE_PROJECT_DIR || input.cwd || process.cwd();
|
||||
const releaseFile = path.join(projectDir, RELEASE_FILE);
|
||||
const { areas, unknown } = parseRelease(String(input.prompt ?? ''));
|
||||
fs.rmSync(releaseFile, { force: true });
|
||||
const output = {};
|
||||
if (areas.length > 0) {
|
||||
fs.writeFileSync(releaseFile, JSON.stringify({ sessionId: input.session_id ?? null, areas, createdAt: new Date().toISOString() }));
|
||||
const text = areas.map(label).join(', ');
|
||||
output.systemMessage = `✅ CORE-FREIGABE für diese Nachricht: ${text}`;
|
||||
output.hookSpecificOutput = {
|
||||
hookEventName: 'UserPromptSubmit',
|
||||
additionalContext: `The user released these Core areas for this message only: ${text}. Other Core areas stay blocked.`,
|
||||
};
|
||||
}
|
||||
if (unknown.length > 0) {
|
||||
output.systemMessage = [output.systemMessage, `⚠ Core-Freigabe nicht erkannt: "${unknown.join('", "')}". Gueltige Bereiche: Ingest, Normalization, Evaluation, Store, Disclosure, Reference Catalogs, Shared Kernel, Presentation, Platform, Core Tests, Governance, alle.`]
|
||||
.filter(Boolean).join('\n');
|
||||
}
|
||||
if (Object.keys(output).length > 0) process.stdout.write(JSON.stringify(output));
|
||||
}
|
||||
|
||||
let raw = '';
|
||||
process.stdin.setEncoding('utf8');
|
||||
process.stdin.on('data', (chunk) => { raw += chunk; });
|
||||
process.stdin.on('end', () => {
|
||||
try {
|
||||
main(raw);
|
||||
} catch (error) {
|
||||
// Fail closed: without a readable message no release remains active.
|
||||
try {
|
||||
fs.rmSync(path.join(process.env.CLAUDE_PROJECT_DIR || process.cwd(), RELEASE_FILE), { force: true });
|
||||
} catch {}
|
||||
process.stdout.write(JSON.stringify({
|
||||
systemMessage: `⚠ Core-Freigabe konnte nicht gelesen werden (${error instanceof Error ? error.message : 'unbekannter Fehler'}). Der Core bleibt gesperrt.`,
|
||||
}));
|
||||
}
|
||||
});
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
{
|
||||
"hooks": {
|
||||
"UserPromptSubmit": [
|
||||
{
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "node \"$CLAUDE_PROJECT_DIR/.claude/hooks/core-release.mjs\"",
|
||||
"timeout": 10
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"PreToolUse": [
|
||||
{
|
||||
"matcher": "Edit|Write|NotebookEdit|Bash|PowerShell",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "node \"$CLAUDE_PROJECT_DIR/.claude/hooks/core-guard.mjs\"",
|
||||
"timeout": 10,
|
||||
"statusMessage": "Checking Core Engine boundary"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue