SLOPSHOPPER

hw-write-guard

Asks before Claude runs or writes code that drives hardware: nidaqmx DO/AO writes, pymodbus coil/register writes, pyserial writes, MQTT publishes to printer…

newguardcommandtoast
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · hw-write-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 › /hw-guard ⎿ hw-write-guard: hw-write-guard asks before each hardware write (Bash, PowerShell, Monitor, Write, Edit, NotebookEdit). This se ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

claude-mods

Personal Claude Code mods, built or vendored at a reviewed commit. Each mod lives in its own folder and is listed in .claude-plugin/marketplace.json.

Source 3 files
hooks/register.ts 505 lines
1import type { EngineInterface, Register } from 'claude-code'
2
3import type { Mode } from '../types'
4import {
5  catted,
6  cdTarget,
7  dotnetSerialWrite,
8  DYN,
9  ipythonShell,
10  isNotebook,
11  isPsScript,
12  isPythonFile,
13  isShellScript,
14  literalWrite,
15  mosquittoTopic,
16  parseShell,
17  piped,
18  type PyImport,
19  pyImports,
20  pythonBlind,
21  pythonHits,
22  pythonRun,
23  scriptRun,
24  scrubPython,
25  touches,
26  writeHits,
27  writtenBy,
28} from './detect'
29
30// Asks before a tool call that would drive hardware. Every way the question can
31// fail to get a "Run it" (Cancel, Other text, closed, nobody to ask, an error in
32// this module) denies the call. /hw-guard notify or off drops the question (see
33// Mode below). Reads files with $.fs only: no network, no processes, no model calls.
34
35const ASK_OPTIONS = { options: ['Cancel', 'Run it'], header: 'HW write' } as const
36const stats = { asked: 0, ran: 0, denied: 0 }
37
38const isAbsolute = (p: string) => /^([A-Za-z]:[\\/]|[\\/])/.test(p)
39/** `p` from `dir`, with / separators and . and .. folded. */
40const join = (dir: string, p: string) => {
41  const out: string[] = []
42  for (const part of (isAbsolute(p) ? p : `${dir}/${p}`).split(/[\\/]+/)) {
43    if (part === '.' || (part === '' && out.length)) continue
44    if (part === '..' && out.length > 1) out.pop()
45    else out.push(part)
46  }
47  return out.join('/')
48}
49const dirOf = (p: string) => p.replace(/[\\/][^\\/]*$/, '')
50const short = (p: string) => p.replace(/^.*[\\/]/, '').split(DYN).join('')
51/** A path as written, for matching one write against one run: / separators, no leading ./, one case. */
52const norm = (p: string) => p.replace(/\\/g, '/').replace(/^(\.\/)+/, '').toLowerCase()
53const MAX_DIRS = 16
54const MAX_DEPTH = 3 // scripts that run scripts; modules that import modules
55const MAX_MODULES = 40 // local modules read for one Python run
56
57async function readText($: EngineInterface, path: string): Promise<string | undefined> {
58  try {
59    const text = await $.fs.read(path)
60    return typeof text === 'string' ? text : undefined
61  } catch {
62    return undefined
63  }
64}
65
66/** A file's text; null when it exists but can't be read (over 4 MiB, no access), undefined when there is none. */
67async function readOrNull($: EngineInterface, path: string): Promise<string | null | undefined> {
68  const text = await readText($, path)
69  if (text !== undefined) return text
70  return (await $.fs.exists(path).catch(() => true)) ? null : undefined
71}
72
73type Scan = { found: string[]; unseen: string[] }
74/** What a command writes to a path before running it: the text when literal, null when it can't be told, undefined when nothing does. */
75type WrittenTo = (path: string) => string | null | undefined
76
77/** The files `import a.b` or `from a.b import c` may load: a.py, a/__init__.py, a/b.py, ... and a/b/c.py. */
78function modulePaths(imp: PyImport, dir: string | undefined, roots: string[]): string[] {
79  const dots = /^\.*/.exec(imp.from ?? '')?.[0].length ?? 0
80  if (dots && dir === undefined) return []
81  let bases = roots
82  if (dots) {
83    let d = dir as string
84    for (let k = 1; k < dots; k++) d = join(d, '..')
85    bases = [d]
86  }
87  const parts = (imp.from === undefined ? imp.name : imp.from.slice(dots)).split('.').filter(Boolean)
88  if (imp.from !== undefined && imp.name !== '*') parts.push(imp.name) // c may be a submodule
89  const out: string[] = []
90  for (const b of bases) {
91    for (let k = 1; k <= parts.length; k++) {
92      const p = join(b, parts.slice(0, k).join('/'))
93      out.push(`${p}.py`, `${p}/__init__.py`)
94    }
95  }
96  return out
97}
98
99/**
100 * The local modules Python imports, MAX_DEPTH deep and MAX_MODULES at most:
101 * each that resolves to a file under `roots` (the script's folder, the working
102 * directory) or, for a relative import, its own package. Stdlib and installed
103 * packages resolve to no file and are left alone; a local one that can't be
104 * read, or that the command changes first, goes in `unseen`.
105 */
106async function localImports($: EngineInterface, code: string, file: string | undefined, roots: string[], writtenTo: WrittenTo, where: string): Promise<{ files: { path: string; text: string }[]; unseen: string[] }> {
107  const files: { path: string; text: string }[] = []
108  const unseen: string[] = []
109  const tried = new Set<string>()
110  const queue = [{ code, dir: file === undefined ? undefined : dirOf(file), depth: 0 }]
111  for (let item = queue.shift(); item; item = queue.shift()) {
112    if (item.depth >= MAX_DEPTH) continue
113    for (const imp of pyImports(scrubPython(item.code).code)) {
114      for (const path of modulePaths(imp, item.dir, roots)) {
115        if (tried.has(path)) continue
116        tried.add(path)
117        const doc = writtenTo(path)
118        if (doc === null) {
119          unseen.push(`${short(path)} (changed earlier in the command)`)
120          continue
121        }
122        // most names are stdlib or installed: one exists() each, a read only for what is there
123        if (doc === undefined && !(await $.fs.exists(path).catch(() => true))) continue
124        if (doc === undefined && files.length >= MAX_MODULES) {
125          unseen.push(`modules ${where} imports past the first ${MAX_MODULES}`)
126          return { files, unseen }
127        }
128        const text = doc ?? (await readText($, path))
129        if (text === undefined) unseen.push(`${short(path)} (imported by ${where})`)
130        else {
131          files.push({ path, text })
132          queue.push({ code: text, dir: dirOf(path), depth: item.depth + 1 })
133        }
134      }
135    }
136  }
137  return { files, unseen }
138}
139
140function notebookCode(json: string): string {
141  try {
142    const nb = JSON.parse(json) as { cells?: { cell_type?: string; source?: string | string[] }[] }
143    return (nb.cells ?? [])
144      .filter(c => c.cell_type === 'code')
145      .map(c => (Array.isArray(c.source) ? c.source.join('') : (c.source ?? '')))
146      .join('\n')
147  } catch {
148    return ''
149  }
150}
151
152/**
153 * Where a shell command runs from (`dirs`, the session's by default), how many
154 * scripts deep it is, what the command that ran it wrote first, and the script
155 * it is read from.
156 */
157type ShellContext = { dirs?: string[]; depth?: number; outer?: WrittenTo; label?: string }
158
159/**
160 * The hardware writes a shell command would run, and what it runs that could
161 * not be read (missing file, python -m, stdin from elsewhere, $VARs, a file the
162 * command itself changes first).
163 */
164async function shellHits($: EngineInterface, command: string, ps: boolean, ctx: ShellContext = {}): Promise<Scan> {
165  const { segments, heredocs, opaque } = parseShell(command, ps)
166  const depth = ctx.depth ?? 0
167  const at = ctx.label ? ` (${ctx.label})` : ''
168  // Every directory a relative path may be relative to: the session's, then
169  // each cd/pushd/Set-Location target seen so far. Kept, not replaced, so a cd
170  // in a subshell or a branch that never ran can't hide the real directory.
171  let dirs = ctx.dirs ?? [await $.session.cwd()]
172  let lost = false // a cd went somewhere that can't be named: relative paths can't be read
173  // What the command writes anywhere in it (order aside, so a write in a $( ) or
174  // bash -c counts too): heredoc and literal text it writes, and every other file it may change.
175  const writes: { key: string; text?: string }[] = []
176  const heredocSegments = new Set<number>()
177  for (const h of heredocs) {
178    if (h.target === undefined) continue
179    writes.push({ key: norm(h.target), text: h.body })
180    heredocSegments.add(h.segment)
181  }
182  for (const pipeline of segments) {
183    for (let s = 0; s < pipeline.length; s++) {
184      if (heredocSegments.has(pipeline[s]!.index)) continue
185      const lit = literalWrite(pipeline, s)
186      if (lit) writes.push({ key: norm(lit.path), text: lit.text })
187      else for (const p of writtenBy(pipeline[s]!.words)) writes.push({ key: norm(p) })
188    }
189  }
190  const writtenTo: WrittenTo = path => {
191    const key = norm(path)
192    const hits = writes.filter(w => w.key === key || key.endsWith(`/${w.key}`) || w.key.endsWith(`/${key}`))
193    if (!hits.length) return ctx.outer?.(path)
194    return hits.length === 1 && hits[0]!.text !== undefined ? hits[0]!.text : null
195  }
196  const found = new Set<string>()
197  const unseen = new Set(opaque)
198  const merge = (scan: Scan) => {
199    for (const f of scan.found) found.add(f)
200    for (const u of scan.unseen) unseen.add(u)
201  }
202  if (dotnetSerialWrite(command)) found.add(`.NET SerialPort write${at}`)
203  /** Every text the paths may name, and whether the command changes one of them first. */
204  const readAll = async (...paths: string[]) => {
205    const files: { path: string; text: string }[] = []
206    let changed = false
207    for (const p of paths) {
208      const doc = writtenTo(p)
209      if (doc === null) changed = true
210      else if (doc !== undefined) files.push({ path: join(dirs[dirs.length - 1] ?? '', p), text: doc })
211      else if (p.includes(DYN) || (lost && !isAbsolute(p))) continue
212      else {
213        for (const dir of isAbsolute(p) ? [''] : dirs) {
214          const path = join(dir, p)
215          const text = await readText($, path)
216          if (text !== undefined) files.push({ path, text })
217        }
218      }
219    }
220    return { files, changed }
221  }
222  /** A file of shell commands the command runs, read and parsed in turn. */
223  const runScript = async (script: { path: string; ps: boolean }): Promise<Scan> => {
224    const name = short(script.path)
225    const { files, changed } = await readAll(script.path)
226    if (changed) return { found: [], unseen: [`${name} (changed earlier in the command)`] }
227    if (!files.length || files.some(f => f.text.includes(DYN))) return { found: [], unseen: [name] }
228    if (depth >= MAX_DEPTH) return { found: [], unseen: [`${name} (scripts nested too deep)`] }
229    const out: Scan = { found: [], unseen: [] }
230    for (const f of files) {
231      // the script's own folder, as PowerShell, cmd and sh name it
232      const here = dirOf(f.path)
233      const text = f.text
234        .replace(/\$PSScriptRoot\b/gi, here)
235        .replace(/%~dp0/gi, `${here}/`)
236        .replace(/\$\(\s*dirname\s+"?\$(0|\{0\}|\{BASH_SOURCE(\[0\])?\})"?\s*\)/g, here)
237      const sub = await shellHits($, text, script.ps, { dirs: [...new Set([...dirs, here])], depth: depth + 1, outer: writtenTo, label: name })
238      out.found.push(...sub.found)
239      out.unseen.push(...sub.unseen)
240    }
241    return out
242  }
243  /** The hardware writes in Python sources and the local modules they import; what can't be read goes in `unseen`. */
244  const scanPython = async (sources: { path?: string; text: string }[], where: string, roots: string[]) => {
245    for (const src of sources) {
246      const mods = await localImports($, src.text, src.path, roots, writtenTo, where)
247      for (const u of mods.unseen) unseen.add(u)
248      const seen = new Set<string>()
249      for (const hit of pythonHits(src.text)) {
250        seen.add(hit)
251        found.add(`${hit} (${where})`)
252      }
253      for (const m of mods.files) {
254        for (const hit of pythonHits(m.text)) {
255          seen.add(hit)
256          found.add(`${hit} (${short(m.path)} from ${where})`)
257        }
258      }
259      // a write split across files: the task made in a module, written in the script
260      if (mods.files.length) {
261        for (const hit of pythonHits([src.text, ...mods.files.map(m => m.text)].join('\n'))) if (!seen.has(hit)) found.add(`${hit} (${where} with its imports)`)
262      }
263      for (const b of pythonBlind(src.text)) unseen.add(`${b} in ${where}`)
264      for (const m of mods.files) for (const b of pythonBlind(m.text)) unseen.add(`${b} in ${short(m.path)}`)
265      // IPython's !cmd lines and %run, in a notebook or ipython -c
266      const shell = ipythonShell(src.text)
267      if (shell && depth >= MAX_DEPTH) unseen.add(`${where} (scripts nested too deep)`)
268      else if (shell) merge(await shellHits($, shell, false, { dirs, depth: depth + 1, outer: writtenTo }))
269    }
270  }
271  for (const pipeline of segments) {
272    for (let s = 0; s < pipeline.length; s++) {
273      const { words, index } = pipeline[s]!
274      const to = cdTarget(words)
275      if (to === null) lost = true
276      else if (to !== undefined) {
277        dirs = [...new Set([...dirs, ...dirs.map(d => join(d, to))])]
278        if (dirs.length > MAX_DIRS) lost = true
279      }
280      const topic = mosquittoTopic(words)
281      if (topic) found.add(`mosquitto_pub to ${topic}${at}`)
282      const script = scriptRun(words, ps)
283      if (script) {
284        merge(await runScript(script))
285        continue
286      }
287      const run = pythonRun(words)
288      if (!run) continue
289      if (run.kind === 'opaque') {
290        unseen.add(run.what)
291        continue
292      }
293      let sources: { path?: string; text: string }[]
294      let where: string
295      let changed = false
296      if (run.kind === 'code') {
297        sources = [{ text: run.code }]
298        where = run.label
299      } else if (run.kind === 'script' || run.kind === 'notebook') {
300        const paths = run.kind === 'script' ? [run.path] : run.paths
301        ;({ files: sources, changed } = await readAll(...paths))
302        if (run.kind === 'notebook') sources = sources.map(f => ({ ...f, text: notebookCode(f.text) }))
303        where = paths.map(short).join(', ')
304      } else if (run.kind === 'module') {
305        const rel = run.module.replace(/\./g, '/')
306        ;({ files: sources, changed } = await readAll(`${rel}.py`, `${rel}/__main__.py`))
307        where = `python -m ${run.module.split(DYN).join('')}`
308      } else {
309        const prev = s > 0 ? pipeline[s - 1] : undefined
310        const doc = heredocs.find(h => !h.target && (h.segment === index || h.segment === prev?.index))
311        const file = run.file ?? (prev && catted(prev.words))
312        if (!prev && !doc && file === undefined) continue // nothing feeds stdin
313        const text = doc?.body ?? (prev && piped(prev.words))
314        if (text !== undefined) sources = [{ text }]
315        else if (file !== undefined) ({ files: sources, changed } = await readAll(file))
316        else sources = []
317        where = file !== undefined && text === undefined ? short(file) : 'python stdin'
318      }
319      if (changed) {
320        unseen.add(`${where} (changed earlier in the command)`)
321        continue
322      }
323      if (!sources.length || sources.some(c => c.text.includes(DYN))) {
324        unseen.add(where)
325        continue
326      }
327      // local imports resolve from the script's folder (Python's own rule) and the working directory
328      const roots = [...new Set([...sources.flatMap(f => (f.path === undefined ? [] : [dirOf(f.path)])), ...dirs])]
329      await scanPython(sources, where, roots)
330    }
331  }
332  return { found: [...found], unseen: [...unseen] }
333}
334
335/** The code lines `after` has that `before` lacks. */
336const newLines = (before: string, after: string) => {
337  const old = new Set(before.split('\n'))
338  return after
339    .split('\n')
340    .filter(l => !old.has(l))
341    .join('\n')
342}
343
344/**
345 * What writing a file puts in place: for Python and notebooks the hardware
346 * writes it adds (or touches); for a file of shell commands the hardware writes
347 * it would run. `partial`: the new text is an edit of the old, so an old text
348 * that can't be read leaves the result unknown.
349 */
350async function fileHits($: EngineInterface, path: string, apply: (before: string) => string, changed: string, partial: boolean): Promise<Scan> {
351  const name = short(path)
352  if (!isPythonFile(path) && !isNotebook(path) && !isShellScript(path)) return { found: [], unseen: [] }
353  const read = await readOrNull($, path)
354  if (read === null && partial && isNotebook(path)) return { found: [], unseen: [name] }
355  const before = read ?? ''
356  const after = apply(before)
357  if (isShellScript(path)) {
358    // Running it is checked again then; here only the writes it would run are named.
359    const ps = isPsScript(path)
360    const ctx = { dirs: [...new Set([await $.session.cwd(), dirOf(path)])], depth: 1 }
361    const now = await shellHits($, after, ps, ctx)
362    const had = new Set((await shellHits($, before, ps, ctx)).found)
363    const isNew = now.found.some(h => !had.has(h)) || (await shellHits($, changed, ps, ctx)).found.length > 0
364    return { found: isNew ? now.found.map(h => `${h} in ${name}`) : [], unseen: [] }
365  }
366  const code = isNotebook(path) ? notebookCode : (text: string) => text
367  const was = code(before)
368  const is = code(after)
369  const hits = writeHits(was, is, isNotebook(path) ? newLines(was, is) : changed)
370  const unknown = read === null && partial && !hits.length && touches(changed) ? [name] : []
371  const blind = pythonBlind(is).filter(b => !pythonBlind(was).includes(b))
372  return { found: hits.length ? [`${hits.join('; ')} in ${name}`] : [], unseen: [...unknown, ...blind.map(b => `${b} in ${name}`)] }
373}
374
375async function ask($: EngineInterface, summary: string, title = 'Hardware write'): Promise<string | undefined> {
376  stats.asked += 1
377  let answer: string
378  try {
379    answer = await $.ui.ask(`${title}: ${summary}`, ASK_OPTIONS)
380  } catch {
381    stats.denied += 1
382    return 'hw-write-guard: the hardware-write question was closed or nobody could answer it, so the call was blocked.'
383  }
384  if (answer === 'Run it') {
385    stats.ran += 1
386    return undefined
387  }
388  stats.denied += 1
389  return `hw-write-guard: the user did not approve this hardware write (${summary}). Do not retry it unless they ask.`
390}
391
392const DENY_ON_ERROR = 'hw-write-guard: the hardware-write check failed, so the call was blocked.'
393
394// ask: the question above (the default). notify: no question, a toast names the
395// write and it runs. off: no check at all. Kept per machine in $.store; a store
396// that can't be read means ask. Published in $.state for other mods to show.
397const MODES: readonly Mode[] = ['ask', 'notify', 'off']
398const MODE_TEXT: Record<Mode, string> = {
399  ask: 'asks before each hardware write',
400  notify: 'shows a toast and lets hardware writes run without asking',
401  off: 'is off: nothing is checked',
402}
403const MODE = { plugin: 'hw-write-guard', key: 'mode' } as const
404async function modeOf($: EngineInterface): Promise<Mode> {
405  const m = await $.store.get('mode').catch(() => undefined)
406  return MODES.includes(m as Mode) ? (m as Mode) : 'ask'
407}
408/** Shows the mode to other mods (mission-control's band); a failure here never gets in the guard's way. */
409async function publishMode($: EngineInterface, mode: Mode): Promise<void> {
410  const held = await $.state.get(MODE).catch(() => undefined)
411  if (held?.value !== mode) await $.state.set(MODE, mode).catch(() => undefined)
412}
413
414export const register: Register = on => {
415  on('session.start', async ($, e, next) => {
416    await $.command.register({
417      name: 'hw-guard',
418      description: 'hw-write-guard status, or /hw-guard ask | notify | off',
419      argumentHint: '[ask | notify | off]',
420    })
421    await publishMode($, await modeOf($))
422    return next(e)
423  })
424
425  on('command.run', { command: 'hw-guard' }, async ($, e) => {
426    const word = String(e.args ?? '').trim().toLowerCase()
427    if (word) {
428      if (!MODES.includes(word as Mode)) return { text: 'Usage: /hw-guard [ask | notify | off]' }
429      // Only the user's own typing (here or through Remote Control) changes the mode:
430      // never Claude, an agent, a scheduled prompt or another session.
431      const kind = (e.origin as { kind?: string } | undefined)?.kind
432      if (kind !== 'composer' && kind !== 'bridge') {
433        return { text: 'hw-write-guard: only the user can change the mode, by typing /hw-guard at the prompt.' }
434      }
435      await $.store.set('mode', word)
436      await publishMode($, word as Mode)
437      return { text: `hw-write-guard now ${MODE_TEXT[word as Mode]}. /hw-guard ask turns the question back on.` }
438    }
439    return {
440      text: `hw-write-guard ${MODE_TEXT[await modeOf($)]} (Bash, PowerShell, Monitor, Write, Edit, NotebookEdit). This session: asked ${stats.asked}, ran ${stats.ran}, denied ${stats.denied}. Change it with /hw-guard ask | notify | off.`,
441    }
442  })
443
444  on('tool.call', { tool: ['Bash', 'PowerShell', 'Monitor', 'Write', 'Edit', 'NotebookEdit'] }, async ($, e, next) => {
445    const mode = await modeOf($)
446    await publishMode($, mode) // a change made in another session shows here too
447    if (mode === 'off') return next(e)
448    let scan: Scan = { found: [], unseen: [] }
449    if (e.tool === 'Bash' || e.tool === 'PowerShell') {
450      // Python that runs but could not be read asks too: never allow what wasn't seen.
451      scan = await shellHits($, e.command, e.tool === 'PowerShell')
452    } else if (e.tool === 'Monitor') {
453      // Monitor does not say which shell runs its command: read it both ways.
454      if (e.command !== undefined) {
455        const [bashScan, psScan] = [await shellHits($, e.command, false), await shellHits($, e.command, true)]
456        scan = { found: [...new Set([...bashScan.found, ...psScan.found])], unseen: [...new Set([...bashScan.unseen, ...psScan.unseen])] }
457      }
458    } else if (e.tool === 'Write') {
459      scan = await fileHits($, e.file_path, () => e.content, e.content, false)
460    } else if (e.tool === 'Edit') {
461      scan = await fileHits(
462        $,
463        e.file_path,
464        before => {
465          if (e.replace_all) return before.split(e.old_string).join(e.new_string)
466          const at = before.indexOf(e.old_string)
467          return at === -1 ? before + '\n' + e.new_string : before.slice(0, at) + e.new_string + before.slice(at + e.old_string.length)
468        },
469        e.new_string,
470        true,
471      )
472    } else if (e.tool === 'NotebookEdit') {
473      if (e.cell_type !== 'markdown' && e.edit_mode !== 'delete') {
474        // a notebook that exists but can't be read (over 4 MiB) asks when the new cell could write
475        const read = await readOrNull($, e.notebook_path)
476        const before = notebookCode(read ?? '')
477        const after = `${before}\n${e.new_source}`
478        const hits = writeHits(before, after, e.new_source)
479        const name = short(e.notebook_path)
480        const blind = pythonBlind(after).filter(b => !pythonBlind(before).includes(b))
481        scan = {
482          found: hits.length ? [`${hits.join('; ')} in ${name}`] : [],
483          unseen: [...(read === null && !hits.length && touches(e.new_source) ? [name] : []), ...blind.map(b => `${b} in ${name}`)],
484        }
485      }
486    }
487    const { found, unseen } = scan
488    if (!found.length && !unseen.length) return next(e)
489    const summary = [...found, ...(unseen.length ? [`could not inspect ${unseen.join(', ')}`] : [])].join('; ')
490    const title = found.length ? undefined : 'Hardware write check'
491    const shown = summary.length > 120 ? summary.slice(0, 117) + '...' : summary
492    if (mode === 'notify') {
493      stats.ran += 1
494      try {
495        $.ui.toast(`hw-write-guard: running ${shown}`)
496      } catch {
497        // a toast that can't show never blocks the call
498      }
499      return next(e)
500    }
501    const deny = await ask($, shown, title)
502    return deny === undefined ? next(e) : { deny }
503  }).catch(($, e, next) => (next.called ? next(e) : { deny: DENY_ON_ERROR }))
504}
505
hooks/detect.ts 939 lines
1// Pure detection: no `$`, so the tests reach every rule directly.
2// Python is scrubbed first (comments dropped, string bodies replaced by
3// numbered placeholders), so a mention in a comment or a string never matches.
4
5export type Scrubbed = { code: string; strings: string[] }
6
7const PLACEHOLDER = (n: number) => `"§${n}§"`
8
9export function scrubPython(src: string): Scrubbed {
10  const strings: string[] = []
11  let code = ''
12  let i = 0
13  while (i < src.length) {
14    const ch = src[i]
15    if (ch === '#') {
16      while (i < src.length && src[i] !== '\n') i++
17      continue
18    }
19    if (ch === '"' || ch === "'") {
20      const triple = src.startsWith(ch.repeat(3), i)
21      const close = triple ? ch.repeat(3) : ch
22      let j = i + close.length
23      let body = ''
24      while (j < src.length) {
25        if (src[j] === '\\') {
26          body += src.slice(j, j + 2)
27          j += 2
28          continue
29        }
30        if (src.startsWith(close, j)) break
31        if (!triple && src[j] === '\n') break
32        body += src[j]
33        j++
34      }
35      strings.push(body)
36      code += PLACEHOLDER(strings.length - 1)
37      i = j + close.length
38      continue
39    }
40    code += ch
41    i++
42  }
43  return { code, strings }
44}
45
46export const PRINTER_TOPIC = /(device\/[^/\s]+\/request|printer|octoprint|klipper|moonraker|prusa|bambu)/i
47
48const CALL = (name: string) => new RegExp(`\\.\\s*(?:${name})\\s*\\(`)
49
50/** One name an import binds: `import a.b as c` is { name: 'a.b', as: 'c' }, `from .x import y` { from: '.x', name: 'y' }. */
51export type PyImport = { from?: string; name: string; as?: string }
52
53/** The imports in scrubbed Python, wherever a statement starts: a line, after `;` or a `:` (try: import x). */
54export function pyImports(code: string): PyImport[] {
55  const out: PyImport[] = []
56  const re = /(?:^|[;:])[ \t]*(?:from[ \t]+(\.*[\w.]*)[ \t]+import[ \t]+(\([^)]*\)|[^;\n]*)|import[ \t]+([^;\n]*))/gm
57  for (const m of code.matchAll(re)) {
58    for (const part of (m[2] ?? m[3] ?? '').replace(/[()\\]/g, ' ').split(',')) {
59      const [name, as] = part.trim().split(/\s+as\s+/)
60      if (!name || !/^[\w.*]+$/.test(name)) continue
61      const imp: PyImport = { name }
62      if (m[1] !== undefined) imp.from = m[1]
63      if (as) imp.as = as.trim()
64      out.push(imp)
65    }
66  }
67  return out
68}
69
70/** The imports of `lib` or a submodule of it. */
71const importsOf = (imports: PyImport[], lib: string) =>
72  imports.filter(i => {
73    const mod = i.from ?? i.name
74    return mod === lib || mod.startsWith(`${lib}.`)
75  })
76/** `names` and the aliases `from lib import name as alias` gives them, as a regex alternation. */
77const named = (imports: PyImport[], names: string[]) =>
78  [...names, ...imports.flatMap(i => (i.as && names.includes(i.name) ? [i.as] : []))].join('|')
79
80/** Hardware writes in one Python source, each a short label; empty when none. */
81export function pythonHits(src: string): string[] {
82  const { code, strings } = scrubPython(src)
83  const imports = pyImports(code)
84  const hits: string[] = []
85
86  if (/\bnidaqmx\b/.test(code)) {
87    // add_do_/add_ao_ anywhere: task.do_channels.add_do_chan, or ch = task.do_channels; ch.add_do_chan
88    const kinds = [/\badd_do_\w/.test(code) && 'DO', /\badd_ao_\w/.test(code) && 'AO'].filter(Boolean)
89    const writes = CALL('write|start').test(code)
90    const streamWriter = /\b(Digital|Analog)\w*Writer\b/.test(code) && CALL('write_\\w+').test(code)
91    if (kinds.length && (writes || streamWriter)) hits.push(`nidaqmx ${kinds.join('/')} write`)
92  }
93
94  if (/\b(pymodbus|Modbus\w*Client)\b/.test(code)) {
95    const m =
96      /\.\s*(write_coils?|write_registers?|mask_write_register|readwrite_registers|write_file_record)\s*\(/.exec(code) ??
97      /\b(\w*Write\w*Request)\s*\(/.exec(code) // client.execute(WriteSingleCoilRequest(...))
98    if (m) hits.push(`pymodbus ${m[1]}`)
99  }
100
101  const serial = importsOf(imports, 'serial')
102  if (serial.length && new RegExp(`\\b(${named(serial, ['Serial', 'serial_for_url', 'RS485'])})\\s*\\(`).test(code) && CALL('write|writelines').test(code)) {
103    hits.push('pyserial write')
104  }
105
106  // client.publish(...), or paho.mqtt.publish's single() / multiple() under any alias
107  const paho = importsOf(imports, 'paho')
108  if (CALL('publish').test(code) || (paho.length && new RegExp(`\\b(${named(paho, ['single', 'multiple'])})\\s*\\(`).test(code))) {
109    const topic = strings.find(s => PRINTER_TOPIC.test(s))
110    if (topic !== undefined) hits.push(`MQTT publish to ${topic}`)
111  }
112
113  return hits
114}
115
116const HW_LIB = /\b(nidaqmx|pymodbus|serial|paho)\b/
117
118/**
119 * Python this check can't read beside a hardware library (named in code or a
120 * string): exec, eval, compile, __import__, importlib.import_module. Each a
121 * short label; empty when none.
122 */
123export function pythonBlind(src: string): string[] {
124  const { code, strings } = scrubPython(src)
125  if (!HW_LIB.test(code) && !strings.some(s => HW_LIB.test(s))) return []
126  const calls = [...code.matchAll(/(?<![.\w])(exec|eval|compile|__import__)\s*\(|\b(import_module)\s*\(/g)]
127  return [...new Set(calls.map(m => `${m[1] ?? m[2]}()`))]
128}
129
130const TOUCHES = /(\.\s*(write\w*|start|publish|mask_write_register|readwrite_registers)\s*\(|\b(single|multiple)\s*\(|add_(do|ao)_|\bSerial\s*\(|Write\w*Request|(?<![.\w])(exec|eval)\s*\()/
131
132/** Whether Python text calls something that could be a hardware write, were its imports known. */
133export const touches = (src: string) => TOUCHES.test(scrubPython(src).code)
134
135/**
136 * Whether writing `after` over `before` puts hardware-writing code in place:
137 * hits the old file lacked, or hits it had that the new text itself touches.
138 */
139export function writeHits(before: string, after: string, changed: string): string[] {
140  const now = pythonHits(after)
141  if (now.length === 0) return []
142  const had = new Set(pythonHits(before))
143  const isNew = now.some(h => !had.has(h))
144  return isNew || touches(changed) ? now : []
145}
146
147/** The shell commands IPython runs from notebook or ipython code: `!cmd` lines and `%run x.py`. */
148export function ipythonShell(code: string): string {
149  return code
150    .split('\n')
151    .flatMap(line => {
152      const m = /^\s*(?:!(.+)|%run\s+(.+))$/.exec(line)
153      return m ? [m[1] ?? `python ${m[2]}`] : []
154    })
155    .join('\n')
156}
157
158export const isPythonFile = (path: string) => /\.(py|pyw)$/i.test(path)
159export const isNotebook = (path: string) => /\.ipynb$/i.test(path)
160/** A file of shell commands; .bat/.cmd are read with the PowerShell rules, as `cmd /c` is. */
161export const isShellScript = (path: string) => /\.(ps1|psm1|bat|cmd|sh|bash|zsh)$/i.test(path)
162export const isPsScript = (path: string) => /\.(ps1|psm1|bat|cmd)$/i.test(path)
163
164/** .NET serial writes in PowerShell (or C# it compiles): a System.IO.Ports SerialPort and a .Write/.WriteLine call. */
165export const dotnetSerialWrite = (src: string) => /\bIO\.Ports\b/i.test(src) && /SerialPort/i.test(src) && /\.\s*Write(Line)?\s*\(/i.test(src)
166
167// ---------------------------------------------------------------- shells
168
169/**
170 * Marks text the shell fills in at run time ($VAR, $( ), backticks, an
171 * expanding heredoc). Code, paths and stdin carrying it can't be inspected.
172 */
173export const DYN = '\u0000'
174const VAR_START = /[A-Za-z_{0-9@*#?!$:]/
175
176export type Segment = { words: string[]; index: number }
177export type Shell = {
178  segments: Segment[][]
179  heredocs: { target?: string; body: string; segment: number }[]
180  /** Commands handed to another shell that can't be read here, e.g. "bash stdin". */
181  opaque: string[]
182}
183
184// PowerShell's ` escapes inside "..." (`0 is dropped so it can't fake DYN), and Bash's $'...' escapes.
185const PS_ESC: Record<string, string> = { n: '\n', t: '\t', r: '\r', a: '\x07', b: '\b', f: '\f', v: '\v', e: '\x1b', '0': '' }
186const C_ESC: Record<string, string> = { n: '\n', t: '\t', r: '\r', a: '\x07', b: '\b', f: '\f', v: '\v', e: '\x1b', '\\': '\\', "'": "'", '"': '"' }
187
188/** Index of the `)` closing the `(` at `open`, quotes skipped; src.length when unclosed. */
189function closeParen(src: string, open: number, ps: boolean): number {
190  const esc = ps ? '`' : '\\'
191  let depth = 0
192  for (let j = open; j < src.length; j++) {
193    const c = src[j]
194    if (c === esc) j++
195    else if (c === "'") {
196      const e = src.indexOf("'", j + 1)
197      if (e === -1) return src.length
198      j = e
199    } else if (c === '"') {
200      j++
201      while (j < src.length && src[j] !== '"') j += src[j] === esc ? 2 : 1
202    } else if (c === '(') depth++
203    else if (c === ')' && --depth === 0) return j
204  }
205  return src.length
206}
207
208/** Index of the backtick closing the one at `open`; src.length when unclosed. */
209function closeTick(src: string, open: number): number {
210  for (let j = open + 1; j < src.length; j++) {
211    if (src[j] === '\\') j++
212    else if (src[j] === '`') return j
213  }
214  return src.length
215}
216
217/**
218 * Splits a Bash or PowerShell command into pipelines of words, quotes
219 * resolved, with heredoc and here-string bodies kept as words of their own.
220 * A pipeline is the segments joined by `|`; pipelines are split by ; & && ||
221 * newlines, parentheses and (PowerShell) braces. What runs inside $( ),
222 * backticks, ( ), <( ), `bash -c`, `cmd /c`, `pwsh -Command`, `iex` and a
223 * shell fed by a heredoc or a pipe is parsed too and added as pipelines of its
224 * own. Good enough to find what program runs, not a full shell.
225 */
226export function parseShell(src: string, ps: boolean): Shell {
227  const pipelines: Segment[][] = [[{ words: [], index: 0 }]]
228  const heredocs: Shell['heredocs'] = []
229  const opaque: string[] = []
230  const nested: { src: string; ps: boolean }[] = []
231  const pendingHeredocs: { tag: string; strip: boolean; segment: number; words: string[]; expands: boolean }[] = []
232  let word: string | null = null
233  let segIndex = 0
234  const cur = () => pipelines[pipelines.length - 1]!
235  const seg = () => cur()[cur().length - 1]!
236  const flush = () => {
237    if (word !== null) seg().words.push(word)
238    word = null
239  }
240  const split = () => {
241    flush()
242    segIndex++
243    pipelines.push([{ words: [], index: segIndex }])
244  }
245  /** Queues `inner` to be parsed as commands; the current word gets its unknown output. */
246  const substitute = (inner: string, text: string) => {
247    nested.push({ src: inner, ps })
248    word = (word ?? '') + DYN + text
249  }
250  /** Queues what an expanding heredoc or @"..."@ runs; DYN-marks it when the shell fills anything in. */
251  const expand = (text: string): string => {
252    let dyn = false
253    for (let j = 0; j < text.length; j++) {
254      if (text[j] === '$' && text[j + 1] === '(') {
255        const e = closeParen(text, j + 1, ps)
256        nested.push({ src: text.slice(j + 2, e), ps })
257        dyn = true
258        j = e
259      } else if (!ps && text[j] === '`') {
260        const e = closeTick(text, j)
261        nested.push({ src: text.slice(j + 1, e), ps })
262        dyn = true
263        j = e
264      } else if (text[j] === '$' && VAR_START.test(text[j + 1] ?? '')) dyn = true
265    }
266    return dyn ? DYN + text : text
267  }
268  let i = 0
269  while (i < src.length) {
270    const ch = src[i]
271    const next = src[i + 1]
272    // PowerShell here-string: @' ... '@ or @" ... "@, closer at a line start
273    if (ps && ch === '@' && (next === "'" || next === '"') && word === null) {
274      const start = src.indexOf('\n', i)
275      const end = src.indexOf(`\n${next}@`, start)
276      if (start !== -1 && end !== -1) {
277        const body = src.slice(start + 1, end).replace(/\r$/, '')
278        seg().words.push(next === '"' ? expand(body) : body)
279        i = end + 3
280        continue
281      }
282    }
283    // a comment runs nothing
284    if (ch === '#' && word === null) {
285      while (i < src.length && src[i] !== '\n') i++
286      continue
287    }
288    if (ch === "'") {
289      // literal; PowerShell writes a quote inside as ''
290      let j = i + 1
291      let body = ''
292      for (;;) {
293        const e = src.indexOf("'", j)
294        if (e === -1) {
295          body += src.slice(j)
296          j = src.length
297          break
298        }
299        body += src.slice(j, e)
300        j = e + 1
301        if (!(ps && src[j] === "'")) break
302        body += "'"
303        j++
304      }
305      word = (word ?? '') + body
306      i = j
307      continue
308    }
309    if (!ps && ch === '$' && next === "'") {
310      let j = i + 2
311      let body = ''
312      while (j < src.length && src[j] !== "'") {
313        if (src[j] === '\\' && j + 1 < src.length) {
314          body += C_ESC[src[j + 1]!] ?? src.slice(j, j + 2)
315          j += 2
316          continue
317        }
318        body += src[j++]
319      }
320      word = (word ?? '') + body
321      i = j + 1
322      continue
323    }
324    if (ch === '"') {
325      // Bash: \ escapes only $ ` " \ and newline here, so "C:\work\x.py" keeps
326      // its backslashes. PowerShell: ` escapes and "" is a quote. Both run $( ).
327      let j = i + 1
328      let body = ''
329      while (j < src.length) {
330        const c = src[j]
331        if (c === '"') {
332          if (!(ps && src[j + 1] === '"')) break
333          body += '"'
334          j += 2
335          continue
336        }
337        if (!ps && c === '\\' && j + 1 < src.length && '$`"\\\n'.includes(src[j + 1]!)) {
338          if (src[j + 1] !== '\n') body += src[j + 1]
339          j += 2
340          continue
341        }
342        if (ps && c === '`' && j + 1 < src.length) {
343          body += PS_ESC[src[j + 1]!] ?? src[j + 1]!
344          j += 2
345          continue
346        }
347        if ((c === '$' && src[j + 1] === '(') || (!ps && c === '`')) {
348          const e = c === '`' ? closeTick(src, j) : closeParen(src, j + 1, ps)
349          nested.push({ src: src.slice(c === '`' ? j + 1 : j + 2, e), ps })
350          body += DYN + src.slice(j, e + 1)
351          j = e + 1
352          continue
353        }
354        if (c === '$' && VAR_START.test(src[j + 1] ?? '')) body += DYN
355        body += c
356        j++
357      }
358      word = (word ?? '') + body
359      i = j + 1
360      continue
361    }
362    if ((ch === '$' || (ps && ch === '@')) && next === '(') {
363      const e = closeParen(src, i + 1, ps)
364      substitute(src.slice(i + 2, e), src.slice(i, e + 1))
365      i = e + 1
366      continue
367    }
368    if (!ps && ch === '`') {
369      const e = closeTick(src, i)
370      substitute(src.slice(i + 1, e), src.slice(i, e + 1))
371      i = e + 1
372      continue
373    }
374    if (ps && ch === '`' && i + 1 < src.length) {
375      // PowerShell escape outside quotes; ` at a line end continues the line
376      if (next === '\r' && src[i + 2] === '\n') i++
377      else if (next !== '\n') word = (word ?? '') + next
378      i += 2
379      continue
380    }
381    if (ch === '(') {
382      // a subshell, or a value an inner command computes
383      const e = closeParen(src, i, ps)
384      if (word === null && seg().words.length === 0) nested.push({ src: src.slice(i + 1, e), ps })
385      else substitute(src.slice(i + 1, e), src.slice(i, e + 1))
386      i = e + 1
387      continue
388    }
389    if (ch === ')' || (ps && (ch === '{' || ch === '}'))) {
390      split()
391      i++
392      continue
393    }
394    if (ch === '$' && VAR_START.test(next ?? '')) {
395      const close = next === '{' ? src.indexOf('}', i) : i
396      const end = close === -1 ? src.length : close + 1
397      word = (word ?? '') + DYN + src.slice(i, end)
398      i = end
399      continue
400    }
401    if (!ps && ch === '\\' && i + 1 < src.length) {
402      if (next !== '\n') word = (word ?? '') + next
403      i += 2
404      continue
405    }
406    if (!ps && src.startsWith('<<<', i)) {
407      flush()
408      seg().words.push('<<<')
409      i += 3
410      continue
411    }
412    if (!ps && ch === '<' && next === '<') {
413      flush()
414      let j = i + 2
415      const strip = src[j] === '-'
416      if (strip) j++
417      while (src[j] === ' ') j++
418      const m = /^(['"]?)([A-Za-z_][\w-]*)\1/.exec(src.slice(j))
419      if (m) {
420        // the segment's words, still growing: its `> f` may come after the <<EOF
421        pendingHeredocs.push({ tag: m[2]!, strip, segment: segIndex, words: seg().words, expands: !m[1] })
422        i = j + m[0].length
423        continue
424      }
425    }
426    if (ch === '\n' || ch === ';' || (ch === '&' && next === '&') || (ch === '|' && next === '|')) {
427      flush()
428      if (ch === '\n' && pendingHeredocs.length) {
429        let pos = i + 1
430        for (const h of pendingHeredocs.splice(0)) {
431          const lines: string[] = []
432          while (pos < src.length) {
433            const nl = src.indexOf('\n', pos)
434            const line = src.slice(pos, nl === -1 ? src.length : nl).replace(/\r$/, '')
435            pos = nl === -1 ? src.length : nl + 1
436            if ((h.strip ? line.replace(/^\t+/, '') : line) === h.tag) break
437            lines.push(line)
438          }
439          const body = lines.join('\n')
440          // `cat > f <<EOF` and `cat <<EOF > f` write the body to f; any other command reads it on stdin
441          const gt = h.words.lastIndexOf('>')
442          const target = gt !== -1 && h.words[0] === 'cat' ? h.words[gt + 1] : undefined
443          heredocs.push({ target, body: h.expands ? expand(body) : body, segment: h.segment })
444        }
445        i = pos
446      } else {
447        i += ch === '&' || ch === '|' ? 2 : 1
448      }
449      segIndex++
450      pipelines.push([{ words: [], index: segIndex }])
451      continue
452    }
453    if (ch === '&') {
454      if (next === '>') {
455        // &> file, &>> file
456        flush()
457        seg().words.push(src[i + 2] === '>' ? '>>' : '>')
458        i += src[i + 2] === '>' ? 3 : 2
459        continue
460      }
461      // PowerShell's call operator starts a command; anywhere else & ends one
462      if (ps && word === null && seg().words.length === 0) seg().words.push('&')
463      else split()
464      i++
465      continue
466    }
467    if (ch === '|') {
468      flush()
469      segIndex++
470      cur().push({ words: [], index: segIndex })
471      i += next === '&' ? 2 : 1
472      continue
473    }
474    if (!ps && (ch === '<' || ch === '>') && next === '(') {
475      // <( ) and >( ) run a command; the word is a pipe path
476      flush()
477      const e = closeParen(src, i + 1, ps)
478      substitute(src.slice(i + 2, e), src.slice(i, e + 1))
479      i = e + 1
480      continue
481    }
482    if (ch === '>' || ch === '<') {
483      if (word !== null && /^\d+$/.test(word)) word = null // the fd in 2>
484      flush()
485      seg().words.push(ch === '>' && next === '>' ? '>>' : ch) // >> appends
486      i += next === ch ? 2 : 1
487      if (ch === '>' && src[i] === '|') i++
488      const fd = /^&(\d+|-)/.exec(src.slice(i))
489      if (fd) {
490        seg().words.push(fd[0]!)
491        i += fd[0].length
492      }
493      continue
494    }
495    if (ch === ' ' || ch === '\t' || ch === '\r') {
496      flush()
497      i++
498      continue
499    }
500    word = (word ?? '') + ch
501    i++
502  }
503  flush()
504  const segments = pipelines.map(p => p.filter(s => s.words.length > 0)).filter(p => p.length > 0)
505  for (const p of segments) {
506    for (let s = 0; s < p.length; s++) {
507      const to = handedOn(p[s]!.words)
508      if (!to) continue
509      if (to.src !== undefined) nested.push({ src: to.src, ps: to.ps })
510      else if (!to.stdin) opaque.push(to.name)
511      else {
512        // a shell reading its commands from a heredoc or a pipe
513        const doc = heredocs.find(h => h.segment === p[s]!.index && !h.target)
514        const text = doc ? doc.body.replace(DYN, '') : s > 0 ? piped(p[s - 1]!.words) : undefined
515        if (text !== undefined) nested.push({ src: text, ps: to.ps })
516        else if (s > 0) opaque.push(`${to.name} stdin`)
517      }
518    }
519  }
520  for (const n of nested) {
521    const sub = parseShell(n.src, n.ps)
522    const shift = segIndex + 1
523    for (const p of sub.segments) segments.push(p.map(x => ({ ...x, index: x.index + shift })))
524    for (const h of sub.heredocs) heredocs.push({ ...h, segment: h.segment + shift })
525    opaque.push(...sub.opaque)
526    segIndex = shift + Math.max(0, ...sub.segments.flat().map(x => x.index), ...sub.heredocs.map(h => h.segment))
527  }
528  return { segments, heredocs, opaque }
529}
530
531const base = (w: string) => w.replace(/^.*[\\/]/, '').replace(/\.exe$/i, '').toLowerCase()
532// python, python3, python3.12, python3.13t, pythonw, py, pyw, ipython, ipython3, with any directory and .exe
533const isPython = (w: string) => /^(i?pythonw?(\d+(\.\d+)*t?)?|pyw?)$/.test(base(w))
534const PY_ARG_FLAGS = new Set(['-X', '-W', '-Q'])
535// `python -m` targets that never run project code
536const QUIET_MODULES = new Set([
537  'pip', 'venv', 'ensurepip', 'virtualenv', 'pipx', 'build', 'twine', 'pydoc', 'py_compile', 'compileall', 'json.tool',
538  'http.server', 'site', 'sysconfig', 'platform', 'tabnanny', 'black', 'ruff', 'isort', 'mypy', 'flake8', 'pylint', 'pyright',
539  // test runners: treated like the bare `pytest` command, which is not inspected either; asking on
540  // every test run would teach "Run it" by reflex (a known gap: tests that drive hardware)
541  'pytest', 'unittest',
542])
543
544// Programs that run the rest of their words as a command: the options that
545// take a value, and how many plain words come before the command.
546const WRAPPERS = new Map<string, { arg?: RegExp; skip?: number } | null>([
547  ...['&', '!', '{', 'do', 'then', 'else', 'elif', 'if', 'while', 'until', 'nohup', 'builtin', 'call'].map(w => [w, null] as const),
548  ['time', {}],
549  ['command', {}],
550  ['exec', { arg: /^-a$/ }],
551  ['sudo', { arg: /^-[ugCDpPrtTU]$|^--(user|group|chdir|prompt|role|type|other-user|host)$/ }],
552  ['doas', { arg: /^-[uC]$/ }],
553  ['env', { arg: /^-[uCS]$|^--(unset|chdir|split-string)$/ }],
554  ['nice', { arg: /^-n$|^--adjustment$/ }],
555  ['timeout', { arg: /^-[sk]$|^--(signal|kill-after)$/, skip: 1 }],
556  ['xargs', { arg: /^-[aEdeLlnPsI]$|^--(arg-file|delimiter|eof|max-lines|max-args|max-procs|max-chars|replace|process-slot-var)$/ }],
557  ['stdbuf', { arg: /^-[ioe]$/ }],
558  ['watch', { arg: /^-n$|^--interval$/ }],
559  ['wsl', { arg: /^-[du]$|^--(distribution|user|cd)$/ }],
560])
561/** Words of a Windows command line, as Start-Process passes its joined -ArgumentList: "..." groups; a comma splits a list. */
562const cmdline = (s: string) => [...s.matchAll(/"([^"]*)"?|[^\s,"]+/g)].map(m => m[1] ?? m[0])
563
564/**
565 * The program and arguments of Start-Process [-FilePath] python [-ArgumentList]
566 * "-u a.py" | 'a','b' (parameter names may be cut short, -Name:value too), or
567 * cmd's start "" /wait python a.py.
568 */
569function startProcess(w: string[]): string[] {
570  let file: string | undefined
571  let args: string[] | undefined
572  const pos: string[] = []
573  for (let k = 1; k < w.length; k++) {
574    const x = w[k]!
575    if (!pos.length && /^\/\w+$/.test(x)) {
576      if (/^\/d$/i.test(x)) k++ // cmd start /d <dir>
577      continue
578    }
579    const m = /^-([A-Za-z]+)(?::([\s\S]*))?$/.exec(x)
580    if (!m) {
581      if (x) pos.push(x)
582      continue
583    }
584    const name = m[1]!.toLowerCase()
585    if ('filepath'.startsWith(name) || name === 'path' || name === 'pspath') file = m[2] ?? w[++k]
586    else if ('argumentlist'.startsWith(name) || name === 'args') {
587      // one string, or a list: 'a','b' is one word here, 'a', 'b' several
588      const list = [m[2] ?? w[++k] ?? '']
589      while (list[list.length - 1]!.endsWith(',') && k + 1 < w.length) list.push(w[++k]!)
590      args = list
591    } else if (/^(wi|wo|v|r|c|e)/.test(name)) k++ // -WindowStyle, -WorkingDirectory, -Verb, -Redirect*, -Credential, -Environment take a value
592  }
593  file ??= pos.shift()
594  return file === undefined ? [] : [file, ...cmdline((args ?? pos).join(' '))]
595}
596
597/**
598 * The command after leading VAR=x / $x = assignments and wrappers (sudo,
599 * timeout 30, xargs, do, then, Start-Process, ...). `saps`: it starts in a
600 * process of its own, whose stdin is not this command's.
601 */
602export function stripWrappers(words: string[]): { w: string[]; xargs: boolean; saps: boolean } {
603  let w = words
604  let xargs = false
605  let saps = false
606  while (w.length) {
607    if (/^[A-Za-z_]\w*=/.test(w[0]!)) {
608      w = w.slice(1)
609      continue
610    }
611    if (w[0]!.startsWith(DYN + '$') && /^[-+*/]?=$/.test(w[1] ?? '')) {
612      w = w.slice(2) // PowerShell: $out = python ...
613      continue
614    }
615    const h = base(w[0]!)
616    if (h === 'start-process' || h === 'saps' || h === 'start') {
617      w = startProcess(w)
618      saps = true
619      continue
620    }
621    if (!WRAPPERS.has(h)) break
622    const spec = WRAPPERS.get(h)
623    if (h === 'xargs') xargs = true
624    w = w.slice(1)
625    if (!spec) continue
626    while (w.length && w[0]!.startsWith('-') && w[0] !== '-') {
627      const opt = w[0]!
628      w = w.slice(spec.arg?.test(opt) ? 2 : 1)
629      if (opt === '--') break
630    }
631    w = w.slice(spec.skip ?? 0)
632  }
633  return { w, xargs, saps }
634}
635
636const SHELLS = new Set(['bash', 'sh', 'zsh', 'dash', 'ksh', 'ash'])
637const PS_ARG = /^-(ex|ep|executionpolicy|w|windowstyle|wd|workingdirectory|of|outputformat|if|inputformat|settingsfile|configurationname|v|version)$/
638
639/** A pwsh -EncodedCommand argument (base64 UTF-16LE), or undefined. */
640function decodePs(b64: string): string | undefined {
641  try {
642    const bin = atob(b64)
643    let out = ''
644    for (let k = 0; k + 1 < bin.length; k += 2) out += String.fromCharCode(bin.charCodeAt(k) | (bin.charCodeAt(k + 1) << 8))
645    return out
646  } catch {
647    return undefined
648  }
649}
650
651/**
652 * The command text a segment hands to another shell (`src`), or that the shell
653 * reads its commands from stdin (`stdin`); neither means it can't be decoded.
654 */
655function handedOn(words: string[]): { name: string; ps: boolean; src?: string; stdin?: boolean } | undefined {
656  const w = stripWrappers(words).w
657  if (!w.length) return undefined
658  const h = base(w[0]!)
659  // one word is the whole command ("python a.py"); several are re-quoted and joined
660  const rest = (k: number) => (w.length === k + 1 ? w[k]! : w.slice(k).map(x => (/\s/.test(x) ? `"${x}"` : x)).join(' '))
661  if (SHELLS.has(h)) {
662    for (let k = 1; k < w.length; k++) {
663      if (/^-[A-Za-z]*c[A-Za-z]*$/.test(w[k]!)) return w[k + 1] === undefined ? undefined : { name: h, ps: false, src: w[k + 1] }
664      if (w[k] === '-o' || w[k] === '+o') k++
665      else if (!/^[-+]/.test(w[k]!)) return undefined // runs a script file: scriptRun
666    }
667    return { name: h, ps: false, stdin: true }
668  }
669  if (h === 'cmd') {
670    const k = w.findIndex(x => /^\/\/?[ck]$/i.test(x)) // //c: Git Bash's spelling of /c
671    return k === -1 ? undefined : { name: h, ps: true, src: rest(k + 1) }
672  }
673  if (h === 'pwsh' || h === 'powershell') {
674    for (let k = 1; k < w.length; k++) {
675      const a = w[k]!.toLowerCase()
676      if (/^-(c|co|com|comm|comma|comman|command)$/.test(a)) return { name: h, ps: true, src: rest(k + 1) }
677      if (/^-(e|ec|en|enc|enco|encod|encode|encoded|encodedc\w*)$/.test(a)) return { name: `${h} -EncodedCommand`, ps: true, src: decodePs(w[k + 1] ?? '') }
678      if (/^-(f|fi|fil|file)$/.test(a)) return undefined
679      if (PS_ARG.test(a)) k++
680      else if (!a.startsWith('-')) return h === 'powershell' ? { name: h, ps: true, src: rest(k) } : undefined
681    }
682    return { name: h, ps: true, stdin: true }
683  }
684  if (h === 'iex' || h === 'invoke-expression') return { name: h, ps: true, src: rest(1).replace(/^-command\s+/i, '') }
685  return undefined
686}
687
688/** A segment's words without redirections, and what they feed stdin: `< f`, `<<< text`. */
689function unredirected(words: string[]): { kept: string[]; file?: string; text?: string } {
690  let file: string | undefined
691  let text: string | undefined
692  const kept: string[] = []
693  for (let k = 0; k < words.length; k++) {
694    if (words[k] === '>' || words[k] === '>>') k++
695    else if (words[k] === '<') file = words[++k]
696    else if (words[k] === '<<<') text = words[++k]
697    else kept.push(words[k]!)
698  }
699  return { kept, file, text }
700}
701
702/**
703 * The file of shell commands a segment runs: ./run.sh, .\flash.ps1, & x.ps1,
704 * . x.ps1, source x.sh, bash run.sh, pwsh [-File] x.ps1, cmd /c x.bat (by way
705 * of the nested parse). `ps`: read it with the PowerShell rules.
706 */
707export function scriptRun(words: string[], ps: boolean): { path: string; ps: boolean } | undefined {
708  const w = stripWrappers(unredirected(words).kept).w
709  if (!w.length) return undefined
710  const h = base(w[0]!)
711  const kind = (path: string, fallback: boolean) => ({ path, ps: isPsScript(path) || (fallback && !/\.(sh|bash|zsh)$/i.test(path)) })
712  if ((h === '.' || h === 'source') && w[1] !== undefined) return kind(w[1], ps)
713  if (SHELLS.has(h)) {
714    for (let k = 1; k < w.length; k++) {
715      if (/^-[A-Za-z]*c[A-Za-z]*$/.test(w[k]!)) return undefined // handedOn's
716      if (w[k] === '-o' || w[k] === '+o') k++
717      else if (!/^[-+]/.test(w[k]!)) return { path: w[k]!, ps: false }
718    }
719    return undefined
720  }
721  if (h === 'pwsh' || h === 'powershell') {
722    for (let k = 1; k < w.length; k++) {
723      const a = w[k]!.toLowerCase()
724      if (/^-(f|fi|fil|file)$/.test(a)) return w[k + 1] === undefined ? undefined : { path: w[k + 1]!, ps: true }
725      if (/^-(c|co\w*|e|ec|en\w*)$/.test(a)) return undefined // -Command, -EncodedCommand: handedOn's
726      if (PS_ARG.test(a)) k++
727      else if (!a.startsWith('-')) return h === 'pwsh' ? { path: w[k]!, ps: true } : undefined // powershell: a command, handedOn's
728    }
729    return undefined
730  }
731  return isShellScript(w[0]!) ? kind(w[0]!, ps) : undefined
732}
733
734export type PythonRun =
735  | { kind: 'code'; code: string; label: string }
736  | { kind: 'script'; path: string }
737  | { kind: 'stdin'; file?: string }
738  | { kind: 'module'; module: string }
739  | { kind: 'notebook'; paths: string[] }
740  | { kind: 'opaque'; what: string }
741
742/**
743 * The notebooks `jupyter execute`, `jupyter nbconvert --execute` or `papermill`
744 * runs (`w` from the program on); undefined when it runs none.
745 */
746function notebookRun(w: string[]): PythonRun | undefined {
747  let h = base(w[0] ?? '')
748  let rest = w.slice(1)
749  if (h === 'jupyter' && rest[0] !== undefined) {
750    h = `jupyter-${rest[0].toLowerCase()}`
751    rest = rest.slice(1)
752  }
753  const runs = h === 'jupyter-execute' || h === 'papermill' || (h === 'jupyter-nbconvert' && rest.some(x => /^--execute\b/.test(x)))
754  if (!runs) return undefined
755  // papermill runs its input and writes its output; nbconvert and execute run every notebook named but --output's
756  const paths = rest
757    .filter((x, k) => !x.startsWith('-') && (/\.ipynb$/i.test(x) || x.includes(DYN)) && !/^--output(-dir)?$/.test(rest[k - 1] ?? ''))
758    .slice(0, h === 'papermill' ? 1 : undefined)
759  return paths.length ? { kind: 'notebook', paths } : undefined
760}
761const NOTEBOOK_MODULES: Record<string, string> = { jupyter: 'jupyter', nbconvert: 'jupyter-nbconvert', papermill: 'papermill' }
762
763/** What Python's own arguments run. */
764function pythonArgs(a: string[]): PythonRun | undefined {
765  for (let k = 0; k < a.length; k++) {
766    const x = a[k]!
767    if (/^(-V|--version|-h|--help|-\?)$/.test(x)) return undefined
768    if (x === '-') return { kind: 'stdin' }
769    if (PY_ARG_FLAGS.has(x)) {
770      k++
771      continue
772    }
773    const m = /^-[bBdEiIOPqRsSuvx]*([cm])([\s\S]*)$/.exec(x) // -c, -uc, -c'code', -mpkg
774    if (m) {
775      const value = m[2] || a[k + 1]
776      if (value === undefined) return undefined
777      if (m[1] === 'c') return { kind: 'code', code: value, label: 'python -c' }
778      const runner = NOTEBOOK_MODULES[value]
779      if (runner) return notebookRun([runner, ...a.slice(k + (m[2] ? 1 : 2))])
780      return QUIET_MODULES.has(value) ? undefined : { kind: 'module', module: value }
781    }
782    if (x.startsWith('-')) continue
783    return /\.ipynb$/i.test(x) ? { kind: 'notebook', paths: [x] } : { kind: 'script', path: x }
784  }
785  return { kind: 'stdin' }
786}
787
788/** The Python a segment's words run, if they run Python at all. */
789export function pythonRun(words: string[]): PythonRun | undefined {
790  // redirections: `> f` is dropped, `< f` and `<<< text` feed stdin
791  const { kept, file, text } = unredirected(words)
792  let { w, xargs, saps } = stripWrappers(kept)
793  if (!w.length) return undefined
794  const head = base(w[0]!)
795  if (['uv', 'poetry', 'pipenv', 'conda', 'pdm', 'hatch', 'rye'].includes(head)) {
796    const run = w.findIndex(x => x === 'run')
797    if (run === -1) return undefined
798    let rest = w.slice(run + 1)
799    while (rest.length && rest[0]!.startsWith('-')) rest = rest.slice(rest[0]!.includes('=') || !/^--?(with|python|project|directory|env-file|from|n|name|p|prefix)$/.test(rest[0]!) ? 1 : 2)
800    if (!rest.length) return undefined
801    if (/\.pyw?$/i.test(rest[0]!)) return { kind: 'script', path: rest[0]! }
802    w = rest
803  }
804  // `$PY x.py`: unknown program, so only Python-looking arguments count
805  if (w[0]!.includes(DYN)) {
806    const run = pythonArgs(w.slice(1))
807    return run?.kind === 'code' || run?.kind === 'module' || (run?.kind === 'script' && /\.pyw?$/i.test(run.path)) ? run : undefined
808  }
809  // ./drive.py, .\drive.py: the shebang line or the py launcher runs it
810  if (/\.pyw?$/i.test(w[0]!)) return { kind: 'script', path: w[0]! }
811  if (!isPython(w[0]!)) return notebookRun(w)
812  if (/^ipython/.test(base(w[0]!)) && /^(kernel|profile|history|locate)$/.test(w[1] ?? '')) return undefined // ipython kernel install, ...
813  const run = pythonArgs(w.slice(1))
814  if (run?.kind !== 'stdin') return run
815  if (text !== undefined) return { kind: 'code', code: text, label: 'python stdin' }
816  if (xargs) return { kind: 'opaque', what: 'python args from xargs' }
817  if (file !== undefined) return { kind: 'stdin', file }
818  return saps ? { kind: 'opaque', what: 'python under Start-Process' } : run
819}
820
821/** The MQTT printer topic a `mosquitto_pub` segment publishes to, if any. */
822export function mosquittoTopic(words: string[]): string | undefined {
823  const w = stripWrappers(words).w
824  if (!w.length || base(w[0]!) !== 'mosquitto_pub') return undefined
825  for (let k = 1; k < w.length; k++) {
826    if (w[k] === '-t' || w[k] === '--topic') {
827      const t = w[k + 1] ?? ''
828      return PRINTER_TOPIC.test(t) ? t : undefined
829    }
830  }
831  return undefined
832}
833
834/** Text a pipeline's earlier segment feeds to stdin: echo/printf/here-string/Write-Output. */
835export function piped(words: string[]): string | undefined {
836  if (!words.length) return undefined
837  const h = base(words[0]!)
838  if (['echo', 'printf', 'write-output'].includes(h)) {
839    const text = words.slice(1).filter(x => !/^-/.test(x)).join(' ')
840    return h === 'printf' || words.includes('-e') ? text.replace(/\\n/g, '\n').replace(/\\t/g, '\t') : text
841  }
842  // a lone here-string or quoted literal; a lone bare word is a command whose output is unknown
843  if (words.length === 1 && /\s/.test(words[0]!)) return words[0]
844  return undefined
845}
846
847/** Files a pipeline's earlier segment cats into stdin. */
848export function catted(words: string[]): string | undefined {
849  if (words.length >= 2 && ['cat', 'type', 'get-content', 'gc'].includes(base(words[0]!))) return words.filter(x => !x.startsWith('-'))[1]
850  return undefined
851}
852
853// Programs that never change the files they name (a redirect of theirs still does).
854const READERS = new Set([
855  'cat', 'type', 'gc', 'get-content', 'head', 'tail', 'less', 'more', 'grep', 'egrep', 'fgrep', 'rg', 'findstr', 'select-string', 'sls',
856  'wc', 'ls', 'dir', 'gci', 'get-childitem', 'stat', 'file', 'test', '[', '[[', 'diff', 'cmp', 'fc', 'compare-object', 'md5sum',
857  'sha1sum', 'sha256sum', 'get-filehash', 'which', 'where', 'get-command', 'gcm', 'test-path', 'resolve-path', 'get-item', 'gi',
858  'realpath', 'readlink', 'dirname', 'basename', 'du', 'echo', 'printf', 'write-output', 'write-host', 'cd', 'chdir', 'pushd', 'popd',
859  'set-location', 'sl', 'black', 'isort', 'ruff', 'flake8', 'pylint', 'mypy', 'pyright', 'pytest', 'chmod', 'attrib', 'icacls',
860  'touch', 'rm', 'del', 'erase', 'remove-item', 'ri', 'rmdir', 'mkdir', 'md', 'mosquitto_pub', 'jupyter', 'papermill',
861])
862const RUNNERS = new Set([...SHELLS, 'pwsh', 'powershell', 'cmd', '.', 'source', 'jupyter-execute', 'jupyter-nbconvert', 'iex', 'invoke-expression'])
863const GIT_READS = new Set(['diff', 'log', 'show', 'status', 'add', 'blame', 'ls-files', 'grep', 'commit', 'rm', 'fetch', 'branch', 'tag', 'remote', 'config'])
864const COPIERS = new Set(['cp', 'mv', 'copy', 'move', 'copy-item', 'cpi', 'move-item', 'mi', 'ren', 'rename', 'rename-item', 'rni', 'xcopy'])
865const IN_PLACE = new Set(['sed', 'perl', 'awk', 'gawk', 'ruby'])
866
867/**
868 * The paths a segment may change, as written: its redirect targets; for cp/mv
869 * and kin the destination; for sed/perl only with -i; for any other program
870 * that is not a known reader, every word it names (`-o f`, `of=f`, `-OutFile:f`).
871 */
872export function writtenBy(words: string[]): string[] {
873  const out: string[] = []
874  for (let k = 0; k < words.length; k++) if ((words[k] === '>' || words[k] === '>>') && words[k + 1] !== undefined) out.push(words[++k]!)
875  const w = stripWrappers(unredirected(words).kept).w
876  if (!w.length) return out
877  const h = base(w[0]!)
878  const args = w.slice(1)
879  // what runs code is read for what it runs (a script it names is its input, not its output)
880  const runs = pythonRun(words) !== undefined || scriptRun(words, false) !== undefined || RUNNERS.has(h) || isPython(w[0]!)
881  if (runs || READERS.has(h) || (h === 'git' && GIT_READS.has(args[0] ?? ''))) return out
882  if (IN_PLACE.has(h)) return args.some(x => /^-[A-Za-z]*i|^--in-place/.test(x)) ? [...out, ...args] : out
883  if (COPIERS.has(h)) {
884    const d = args.findIndex(x => /^-(dest\w*|newname)$/i.test(x))
885    const named = d === -1 ? undefined : args[d + 1]
886    const pos = args.filter((x, k) => !x.startsWith('-') && !(d !== -1 && k === d + 1))
887    const dest = named ?? pos.pop()
888    if (dest === undefined) return out
889    // into a folder (no extension, or a trailing separator): each source's name there
890    const intoDir = /[\\/]$/.test(dest) || !/\.[^\\/]*$/.test(dest)
891    return [...out, dest, ...(intoDir ? pos.map(s => `${dest.replace(/[\\/]$/, '')}/${s.replace(/^.*[\\/]/, '')}`) : [])]
892  }
893  return [...out, ...args.map(x => x.replace(/^-\w+:|^[\w-]*=/, ''))]
894}
895
896/**
897 * A file a segment fills with literal text: echo/printf/Write-Output > f, a
898 * here-string or literal piped to Set-Content / Out-File, or Set-Content f
899 * -Value text. Undefined for anything else, appends included.
900 */
901export function literalWrite(pipeline: Segment[], s: number): { path: string; text: string } | undefined {
902  const words = pipeline[s]!.words
903  const h = base(words[0] ?? '')
904  const redirects = words.filter(x => x === '>' || x === '>>' || x === '<' || x === '<<<')
905  if (redirects.length === 1 && words[words.length - 2] === '>') {
906    // only what echoes its words: a lone "quoted word" in Bash is a program, its output unknown
907    const text = ['echo', 'printf', 'write-output'].includes(h) ? piped(words.slice(0, -2)) : undefined
908    return text === undefined ? undefined : { path: words[words.length - 1]!, text }
909  }
910  if (redirects.length || !['set-content', 'sc', 'out-file'].includes(h)) return undefined
911  let path: string | undefined
912  let value: string | undefined
913  for (let k = 1; k < words.length; k++) {
914    const m = /^-([A-Za-z]+)(?::([\s\S]*))?$/.exec(words[k]!)
915    if (!m) {
916      if (path === undefined) path = words[k]
917      else if (value === undefined) value = words[k]
918      else return undefined
919      continue
920    }
921    const name = m[1]!.toLowerCase()
922    const arg = () => m[2] ?? words[++k]
923    if (/^(filter|include|exclude|en\w*|wi\w*|st\w*)$/.test(name)) arg() // -Encoding, -Width, -Stream, ...
924    else if (/^(p|pa|pat|path|pspath|fi\w*|li\w*|lp)$/.test(name)) path = arg()
925    else if (/^v/.test(name)) value = arg()
926    else if (!/^(nonewline|force|nocl\w*|confirm|whatif|passthru|asbytestream)$/.test(name)) return undefined // -Append and the unknown
927  }
928  const text = value ?? (s > 0 ? piped(pipeline[s - 1]!.words) : undefined)
929  return path === undefined || text === undefined ? undefined : { path, text }
930}
931
932/** Where a cd-like segment moves to: a path, null when it can't be told (cd, cd -, cd ~, cd $X), undefined when it is no cd. */
933export function cdTarget(words: string[]): string | null | undefined {
934  const w = stripWrappers(words).w
935  if (!w.length || !['cd', 'chdir', 'pushd', 'set-location', 'sl', 'push-location'].includes(base(w[0]!))) return undefined
936  const to = w.slice(1).filter(x => !/^(-[PLe@]|-path|-literalpath|-passthru|\/d)$/i.test(x))[0]
937  return to === undefined || to === '-' || to.startsWith('~') || to.includes(DYN) ? null : to
938}
939
types/index.d.ts 12 lines
1/** How hw-write-guard treats a hardware write: ask first, toast and let it run, or no check at all. */
2export type Mode = 'ask' | 'notify' | 'off'
3
4declare module 'claude-code' {
5  interface PluginState {
6    'hw-write-guard': {
7      /** the mode /hw-guard last set ('ask' when never set), for other mods to show (mission-control's band) */
8      mode: Mode
9    }
10  }
11}
12