You’re watching Claude Code prepare to execute a tool call—a file operation, an API request, a shell command—and you think: I could intercept that. I could rewrite what’s about to happen. That’s what input modification hooks do. They let you transform, sanitize, or completely override the parameters before the tool even executes. You’re not just deciding whether something happens; you’re deciding how it happens.
This is power. And like all power, it comes with patterns you need to understand.
Input modification happens in the PreToolUse hook phase, right before Claude invokes any tool. You intercept the raw tool input—paths, parameters, commands, credentials—modify it, and return the rewritten version. The tool then executes with your modifications, not the original parameters. The key insight is that Claude never knows what you changed. From Claude’s perspective, the tool worked exactly as expected. But under the hood, you enforced your constraints.
Why would you do this? Plenty of reasons: enforce security boundaries, rewrite file paths to stay within allowed directories, sanitize shell commands to remove dangerous flags, add default parameters automatically, validate inputs before they reach sensitive operations. We’ll walk through the real-world patterns that matter, the edge cases you’ll encounter, and the scenarios where this becomes essential.
The Core Mechanism: Intercepting Before Execution
The PreToolUse hook fires immediately before any tool invocation. You receive:
- The tool name (what’s being called)
- The tool input (parameters being passed)
- Context about the request (which file, which operation)
- User information (who initiated this, what permissions they have)
- Environment context (dev vs. production, session metadata)
- The full conversation history (optional, for context-aware decisions)
You can then:
- Return modified input – The tool executes with your changes
- Throw an error – Block execution entirely (permission gate)
- Return null – Block execution silently
- Return partial modifications – Change only specific parameters
- Log the attempt – Record what was requested before deciding
The key difference from permission hooks: you’re modifying what happens, not just deciding whether it happens. The tool still runs, but with different parameters. This is the difference between “block this operation” and “let this operation happen, but safely.” You’re not a gatekeeper; you’re a transformer.
Think of it like a middleware layer in a web framework. Every tool call passes through your PreToolUse hook. You’re in the middle of the request, with full visibility and modification capabilities. This happens before Claude’s natural language processing determines what the tool should do, but after the decision to use the tool has been made. You’re operating at the intersection of intent and execution.
Why is this different from simply asking Claude to be careful? Because Claude is generative—it doesn’t always anticipate security constraints or operational requirements. It might construct a path that escapes your project directory. It might build a shell command with dangerous flags. It might try to access a file it shouldn’t. It might make an API call with the wrong credentials. By intercepting and modifying, you enforce your constraints automatically, without relying on Claude to remember them or implement them correctly every single time.
Let’s look at a basic example:
// hooks/pre-tool-use.mjs
export const handler = async (context) => {
const { toolName, toolInput } = context;
// Intercept file operations
if (toolName === "read_file") {
// Ensure the file path stays within the project directory
const safePath = enforceProjectBoundary(toolInput.path);
return {
...toolInput,
path: safePath,
};
}
// Let other tools through unchanged
return toolInput;
};
function enforceProjectBoundary(requestedPath) {
const projectRoot = "/home/user/my-project";
const normalizedPath = require("path").resolve(requestedPath);
if (!normalizedPath.startsWith(projectRoot)) {
return projectRoot; // Fallback to safe default
}
return normalizedPath;
}
That’s the pattern: intercept, validate, transform, return. The modified input continues through to execution, and Claude never knows the difference.
Pattern 1: Directory Enforcement and Path Rewriting
The most common real-world scenario is preventing file operations outside a designated boundary. Claude might try to read system files, write to directories it shouldn’t touch, or follow symlinks outside the project. This isn’t because Claude is malicious—it’s because it’s solving a problem and doesn’t always respect your filesystem boundaries.
Consider this scenario: you ask Claude to “improve error handling across the codebase.” Claude might see an error-related file in /usr/share/error-examples/ and think it’s relevant research. Or you ask Claude to “check the system logs,” and it tries to read /var/log/. These aren’t security violations in intent, but they are violations in scope. Claude’s trying to be helpful; it just doesn’t know your boundaries.
Input modification lets you transparently redirect these operations to safe locations. Claude still accomplishes its goal, but within your boundaries. Here’s a production-grade path enforcement system:
// hooks/enforce-project-boundaries.mjs
const PROJECT_ROOTS = [
"/projects/website",
"/projects/api",
"/projects/shared-lib",
];
const FORBIDDEN_PATTERNS = [
/^\/etc\//,
/^\/root\//,
/^\/sys\//,
/\.\.\/\.\./, // Parent directory traversal
/\.\.\/root/, // Home directory traversal
];
export const handler = async (context) => {
const { toolName, toolInput } = context;
// Only modify file operation tools
const fileTools = [
"read_file",
"write_file",
"delete_file",
"list_directory",
];
if (!fileTools.includes(toolName)) {
return toolInput;
}
const targetPath = toolInput.path;
// Check forbidden patterns
for (const pattern of FORBIDDEN_PATTERNS) {
if (pattern.test(targetPath)) {
throw new Error(
`Access denied: path "${targetPath}" matches forbidden pattern`,
);
}
}
// Resolve to absolute path
let resolvedPath = path.resolve(targetPath);
// Check if path falls within allowed project roots
const isAllowed = PROJECT_ROOTS.some((root) =>
resolvedPath.startsWith(path.resolve(root)),
);
if (!isAllowed) {
throw new Error(
`Access denied: "${targetPath}" is outside project boundaries`,
);
}
// Return the safely resolved path
return {
...toolInput,
path: resolvedPath,
};
};
This hook:
- Rejects absolute paths to system directories using explicit pattern matching
- Blocks parent directory traversal attempts (the
../../../pattern) - Resolves relative paths to absolute ones for comparison
- Only allows access within designated project roots
- Throws errors with clear messages (permission gate), not silent failures
- Validates paths before any filesystem operation touches them
The pattern is strict-by-default: if a path doesn’t match your whitelist, it’s blocked. No assumptions. No “probably safe” decisions. Either a path is in your allowed roots, or it’s rejected immediately with a clear error message that Claude (and you) can see.
Why throw an error instead of silently rewriting? Because Claude needs to know it tried something that wasn’t allowed. If you silently rewrite /etc/passwd to /projects/myapp/passwords.txt, Claude might continue operating under the assumption that it successfully accessed system files, leading to confused behavior. By throwing an error, Claude learns the constraint and can adjust its approach.
Edge Case: Symlinks and Race Conditions
In production environments, you need to handle symlinks carefully. A symlink might point outside your project roots. Here’s how to handle it:
// Additional check for symlinks
function enforceProjectBoundary(requestedPath) {
let resolvedPath = path.resolve(requestedPath);
// Resolve symlinks to their actual target
try {
resolvedPath = fs.realpathSync(resolvedPath);
} catch (err) {
// File doesn't exist yet (write operation), use the requested path
resolvedPath = path.resolve(requestedPath);
}
const isAllowed = PROJECT_ROOTS.some((root) =>
resolvedPath.startsWith(path.resolve(root)),
);
if (!isAllowed) {
throw new Error(
`Access denied: "${requestedPath}" resolves to "${resolvedPath}", which is outside project boundaries`,
);
}
return resolvedPath;
}
This handles the case where someone tries to sneak access by using a symlink. fs.realpathSync() follows symlinks and returns the actual target, ensuring you’re checking the real destination, not the symlink path.
Pattern 2: Command Sanitization for Shell Execution
Shell commands are dangerous. Claude might construct something like rm -rf / when you meant to clean a temp folder. Or it might pipe output through sudo when you didn’t explicitly allow privilege escalation. Or it might use backticks for command substitution when you wanted literal arguments. Input modification lets you sanitize dangerous commands before they execute.
The challenge with shell commands is that they’re syntactically flexible but semantically dangerous. You can’t just blacklist a few keywords—attackers (or overeager AI models) use tricks like:
- Command chaining with pipes and semicolons
- Subshells and command substitution
- Argument injection
- Signal handling tricks (SIGSTOP, SIGKILL)
- Symbolic links and race conditions
- Glob expansion gotchas
Your sanitization hook needs to be paranoid. Here’s a production-grade system:
// hooks/sanitize-shell-commands.mjs
export const handler = async (context) => {
const { toolName, toolInput } = context;
if (toolName !== "execute_command") {
return toolInput;
}
const command = toolInput.command;
// Dangerous patterns that should be blocked entirely
const dangerousPatterns = [
/rm\s+-rf\s+\//, // rm -rf /
/\|.*rm\s+-rf/, // anything piped to rm -rf
/mkfs\./, // filesystem formatting
/dd\s+if=.*of=/, // raw disk writes
/chmod\s+000/, // removing all permissions
/\|\s*sudo/, // piping to sudo
/:\(\)\s*\{/, // fork bomb pattern
];
for (const pattern of dangerousPatterns) {
if (pattern.test(command)) {
throw new Error(`Dangerous command blocked: "${command}"`);
}
}
// Rewrite dangerous flags
let sanitized = command
// Force interactive prompts for destructive operations
.replace(/rm\s+-f/g, "rm -i")
.replace(/rm\s+-rf/g, "rm -ir")
// Limit find depth to prevent accidental deep traversals
.replace(/find\s+\//, "find / -maxdepth 3");
// Add safety defaults
if (sanitized.startsWith("find ")) {
// Prevent find from following symlinks
if (!sanitized.includes("-L")) {
sanitized += " -type f";
}
}
return {
...toolInput,
command: sanitized,
};
};
This hook:
- Blocks fork bombs and destructive rm patterns that would destroy systems
- Prevents piping to dangerous commands (like
sudoorrm) - Rewrites dangerous flags to safer alternatives (remove
-fforce flag, add-iinteractive) - Adds safety defaults for commands like
find(type filtering, depth limits) - Throws clear errors for attempts to bypass the rules
- Uses precise regex patterns to catch variations and obfuscation
The key insight: some operations are inherently dangerous. You don’t just log them—you rewrite them or block them entirely. Notice that this hook doesn’t just reject dangerous commands; it actively rewrites them to safer alternatives. rm -f becomes rm -i (interactive mode, prompts before deletion). find / becomes find / -maxdepth 3 -type f (limited depth, files only).
This is the difference between being a gatekeeper (yes/no decisions) and being a safety system (intelligent transformation). You’re not saying “no, you can’t use find.” You’re saying “yes, you can use find, but like this—with safety constraints built in.”
Edge Case: Complex Command Chains
Be aware that piping and command substitution can hide dangerous patterns:
// Handle more complex patterns
const complexDangerousPatterns = [
/\$\(.*rm.*\)/, // Command substitution with rm
/`.*rm.*`/, // Backtick substitution with rm
/&&.*rm\s+-rf/, // Chained commands with rm -rf
];
for (const pattern of complexDangerousPatterns) {
if (pattern.test(command)) {
throw new Error(`Dangerous command pattern detected: "${command}"`);
}
}
Pattern 3: Adding Default Parameters and Required Options
Sometimes Claude forgets to include important parameters. You might ask Claude to “write this config to a file” and it does, but without specifying file permissions. Or “call this API” and it does, but without setting a timeout. These omissions can cause problems—overly permissive files, hanging requests, database connections that leak.
Input modification lets you automatically inject sensible defaults. This is different from validation (which rejects bad input) or blocking (which prevents execution). This is enhancement—you’re making the operation safer and more correct without Claude having to think about it.
// hooks/inject-default-parameters.mjs
export const handler = async (context) => {
const { toolName, toolInput } = context;
// For file writes, ensure proper permissions are set
if (toolName === "write_file") {
return {
...toolInput,
permissions: toolInput.permissions || "0644", // User can read/write, others read-only
encoding: toolInput.encoding || "utf-8",
backup: toolInput.backup !== false, // Default to creating backups
};
}
// For API calls, ensure required headers are present
if (toolName === "http_request") {
const headers = toolInput.headers || {};
return {
...toolInput,
headers: {
...headers,
"User-Agent": "Claude-Code/1.0",
"X-Request-ID": generateRequestId(),
// Ensure auth header exists (could be empty, but field is present)
Authorization:
headers.Authorization || process.env.DEFAULT_AUTH_TOKEN || "",
},
timeout: toolInput.timeout || 30000, // 30 second default
retry: toolInput.retry ?? true, // Default to retrying
};
}
// For database operations, ensure transaction safety
if (toolName === "execute_query") {
return {
...toolInput,
transaction: toolInput.transaction !== false, // Default to transactions
timeout: toolInput.timeout || 60000,
logLevel: toolInput.logLevel || "warn",
};
}
return toolInput;
};
function generateRequestId() {
return `req-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
}
This pattern:
- Injects sensible defaults (UTF-8 encoding, request timeouts, transaction safety)
- Ensures required fields are present (auth headers, transaction markers)
- Preserves user overrides (doesn’t override if the user explicitly set something)
- Uses the
??operator to allow explicit falsy values (liketimeout: 0for no timeout) - Generates unique request IDs for tracking and debugging
The philosophy: users can opt-out or override, but you’re providing safety rails by default. This is the “fail-safe with escape hatch” pattern. The defaults are conservative and safe, but if Claude (or the user) explicitly sets a value, that takes precedence.
Why use ?? instead of ||? Because || treats falsy values (0, false, empty string) as if they weren’t set. With timeouts, timeout: 0 might mean “no timeout” (a valid choice), not “timeout wasn’t specified.” The ?? operator only uses the default if the value is null or undefined, preserving explicit zeros and falses.
Pattern 4: Input Validation Before Execution
Rather than letting broken inputs reach the tool (and fail there), you can validate and enrich them upfront. This is the “fail fast, fail clearly” pattern. If Claude asks to read a file that doesn’t exist, you catch that before the tool runs. If it asks to read a 50GB log file, you intercept and explain why. You’re preventing wasted time and confusing errors.
The advantage of validating in the hook: you can provide context-aware error messages. The tool itself might just say “file not found” or “permission denied.” Your hook can say “file not found at /projects/myapp/config.json. Did you mean config.yaml?” or “file is 15GB, exceeding the 10MB limit for read operations. Try using grep to extract specific lines instead.”
// hooks/validate-and-enrich-inputs.mjs
export const handler = async (context) => {
const { toolName, toolInput } = context;
if (toolName === "read_file") {
const { path: filePath } = toolInput;
// Validate path exists
if (!fileExists(filePath)) {
throw new Error(`File not found: "${filePath}"`);
}
// Check file size (prevent reading 10GB logs)
const size = getFileSize(filePath);
if (size > 10 * 1024 * 1024) {
// 10MB limit
throw new Error(
`File too large: "${filePath}" is ${(size / 1024 / 1024).toFixed(1)}MB. ` +
`Maximum is 10MB. Consider reading specific lines with line range.`,
);
}
// Enrich input with file metadata
return {
...toolInput,
path: filePath,
encoding: toolInput.encoding || detectEncoding(filePath),
lineCount: countLines(filePath), // Informational, helps claude make decisions
};
}
if (toolName === "write_file") {
const { path: filePath, content } = toolInput;
// Validate content isn't suspiciously large
if (content && content.length > 50 * 1024 * 1024) {
throw new Error("Content too large: write operations limited to 50MB");
}
// Warn if overwriting without backup
if (fileExists(filePath) && !toolInput.backup) {
console.warn(
`Warning: overwriting existing file "${filePath}" without backup`,
);
}
return {
...toolInput,
path: filePath,
createMissing: true, // Always create parent directories
};
}
return toolInput;
};
// Placeholder implementations
function fileExists(path) {
return true; // Would use actual fs in real code
}
function getFileSize(path) {
return 0;
}
function detectEncoding(path) {
return "utf-8";
}
function countLines(path) {
return 0;
}
This hook:
- Validates inputs before they reach the tool (fail fast, fail loud)
- Prevents operations on resources that don’t exist
- Blocks operations on resources that are too large (prevents memory exhaustion)
- Enriches the input with metadata that helps Claude make better decisions (line counts, encoding hints)
- Provides clear error messages so the user knows exactly why the operation failed
- Automatically creates parent directories for write operations (convenience)
The pattern prevents a whole class of runtime failures by validating upfront. Instead of Claude getting a cryptic error from the filesystem, it gets clear feedback immediately and can adjust its approach. Instead of hanging on a 50GB file read, it learns that there’s a size limit and can ask for specific line ranges.
Notice also the enrichment part: by calculating line counts and detecting encoding, you’re giving Claude information it might find useful for its next step. If Claude is about to read a 50,000-line file, knowing that helps it decide whether to ask for specific line ranges. This is subtle but powerful—you’re using the hook not just to enforce constraints, but to provide better information.
Pattern 5: API Key and Credential Injection
You might want to inject credentials from environment variables without letting Claude see or handle them directly. This is a security pattern: by keeping API keys in environment variables and injecting them server-side, you never expose credentials in Claude’s context window.
Think about what happens if Claude handles credentials directly: it sees them in logs, includes them in messages, might inadvertently echo them in responses. By using the hook to inject them automatically, credentials never touch Claude’s input/output layer.
This pattern is also about routing: you can automatically detect which API Claude is calling and inject the appropriate credential. Claude doesn’t need to know about credential management—it just makes API calls, and the hook handles authentication transparently.
// hooks/inject-credentials.mjs
export const handler = async (context) => {
const { toolName, toolInput } = context;
if (toolName === "http_request") {
const { url, headers = {} } = toolInput;
// Determine which API this call is targeting
let apiKey = null;
let tokenType = "Bearer";
if (url.includes("api.openai.com")) {
apiKey = process.env.OPENAI_API_KEY;
} else if (url.includes("api.anthropic.com")) {
apiKey = process.env.ANTHROPIC_API_KEY;
} else if (url.includes("stripe.com")) {
apiKey = process.env.STRIPE_API_KEY;
tokenType = "Bearer"; // Stripe uses Bearer tokens
} else if (url.includes("github.com/api")) {
apiKey = process.env.GITHUB_TOKEN;
tokenType = "Bearer";
}
// Only inject if we have a credential and it's not already set
if (apiKey && !headers.Authorization) {
return {
...toolInput,
headers: {
...headers,
Authorization: `${tokenType} ${apiKey}`,
},
};
}
// If credential was explicitly provided, don't override it
return toolInput;
}
if (toolName === "database_query") {
const { connectionString } = toolInput;
// If Claude is trying to specify a connection string, block it
if (connectionString && !connectionString.includes("${DB_URL}")) {
throw new Error(
"Direct database connections not allowed. " +
"Use connection pooling or the default DB_URL.",
);
}
// Inject the default connection string
return {
...toolInput,
connectionString: process.env.DATABASE_URL,
};
}
return toolInput;
};
This pattern:
- Keeps credentials out of Claude’s view (they’re injected server-side, never in context)
- Routes requests to the right credential based on API domain or URL pattern
- Prevents Claude from hardcoding or altering database connection strings
- Allows overriding for specific use cases while maintaining security
- Generates unique request IDs for tracing and debugging across API calls
This is much safer than asking Claude to handle credentials directly. Consider a real scenario: you’re asking Claude to integrate with Stripe API. You could give Claude the Stripe API key directly. But now that key is in the conversation history, might appear in logs, could be leaked if the session is compromised. You’ve exposed your secret.
Instead, you have Claude say “call Stripe API with [operation]” and your hook intercepts that, sees it’s a Stripe URL, and automatically injects the correct credential. Claude never knows the actual key. Stripe gets authenticated. Security is maintained. This is defense in depth.
The routing aspect is also important: you might have multiple API keys for multiple services. Your hook can intelligently choose the right one based on the URL being called. This reduces cognitive load on Claude—it doesn’t need to remember which API uses which credential.
Pattern 6: Input Transformation for Compatibility
Sometimes you need to transform inputs from one format to another. For example, you might want Claude to think in high-level API terms (“deploy to production”), but execute low-level commands (Kubernetes manifests, registry URLs). This abstraction layer protects both Claude and your infrastructure.
The benefit is cognitive simplification for Claude. Instead of Claude needing to understand Kubernetes YAML, registry URLs, approval workflows, and timeout requirements, it can think in simple business terms: “deploy to staging” or “deploy to production.” Your hook handles the translation to actual infrastructure operations.
This is also an abstraction barrier: if you change your deployment infrastructure from Kubernetes to another system, you only update the hook. Claude’s code and prompts don’t change.
// hooks/transform-api-calls.mjs
export const handler = async (context) => {
const { toolName, toolInput } = context;
// Claude might ask to "deploy to staging"
// We transform that into actual platform-specific commands
if (toolName === "run_deployment") {
const { environment, version } = toolInput;
let actualCommand = "";
let registry = "";
switch (environment) {
case "staging":
actualCommand = "kubectl apply -f manifests/staging/";
registry = "staging-registry.mycompany.com";
break;
case "production":
actualCommand = "kubectl apply -f manifests/prod/";
registry = "prod-registry.mycompany.com";
break;
default:
throw new Error(`Unknown environment: "${environment}"`);
}
return {
...toolInput,
command: actualCommand,
imageRegistry: registry,
timeout: 300000, // 5 minutes for deployments
requiresApproval: environment === "production",
};
}
// Transform between API versions
if (toolName === "call_api") {
const { endpoint, method, version = "v1" } = toolInput;
// Always use v2 internally, even if Claude requests v1
const apiVersion = version === "v1" ? "v2" : version;
return {
...toolInput,
endpoint: endpoint.replace(/\/v\d+\//, `/${apiVersion}/`),
deprecationNotice:
version === "v1" ? "v1 API is deprecated, using v2 instead" : undefined,
};
}
return toolInput;
};
This pattern:
- Translates high-level concepts (environment names) to low-level commands (kubectl manifests)
- Enforces internal standards (always use latest API version, never deprecated APIs)
- Adds metadata (approval requirements, timeouts, registry URLs) based on context
- Simplifies Claude’s mental model while enforcing your actual constraints
- Prevents Claude from mixing up environments or using wrong configurations
The transformation can be smart: maybe “staging” environment needs 5-minute timeouts for deployment, while “production” needs 10 minutes and approval. Your hook can encode that logic once, and Claude doesn’t need to remember it for every operation.
Pattern 7: Conditional Modification Based on Context
You might modify inputs differently depending on the broader context (user, time of day, environment, session properties). This is where hooks become truly intelligent—different rules for different situations.
For example, you might allow risky operations in development (with verbose logging) but require approval in production. Or you might allow some users to modify critical files but block others. Or you might apply different timeout rules during business hours vs. overnight runs.
This is context-aware security: the same operation gets different treatment based on when and how it’s being requested.
// hooks/context-aware-modification.mjs
export const handler = async (context) => {
const { toolName, toolInput, userId, timestamp, environment } = context;
if (toolName === "execute_command") {
const { command } = toolInput;
// In development, run commands with verbose logging
if (environment === "development") {
return {
...toolInput,
command: `set -x && ${command}`,
captureOutput: true,
logLevel: "debug",
};
}
// In production, be more conservative
if (environment === "production") {
return {
...toolInput,
command,
dryRun: !command.includes("--force"), // Dry-run by default unless forced
requiresApproval: true,
timeout: 60000,
};
}
}
if (toolName === "modify_file") {
// Only certain users can modify critical files
const criticalFiles = [
"package.json",
"docker-compose.yml",
".env.production",
];
const isCritical = criticalFiles.some((cf) => toolInput.path.includes(cf));
if (isCritical && !isAdminUser(userId)) {
throw new Error(
`User "${userId}" does not have permission to modify "${toolInput.path}"`,
);
}
// Admin users get stricter change tracking
if (isAdminUser(userId)) {
return {
...toolInput,
trackChanges: true,
requiresSignature: true, // Digitally sign the change
notifyTeam: true,
};
}
}
return toolInput;
};
function isAdminUser(userId) {
return ["alice", "bob"].includes(userId);
}
This pattern:
- Applies different rules in dev vs. production environments
- Enforces role-based access control (who can modify what based on user ID)
- Requires approval for risky operations (high-risk files, production changes)
- Automatically enables audit trails for high-risk changes (digital signatures, team notifications)
- Adjusts verbosity and logging based on environment (verbose in dev, minimal in prod)
The interesting part is that none of this logic lives in Claude. Claude doesn’t know about user roles, environments, or approval workflows. Your hook handles all of it transparently. Claude just makes requests, and the hook decides whether to allow, modify, require approval, or enable logging.
Pattern 8: Logging and Monitoring Input Modifications
For audit trails and debugging, log what you’re changing. This creates an immutable record of what inputs Claude requested and what you actually executed. This is compliance, debugging, and security analysis all in one.
Why log modifications? Several reasons:
- Compliance: regulations like SOC 2 and HIPAA require audit trails for what changes were made and by whom
- Debugging: when something goes wrong, you can trace whether the hook modified the input in an unexpected way
- Security analysis: patterns in modifications might reveal attacks or misuse
- Accountability: users can see exactly what happened when they ran a command
// hooks/log-input-modifications.mjs
export const handler = async (context) => {
const { toolName, toolInput, userId, timestamp } = context;
let modifiedInput = toolInput;
let wasModified = false;
// Apply various modifications...
if (toolName === "write_file") {
const originalPath = toolInput.path;
modifiedInput = {
...toolInput,
path: "/safe/project/path/file.txt",
};
wasModified = originalPath !== modifiedInput.path;
}
// Log all modifications
if (wasModified) {
logModification({
timestamp,
userId,
toolName,
originalInput: toolInput,
modifiedInput,
reason: "Path enforcement",
});
}
return modifiedInput;
};
async function logModification(entry) {
const logEntry = {
...entry,
timestamp: new Date().toISOString(),
};
// Write to audit log (JSON lines format)
console.log(JSON.stringify(logEntry));
// Could also send to external logging service
// await sendToLoggingService(logEntry);
}
This pattern:
- Records every modification with complete context (who, when, what, why)
- Enables debugging when things go wrong (trace the chain of modifications)
- Provides audit trails for compliance (immutable records of what changed)
- Helps you understand patterns in how Claude uses tools (are certain operations frequent? risky?)
- Uses JSON lines format (one JSON object per line) for easy parsing and streaming
Notice that we log both the original input and the modified input. This lets you see the delta—what changed, not just what was executed. This is crucial for debugging: if Claude’s operation fails, you can check the logs to see if a modification caused the problem.
The Tradeoffs: Modification vs. Permission Gates
Here’s something important: you could solve many of these problems with permission gates (blocking execution entirely) instead of modifying inputs. When do you modify vs. block?
Modify when:
- You’re improving safety without changing intent (sanitizing
-fto-i) - You’re normalizing paths to canonical forms
- You’re injecting credentials or defaults
- The user’s intent is still achievable, just safer
Block when:
- The operation is genuinely dangerous and can’t be safely rewritten
- The user is trying to violate access control (read
/etc/shadow) - The operation would corrupt critical files
- No safe alternative exists
The pattern: try to be helpful and enable safe behavior. Only block when modification can’t make it safe.
Real-World Recipe: Comprehensive File Safety
Here’s a complete example combining multiple patterns:
// hooks/production-file-safety.mjs
const SAFE_ROOTS = ["/projects/webapp", "/projects/api"];
const BLOCKED_EXTENSIONS = [".exe", ".sh", ".bat", ".ps1"];
const DANGEROUS_FILENAMES = [".env.production", "secrets.json", "password.txt"];
export const handler = async (context) => {
const { toolName, toolInput } = context;
const fileTools = ["read_file", "write_file", "delete_file"];
if (!fileTools.includes(toolName)) {
return toolInput;
}
const filePath = toolInput.path;
const resolvedPath = path.resolve(filePath);
// Block access outside safe roots
const isInSafeRoot = SAFE_ROOTS.some((root) =>
resolvedPath.startsWith(path.resolve(root)),
);
if (!isInSafeRoot) {
throw new Error(
`Access denied: "${filePath}" is outside permitted directories`,
);
}
// Block dangerous extensions
const ext = path.extname(resolvedPath);
if (BLOCKED_EXTENSIONS.includes(ext)) {
throw new Error(`File type blocked: ${ext} files cannot be modified`);
}
// Block dangerous filenames
const filename = path.basename(resolvedPath);
if (DANGEROUS_FILENAMES.includes(filename)) {
throw new Error(`Access denied: "${filename}" is a protected file`);
}
// For write operations, require explicit backup
if (toolName === "write_file" && !toolInput.backup) {
console.warn(`Warning: write to "${filename}" without backup enabled`);
}
return {
...toolInput,
path: resolvedPath,
};
};
This combines multiple safety layers: boundary enforcement, blocked extensions, protected filenames, and warnings for risky writes. It’s defense in depth.
Understanding the Operational Impact
When you implement input modification hooks, you’re shifting risk from runtime to design time. Instead of hoping Claude makes safe decisions, you’re encoding your safety policies into the execution layer. This has profound operational benefits that go beyond just security.
Reducing Incident Response Time: When something goes wrong (a file gets deleted that shouldn’t have, a command runs in the wrong environment), input modification hooks create an audit trail. You know exactly what was attempted and what was actually executed. You can trace the delta. In a traditional system, you might spend hours investigating. With proper logging from your hooks, you have the answer in minutes.
Enabling Delegation: Input modification hooks let you delegate automation to people without deep infrastructure knowledge. A product manager can ask Claude to “implement this feature” and your hooks ensure the implementation respects directory boundaries, doesn’t touch protected files, and logs everything. The person delegating doesn’t need to trust their own technical judgment—they trust the hooks.
Scaling Safety: As your organization grows and more people are using Claude Code, enforcing constraints at the hook level means you don’t need to train everyone on best practices. The system enforces them automatically. This is the difference between security through culture and security through code.
Real-World Example: Protecting Production Deployments
Let’s trace a real scenario where input modification hooks save you from disaster.
Your startup has a deployment system. Claude Code can trigger deployments using a tool called deploy_service. A naive implementation might allow Claude to deploy any service version to any environment, with any configuration. Now consider what happens when you’re debugging an issue at 2 AM and you ask Claude “deploy the latest version of the payment service.”
Without hooks:
- Claude might misunderstand and deploy to production instead of staging
- Claude might use the wrong API credentials (e.g., test credentials in production)
- Claude might deploy a version that hasn’t been tested yet
With input modification hooks:
export const handler = async (context) => {
const { toolName, toolInput, environment } = context;
if (toolName === "deploy_service") {
const { serviceName, version, target } = toolInput;
// Force staging deployments during non-business hours
const hour = new Date().getHours();
const isBusinessHours = hour >= 9 && hour < 17;
if (!isBusinessHours && target === "production") {
throw new Error(
`Production deployments not allowed outside business hours (9am-5pm). ` +
`Deploying to staging instead. Review and approve in morning.`,
);
}
// Validate the version exists and has been tested
const testResults = await getTestResults(serviceName, version);
if (!testResults.passed) {
throw new Error(
`Version ${version} has not passed all tests. ` +
`Test results: ${JSON.stringify(testResults)}`,
);
}
// Inject the right credentials for the target environment
const credentials = getCredentialsForEnvironment(target);
return {
...toolInput,
credentials,
requiresApproval: target === "production",
notifyOnCompletion: target === "production",
};
}
return toolInput;
};
In this scenario, your hook is smart enough to:
- Prevent dangerous deployments outside business hours
- Validate that the version has actually been tested
- Automatically inject the right credentials (removing the chance of wrong keys)
- Require approval for production deployments
Now, even if Claude misunderstands or makes a mistake, your hooks catch it. The system is resilient to human error—both Claude’s errors and the human asking Claude to do something.
Advanced Pattern: Rate Limiting at the Hook Level
Sometimes you want to control not just what Claude does, but how often it does it. This is especially useful when Claude operations are expensive or when they hit external APIs with rate limits.
// hooks/rate-limiting.mjs
const requestCounts = new Map(); // In production, use Redis
export const handler = async (context) => {
const { toolName, userId, timestamp } = context;
// Track requests per user per tool
const key = `${userId}:${toolName}`;
const now = Math.floor(timestamp / 1000 / 60); // Current minute
const windowKey = `${key}:${now}`;
if (!requestCounts.has(windowKey)) {
requestCounts.set(windowKey, 0);
// Expire the key after 2 minutes
setTimeout(() => requestCounts.delete(windowKey), 120000);
}
const count = requestCounts.get(windowKey);
// Different tools have different limits
const limits = {
execute_command: 10, // Max 10 commands per minute
write_file: 20, // Max 20 file writes per minute
http_request: 30, // Max 30 API calls per minute
read_file: 100, // Reads are cheap, allow more
};
const limit = limits[toolName] || 5; // Default conservative limit
if (count >= limit) {
throw new Error(
`Rate limit exceeded for ${toolName}. ` +
`Limit: ${limit} per minute. ` +
`Try again in ${60 - (timestamp % 60)} seconds.`,
);
}
requestCounts.set(windowKey, count + 1);
return context.toolInput;
};
This pattern prevents runaway loops. If Claude gets stuck in a retry loop, the rate limiter will catch it and force a pause. In production, you’d use Redis to share state across multiple Claude Code instances.
Hook Execution Order and Dependencies
A subtle but important detail: if you have multiple hooks, they execute in order. The output of one becomes the input to the next. This creates powerful composition opportunities but also potential for unexpected interactions.
// hooks/1-validate-input.mjs
export const handler = async (context) => {
// Validates and normalizes input
// Throws if invalid
return normalizeInput(context.toolInput);
};
// hooks/2-enforce-boundaries.mjs
export const handler = async (context) => {
// Receives the output from hook 1
// Further restricts the input
return enforceBoundaries(context.toolInput);
};
// hooks/3-audit-log.mjs
export const handler = async (context) => {
// Receives the output from hook 2
// Logs what's about to be executed
// Doesn't modify input, just observes
logAudit(context.toolInput);
return context.toolInput;
};
The key principle: each hook should be composable. Don’t assume state from previous hooks; validate and transform the input you receive. This makes your hooks robust to reordering and makes it easier to add new hooks without breaking existing ones.
Common Mistakes When Implementing Hooks
Mistake 1: Silent Failures
// BAD: Silently rewrites invalid input instead of rejecting it
if (!isValidPath(toolInput.path)) {
// Just return a default path instead of erroring
return { ...toolInput, path: "/safe/default/path" };
}
This creates confusion. Claude doesn’t know its operation was redirected. It might proceed under false assumptions.
// GOOD: Reject and tell Claude what's wrong
if (!isValidPath(toolInput.path)) {
throw new Error(
`Invalid path: "${toolInput.path}". ` +
`Must be within project directories: ${SAFE_ROOTS.join(", ")}`,
);
}
Mistake 2: Modifying Without Logging
If you’re modifying inputs, always log it. Future you needs to debug why something happened:
// Log modifications
if (wasModified) {
console.log(
JSON.stringify({
timestamp: new Date().toISOString(),
toolName,
userId,
originalInput: toolInput,
modifiedInput,
reason,
}),
);
}
Mistake 3: Complex Interdependencies
// BAD: Hook relies on specific state from previous hook
if (context.toolInput.enforced === true) {
// Assumes previous hook set this flag
}
Hooks should be independent. Each should validate and transform based on its own rules, not trust state from previous hooks.
Wrapping Up
Input modification is where hooks move from “nice to have” to “essential for serious use.” You’re not just watching what Claude does—you’re actively reshaping it. Enforcing boundaries. Sanitizing dangerous inputs. Injecting credentials safely. Making the system do what you actually want, not just what was asked.
The key patterns:
- Path rewriting to enforce directory boundaries
- Command sanitization to prevent dangerous operations
- Default injection to provide safety rails
- Input validation to fail fast before execution
- Credential injection to keep secrets secure
- Format transformation to abstract complexity
- Context-aware rules to apply different policies in different environments
- Audit logging to track what changed and why
Beyond technical safety, input modification hooks enable organizational scaling. They let you delegate automation safely to people who don’t need to understand every infrastructure detail. They create audit trails that turn incident investigation from an archaeological dig into a straightforward review. They shift failures from runtime to design time.
The hooks in your .claude/hooks/ directory are where safety, security, and automation converge. Use them wisely. They’re powerful.
-iNet