HookStack
Back to catalogue
SecurityPreToolUse· Write|Edit

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
// @hookstack pre-write-env-gitignore-guard
// Avertit si un fichier .env créé n'est pas couvert par .gitignore (PreToolUse Write|Edit)
import { existsSync, readFileSync } from "node:fs";
import { basename, dirname, join } from "node:path";
import { fileURLToPath } from "node:url";

// .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