HookStack
Back to catalogue
SecurityPreToolUse· Write|EditPreToolUseBefore tool execution · can block⚡ blocking

Env file gitignore guard

A new .env can never sneak into a commit unignored

When the agent creates a .env-style file, it checks the nearest .gitignore for a covering rule and warns if the file would be tracked — before any secret lands in it. Skips shared templates (.env.example/.sample). Non-blocking advisory, so it pairs cleanly with hard secret-write guards.

What does the Env file gitignore guard hook do?

Env file gitignore guard is a Claude Code PreToolUse hook matching Write|Edit. It fires automatically at that lifecycle event — outside the model, so it can't be skipped or forgotten. A new .env can never sneak into a commit unignored.

As a PreToolUse hook it runs before the action completes, so it can block or adjust what Claude is about to do. Because it is a deterministic Node.js script, it executes on every matching event without relying on the model to remember — the guarantee that makes agentic workflows safe to automate.

Use cases

  • Secret leak prevention
  • Onboarding safety
  • Compliance

Tags

#security#secrets#gitignore#env#advisory

settings.json fragment

{
  "hooks": {
    "PreToolUse": [
      {
        "hooks": [
          {
            "command": "node $CLAUDE_PROJECT_DIR/.claude/hooks/env-gitignore-guard.mjs",
            "type": "command"
          }
        ],
        "matcher": "Write|Edit"
      }
    ]
  }
}

Script · .claude/hooks/env-gitignore-guard.mjs

#!/usr/bin/env node
// Avertit si un fichier .env créé n'est pas couvert par .gitignore (PreToolUse Write|Edit)
import { readFileSync, existsSync } from 'fs';
import { fileURLToPath } from 'url';
import { dirname, basename, join } from 'path';

// .env, .env.local, .env.production… mais PAS les modèles partagés (.env.example/.sample/.template).
const ENV_FILE = /^\.env(?:\.[A-Za-z0-9_-]+)?$/;
const TEMPLATE = /\.(?:example|sample|template|dist)$/i;

// Une ligne de .gitignore qui couvre les fichiers .env.
const COVERS_ENV = /^\s*\.env(?:\*|\.\*)?\s*$|^\s*\*\.env\s*$/m;

function findGitignore(dir, fileExists, depth = 0) {
  if (depth > 6 || !dir) return null;
  const candidate = join(dir, '.gitignore');
  if (fileExists(candidate)) return candidate;
  const parent = dirname(dir);
  return parent === dir ? null : findGitignore(parent, fileExists, depth + 1);
}

export function run(input, { readFile = readFileSync, fileExists = existsSync } = {}) {
  const filePath = input.tool_input?.file_path ?? '';
  const base = basename(filePath);
  if (!ENV_FILE.test(base) || TEMPLATE.test(base)) return null;

  const gitignore = findGitignore(dirname(filePath), fileExists);
  let covered = false;
  if (gitignore) {
    try { covered = COVERS_ENV.test(readFile(gitignore, 'utf8')); } catch { covered = false; }
  }
  if (covered) return null;

  return {
    message:
      `[env-gitignore] ${base} n'est pas couvert par .gitignore — un secret pourrait être commité. ` +
      'Ajoutez une ligne `.env*` à votre .gitignore avant d\'y mettre des valeurs.\n',
  };
}

/* v8 ignore next 5 */
if (process.argv[1] === fileURLToPath(import.meta.url)) {
  const input = JSON.parse(readFileSync(0, 'utf8'));
  const result = run(input);
  if (result?.message) process.stderr.write(result.message);
}

Learn more

Related hooks