Protects the Sparkle EdDSA signing key: denies deleting it from the keychain, asks before generate_keys may create or import a key and before an edit changes…

Protects the Sparkle EdDSA signing key. Losing or regenerating it breaks auto-update for every installed copy of the app: they only accept updates signed with the key matching their SUPublicEDKey.
tool.check rules, applied to the main conversation and subagents alike. An engine deny is never weakened.
| Tool | Situation | Verdict |
|---|---|---|
Bash | security delete-generic-password / delete-internet-password aimed at the Sparkle item: service https://sparkle-project.org, label Private key for signing Sparkle updates, or -a ed25519 (the default account) | deny |
Bash | generate_keys without -p/--lookUpPublicKey or -x/--exportedPrivateKeyFile (it creates a new key when it finds none, e.g. in another keychain or --account) | ask |
Bash | generate_keys -f/--importedPrivateKeyFile (imports a key in place of the current one), even beside -p | ask |
Edit, Write | the change modifies or removes an existing SUPublicEDKey value | ask |
generate_keys, ./bin/generate_keys, …/Sparkle/bin/generate_keys); rtk, sudo, env, xcrun, VAR=value prefixes are stripped and &&, ||, ;, | chains are split.replace_all), and the SUPublicEDKey values before and after compared. So an Edit whose old_string is only <string>…</string> is still caught. Adding a key where there was none, or a Write creating a new file, passes.<key>SUPublicEDKey</key><string>…</string>), YAML (SUPublicEDKey: …), build settings / xcconfig (INFOPLIST_KEY_SUPublicEDKey = …) and JSON.| Command | Effect |
|---|---|
/sparkle-guard status | Lists the rules |
generate_keys flags (research notes)From the generate_keys source in the Sparkle 2.x repository (generate_keys/main.swift, read through a summarising fetch, not by running --help):
-p / --lookUpPublicKey: only prints the existing public key;-x / --exportedPrivateKeyFile <file>: exports the private key to a file;-f / --importedPrivateKeyFile <file>: imports a private key into the keychain instead of generating one;--account <name>: keychain account to use (default ed25519);https://sparkle-project.org, label Private key for signing Sparkle updates.claude --plugin-dir /path/to/ModsTools/mods/sparkle-guard
SUPublicEDKey asks too (treated like a change).Bash (sed -i, plutil -replace, PlistBuddy) are not checked.security delete-* with only -a <other account> and no service or label is not recognised as the Sparkle item; a custom --account key deleted that way slips through.sh -c "…", eval or scripts are not parsed.claude plugin validate mods/sparkle-guard
claude plugin test mods/sparkle-guard # 16 testshooks/register.ts 64 lines1import type { EngineInterface, Register } from 'claude-code'
2
3import { RULES, applyEdit, checkCommand, checkKeyChange } from './rules'
4import type { Replacement, Verdict } from './rules'
5
6type FileInput = { file_path?: unknown; content?: unknown; old_string?: unknown; new_string?: unknown; replace_all?: unknown; edits?: unknown }
7
8async function readText($: EngineInterface, path: string): Promise<string | null> {
9 try {
10 return await $.fs.read(path)
11 } catch {
12 return null
13 }
14}
15
16const asReplacement = (one: unknown): Replacement | null => {
17 const edit = one as FileInput
18 if (typeof edit.old_string !== 'string' || typeof edit.new_string !== 'string') return null
19
20 return { old_string: edit.old_string, new_string: edit.new_string, replace_all: edit.replace_all === true }
21}
22
23// The verdict on a file tool call: the file's text before, against the text the call leaves.
24async function checkFile($: EngineInterface, tool: string, input: FileInput): Promise<Verdict | null> {
25 if (typeof input.file_path !== 'string') return null
26 const path = input.file_path.startsWith('/') ? input.file_path : `${await $.session.cwd()}/${input.file_path}`
27 const before = await readText($, path)
28 if (tool === 'Write') return typeof input.content === 'string' ? checkKeyChange(path, before, input.content) : null
29 if (before === null) return null
30 const edits = Array.isArray(input.edits) ? input.edits.map(asReplacement) : [asReplacement(input)]
31 if (edits.some(edit => edit === null)) return null
32
33 return checkKeyChange(path, before, (edits as Replacement[]).reduce(applyEdit, before))
34}
35
36export const register: Register = on => {
37 on('session.start', async ($, e, next) => {
38 await $.command.register({
39 name: 'sparkle-guard',
40 description: 'sparkle-guard status: the rules protecting the Sparkle EdDSA signing key',
41 })
42
43 return next(e)
44 })
45
46 on('command.run', { command: 'sparkle-guard' }, async () => ({
47 text: `sparkle-guard protects the Sparkle EdDSA signing key:\n${RULES.map(rule => `- ${rule}`).join('\n')}`,
48 }))
49
50 on('tool.check', async ($, e, next) => {
51 const verdict = await next(e)
52 if (verdict.decision === 'deny') return verdict
53 let mine: Verdict | null = null
54 if (e.tool === 'Bash') {
55 const command = (e.input as { command?: unknown }).command
56 mine = typeof command === 'string' ? checkCommand(command) : null
57 } else if (e.tool === 'Edit' || e.tool === 'Write' || e.tool === 'MultiEdit') {
58 mine = await checkFile($, e.tool, e.input as FileInput)
59 }
60
61 return mine ?? verdict
62 })
63}
64hooks/rules.ts 154 lines1// Pure rules of sparkle-guard: which shell commands and file edits put the Sparkle
2// EdDSA signing key at risk. No `$` here.
3
4export type Verdict = { decision: 'deny' | 'ask'; reason: string }
5
6// Wrappers that run the command after them; the value is their options that take an argument.
7const WRAPPERS: Record<string, Set<string>> = {
8 rtk: new Set(),
9 sudo: new Set(['-u', '-g', '-C', '-D', '-h', '-p', '-U']),
10 env: new Set(['-u', '-C', '-S']),
11 command: new Set(),
12 nohup: new Set(),
13 time: new Set(),
14 nice: new Set(['-n']),
15 xcrun: new Set(['--sdk', '--toolchain']),
16}
17
18const unquote = (word: string) => word.replace(/^['"]+|['"]+$/g, '')
19
20const isAssignment = (word: string) => /^[A-Za-z_][A-Za-z0-9_]*=/.test(word)
21
22const baseName = (word: string) => word.slice(word.lastIndexOf('/') + 1)
23
24// The command a segment runs, wrappers, their options and env assignments dropped.
25function strip(words: string[]): string[] {
26 let i = 0
27 for (;;) {
28 while (words[i] !== undefined && isAssignment(words[i]!)) i++
29 const takesArg = WRAPPERS[words[i] ?? '']
30 if (takesArg === undefined) break
31 i++
32 while (words[i]?.startsWith('-')) i += takesArg.has(words[i]!) ? 2 : 1
33 }
34
35 return words.slice(i)
36}
37
38/** Each simple command of a shell line: its raw text and its unquoted words, wrappers dropped. */
39export function segments(command: string): { raw: string; words: string[] }[] {
40 return command
41 .split(/&&|\|\||[;|&\n]/)
42 .map(part => ({ raw: part.trim(), words: strip(part.trim().split(/\s+/).filter(Boolean).map(unquote)) }))
43 .filter(one => one.words.length > 0)
44}
45
46// The keychain item generate_keys writes: service, label and default account.
47export const KEYCHAIN_SERVICE = 'https://sparkle-project.org'
48export const KEYCHAIN_LABEL = 'Private key for signing Sparkle updates'
49export const DEFAULT_ACCOUNT = 'ed25519'
50
51const DELETES = new Set(['delete-generic-password', 'delete-internet-password'])
52
53// `security delete-*-password` aimed at the Sparkle item: by service, label or the default account.
54function deletesKey(raw: string, words: string[]): boolean {
55 if (baseName(words[0] ?? '') !== 'security' || !DELETES.has(words[1] ?? '')) return false
56 const text = raw.toLowerCase()
57 if (text.includes('sparkle-project.org') || text.includes(KEYCHAIN_LABEL.toLowerCase())) return true
58 const account = words.indexOf('-a')
59
60 return account >= 0 && words[account + 1] === DEFAULT_ACCOUNT
61}
62
63const IMPORT = new Set(['-f', '--importedPrivateKeyFile'])
64const READ_ONLY = new Set(['-p', '--lookUpPublicKey', '-x', '--exportedPrivateKeyFile', '-h', '--help'])
65
66const flagName = (arg: string) => (arg.startsWith('--') && arg.includes('=') ? arg.slice(0, arg.indexOf('=')) : arg)
67
68// Why a generate_keys call may create or replace the key, or null when it only reads it.
69function generateKeysRisk(args: string[]): Verdict | null {
70 const flags = args.map(flagName)
71 if (flags.some(flag => IMPORT.has(flag))) {
72 return {
73 decision: 'ask',
74 reason:
75 'sparkle-guard: generate_keys -f imports a private key into the keychain in place of the Sparkle signing key, so confirm only if it is the original key exported with -x.',
76 }
77 }
78 if (flags.some(flag => READ_ONLY.has(flag))) return null
79
80 return {
81 decision: 'ask',
82 reason:
83 'sparkle-guard: generate_keys without -p or -x creates a new Sparkle signing key when it finds none in this keychain and account, which breaks auto-update for every installed user; use generate_keys -p to only print the public key.',
84 }
85}
86
87/** The verdict on a Bash command line, or null when it leaves the Sparkle key alone. */
88export function checkCommand(command: string): Verdict | null {
89 let ask: Verdict | null = null
90 for (const { raw, words } of segments(command)) {
91 if (deletesKey(raw, words)) {
92 return {
93 decision: 'deny',
94 reason:
95 'sparkle-guard: deleting the Sparkle EdDSA private key from the keychain is blocked, because without it no update can ever be signed for the copies already installed.',
96 }
97 }
98 if (baseName(words[0] ?? '') === 'generate_keys') ask = ask ?? generateKeysRisk(words.slice(1))
99 }
100
101 return ask
102}
103
104const PLIST = /<key>\s*SUPublicEDKey\s*<\/key>\s*<string>\s*([^<]*?)\s*<\/string>/g
105const ASSIGNED = /(?:INFOPLIST_KEY_)?SUPublicEDKey["']?\s*[:=]\s*["']?([A-Za-z0-9+/=_.$(){}-]+)/g
106
107/** Every SUPublicEDKey value in a text: plist, YAML, xcconfig/build setting or JSON. */
108export function publicKeys(text: string): string[] {
109 const found: string[] = []
110 for (const match of text.matchAll(PLIST)) found.push(match[1] ?? '')
111 for (const match of text.matchAll(ASSIGNED)) found.push(match[1] ?? '')
112
113 return found
114}
115
116export type Replacement = { old_string: string; new_string: string; replace_all?: boolean }
117
118/** The text after an Edit's replacement: the first occurrence, or all with replace_all. */
119export function applyEdit(text: string, edit: Replacement): string {
120 if (edit.old_string === '') return text
121 if (edit.replace_all === true) return text.split(edit.old_string).join(edit.new_string)
122 const at = text.indexOf(edit.old_string)
123
124 return at < 0 ? text : text.slice(0, at) + edit.new_string + text.slice(at + edit.old_string.length)
125}
126
127const short = (value: string) => (value.length > 12 ? `${value.slice(0, 12)}…` : value)
128
129const sameValues = (a: string[], b: string[]) => a.length === b.length && a.every((value, i) => value === b[i])
130
131/**
132 * The verdict when a file's text goes from `before` to `after`: asking when an existing
133 * SUPublicEDKey value changes or disappears; adding one where there was none is fine.
134 */
135export function checkKeyChange(file: string, before: string | null, after: string): Verdict | null {
136 const old = before === null ? [] : publicKeys(before)
137 if (old.length === 0) return null
138 const next = publicKeys(after)
139 if (sameValues(old, next)) return null
140 const what = next.length === 0 ? `removes SUPublicEDKey ${short(old[0]!)}` : `changes SUPublicEDKey from ${short(old[0]!)} to ${short(next.find(value => !old.includes(value)) ?? next[0]!)}`
141
142 return {
143 decision: 'ask',
144 reason: `sparkle-guard: this edit ${what} in ${baseName(file)}, and installed apps only accept updates signed with the key matching the old value, so confirm only if you are rotating keys on purpose.`,
145 }
146}
147
148export const RULES = [
149 'deny: security delete-generic-password / delete-internet-password aimed at the Sparkle key (service https://sparkle-project.org, label "Private key for signing Sparkle updates", or account ed25519)',
150 'ask: generate_keys without -p/--lookUpPublicKey or -x/--exportedPrivateKeyFile (it creates a new key when none is found)',
151 'ask: generate_keys -f/--importedPrivateKeyFile (imports a key in place of the current one)',
152 'ask: Edit/Write that changes or removes an existing SUPublicEDKey value (adding one is allowed)',
153]
154