Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
86 changes: 86 additions & 0 deletions .claude/hooks/dist/mcp-directory-guard.mjs
Original file line number Diff line number Diff line change
@@ -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();
104 changes: 104 additions & 0 deletions .claude/hooks/src/mcp-directory-guard.ts
Original file line number Diff line number Diff line change
@@ -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 <opc-dir-variant> && (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();
10 changes: 10 additions & 0 deletions .claude/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,16 @@
"timeout": 5
}
]
},
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "node $HOME/.claude/hooks/dist/mcp-directory-guard.mjs",
"timeout": 5
}
]
}
],
"PreCompact": [
Expand Down