#!/usr/bin/env node
/**
* Claude Code PermissionRequest Hook
* Auto-approves non-destructive MCP tools from all servers
*
* This hook intercepts permission requests and automatically approves
* read-only operations (tools with readOnlyHint or without destructiveHint),
* eliminating the need for manual user confirmation on safe operations.
*
* Updated: 2026-01-09 - Configuration loading, pattern matching fixes
* Created: 2026-01-08
* Related: MCP Tool Annotations (readOnlyHint, destructiveHint)
*/
const fs = require('fs');
const path = require('path');
const os = require('os');
// Default destructive patterns (always require confirmation)
const DEFAULT_DESTRUCTIVE_PATTERNS = [
'delete',
'remove',
'destroy',
'drop',
'clear',
'wipe',
'purge',
'forget',
'erase',
'reset',
'update', // Can be destructive
'modify',
'edit',
'change',
'write', // Can overwrite
'create', // Can create unwanted resources
'deploy',
'publish',
'execute', // Code execution can be dangerous
'run',
'eval',
'consolidate' // Modifies memories
];
// Default safe read-only patterns (can be auto-approved)
const DEFAULT_SAFE_PATTERNS = [
'get',
'list',
'read',
'retrieve',
'fetch',
'search',
'find',
'query',
'recall',
'check',
'status',
'health',
'stats',
'analyze',
'view',
'show',
'describe',
'inspect'
];
// Configuration state (loaded at startup)
let config = {
enabled: true,
autoApprove: true,
logDecisions: false,
destructivePatterns: DEFAULT_DESTRUCTIVE_PATTERNS,
safePatterns: DEFAULT_SAFE_PATTERNS
};
/**
* Load configuration from ~/.claude/hooks/config.json
* Merges custom patterns with built-in defaults
*/
function loadConfiguration() {
try {
const configPath = path.join(os.homedir(), '.claude', 'hooks', 'config.json');
if (!fs.existsSync(configPath)) {
// No config file, use defaults
return;
}
const configData = fs.readFileSync(configPath, 'utf8');
const fullConfig = JSON.parse(configData);
if (!fullConfig.permissionRequest) {
// No permissionRequest section, use defaults
return;
}
const hookConfig = fullConfig.permissionRequest;
// Load flags (with defaults)
config.enabled = hookConfig.enabled !== undefined ? hookConfig.enabled : true;
config.autoApprove = hookConfig.autoApprove !== undefined ? hookConfig.autoApprove : true;
config.logDecisions = hookConfig.logDecisions !== undefined ? hookConfig.logDecisions : false;
// Merge custom patterns with defaults
if (hookConfig.customSafePatterns && Array.isArray(hookConfig.customSafePatterns)) {
config.safePatterns = [...DEFAULT_SAFE_PATTERNS, ...hookConfig.customSafePatterns];
}
if (hookConfig.customDestructivePatterns && Array.isArray(hookConfig.customDestructivePatterns)) {
config.destructivePatterns = [...DEFAULT_DESTRUCTIVE_PATTERNS, ...hookConfig.customDestructivePatterns];
}
if (config.logDecisions) {
console.error('[PermissionRequest] Configuration loaded successfully');
console.error(` - Enabled: ${config.enabled}`);
console.error(` - Auto-approve: ${config.autoApprove}`);
console.error(` - Safe patterns: ${config.safePatterns.length}`);
console.error(` - Destructive patterns: ${config.destructivePatterns.length}`);
}
} catch (error) {
// On error, fall back to defaults
console.error(`[PermissionRequest] Failed to load config: ${error.message}`);
console.error('[PermissionRequest] Using default patterns');
}
}
// Load configuration at module initialization
loadConfiguration();
/**
* Main hook entry point
* Receives JSON via stdin, processes permission request, returns decision
*/
async function main() {
try {
// Check if hook is enabled
if (!config.enabled) {
if (config.logDecisions) {
console.error('[PermissionRequest] Hook disabled, prompting user');
}
outputDecision('prompt');
return;
}
// Read stdin input
const input = await readStdin();
const payload = JSON.parse(input);
// Check if this is an MCP tool call
if (isMCPToolCall(payload)) {
const toolName = extractToolName(payload);
// Check if auto-approve is disabled
if (!config.autoApprove) {
if (config.logDecisions) {
console.error('[PermissionRequest] Auto-approve disabled, prompting user');
}
outputDecision('prompt');
return;
}
// Check if tool is safe (non-destructive)
if (isSafeTool(toolName)) {
// Auto-approve safe tools
if (config.logDecisions) {
console.error(`[PermissionRequest] Auto-approved: ${toolName}`);
}
outputDecision('allow', {
reason: `Auto-approved safe tool: ${toolName}`,
auto_approved: true,
server: payload.server_name,
tool_name: toolName
});
} else {
// Require confirmation for potentially destructive tools
if (config.logDecisions) {
console.error(`[PermissionRequest] Prompting for: ${toolName}`);
}
outputDecision('prompt');
}
} else {
// Not an MCP tool call, show normal dialog
outputDecision('prompt');
}
} catch (error) {
// On error, fall back to prompting user
console.error('[PermissionRequest Hook] Error:', error.message);
outputDecision('prompt');
}
}
/**
* Check if the payload represents an MCP tool call
*/
function isMCPToolCall(payload) {
return payload && (
payload.hook_event_name === 'PermissionRequest' ||
payload.type === 'mcp_tool_call' ||
(payload.tool_name && payload.server_name)
);
}
/**
* Extract clean tool name from payload (strip mcp__ prefix)
* Preserves case for camelCase detection in isSafeTool()
*/
function extractToolName(payload) {
let toolName = payload.tool_name || '';
// Strip mcp__servername__ prefix if present
// Examples: mcp__memory__retrieve_memory -> retrieve_memory
// mcp__shodh-cloudflare__recall -> recall
// mcp__my_custom_server__get_data -> get_data
// Use non-greedy match to handle server names with underscores
const mcpPrefix = /^mcp__.+?__/;
toolName = toolName.replace(mcpPrefix, '');
return toolName; // Preserve case for camelCase detection
}
/**
* Check if the tool is safe (non-destructive) based on naming patterns
* Uses word boundary regex to prevent false matches
* Handles underscores, hyphens, and camelCase as word separators
* Examples:
* - "get_updated_records" → ["get", "updated", "records"]
* - "statusCheck" → ["status", "check"]
* - "GetData" → ["get", "data"]
*/
function isSafeTool(toolName) {
if (!toolName) {
return false;
}
// Split tool name by underscores, hyphens, and camelCase boundaries
// First insert separators before capital letters, then split
const withSeparators = toolName.replace(/([a-z])([A-Z])/g, '$1_$2');
const parts = withSeparators.toLowerCase().split(/[_-]/);
// First check: Does any part match a destructive pattern exactly?
for (const pattern of config.destructivePatterns) {
if (parts.includes(pattern)) {
return false; // Destructive - require confirmation
}
}
// Second check: Does any part match a safe pattern exactly?
for (const pattern of config.safePatterns) {
if (parts.includes(pattern)) {
return true; // Safe - auto-approve
}
}
// Unknown pattern - require confirmation (safer default)
return false;
}
/**
* Output the decision in Claude Code hook format
*/
function outputDecision(behavior, metadata = {}) {
const decision = {
hookSpecificOutput: {
hookEventName: 'PermissionRequest',
decision: {
behavior: behavior // 'allow', 'deny', or 'prompt'
}
}
};
// Add metadata if provided (for logging/debugging)
if (Object.keys(metadata).length > 0 && behavior !== 'prompt') {
decision.hookSpecificOutput.metadata = metadata;
}
console.log(JSON.stringify(decision));
}
/**
* Read all data from stdin
*/
function readStdin() {
return new Promise((resolve, reject) => {
let data = '';
process.stdin.setEncoding('utf8');
process.stdin.on('readable', () => {
let chunk;
while ((chunk = process.stdin.read()) !== null) {
data += chunk;
}
});
process.stdin.on('end', () => {
resolve(data);
});
process.stdin.on('error', (error) => {
reject(error);
});
// Timeout after 1 second
setTimeout(() => {
if (data.length === 0) {
reject(new Error('Timeout reading stdin'));
}
}, 1000);
});
}
// Run main
main().catch(error => {
console.error('[PermissionRequest Hook] Fatal error:', error);
outputDecision('prompt');
process.exit(1);
});