You’re watching Claude run a command in your terminal. It’s about to delete a directory. But what if you could freeze that moment—before the tool executes—and say “wait, let me check that path first”? That’s exactly what PreToolUse hooks do.
In this article, we’re diving deep into one of Claude Code’s most powerful safety and control features: PreToolUse hooks. We’ll explore when they fire, what information they give you, how to make decisions about tool execution, and—most importantly—how to modify tool inputs on the fly before Claude’s actions hit your system.
Whether you’re building safeguards for enterprise workflows, sanitizing user inputs, preventing accidental data loss, or implementing organizational policies, PreToolUse hooks are your first line of defense. This is where you say “Claude, I trust you—but not quite that much.”
What Are PreToolUse Hooks?
A PreToolUse hook is an event that fires before any tool execution in Claude Code. Think of it as a checkpoint inspector—it sees every tool Claude wants to use, the inputs Claude is about to send, and gives you three options:
- Allow it: Let the tool run as-is
- Deny it: Block the tool completely
- Modify it: Change the tool inputs and then run it
The timing is critical. This happens after Claude has decided to use a tool but before that tool touches your system, files, or network. You’re operating in the gap between decision and execution—the most important gap for safety.
When Does PreToolUse Fire?
PreToolUse fires for every tool Claude Code wants to execute:
- File operations (Read, Write, Edit, Delete)
- Shell commands (Bash, PowerShell)
- Git operations
- Network requests
- Custom tools in your
.claude/tools/directory - Browser automation tools
- System utilities
- Any MCP server tools
It does NOT fire for:
- Decisions to generate text (no tool needed)
- Internal reasoning or planning
- Calls to the Claude API that don’t use tools
Why This Matters
Most “AI safety” discussions happen at the model level. Can we make Claude think more carefully? Yes, but that’s slow and unreliable. PreToolUse hooks operate at the execution level—they’re your final checkpoint before anything actually happens. Even if Claude has a reasoning error, a bug in its decision-making, or a misunderstanding of what you asked, PreToolUse catches it before consequences unfold.
This is defense in depth. You don’t trust Claude completely (no one should), and you don’t trust users completely either. PreToolUse lets you enforce both at once.
The Real Power: Preventing Cascade Failures
Consider a practical scenario: Claude is refactoring your authentication system. It renames a function from validateToken to validateUserToken. But there are five call sites. Claude updates three of them in one file and misses the other two in a different file. Without PreToolUse, those missed call sites break silently. Tests might catch it, or they might not if coverage is incomplete. The code goes to review. The reviewer is tired. It gets merged. A day later, customers report login failures.
With PreToolUse, you can detect problematic patterns before they execute. You could have a hook that says: “Before you commit code that touches authentication functions, let me verify that all the call sites are updated.” This is meta-safety—you’re not just checking what Claude is doing, you’re checking whether Claude understands the scope of its changes.
The psychological aspect matters too. When you know PreToolUse is there, watching, you’re more confident letting Claude Code run autonomously. You’re not staring at your screen, nervous. You’re sleeping well because you know that if Claude makes a mistake, the hook catches it. This confidence translates to velocity. You use Claude Code more aggressively, you trust it more, and paradoxically, you’re safer because you’re not trying to micromanage every decision.
How PreToolUse Fits Into Your Workflow
Most developers using Claude Code fall into one of three categories. The careful ones validate every output before running. They use PreToolUse hooks to enforce guardrails, but they’re also eyeballing the commands. The balanced ones run things most of the time, but use hooks to block known-dangerous patterns. The aggressive ones let Claude Code run mostly unsupervised, trusting that hooks will catch problems. Each strategy is valid depending on your risk tolerance and how well you’ve tuned your hooks.
The key is that PreToolUse is transparent to Claude. Claude doesn’t see your hooks. It just makes tool calls, and sometimes they’re approved, sometimes they’re denied, sometimes they’re modified. Claude adapts to the feedback and tries different approaches. Over a session, Claude learns what works in your environment—not explicitly, but through trial and error. This learning is valuable. After three denials of risky approaches, Claude might propose safer alternatives.
Real-world teams use PreToolUse for: (1) protecting critical infrastructure (never allow production deploys without approval), (2) preventing credentials leaks (catch hardcoded API keys), (3) enforcing standards (require specific commit message formats), and (4) audit trails (log everything for compliance). Most mature deployments use multiple hooks composed together, each handling one concern.
The Hook Contract: Parameters and Return Values
When your PreToolUse hook runs, it receives two pieces of information:
// Hook signature
async function preToolUse(toolName, toolInput) {
// toolName: string - name of the tool (e.g., "bash", "write_file")
// toolInput: object - the parameters Claude is sending to the tool
// You return one of:
// { action: "allow" } - run the tool as-is
// { action: "deny", reason: "string" } - block execution
// { action: "modify", input: modifiedInput } - change inputs and run
}
Let’s look at each component in detail.
The toolName Parameter
This is the identifier of the tool Claude wants to use. Common examples:
bash # Shell command execution
write_file # Create/overwrite a file
read_file # Read file contents
edit_file # Edit existing file
delete_file # Delete a file
git # Git operations
navigate # Browser navigation
mcp__plugin_playwright__browser_click # Browser automation (MCP tools)
Tool names follow a pattern. MCP-based tools have prefixes like mcp__plugin_playwright__. First-party Claude Code tools are simple (bash, write_file). Knowing the exact name is important for your hook logic.
The toolInput Parameter
This is an object containing all the parameters Claude is sending to the tool. The structure varies by tool:
// For bash tool
{
command: "rm -rf /important/data"
}
// For write_file tool
{
file_path: "/home/user/myfile.txt",
content: "file contents here"
}
// For edit_file tool
{
file_path: "/path/to/file",
old_string: "original text",
new_string: "replacement text"
}
// For read_file tool
{
file_path: "/path/to/file"
}
// For browser_click tool (MCP)
{
ref: "ref_1",
coordinate: [100, 200],
button: "left"
}
The key insight: by the time PreToolUse fires, you have the complete set of parameters. Nothing is hidden. You can inspect, validate, and modify at a granular level.
Core Pattern 1: Simple Allow/Deny Decisions
The simplest PreToolUse hooks make binary decisions: allow or deny. No modifications, just a gate.
Here’s a hook that blocks any rm commands:
// ~/.claude/hooks/pretooluse.mjs
export async function preToolUse(toolName, toolInput) {
if (toolName === "bash") {
// Check if it's a dangerous command
if (toolInput.command && toolInput.command.includes("rm -rf")) {
return {
action: "deny",
reason:
"Destructive rm -rf commands are blocked. Use git rm or manual deletion instead.",
};
}
}
return { action: "allow" };
}
When Claude tries to execute rm -rf /tmp/build, your hook catches it:
❌ Tool execution blocked: Destructive rm -rf commands are blocked. Use git rm or manual deletion instead.
Claude sees the denial and can adjust its approach, ask you for clarification, or use an alternative method. The reason is important—it tells Claude why it was blocked, which helps it reason about alternatives.
Why “rm -rf” Specifically?
Because that’s the nuclear option. rm -rf recursively deletes directories, and when combined with wildcards or path mistakes, it’s genuinely dangerous. You could lose your entire project. Other forms of rm are usually fine—rm somefile.txt is safe. The -rf flag is the red flag.
Core Pattern 2: Contextual Allow/Deny
Real-world safety requires more nuance. You might allow some deletions but not others:
// ~/.claude/hooks/pretooluse.mjs
export async function preToolUse(toolName, toolInput) {
if (toolName === "bash") {
const cmd = toolInput.command || "";
// Allow: rm for files in /tmp or /cache
if (cmd.match(/rm\s+(-r\s+)?\/tmp\//)) {
return { action: "allow" };
}
if (cmd.match(/rm\s+(-r\s+)?\/cache\//)) {
return { action: "allow" };
}
// Block: rm for anything in /home or /var
if (cmd.match(/rm.*\/home\//) || cmd.match(/rm.*\/var\//)) {
return {
action: "deny",
reason: "Cannot delete files in /home or /var. Too risky.",
};
}
}
return { action: "allow" };
}
Now Claude can safely delete build artifacts in /tmp but can’t touch user home directories. This is contextual safety—the tool isn’t blocked entirely, just in risky contexts. You’re saying “deletions are okay, but not those deletions.”
Why This Matters
Without context, you either allow everything (unsafe) or block everything (useless). Context lets you be permissive in safe scenarios and strict in dangerous ones. Claude gets to work efficiently, and you sleep better at night.
Core Pattern 3: Path Enforcement for File Operations
File operations are among the most important to guard. You might want to enforce that Claude only writes to specific directories:
// ~/.claude/hooks/pretooluse.mjs
const ALLOWED_DIRS = ["/home/user/projects", "/home/user/documents", "/tmp"];
const PROTECTED_DIRS = ["/usr", "/bin", "/etc", "/sys", "/proc"];
export async function preToolUse(toolName, toolInput) {
const path = toolInput.file_path;
if (!path) {
return { action: "allow" };
}
// Block writes to protected system directories
for (const protected of PROTECTED_DIRS) {
if (path.startsWith(protected)) {
return {
action: "deny",
reason: `Cannot write to protected directory: ${protected}`,
};
}
}
// For these tools, enforce whitelist
if (["write_file", "edit_file"].includes(toolName)) {
const isAllowed = ALLOWED_DIRS.some((dir) => path.startsWith(dir));
if (!isAllowed) {
return {
action: "deny",
reason: `File operations only allowed in: ${ALLOWED_DIRS.join(", ")}`,
};
}
}
return { action: "allow" };
}
Now Claude can work freely in your project directories but can’t accidentally modify system files. This is especially valuable in Windows environments where your Documents and Projects folders contain everything important, and C:\Windows\System32 contains everything critical.
Path Handling Gotchas
Paths can be tricky. Relative paths (./file.txt), symlinks, and Windows vs Unix formats all complicate things. A more robust version might normalize paths:
const ALLOWED_DIRS = [
path.resolve("/home/user/projects"),
path.resolve("/home/user/documents"),
];
export async function preToolUse(toolName, toolInput) {
if (toolName === "write_file") {
const normalized = path.resolve(toolInput.file_path);
const isAllowed = ALLOWED_DIRS.some(
(dir) => normalized === dir || normalized.startsWith(dir + path.sep),
);
if (!isAllowed) {
return { action: "deny", reason: "Path not in allowed directories" };
}
}
return { action: "allow" };
}
This handles relative paths, symlinks, and prevents sneaky attacks like /home/user/projects/../../../etc/passwd.
Core Pattern 4: Input Modification and Sanitization
Here’s where PreToolUse becomes truly powerful: you can modify tool inputs before execution.
Suppose Claude is about to write a file with hardcoded credentials. You can intercept that and sanitize it:
// ~/.claude/hooks/pretooluse.mjs
export async function preToolUse(toolName, toolInput) {
if (toolName === "write_file") {
let content = toolInput.content;
// Detect and remove hardcoded AWS keys
content = content.replace(/AKIA[0-9A-Z]{16}/g, "**REDACTED_AWS_KEY**");
// Detect and remove hardcoded API tokens
content = content.replace(/sk-[a-zA-Z0-9]{48,}/g, "**REDACTED_API_TOKEN**");
// Detect and remove passwords in connection strings
content = content.replace(/password=([^\s;]+)/gi, "password=**REDACTED**");
if (content !== toolInput.content) {
console.warn("⚠️ Credentials detected and redacted in file content");
return {
action: "modify",
input: {
...toolInput,
content: content,
},
};
}
}
return { action: "allow" };
}
When Claude tries to create a config file with secrets, your hook automatically strips them out. Claude still creates the file, but safely—without credentials that could leak.
This is especially valuable for Windows developers who often work with .env files, connection strings, and configuration files that contain sensitive data. You’re providing a safety net without slowing Claude down.
Regex Patterns for Common Secrets
Some patterns to watch for:
// AWS Access Keys
/AKIA[0-9A-Z]{16}/
// OpenAI API Keys
/sk-[a-zA-Z0-9]{48,}/
// GitHub tokens
/ghp_[a-zA-Z0-9]{36}/
// Database passwords (rough)
/(password|passwd)=[^\s;]*/gi
// Connection strings (Azure)
/DefaultEndpointsProtocol=https;AccountName=\w+;/
// Private keys (rough, many false positives)
/-----BEGIN [A-Z]+ PRIVATE KEY-----/
Be careful with these patterns—they can have false positives. A variable named password_length shouldn’t be redacted.
Core Pattern 5: Command-Level Filtering
Not all bash commands are created equal. You might want to allow most commands but block specific dangerous patterns:
// ~/.claude/hooks/pretooluse.mjs
const DANGEROUS_PATTERNS = [
/rm\s+-rf\s+\//, // rm -rf /
/mkfs/, // Format filesystem
/dd\s+if=\/dev/, // Direct disk writes
/sudo\s+shutdown/, // Shutdown commands
/docker\s+rm\s+-f\s+\//, // Force docker cleanup
/(wget|curl)\s+.*\|\s*bash/, // Pipe to bash
];
export async function preToolUse(toolName, toolInput) {
if (toolName === "bash") {
const cmd = toolInput.command;
for (const pattern of DANGEROUS_PATTERNS) {
if (pattern.test(cmd)) {
return {
action: "deny",
reason: `Command matches dangerous pattern: ${pattern}`,
};
}
}
}
return { action: "allow" };
}
This hook is aggressive—it blocks patterns that are almost never safe. You can make it more permissive based on your specific risk tolerance. Some teams might allow curl | bash in controlled environments; most shouldn’t.
Why “Pipe to Bash” Is Dangerous
curl https://example.com | bash downloads and immediately executes a script. If the server is compromised, you’ve just handed over your system. There’s no chance to review what’s running. This is especially dangerous in production contexts where you might have access to secrets.
Core Pattern 6: Git Operation Safety
Git operations can be destructive too. You might want to ensure Claude never force-pushes or rewrites history without explicit approval:
// ~/.claude/hooks/pretooluse.mjs
export async function preToolUse(toolName, toolInput) {
if (toolName === "bash" && toolInput.command) {
const cmd = toolInput.command;
// Block force push
if (cmd.includes("git push --force")) {
return {
action: "deny",
reason:
"Force push blocked. Use explicit approval: git push --force-with-lease",
};
}
// Block hard resets on main/master
if (cmd.match(/git\s+reset\s+--hard/) && cmd.match(/main|master/)) {
return {
action: "deny",
reason:
"Hard reset on main/master blocked. Cherry-pick or create a new branch instead.",
};
}
// Block history rewrites
if (cmd.includes("git rebase -i") && cmd.includes("force")) {
return {
action: "deny",
reason: "Interactive rebase with force blocked on shared branches.",
};
}
}
return { action: "allow" };
}
These guards prevent the common disasters: accidentally pushing to main, rewriting shared history, or losing commits in a hard reset. In a team environment, this becomes even more critical.
Core Pattern 7: Intelligent Input Modification
Beyond sanitization, you can actually improve Claude’s inputs. For example, automatically adding safety flags or better arguments:
// ~/.claude/hooks/pretooluse.mjs
export async function preToolUse(toolName, toolInput) {
if (toolName === "bash") {
let cmd = toolInput.command;
// If Claude runs a find command without limiting depth, add safety
if (cmd.includes("find ") && !cmd.includes("-maxdepth")) {
cmd = cmd.replace("find ", "find -maxdepth 3 ");
return {
action: "modify",
input: {
...toolInput,
command: cmd,
},
};
}
// If Claude uses cp without -i, add interactive mode
if (cmd.match(/\bcp\s/) && !cmd.includes("-i")) {
cmd = cmd.replace(/\bcp\s/, "cp -i ");
return {
action: "modify",
input: {
...toolInput,
command: cmd,
},
};
}
// If Claude uses rm without a limit, add interactive mode
if (
cmd.match(/\brm\s/) &&
!cmd.includes("-i") &&
!cmd.includes("--force")
) {
cmd = cmd.replace(/\brm\s/, "rm -i ");
return {
action: "modify",
input: {
...toolInput,
command: cmd,
},
};
}
}
return { action: "allow" };
}
This hook makes Claude safer without blocking actions. When Claude tries to delete files with rm, your hook adds -i (interactive) mode. Claude still gets to delete, but has to confirm each deletion. You’re not being paranoid; you’re being smart.
These modifications happen silently. Claude doesn’t know you changed the command, but that’s okay—the outcome is what matters. rm -i is strictly safer than rm.
Core Pattern 8: Logging and Audit Trails
For compliance and debugging, you want to log what Claude is doing. PreToolUse hooks are the perfect place to create audit trails:
// ~/.claude/hooks/pretooluse.mjs
const AUDIT_LOG = "/home/user/.claude/audit.log";
function logToolExecution(decision, toolName, toolInput, metadata = {}) {
const timestamp = new Date().toISOString();
const entry = {
timestamp,
decision, // "allow", "deny", "modify"
toolName,
toolInput: sanitizeForLogging(toolInput),
metadata,
};
fs.appendFileSync(AUDIT_LOG, JSON.stringify(entry) + "\n");
}
function sanitizeForLogging(input) {
const sanitized = { ...input };
// Remove sensitive data before logging
if (sanitized.content) {
sanitized.content = sanitized.content.substring(0, 100) + "...";
}
if (sanitized.command) {
sanitized.command = sanitized.command.replace(
/password=[^\s]*/gi,
"password=***",
);
}
return sanitized;
}
export async function preToolUse(toolName, toolInput) {
// Evaluate the tool
let decision = "allow";
let modification = null;
if (toolName === "bash") {
const cmd = toolInput.command;
if (cmd.includes("rm -rf")) {
decision = "deny";
logToolExecution(decision, toolName, toolInput, {
reason: "Destructive command blocked",
});
return {
action: "deny",
reason: "Destructive rm -rf commands blocked",
};
}
}
// Log the allow
logToolExecution(decision, toolName, toolInput);
return { action: "allow" };
}
Now every tool execution is recorded. You can review your audit log:
{"timestamp":"2026-03-16T14:32:01.234Z","decision":"allow","toolName":"bash","toolInput":{"command":"git status"},"metadata":{}}
{"timestamp":"2026-03-16T14:33:15.567Z","decision":"deny","toolName":"bash","toolInput":{"command":"rm -rf /important"},"metadata":{"reason":"Destructive command blocked"}}
{"timestamp":"2026-03-16T14:34:42.891Z","decision":"allow","toolName":"write_file","toolInput":{"file_path":"/home/user/projects/app/config.json"},"metadata":{}}
Perfect for compliance reports, debugging unexpected behavior, or investigating security incidents.
Implementing Your PreToolUse Hook
To activate PreToolUse hooks in Claude Code, add them to your .claude/hooks/ directory:
mkdir -p ~/.claude/hooks
touch ~/.claude/hooks/pretooluse.mjs
Then edit your ~/.claude/claude.json configuration:
{
"hooks": {
"preToolUse": "./hooks/pretooluse.mjs"
}
}
Restart Claude Code and your hook is active. You should see an indication that the hook is loaded.
Hook Lifecycle and Error Handling
Your hook must complete within a reasonable timeout (typically 5 seconds). If your hook throws an error, Claude Code defaults to { action: "deny" } for safety:
// ~/.claude/hooks/pretooluse.mjs
export async function preToolUse(toolName, toolInput) {
try {
// Your logic here
return { action: "allow" };
} catch (error) {
// If your hook breaks, the tool is blocked
console.error("PreToolUse hook error:", error);
// Safe fallback
return {
action: "deny",
reason: "Hook error: " + error.message,
};
}
}
This fail-safe behavior means your safety guardrails never disappear due to a bug—they get more strict instead. If you have a syntax error or logic bug, every tool gets blocked until you fix it. It’s conservative, but safer than the alternative (silently allowing everything when your hook crashes).
Advanced Pattern 8: Network Request Validation
PreToolUse can also guard against unintended network calls. Claude might fetch URLs without realizing they’re external or risky:
// ~/.claude/hooks/pretooluse.mjs
const BLOCKED_DOMAINS = [
"localhost:3000",
"internal.company.com",
"10.0.0.0/8", // Private IP ranges
"172.16.0.0/12",
"192.168.0.0/16",
];
const ALLOWED_DOMAINS = [
"github.com",
"api.github.com",
"npmjs.org",
"registry.npmjs.org",
];
export async function preToolUse(toolName, toolInput) {
// Intercept fetch/WebFetch tools
if (toolName.includes("fetch") || toolName === "WebFetch") {
const url = toolInput.url || "";
// Block localhost
if (url.includes("localhost") || url.includes("127.0.0.1")) {
return {
action: "deny",
reason: "Network requests to localhost are blocked",
};
}
// Only allow whitelisted domains
const isAllowed = ALLOWED_DOMAINS.some((domain) => url.includes(domain));
if (!isAllowed && url.startsWith("http")) {
return {
action: "deny",
reason: `Network requests only allowed to: ${ALLOWED_DOMAINS.join(", ")}`,
};
}
}
return { action: "allow" };
}
This prevents Claude from making requests to internal services, staging environments, or accidentally exposing your development infrastructure.
Pattern 9: Preventing Information Leakage
Some tools might expose sensitive information you don’t want Claude to see. You can block read operations on protected files:
// ~/.claude/hooks/pretooluse.mjs
const PROTECTED_FILES = [
"~/.ssh/id_rsa", // SSH keys
"~/.aws/credentials", // AWS credentials
"~/.kube/config", // Kubernetes config
".env", // Environment variables
"secrets.json", // Secrets file
"config/database.yml", // Database credentials
];
export async function preToolUse(toolName, toolInput) {
if (toolName === "read_file") {
const filePath = toolInput.file_path;
const expandedPath = filePath.replace("~", process.env.HOME);
for (const protected of PROTECTED_FILES) {
const expandedProtected = protected.replace("~", process.env.HOME);
if (
expandedPath.endsWith(expandedProtected) ||
expandedPath.includes(expandedProtected)
) {
return {
action: "deny",
reason: `Cannot read protected file: ${protected}`,
};
}
}
}
return { action: "allow" };
}
This prevents Claude from accidentally reading your AWS credentials, SSH keys, or database passwords—even if you ask it to read “that file with the config.” It’s operating at the tool level, so Claude can’t circumvent it by reading .env.local or other variants.
Real-World Windows Scenario
Let’s build a complete, production-ready hook for Windows developers:
// ~/.claude/hooks/pretooluse.mjs
// Windows-friendly paths
const ALLOWED_BASE_DIRS = [
process.env.USERPROFILE + "\\Documents",
process.env.USERPROFILE + "\\Projects",
process.env.USERPROFILE + "\\Desktop",
process.env.TEMP,
];
const PROTECTED_PATHS = [
"C:\\Windows",
"C:\\Program Files",
process.env.SYSTEMROOT,
];
const DANGEROUS_COMMANDS = [
/taskkill\s+\/F/i,
/del\s+\/s\s+\/q/i,
/format\s+[A-Z]:/i,
/cipher\s+\/w/i,
];
export async function preToolUse(toolName, toolInput) {
// Pattern 1: Block destructive file operations
if (["write_file", "edit_file"].includes(toolName)) {
const filePath = toolInput.file_path;
// Normalize path to Windows format for comparison
const normalizedPath = filePath.replace(/\//g, "\\");
// Check protected directories
for (const protected of PROTECTED_PATHS) {
if (normalizedPath.startsWith(protected)) {
return {
action: "deny",
reason: `Cannot write to system directory: ${protected}`,
};
}
}
}
// Pattern 2: Sanitize .env file contents
if (toolName === "write_file" && toolInput.file_path?.endsWith(".env")) {
let content = toolInput.content;
const original = content;
// Remove hardcoded secrets
content = content.replace(
/DATABASE_PASSWORD=.*/g,
"DATABASE_PASSWORD=***REDACTED***",
);
content = content.replace(/API_KEY=.*/g, "API_KEY=***REDACTED***");
content = content.replace(
/AZURE_CONNECTION_STRING=.*/g,
"AZURE_CONNECTION_STRING=***REDACTED***",
);
if (content !== original) {
console.warn("⚠️ Secrets detected in .env file and redacted");
return {
action: "modify",
input: {
...toolInput,
content: content,
},
};
}
}
// Pattern 3: Block dangerous PowerShell commands
if (toolName === "bash" || toolName === "powershell") {
const cmd = toolInput.command || "";
for (const pattern of DANGEROUS_COMMANDS) {
if (pattern.test(cmd)) {
return {
action: "deny",
reason: `Dangerous command blocked: ${pattern.source}`,
};
}
}
}
// Pattern 4: Audit logging
if (["write_file", "delete_file", "bash"].includes(toolName)) {
const auditPath = path.join(process.env.USERPROFILE, ".claude-audit.log");
const timestamp = new Date().toISOString();
const entry = `${timestamp} | ${toolName} | ${JSON.stringify(toolInput).substring(0, 100)}\n`;
try {
fs.appendFileSync(auditPath, entry);
} catch (e) {
// Silently fail if we can't write audit log
}
}
return { action: "allow" };
}
This hook gives you:
- Path enforcement: Claude can only write to your Documents/Projects/Desktop
- Secret sanitization: Hardcoded credentials are automatically stripped from .env files
- Command blocking: Destructive PowerShell commands are rejected
- Audit trail: Every sensitive operation is logged
All on Windows, where file paths use backslashes and environment variables work differently.
Pattern 10: Team-Based Governance
For teams, PreToolUse hooks can enforce organizational policies:
// ~/.claude/hooks/pretooluse.mjs
const TEAM_CONFIG = {
allowedRepositories: ["company/", "client-", "internal-"],
forbiddenDependencies: ["vulnerable-package-v1", "deprecated-lib"],
requiredHeaders: {
".js": "// Copyright 2026 Company Name",
".py": "# Copyright 2026 Company Name",
},
};
export async function preToolUse(toolName, toolInput) {
// Pattern: Prevent pushing to non-team repos
if (toolName === "bash" && toolInput.command?.includes("git push")) {
const isTeamRepo = TEAM_CONFIG.allowedRepositories.some((prefix) =>
toolInput.command.includes(prefix),
);
if (!isTeamRepo) {
return {
action: "deny",
reason: "Can only push to team repositories",
};
}
}
// Pattern: Block forbidden dependencies
if (
toolName === "write_file" &&
(toolInput.file_path?.endsWith("package.json") ||
toolInput.file_path?.endsWith("requirements.txt"))
) {
let content = toolInput.content;
const original = content;
for (const forbidden of TEAM_CONFIG.forbiddenDependencies) {
content = content.replace(
new RegExp(forbidden, "g"),
`# ${forbidden} (BLOCKED)`,
);
}
if (content !== original) {
return {
action: "deny",
reason: "Cannot use forbidden dependencies. Contact security team.",
};
}
}
// Pattern: Auto-add required headers
if (toolName === "write_file") {
const ext = path.extname(toolInput.file_path);
const header = TEAM_CONFIG.requiredHeaders[ext];
if (header && !toolInput.content.startsWith(header)) {
return {
action: "modify",
input: {
...toolInput,
content: header + "\n\n" + toolInput.content,
},
};
}
}
return { action: "allow" };
}
This is perfect for teams where you need to enforce code standards, licensing requirements, and security policies automatically. Every file gets the copyright header. No one can push to unauthorized repos. Forbidden packages get blocked at source.
Pattern 11: Dynamic Rate Limiting and Quotas
You can use PreToolUse to implement quotas on how often Claude uses certain tools:
// ~/.claude/hooks/pretooluse.mjs
const QUOTA_STATE = "/tmp/claude-quotas.json";
function getQuotaState() {
try {
return JSON.parse(fs.readFileSync(QUOTA_STATE, "utf8"));
} catch {
return {
bash_count: 0,
write_count: 0,
reset_time: Date.now(),
};
}
}
function saveQuotaState(state) {
fs.writeFileSync(QUOTA_STATE, JSON.stringify(state, null, 2));
}
export async function preToolUse(toolName, toolInput) {
const now = Date.now();
const quotaState = getQuotaState();
// Reset quotas every hour
if (now - quotaState.reset_time > 3600000) {
quotaState.bash_count = 0;
quotaState.write_count = 0;
quotaState.reset_time = now;
}
// Limit bash commands to 20 per hour
if (toolName === "bash") {
if (quotaState.bash_count >= 20) {
return {
action: "deny",
reason:
"Bash command quota exceeded (20 per hour). Try again in a few minutes.",
};
}
quotaState.bash_count++;
}
// Limit file writes to 50 per hour
if (toolName === "write_file") {
if (quotaState.write_count >= 50) {
return {
action: "deny",
reason:
"File write quota exceeded (50 per hour). Batch your operations.",
};
}
quotaState.write_count++;
}
saveQuotaState(quotaState);
return { action: "allow" };
}
This prevents runaway Claude workflows where a bug causes thousands of operations. Smart rate limiting lets you work with confidence.
Pattern 12: Environment-Based Behavior
Different behavior in different environments—strict in production, permissive in development:
// ~/.claude/hooks/pretooluse.mjs
const ENV = process.env.NODE_ENV || "development";
export async function preToolUse(toolName, toolInput) {
// In production: strict safety
if (ENV === "production") {
if (toolName === "bash") {
const cmd = toolInput.command;
// No database operations in production
if (cmd.match(/\b(psql|mysql|mongosh)\b/)) {
return {
action: "deny",
reason: "Direct database commands not allowed in production",
};
}
// No file deletions
if (cmd.includes("rm ")) {
return {
action: "deny",
reason: "File deletions not allowed in production",
};
}
}
}
// In development: more permissive
if (ENV === "development") {
if (toolName === "bash") {
const cmd = toolInput.command;
// Add verbose flags for debugging
if (cmd.includes("npm ") && !cmd.includes("--verbose")) {
return {
action: "modify",
input: {
...toolInput,
command: cmd.replace("npm ", "npm --verbose "),
},
};
}
}
}
return { action: "allow" };
}
This ensures Claude behaves safely in production but stays efficient during development.
Testing Your Hooks: A Complete Example
Testing PreToolUse hooks requires creating a test harness:
// test-pretooluse.mjs
const tests = [
{
name: "Allow git status",
tool: "bash",
input: { command: "git status" },
expected: "allow",
},
{
name: "Block rm -rf /",
tool: "bash",
input: { command: "rm -rf /" },
expected: "deny",
},
{
name: "Sanitize .env secrets",
tool: "write_file",
input: {
file_path: ".env",
content: "API_KEY=sk-1234567890",
},
expected: "modify",
},
{
name: "Enforce path restrictions",
tool: "write_file",
input: {
file_path: "/tmp/test.txt",
},
expected: "allow",
},
{
name: "Block Windows system writes",
tool: "write_file",
input: {
file_path: "C:\\Windows\\System32\\config.txt",
},
expected: "deny",
},
];
async function runTests() {
let passed = 0;
let failed = 0;
for (const test of tests) {
try {
const result = await preToolUse(test.tool, test.input);
const actual = result.action;
if (actual === test.expected) {
console.log(`✅ ${test.name}`);
passed++;
} else {
console.log(
`❌ ${test.name}: expected ${test.expected}, got ${actual}`,
);
failed++;
}
} catch (error) {
console.log(`❌ ${test.name}: ${error.message}`);
failed++;
}
}
console.log(`\n${passed} passed, ${failed} failed`);
process.exit(failed > 0 ? 1 : 0);
}
runTests();
Run this with: node test-pretooluse.mjs
Common Pitfalls and How to Avoid Them
Pitfall 1: Over-Blocking Everything
Too strict and Claude can’t work. Too permissive and you lose safety.
// ❌ Bad: Blocks everything
export async function preToolUse(toolName, toolInput) {
return { action: "deny", reason: "All tools blocked" };
}
// ✅ Good: Strategic allow/deny
export async function preToolUse(toolName, toolInput) {
if (toolName === "bash" && toolInput.command?.includes("rm -rf /")) {
return { action: "deny" };
}
return { action: "allow" };
}
Pitfall 2: Modifying Without Logging
When you modify inputs, Claude doesn’t know it happened. Always communicate:
// ❌ Bad: Silent modification
if (cmd.includes("rm ")) {
return {
action: "modify",
input: { command: cmd.replace("rm ", "rm -i ") },
};
}
// ✅ Good: Transparent modification
if (cmd.includes("rm ")) {
console.log("ℹ️ Adding interactive mode to rm command");
return {
action: "modify",
input: { command: cmd.replace("rm ", "rm -i ") },
};
}
Pitfall 3: Async Operations in Hooks
Hooks should be synchronous or complete within milliseconds. Don’t make API calls or database queries:
// ❌ Bad: Slow async operation
export async function preToolUse(toolName, toolInput) {
const response = await fetch(
"https://api.example.com/check?cmd=" + toolInput.command,
);
// This blocks Claude and feels slow
}
// ✅ Good: Fast inline checks
export async function preToolUse(toolName, toolInput) {
const isSafe = BLACKLIST.includes(toolInput.command);
// Instant decision
}
Debugging Your Hooks
PreToolUse hooks can be tricky to test. Here’s how to debug:
// ~/.claude/hooks/pretooluse.mjs
const DEBUG = process.env.CLAUDE_HOOK_DEBUG === "true";
export async function preToolUse(toolName, toolInput) {
if (DEBUG) {
console.log("🔍 PreToolUse Hook Debug:");
console.log(" Tool:", toolName);
console.log(" Input:", JSON.stringify(toolInput, null, 2));
}
// Your logic
const result = evaluateTool(toolName, toolInput);
if (DEBUG) {
console.log(" Decision:", result.action);
}
return result;
}
function evaluateTool(toolName, toolInput) {
// ... your logic
return { action: "allow" };
}
Then run Claude Code with debugging enabled:
CLAUDE_HOOK_DEBUG=true claude code
Beyond Security: Creative Uses of PreToolUse
PreToolUse hooks aren’t just for blocking actions. They’re for controlling Claude’s behavior:
Enforce naming conventions: Modify filenames before Claude creates them
Add required headers: Automatically add copyright or license headers to new files
Format enforcement: Automatically apply code style (tabs vs spaces, etc.)
Context preservation: Add metadata Claude might forget
Performance optimization: Route slow tools through caches
Local first: Prefer local alternatives over network tools
Auto-fix common mistakes: Fix deprecated patterns before they run
The pattern is always the same: intercept, evaluate, allow/deny/modify. Your hook is the gatekeeper between intention and execution.
Summary: PreToolUse in Your Arsenal
PreToolUse hooks are Claude Code’s answer to the question: “How do I control what Claude does before it happens?”
They give you three powerful moves:
- Allow: Let Claude execute freely
- Deny: Block dangerous actions with a message
- Modify: Silently improve or sanitize inputs
Combined with strategic decision logic, they become:
- Path enforcement (no writes outside projects)
- Command filtering (no rm -rf)
- Input sanitization (remove hardcoded secrets)
- Audit trails (log everything sensitive)
- Smart defaults (add safety flags automatically)
- Rate limiting (prevent runaway workflows)
- Organizational policies (team-level governance)
For Windows developers especially, these hooks solve real problems: protecting your Documents and Projects folders, preventing accidental system changes, and maintaining compliance around credentials.
Start simple—block one dangerous pattern. Then build up your confidence and complexity. A mature PreToolUse hook becomes the silent guardian between Claude and your system. It’s not paranoia; it’s professionalism.
The power is in your hands. Use it wisely.
Advanced Error Handling Patterns
Hooks need robust error handling to prevent failures from breaking safety. When your hook code fails, Claude Code should default to denial—the safest possible state:
// ~/.claude/hooks/pretooluse.mjs
const HOOK_TIMEOUT = 5000;
function createTimeoutPromise(timeout) {
return new Promise((_, reject) => {
setTimeout(() => reject(new Error("Hook timeout")), timeout);
});
}
export async function preToolUse(toolName, toolInput) {
try {
// Wrap in timeout to prevent hanging hooks from blocking forever
return await Promise.race([
executeHookLogic(toolName, toolInput),
createTimeoutPromise(HOOK_TIMEOUT),
]);
} catch (error) {
console.error(`Hook error for ${toolName}:`, error);
// On error, default to deny for safety
return {
action: "deny",
reason: `Hook error: ${error.message}`,
};
}
}
async function executeHookLogic(toolName, toolInput) {
// Your actual logic here
return { action: "allow" };
}
This pattern ensures that even if your hook logic fails—whether from a coding error, file system issue, or unexpected input—Claude Code defaults to denying execution. This is fail-safe security: when in doubt, say no.
Hook Performance Optimization
Hooks that run slowly impact Claude’s responsiveness. Optimize for speed with caching and pre-compilation strategies:
// ~/.claude/hooks/pretooluse.mjs
// Pre-compile regexes once at module load
const DANGEROUS_PATTERNS = [/rm\s+-rf\s+\//, /mkfs/, /dd\s+if=\/dev/];
// Cache whitelist/blacklist lookups to avoid repeated compilation
const BLACKLIST_CACHE = new Map();
function isInBlacklist(command) {
if (BLACKLIST_CACHE.has(command)) {
return BLACKLIST_CACHE.get(command);
}
const isBlacklisted = DANGEROUS_PATTERNS.some((p) => p.test(command));
BLACKLIST_CACHE.set(command, isBlacklisted);
// Limit cache size to prevent memory bloat
if (BLACKLIST_CACHE.size > 1000) {
const firstKey = BLACKLIST_CACHE.keys().next().value;
BLACKLIST_CACHE.delete(firstKey);
}
return isBlacklisted;
}
export async function preToolUse(toolName, toolInput) {
if (toolName === "bash") {
const cmd = toolInput.command;
if (isInBlacklist(cmd)) {
return {
action: "deny",
reason: "Command matches dangerous pattern",
};
}
}
return { action: "allow" };
}
Caching common commands prevents repeated regex compilation and makes hook decisions nearly instant. Typical hook execution should complete in under 10 milliseconds.
Advanced Monitoring and Analytics
For production systems, implement comprehensive hook analytics to understand patterns over time:
// ~/.claude/hooks/pretooluse.mjs
const ANALYTICS_DIR = path.join(process.env.HOME, ".claude/analytics");
function recordAnalytic(toolName, decision, toolInput) {
const date = new Date().toISOString().split("T")[0];
const analyticsFile = path.join(ANALYTICS_DIR, `hook-${date}.jsonl`);
// Ensure directory exists
if (!fs.existsSync(ANALYTICS_DIR)) {
fs.mkdirSync(ANALYTICS_DIR, { recursive: true });
}
const record = {
timestamp: new Date().toISOString(),
toolName,
decision,
inputSize: JSON.stringify(toolInput).length,
executionTime: Date.now(),
};
fs.appendFileSync(analyticsFile, JSON.stringify(record) + "\n");
}
export async function preToolUse(toolName, toolInput) {
let decision = "allow";
// Your logic here
if (toolName === "bash" && toolInput.command?.includes("rm -rf")) {
decision = "deny";
}
recordAnalytic(toolName, decision, toolInput);
return { action: decision };
}
Query daily analytics to find patterns over time. A high deny rate for a specific tool indicates where Claude needs more guardrails. Track metrics like:
- Deny/allow ratio per tool
- Time of day patterns
- Command types blocked most frequently
- False positive rate (blocks that were actually safe)
Production Patterns: Composite Hook Architecture
For enterprise deployments, use a composite pattern with multiple validation layers that don’t interfere with each other:
// ~/.claude/hooks/pretooluse.mjs
// Layer 1: Security validators (fail-closed)
const securityValidators = [
validatePathAccess,
validateCommandSafety,
validateNetworkAccess,
];
// Layer 2: Compliance validators (team policies)
const complianceValidators = [
validateLicensing,
validateTeamPolicies,
validateAuditRequirements,
];
// Layer 3: Performance validators (resource limits)
const performanceValidators = [checkRateLimit, validateResourceUsage];
async function validatePathAccess(toolName, toolInput) {
// Path-based security checks
if (["write_file", "edit_file"].includes(toolName)) {
const filePath = toolInput.file_path;
if (filePath.startsWith("/etc") || filePath.startsWith("/sys")) {
return { action: "deny", reason: "System path access blocked" };
}
}
return null; // Pass through to next validator
}
async function validateCommandSafety(toolName, toolInput) {
// Command pattern checks
if (toolName === "bash") {
const cmd = toolInput.command;
if (cmd.includes("rm -rf") && !cmd.includes("--dry-run")) {
return { action: "deny", reason: "Destructive command blocked" };
}
}
return null;
}
async function validateNetworkAccess(toolName, toolInput) {
// Network restrictions
if (toolName === "WebFetch") {
const url = toolInput.url;
if (url.includes("internal.") || url.includes("localhost")) {
return { action: "deny", reason: "Internal network access blocked" };
}
}
return null;
}
// Compliance validators omitted for brevity...
export async function preToolUse(toolName, toolInput) {
// Run all validators in sequence, stopping at first denial
for (const validator of [
...securityValidators,
...complianceValidators,
...performanceValidators,
]) {
try {
const result = await validator(toolName, toolInput);
if (result) {
return result;
}
} catch (error) {
console.error(`Validator error: ${error.message}`);
return { action: "deny", reason: "Validator error" };
}
}
return { action: "allow" };
}
This architecture lets you add validators independently without modifying core logic. Each layer handles its domain, making the hook easier to reason about and maintain.
Real-World Scenario: Protecting Sensitive Database Operations
A practical example for teams managing databases where mistakes are expensive:
// ~/.claude/hooks/pretooluse.mjs
export async function preToolUse(toolName, toolInput) {
if (toolName === "bash") {
const cmd = toolInput.command;
// Pattern 1: Prevent direct production database access
if (cmd.match(/psql|mysql|mongosh/) && cmd.includes("production")) {
return {
action: "deny",
reason:
"Direct production database access blocked. Use migration tools instead.",
};
}
// Pattern 2: Require backup before destructive operations
if (cmd.match(/DROP|DELETE|TRUNCATE/) && !cmd.includes("--backup")) {
return {
action: "deny",
reason: "Destructive database operations require --backup flag",
};
}
// Pattern 3: Force dry-run for schema migrations
if (cmd.includes("migrate") && !cmd.includes("--dry-run")) {
console.log("ℹ️ Running migration in dry-run mode first");
return {
action: "modify",
input: {
...toolInput,
command: cmd.replace("migrate", "migrate --dry-run"),
},
};
}
// Pattern 4: Log all database operations for audit trail
if (cmd.match(/\b(psql|mysql|mongosh)\b/)) {
const logPath = `/var/log/claude-db-operations.log`;
try {
fs.appendFileSync(logPath, `${new Date().toISOString()} | ${cmd}\n`);
} catch (e) {
// Ignore log failures - don't let audit logging break the hook
}
}
}
return { action: "allow" };
}
This keeps your databases safe while still allowing necessary operations through proper channels. The combination of access restrictions and required flags prevents accidental data loss.
Hook Chaining and Decision Context
For complex scenarios, hook systems benefit from maintaining context across decisions:
// ~/.claude/hooks/pretooluse.mjs
// Hook context and state
const hookContext = {
toolCount: 0,
denialCount: 0,
startTime: Date.now(),
previousDecisions: [],
};
function recordDecision(toolName, decision) {
hookContext.previousDecisions.push({
tool: toolName,
decision,
timestamp: Date.now(),
});
// Trim old decisions to avoid memory bloat
if (hookContext.previousDecisions.length > 100) {
hookContext.previousDecisions.shift();
}
}
function getRecentDecisions(toolName, windowMs = 60000) {
const now = Date.now();
return hookContext.previousDecisions.filter(
(d) => d.tool === toolName && now - d.timestamp < windowMs,
);
}
export async function preToolUse(toolName, toolInput) {
hookContext.toolCount++;
// Chain multiple decisions together
let finalDecision = { action: "allow" };
// Check 1: Pattern matching
const patternCheck = checkDangerousPatterns(toolName, toolInput);
if (patternCheck) {
finalDecision = patternCheck;
}
// Check 2: Context-aware decisions based on recent history
if (finalDecision.action === "allow") {
const contextCheck = checkContextualRules(toolName, toolInput, hookContext);
if (contextCheck) {
finalDecision = contextCheck;
}
}
// Check 3: Rate limiting based on recent activity
if (finalDecision.action === "allow") {
const recentDenials = getRecentDecisions(toolName, 60000).filter(
(d) => d.decision === "deny",
).length;
if (recentDenials > 5) {
finalDecision = {
action: "deny",
reason: "Too many recent denials for this tool. Wait and try again.",
};
}
}
// Record the decision
recordDecision(toolName, finalDecision.action);
if (finalDecision.action === "deny") {
hookContext.denialCount++;
}
return finalDecision;
}
function checkDangerousPatterns(toolName, toolInput) {
// Basic pattern checking
return null;
}
function checkContextualRules(toolName, toolInput, context) {
// Context-aware decisions based on hook history
return null;
}
This approach allows hooks to make smarter decisions based on broader context—for example, detecting repeated denial patterns that might indicate a looping problem or misconfiguration.
Real-World Enterprise Scenarios
Let’s walk through what PreToolUse hooks look like when deployed in actual enterprise environments.
Scenario 1: The SaaS Startup
A SaaS company uses Claude Code to automate infrastructure changes. They can’t let Claude directly modify production databases—too risky. PreToolUse hooks intercept database-modifying operations and require them to go through a staging environment first.
// .claude/hooks/pretooluse/staging-requirement.mjs
function requiresStagingFirst(toolName, toolInput) {
const productionPatterns = [/production/i, /prod-/, /live-/, /-prod/];
const isProductionTarget = productionPatterns.some((p) =>
p.test(toolInput.target || toolInput.database || toolInput.path || ""),
);
if (isProductionTarget && toolName === "bash") {
const stagingTarget = toolInput.command.replace(/production/i, "staging");
return {
action: "modify",
input: { ...toolInput, command: stagingTarget },
reason:
"Redirected to staging. Run in staging first, validate results, then request production deployment.",
};
}
return { action: "allow" };
}
Now every database command targeting production automatically runs against staging first. This prevents mistakes and gives operators time to review results before they hit real customers.
Scenario 2: The Financial Services Firm
A financial firm uses Claude Code for automated trading logic. They need absolute certainty that trades can’t execute without human approval. PreToolUse hooks add an approval gate.
// .claude/hooks/pretooluse/financial-approval.mjs
const approvalRequired = [
"execute_trade",
"transfer_funds",
"modify_portfolio",
];
function needsApproval(toolName) {
return approvalRequired.some((p) => toolName.includes(p));
}
function getApprovalFromUser() {
// In a real system, this would call an HTTP endpoint that waits for human input
// For now, just show the tool call and block
console.log("Approval needed for financial operation");
return false;
}
export async function preToolUse(toolName, toolInput) {
if (needsApproval(toolName)) {
const approved = await getApprovalFromUser();
if (!approved) {
return {
action: "deny",
reason:
"Financial operations require human approval. Request was: " +
JSON.stringify(toolInput),
};
}
}
return { action: "allow" };
}
Now Claude can prepare trades, but execution requires a human click. This is how you get the benefits of automation while maintaining the human control that regulators require.
Scenario 3: The Healthcare Provider
A healthcare provider uses Claude Code for patient data processing. They have strict compliance requirements (HIPAA, GDPR). PreToolUse hooks ensure no data leaves secure boundaries.
// .claude/hooks/pretooluse/hipaa-compliance.mjs
const securePaths = [
"/secure/healthcare/",
"/var/secure/patient-data/",
"/vault/ehr/",
];
const forbiddenDestinations = [
"s3://public-",
"ftp://external",
"smtp://gmail",
"http://", // No unencrypted HTTP for patient data
];
export function preToolUse(toolName, toolInput) {
// Check if this operation involves patient data
const sourcePath =
toolInput.file_path || toolInput.from || toolInput.source || "";
const isPatientData = securePaths.some((p) => sourcePath.includes(p));
if (isPatientData) {
// Patient data requires extra scrutiny
const destination =
toolInput.to || toolInput.destination || toolInput.url || "";
// Check if destination is allowed
const forbiddenMatch = forbiddenDestinations.some((p) =>
destination.includes(p),
);
if (forbiddenMatch) {
return {
action: "deny",
reason:
"Cannot export patient data to external/public destination. HIPAA violation prevented.",
};
}
// Allow only to secure internal endpoints
if (!securePaths.some((p) => destination.includes(p))) {
return {
action: "deny",
reason:
"Patient data can only be exported to secure internal locations.",
};
}
}
return { action: "allow" };
}
This prevents accidental data breaches. Even if Claude gets confused about destinations, the hook catches it. Compliance becomes enforced at the tool level, not the code level.
The Psychology of Tool Gates
There’s something important to understand about preToolUse hooks: they’re not just security. They’re also psychological.
When you have a PreToolUse hook that blocks dangerous operations, it changes how Claude approaches problems. Claude “learns” (through the context of repeated blocking) that certain operations aren’t allowed. It adapts. It finds workarounds. It becomes more careful.
This isn’t magic. Claude isn’t actually “learning” in a persistent sense—it forgets between sessions. But within a session, the blocking feedback shapes behavior. Claude tries something, gets blocked, sees the error message, and tries something else. Over a session, this creates a dynamic where Claude becomes more conservative in high-stakes areas.
In practical terms: your hook isn’t just preventing bad things. It’s creating a feedback loop that makes Claude behave more carefully. This is why clear, specific denial messages matter. “denied” is unhelpful. “Cannot modify production database—run against staging first” guides Claude toward the right solution.
Hook Evolution and Maintenance
PreToolUse hooks aren’t set-and-forget. As your system evolves, hooks need to evolve too.
Track your hook decisions. Log every denial, every modification. After a month, ask: “What are we blocking most? Is it still necessary? Can we allow it with safeguards instead?” Sometimes a conservative hook is the right call. Sometimes it’s blocking legitimate work and deserves reconsideration.
Create a hook review process. Quarterly, examine your denial logs. Are there patterns? Are denials helping or just frustrating? Adjust thresholds. Loosen restrictions where safe. Tighten them where needed. Hooks should evolve with your confidence in the system.
The Limits of PreToolUse
PreToolUse hooks are powerful but not omniscient. They see tool calls, not intentions. They can’t tell if Claude is making a mistake or being clever. They can’t always distinguish between legitimate work and risky behavior.
For example: a PreToolUse hook might block “delete file” operations to prevent data loss. But what if Claude legitimately needs to clean up temporary files? Your hook blocks it, Claude can’t proceed, and you have to manually intervene. Over time, hooks can become more liability than protection.
The answer is context. Your hooks should be smart enough to:
- Allow risky operations in low-stakes contexts (tests, temp directories)
- Block them in high-stakes contexts (production, user data)
- Ask for clarification when uncertain
- Log decisions so you can audit them later
Hooks are tools for humans to delegate safely. They succeed when they’re intelligent enough that they rarely block legitimate work while catching the dangerous stuff reliably.
Building a Hook Culture: Beyond Technical Implementation
Here’s what teams often miss: PreToolUse hooks aren’t just technical infrastructure. They’re the crystallization of your team’s trust in automation and your approach to risk management. The hooks you write reflect what you believe should be automated versus what should require human judgment. They represent your organization’s philosophy toward AI-assisted development.
When you create a hook that requires approval for financial transactions, you’re not just implementing a technical gate. You’re saying “we trust Claude with complex logic, but we reserve final judgment on financial commitments to humans.” That’s a statement about values, not just tech. Similarly, when you configure hooks that modify database commands to run staging-first, you’re automating a safety check that used to require a code review. That’s organizational leverage—the hook becomes a permanent enforcement mechanism for a decision your team made once.
This is why hook maintenance matters. As your team grows and your confidence in Claude increases, your hooks should evolve. A hook that was appropriately conservative when you first deployed Claude might become unnecessarily restrictive a year later. Successful teams review hooks regularly, tune them based on actual usage patterns, and adjust thresholds. This keeps hooks aligned with your actual risk tolerance instead of becoming calcified restrictions.
There’s also a training benefit that deserves recognition. When junior developers work with Claude Code in an environment where hooks enforce best practices, they absorb those practices. They see destructive operations get prevented. They see database commands get redirected to staging. They see audit trails logged. These become internalized as normal workflow. You’re not just protecting against Claude mistakes—you’re training humans to follow safe procedures.