Skip to content
Open
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*&&`);
}
Comment on lines +33 to +44
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Allow quoted cd prefixes and quote corrected cd target.

Line 43 currently rejects valid forms like cd "$CLAUDE_OPC_DIR" && ..., and Line 87 can emit a broken command when the resolved OPC path contains spaces. This can deny correct commands and suggest unusable retries.

🔧 Proposed fix
 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*&&`);
+  const target = `(?:${variants.join('|')})`;
+  // allow both: cd $CLAUDE_OPC_DIR && ... and cd "$CLAUDE_OPC_DIR" && ...
+  return new RegExp(`^\\s*cd\\s+(?:(["'])${target}\\1|${target})\\s*&&`);
 }
+
+function quoteShellArg(value: string): string {
+  return "'" + value.replace(/'/g, "'\\''") + "'";
+}
@@
-  const dirRef = opcDir || '$CLAUDE_OPC_DIR';
+  const dirRef = opcDir ? quoteShellArg(opcDir) : '"$CLAUDE_OPC_DIR"';
   const corrected = `cd ${dirRef} && ${command.trimStart()}`;

Also applies to: 86-87

🧰 Tools
🪛 ast-grep (0.41.1)

[warning] 42-42: Regular expression constructed from variable input detected. This can lead to Regular Expression Denial of Service (ReDoS) attacks if the variable contains malicious patterns. Use libraries like 'recheck' to validate regex safety or use static patterns.
Context: new RegExp(^\\s*cd\\s+(${variants.join('|')})\\s*&&)
Note: [CWE-1333] Inefficient Regular Expression Complexity [REFERENCES]
- https://owasp.org/www-community/attacks/Regular_expression_Denial_of_Service_-_ReDoS
- https://cwe.mitre.org/data/definitions/1333.html

(regexp-from-variable)

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In @.claude/hooks/src/mcp-directory-guard.ts around lines 33 - 44, The regex in
buildCdPrefixPattern currently doesn't allow cd targets wrapped in quotes and
will fail to recognize commands like cd "$CLAUDE_OPC_DIR" && ...; update the
pattern to accept optional single or double quotes around the variants (both
'\\$CLAUDE_OPC_DIR', '\\$\\{CLAUDE_OPC_DIR\\}' and escapedDir) so quoted forms
match, and wherever the code constructs/suggests a corrected cd target (the
place that uses escapedDir to emit a replacement), ensure the resolved OPC path
is properly escaped and wrapped in quotes so paths with spaces produce a valid
cd "path" && ... suggestion.


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