diff --git a/.claude/hooks/core-areas.mjs b/.claude/hooks/core-areas.mjs new file mode 100644 index 0000000..83b65e4 --- /dev/null +++ b/.claude/hooks/core-areas.mjs @@ -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); +} diff --git a/.claude/hooks/core-guard.mjs b/.claude/hooks/core-guard.mjs new file mode 100644 index 0000000..eb0d97d --- /dev/null +++ b/.claude/hooks/core-guard.mjs @@ -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: "). +// 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: " 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}).`, + ); + } +}); diff --git a/.claude/hooks/core-release.mjs b/.claude/hooks/core-release.mjs new file mode 100644 index 0000000..9aeef5f --- /dev/null +++ b/.claude/hooks/core-release.mjs @@ -0,0 +1,88 @@ +#!/usr/bin/env node +// UserPromptSubmit hook: records an explicit "Core-Freigabe: [, ]" 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.`, + })); + } +}); diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 0000000..4fc6b6e --- /dev/null +++ b/.claude/settings.json @@ -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" + } + ] + } + ] + } +} \ No newline at end of file