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

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.
In a terminal Claude Code session (2.1.287 or newer):
/plugin install sign-guard --marketplace LupusMalusDeviant/claude-mods
Set them in /config (or when installing).
| Option | Default | What it does |
|---|---|---|
mode | ask | ask: 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. |
language | en | Language of the texts this mod shows and writes. |
MIT, see LICENSE.
hooks/entry.tsx 30 lines1// 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}
30mods/sign-guard/hooks/register.tsx 188 lines1// 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}
188mods/settings/hooks/band.ts 44 lines1// 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}
44mods/sign-guard/types/contract.d.ts 18 lines1export 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