
You've just handed off your CI/CD pipeline configuration to Claude Code for optimization. It's made some smart suggestions. Then you realize—it's also about to modify your secrets management setup, which requires a manual approval process and audit trail. You break into a cold sweat.
This is where hooks save the day. We're going to build a PreToolUse hook that acts as a gatekeeper, blocking edits to protected files before they happen, with crystal-clear rejection messages and a documented override mechanism for when you need to break the glass.
This is the simplest, most commonly needed hook pattern in Claude Code. It's also your first line of defense in governance workflows and the foundation of responsible AI-assisted development.
Table of Contents
- Why This Matters: The Cost of Accidental Changes
- How PreToolUse Hooks Work: The Lifecycle
- The Config File: Patterns of Protected Files
- The Hook: PreToolUse in .mjs Format
- Configuration Loading: Building Trust in the Config
- Pattern Matching with minimatch: Flexible File Matching
- Tool Input Extraction: Understanding What Claude Wants to Do
- Override Keyword Check: Intentional Friction
- The Denial Message: Clear, Actionable Feedback
- Testing Your Hook: Verification Before Production
- Test 1: Editing a Protected File (Should Fail)
- Test 2: Override the Protection
- Test 3: Unprotected File (Should Pass)
- Real-World Scenario: Multi-Environment Config
- Extending: Audit Logging for Compliance
- Edge Cases and Gotchas
- Case Sensitivity: Operating System Differences
- Path Normalization: Windows Compatibility
- Glob Escaping: Literal Special Characters
- Performance: Large Pattern Lists
- Combining with Other Governance Patterns
- Layer 1: File Protection (This Hook)
- Layer 2: Command Denial (Separate PreToolUse Hook)
- Layer 3: Commit Message Validation
- Layer 4: Post-Tool-Use Validation
- Debugging: What Happens When a Hook Fails?
- Common Integration Scenarios
- Scenario 1: Startup Protection (Version-Controlled Rules)
- Scenario 2: Environment-Specific Patterns
- Scenario 3: Gradual Rollout (User Adoption)
- Hook Chaining: Combining Multiple Protection Rules
- Structure Your Hooks
- Example: Chained Audit Logging
- Ordered Execution for Complex Rules
- Real-World Configuration: Enterprise Setup
- Monitoring and Alerting
- Performance Considerations
- 1. Cache Pattern Matches
- 2. Exclude Obvious Safe Files
- Troubleshooting
- "My hook never fires"
- "My override keyword doesn't work"
- "Protected patterns aren't matching"
- Production Deployment Checklist
- Configuration Validation
- Hook Implementation
- Testing
- Team Communication
- Monitoring
- Rollback Plan
- Integration with Team Workflows
- For Code Review Teams
- For DevOps Teams
- For Security Teams
- Advanced: Dynamic Protection Rules
- Testing in Production: Staged Rollout
- Week 1: Observability
- Week 2: Warnings
- Week 3: Enforcement
- Summary: Why This Hook Pattern Matters
- The Hook Ecosystem: Building on File Protection
- Foundation Layer: File Protection
- Command Safety Layer: Bash/Shell Restrictions
- Validation Layer: Pre-Commit Checks
- Audit Layer: Centralized Logging
- Organizational Adoption: Rolling Out Hooks to Your Team
- Month 1: Soft Launch
- Month 2: Visibility
- Month 3: Enforcement
- Ongoing: Refinement
- Connecting Hooks to Your Security Policies
- Advanced: Conditional Protection Based on Context
- Maintenance: Keeping Hooks Fresh
- Final Thought: Protection as Culture
Why This Matters: The Cost of Accidental Changes
Before hooks existed, preventing accidental modifications meant:
- Manual reviews of every change (slow and error-prone, bottlenecks your team)
- Filesystem permissions (coarse-grained, affects humans too, doesn't distinguish between edits)
- Post-hoc audits (too late if sensitive data leaked or infrastructure was misconfigured)
- Trust and hope (not a strategy, doesn't scale)
Here's the problem: without governance, Claude Code can modify anything. A well-intentioned request—"Clean up our CI config"—can accidentally touch your secrets vault. A subtle misunderstanding—"Update all configuration files"—could modify production database connection strings. The cost of these mistakes is high: data leaks, service outages, security incidents.
With a PreToolUse hook, you:
- Catch violations in real-time before Claude writes anything (fail fast, fail safe)
- Provide clear feedback about why the file is protected and how to proceed
- Support authorized overrides with explicit intent (not a brick wall, but intentional friction)
- Create an audit trail (logging which files were attempted, who approved what, when)
- Scale governance without slowing down your team or creating bottlenecks
The hook runs before Claude's tools execute, giving you a chance to say "not so fast" before damage is done. It's the difference between "uh oh, we just leaked credentials" and "Claude tried to edit the credentials, the hook blocked it, and here's the audit log."
How PreToolUse Hooks Work: The Lifecycle
A PreToolUse hook intercepts any tool call Claude is about to make—Read, Write, Edit, Bash, you name it. You examine the input, decide yes/no/modify, and respond. This happens before the tool executes, so you have veto power.
Here's the lifecycle:
- User gives Claude a task ("Update our CI config to use the new Docker registry")
- Claude analyzes and decides to use a tool (Edit tool, path:
.github/workflows/deploy.yml) - Hook intercepts before execution - this is where we check file patterns
- Hook returns a decision:
allow→ Claude's tool runs normally, file gets modifieddeny→ Tool blocked, Claude gets an error messagemodify→ Tool input altered before execution (advanced)
We'll use deny to block, with a helpful message explaining the restriction and how to override it. The denial isn't punitive—it's educational and deliberate.
The Config File: Patterns of Protected Files
Before we write code, we need to know which files to protect. We'll store this in a simple configuration file so you can edit protections without recompiling your hook or restarting services.
Create .claude/protected-files.json:
{
"protected_patterns": [
"secrets/**",
".env",
".env.*",
"config/production.json",
".github/workflows/deploy*.yml",
"terraform/**",
"kubernetes/**",
"src/auth/**",
"Dockerfile",
"docker-compose.prod.yml"
],
"description": "Files that require manual approval before editing",
"override_keyword": "OVERRIDE_PROTECTED_FILE"
}This uses glob patterns (the * and ** syntax you know from .gitignore). We're protecting:
- All secrets/ directory contents (organization-wide secrets management)
- Environment files (.env, .env.production, .env.staging, etc.)
- Infrastructure as code (terraform, kubernetes, Dockerfile—these define your production environment)
- Deployment workflows (GitHub Actions, CI/CD pipelines—these can deploy breaking changes)
- Auth system code (authentication is security-critical)
- Production configs (production settings are different from dev settings and affect all users)
You can adjust these patterns for your needs. The override_keyword is how someone explicitly approves a protected file edit—a deliberate moment to say "yes, I want to do this."
Why glob patterns? They're familiar to developers (everyone knows .gitignore), they're flexible (can protect single files or entire directories), and they scale (adding a new protected pattern is a one-line edit).
The Hook: PreToolUse in .mjs Format
Now the actual code. Create .claude/hooks/pre-tool-use.mjs:
import fs from "fs";
import path from "path";
import { minimatch } from "minimatch";
// Load protected patterns from config
const CONFIG_PATH = path.join(process.cwd(), ".claude", "protected-files.json");
let protectedPatterns = [];
let overrideKeyword = "OVERRIDE_PROTECTED_FILE";
try {
const config = JSON.parse(fs.readFileSync(CONFIG_PATH, "utf-8"));
protectedPatterns = config.protected_patterns || [];
overrideKeyword = config.override_keyword || "OVERRIDE_PROTECTED_FILE";
} catch (err) {
console.error(`Failed to load protected files config: ${err.message}`);
// Fall back to empty (allow all) if config is missing
protectedPatterns = [];
}
// Helper: check if a file path matches any protected pattern
function isProtectedFile(filePath) {
const normalizedPath = path.normalize(filePath).replace(/\\/g, "/");
return protectedPatterns.some((pattern) => {
return minimatch(normalizedPath, pattern, { dot: true });
});
}
// Main hook
export default async function preToolUse(
toolInput,
{ userMessage, systemState },
) {
const { toolName, toolInput: input } = toolInput;
// Only care about Write and Edit tools (tools that modify files)
if (!["Write", "Edit"].includes(toolName)) {
return { allow: true };
}
// Extract the file path from tool input
let filePath = null;
if (toolName === "Write") {
filePath = input.file_path;
} else if (toolName === "Edit") {
filePath = input.file_path;
}
if (!filePath) {
return { allow: true };
}
// Check if this file is protected
if (isProtectedFile(filePath)) {
// Check for override keyword in the user's original message
const userText = userMessage?.content || "";
if (userText.includes(overrideKeyword)) {
// Log the override for audit trail
console.log(
`[PROTECTED_FILE_OVERRIDE] User approved edit to: ${filePath}`,
);
return {
allow: true,
metadata: {
audited: true,
protected_file: true,
override_used: true,
},
};
}
// Deny with helpful message
const protectedList = protectedPatterns.slice(0, 5).join(", ");
const moreCount =
protectedPatterns.length > 5
? ` (+${protectedPatterns.length - 5} more)`
: "";
return {
deny: true,
message: `❌ Cannot modify protected file: ${filePath}
This file is protected because it contains:
- Sensitive configuration
- Infrastructure code
- Security-critical systems
- Production deployment settings
**Protected patterns:** ${protectedList}${moreCount}
**To override this protection:**
1. Confirm the edit is necessary and authorized
2. Include the phrase "${overrideKeyword}" in your next message
3. Claude will allow the modification and log it for audit
Example:
"${overrideKeyword}: Update deploy.yml to use new Docker image"
This ensures all protected file edits are intentional and audited.`,
};
}
return { allow: true };
}Let's break down what's happening here:
Configuration Loading: Building Trust in the Config
const config = JSON.parse(fs.readFileSync(CONFIG_PATH, "utf-8"));
protectedPatterns = config.protected_patterns || [];We read the JSON file and extract the protected_patterns array. The configuration is loaded every time (no caching), so changes to .protected-files.json take effect immediately without restarting. If the file doesn't exist, we fall back to an empty array (no protection). This is safe—if you haven't configured protection, we let edits through. The failure mode is permissive, not restrictive.
Pattern Matching with minimatch: Flexible File Matching
return protectedPatterns.some((pattern) => {
return minimatch(normalizedPath, pattern, { dot: true });
});The minimatch library handles glob patterns like secrets/** and *.env*. We normalize the path (convert backslashes to forward slashes for consistency on Windows, since minimatch expects forward slashes) and test it against each pattern. If any pattern matches, the file is protected.
The { dot: true } option means patterns can match files starting with . (like .env). Without this flag, .env wouldn't match .* patterns. With it, .env is visible to glob patterns.
Why minimatch and not just regex? Glob patterns are more intuitive for developers. Everyone understands src/**/*.test.js. Fewer people intuitively understand the regex equivalent. Using glob patterns means your team doesn't need to learn regex syntax to maintain protection rules.
Tool Input Extraction: Understanding What Claude Wants to Do
if (toolName === "Write") {
filePath = input.file_path;
} else if (toolName === "Edit") {
filePath = input.file_path;
}Both Write and Edit tools use the file_path parameter. We extract it and proceed. If the tool is Read or Bash, we skip protection—those tools don't modify files. Read is informational, Bash can do anything, but we're focusing on file protection here. (You could add a separate hook to restrict Bash commands like rm -rf or git push --force.)
Override Keyword Check: Intentional Friction
const userText = userMessage?.content || "";
if (userText.includes(overrideKeyword)) {
console.log(`[PROTECTED_FILE_OVERRIDE] User approved edit to: ${filePath}`);
return {
allow: true,
metadata: {
audited: true,
protected_file: true,
override_used: true,
},
};
}If the user's original message contains the override keyword (OVERRIDE_PROTECTED_FILE), we allow the edit. We also log it and attach metadata so you can audit who approved what. This is intentional friction. It's not unbreakable—you can always override. But the keyword requirement ensures edits to sensitive files are deliberate, not accidental. It creates a moment of conscious choice: "Am I really sure I want to edit this protected file?"
Why keywords instead of just allowing? Friction prevents accidents. If someone says "Clean up our config files," Claude might interpret that broadly. The override keyword forces a conscious moment: "Do I really want to edit .env.production?" The answer might be "no, actually, only edit non-sensitive configs." The keyword catches misalignments.
The Denial Message: Clear, Actionable Feedback
return {
deny: true,
message: `❌ Cannot modify protected file: ${filePath}
This file is protected because it contains:
- Sensitive configuration
- Infrastructure code
- Security-critical systems
- Production deployment settings
**Protected patterns:** ${protectedList}${moreCount}
**To override this protection:**
1. Confirm the edit is necessary and authorized
2. Include the phrase "${overrideKeyword}" in your next message
3. Claude will allow the modification and log it for audit
Example:
"${overrideKeyword}: Update deploy.yml to use new Docker image"
This ensures all protected file edits are intentional and audited.`,
};When we deny, we provide:
- Clear reason (protected because...)
- List of protected patterns (so you see what's guarded and can understand the scope)
- Exact steps to override (including the keyword to use)
- An example (shows proper override syntax)
This isn't punitive. It's educational. A developer who sees this message will understand why the protection exists and how to proceed if needed. They'll also understand that the protection is there for a reason—and that if they override it, it'll be logged.
Testing Your Hook: Verification Before Production
Let's verify it works. Create a simple test file structure:
mkdir -p .claude/hooks
# (hooks/pre-tool-use.mjs already created above)
mkdir -p secrets
echo "DB_PASSWORD=test" > .env
echo "public file" > src/main.jsNow ask Claude Code to make some edits:
Test 1: Editing a Protected File (Should Fail)
You: "Update the .env file to add a new API key"
Claude Code attempts: Edit on .env
Hook response: Denial with override message
Claude sees: The denial message and understands it needs the override keyword
Result: ✅ Protection works. Claude cannot edit .env without override.
Test 2: Override the Protection
You: "OVERRIDE_PROTECTED_FILE: Add API_KEY to .env"
Claude Code attempts: Edit on .env
Hook response: Allows it, logs the override
Claude proceeds: Makes the edit normally
Result: ✅ Override works. Claude edits .env and logs it.
Test 3: Unprotected File (Should Pass)
You: "Update src/main.js to add error handling"
Claude Code attempts: Edit on src/main.js
Hook response: Allows immediately (no protection)
Claude proceeds: Makes the edit
Result: ✅ Unprotected files work normally.
Real-World Scenario: Multi-Environment Config
Here's a more sophisticated .protected-files.json for a real project:
{
"protected_patterns": [
"*.env*",
".env.production",
".env.staging",
"config/secrets/**",
".github/workflows/*.yml",
"terraform/**",
"kubernetes/production/**",
"docker/Dockerfile.prod",
"docker/docker-compose.prod.yml",
"src/auth/oauth-secret.js",
"src/db/connection-string.js",
"aws-credentials.json",
".ssh/**",
"ssl-certificates/**"
],
"description": "Protects secrets, infrastructure, and production configs",
"override_keyword": "AUTHORIZE_SENSITIVE_CHANGE"
}Now you've got:
- Environment files protected (all variants, all environments)
- Kubernetes production protected (but staging isn't, so Claude can test changes there first)
- Infrastructure as code protected (terraform, docker)
- SSH keys and certificates protected
- OAuth secrets protected
- Database credentials protected
The override keyword is more descriptive (AUTHORIZE_SENSITIVE_CHANGE not just OVERRIDE), making it clear what you're doing. This helps prevent someone from casually including the keyword in a request. It forces a moment of thought: "Am I really authorizing a sensitive change?"
Extending: Audit Logging for Compliance
Your hook can also emit to an audit log. This is critical for security and compliance—you need to know who changed what, when. Modify the override section:
if (userText.includes(overrideKeyword)) {
const timestamp = new Date().toISOString();
const auditEntry = {
timestamp,
file: filePath,
action: toolName,
override: true,
user: process.env.USER || "unknown",
message: userText.substring(0, 200), // First 200 chars of the request
};
// Append to audit log
const auditPath = path.join(process.cwd(), ".claude", "audit.jsonl");
fs.appendFileSync(auditPath, JSON.stringify(auditEntry) + "\n");
return { allow: true, metadata: { audited: true } };
}Now every override is logged to .claude/audit.jsonl, one JSON entry per line. You can later query this for compliance reports: "Show me all edits to .env.production in the last month." This is invaluable for security audits, post-incident analysis, and understanding who touched what.
Example audit entry:
{
"timestamp": "2026-03-17T14:22:33Z",
"file": ".env.production",
"action": "Edit",
"override": true,
"user": "alice",
"message": "AUTHORIZE_SENSITIVE_CHANGE: Update API key"
}Edge Cases and Gotchas
Case Sensitivity: Operating System Differences
On Linux/Mac, .env and .ENV are different files. Your patterns are case-sensitive by default. If you need case-insensitive matching:
return protectedPatterns.some((pattern) => {
return minimatch(normalizedPath.toLowerCase(), pattern.toLowerCase(), {
dot: true,
});
});But be cautious: case-insensitive matching on case-sensitive filesystems can hide mistakes. Better to be explicit: if you want to protect both .env and .ENV, add both patterns.
Path Normalization: Windows Compatibility
Windows uses backslashes. minimatch expects forward slashes. That's why we do:
const normalizedPath = path.normalize(filePath).replace(/\\/g, "/");Always normalize before pattern matching. This ensures hooks work consistently across Windows, Mac, and Linux.
Glob Escaping: Literal Special Characters
If your filename has literal * or [ characters (rare), glob patterns will interpret them as wildcards. Escape them with backslash: my\*file.txt. In most projects, this isn't an issue. Document it if you encounter it.
Performance: Large Pattern Lists
With hundreds of protected patterns, the some() loop could get slow. If that's a concern, compile patterns to regex:
const regexPatterns = protectedPatterns.map(
(p) => new RegExp(minimatch.makeRe(p)),
);
function isProtectedFile(filePath) {
return regexPatterns.some((regex) => regex.test(normalizedPath));
}This pre-compiles patterns once at startup instead of compiling per file check. For teams with 10+ patterns, caching makes sense. For 50+, it's necessary.
Combining with Other Governance Patterns
The file protection hook works best as part of a layered governance strategy. Here's how to combine it with other hooks:
Layer 1: File Protection (This Hook)
Blocks writes to critical files before they happen. Fast, simple, clear. Prevents accidental modifications.
Layer 2: Command Denial (Separate PreToolUse Hook)
Blocks destructive shell commands (rm -rf, git push --force, DROP TABLE, etc.). Run alongside file protection to defend your repository and data. This prevents scripts from causing damage.
Layer 3: Commit Message Validation
After changes are staged, validate that commit messages follow your team's format. This doesn't block edits—it enforces communication standards and makes history searchable.
Layer 4: Post-Tool-Use Validation
Run tests, security scans, or linting after changes. This catches logic errors that permission hooks can't detect. For instance, after Claude edits a Terraform file, run terraform validate automatically.
The key insight: no single hook solves everything. File protection stops certain classes of mistakes. Command denial stops others. Together, they create a safety net.
Debugging: What Happens When a Hook Fails?
Hooks are code. Code has bugs. What happens if your .mjs file crashes?
If your hook throws an error:
- Claude sees an error message (the hook failed, details logged)
- The tool does NOT execute (safe-fail behavior)
- You see the error in
.claude/hook-errors.logor console
So even buggy hooks are safe—they fail closed, not open. You can't accidentally create a hole.
To test your hook without running Claude Code:
# Manually test the hook with Node.js
node -e "
import('./pre-tool-use.mjs').then(m => {
const result = m.default(
{ toolName: 'Edit', toolInput: { file_path: '.env' } },
{ userMessage: { content: 'just editing' } }
);
console.log(result);
});
"This lets you verify the hook logic before deploying it to your team.
Common Integration Scenarios
Scenario 1: Startup Protection (Version-Controlled Rules)
Your .claude/protected-files.json is committed to git. Every developer, every environment, gets the same rules. This means:
- Consistency: Alice in Portland and Bob in Berlin protect the same files
- Version control: Changes to protection rules go through code review (you see a diff: "added terraform/** to protected patterns")
- Auditability: You can see when protections were added/relaxed and why
Scenario 2: Environment-Specific Patterns
Different environments need different protections. Production is locked down. Development is more permissive.
Create .claude/protected-files.prod.json:
{
"protected_patterns": ["src/**", "package.json", "Dockerfile", "terraform/**"]
}Then in your hook, load based on environment:
const env = process.env.NODE_ENV || "development";
const configPath = path.join(
process.cwd(),
".claude",
`protected-files.${env}.json`,
);Now production workspaces enforce stricter rules. Development allows more freedom.
Scenario 3: Gradual Rollout (User Adoption)
You're introducing hooks to your team. Start with a small set of protected files:
{
"protected_patterns": [".env", "secrets/**"]
}Let your team get used to overriding. After a week, expand:
{
"protected_patterns": [
".env",
"secrets/**",
".github/workflows/**",
"terraform/**"
]
}Gradual adoption prevents surprise blocks that frustrate developers. They understand the rules before they hit enforcement.
Hook Chaining: Combining Multiple Protection Rules
You don't have to write one massive hook. You can chain multiple hooks, each with a specific purpose. Here's how:
Structure Your Hooks
Create multiple hook files in .claude/hooks/:
pre-tool-use-files.mjs- File protection (this one)pre-tool-use-commands.mjs- Command denial (separate)pre-tool-use-audit.mjs- Centralized audit loggingpost-tool-use-validation.mjs- Validate after execution
Claude Code runs them in order. Each hook can:
- Allow → Next hook runs
- Deny → Stop immediately, show error
- Modify → Change the input, pass to next hook
Example: Chained Audit Logging
Create .claude/hooks/post-tool-use-audit.mjs:
import fs from "fs";
import path from "path";
export default async function postToolUse(
toolResult,
{ toolName, toolInput, userMessage },
) {
// Log every tool execution for compliance
const auditEntry = {
timestamp: new Date().toISOString(),
tool: toolName,
action: "tool_executed",
user: process.env.USER || "unknown",
filePath: toolInput.file_path || toolInput.command || null,
};
const auditPath = path.join(process.cwd(), ".claude", "complete-audit.jsonl");
fs.appendFileSync(auditPath, JSON.stringify(auditEntry) + "\n");
// Allow the result to pass through
return { allow: true };
}Now every tool execution is logged, regardless of which hook allowed it. This creates a complete audit trail without duplicating logging in each hook.
Ordered Execution for Complex Rules
Your hooks can depend on each other:
- File protection hook runs first, blocks protected file edits
- Syntax validation hook runs next, validates XML/JSON syntax before write
- Audit logging hook runs last, records what happened
If hook #1 denies, hooks #2 and #3 never run. This saves computation and keeps denials fast.
Real-World Configuration: Enterprise Setup
Here's a production .claude/protected-files.json for a mid-size company:
{
"protected_patterns": [
".env*",
".env.*.local",
"config/secrets/**",
"config/production.yaml",
".github/workflows/deploy*.yml",
".github/workflows/release*.yml",
"terraform/**",
"kubernetes/production/**",
"docker/Dockerfile.prod",
"docker/docker-compose.prod.yml",
"src/auth/**",
"src/admin/**",
"src/billing/**",
"Makefile",
"docker-compose.override.yml",
"scripts/deploy.sh",
"scripts/database/migration*.sql"
],
"description": "Enterprise-grade file protection for production systems",
"override_keyword": "AUTHORIZE_CRITICAL_CHANGE",
"log_overrides": true
}This protects:
- All environment files (secrets)
- Configuration management (production, staging configs)
- Infrastructure code (terraform, kubernetes)
- Deployment automation (GitHub Actions workflows)
- Security-sensitive code (auth, admin, billing modules)
- Database scripts (migration scripts require care)
- Build scripts (Makefile, docker compose)
The override keyword is intentionally verbose (AUTHORIZE_CRITICAL_CHANGE not just OVERRIDE), making it harder to slip into a normal request by accident. It forces intention.
Monitoring and Alerting
Hooks can emit warnings without blocking. For example, if someone repeatedly overrides protection rules, that might indicate a problem—maybe the rules are too strict, or maybe someone's being reckless.
// Count overrides in the last hour
const auditPath = path.join(process.cwd(), ".claude", "audit.jsonl");
const recentOverrides = readRecentOverrides(auditPath, 60 * 60 * 1000);
if (recentOverrides.length > 5) {
console.warn(
`⚠️ High override activity: ${recentOverrides.length} overrides in last hour`,
);
// Alert Slack, email, etc.
}This detects when someone is systematically bypassing protections—maybe a sign they need the rules relaxed, or maybe a sign of compromise or carelessness.
Performance Considerations
With thousands of files in your repo and dozens of protection patterns, the glob matching could add latency. Some optimizations:
1. Cache Pattern Matches
const cache = new Map();
function isProtectedFile(filePath) {
if (cache.has(filePath)) {
return cache.get(filePath);
}
const isProtected = protectedPatterns.some((pattern) =>
minimatch(filePath, pattern, { dot: true }),
);
cache.set(filePath, isProtected);
return isProtected;
}Once you've decided a file is protected (or not), remember it. The cache avoids re-checking the same file twice.
2. Exclude Obvious Safe Files
function isProtectedFile(filePath) {
// Fast reject: files that can never be protected
if (filePath.endsWith(".js") || filePath.endsWith(".ts")) {
// Might be protected, but check patterns
// (Could skip if you know no patterns match source files)
}
return protectedPatterns.some((pattern) =>
minimatch(filePath, pattern, { dot: true }),
);
}This is premature optimization for most teams. But if you have 10,000+ file edits per day, caching helps.
Troubleshooting
"My hook never fires"
- File location: Is the hook at
.claude/hooks/pre-tool-use.mjs? Check the filename exactly. - Config exists: Does
.claude/protected-files.jsonexist and parse correctly? - Claude Code running: Did you reload the workspace after creating the hook?
Try:
# Verify the hook exists
ls -la .claude/hooks/pre-tool-use.mjs
# Verify the config parses
jq . .claude/protected-files.json
# Check logs
cat .claude/hook-errors.log"My override keyword doesn't work"
- Exact match: Is it spelled exactly as in the config?
OVERRIDE_PROTECTED_FILE(case-sensitive by default) - In the user message: Did you include it in your instruction to Claude, not in a code comment?
- Timing: Is the hook actually firing? Add debug logs to verify.
"Protected patterns aren't matching"
Glob patterns can be tricky. Test them:
node -e "
import('minimatch').then(m => {
const match = m.minimatch('.env.production', '.env.*');
console.log('Match:', match);
});
"Production Deployment Checklist
Before deploying hooks to a team, verify these items:
Configuration Validation
- All protected file patterns tested and documented
- Override keyword is documented in team wiki/README
- Override keyword is intentionally verbose (not "yes", something like "OVERRIDE_PROTECTED_FILE")
- Config file is committed to git (so all developers see same rules)
- Example protected files listed in comments
Hook Implementation
- Hook loads config and has fallback if config missing
- Hook handles missing config gracefully (allow all if no config)
- Glob patterns are optimized (not catastrophically slow)
- Error messages are clear and actionable
- Hook logs overrides to audit trail
- Hook includes examples of how to override
Testing
- Test that protected file edit is blocked without override
- Test that protected file edit is allowed WITH override keyword
- Test that unprotected file edit always works
- Test that hook handles special characters in filenames
- Test that hook handles very long commands
- Test that hook doesn't crash on invalid patterns
Team Communication
- Send announcement to team: "New file protection hooks"
- Explain why protection exists (security, compliance, etc.)
- Give examples of protected files
- Show how to override (with keyword)
- Point to documentation/README
- Be available for questions first week
Monitoring
- Set up monitoring of
audit.jsonlfile - Alert if same file is overridden multiple times
- Weekly report: most-overridden files (indicator that rules are too strict)
- Monthly review: are the rules still making sense?
Rollback Plan
- Know how to disable hooks (delete or rename hook files)
- Document rollback steps
- Have backup copy of previous configuration
- Plan for gradual rollout vs. all-at-once
Integration with Team Workflows
For Code Review Teams
Your protected files hook helps reviewers:
Reviewer sees commit to main branch
Looks at diff: changes to .env.production
Checks audit log: sees "OVERRIDE_PROTECTED_FILE: alice updated API keys"
Asks alice in code review: why did you need to change this?
Alice explains: rotated keys after security incident
Reviewer: approved
The audit trail makes it easy to verify that sensitive changes were intentional.
For DevOps Teams
Infrastructure-as-code files (terraform, kubernetes) get protected:
Developer: "Update the Kubernetes deployment"
Claude: Analyzes, prepares edit to kubernetes/production/deploy.yaml
Hook: Blocks without override
Developer: "AUTHORIZE_CRITICAL_CHANGE: Update image tag to v1.2.3"
Hook: Logs the change with timestamp and user
Pipeline: Runs validation on the file before deploying
Now infrastructure changes have audit trail + automated validation.
For Security Teams
Secrets management gets centralized:
Hook protects: secrets/**, .env*, config/secrets/**
When overridden: logged to audit trail
Security team: Reviews audit trail weekly
Generates report: "10 secret file changes this week by 4 engineers"
Identifies risks: "Bob updated 5 secret files. Is his machine compromised?"
The hook gives security teams visibility without blocking developers.
Advanced: Dynamic Protection Rules
Instead of static JSON, generate rules from your infrastructure:
import fs from "fs";
import path from "path";
import { execSync } from "child_process";
// Generate protection rules from git
function generateRulesFromGit() {
// Get list of files modified in last month
const output = execSync(
'git log --name-only --since="1 month ago" --pretty=""',
{ encoding: "utf-8" },
);
const frequentlyChangedFiles = output.split("\n").filter((f) => f);
// Don't protect frequently-changing files (they're probably not sensitive)
// DO protect files that haven't changed in a year (probably frozen infra)
const oldFiles = execSync(
'git log --name-only --until="1 year ago" --pretty="" | sort -u',
{ encoding: "utf-8" },
)
.split("\n")
.filter((f) => f);
return {
protected_patterns: oldFiles.filter((f) =>
f.match(/terraform|kubernetes|dockerfile/i),
),
description:
"Auto-generated: files unchanged in 1+ year + known infrastructure",
};
}
// Call this at hook startup
const rules = generateRulesFromGit();This adapts protection rules based on your actual development patterns. New code isn't protected by default, ancient infrastructure code is.
Testing in Production: Staged Rollout
Instead of rolling out hooks to entire team at once:
Week 1: Observability
Deploy hooks in log-only mode—they never block, just log attempts:
// In hook, instead of deny:
if (isProtectedFile(filePath)) {
console.log(`[PROTECTED_FILE_WOULD_BLOCK] ${filePath}`);
return { allow: true }; // Allow, but log
}Watch logs. See which files developers try to edit. Are your patterns too strict?
Week 2: Warnings
Switch to warning mode—they block for 5 seconds, show a warning, then allow:
if (isProtectedFile(filePath)) {
console.warn(`⚠️ About to edit protected file: ${filePath}`);
await new Promise((resolve) => setTimeout(resolve, 5000));
return { allow: true }; // Still allow
}Developers get used to seeing the warnings. They start including override keywords.
Week 3: Enforcement
Enable actual blocking:
if (isProtectedFile(filePath)) {
return { deny: true, message: "..." }; // Actually block
}By now, developers understand the rules and know how to override.
This gradual approach prevents surprise blocks and frustrated team members.
Summary: Why This Hook Pattern Matters
File protection hooks are:
- Simple to implement (minimal code, clear logic)
- Easy to maintain (rules in JSON, not code)
- Non-invasive (developers can override when needed)
- Auditable (every override is logged)
- Scalable (works for teams of 5 or 500)
They're the foundation of governance in Claude Code. Once you've protected critical files, you can add command denial, validation checks, and other layers on top.
Start here. Master this pattern. Then layer on more sophisticated governance as your team grows. The goal isn't to block Claude—it's to create intentional moments of review before sensitive changes happen. It's the difference between "uh oh, we have a problem" and "Claude asked to do that, we reviewed it, we approved it, and here's the audit log."
Your systems are critical. Teach Claude Code to respect those boundaries.
The Hook Ecosystem: Building on File Protection
Once you've mastered file protection hooks, you'll want to layer on other governance patterns. The full ecosystem looks something like this:
Foundation Layer: File Protection
Block writes to critical files before they happen. This is your first line of defense against accidental modifications. Fast, simple, and immediately valuable. Most teams start here.
Command Safety Layer: Bash/Shell Restrictions
Extend protection beyond file writes to restrict dangerous shell commands. You can block rm -rf, git push --force, DROP TABLE, and similar destructive operations. This prevents scripts from causing damage even if they're running with the right intentions.
// Example: Block dangerous commands
export default async function preToolUse({ toolName, toolInput }) {
if (toolName !== "Bash") return { allow: true };
const command = toolInput.command || "";
const dangerousPatterns = [
/rm\s+-rf/, // Recursive deletion
/git\s+push.*force/, // Force push (history rewrite)
/DROP\s+TABLE/i, // Database destruction
/TRUNCATE/i, // Data destruction
/DELETE\s+FROM\s+\*/i, // Bulk delete with no WHERE clause
];
if (dangerousPatterns.some((p) => p.test(command))) {
return {
deny: true,
message: `This command is potentially destructive and blocked for safety.`,
};
}
return { allow: true };
}This layer catches destructive operations that file protection can't. Together, they form a safety net.
Validation Layer: Pre-Commit Checks
After Claude makes changes, validate them before they're committed. Run linters, security scanners, or syntax validators automatically. If validation fails, the changes don't get committed.
// Example: Validate YAML before commit
export default async function postToolUse({ toolInput, toolResult }) {
if (
!toolInput.file_path.endsWith(".yml") &&
!toolInput.file_path.endsWith(".yaml")
) {
return { allow: true };
}
// Try to parse the YAML
try {
YAML.parse(fs.readFileSync(toolInput.file_path, "utf-8"));
return { allow: true };
} catch (err) {
return {
deny: true,
message: `Invalid YAML syntax: ${err.message}`,
};
}
}This layer ensures changed files actually work before they're committed.
Audit Layer: Centralized Logging
All meaningful actions are logged to a centralized audit trail. Who touched what, when, and with what intent. This is non-blocking—it just logs. But the log becomes invaluable for compliance and incident investigation.
Together, these layers create a comprehensive governance system. No single layer solves everything, but together they create a safety net that catches multiple classes of mistakes.
Organizational Adoption: Rolling Out Hooks to Your Team
Rolling out hooks to a large team requires thought. If you're too strict, developers get frustrated. If you're too permissive, governance breaks down. Here's a proven path:
Month 1: Soft Launch
Deploy hooks in log-only mode. They never block, just log. Developers don't even notice they're there. But you're collecting data on what they do. You'll see: "Developers try to edit .env.production about twice a week." "Nobody ever tries to edit terraform/**."
Use this data to calibrate. Are your patterns catching what you expect? Are there unintended categories of files people need to edit?
Month 2: Visibility
Switch to warning mode. Blocks for a few seconds with a clear message about why and how to override. Developers get used to seeing warnings. The warnings have example override keywords. Developers start including those keywords when they really need to edit protected files.
The warnings are educational. They reinforce why files are protected.
Month 3: Enforcement
Full blocking. By now, developers understand the rules. They know how to override when needed. The initial rush of questions has mostly subsided. Most teams run enforcement with way fewer complaints than expected.
Ongoing: Refinement
Review which files are most frequently overridden. If some files are overridden constantly, either:
- The rules are too strict (relax them), or
- Your processes are wrong (developers shouldn't need to edit these constantly)
Adjust based on reality.
Connecting Hooks to Your Security Policies
Hooks aren't just technical controls—they're how you enforce security policy in code. When your security policy says "All *.env files must be immutable after deployment," the hook is how you make that real. When it says "Production config changes require override approval," the hook is how you create intentional friction.
Think of hooks as policy made executable. Your security or architecture team defines policy. Engineers implement hooks. Claude Code respects the policy.
This means hooks are best decided collaboratively. Your infrastructure team should have input—they know what files are really critical. Your security team should have input—they know what threats you're protecting against. Your developers should have input—they know what legitimate editing patterns look like.
The result: hooks that are both strict enough (catching real mistakes) and flexible enough (allowing legitimate work).
Advanced: Conditional Protection Based on Context
You don't always need the same protection level. Development environments can be more permissive. Production more strict. Branch-specific rules can vary too.
export default async function preToolUse(
{ toolName, toolInput },
{ environment, branch, user },
) {
if (toolName !== "Write" && toolName !== "Edit") {
return { allow: true };
}
// Relax rules in development
if (environment === "development") {
return { allow: true };
}
// Stricter rules on main branch
if (branch === "main" && isProtected(toolInput.file_path)) {
return { deny: true, message: "Protected on main branch" };
}
// Users with special role can edit anything
if (user.role === "devops-lead") {
return { allow: true };
}
// Standard rules otherwise
return checkStandardRules(toolInput.file_path);
}This kind of contextual logic lets you be strict where it matters and flexible where it doesn't. Developers working in feature branches get more freedom. Main branch is locked down. DevOps leads can make emergency changes.
Maintenance: Keeping Hooks Fresh
Hooks are code. Code needs maintenance. Your .claude/protected-files.json should evolve as your codebase evolves.
When you create a new sensitive directory, add it to protected patterns. When you promote something from critical to standard, relax the protection. Review your protection rules quarterly—are they still making sense?
Some teams assign a rotating "hook maintenance" responsibility. Each quarter, someone reviews the rules and updates them if needed. It's a small overhead with real benefits.
Final Thought: Protection as Culture
The deepest value of file protection hooks isn't the blocks themselves. It's what they signal about your culture. When developers see that critical files are protected, that edits are audited, and that intentional friction is built in, they understand: "This organization takes security and stability seriously."
That understanding shapes behavior. People become more careful. They think twice before overriding protection. They document their changes better because they know someone will see them.
Good governance creates good behavior. Hooks are one way to make that governance real.
-iNet