This document describes the deterministic guardrails that prevent private files from being accidentally committed to this public repository.
- Defense-in-Depth Architecture
- Layer 1:
.gitignore - Layer 2: Claude Code PreToolUse Hook
- Layer 3: Git Pre-Commit Hook
- How It Works End-to-End
- Setup for New Contributors
- Testing the Hooks
- Maintenance
Three independent layers protect the private file boundary. Each layer catches different failure modes:
┌─────────────────────────────────────────────────────────────────┐
│ Commit Attempt │
│ │
│ Layer 1: .gitignore │
│ ├─ Prevents `git add .` and `git add -A` from staging │
│ ├─ Does NOT prevent `git add -f <file>` (force flag) │
│ └─ Does NOT prevent `git add <file>` if file was once tracked │
│ │
│ Layer 2: Claude Code PreToolUse Hook │
│ ├─ Intercepts ALL Bash commands before execution │
│ ├─ Blocks `git add` if command references a private path │
│ ├─ Deterministic — cannot be overridden by LLM reasoning │
│ └─ Only applies to Claude Code sessions (not manual git) │
│ │
│ Layer 3: Git Pre-Commit Hook │
│ ├─ Runs on every `git commit` (by anyone) │
│ ├─ Inspects staged files via `git diff --cached --name-only` │
│ ├─ Blocks the commit if any staged file matches a private path │
│ └─ Universal safety net — catches all bypass methods │
└─────────────────────────────────────────────────────────────────┘
| Scenario | Layer 1 | Layer 2 | Layer 3 |
|---|---|---|---|
git add . |
Blocked | — | — |
git add -A |
Blocked | — | — |
Claude runs git add KNOWN_ISSUES.md |
Bypassed | Blocked | Blocked |
Claude runs git add -f KNOWN_ISSUES.md |
Bypassed | Blocked | Blocked |
Human runs git add -f KNOWN_ISSUES.md |
Bypassed | N/A | Blocked |
Human runs git add -f ... && git commit --no-verify |
Bypassed | N/A | Bypassed |
Note:
git commit --no-verifyskips all git hooks. This is the only way to bypass all three layers and requires explicit, deliberate action.
Standard git mechanism. The relevant entries in .gitignore:
# Private — local only
00_Reference/
KNOWN_ISSUES.md
CHANGELOG.md
PRODUCT_VISION.md
03_Analysis/company_dives/
.claude/CLAUDE.md
PHASE_1_5_PLAN.mdScope: Prevents unintentional staging via wildcard commands (git add .,
git add -A). Does not prevent explicit git add <file> or git add -f.
Claude Code supports hooks — shell commands that execute at specific points in the agent lifecycle. A PreToolUse hook runs before a tool is executed and can block it by exiting with code 2.
We register a PreToolUse hook on the Bash tool that inspects every shell
command Claude is about to run. If the command is a git add that references
a private path, the hook blocks execution before it happens.
This is the project-level Claude Code settings file. It is committed to the repository, so every contributor who uses Claude Code gets the hook automatically.
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "python \"$CLAUDE_PROJECT_DIR/.claude/hooks/guard-private-files.py\""
}
]
}
]
}
}Key fields:
| Field | Value | Purpose |
|---|---|---|
matcher |
"Bash" |
Only intercept Bash tool invocations (not Read, Write, etc.) |
type |
"command" |
Run a shell command as the hook |
command |
python "...guard-private-files.py" |
The hook script to execute |
$CLAUDE_PROJECT_DIR |
(auto-resolved) | Points to the project root at runtime |
The hook script. Written in Python (not bash + jq) because jq is not
guaranteed to be available on all development environments.
#!/usr/bin/env python3
"""Claude Code PreToolUse hook: block git-add of private files.
Reads tool invocation JSON from stdin. If the Bash command is a `git add`
that references any private path, exits with code 2 to block the action.
"""
import json
import sys
import re
PRIVATE_PATTERNS = [
"KNOWN_ISSUES.md",
"CHANGELOG.md",
"PRODUCT_VISION.md",
"company_dives",
"00_Reference",
]
def main():
try:
data = json.load(sys.stdin)
command = data.get("tool_input", {}).get("command", "")
except (json.JSONDecodeError, AttributeError):
sys.exit(0)
# Only check git add commands
if not re.match(r"\s*git\s+add", command):
sys.exit(0)
for pattern in PRIVATE_PATTERNS:
if pattern in command:
print(
f"BLOCKED: '{pattern}' is a private file/directory and must not "
f"be staged for commit. See CLAUDE.md privacy rules.",
file=sys.stderr,
)
sys.exit(2)
sys.exit(0)
if __name__ == "__main__":
main()Claude Code sends a JSON object to the hook's stdin with this structure:
{
"tool_name": "Bash",
"tool_input": {
"command": "git add KNOWN_ISSUES.md",
"description": "Stage file for commit"
}
}The hook script:
- Parses JSON from stdin → extracts
tool_input.command - Regex check — if the command does not match
^\s*git\s+add, exits 0 (allow). This ensures non-git commands are never delayed. - Pattern scan — checks if the command string contains any of the 5 private patterns (substring match)
- Decision:
- Match found → print explanation to stderr, exit with code 2 (block)
- No match → exit with code 0 (allow)
| Exit Code | Meaning | Effect |
|---|---|---|
| 0 | Allow | Command executes normally |
| 2 | Block | Command is prevented from executing; stderr message shown to user |
| Other non-zero | Error | Hook is treated as failed; behavior depends on Claude Code version |
A standard git hook that runs
automatically before every git commit. It inspects the staged file list and
aborts the commit if any file matches a private path pattern.
This layer is independent of Claude Code — it protects against manual mistakes by any contributor using any git client.
#!/bin/bash
# Pre-commit hook: block commits containing private files.
# These files are gitignored and local-only. If they appear in staging,
# something bypassed .gitignore (e.g., git add -f).
PRIVATE_PATTERNS=(
"KNOWN_ISSUES.md"
"CHANGELOG.md"
"PRODUCT_VISION.md"
"03_Analysis/company_dives/"
"03_Analysis/NETWORK_FINDINGS.md"
"00_Reference/"
"PHASE_1_5_PLAN.md"
".claude/CLAUDE.md"
)
# Only check Added/Copied/Modified/Renamed — allow deletions (git rm --cached)
STAGED=$(git diff --cached --name-only --diff-filter=ACMR)
for pattern in "${PRIVATE_PATTERNS[@]}"; do
if echo "$STAGED" | grep -q "$pattern"; then
echo "ERROR: Attempted to commit private file matching '$pattern'."
echo "These files are local-only. Remove from staging: git reset HEAD <file>"
exit 1
fi
done
exit 0- Trigger: Git calls this script automatically before creating a commit
- Inspection:
git diff --cached --name-only --diff-filter=ACMRlists staged file paths, excluding deletions. This allowsgit rm --cachedto untrack private files without triggering the hook. - Pattern matching: Each staged path is checked against the private
patterns using
grepsubstring matching - Decision:
- Match found → print error with remediation instructions, exit 1 (abort commit)
- No match → exit 0 (allow commit)
This file lives in .git/hooks/, which is not tracked by git. It exists
only on the local machine where it was created. New contributors must set it up
manually (see Setup for New Contributors).
User: "commit the pipeline changes"
Claude: git add 02_Pipeline/pipeline.py
→ PreToolUse hook fires
→ Parses command: "git add 02_Pipeline/pipeline.py"
→ Matches "git add" regex: YES
→ Contains private pattern? NO
→ Exit 0 (ALLOW)
→ Command executes successfully
Claude: git commit -m "fix: update pipeline stage order"
→ Pre-commit hook fires
→ Staged files: 02_Pipeline/pipeline.py
→ Matches private pattern? NO
→ Exit 0 (ALLOW)
→ Commit succeeds
User: "commit everything"
Claude: git add KNOWN_ISSUES.md
→ PreToolUse hook fires
→ Parses command: "git add KNOWN_ISSUES.md"
→ Matches "git add" regex: YES
→ Contains "KNOWN_ISSUES.md"? YES
→ stderr: "BLOCKED: 'KNOWN_ISSUES.md' is a private file..."
→ Exit 2 (BLOCK)
→ Command NEVER executes
$ git add -f KNOWN_ISSUES.md # Bypasses .gitignore — file is staged
$ git commit -m "oops"
→ Pre-commit hook fires
→ Staged files: KNOWN_ISSUES.md
→ Matches "KNOWN_ISSUES.md"? YES
→ "ERROR: Attempted to commit private file matching 'KNOWN_ISSUES.md'."
→ Exit 1 (ABORT)
→ Commit rejected
$ git reset HEAD KNOWN_ISSUES.md # Remediation
The Claude Code hook is configured in .claude/settings.json, which is
committed to the repository. Any contributor who uses Claude Code in this
project gets the hook automatically — no setup required.
The git pre-commit hook must be installed manually. Run this from the project root:
cp docs/pre-commit .git/hooks/pre-commit
chmod +x .git/hooks/pre-commitFuture improvement: Adopt a pre-commit framework (e.g., pre-commit) to manage git hooks declaratively and install them automatically via
pre-commit install.
From within a Claude Code session, ask Claude to run:
git add KNOWN_ISSUES.mdExpected: Command is blocked. stderr shows:
BLOCKED: 'KNOWN_ISSUES.md' is a private file/directory and must not be staged
for commit. See CLAUDE.md privacy rules.
git add README.mdExpected: Command executes normally.
echo '{"tool_input":{"command":"git add KNOWN_ISSUES.md"}}' | python .claude/hooks/guard-private-files.py
echo "exit code: $?"
# Expected: stderr message, exit code 2
echo '{"tool_input":{"command":"git add README.md"}}' | python .claude/hooks/guard-private-files.py
echo "exit code: $?"
# Expected: no output, exit code 0
echo '{"tool_input":{"command":"python script.py"}}' | python .claude/hooks/guard-private-files.py
echo "exit code: $?"
# Expected: no output, exit code 0 (non-git commands pass through)git add -f KNOWN_ISSUES.md
git commit -m "test"
# Expected: "ERROR: Attempted to commit private file matching 'KNOWN_ISSUES.md'."
# Clean up:
git reset HEAD KNOWN_ISSUES.mdgit add .gitignore
git commit -m "test normal commit"
# Expected: Commit succeedsTo protect a new file or directory:
- Add to
.gitignore— prevents wildcard staging - Add to
.claude/hooks/guard-private-files.py— add the pattern to thePRIVATE_PATTERNSlist - Add to
.git/hooks/pre-commit— add the pattern to thePRIVATE_PATTERNSarray - Commit
.gitignoreand.claude/hooks/guard-private-files.py
Reverse the steps above: remove the pattern from all three locations and commit.
If the Claude Code hook is not firing:
- Verify
.claude/settings.jsonexists and is valid JSON - Verify
pythonis on PATH - Check that
$CLAUDE_PROJECT_DIRresolves correctly (runecho $CLAUDE_PROJECT_DIRin a Claude Code session)
If the git pre-commit hook is not firing:
- Verify
.git/hooks/pre-commitexists and is executable (chmod +x) - Verify it has a valid shebang line (
#!/bin/bash) - Check that git is not invoked with
--no-verify