SLOPSHOPPER

publish-guard

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

newpaneguardcommandtoasttool
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · publish-guard
│ ┃ ⛨ Guard ✕ › fix the failing auth test and add an audit log call │ ┃ Publish guard: 0 blocked │ ⏺ Read(src/auth.ts) │ ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /pubguard │ ⎿ publish-guard: Publish guard: 0 blocked so far. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · ⛨ Guard
Publish guard: 0 blocked
README

publish-guard

Checks text before Claude sends it anywhere public:

  • typing into the browser and filling forms through a browser MCP (computer type actions, form_input, and both inside browser_batch)
  • Write and Edit to files whose path contains one of your guarded paths

If 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.

Settings

SettingDefaultWhat it is
blocked_termsemptyComma-separated words, names or domains that must never go out. Case-insensitive.
allowed_termsemptyComma-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_latinoffkorean also blocks Korean text.

With no blocked terms and Korean off, the guard has nothing to check and the pane says so.

What it touches

Reads tool calls only. Keeps the last 50 blocks (what kind, and where) in the mod's own store.

Source 1 files
hooks/register.tsx 111 lines
1// 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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
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