Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions .claude/hooks/public-surface-reminder/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
# public-surface-reminder

`PreToolUse` hook that **never blocks**. On every `Bash` command that would
publish text to a public Git/GitHub surface, writes a short reminder to
stderr so the model re-reads the command with the two rules freshly in
mind:

1. **No real customer or company names.** Use `Acme Inc`. No exceptions.
2. **No internal work-item IDs or tracker URLs.** No `SOC-123` /
`ENG-456` / `ASK-789` / similar, no `linear.app` / `sentry.io` URLs.

Attention priming, not enforcement. The model is responsible for actually
applying the rule — the hook just ensures the rule is in the active
context at the moment the command is about to fire.

## What counts as "public surface"

- `git commit` (including `--amend`)
- `git push`
- `gh pr (create|edit|comment|review)`
- `gh issue (create|edit|comment)`
- `gh api -X POST|PATCH|PUT`
- `gh release (create|edit)`

Any other `Bash` command passes through silently.

## Why no denylist

Because a denylist is itself a customer leak. A file named
`customers.txt` that enumerates "these are our customers" is worse than
the bug it tries to prevent. Recognition and replacement happen at write
time, done by the model, every time.

## Exit code

Always `0`. The hook prints a reminder and steps aside.
85 changes: 85 additions & 0 deletions .claude/hooks/public-surface-reminder/index.mts
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
#!/usr/bin/env node
// Claude Code PreToolUse hook — public-surface reminder.
//
// Never blocks. On every Bash command that would publish text to a public
// Git/GitHub surface (git commit, git push, gh pr/issue/api/release write),
// writes a short reminder to stderr so the model re-reads the command with
// the two rules freshly in mind:
//
// 1. No real customer/company names — ever. Use `Acme Inc` instead.
// 2. No internal work-item IDs or tracker URLs — no `SOC-123`, `ENG-456`,
// `ASK-789`, `linear.app`, `sentry.io`, etc.
//
// Exit code is always 0. This is attention priming, not enforcement. The
// model is responsible for actually applying the rule — the hook just makes
// sure the rule is in the active context at the moment the command is about
// to fire.
Comment on lines +13 to +16
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

John-David Dalton (@jdalton) I think stderr is only read into Claude's context on exit code 2, which would block the tool call.

Looking through the hook docs it doesn't mention reading stderr on exit code 0.

I had claude run a quick test, the hook does fire (stream shows hook_started + hook_response with the reminder on stderr, exit_code 0), but the subsequent tool_result sent to the model contains only the Bash stdout.

//
// Deliberately carries no list of customer names. Recognition and
// replacement happen at write time, not via enumeration.
//
// Reads a Claude Code PreToolUse JSON payload from stdin:
// { "tool_name": "Bash", "tool_input": { "command": "..." } }

import { readFileSync } from 'node:fs'

type ToolInput = {
tool_name?: string
tool_input?: {
command?: string
}
}

// Commands that can publish content outside the local machine.
// Keep broad — better to remind on an extra read than miss a write.
const PUBLIC_SURFACE_PATTERNS: RegExp[] = [
/\bgit\s+commit\b/,
/\bgit\s+push\b/,
/\bgh\s+pr\s+(create|edit|comment|review)\b/,
/\bgh\s+issue\s+(create|edit|comment)\b/,
/\bgh\s+api\b[^|]*-X\s*(POST|PATCH|PUT)\b/i,
/\bgh\s+release\s+(create|edit)\b/,
]

function isPublicSurface(command: string): boolean {
const normalized = command.replace(/\s+/g, ' ')
return PUBLIC_SURFACE_PATTERNS.some(re => re.test(normalized))
}

function main(): void {
let raw = ''
try {
raw = readFileSync(0, 'utf8')
} catch {
return
}

let input: ToolInput
try {
input = JSON.parse(raw)
} catch {
return
}

if (input.tool_name !== 'Bash') {
return
}
const command = input.tool_input?.command
if (!command || typeof command !== 'string') {
return
}
if (!isPublicSurface(command)) {
return
}

const lines = [
'[public-surface-reminder] This command writes to a public Git/GitHub surface.',
' • Re-read the commit message / PR body / comment BEFORE it sends.',
' • No real customer or company names — use `Acme Inc`. No exceptions.',
' • No internal work-item IDs or tracker URLs (linear.app, sentry.io, SOC-/ENG-/ASK-/etc.).',
' • If you spot one, cancel and rewrite the text first.',
]
process.stderr.write(lines.join('\n') + '\n')
}

main()
12 changes: 12 additions & 0 deletions .claude/hooks/public-surface-reminder/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
{
"name": "@socketsecurity/hook-public-surface-reminder",
"private": true,
"type": "module",
"main": "./index.mts",
"exports": {
".": "./index.mts"
},
"devDependencies": {
"@types/node": "24.9.2"
}
}
9 changes: 9 additions & 0 deletions .claude/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,15 @@
"command": "node .claude/hooks/check-new-deps/index.mts"
}
]
},
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "node .claude/hooks/public-surface-reminder/index.mts"
}
]
}
]
}
Expand Down
3 changes: 3 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,8 @@ The umbrella rule: never run a git command that mutates state belonging to a pat
- Never create files unless necessary; always prefer editing existing files
- Forbidden to create docs unless requested
- 🚨 **NEVER use `npx`, `pnpm dlx`, or `yarn dlx`** — use `pnpm exec <package>` or `pnpm run <script>`
- **NEVER reference Linear issues** (e.g. `SOC-123`, `ENG-456`, `ASK-789`, Linear URLs) in code, code comments, or PR titles/descriptions/review comments. Linear tracking lives in Linear; keep the codebase and PR history tool-agnostic.
- 🚨 **NEVER write a real customer or company name into any commit, PR, issue, GitHub comment, or release note.** When about to write any name, stop and ask: "is this a real company?" If yes, replace it with `Acme Inc` (or drop the reference entirely). No enumerated denylist exists anywhere — a denylist is itself a leak. Recognition is done at write time, every time. The `.claude/hooks/public-surface-reminder` hook re-prints this rule on every public-surface `git`/`gh` command as a priming nudge; the rule still applies when the hook is not installed.

## EVOLUTION

Expand All @@ -115,6 +117,7 @@ If user repeats instruction 2+ times, ask: "Should I add this to CLAUDE.md?"
- `Promise.race` / `Promise.any`: NEVER pass a long-lived promise (interrupt signal, pool member) into a race inside a loop. Each call re-attaches `.then` handlers to every arm; handlers accumulate on surviving promises until they settle. For concurrency limiters, use a single-waiter "slot available" signal (resolved by each task's `.then`) instead of re-racing `executing[]`. See nodejs/node#17469 and `@watchable/unpromise`. Race with two fresh arms (e.g. one-shot `withTimeout`) is safe.
- File existence: ALWAYS `existsSync` from `node:fs`. NEVER `fs.access`, `fs.stat`-for-existence, or an async `fileExists` wrapper. Import: `import { existsSync, promises as fs } from 'node:fs'`.
- Stream discipline: stdout carries ONLY the data the command was asked to produce (JSON payload, report, fix output — the thing a script pipes into `jq` or redirects to a file). stderr carries everything else: progress, spinners, status, warnings, errors, dry-run previews, context, banners, prompts. Test: would `command | jq` or `command > file` make sense with only the stdout content? If no, it belongs on stderr. Under `--json` / `--markdown`, stdout MUST parse as the declared format with no prefix/suffix text.
- Replying to Cursor Bugbot: reply on the inline review-comment thread, not as a detached PR comment, so the reply threads under the bot's finding. Use `gh api repos/{owner}/{repo}/pulls/{pr}/comments/{comment_id}/replies -X POST -f body=...`. Find the `{comment_id}` via `gh api repos/{owner}/{repo}/pulls/{pr}/comments -q '.[] | select(.user.login == "cursor[bot]") | {id, path, line, body: (.body|.[0:200])}'`. Detached `gh pr comment` is wrong for bot findings.

### Documentation Policy

Expand Down