|
| 1 | +#!/usr/bin/env node |
| 2 | +// Claude Code PreToolUse hook — private-name guard. |
| 3 | +// |
| 4 | +// Never blocks. On every Bash command that would publish text to a public |
| 5 | +// Git/GitHub surface (git commit, git push, gh pr/issue/api/release write), |
| 6 | +// writes a short reminder to stderr so the model re-reads the command with |
| 7 | +// the rule freshly in mind: |
| 8 | +// |
| 9 | +// No private repos or internal project names in public surfaces. |
| 10 | +// Omit the reference entirely — don't substitute a placeholder. |
| 11 | +// |
| 12 | +// Exit code is always 0. This is attention priming, not enforcement. The |
| 13 | +// model is responsible for applying the rule — the hook just makes sure |
| 14 | +// the rule is in the active context at the moment the command is about |
| 15 | +// to fire. |
| 16 | +// |
| 17 | +// Deliberately carries no enumerated denylist. Recognition and replacement |
| 18 | +// happen at write time, not via a list of names. A denylist is itself a |
| 19 | +// leak — a file named `private-projects.txt` would be the very thing it |
| 20 | +// tries to prevent. |
| 21 | +// |
| 22 | +// Reads a Claude Code PreToolUse JSON payload from stdin: |
| 23 | +// { "tool_name": "Bash", "tool_input": { "command": "..." } } |
| 24 | + |
| 25 | +import { readFileSync } from 'node:fs' |
| 26 | + |
| 27 | +type ToolInput = { |
| 28 | + tool_name?: string |
| 29 | + tool_input?: { |
| 30 | + command?: string |
| 31 | + } |
| 32 | +} |
| 33 | + |
| 34 | +// Commands that can publish content outside the local machine. |
| 35 | +// Keep broad — better to remind on an extra read than miss a write. |
| 36 | +const PUBLIC_SURFACE_PATTERNS: RegExp[] = [ |
| 37 | + /\bgit\s+commit\b/, |
| 38 | + /\bgit\s+push\b/, |
| 39 | + /\bgh\s+pr\s+(create|edit|comment|review)\b/, |
| 40 | + /\bgh\s+issue\s+(create|edit|comment)\b/, |
| 41 | + /\bgh\s+api\b[^|]*-X\s*(POST|PATCH|PUT)\b/i, |
| 42 | + /\bgh\s+release\s+(create|edit)\b/, |
| 43 | +] |
| 44 | + |
| 45 | +function isPublicSurface(command: string): boolean { |
| 46 | + const normalized = command.replace(/\s+/g, ' ') |
| 47 | + return PUBLIC_SURFACE_PATTERNS.some(re => re.test(normalized)) |
| 48 | +} |
| 49 | + |
| 50 | +function main(): void { |
| 51 | + let raw = '' |
| 52 | + try { |
| 53 | + raw = readFileSync(0, 'utf8') |
| 54 | + } catch { |
| 55 | + return |
| 56 | + } |
| 57 | + |
| 58 | + let input: ToolInput |
| 59 | + try { |
| 60 | + input = JSON.parse(raw) |
| 61 | + } catch { |
| 62 | + return |
| 63 | + } |
| 64 | + |
| 65 | + if (input.tool_name !== 'Bash') { |
| 66 | + return |
| 67 | + } |
| 68 | + const command = input.tool_input?.command |
| 69 | + if (!command || typeof command !== 'string') { |
| 70 | + return |
| 71 | + } |
| 72 | + if (!isPublicSurface(command)) { |
| 73 | + return |
| 74 | + } |
| 75 | + |
| 76 | + const lines = [ |
| 77 | + '[private-name-guard] This command writes to a public Git/GitHub surface.', |
| 78 | + ' • Re-read the commit message / PR body / comment BEFORE it sends.', |
| 79 | + ' • No private repo names. No internal project codenames. No unreleased', |
| 80 | + ' product names. No internal-only tooling repos absent from the public', |
| 81 | + ' org page. No customer/partner names.', |
| 82 | + ' • Omit the reference entirely. Do not substitute a placeholder — the', |
| 83 | + ' placeholder itself is a tell.', |
| 84 | + ' • If you spot one, cancel and rewrite the text first.', |
| 85 | + ] |
| 86 | + process.stderr.write(lines.join('\n') + '\n') |
| 87 | +} |
| 88 | + |
| 89 | +main() |
0 commit comments