Skip to content

chore: add Claude Code hook to protect .env files#4474

Open
pomfrida wants to merge 2 commits intomainfrom
chore/custom-ai-hook
Open

chore: add Claude Code hook to protect .env files#4474
pomfrida wants to merge 2 commits intomainfrom
chore/custom-ai-hook

Conversation

@pomfrida
Copy link
Collaborator

@pomfrida pomfrida commented Feb 6, 2026

Summary

  • Add a PreToolUse hook that blocks Claude Code from reading .env files, protecting secrets from accidental exposure
  • Move hook scripts into .claude/hooks/ to keep all Claude configuration co-located
  • Document hooks in .claude/README.md with usage guide, available events, and examples for creating custom hooks

Test plan

  • Verified hook blocks Read tool on .env files (exit code 2)
  • Verified hook blocks Grep tool on .env files
  • Verified $CLAUDE_PROJECT_DIR path resolution works after move

🤖 Generated with Claude Code

@pomfrida pomfrida self-assigned this Feb 6, 2026
@pomfrida pomfrida added the ai-tooling AI-assisted development tools, prompts, and workflows (Claude Code, Copilot, Opencode etc.) label Feb 6, 2026
@pomfrida
Copy link
Collaborator Author

pomfrida commented Feb 6, 2026

@claude review

@claude
Copy link

claude bot commented Feb 6, 2026

Claude finished @pomfrida's task in 1m 4s —— View job


Code Review Complete ✓

Todo List

  • Read changed files
  • Review hook implementation (read_hook.js)
  • Review settings configuration (settings.json)
  • Review documentation (README.md)
  • Provide comprehensive feedback

Summary

This PR implements a security-focused hook to protect .env files from accidental exposure, along with comprehensive documentation. The approach is sound, the implementation is clean, and the documentation is excellent. I've identified a few potential improvements below.

Security & Implementation

✅ Strengths

  1. Effective protection: The hook correctly blocks both Read and Grep tools from accessing .env files
  2. Clear error messages: The hook provides helpful feedback when blocked
  3. Proper exit codes: Uses exit code 2 for blocking behavior
  4. Good structure: Moving hooks to .claude/hooks/ keeps configuration organized

🔍 Potential Issues

1. Pattern matching could be more robust (.claude/hooks/read_hook.js:12)

