Skip to content

Commit 8e77a89

Browse files
author
pwnholic
committed
feat: add PreToolUse:Bash MCP directory guard (PR parcadei#160)
- New hook blocks scripts/(mcp|core)/ commands outside OPC directory - Prevents ModuleNotFoundError when uv run misses pyproject.toml - Returns corrected command with cd prefix suggestion - Fixes parcadei#148
1 parent e2b3c61 commit 8e77a89

3 files changed

Lines changed: 195 additions & 0 deletions

File tree

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
// src/mcp-directory-guard.ts
2+
import { readFileSync } from "fs";
3+
4+
// src/shared/opc-path.ts
5+
import { existsSync } from "fs";
6+
import { join } from "path";
7+
function getOpcDir() {
8+
const envOpcDir = process.env.CLAUDE_OPC_DIR;
9+
if (envOpcDir && existsSync(envOpcDir)) {
10+
return envOpcDir;
11+
}
12+
const projectDir = process.env.CLAUDE_PROJECT_DIR || process.cwd();
13+
const localOpc = join(projectDir, "opc");
14+
if (existsSync(localOpc)) {
15+
return localOpc;
16+
}
17+
const homeDir = process.env.HOME || process.env.USERPROFILE || "";
18+
if (homeDir) {
19+
const globalClaude = join(homeDir, ".claude");
20+
const globalScripts = join(globalClaude, "scripts", "core");
21+
if (existsSync(globalScripts)) {
22+
return globalClaude;
23+
}
24+
}
25+
return null;
26+
}
27+
28+
// src/mcp-directory-guard.ts
29+
var SCRIPT_PATH_PATTERN = /\bscripts\/(mcp|core)\//;
30+
function buildCdPrefixPattern(opcDir) {
31+
const escapedDir = opcDir ? opcDir.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") : "";
32+
const variants = [
33+
"\\$CLAUDE_OPC_DIR",
34+
"\\$\\{CLAUDE_OPC_DIR\\}"
35+
];
36+
if (escapedDir) {
37+
variants.push(escapedDir);
38+
}
39+
return new RegExp(`^\\s*cd\\s+(${variants.join("|")})\\s*&&`);
40+
}
41+
function main() {
42+
let input;
43+
try {
44+
const stdinContent = readFileSync(0, "utf-8");
45+
input = JSON.parse(stdinContent);
46+
} catch {
47+
console.log("{}");
48+
return;
49+
}
50+
if (input.tool_name !== "Bash") {
51+
console.log("{}");
52+
return;
53+
}
54+
const command = input.tool_input?.command;
55+
if (!command) {
56+
console.log("{}");
57+
return;
58+
}
59+
if (!SCRIPT_PATH_PATTERN.test(command)) {
60+
console.log("{}");
61+
return;
62+
}
63+
const opcDir = getOpcDir();
64+
const cdPrefix = buildCdPrefixPattern(opcDir);
65+
if (cdPrefix.test(command)) {
66+
console.log("{}");
67+
return;
68+
}
69+
const dirRef = opcDir || "$CLAUDE_OPC_DIR";
70+
const corrected = `cd ${dirRef} && ${command.trimStart()}`;
71+
const output = {
72+
hookSpecificOutput: {
73+
hookEventName: "PreToolUse",
74+
permissionDecision: "deny",
75+
permissionDecisionReason: `OPC directory guard: commands referencing scripts/(mcp|core)/ must run from the OPC directory so uv can find pyproject.toml.
76+
77+
Blocked command:
78+
${command.trim()}
79+
80+
Corrected command:
81+
${corrected}`
82+
}
83+
};
84+
console.log(JSON.stringify(output));
85+
}
86+
main();
Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
/**
2+
* PreToolUse:Bash Hook - OPC Script Directory Guard
3+
*
4+
* Prevents running scripts from `scripts/(mcp|core)/` without first
5+
* changing to $CLAUDE_OPC_DIR. When Claude runs these scripts from the
6+
* wrong directory, `uv run` misses `opc/pyproject.toml` and its
7+
* dependencies, causing ModuleNotFoundError.
8+
*
9+
* Detection: any Bash command referencing `scripts/(mcp|core)/` paths
10+
* Allowed: commands prefixed with `cd $CLAUDE_OPC_DIR &&` (or resolved path)
11+
* Denied: returns corrected command in the reason message
12+
*
13+
* Fixes: #148
14+
*/
15+
16+
import { readFileSync } from 'fs';
17+
import { getOpcDir } from './shared/opc-path.js';
18+
import type { PreToolUseInput, PreToolUseHookOutput } from './shared/types.js';
19+
20+
/**
21+
* Pattern matching scripts/(mcp|core)/ references in Bash commands.
22+
* Captures the path for use in the corrected command suggestion.
23+
*/
24+
const SCRIPT_PATH_PATTERN = /\bscripts\/(mcp|core)\//;
25+
26+
/**
27+
* Pattern matching a proper cd prefix to OPC dir.
28+
* Accepts:
29+
* cd $CLAUDE_OPC_DIR &&
30+
* cd ${CLAUDE_OPC_DIR} &&
31+
* cd /resolved/opc/path &&
32+
*/
33+
function buildCdPrefixPattern(opcDir: string | null): RegExp {
34+
const escapedDir = opcDir ? opcDir.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') : '';
35+
// Match: cd <opc-dir-variant> && (with flexible whitespace)
36+
const variants = [
37+
'\\$CLAUDE_OPC_DIR',
38+
'\\$\\{CLAUDE_OPC_DIR\\}',
39+
];
40+
if (escapedDir) {
41+
variants.push(escapedDir);
42+
}
43+
return new RegExp(`^\\s*cd\\s+(${variants.join('|')})\\s*&&`);
44+
}
45+
46+
function main(): void {
47+
let input: PreToolUseInput;
48+
try {
49+
const stdinContent = readFileSync(0, 'utf-8');
50+
input = JSON.parse(stdinContent) as PreToolUseInput;
51+
} catch {
52+
// Can't read input - allow through
53+
console.log('{}');
54+
return;
55+
}
56+
57+
// Only process Bash tool
58+
if (input.tool_name !== 'Bash') {
59+
console.log('{}');
60+
return;
61+
}
62+
63+
const command = input.tool_input?.command as string;
64+
if (!command) {
65+
console.log('{}');
66+
return;
67+
}
68+
69+
// Check if command references OPC script paths
70+
if (!SCRIPT_PATH_PATTERN.test(command)) {
71+
// No script path reference - allow through
72+
console.log('{}');
73+
return;
74+
}
75+
76+
const opcDir = getOpcDir();
77+
const cdPrefix = buildCdPrefixPattern(opcDir);
78+
79+
// Check if command already has the correct cd prefix
80+
if (cdPrefix.test(command)) {
81+
console.log('{}');
82+
return;
83+
}
84+
85+
// Build corrected command suggestion
86+
const dirRef = opcDir || '$CLAUDE_OPC_DIR';
87+
const corrected = `cd ${dirRef} && ${command.trimStart()}`;
88+
89+
const output: PreToolUseHookOutput = {
90+
hookSpecificOutput: {
91+
hookEventName: 'PreToolUse',
92+
permissionDecision: 'deny',
93+
permissionDecisionReason:
94+
`OPC directory guard: commands referencing scripts/(mcp|core)/ must ` +
95+
`run from the OPC directory so uv can find pyproject.toml.\n\n` +
96+
`Blocked command:\n ${command.trim()}\n\n` +
97+
`Corrected command:\n ${corrected}`,
98+
},
99+
};
100+
101+
console.log(JSON.stringify(output));
102+
}
103+
104+
main();

.claude/settings.json

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -228,6 +228,11 @@
228228
{
229229
"matcher": "Bash",
230230
"hooks": [
231+
{
232+
"type": "command",
233+
"command": "node $HOME/.claude/hooks/dist/mcp-directory-guard.mjs",
234+
"timeout": 5
235+
},
231236
{
232237
"type": "command",
233238
"command": "node $HOME/.claude/hooks/dist/import-error-detector.mjs",

0 commit comments

Comments
 (0)