|
| 1 | +#!/usr/bin/env node |
| 2 | +// Claude Code PreToolUse hook — public-surface reminder. |
| 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 two rules freshly in mind: |
| 8 | +// |
| 9 | +// 1. No real customer/company names — ever. Use `Acme Inc` instead. |
| 10 | +// 2. No internal work-item IDs or tracker URLs — no `SOC-123`, `ENG-456`, |
| 11 | +// `ASK-789`, `linear.app`, `sentry.io`, etc. |
| 12 | +// |
| 13 | +// Exit code is always 0. This is attention priming, not enforcement. The |
| 14 | +// model is responsible for actually applying the rule — the hook just makes |
| 15 | +// sure the rule is in the active context at the moment the command is about |
| 16 | +// to fire. |
| 17 | +// |
| 18 | +// Deliberately carries no list of customer names. Recognition and |
| 19 | +// replacement happen at write time, not via enumeration. |
| 20 | +// |
| 21 | +// Reads a Claude Code PreToolUse JSON payload from stdin: |
| 22 | +// { "tool_name": "Bash", "tool_input": { "command": "..." } } |
| 23 | + |
| 24 | +import { readFileSync } from 'node:fs' |
| 25 | + |
| 26 | +type ToolInput = { |
| 27 | + tool_name?: string |
| 28 | + tool_input?: { |
| 29 | + command?: string |
| 30 | + } |
| 31 | +} |
| 32 | + |
| 33 | +// Commands that can publish content outside the local machine. |
| 34 | +// Keep broad — better to remind on an extra read than miss a write. |
| 35 | +const PUBLIC_SURFACE_PATTERNS: RegExp[] = [ |
| 36 | + /\bgit\s+commit\b/, |
| 37 | + /\bgit\s+push\b/, |
| 38 | + /\bgh\s+pr\s+(create|edit|comment|review)\b/, |
| 39 | + /\bgh\s+issue\s+(create|edit|comment)\b/, |
| 40 | + /\bgh\s+api\b[^|]*-X\s*(POST|PATCH|PUT)\b/i, |
| 41 | + /\bgh\s+release\s+(create|edit)\b/, |
| 42 | +] |
| 43 | + |
| 44 | +function isPublicSurface(command: string): boolean { |
| 45 | + const normalized = command.replace(/\s+/g, ' ') |
| 46 | + return PUBLIC_SURFACE_PATTERNS.some(re => re.test(normalized)) |
| 47 | +} |
| 48 | + |
| 49 | +function main(): void { |
| 50 | + let raw = '' |
| 51 | + try { |
| 52 | + raw = readFileSync(0, 'utf8') |
| 53 | + } catch { |
| 54 | + return |
| 55 | + } |
| 56 | + |
| 57 | + let input: ToolInput |
| 58 | + try { |
| 59 | + input = JSON.parse(raw) |
| 60 | + } catch { |
| 61 | + return |
| 62 | + } |
| 63 | + |
| 64 | + if (input.tool_name !== 'Bash') { |
| 65 | + return |
| 66 | + } |
| 67 | + const command = input.tool_input?.command |
| 68 | + if (!command || typeof command !== 'string') { |
| 69 | + return |
| 70 | + } |
| 71 | + if (!isPublicSurface(command)) { |
| 72 | + return |
| 73 | + } |
| 74 | + |
| 75 | + const lines = [ |
| 76 | + '[public-surface-reminder] This command writes to a public Git/GitHub surface.', |
| 77 | + ' • Re-read the commit message / PR body / comment BEFORE it sends.', |
| 78 | + ' • No real customer or company names — use `Acme Inc`. No exceptions.', |
| 79 | + ' • No internal work-item IDs or tracker URLs (linear.app, sentry.io, SOC-/ENG-/ASK-/etc.).', |
| 80 | + ' • If you spot one, cancel and rewrite the text first.', |
| 81 | + ] |
| 82 | + process.stderr.write(lines.join('\n') + '\n') |
| 83 | +} |
| 84 | + |
| 85 | +main() |
0 commit comments