if (readPath.includes('.env')) {

This will catch legitimate files like development.ts or preview.tsx that happen to contain the substring .env. Consider:

const basename = readPath.split('/').pop() || ''
if (basename === '.env' || basename.startsWith('.env.')) {

This would match .env, .env.local, .env.development, etc., but not deployment.ts.

Fix this →

2. Missing error handling for JSON parsing (.claude/hooks/read_hook.js:7)

const toolArgs = JSON.parse(Buffer.concat(chunks).toString())

If stdin provides malformed JSON, this will crash with an uncaught exception. Consider wrapping in a try-catch:

let toolArgs
try {
  toolArgs = JSON.parse(Buffer.concat(chunks).toString())
} catch (e) {
  console.error('Failed to parse hook input:', e.message)
  process.exit(1)
}

Fix this →

3. Hook doesn't handle Glob tool (.claude/settings.json:5)

The matcher only includes Read|Grep, but the Glob tool could also be used to list .env files. While Glob wouldn't expose the contents, it could reveal the file's existence. Consider if this is intentional or if the matcher should be Read|Grep|Glob.

Documentation

✅ Strengths

The .claude/README.md documentation is outstanding:

  1. Comprehensive coverage: Explains hooks, events, exit codes, and input formats thoroughly
  2. Excellent examples: The custom hook examples are practical and well-commented
  3. Clear structure: Good use of tables, code blocks, and hierarchical organization
  4. Helpful comparisons: The OpenCode comparison section is valuable context
  5. Actionable tips: The tips section provides practical guidance

💡 Minor Suggestions

1. Add testing guidance for hooks (.claude/README.md:252)

After line 252 in the "Tips" section, consider adding guidance on testing hooks:

- Test hooks locally by running them with sample JSON: `echo '{"tool_input":{"file_path":".env"}}' | node .claude/hooks/read_hook.js`

2. Consider documenting hook limitations (.claude/README.md:120-122)

The documentation could mention that hooks only protect against tool usage, not against Claude suggesting the file contents from memory or discussing them conceptually. This sets appropriate security expectations.

Configuration

✅ Strengths

The .claude/settings.json configuration is clean and follows best practices:

  1. Uses $CLAUDE_PROJECT_DIR for portable paths
  2. Clear structure with proper nesting
  3. Well-formatted JSON

Testing

The test plan shows manual verification, which is appropriate for this type of change. Consider whether automated tests for hooks are feasible in your CI/CD pipeline.

Overall Assessment

This is a well-implemented feature with excellent documentation. The primary concerns are:

  1. The pattern matching logic could produce false positives
  2. Missing error handling could cause unexpected behavior
  3. Consider whether Glob should be included in the matcher

The documentation alone makes this PR valuable - it provides a solid foundation for future hook development in the project.

Recommendations

  • Priority: Fix the pattern matching to avoid false positives with files containing .env substring
  • Nice to have: Add error handling for JSON parsing
  • Consider: Whether Glob should be included in the security boundary

@claude
Copy link

claude bot commented Feb 6, 2026

Code Review

I've identified 2 security issues with the .env protection hook:

Issue 1: Bash tool bypass

The hook matcher only covers Read and Grep tools, but not the Bash tool. This means .env files can still be read via commands like cat .env, completely bypassing the protection.

Reference:

{
"matcher": "Read|Grep",
"hooks": [

Suggested fix: Add Bash to the matcher and update the hook script to check the command field for .env references.

Issue 2: Grep directory search bypass

The Grep tool can expose .env file contents when searching directories. The hook only checks if the path itself contains .env, but when Grep searches a directory like . or /project, it includes .env files in results.

Reference:

const readPath =
toolArgs.tool_input?.file_path || toolArgs.tool_input?.path || ''
if (readPath.includes('.env')) {
console.error('You cannot read the .env file')
process.exit(2)
}

Suggested fix: For Grep invocations, check if performing a directory search and either block it or exclude .env files via glob patterns.

@pomfrida
Copy link
Collaborator Author

pomfrida commented Feb 6, 2026

@claude can you review again after I added your suggestions

@claude
Copy link

claude bot commented Feb 6, 2026

Claude finished @pomfrida's task in 1m 18s —— View job


Code Review ✓

Todo List

  • Read changed files to see updates
  • Review hook implementation changes
  • Verify previous suggestions were addressed
  • Check for security issues
  • Provide comprehensive feedback

Summary

Excellent work! You've addressed all the major concerns from the previous review. The hook implementation is now significantly more robust with improved pattern matching, error handling, and comprehensive tool coverage. The documentation remains outstanding.


Previous Suggestions - Status

✅ Addressed Successfully

  1. Pattern matching improvement (.claude/hooks/read_hook.js:31-36)

    • ✓ Now uses basename === '.env' || basename.startsWith('.env.') instead of .includes('.env')
    • ✓ Eliminates false positives from files like development.ts or preview.tsx
    • Perfect implementation
  2. Error handling for JSON parsing (.claude/hooks/read_hook.js:7-13)

    • ✓ Added try-catch block around JSON.parse()
    • ✓ Provides clear error message on stderr
    • ✓ Exits with code 1 for parsing failures
    • Exactly as suggested
  3. Bash tool bypass (.claude/hooks/read_hook.js:18-26, .claude/settings.json:5)

    • ✓ Added Bash to the matcher regex
    • ✓ Hook now checks toolInput.command for .env references
    • ✓ Uses regex /\.\benv\b/ to match .env with word boundaries
    • Critical security improvement
  4. Glob tool coverage (.claude/hooks/read_hook.js:38-45, .claude/settings.json:5)

    • ✓ Added Glob to the matcher regex
    • ✓ Hook checks the pattern field for .env targeting
    • ✓ Prevents glob patterns like **/.env* from listing secrets
    • Another important security enhancement
  5. Grep directory search bypass (.claude/hooks/read_hook.js:47-54)

    • ✓ Added pattern checking for Grep tool
    • ✓ Blocks grep patterns that target .env files
    • ✓ Uses same word boundary regex as Bash check
    • Addresses the directory search concern

Security Analysis

🔒 Protection Coverage

The hook now provides defense-in-depth across all major attack vectors:

Tool Protection Coverage
Read Basename check ✓ Direct file access blocked
Grep Path + pattern check ✓ Directory searches & patterns blocked
Glob Path + pattern check ✓ Wildcard patterns blocked
Bash Command regex ✓ Shell commands blocked

⚠️ Remaining Considerations

While the implementation is solid, here are some edge cases to be aware of:

1. Bash regex could be more precise (.claude/hooks/read_hook.js:21)

The regex /\.\benv\b/ will match:

  • .env (correct)
  • cat .env (correct)
  • .environment (false positive - shouldn't match due to word boundary)
  • process.env (false positive - not a file reference)

The word boundary \b after env helps, but process.env is a common JavaScript pattern that would be blocked. Consider if this is acceptable or if you need to refine the pattern further. For example:

// More restrictive: only match .env as filename
if (/(?:^|[^\w])\.env(?:[.\s]|$)/.test(command)) {

This would match .env when it appears as a standalone filename but not in process.env.

2. Symlinks and relative paths (.claude/hooks/read_hook.js:31)

The basename approach works for direct paths but doesn't resolve symlinks or relative paths like ../../.env. However, this is likely acceptable since:

  • The hook checks the requested path, not the resolved path
  • Users who create symlinks to bypass security are acting intentionally
  • Adding symlink resolution would add complexity and may not be worth it

3. Base64 or encoded filenames (theoretical)

If someone tries Read with a path like LmVudg== (base64 for .env), the hook won't catch it. However:

  • Claude Code tools don't accept base64-encoded paths
  • This would require modifying the tool invocation at a lower level
  • Out of scope for a PreToolUse hook

4. Environment variable access (not in scope)

This hook protects .env files, but Claude can still access environment variables through other means (e.g., Bash commands that echo $ENV_VAR). This is likely intentional since:

  • Environment variables are meant to be accessible to running processes
  • Blocking all environment access would break legitimate workflows
  • .env files specifically contain the source of truth for secrets

Implementation Quality

✅ Strengths

  1. Clean structure: Each tool has its own check with clear logic flow
  2. Consistent error messages: All blocks provide helpful feedback
  3. Proper early return: Bash check returns early to avoid unnecessary processing
  4. Flexible path handling: Checks both file_path and path parameters
  5. Pattern validation: Both basename and pattern fields are checked where relevant

💡 Minor Refinement Suggestion

Consider extracting the .env check into a helper function to reduce duplication:

function isEnvFile(filename) {
  return filename === '.env' || filename.startsWith('.env.')
}

function containsEnvReference(text) {
  return /\.\benv\b/.test(text)
}

This would make the logic more reusable and easier to test. However, the current implementation is clear enough that this is purely a nice-to-have.


Documentation Quality

The .claude/README.md documentation is exceptional:

  • ✓ Comprehensive hooks section with clear examples
  • ✓ Complete table of available hook events
  • ✓ Excellent "Creating a Custom Hook" guide
  • ✓ Helpful tips section
  • ✓ Hook input format clearly documented
  • ✓ Current hooks table shows the exact configuration

No changes needed to documentation - it's production-ready.


Test Coverage

The test plan shows manual verification of:

  • ✓ Read tool blocking
  • ✓ Grep tool blocking
  • ✓ Path resolution with $CLAUDE_PROJECT_DIR

Consider adding verification for:

  • Bash tool blocking (e.g., cat .env)
  • Glob tool blocking (e.g., **/.env*)
  • Pattern-based Grep blocking (e.g., grep with pattern containing .env)

These can be tested by asking Claude Code to perform these actions and verifying they're blocked.


Overall Assessment

This PR is ready to merge from a technical perspective. The implementation is:

  • ✅ Secure: Covers all major tool-based access vectors
  • ✅ Robust: Handles errors gracefully
  • ✅ Well-documented: Excellent README with examples
  • ✅ Maintainable: Clear code structure

Recommendations

Before merging:

  1. Test the new Bash and Glob protections manually
  2. Consider whether process.env false positives are acceptable
  3. Optionally refactor to helper functions for better testability

After merging:
4. Monitor for any legitimate workflows that get blocked
5. Consider adding automated tests for hook behavior in CI


Great work on implementing all the suggestions! The security posture of this repository is significantly improved.

"hooks": {
"PreToolUse": [
{
"matcher": "Read|Grep",
Copy link
Collaborator

Choose a reason for hiding this comment

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

Many ways to trick the system, so this security thing is a difficult one! I tried to ask claude to edit the .env file (and to edit you must first know what the file contains aka read it), so if its ever been looked at before this hook is active claude will know the contents and be able to edit. So to be even more secure I would suggest to add these aswell:

"matcher": "Read|Grep|Edit|Write|NotebookEdit",

Comment on lines 9 to 16
const readPath =
toolArgs.tool_input?.file_path || toolArgs.tool_input?.path || ''

if (readPath.includes('.env')) {
console.error('You cannot read the .env file')
process.exit(2)
}
}
Copy link
Collaborator

@millus millus Feb 6, 2026

Choose a reason for hiding this comment

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

After testing it out with the edit route to trick claude to show me .env content in the course project, he suggested these improvements:

async function main() {
  const chunks = [];
  for await (const chunk of process.stdin) {
    chunks.push(chunk);
  }
  const toolArgs = JSON.parse(Buffer.concat(chunks).toString());

  // Get the file path from various tool input structures
  const readPath =
    toolArgs.tool_input?.file_path ||
    toolArgs.tool_input?.path ||
    toolArgs.tool_input?.notebook_path ||
    "";

  // List of sensitive file patterns to block
  const blockedPatterns = [
    ".env",
    ".env.local",
    ".env.production",
    ".env.development",
    "credentials",
    "secrets",
    "private_key",
    "id_rsa",
    ".pem",
    ".key",
  ];

  // Check if the path contains any blocked patterns
  const isBlocked = blockedPatterns.some((pattern) =>
    readPath.toLowerCase().includes(pattern.toLowerCase()),
  );

  if (isBlocked) {
    console.error(
      `🚫 Blocked: Claude is trying to access a sensitive file: ${readPath}`,
    );
    console.error("This file may contain secrets, API keys, or credentials.");
    process.exit(2);
  }

  // Hook passes - allow the file access
  // (Logging removed to reduce noise - only block attempts are logged)
}

main();

`
Maybe you could see if this will be a good fit for this repo as well @claude?

Copy link

@claude claude bot Feb 6, 2026

Choose a reason for hiding this comment

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

Claude finished @millus's task in 1m 4s —— View job


Security Hook Analysis & Recommendations

Todo List

  • Read current hook implementation
  • Analyze suggested changes
  • Evaluate security vs usability tradeoffs
  • Provide recommendations

Great catch on the Edit/Write bypass, @millus! You're absolutely right that the current implementation has a significant gap. Let me break down the suggested changes:

Suggested Changes Analysis

1. ✅ Add Edit/Write/NotebookEdit to matcher (CRITICAL)

Current state: Matcher only covers Read|Grep|Glob|Bash

The bypass: If Claude has seen .env contents before the hook was active (or remembers from conversation history), it can be asked to "edit" the file, which bypasses the Read protection entirely.

Recommendation: Strongly recommend adding these tools

"matcher": "Read|Grep|Glob|Bash|Edit|Write|NotebookEdit"

Why: This closes a major security hole. Without this, the protection is easily circumvented by using write-based tools instead of read-based tools.

Implementation note: The hook would need to check toolInput.file_path and toolInput.notebook_path for these tools, which the suggested code already does.


2. ⚠️ Expand blocked patterns (TRADEOFF REQUIRED)

Suggested patterns:

const blockedPatterns = [
  ".env",
  ".env.local", 
  ".env.production",
  ".env.development",
  "credentials",
  "secrets",
  "private_key",
  "id_rsa",
  ".pem",
  ".key",
]

Pros:

  • Broader protection beyond just .env files
  • Catches common credential file patterns
  • Defense in depth

Cons:

  • High false positive risk: Blocking "credentials" would block files like:
    • src/utils/credentials.ts (legitimate code)
    • docs/credentials-guide.md (documentation)
    • tests/credentials.test.ts (test files)
  • Same issue with "secrets", "key", etc.
  • Could significantly impact developer workflow

Recommendation: Use a more targeted approach

Instead of substring matching on generic terms, I'd suggest:

const blockedPatterns = [
  /^\.env($|\.)/,           // .env, .env.local, etc.
  /^id_rsa($|\.)/,          // SSH private keys
  /\.pem$/,                 // Certificate files
  /^\.?private[_-]key/,     // private_key, .private-key, etc.
  /credentials\.json$/,     // Specific credential files
  /secrets\.json$/,         // Specific secret files
]

This approach:

  • Uses regex patterns for precise matching
  • Targets specific file naming conventions
  • Minimizes false positives for legitimate code files
  • Still catches the most dangerous patterns

3. ⚠️ Simplify to .includes() check (REGRESSION)

Current approach: Uses basename matching

const basename = filePath.split('/').pop() || ''
if (basename === '.env' || basename.startsWith('.env.')) {
  // block
}

Suggested approach: Use .includes()

const isBlocked = blockedPatterns.some((pattern) =>
  readPath.toLowerCase().includes(pattern.toLowerCase()),
)

Problem: This is actually a regression from the improvements in this PR!

The previous review specifically called out that .includes('.env') causes false positives:

  • development.ts ❌ (contains ".env")
  • preview.tsx ❌ (contains ".env")
  • deployment/config.js ❌ (contains ".env")

Recommendation: Keep the current basename approach for .env files, but combine it with regex patterns for other sensitive files:

// Check basename for .env files (most precise)
const basename = filePath.split('/').pop() || ''
if (basename === '.env' || basename.startsWith('.env.')) {
  console.error('Blocked: cannot access .env files')
  process.exit(2)
}

// Check against other sensitive patterns
const sensitivePatterns = [
  /^id_rsa($|\.)/,
  /\.pem$/,
  /^\.?private[_-]key/,
  /credentials\.json$/,
  /secrets\.json$/,
]

if (sensitivePatterns.some(pattern => pattern.test(basename))) {
  console.error('Blocked: cannot access sensitive credential files')
  process.exit(2)
}

Final Recommendations

Must implement (closes critical gap):

  1. ✅ Add Edit|Write|NotebookEdit to the matcher
  2. ✅ Update hook to check these tools for file_path/notebook_path

Should consider (defense in depth with low false positives):
3. ✅ Add targeted regex patterns for common credential files (SSH keys, PEM files, specific JSON files)

Should NOT implement (too many false positives):
4. ❌ Generic substring matching on terms like "credentials", "secrets", "key"
5. ❌ Reverting to .includes() for .env detection


Proposed Implementation

For this design system repo specifically, I'd recommend:

const basename = filePath.split('/').pop() || ''

// Block .env files
if (basename === '.env' || basename.startsWith('.env.')) {
  console.error('Blocked: cannot access .env files')
  process.exit(2)
}

// Block common credential file patterns
const credentialPatterns = [
  /^id_rsa($|\.pub)?$/,     // SSH keys
  /\.pem$/,                 // SSL certificates  
  /\.key$/,                 // Private key files
  /^\.?credentials\.json$/, // Credential config
  /^\.?secrets\.json$/,     // Secrets config
]

if (credentialPatterns.some(p => p.test(basename))) {
  console.error('Blocked: cannot access credential/certificate files')
  process.exit(2)
}

This balances security with usability - it protects the most sensitive files while allowing normal development workflows on files that just happen to contain keywords like "authentication" or "authorization" in their names.

Would you like me to implement these changes to close the Edit/Write gap?


├── commands/ # Slash commands
│ └── new-component.md # /new-component <Name>
├── hooks/ # Custom hook scripts
│ └── read_hook.js # Blocks reading of .env files
Copy link
Collaborator

Choose a reason for hiding this comment

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

Really nice and important addition to our project! 👏

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ai-tooling AI-assisted development tools, prompts, and workflows (Claude Code, Copilot, Opencode etc.)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants