Blocks git commit/push when the diff adds personal names (〜さん/様/先生) or Slack tokens, with a shield band above the prompt, a status-line counter, a toast and a…

A Claude Code Mod that stops Claude from committing personal names or Slack tokens.
Before Claude runs git commit or git push, it scans the lines the commit or push would add. If it finds any of these, it blocks the command and tells Claude each hit's file and line:
山田さん, タナカ様, 佐藤先生, 鈴木氏 (kanji or katakana)Yamada-san, Sato-senseixoxb-…, xoxp-…, xapp-… (shown masked)What you see:
🛡️ ARMED before the first check, then ⛔ BLOCKED (red, with the hits) or ✅ CLEAN (green), with running counts. Details opens the pane; Hide hides the band until the next check.privacy-guard: 🛡️ 3 checked · 1 blocked.⚠️ blocked or 🛡️ clean) and a pane listing each hit. /privacy-guard reopens the pane.Because it reads the staged changes before the command runs, a command that stages and commits in one go (git add x && git commit) is refused with a note to run git add first and git commit separately; otherwise the files that git add stages would go unchecked.
Words that name nobody (皆さん, お客様, ご苦労様, 先生 on its own) are ignored. When only names are found, privacy-guard asks you in Claude Code's question dialog whether to commit them anyway; Claude cannot answer it, so only you can let a name through (the band then shows ⚠️ ALLOWED). Dismissing the dialog, or a run with no one to ask (claude -p), blocks. Slack tokens are always blocked. If the check itself fails (for example, outside a git repo), the command runs as normal.
At the prompt of a Claude Code terminal session:
/plugin install privacy-guard --marketplace taketonomy/privacy-guard
Answer y to add the marketplace, then pick a scope.
From a shell:
claude plugin install privacy-guard --marketplace taketonomy/privacy-guard
In the desktop app, where /plugin does not run: click + next to the prompt box, then Plugins, Add marketplace with taketonomy/privacy-guard, then Add plugin.
主催: 山田さん and ask Claude to commit it. The commit is blocked and the pane lists 山田さん.claude --plugin-dir /path/to/privacy-guard
claude plugin validate .
claude plugin test .
The detection rules are in hooks/scan.ts; the blocking, toast and pane are in hooks/register.tsx.
MIT. See LICENSE.
hooks/register.tsx 192 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Check, Hit } from '../types'
5import { gitAction, gitDirectory, scanDiff, stagesBeforeCommit } from './scan'
6import type { GitAction } from './scan'
7
8const PANE = 'privacy-guard'
9const last = atom({ plugin: 'privacy-guard', key: 'last' } as const, null as Check | null)
10const checked = atom({ plugin: 'privacy-guard', key: 'checked' } as const, 0)
11const blocked = atom({ plugin: 'privacy-guard', key: 'blocked' } as const, 0)
12const isHidden = atom({ plugin: 'privacy-guard', key: 'isHidden' } as const, false)
13
14// Only the person can let names through: Claude cannot answer this dialog.
15// Tokens are never let through.
16const ALLOW = 'Commit anyway'
17const BLOCK = 'Block it'
18
19const git = async ($: EngineInterface, cwd: string | undefined, args: string[]) => {
20 const ran = await $.process.run(['git', ...args], { cwd })
21 return ran.exitCode === 0 ? ran.stdout : undefined
22}
23
24// The lines this commit or push would add.
25const pendingDiff = async ($: EngineInterface, cwd: string | undefined, action: NonNullable<GitAction>) => {
26 if (action.kind === 'commit') return git($, cwd, ['diff', action.isAll ? 'HEAD' : '--cached', '-U0', '--no-color'])
27
28 for (const base of ['@{upstream}', 'origin/HEAD', 'origin/main']) {
29 const diff = await git($, cwd, ['diff', '-U0', '--no-color', `${base}...HEAD`])
30 if (diff !== undefined) return diff
31 }
32 return undefined
33}
34
35// Claude Code already puts the mod's name in front of its status text.
36const statusText = (n: number, b: number) => `🛡️ ${n} checked · ${b} blocked`
37
38// Asks the person whether these names may be committed. Dismissed, or no one
39// to ask (a -p run), counts as no.
40const askPerson = async ($: EngineInterface, kind: string, hits: Hit[]) => {
41 const list = hits.slice(0, 5).map(hit => hit.match).join(', ')
42 try {
43 const answer = await $.ui.ask(`privacy-guard found personal names in this git ${kind}: ${list}. Commit them anyway?`, {
44 options: [BLOCK, ALLOW],
45 header: 'Privacy',
46 })
47 return answer === ALLOW
48 } catch {
49 return false
50 }
51}
52
53// The command's first line, short enough for the pane (a commit message can run on).
54const firstLine = (command: string) => {
55 const line = command.split('\n')[0] ?? ''
56 return line.length > 60 ? `${line.slice(0, 59)}…` : line
57}
58
59const describe = (hits: Hit[]) =>
60 hits.map(hit => ` ${hit.file}:${hit.line} ${hit.kind === 'token' ? 'token' : 'name'} ${hit.match}`).join('\n')
61
62export const register: Register = on => {
63 on('session.start', async ($, e, next) => {
64 await $.command.register({ name: 'privacy-guard', description: 'Show the last privacy check in a pane' })
65 $.ui.status(statusText(await read($, checked), await read($, blocked)))
66 return next(e)
67 })
68
69 on('command.run', { command: 'privacy-guard' }, async $ => {
70 await $.ui.open({ id: PANE, title: 'Privacy guard' })
71 return { text: 'Privacy guard pane opened.' }
72 })
73
74 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
75 const action = gitAction(e.command)
76 if (action === null) return next(e)
77
78 if (action.kind === 'commit' && stagesBeforeCommit(e.command)) {
79 $.ui.toast('🛡️ privacy-guard: stage first, then commit in a separate command')
80 return {
81 deny:
82 'privacy-guard checks the staged changes before a command runs, so it cannot see what ' +
83 '`git add` in this same command would stage. Run the `git add` (or rm/mv) on its own first, ' +
84 'then run `git commit` as a separate command.',
85 }
86 }
87
88 const diff = await pendingDiff($, gitDirectory(e.command), action)
89 const hits = diff === undefined ? [] : scanDiff(diff)
90 const names = hits.filter(hit => hit.kind === 'name').length
91 const tokens = hits.length - names
92 const isAllowed = hits.length > 0 && tokens === 0 && (await askPerson($, action.kind, hits))
93 const isBlocked = hits.length > 0 && !isAllowed
94
95 await update($, last, () => ({ command: e.command, hits, isBlocked, isAllowed }))
96 await update($, isHidden, () => false)
97 const n = await update($, checked, c => c + 1)
98 const b = isBlocked ? await update($, blocked, c => c + 1) : await read($, blocked)
99 $.ui.status(statusText(n, b))
100
101 if (hits.length === 0) {
102 $.ui.toast(`🛡️ privacy-guard: git ${action.kind} is clean`)
103 return next(e)
104 }
105 if (isAllowed) {
106 $.ui.toast(`⚠️ privacy-guard: you allowed ${names} name(s) in this git ${action.kind}`)
107 return next(e)
108 }
109
110 $.ui.toast(`⚠️ privacy-guard: blocked git ${action.kind} — ${names} name(s), ${tokens} token(s)`)
111 void $.ui.open({ id: PANE, title: 'Privacy guard' })
112
113 return {
114 deny:
115 `privacy-guard blocked this git ${action.kind}: the changes add personal names or secrets.\n` +
116 `${describe(hits)}\n` +
117 (tokens > 0
118 ? 'Remove the tokens; they are never committed. '
119 : 'The user was asked and did not allow it. ') +
120 'Replace names with roles ("the presenter", "the organizer") and try again. ' +
121 'Do not retry the same changes or try to get around this check.',
122 }
123 // A failed check (git missing, not a repo) lets the command through rather than wedging it.
124 }).catch(($, e, next) => next(e))
125
126 // The shield band above the prompt: armed before any check, then the last verdict.
127 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
128 if (e.props.hasSurvey || (await read($, isHidden))) return next(e)
129
130 const { Box, Button, Text } = $.ui.resolve(e)
131 const check = await read($, last)
132 const n = await read($, checked)
133 const b = await read($, blocked)
134
135 const [color, head, body] =
136 check === null
137 ? ['cyan', '🛡️ ARMED', 'watching git commit / push for names and Slack tokens']
138 : check.isAllowed
139 ? ['yellow', '⚠️ ALLOWED', `you let ${check.hits.length} name(s) through: ${check.hits.slice(0, 3).map(hit => hit.match).join(', ')}`]
140 : check.isBlocked
141 ? ['red', '⛔ BLOCKED', `${check.hits.length} hit(s): ${check.hits.slice(0, 3).map(hit => hit.match).join(', ')}`]
142 : ['green', '✅ CLEAN', 'last git write had no names or tokens']
143
144 return (
145 <Box>
146 <Text color={color} bold>
147 {head}{' '}
148 </Text>
149 <Text>{body} </Text>
150 <Text dimColor>
151 · {n} checked · {b} blocked{' '}
152 </Text>
153 {check === null ? null : (
154 <Button key="details" label="Details" onPress={() => $.ui.open({ id: PANE, title: 'Privacy guard' })} />
155 )}
156 <Button key="hide" label="Hide" onPress={() => update($, isHidden, () => true)} />
157 </Box>
158 )
159 })
160
161 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
162 const { Box, Text } = $.ui.resolve(e)
163 const check = await read($, last)
164
165 if (check === null) return <Text dimColor>No git commit or push checked yet.</Text>
166
167 return (
168 <Box flexDirection="column">
169 <Text bold color={check.isAllowed ? 'yellow' : check.isBlocked ? 'red' : 'green'}>
170 {check.isAllowed
171 ? `Allowed by you: ${check.hits.length} hit(s)`
172 : check.isBlocked
173 ? `Blocked: ${check.hits.length} hit(s)`
174 : 'Clean: nothing found'}
175 </Text>
176 <Text dimColor>{firstLine(check.command)}</Text>
177 {check.hits.map(hit => (
178 <Box flexDirection="column" marginTop={1}>
179 <Text>
180 <Text color={hit.kind === 'token' ? 'magenta' : 'yellow'}>{hit.kind === 'token' ? 'TOKEN' : 'NAME '}</Text>{' '}
181 <Text bold>{hit.match}</Text>
182 </Text>
183 <Text dimColor>
184 {hit.file}:{hit.line}
185 </Text>
186 </Box>
187 ))}
188 </Box>
189 )
190 })
191}
192hooks/scan.ts 78 lines1import type { Hit } from '../types'
2
3// A kanji or katakana name followed by an honorific: 山田さん, タナカさん, 坂口先生.
4const NAME = /([\p{sc=Han}\p{sc=Katakana}ー]{1,6})(さん|様|先生|氏)/gu
5// A romanized one: Yamada-san, Sakaguchi-sensei.
6const NAME_LATIN = /\b[A-Z][a-z]+-(san|sama|sensei)\b/g
7// Slack bot, user and app tokens.
8const TOKEN = /\bxox[abprs]-[0-9A-Za-z-]{10,}|\bxapp-[0-9A-Za-z-]{10,}/g
9
10// Honorifics that name nobody.
11const NOT_A_NAME = new Set(['皆さん', '皆様', '客様', '客さん', '神様', '王様', '苦労様', '世話様', '奥さん', '旦那さん', '先生'])
12
13const maskToken = (token: string) => `${token.slice(0, 5)}…${token.slice(-4)}`
14
15export const scanLine = (text: string): Pick<Hit, 'kind' | 'match'>[] => {
16 const found: Pick<Hit, 'kind' | 'match'>[] = []
17
18 for (const m of text.matchAll(NAME)) {
19 if (!NOT_A_NAME.has(m[0]) && !NOT_A_NAME.has(m[2] ?? '')) found.push({ kind: 'name', match: m[0] })
20 }
21 for (const m of text.matchAll(NAME_LATIN)) found.push({ kind: 'name', match: m[0] })
22 for (const m of text.matchAll(TOKEN)) found.push({ kind: 'token', match: maskToken(m[0]) })
23
24 return found
25}
26
27// Reads `git diff -U0` output and scans only the lines it adds.
28export const scanDiff = (diff: string): Hit[] => {
29 const hits: Hit[] = []
30 let file = ''
31 let line = 0
32
33 for (const row of diff.split('\n')) {
34 if (row.startsWith('+++ ')) {
35 file = row.replace(/^\+\+\+ (b\/)?/, '')
36 continue
37 }
38 const hunk = /^@@ -\d+(?:,\d+)? \+(\d+)/.exec(row)
39 if (hunk) {
40 line = Number(hunk[1])
41 continue
42 }
43 if (row.startsWith('+')) {
44 for (const one of scanLine(row.slice(1))) hits.push({ ...one, file, line })
45 line += 1
46 }
47 }
48
49 return hits
50}
51
52export type GitAction = { kind: 'commit'; isAll: boolean } | { kind: 'push' } | null
53
54// Which git write a Bash command runs, if any.
55export const gitAction = (command: string): GitAction => {
56 if (/\bgit\s+(-C\s+\S+\s+)?commit\b/.test(command)) {
57 const isAll = /\bgit\s+(-C\s+\S+\s+)?commit\b[^;&|]*\s(-a\w*|-\w*a\w*|--all)\b/.test(command)
58 return { kind: 'commit', isAll }
59 }
60 if (/\bgit\s+(-C\s+\S+\s+)?push\b/.test(command)) return { kind: 'push' }
61
62 return null
63}
64
65// Whether the command stages files before its commit (`git add x && git commit`).
66// The check reads the index before the command runs, so it cannot see what that
67// staging step will add.
68export const stagesBeforeCommit = (command: string): boolean => {
69 const commit = /\bgit\s+(-C\s+\S+\s+)?commit\b/.exec(command)
70 if (commit === null) return false
71 return /\bgit\s+(-C\s+\S+\s+)?(add|rm|mv)\b/.test(command.slice(0, commit.index))
72}
73
74// The directory the command runs git in: `git -C <dir>`, or a leading `cd <dir> &&`.
75export const gitDirectory = (command: string): string | undefined =>
76 /\bgit\s+-C\s+("[^"]+"|'[^']+'|\S+)/.exec(command)?.[1]?.replace(/^["']|["']$/g, '') ??
77 /^\s*cd\s+("[^"]+"|'[^']+'|\S+)\s*&&/.exec(command)?.[1]?.replace(/^["']|["']$/g, '')
78types/index.d.ts 21 lines1export type Hit = {
2 kind: 'name' | 'token'
3 file: string
4 line: number
5 match: string
6}
7
8export type Check = {
9 command: string
10 hits: Hit[]
11 isBlocked: boolean
12 // The person chose to let these hits through.
13 isAllowed?: boolean
14}
15
16declare module 'claude-code' {
17 interface PluginState {
18 'privacy-guard': { last: Check | null; checked: number; blocked: number; isHidden: boolean }
19 }
20}
21