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

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.
hooks/register.ts 505 lines1import 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}
505hooks/detect.ts 939 lines1// 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}
939types/index.d.ts 12 lines1/** 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