Stops Claude from typing private terms into the browser or writing them into your publishing files, and keeps a log of what it blocked

Checks text before Claude sends it anywhere public:
computer type actions, form_input, and both inside browser_batch)Write and Edit to files whose path contains one of your guarded pathsIf the text contains one of your blocked terms, or Korean text when that option is on, the call is denied and Claude is told to remove it. The deny message, the toast and the log only ever say "a blocked term" or "Korean text", never the term itself, so the guard doesn't leak what it guards. /pubguard shows the last blocks.
| Setting | Default | What it is |
|---|---|---|
blocked_terms | empty | Comma-separated words, names or domains that must never go out. Case-insensitive. |
allowed_terms | empty | Comma-separated exceptions, removed before checking. Useful for a name you credit on purpose. |
guarded_paths | /publishing/ | Comma-separated pieces of a path. Writes and edits to matching paths are checked. |
block_non_latin | off | korean also blocks Korean text. |
With no blocked terms and Korean off, the guard has nothing to check and the pane says so.
Reads tool calls only. Keeps the last 50 blocks (what kind, and where) in the mod's own store.
hooks/register.tsx 111 lines1// publish-guard: before Claude types into the browser (YouTube Studio, forms) or writes a file
2// under a guarded path, it checks the text for your blocked terms (and, if turned on, Korean
3// text). The matched term is never echoed back. /pubguard shows the block log.
4import type { Register } from 'claude-code'
5
6const PANE = 'guard'
7const HANGUL = /[가-힣ㄱ-ㆎ]/
8type Hit = { at: number; what: string; where: string }
9let log: Hit[] = []
10let blocked: string[] = []
11let allowed: string[] = []
12let paths: string[] = []
13let korean = false
14
15const list = (s: unknown) => String(s ?? '').split(',').map(x => x.trim()).filter(Boolean)
16const reOf = (s: string) => new RegExp(s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'gi')
17
18function problem(text: string): string | undefined {
19 let t = text
20 for (const a of allowed) t = t.replace(reOf(a), ' ')
21 const low = t.toLowerCase()
22 if (blocked.some(b => low.includes(b.toLowerCase()))) return 'a blocked term'
23 if (korean && HANGUL.test(t)) return 'Korean text'
24 return undefined
25}
26function textsOf(e: any): { text: string; where: string }[] {
27 const t = String(e.tool)
28 if (/^mcp__.*__computer$/.test(t) && e.action === 'type') return [{ text: String(e.text ?? ''), where: 'browser typing' }]
29 if (/^mcp__.*__form_input$/.test(t)) return [{ text: String(e.value ?? ''), where: 'browser form' }]
30 if (/^mcp__.*__browser_batch$/.test(t)) return (e.actions ?? []).flatMap((a: any) => textsOf({ tool: 'mcp__browser__' + a.name, ...(a.input ?? {}) }))
31 const file = String(e.file_path ?? '')
32 if ((t === 'Write' || t === 'Edit') && paths.some(p => file.includes(p))) {
33 return [{ text: String(e.content ?? e.new_string ?? ''), where: file.split('/').slice(-2).join('/') }]
34 }
35 return []
36}
37
38const esc = (s: string) => s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
39const STYLE = `<style>
40 svg{--bg:#ffffff;--fg:#141413;--mu:#73726c;--ln:#e8e6dc;--tr:#f0eee6;--ac:#d97757;--ok:#788c5d;--wa:#c98a3a;--bad:#bf4d43}
41 @media (prefers-color-scheme: dark){svg{--bg:#262624;--fg:#faf9f5;--mu:#a3a199;--ln:#3d3d3a;--tr:#30302e}}
42 .card{fill:var(--bg);stroke:var(--ln);stroke-width:1}
43 .fg{fill:var(--fg)} .mu{fill:var(--mu)} .tr{fill:var(--tr)} .trs{stroke:var(--tr)} .ln{stroke:var(--ln)}
44 .ac{fill:var(--ac)} .acs{stroke:var(--ac)} .ok{fill:var(--ok)} .oks{stroke:var(--ok)} .wa{fill:var(--wa)} .was{stroke:var(--wa)} .bad{fill:var(--bad)} .bads{stroke:var(--bad)}
45 .t{font-family:ui-serif,"Tiempos Text",Georgia,serif}
46 text{font-family:-apple-system,BlinkMacSystemFont,"Inter",sans-serif}
47</style>`
48function card() {
49 const idle = !blocked.length && !korean
50 const n = Math.max(1, Math.min(5, log.length)), H = 120 + n * 28
51 const rows = log.slice(-5).reverse().map((h, i) => `<circle cx="30" cy="${103 + i * 28}" r="3" class="bad"/><text x="42" y="${107 + i * 28}" font-size="13" class="fg">Blocked ${h.what} <tspan class="mu">→ ${esc(h.where)}</tspan></text>`).join('')
52 const empty = idle ? 'Add blocked terms in /plugin → buildalone-mods → publish-guard' : 'Nothing blocked yet.'
53 return `<svg xmlns="http://www.w3.org/2000/svg" width="460" height="${H}" viewBox="0 0 460 ${H}">${STYLE}
54 <rect x="0.5" y="0.5" width="459" height="${H - 1}" rx="14" class="card"/>
55 <path d="M36 22 l13 5 v9 c0 8 -6 13 -13 16 c-7 -3 -13 -8 -13 -16 v-9 z" class="${log.length ? 'ac' : idle ? 'tr' : 'ok'}"/>
56 <text x="60" y="36" font-size="15" class="fg t">Publish guard</text>
57 <text x="60" y="54" font-size="12" class="mu">Checks browser typing, forms and files in guarded paths</text>
58 <text x="436" y="40" text-anchor="end" font-size="26" font-weight="600" class="fg">${log.length}</text>
59 <text x="436" y="56" text-anchor="end" font-size="11" class="mu">blocked</text>
60 <line x1="24" y1="76" x2="436" y2="76" class="ln"/>
61 ${log.length ? rows : `<text x="24" y="107" font-size="13" class="mu">${esc(empty)}</text>`}</svg>`
62}
63
64export const register: Register = (on, options) => {
65 blocked = list(options.blocked_terms)
66 allowed = list(options.allowed_terms)
67 paths = list(options.guarded_paths)
68 korean = options.block_non_latin === 'korean'
69
70 on('session.start', async ($, e, next) => {
71 await $.tool.register({ name: 'show', description: 'Open the ⛨ Guard pane in Claude Code for the person' })
72 await $.command.register({ name: 'pubguard', description: 'Show what the publish guard has blocked' })
73 log = ((await $.store.get('log')) as Hit[]) ?? []
74 return next(e)
75 })
76
77 on('command.run', { command: 'pubguard' }, async $ => {
78 await $.ui.open({ id: PANE, title: '⛨ Guard' })
79 return { text: `Publish guard: ${log.length} blocked so far.` }
80 })
81
82 on('tool.call', async ($, e, next) => {
83 for (const { text, where } of textsOf(e)) {
84 const why = problem(text)
85 if (why) {
86 log = [...log, { at: await $.clock.now(), what: why, where }].slice(-50)
87 await $.store.set('log', log)
88 $.ui.toast(`⛨ Publish guard blocked ${why} going into ${where}`)
89 $.ui.invalidate('ui.render')
90 return { deny: `publish-guard: this would put ${why} into ${where}. Remove it and try again.` }
91 }
92 }
93 return next(e)
94 })
95
96 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
97 if (e.surface === 'terminal') {
98 const { Text } = $.ui.resolve(e)
99 return <Text>Publish guard: {log.length} blocked</Text>
100 }
101 const { Svg } = $.ui.resolve(e)
102 return <Svg source={card()} alt="Publish guard block log" width={460} isInteractive />
103 })
104
105 on('tool.call', { tool: 'mcp__publish-guard__show' }, async $ => {
106 try { await $.ui.close({ id: PANE }) } catch {}
107 await $.ui.open({ id: PANE, title: '⛨ Guard' })
108 return { result: '⛨ Guard pane opened.' }
109 })
110}
111