Skip to content

Commit 50a1ed2

Browse files
committed
chore(claude): add private-name-guard hook + rule
Sibling to public-surface-reminder. Fires on the same set of public- surface bash commands (git commit, git push, gh pr/issue/api/release write) and prints a stderr nudge to omit any private repo or internal project name. Never blocks; pure attention priming. CLAUDE.md grows a corresponding rule. The hook is the priming nudge; the rule applies even when the hook isn't installed.
1 parent 07c3241 commit 50a1ed2

5 files changed

Lines changed: 166 additions & 1 deletion

File tree

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
# private-name-guard
2+
3+
`PreToolUse` hook that **never blocks**. On every `Bash` command that
4+
would publish text to a public Git/GitHub surface, writes a short
5+
reminder to stderr so the model re-reads the command with the rule
6+
freshly in mind:
7+
8+
> No private repos or internal project names in public surfaces. Omit
9+
> the reference entirely — don't substitute a placeholder. The
10+
> placeholder itself is a tell.
11+
12+
Attention priming, not enforcement. The model is responsible for
13+
applying the rule — the hook just ensures the rule is in the active
14+
context at the moment the command is about to fire.
15+
16+
Sibling to `public-surface-reminder`, which covers customer/company
17+
names and internal work-item IDs. The two hooks compose: both fire on
18+
the same public-surface commands, each priming a distinct slice of the
19+
rule set.
20+
21+
## What counts as "public surface"
22+
23+
- `git commit` (including `--amend`)
24+
- `git push`
25+
- `gh pr (create|edit|comment|review)`
26+
- `gh issue (create|edit|comment)`
27+
- `gh api -X POST|PATCH|PUT`
28+
- `gh release (create|edit)`
29+
30+
Any other `Bash` command passes through silently.
31+
32+
## Why no denylist
33+
34+
Because a denylist is itself a leak. A file named `private-projects.txt`
35+
that enumerates "these are our internal repos" is worse than no list at
36+
all — anyone who finds it gets the org's full internal map for free.
37+
Recognition happens at write time, every time, by the model reading the
38+
text it's about to send. The hook just makes sure that read happens.
39+
40+
## Wiring
41+
42+
`.claude/settings.json`:
43+
44+
```json
45+
{
46+
"hooks": {
47+
"PreToolUse": [
48+
{
49+
"matcher": "Bash",
50+
"hooks": [{ "type": "command", "command": "node .claude/hooks/private-name-guard/index.mts" }]
51+
}
52+
]
53+
}
54+
}
55+
```
56+
57+
## Exit code
58+
59+
Always `0`. The hook never blocks; it only prints to stderr.
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
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()
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
{
2+
"name": "hook-private-name-guard",
3+
"private": true,
4+
"type": "module",
5+
"main": "./index.mts",
6+
"exports": {
7+
".": "./index.mts"
8+
},
9+
"devDependencies": {
10+
"@types/node": "24.9.2"
11+
}
12+
}

.claude/settings.json

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,11 +19,15 @@
1919
"hooks": [
2020
{
2121
"type": "command",
22-
"command": "node .claude/hooks/token-guard/index.mts"
22+
"command": "node .claude/hooks/private-name-guard/index.mts"
2323
},
2424
{
2525
"type": "command",
2626
"command": "node .claude/hooks/public-surface-reminder/index.mts"
27+
},
28+
{
29+
"type": "command",
30+
"command": "node .claude/hooks/token-guard/index.mts"
2731
}
2832
]
2933
}

CLAUDE.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -144,6 +144,7 @@ See `docs/references/error-messages.md` for worked examples and anti-patterns.
144144
- Forbidden to create docs unless requested
145145
- 🚨 **NEVER use `npx`, `pnpm dlx`, or `yarn dlx`** — use `pnpm exec` or `pnpm run` # zizmor: documentation-prohibition
146146
- **minimumReleaseAge**: NEVER add packages to `minimumReleaseAgeExclude` in CI. Locally, ASK before adding — the age threshold is a security control.
147+
- 🚨 **NEVER mention private repos or internal project names** in commits, PR titles/descriptions/comments, issues, release notes, or any public-surface text. Internal codenames, unreleased product names, internal tooling repo names not on the public org page, customer names, partner names — none belong in public surfaces. **Omit the reference entirely.** Don't substitute a placeholder ("an internal tool", "a downstream consumer", etc.) — the placeholder itself is a tell that something is being elided. Rewrite the sentence to not need the reference at all. The `.claude/hooks/private-name-guard` hook re-prints this rule on every public-surface `git`/`gh` command as a priming nudge; the rule applies even when the hook isn't installed.
147148

148149
## DOCUMENTATION POLICY
149150

0 commit comments

Comments
 (0)