SLOPSHOPPER

sparkle-guard

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…

newguardcommand
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · sparkle-guard
› fix the failing auth test and add an audit log call ⏺ 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 › /sparkle-guard ⎿ sparkle-guard: sparkle-guard protects the Sparkle EdDSA signing key: ⎿ sparkle-guard: - deny: security delete-generic-password / delete-internet-password aimed at the Sparkle key (service https:// ⎿ sparkle-guard: - ask: generate_keys without -p/--lookUpPublicKey or -x/--exportedPrivateKeyFile (it creates a new key when no ⎿ sparkle-guard: - ask: generate_keys -f/--importedPrivateKeyFile (imports a key in place of the current one) ⎿ sparkle-guard: - ask: Edit/Write that changes or removes an existing SUPublicEDKey value (adding one is allowed) ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

sparkle-guard

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.

What it does

tool.check rules, applied to the main conversation and subagents alike. An engine deny is never weakened.

ToolSituationVerdict
Bashsecurity 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
Bashgenerate_keys without -p/--lookUpPublicKey or -x/--exportedPrivateKeyFile (it creates a new key when it finds none, e.g. in another keychain or --account)ask
Bashgenerate_keys -f/--importedPrivateKeyFile (imports a key in place of the current one), even beside -pask
Edit, Writethe change modifies or removes an existing SUPublicEDKey valueask
  • The binary is matched by basename (generate_keys, ./bin/generate_keys, …/Sparkle/bin/generate_keys); rtk, sudo, env, xcrun, VAR=value prefixes are stripped and &&, ||, ;, | chains are split.
  • Edits are judged on the whole file: the current text is read, the edit applied (first occurrence, or all with 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.
  • Values are found in plists (<key>SUPublicEDKey</key><string>…</string>), YAML (SUPublicEDKey: …), build settings / xcconfig (INFOPLIST_KEY_SUPublicEDKey = …) and JSON.
  • Every reason is one sentence saying why.

Commands

CommandEffect
/sparkle-guard statusLists the rules

Sparkle 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):

  • no flag: looks the key up in the login keychain and prints the public key; if none is found it generates a new key pair and stores it;
  • -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);
  • keychain item: service https://sparkle-project.org, label Private key for signing Sparkle updates.

Install

claude --plugin-dir /path/to/ModsTools/mods/sparkle-guard

Limits

  • Removing an existing SUPublicEDKey asks too (treated like a change).
  • Edits made through 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.
  • Commands hidden in sh -c "…", eval or scripts are not parsed.
  • An unreadable file (permissions) is not judged: the engine's verdict stands.

Develop

claude plugin validate mods/sparkle-guard
claude plugin test mods/sparkle-guard   # 16 tests
Source 2 files
hooks/register.ts 64 lines
1import 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}
64
hooks/rules.ts 154 lines
1// 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