SLOPSHOPPER

sign-guard

Spots Windows builds (cargo --release, dotnet publish, go build, pyinstaller, tauri, electron-builder, msbuild) and signs the new .exe/.msi/.msix with your own…

newbandguardtoastprocess
A shopper browsing a rack in a slop shop
README

Sign Guard

Spots Windows builds (cargo --release, dotnet publish, go build, pyinstaller, tauri, electron-builder, msbuild) and signs the new .exe/.msi/.msix with your own signing script.

Part of the Mod Box, which bundles it with other mods and one window to switch and configure them all. This is the same mod on its own.

Install

In a terminal Claude Code session (2.1.287 or newer):

/plugin install sign-guard --marketplace LupusMalusDeviant/claude-mods

Options

Set them in /config (or when installing).

OptionDefaultWhat it does
modeaskask: offer a sign button. auto: sign right away.
script``PowerShell script called as <script> -Exe <file>. Empty: ~/.claude/skills/sign-binary/sign.ps1
subject``Files already signed by a certificate whose subject contains this are skipped. Empty: any validly signed file is skipped.
languageenLanguage of the texts this mod shows and writes.

License

MIT, see LICENSE.

Source 4 files
hooks/entry.tsx 30 lines
1// Entry of the standalone Sign Guard. The mod hands its display to a band (in the Mod Box the
2// band manager draws it); here this hook, registered first and so the outermost, draws it below
3// whatever else is above the prompt.
4import type { Register } from 'claude-code'
5
6import { register as inner } from '../mods/sign-guard/hooks/register'
7import { openBand, takeBand } from '../mods/settings/hooks/band'
8
9export const register: Register = (on, options) => {
10  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
11    openBand(e)
12    const rest = await next(e)
13    const parts = takeBand(e)
14    if (e.props.hasSurvey || parts.length === 0) return rest
15    const { Box, Text } = $.ui.resolve(e)
16    return (
17      <Box flexDirection="column">
18        {rest}
19        {parts.map(p => (
20          <Box key={`band-${p.id}`} flexDirection="row" paddingX={1}>
21            <Box flexShrink={1}>{p.line as any}</Box>
22            {(p.actions as any[]).flatMap((a, i) => [<Text key={`gap-${i}`}>{'  '}</Text>, a])}
23          </Box>
24        ))}
25      </Box>
26    )
27  })
28  ;(inner as any)(on, options)
29}
30
mods/sign-guard/hooks/register.tsx 188 lines
1// Sign guard: sign the new binaries after a Windows build.
2// Detects build commands in the Bash tool (cargo --release, dotnet publish, go build, pyinstaller,
3// tauri build, electron-builder, msbuild, npm/pnpm/yarn run dist|build:win|package) and then looks
4// under the working folder for every .exe/.msi/.msix created during the build.
5// Mode "fragen" (ask): band with a button. Mode "auto": sign right away.
6// Signing uses your own script (option script, called with -Exe <file>). Already signed
7// files are skipped: with option subject only those signed by that certificate, otherwise any validly signed one.
8import { atom, read, update } from 'claude-code'
9import type { EngineInterface, Register } from 'claude-code'
10
11import type { SignFile, SignJob } from '../types'
12import { offer as offerBand } from '../../settings/hooks/band'
13import type { BandPart } from '../../settings/hooks/band'
14
15const job = atom({ plugin: 'sign-guard', key: 'job' } as const, null as SignJob | null)
16const busy = atom({ plugin: 'sign-guard', key: 'busy' } as const, false)
17
18const BINARY = /\.(exe|msi|msix)$/i
19// Folders holding intermediate output or third-party files.
20const SKIP = new Set(['node_modules', '.git', 'deps', 'incremental', '.fingerprint', 'build', 'obj', 'examples', '.venv', 'venv', '__pycache__', 'vendor', '.cargo'])
21const MAX_DEPTH = 7
22const MAX_ENTRIES = 8000
23// For band(): the script path from the options.
24let scriptOption = ''
25// Certificate whose signature counts as "already done" (empty: any valid signature).
26let subject = ''
27
28// Display language (options.language: de | en)
29let lang: 'de' | 'en' = 'en'
30const t = (de: string, en: string) => (lang === 'en' ? en : de)
31
32export const BUILD =
33  /\b(cargo\s+(?:build|tauri\s+build)[^;&|]*--release|dotnet\s+publish|go\s+build|pyinstaller|tauri\s+build|electron-builder|msbuild|(?:npm|pnpm|yarn|bun)\s+(?:run\s+)?(?:dist|make|package|build:win|tauri\s+build)\b)/i
34
35export const register: Register = (on, options) => {
36  // Band manager: hand this mod’s display to the Mod Box (it draws, orders and places it).
37  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
38    const rest = await next(e)
39    if (!e.props.hasSurvey) {
40      const part = await band($, e).catch(() => null)
41      if (part) offerBand(e, part)
42    }
43    return rest
44  })
45
46  const auto = options.mode === 'auto'
47  const scriptOpt = String(options.script ?? '').trim()
48  scriptOption = scriptOpt
49  subject = String(options.subject ?? '').trim()
50  lang = options.language === 'de' ? 'de' : 'en'
51
52  on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
53    const cmd = String(e.command ?? '')
54    const m = BUILD.exec(cmd)
55    if (!m) return next(e)
56    const startedAt = (await $.clock.now()) - 2000
57    const result = await next(e)
58    if (('deny' in result && result.deny !== undefined) || result.isError) return result
59    if ((await $.env.get('OS')) !== 'Windows_NT') return result
60    void found($, m[1] ?? m[0], startedAt).then(async j => {
61      if (j && auto) await signAll($, scriptOpt)
62    }).catch(() => {})
63    return result
64  })
65
66}
67
68async function found($: EngineInterface, build: string, since: number): Promise<SignJob | null> {
69  const cwd = await $.session.cwd()
70  const files: string[] = []
71  let seen = 0
72  const walk = async (dir: string, depth: number): Promise<void> => {
73    if (depth > MAX_DEPTH || seen > MAX_ENTRIES) return
74    const entries = await $.fs.list(dir).catch(() => [])
75    for (const x of entries) {
76      seen++
77      const p = `${dir}/${x.name}`
78      if (x.kind === 'dir') {
79        if (!SKIP.has(x.name) && !x.name.startsWith('.')) await walk(p, depth + 1)
80      } else if (x.kind === 'file' && BINARY.test(x.name) && x.mtimeMs >= since) {
81        files.push(p)
82      }
83    }
84  }
85  await walk(cwd, 0)
86  if (files.length === 0) return null
87  const signed = await Promise.all(files.map(f => isOurs($, f)))
88  const todo = files.filter((_, i) => !signed[i])
89  if (todo.length === 0) return null
90  const j: SignJob = {
91    build: build.trim().slice(0, 40),
92    cwd,
93    at: await $.clock.now(),
94    files: todo.map(path => ({ path, ok: null, message: '' })),
95    phase: 'gefunden',
96    isDismissed: false,
97  }
98  await update($, job, () => j)
99  return j
100}
101
102async function scriptPath($: EngineInterface, opt: string): Promise<string> {
103  if (opt) return opt
104  const home = (await $.env.get('USERPROFILE')) ?? (await $.env.get('HOME')) ?? ''
105  return `${home}\\.claude\\skills\\sign-binary\\sign.ps1`
106}
107
108async function isOurs($: EngineInterface, file: string): Promise<boolean> {
109  const r = await $.process
110    .run(['powershell', '-NoProfile', '-NonInteractive', '-Command', `$s = Get-AuthenticodeSignature -LiteralPath '${file.replace(/'/g, "''")}'; "$($s.Status)|$($s.SignerCertificate.Subject)"`], { timeoutMs: 20_000 })
111    .catch(() => null)
112  if (!r || r.exitCode !== 0) return false
113  const [status = '', who = ''] = r.stdout.trim().split('|')
114  return subject ? who.includes(subject) : status === 'Valid'
115}
116
117async function signAll($: EngineInterface, opt: string) {
118  const j = await read($, job)
119  if (!j || (await read($, busy))) return
120  const script = await scriptPath($, opt)
121  if (!(await $.fs.exists(script))) {
122    await update($, job, (x): SignJob | null => (x ? { ...x, phase: 'fehler', files: x.files.map(f => ({ ...f, ok: false, message: t(`Skript fehlt: ${script}`, `script missing: ${script}`) })) } : x))
123    $.ui.toast(t(`Signier-Wächter: ${script} nicht gefunden`, `Sign Guard: ${script} not found`))
124    return
125  }
126  await update($, busy, () => true)
127  const results: SignFile[] = []
128  try {
129    for (const f of j.files) {
130      const r = await $.process
131        .run(['powershell', '-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', script, '-Exe', f.path], { cwd: j.cwd, timeoutMs: 120_000 })
132        .catch((err: unknown) => ({ exitCode: -1, stdout: '', stderr: String(err) }))
133      const text = (r.exitCode === 0 ? r.stdout : r.stderr || r.stdout).trim().split('\n').filter(Boolean).pop() ?? ''
134      results.push({ path: f.path, ok: r.exitCode === 0, message: text.slice(0, 300) })
135    }
136  } finally {
137    await update($, busy, () => false)
138  }
139  const good = results.filter(r => r.ok).length
140  await update($, job, (x): SignJob | null => (x ? { ...x, files: results, phase: good === results.length ? 'signiert' : good > 0 ? 'teilweise' : 'fehler' } : x))
141  $.ui.toast(t(`Signier-Wächter: ${good} von ${results.length} signiert`, `Sign Guard: ${good} of ${results.length} signed`))
142}
143
144function base(p: string): string {
145  return p.split(/[\\/]/).pop() ?? p
146}
147
148// For the Mod Box band manager (instead of an AbovePrompt hook of its own).
149export async function band($: EngineInterface, e: any): Promise<BandPart | null> {
150  const j = await read($, job)
151  if (!j || j.isDismissed) return null
152  const isBusy = await read($, busy)
153  const { Text, Button } = $.ui.resolve(e)
154  const names = j.files.map(f => `${f.ok === true ? '✔' : f.ok === false ? '✘' : '·'} ${base(f.path)}`).join('  ')
155  const head =
156    isBusy ? t('signiere …', 'signing …')
157    : j.phase === 'gefunden' ? t(`${j.files.length} neue Binärdatei(en) nach ${j.build}:`, `${j.files.length} new binary file(s) after ${j.build}:`)
158    : j.phase === 'signiert' ? t('signiert:', 'signed:')
159    : j.phase === 'teilweise' ? t('teilweise signiert:', 'partly signed:')
160    : t('Signieren fehlgeschlagen:', 'Signing failed:')
161  const color = j.phase === 'signiert' ? 'green' : j.phase === 'gefunden' ? 'cyan' : 'yellow'
162  const actions: unknown[] = []
163  if (j.phase === 'gefunden' && !isBusy) actions.push(<Button key="sg-sign" label={t('Signieren', 'Sign')} plain onPress={() => void signAll($, scriptOption)} />)
164  if (j.phase !== 'gefunden' && !isBusy) {
165    actions.push(<Button key="sg-why" label="Details" plain onPress={() => $.ui.log(j.files.map(f => `${f.path}: ${f.message || (f.ok ? t('signiert', 'signed') : t('offen', 'open'))}`).join('\n'))} />)
166  }
167  actions.push(<Button key="sg-weg" label="×" plain onPress={() => void update($, job, x => (x ? { ...x, isDismissed: true } : x))} />)
168  return {
169    id: 'signGuard',
170    urgent: j.phase !== 'signiert',
171    short: (
172      <Text>
173        <Text color={color} bold>{'🔏'}</Text>
174        <Text dimColor>{` ${j.files.filter(f => f.ok).length}/${j.files.length}`}</Text>
175      </Text>
176    ),
177    line: (
178      <Text>
179        <Text color={color} bold>{'🔏 '}</Text>
180        <Text>{`${head} `}</Text>
181        <Text dimColor>{names}</Text>
182      </Text>
183    ),
184    actions,
185    status: `🔏 ${head} ${names}`,
186  }
187}
188
mods/settings/hooks/band.ts 44 lines
1// The band manager's mailbox. A band mod computes its display in its own AbovePrompt hook (with its
2// own $; the validator does not let $ cross an import) and drops it here with offer(). The settings
3// hook, registered first and so the outermost, runs after all of them, takes the parts and draws
4// them ordered, compact or one line each, or sends them to the status line.
5export type BandPart = {
6  /** The mod's switch key, e.g. "cacheMeter". */
7  id: string
8  /** Wants attention (CI red, sign offer …): always its own line with its buttons. */
9  urgent: boolean
10  /** One short element for the compact line. */
11  short: unknown
12  /** The full element for its own line. */
13  line: unknown
14  /** Buttons that belong to the part. */
15  actions: unknown[]
16  /** Plain text for the status line. */
17  status: string
18}
19
20const parts = new Map<string, BandPart[]>()
21
22function keyOf(e: { requestId?: string }): string {
23  return e.requestId ?? 'band'
24}
25
26/** Called by the manager before the inner hooks run. */
27export function openBand(e: { requestId?: string }) {
28  parts.set(keyOf(e), [])
29}
30
31/** Called by a band mod with its display for this render. */
32export function offer(e: { requestId?: string }, part: BandPart) {
33  const list = parts.get(keyOf(e))
34  if (list) list.push(part)
35  else parts.set(keyOf(e), [part])
36}
37
38/** Called by the manager after the inner hooks ran. */
39export function takeBand(e: { requestId?: string }): BandPart[] {
40  const list = parts.get(keyOf(e)) ?? []
41  parts.delete(keyOf(e))
42  return list
43}
44
mods/sign-guard/types/contract.d.ts 18 lines
1export type SignFile = { path: string; ok: boolean | null; message: string }
2
3export type SignJob = {
4  /** What was built (the detected command, shortened). */
5  build: string
6  cwd: string
7  at: number
8  files: SignFile[]
9  phase: 'gefunden' | 'signiert' | 'teilweise' | 'fehler'
10  isDismissed: boolean
11}
12
13declare module 'claude-code' {
14  interface PluginState {
15    'sign-guard': { job: SignJob | null; busy: boolean }
16  }
17}
18