diff --git a/.claude/hooks/dist/mcp-directory-guard.mjs b/.claude/hooks/dist/mcp-directory-guard.mjs new file mode 100644 index 00000000..e50ce092 --- /dev/null +++ b/.claude/hooks/dist/mcp-directory-guard.mjs @@ -0,0 +1,86 @@ +// src/mcp-directory-guard.ts +import { readFileSync } from "fs"; + +// src/shared/opc-path.ts +import { existsSync } from "fs"; +import { join } from "path"; +function getOpcDir() { + const envOpcDir = process.env.CLAUDE_OPC_DIR; + if (envOpcDir && existsSync(envOpcDir)) { + return envOpcDir; + } + const projectDir = process.env.CLAUDE_PROJECT_DIR || process.cwd(); + const localOpc = join(projectDir, "opc"); + if (existsSync(localOpc)) { + return localOpc; + } + const homeDir = process.env.HOME || process.env.USERPROFILE || ""; + if (homeDir) { + const globalClaude = join(homeDir, ".claude"); + const globalScripts = join(globalClaude, "scripts", "core"); + if (existsSync(globalScripts)) { + return globalClaude; + } + } + return null; +} + +// src/mcp-directory-guard.ts +var SCRIPT_PATH_PATTERN = /\bscripts\/(mcp|core)\//; +function buildCdPrefixPattern(opcDir) { + const escapedDir = opcDir ? opcDir.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") : ""; + const variants = [ + "\\$CLAUDE_OPC_DIR", + "\\$\\{CLAUDE_OPC_DIR\\}" + ]; + if (escapedDir) { + variants.push(escapedDir); + } + return new RegExp(`^\\s*cd\\s+(${variants.join("|")})\\s*&&`); +} +function main() { + let input; + try { + const stdinContent = readFileSync(0, "utf-8"); + input = JSON.parse(stdinContent); + } catch { + console.log("{}"); + return; + } + if (input.tool_name !== "Bash") { + console.log("{}"); + return; + } + const command = input.tool_input?.command; + if (!command) { + console.log("{}"); + return; + } + if (!SCRIPT_PATH_PATTERN.test(command)) { + console.log("{}"); + return; + } + const opcDir = getOpcDir(); + const cdPrefix = buildCdPrefixPattern(opcDir); + if (cdPrefix.test(command)) { + console.log("{}"); + return; + } + const dirRef = opcDir || "$CLAUDE_OPC_DIR"; + const corrected = `cd ${dirRef} && ${command.trimStart()}`; + const output = { + hookSpecificOutput: { + hookEventName: "PreToolUse", + permissionDecision: "deny", + permissionDecisionReason: `OPC directory guard: commands referencing scripts/(mcp|core)/ must run from the OPC directory so uv can find pyproject.toml. + +Blocked command: + ${command.trim()} + +Corrected command: + ${corrected}` + } + }; + console.log(JSON.stringify(output)); +} +main(); diff --git a/.claude/hooks/src/mcp-directory-guard.ts b/.claude/hooks/src/mcp-directory-guard.ts new file mode 100644 index 00000000..6709b191 --- /dev/null +++ b/.claude/hooks/src/mcp-directory-guard.ts @@ -0,0 +1,104 @@ +/** + * PreToolUse:Bash Hook - OPC Script Directory Guard + * + * Prevents running scripts from `scripts/(mcp|core)/` without first + * changing to $CLAUDE_OPC_DIR. When Claude runs these scripts from the + * wrong directory, `uv run` misses `opc/pyproject.toml` and its + * dependencies, causing ModuleNotFoundError. + * + * Detection: any Bash command referencing `scripts/(mcp|core)/` paths + * Allowed: commands prefixed with `cd $CLAUDE_OPC_DIR &&` (or resolved path) + * Denied: returns corrected command in the reason message + * + * Fixes: #148 + */ + +import { readFileSync } from 'fs'; +import { getOpcDir } from './shared/opc-path.js'; +import type { PreToolUseInput, PreToolUseHookOutput } from './shared/types.js'; + +/** + * Pattern matching scripts/(mcp|core)/ references in Bash commands. + * Captures the path for use in the corrected command suggestion. + */ +const SCRIPT_PATH_PATTERN = /\bscripts\/(mcp|core)\//; + +/** + * Pattern matching a proper cd prefix to OPC dir. + * Accepts: + * cd $CLAUDE_OPC_DIR && + * cd ${CLAUDE_OPC_DIR} && + * cd /resolved/opc/path && + */ +function buildCdPrefixPattern(opcDir: string | null): RegExp { + const escapedDir = opcDir ? opcDir.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') : ''; + // Match: cd && (with flexible whitespace) + const variants = [ + '\\$CLAUDE_OPC_DIR', + '\\$\\{CLAUDE_OPC_DIR\\}', + ]; + if (escapedDir) { + variants.push(escapedDir); + } + return new RegExp(`^\\s*cd\\s+(${variants.join('|')})\\s*&&`); +} + +function main(): void { + let input: PreToolUseInput; + try { + const stdinContent = readFileSync(0, 'utf-8'); + input = JSON.parse(stdinContent) as PreToolUseInput; + } catch { + // Can't read input - allow through + console.log('{}'); + return; + } + + // Only process Bash tool + if (input.tool_name !== 'Bash') { + console.log('{}'); + return; + } + + const command = input.tool_input?.command as string; + if (!command) { + console.log('{}'); + return; + } + + // Check if command references OPC script paths + if (!SCRIPT_PATH_PATTERN.test(command)) { + // No script path reference - allow through + console.log('{}'); + return; + } + + const opcDir = getOpcDir(); + const cdPrefix = buildCdPrefixPattern(opcDir); + + // Check if command already has the correct cd prefix + if (cdPrefix.test(command)) { + console.log('{}'); + return; + } + + // Build corrected command suggestion + const dirRef = opcDir || '$CLAUDE_OPC_DIR'; + const corrected = `cd ${dirRef} && ${command.trimStart()}`; + + const output: PreToolUseHookOutput = { + hookSpecificOutput: { + hookEventName: 'PreToolUse', + permissionDecision: 'deny', + permissionDecisionReason: + `OPC directory guard: commands referencing scripts/(mcp|core)/ must ` + + `run from the OPC directory so uv can find pyproject.toml.\n\n` + + `Blocked command:\n ${command.trim()}\n\n` + + `Corrected command:\n ${corrected}`, + }, + }; + + console.log(JSON.stringify(output)); +} + +main(); diff --git a/.claude/settings.json b/.claude/settings.json index c575d685..546caff2 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -77,6 +77,16 @@ "timeout": 5 } ] + }, + { + "matcher": "Bash", + "hooks": [ + { + "type": "command", + "command": "node $HOME/.claude/hooks/dist/mcp-directory-guard.mjs", + "timeout": 5 + } + ] } ], "PreCompact": [