SLOPSHOPPER

no-shell-edits

Stop Claude from editing files through Bash (sed -i, heredocs, echo >, tee, python -c) and point it to Edit/Write, so every change shows as a diff and /rewind…

newguardcommandtoaststatus
v0.1.0MITupdated 2026-10-06Jvrd97/claude-no-shell-edits
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · no-shell-edits
› 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 › /no-shell-edits ⎿ no-shell-edits: Nothing blocked this session. ⎿ no-shell-edits: ⎿ no-shell-edits: Settings ⎿ no-shell-edits: mode deny ⎿ no-shell-edits: blockReads off ⎿ no-shell-edits: allowPaths *.log, .git/** ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

no-shell-edits

A Claude Code mod that stops Claude from editing files through Bash (sed -i, heredocs, echo >, tee, python -c) and sends it to Edit or Write instead, so every change shows as a diff and /rewind can undo it.

⏺ Bash(sed -i '' 's/foo/bar/' src/app.ts)
  ⎿  Error: no-shell-edits blocked this command: `sed -i` on src/app.ts. Use Edit for changes to
     src/app.ts (or Write for a new file) so the change shows as a diff and /rewind can undo it.

⏺ Update(src/app.ts)
  ⎿  Updated src/app.ts with 1 addition and 1 removal

Why

In auto mode Claude often edits files through the shell instead of its file tools. Such a change has no diff in the transcript, skips your review, and /rewind cannot undo it, because checkpoints only track Edit and Write. Three open issues ask for a fix, with about 250 👍 together (checked 2026-10-06):

The mod guards the Bash tool: the model gets the refusal at the moment it reaches for sed, together with the exact tool to use instead.

What it does

CaughtExample
In-place editorssed -i, sed --in-place, perl -pi, awk -i inplace, also through xargs and find -exec
Redirections into a fileecho x > f, printf … >> f, cmd &> f, `>, 1> f`
Heredocs into a filecat > f <<'EOF' … EOF
tee`… \tee f, tee -a f`
Inline scripts that writepython -c "open('f','w')…", Path('f').write_text, node -e "fs.writeFileSync('f')", ruby -e 'File.write("f")', python - <<EOF … EOF
truncatetruncate -s 0 f
cp/mv over an existing filemv /tmp/edited f when f exists
Shell reads (opt-in)cat f, head -n 50 f, tail f, less f, more f with blockReads on

It also looks inside bash -c '…', eval, $(…) and sudo/env/timeout wrappers, and follows a cd earlier in the same command.

Never flagged: /dev/null and the rest of /dev, fd duplications like 2>&1, scratch files in /tmp, /var/folders or $TMPDIR outside the project, a $(mktemp) variable, paths in allowPaths, and build tools that write files on their own (make, cargo build, curl -o). Quotes, [[ $a > $b ]] and arithmetic $(( a > b )) are understood, so echo "a > b" and grep '>' f pass.

Besides the refusal:

WhereWhat
ToastOnce per kind per session: Blocked \sed -i\ on a.txt; Claude was told to use Edit
/no-shell-editsEvery command blocked this session (time, how, target, the command) and the settings in force
Status line (opt-in)no-shell-edits: 3 blocked
1 command blocked this session.

20:41:07  blocked  sed -i → a.txt
          $ sed -i '' 's/a/b/' a.txt

Settings
  mode        deny
  blockReads  off
  allowPaths  *.log, .git/**
  statusLine  off
Always allowed: /dev/*; outside the project also /tmp, /private/tmp, /var/tmp, /var/folders, /private/var/folders and $TMPDIR

Install

You need Claude Code with mods (function hooks); tested on 2.1.285 and 2.1.291. Mods are in early access: if the CLI says hooks modules are not turned on, start it as CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 claude.

From the marketplace in this repo:

/plugin marketplace add Jvrd97/claude-no-shell-edits
/plugin install no-shell-edits@no-shell-edits

Or straight from disk, for one session:

git clone https://github.com/Jvrd97/claude-no-shell-edits.git
claude --plugin-dir ./claude-no-shell-edits

Settings

Change them in /config under the plugin, or in settings.json under pluginConfigs:

FieldDefaultMeaning
modedenydeny refuses the command. warn lets it run and adds a note to the result Claude reads: what went wrong and to use Edit next time
blockReadsfalseAlso refuse cat/head/tail/less/more of a single file and tell Claude to use Read. tail -f, pipes and several files pass
allowPaths*.log,.git/**Comma-separated globs the shell may write. A glob without / matches a file name anywhere; one with / matches from the project root (dist/**). Setting it replaces the default
statusLinefalsePin no-shell-edits: N blocked under the prompt

How it works

  • tool.call on Bash reads the command with a small shell lexer: quotes, escapes, heredoc bodies, $(…), [[ ]] and arithmetic. It splits the line into simple commands and checks each one's redirections and program.
  • A target resolves against the session's working directory ($.session.cwd()), HOME and TMPDIR, then against the allow rules. A /tmp path inside the project counts as a project file: a repo that lives in a temp folder is still guarded.
  • For cp/mv the mod stats the destination and flags only an existing file.
  • deny returns the refusal before the command runs. warn runs it and appends one line of context to the result.
  • session.start registers /no-shell-edits. The session's flagged commands (up to 200) and the kinds already toasted live in the session's own state. The mod sends nothing anywhere and writes no files.

What it cannot do:

  • Know a target held in a variable it did not see set: echo x > "$out" passes. In-place editors (sed -i "$f") are flagged anyway, because editing is all they do.
  • See writes a program makes on its own: an awk print > "f", a script file python fix.py, git apply, dd of=f. Only inline -c/-e scripts and heredoc-fed ones are read, by pattern.
  • Stop a determined workaround. It is a guard for a habit, not a sandbox.
  • Avoid every false positive: make > out.txt or git diff > changes.patch inside the project are refused. Add such paths to allowPaths, or switch to warn.

Develop

Inside Claude Code run /plugin-types .claude/types once: it writes the API types tsc reads. Then:

tsc -p .
claude plugin validate .claude-plugin/plugin.json
claude plugin test .

По-русски

В auto mode Claude часто правит файлы через Bash: sed -i, heredoc, echo >, tee, python -c. У такой правки нет diff в транскрипте, она проходит мимо ревью, и /rewind её не откатит.

Мод перехватывает такие команды до запуска и отвечает модели, какой инструмент взять: Edit для существующего файла, Write для нового. Временные файлы, /dev/null, логи и пути из allowPaths проходят. Режим warn пропускает команду и только напоминает модели про Edit. /no-shell-edits показывает, что заблокировано за сессию.

License

MIT

Source 3 files
hooks/register.ts 113 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { ShellEditEvent } from '../types'
5import type { Context, Finding, Settings } from './logic'
6import {
7  analyze,
8  compileGlobs,
9  denyReason,
10  freshKinds,
11  report,
12  settingsOf,
13  statusText,
14  toastText,
15  warnContext,
16} from './logic'
17
18const COMMAND = 'no-shell-edits'
19const TOAST_MS = 6_000
20/** Events kept per session; /no-shell-edits lists them all. */
21const MAX_EVENTS = 200
22
23const events = atom({ plugin: 'no-shell-edits', key: 'events' } as const, [])
24const toasted = atom({ plugin: 'no-shell-edits', key: 'toasted' } as const, [])
25
26async function contextOf($: EngineInterface, settings: Settings): Promise<Context> {
27  const cwd = await $.session.cwd()
28  const home = await $.env.get('HOME')
29  const tmpDir = await $.env.get('TMPDIR')
30
31  return { cwd, home, tmpDir, allow: compileGlobs(settings.allowPaths), blockReads: settings.blockReads }
32}
33
34/** cp/mv count only when they replace a file that is already there; everything else stands as found. */
35async function existing($: EngineInterface, findings: readonly Finding[]): Promise<Finding[]> {
36  const kept: Finding[] = []
37
38  for (const finding of findings) {
39    if (!finding.mustExist || finding.path === null) {
40      kept.push(finding)
41      continue
42    }
43
44    const stat = await $.fs.stat(finding.path).catch(() => undefined)
45
46    if (stat?.kind === 'file') {
47      kept.push(finding)
48    }
49  }
50
51  return kept
52}
53
54async function record($: EngineInterface, command: string, findings: readonly Finding[], settings: Settings): Promise<void> {
55  const at = await $.clock.now()
56  const event: ShellEditEvent = {
57    at,
58    command,
59    action: settings.mode === 'warn' ? 'warned' : 'blocked',
60    hits: findings.map(finding => ({ kind: finding.kind, via: finding.via, target: finding.target })),
61  }
62  const list = await update($, events, all => [...all, event].slice(-MAX_EVENTS))
63  const fresh = freshKinds(findings, await read($, toasted))
64
65  if (fresh.length > 0) {
66    await update($, toasted, kinds => [...kinds, ...fresh.map(finding => finding.kind)])
67
68    for (const finding of fresh) {
69      $.ui.toast(toastText(finding, settings.mode), { timeoutMs: TOAST_MS })
70    }
71  }
72
73  if (settings.showsStatus) {
74    $.ui.status(statusText(list.length, settings.mode))
75  }
76}
77
78export const register: Register = (on, options) => {
79  const settings = settingsOf(options)
80
81  on('session.start', async ($, e, next) => {
82    await $.command.register({ name: COMMAND, description: 'Show the shell edits blocked this session and the no-shell-edits settings' })
83
84    return next(e)
85  })
86
87  on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
88    const findings = await existing($, analyze(e.command, await contextOf($, settings)))
89
90    if (findings.length === 0) {
91      return next(e)
92    }
93
94    if (settings.mode === 'deny') {
95      await record($, e.command, findings, settings)
96
97      return { deny: denyReason(findings) }
98    }
99
100    const ran = await next(e)
101
102    if (ran.deny !== undefined) {
103      return ran
104    }
105
106    await record($, e.command, findings, settings)
107
108    return { ...ran, context: [...(ran.context ?? []), warnContext(findings)] }
109  })
110
111  on('command.run', { command: COMMAND }, async $ => ({ text: report(await read($, events), settings) }))
112}
113
hooks/logic.ts 1442 lines
1import type { ShellEditEvent, ShellEditHit, ShellEditKind } from '../types'
2
3export type Mode = 'deny' | 'warn'
4
5/** A file a command writes (or reads, with blockReads), with what the guard needs to judge it. */
6export type Finding = ShellEditHit & {
7  /** Absolute path the target resolves to; null when the command does not name it literally. */
8  path: string | null
9  /** cp/mv: an edit only when the destination already exists as a file. */
10  mustExist: boolean
11}
12
13export type Glob = { pattern: string; regex: RegExp; isBase: boolean }
14
15export type Context = {
16  /** The session's working directory, absolute: relative targets resolve against it. */
17  cwd: string
18  home: string | undefined
19  tmpDir: string | undefined
20  allow: readonly Glob[]
21  blockReads: boolean
22}
23
24export type Settings = { mode: Mode; blockReads: boolean; allowPaths: string; showsStatus: boolean }
25
26/** Directories every scratch write goes to; writes there are never flagged. */
27export const TMP_ROOTS = ['/tmp', '/private/tmp', '/var/tmp', '/var/folders', '/private/var/folders'] as const
28export const DEFAULT_ALLOW = '*.log,.git/**'
29/** `bash -c "..."` inside `$(...)` inside `bash -c`: deeper than this is not a habit worth chasing. */
30const MAX_DEPTH = 3
31const COMMAND_PREVIEW = 100
32
33// ---------------------------------------------------------------- lexer
34
35type Word = { type: 'word'; text: string; isLiteral: boolean; isQuoted: boolean }
36type Op = { type: 'op'; op: string }
37type Redir = { type: 'redir'; op: string; fd: string | null; target: Word | null; body: string | null }
38type Token = Word | Op | Redir
39
40const HEREDOC_OPS = new Set(['<<', '<<-'])
41const INPUT_OPS = new Set(['<<', '<<-', '<<<'])
42const OUTPUT_OPS = new Set(['>', '>>', '>|', '&>', '&>>', '<>'])
43/** Words after which the next word starts a command, as an operator does. */
44const COMMAND_LEAD = new Set(['if', 'then', 'elif', 'else', 'while', 'until', 'do', 'for', '!', '{', 'time'])
45const NAME_START = /[A-Za-z_]/
46const NAME_CHAR = /[A-Za-z0-9_]/
47const SPECIAL_PARAM = /[@*#?$!0-9-]/
48
49/** Index just past the `)` closing a `(` opened before `from`, skipping quotes and heredoc bodies. */
50const closeParen = (src: string, from: number): number => {
51  let depth = 1
52  let i = from
53  const pending: { delim: string; strip: boolean }[] = []
54
55  while (i < src.length) {
56    const c = src[i]
57
58    if (c === '\\') {
59      i += 2
60      continue
61    }
62
63    if (c === "'") {
64      const end = src.indexOf("'", i + 1)
65      i = end < 0 ? src.length : end + 1
66      continue
67    }
68
69    if (c === '"') {
70      i = closeDouble(src, i + 1)
71      continue
72    }
73
74    if (c === '<' && src[i + 1] === '<' && src[i + 2] !== '<') {
75      const found = heredocDelimiter(src, i + 2)
76
77      if (found !== null) {
78        pending.push({ delim: found.delim, strip: found.strip })
79        i = found.end
80        continue
81      }
82    }
83
84    if (c === '\n' && pending.length > 0) {
85      i = skipBodies(src, i, pending.splice(0)).end
86      continue
87    }
88
89    if (c === '(') {
90      depth += 1
91    } else if (c === ')') {
92      depth -= 1
93
94      if (depth === 0) {
95        return i + 1
96      }
97    }
98
99    i += 1
100  }
101
102  return src.length
103}
104
105/** Index just past the `"` closing a double-quoted string that starts at `from`. */
106const closeDouble = (src: string, from: number): number => {
107  let i = from
108
109  while (i < src.length) {
110    const c = src[i]
111
112    if (c === '\\') {
113      i += 2
114    } else if (c === '"') {
115      return i + 1
116    } else if (c === '$' && src[i + 1] === '(') {
117      i = closeParen(src, i + 2)
118    } else if (c === '`') {
119      const end = src.indexOf('`', i + 1)
120      i = end < 0 ? src.length : end + 1
121    } else {
122      i += 1
123    }
124  }
125
126  return src.length
127}
128
129/** The delimiter of a heredoc whose `<<` ends just before `from`, or null for a malformed one. */
130const heredocDelimiter = (src: string, from: number): { delim: string; strip: boolean; end: number } | null => {
131  let i = from
132  const strip = src[i] === '-'
133
134  if (strip) {
135    i += 1
136  }
137
138  while (src[i] === ' ' || src[i] === '\t') {
139    i += 1
140  }
141
142  const quote = src[i]
143
144  if (quote === "'" || quote === '"') {
145    const end = src.indexOf(quote, i + 1)
146
147    return end < 0 ? null : { delim: src.slice(i + 1, end), strip, end: end + 1 }
148  }
149
150  const match = /^\\?([^\s;&|<>()'"]+)/.exec(src.slice(i))
151
152  return match === null || match[1] === undefined ? null : { delim: match[1], strip, end: i + match[0].length }
153}
154
155/** Reads the bodies of heredocs whose lines start after the newline at `at`; `end` is the last delimiter's newline. */
156const skipBodies = (src: string, at: number, pending: readonly { delim: string; strip: boolean }[]): { bodies: string[]; end: number } => {
157  const bodies: string[] = []
158  let i = at + 1
159
160  for (const doc of pending) {
161    const lines: string[] = []
162    let isClosed = false
163
164    while (i < src.length) {
165      const stop = src.indexOf('\n', i)
166      const lineEnd = stop < 0 ? src.length : stop
167      const line = src.slice(i, lineEnd)
168      i = lineEnd + 1
169
170      if ((doc.strip ? line.replace(/^\t+/, '') : line) === doc.delim) {
171        isClosed = true
172        break
173      }
174
175      lines.push(line)
176    }
177
178    bodies.push(lines.join('\n'))
179
180    if (!isClosed) {
181      i = src.length
182    }
183  }
184
185  return { bodies, end: Math.min(i, src.length) - 1 }
186}
187
188type Lexed = { tokens: Token[]; subs: string[] }
189
190/**
191 * Splits a command line into words, operators and redirections the way a POSIX shell
192 * reads it: quotes and escapes stay inside a word, `$(...)` and backticks are collected
193 * for a recursive look, heredoc bodies attach to their `<<`, and `>`/`<` inside `[[ ]]`
194 * or arithmetic are comparisons, not redirections.
195 */
196export const lex = (src: string): Lexed => {
197  const tokens: Token[] = []
198  const subs: string[] = []
199  const pending: Redir[] = []
200  let text = ''
201  let inWord = false
202  let isLiteral = true
203  let isQuoted = false
204  let inTest = false
205  let i = 0
206
207  const last = (): Token | undefined => tokens[tokens.length - 1]
208
209  const atCommandStart = (): boolean => {
210    const prev = last()
211
212    return prev === undefined || prev.type === 'op' || (prev.type === 'word' && !prev.isQuoted && COMMAND_LEAD.has(prev.text))
213  }
214
215  const flush = (): void => {
216    if (!inWord) {
217      return
218    }
219
220    const word: Word = { type: 'word', text, isLiteral, isQuoted }
221    const prev = last()
222
223    if (prev !== undefined && prev.type === 'redir' && prev.target === null) {
224      prev.target = word
225
226      if (HEREDOC_OPS.has(prev.op)) {
227        pending.push(prev)
228      }
229    } else {
230      if (!isQuoted && text === '[[' && atCommandStart()) {
231        inTest = true
232      } else if (!isQuoted && text === ']]') {
233        inTest = false
234      }
235
236      tokens.push(word)
237    }
238
239    text = ''
240    inWord = false
241    isLiteral = true
242    isQuoted = false
243  }
244
245  /** `$...` at `i`: appends the expansion as written and returns the index after it. */
246  const dollar = (at: number): number => {
247    const next = src[at + 1]
248
249    if (next === '(') {
250      const end = closeParen(src, at + 2)
251      const inner = src.slice(at + 2, end - 1)
252
253      if (!inner.startsWith('(')) {
254        subs.push(inner)
255      }
256
257      text += src.slice(at, end)
258      isLiteral = false
259
260      return end
261    }
262
263    if (next === '{') {
264      const close = src.indexOf('}', at + 2)
265      const end = close < 0 ? src.length : close + 1
266      text += src.slice(at, end)
267      isLiteral = false
268
269      return end
270    }
271
272    if (next !== undefined && NAME_START.test(next)) {
273      let end = at + 2
274
275      while (end < src.length && NAME_CHAR.test(src[end] ?? '')) {
276        end += 1
277      }
278
279      text += src.slice(at, end)
280      isLiteral = false
281
282      return end
283    }
284
285    if (next !== undefined && SPECIAL_PARAM.test(next)) {
286      text += src.slice(at, at + 2)
287      isLiteral = false
288
289      return at + 2
290    }
291
292    text += '$'
293
294    return at + 1
295  }
296
297  const backtick = (at: number): number => {
298    const close = src.indexOf('`', at + 1)
299    const end = close < 0 ? src.length : close + 1
300    subs.push(src.slice(at + 1, close < 0 ? src.length : close))
301    text += src.slice(at, end)
302    isLiteral = false
303
304    return end
305  }
306
307  const op = (value: string): void => {
308    flush()
309    tokens.push({ type: 'op', op: value })
310  }
311
312  const redir = (value: string): void => {
313    let fd: string | null = null
314
315    if (inWord && !isQuoted && isLiteral && /^\d+$/.test(text)) {
316      fd = text
317      text = ''
318      inWord = false
319    } else {
320      flush()
321    }
322
323    tokens.push({ type: 'redir', op: value, fd, target: null, body: null })
324  }
325
326  while (i < src.length) {
327    const c = src[i] ?? ''
328    const c1 = src[i + 1]
329    const c2 = src[i + 2]
330
331    if (c === ' ' || c === '\t') {
332      flush()
333      i += 1
334    } else if (c === '\n') {
335      op('\n')
336
337      if (pending.length > 0) {
338        const docs = pending.splice(0)
339        const read = skipBodies(
340          src,
341          i,
342          docs.map(doc => ({ delim: doc.target?.text ?? '', strip: doc.op === '<<-' })),
343        )
344        docs.forEach((doc, k) => {
345          doc.body = read.bodies[k] ?? ''
346        })
347        i = read.end + 1
348      } else {
349        i += 1
350      }
351    } else if (c === '#' && !inWord) {
352      const stop = src.indexOf('\n', i)
353      i = stop < 0 ? src.length : stop
354    } else if (c === '\\') {
355      if (c1 !== '\n' && c1 !== undefined) {
356        text += c1
357        inWord = true
358        isQuoted = true
359      }
360
361      i += 2
362    } else if (c === "'") {
363      const close = src.indexOf("'", i + 1)
364      text += src.slice(i + 1, close < 0 ? src.length : close)
365      inWord = true
366      isQuoted = true
367      i = close < 0 ? src.length : close + 1
368    } else if (c === '$' && c1 === "'") {
369      let k = i + 2
370
371      while (k < src.length && src[k] !== "'") {
372        k += src[k] === '\\' ? 2 : 1
373      }
374
375      text += src.slice(i + 2, k)
376      inWord = true
377      isQuoted = true
378      i = k + 1
379    } else if (c === '"') {
380      inWord = true
381      isQuoted = true
382      i += 1
383
384      while (i < src.length && src[i] !== '"') {
385        const d = src[i]
386
387        if (d === '\\' && i + 1 < src.length && '$`"\\\n'.includes(src[i + 1] ?? '')) {
388          text += src[i + 1] === '\n' ? '' : src[i + 1]
389          i += 2
390        } else if (d === '$') {
391          i = dollar(i)
392        } else if (d === '`') {
393          i = backtick(i)
394        } else {
395          text += d
396          i += 1
397        }
398      }
399
400      i += 1
401    } else if (c === '$') {
402      inWord = true
403      i = dollar(i)
404    } else if (c === '`') {
405      inWord = true
406      i = backtick(i)
407    } else if ((c === '<' || c === '>') && c1 === '(' && !inTest) {
408      const end = closeParen(src, i + 2)
409      subs.push(src.slice(i + 2, end - 1))
410      text += src.slice(i, end)
411      inWord = true
412      isLiteral = false
413      i = end
414    } else if (c === '|') {
415      op(c1 === '|' ? '||' : c1 === '&' ? '|&' : '|')
416      i += c1 === '|' || c1 === '&' ? 2 : 1
417    } else if (c === '&') {
418      if (c1 === '&') {
419        op('&&')
420        i += 2
421      } else if (c1 === '>' && !inTest) {
422        flush()
423        tokens.push({ type: 'redir', op: c2 === '>' ? '&>>' : '&>', fd: null, target: null, body: null })
424        i += c2 === '>' ? 3 : 2
425      } else {
426        op('&')
427        i += 1
428      }
429    } else if (c === ';') {
430      op(';')
431      i += c1 === ';' || c1 === '&' ? 2 : 1
432    } else if (c === '(') {
433      flush()
434
435      if (c1 === '(' && atCommandStart()) {
436        i = closeParen(src, i + 1)
437      } else {
438        op('(')
439        i += 1
440      }
441    } else if (c === ')') {
442      op(')')
443      i += 1
444    } else if ((c === '>' || c === '<') && !inTest) {
445      const three = src.slice(i, i + 3)
446      const two = src.slice(i, i + 2)
447      const value =
448        three === '<<<' || three === '<<-'
449          ? three
450          : two === '>>' || two === '>|' || two === '>&' || two === '<<' || two === '<>' || two === '<&'
451            ? two
452            : c
453      redir(value)
454      i += value.length
455    } else {
456      text += c
457      inWord = true
458      i += 1
459    }
460  }
461
462  flush()
463
464  return { tokens, subs }
465}
466
467// ---------------------------------------------------------------- simple commands
468
469type Segment = { words: Word[]; redirs: Redir[]; isPipedOut: boolean }
470
471const segmentsOf = (tokens: readonly Token[]): Segment[] => {
472  const segments: Segment[] = []
473  let current: Segment = { words: [], redirs: [], isPipedOut: false }
474
475  for (const token of tokens) {
476    if (token.type === 'op') {
477      current.isPipedOut = token.op === '|' || token.op === '|&'
478      segments.push(current)
479      current = { words: [], redirs: [], isPipedOut: false }
480    } else if (token.type === 'redir') {
481      current.redirs.push(token)
482    } else {
483      current.words.push(token)
484    }
485  }
486
487  segments.push(current)
488
489  return segments.filter(segment => segment.words.length > 0 || segment.redirs.length > 0)
490}
491
492// ---------------------------------------------------------------- paths and targets
493
494const TEMP = '\u0000temp'
495
496type State = { cwd: string; vars: Map<string, string> }
497
498export const normalize = (path: string): string => {
499  const parts: string[] = []
500
501  for (const part of path.split('/')) {
502    if (part === '' || part === '.') {
503      continue
504    }
505
506    if (part === '..') {
507      parts.pop()
508    } else {
509      parts.push(part)
510    }
511  }
512
513  return `/${parts.join('/')}`
514}
515
516export const resolvePath = (base: string, path: string): string => normalize(path.startsWith('/') ? path : `${base}/${path}`)
517
518const basename = (path: string): string => path.slice(path.lastIndexOf('/') + 1)
519
520const isUnder = (path: string, root: string): boolean => path === root || path.startsWith(`${root.replace(/\/$/, '')}/`)
521
522const globRegex = (glob: string): RegExp => {
523  let out = ''
524
525  for (let k = 0; k < glob.length; k++) {
526    const c = glob[k] ?? ''
527
528    if (c === '*' && glob[k + 1] === '*') {
529      if (glob[k + 2] === '/') {
530        out += '(?:.*/)?'
531        k += 2
532      } else {
533        out += '.*'
534        k += 1
535      }
536    } else if (c === '*') {
537      out += '[^/]*'
538    } else if (c === '?') {
539      out += '[^/]'
540    } else {
541      out += c.replace(/[.+^${}()|[\]\\]/g, '\\$&')
542    }
543  }
544
545  return new RegExp(`^${out}$`)
546}
547
548/** allowPaths as the settings field spells it: comma-separated globs; one without `/` matches a file name anywhere. */
549export const compileGlobs = (text: string): Glob[] =>
550  text
551    .split(',')
552    .map(part => part.trim().replace(/^\.\//, ''))
553    .filter(part => part.length > 0)
554    .map(pattern => ({ pattern, regex: globRegex(pattern), isBase: !pattern.includes('/') }))
555
556export const isAllowedPath = (path: string, ctx: Context): boolean => {
557  if (isUnder(path, '/dev')) {
558    return true
559  }
560
561  const isInProject = isUnder(path, ctx.cwd)
562  // A temp folder is scratch space only outside the project: a project that itself lives in /tmp stays guarded.
563  const isScratch = TMP_ROOTS.some(root => isUnder(path, root)) || (ctx.tmpDir !== undefined && isUnder(path, normalize(ctx.tmpDir)))
564
565  if (isScratch && !isInProject) {
566    return true
567  }
568
569  const rel = isInProject ? path.slice(ctx.cwd.replace(/\/$/, '').length + 1) : null
570  const name = basename(path)
571
572  return ctx.allow.some(glob => (glob.isBase ? glob.regex.test(name) : glob.regex.test(path) || (rel !== null && glob.regex.test(rel))))
573}
574
575type Target = { kind: 'allowed' } | { kind: 'unknown'; text: string } | { kind: 'file'; text: string; path: string }
576
577/** `$NAME/rest` with NAME set earlier in the command (or TMPDIR/HOME) spelled out; null when it stays unknown. */
578const expandVars = (text: string, state: State): string | null => {
579  if (/^\$\(\s*mktemp\b/.test(text) || /^`\s*mktemp\b/.test(text)) {
580    return TEMP
581  }
582
583  const match = /^\$\{?([A-Za-z_][A-Za-z0-9_]*)\}?(.*)$/.exec(text)
584
585  if (match === null || match[1] === undefined) {
586    return null
587  }
588
589  const value = state.vars.get(match[1])
590  const rest = match[2] ?? ''
591
592  if (value === undefined || rest.includes('$') || rest.includes('`')) {
593    return null
594  }
595
596  return value === TEMP ? TEMP : value + rest
597}
598
599const classify = (word: Word, state: State, ctx: Context): Target => {
600  let text = word.text
601
602  if (text === '-' || (text === '' && word.isQuoted)) {
603    return { kind: 'allowed' }
604  }
605
606  if (text === '{}') {
607    return { kind: 'unknown', text }
608  }
609
610  if (!word.isLiteral) {
611    const expanded = expandVars(text, state)
612
613    if (expanded === TEMP) {
614      return { kind: 'allowed' }
615    }
616
617    if (expanded === null) {
618      return { kind: 'unknown', text }
619    }
620
621    text = expanded
622  }
623
624  if (text.startsWith('~/') || text === '~') {
625    if (ctx.home === undefined) {
626      return { kind: 'unknown', text }
627    }
628
629    text = ctx.home + text.slice(1)
630  }
631
632  const path = resolvePath(state.cwd, text)
633
634  return isAllowedPath(path, ctx) ? { kind: 'allowed' } : { kind: 'file', text: word.text, path }
635}
636
637// ---------------------------------------------------------------- per-tool argument readers
638
639const ALIASES: Record<string, string> = { gsed: 'sed', gawk: 'awk', mawk: 'awk', nawk: 'awk', nodejs: 'node', python: 'python', gcp: 'cp', gmv: 'mv' }
640
641const nameOf = (word: Word): string => {
642  const base = basename(word.text)
643
644  if (/^python[0-9.]*$/.test(base)) {
645    return 'python'
646  }
647
648  return ALIASES[base] ?? base
649}
650
651const isAssignment = (word: Word): boolean => /^[A-Za-z_][A-Za-z0-9_]*=/.test(word.text)
652
653/** Wrappers that run the command after them; each lists its options that take a value. */
654const WRAPPERS: Record<string, readonly string[]> = {
655  sudo: ['-u', '-g', '-C', '-D', '-h', '-p', '-r', '-t', '-U', '-T'],
656  doas: ['-u', '-C'],
657  env: ['-u', '-C', '-S', '--unset', '--chdir'],
658  command: [],
659  builtin: [],
660  exec: ['-a'],
661  nohup: [],
662  time: [],
663  nice: ['-n', '--adjustment'],
664  ionice: ['-c', '-n', '-p'],
665  stdbuf: ['-i', '-o', '-e'],
666  timeout: ['-s', '-k', '--signal', '--kill-after'],
667  xargs: ['-I', '-n', '-P', '-L', '-d', '-E', '-s', '-a', '--arg-file', '--delimiter', '--max-args', '--max-procs', '--replace'],
668}
669
670type Unwrapped = { words: Word[]; isFed: boolean }
671
672/** Drops env assignments and wrappers (`sudo`, `env`, `xargs`, ...); `isFed` when xargs supplies the file names. */
673const unwrap = (input: readonly Word[]): Unwrapped => {
674  let words = [...input]
675  let isFed = false
676
677  for (;;) {
678    while (words[0] !== undefined && (isAssignment(words[0]) || (!words[0].isQuoted && COMMAND_LEAD.has(words[0].text)))) {
679      words = words.slice(1)
680    }
681
682    const head = words[0]
683
684    if (head === undefined) {
685      return { words, isFed }
686    }
687
688    const name = nameOf(head)
689    const takesValue = WRAPPERS[name]
690
691    if (takesValue === undefined) {
692      return { words, isFed }
693    }
694
695    if (name === 'command' && words[1]?.text.startsWith('-') === true && /[vV]/.test(words[1].text)) {
696      return { words: [], isFed }
697    }
698
699    isFed = isFed || name === 'xargs'
700    let k = 1
701
702    while (k < words.length) {
703      const text = words[k]?.text ?? ''
704
705      if (text === '--') {
706        k += 1
707        break
708      }
709
710      if (!text.startsWith('-') || text === '-') {
711        break
712      }
713
714      k += takesValue.includes(text) ? 2 : 1
715    }
716
717    if (name === 'timeout' && k < words.length) {
718      k += 1
719    }
720
721    words = words.slice(k)
722  }
723}
724
725type Operands = { flags: string[]; operands: Word[] }
726
727/** Splits arguments into options and operands; `withValue` lists options whose value is the next word. */
728const operandsOf = (args: readonly Word[], withValue: readonly string[]): Operands => {
729  const flags: string[] = []
730  const operands: Word[] = []
731
732  for (let k = 0; k < args.length; k++) {
733    const word = args[k]
734
735    if (word === undefined) {
736      continue
737    }
738
739    if (word.text === '--') {
740      operands.push(...args.slice(k + 1))
741      break
742    }
743
744    if (word.text.startsWith('-') && word.text.length > 1 && !word.isQuoted) {
745      flags.push(word.text)
746
747      if (withValue.includes(word.text)) {
748        k += 1
749      }
750    } else {
751      operands.push(word)
752    }
753  }
754
755  return { flags, operands }
756}
757
758const SED_SUFFIX = /^\.[\w.~-]*$/
759
760/** `sed -i`/`--in-place` (GNU and BSD spellings): the files it edits, or null when it does not edit in place. */
761export const sedInPlace = (args: readonly Word[]): Word[] | null => {
762  let isInPlace = false
763  let hasScript = false
764  const operands: Word[] = []
765
766  for (let k = 0; k < args.length; k++) {
767    const word = args[k]
768
769    if (word === undefined) {
770      continue
771    }
772
773    const text = word.text
774
775    if (text === '--') {
776      operands.push(...args.slice(k + 1))
777      break
778    }
779
780    if (word.isQuoted || !text.startsWith('-') || text.length === 1) {
781      operands.push(word)
782      continue
783    }
784
785    if (text.startsWith('--')) {
786      if (text === '--in-place' || text.startsWith('--in-place=')) {
787        isInPlace = true
788      } else if (text === '--expression' || text === '--file') {
789        hasScript = true
790        k += 1
791      } else if (text.startsWith('--expression=') || text.startsWith('--file=')) {
792        hasScript = true
793      }
794
795      continue
796    }
797
798    for (let c = 1; c < text.length; c++) {
799      const flag = text[c]
800
801      if (flag === 'i' || flag === 'I') {
802        isInPlace = true
803        const next = args[k + 1]
804        // BSD sed takes the backup suffix as its own word: `sed -i '' ...` or `sed -i .bak ...`.
805        const isBsdSuffix = next !== undefined && ((next.text === '' && next.isQuoted) || SED_SUFFIX.test(next.text))
806
807        if (c === text.length - 1 && isBsdSuffix) {
808          k += 1
809        }
810
811        break
812      }
813
814      if (flag === 'e' || flag === 'f') {
815        hasScript = true
816
817        if (c === text.length - 1) {
818          k += 1
819        }
820
821        break
822      }
823
824      if (flag === 'l') {
825        if (c === text.length - 1) {
826          k += 1
827        }
828
829        break
830      }
831    }
832  }
833
834  if (!isInPlace) {
835    return null
836  }
837
838  return hasScript ? operands : operands.slice(1)
839}
840
841type Perl = { isInPlace: boolean; script: string | null; files: Word[] }
842
843const PERL_WITH_VALUE = new Set(['I', 'M', 'm', 'x', 'd', 'D', 'C', 'F'])
844
845export const perlArgs = (args: readonly Word[]): Perl => {
846  let isInPlace = false
847  let script: string | null = null
848  const operands: Word[] = []
849
850  for (let k = 0; k < args.length; k++) {
851    const word = args[k]
852
853    if (word === undefined) {
854      continue
855    }
856
857    const text = word.text
858
859    if (text === '--') {
860      operands.push(...args.slice(k + 1))
861      break
862    }
863
864    if (word.isQuoted || !text.startsWith('-') || text.length === 1) {
865      operands.push(word)
866      continue
867    }
868
869    for (let c = 1; c < text.length; c++) {
870      const flag = text[c] ?? ''
871
872      if (flag === 'i') {
873        isInPlace = true
874        break
875      }
876
877      if (flag === 'e' || flag === 'E') {
878        const rest = text.slice(c + 1)
879        const code = rest.length > 0 ? rest : (args[k + 1]?.text ?? '')
880        script = script === null ? code : `${script}\n${code}`
881
882        if (rest.length === 0) {
883          k += 1
884        }
885
886        break
887      }
888
889      if (flag === '0' || flag === 'l') {
890        while (/[0-9a-fA-Fx]/.test(text[c + 1] ?? '')) {
891          c += 1
892        }
893
894        continue
895      }
896
897      if (PERL_WITH_VALUE.has(flag)) {
898        if (c === text.length - 1 && (flag === 'I' || flag === 'M' || flag === 'm')) {
899          k += 1
900        }
901
902        break
903      }
904    }
905  }
906
907  return { isInPlace, script, files: script === null ? operands.slice(1) : operands }
908}
909
910/** gawk's `-i inplace` (`--include=inplace`): the files it edits, or null. */
911export const awkInPlace = (args: readonly Word[]): Word[] | null => {
912  let isInPlace = false
913  let hasProgram = false
914  const operands: Word[] = []
915  const isInplaceLib = (name: string | undefined): boolean => name === 'inplace' || name === 'inplace.awk'
916
917  for (let k = 0; k < args.length; k++) {
918    const word = args[k]
919
920    if (word === undefined) {
921      continue
922    }
923
924    const text = word.text
925
926    if (text === '--') {
927      operands.push(...args.slice(k + 1))
928      break
929    }
930
931    if (word.isQuoted || !text.startsWith('-') || text.length === 1) {
932      operands.push(word)
933    } else if (text === '-i' || text === '--include') {
934      isInPlace = isInPlace || isInplaceLib(args[k + 1]?.text)
935      k += 1
936    } else if (text.startsWith('--include=')) {
937      isInPlace = isInPlace || isInplaceLib(text.slice('--include='.length))
938    } else if (text.startsWith('-i')) {
939      isInPlace = isInPlace || isInplaceLib(text.slice(2))
940    } else if (text === '-f' || text === '-e' || text === '--file' || text === '--source') {
941      hasProgram = true
942      k += 1
943    } else if (text === '-v' || text === '-F' || text === '--assign' || text === '--field-separator') {
944      k += 1
945    } else if (text.startsWith('-f') || text.startsWith('-e')) {
946      hasProgram = true
947    }
948  }
949
950  if (!isInPlace) {
951    return null
952  }
953
954  return (hasProgram ? operands : operands.slice(1)).filter(word => !isAssignment(word))
955}
956
957type ScriptLang = 'python' | 'node' | 'ruby' | 'perl'
958
959/** The inline program of `python -c`, `node -e`, `ruby -e`; null when the command runs a script file. */
960const inlineScript = (lang: ScriptLang, args: readonly Word[]): string | null => {
961  const flags = lang === 'python' ? ['-c'] : lang === 'node' ? ['-e', '--eval', '-p', '--print'] : ['-e']
962  const parts: string[] = []
963
964  for (let k = 0; k < args.length; k++) {
965    const text = args[k]?.text ?? ''
966    const attached = flags.find(flag => flag.length === 2 && text.startsWith(flag) && text.length > 2)
967
968    if (flags.includes(text)) {
969      parts.push(args[k + 1]?.text ?? '')
970      k += 1
971
972      if (lang === 'python') {
973        break
974      }
975    } else if (attached !== undefined) {
976      parts.push(text.slice(2))
977    } else if (!text.startsWith('-')) {
978      break
979    }
980  }
981
982  return parts.length === 0 ? null : parts.join('\n')
983}
984
985const WRITE_MODE = /[wax+]/
986
987/** Paths a script opens for writing; null stands for one it names through a variable. */
988export const scriptWrites = (lang: ScriptLang, code: string): Array<string | null> => {
989  const found: Array<string | null> = []
990  const literal = (quoted: string | undefined): string | null => (quoted === undefined || quoted === '' ? null : quoted)
991
992  if (lang === 'python') {
993    for (const m of code.matchAll(/\bopen\(\s*(?:(['"])(.*?)\1|[^,()]+)\s*,\s*(?:mode\s*=\s*)?(['"])([rwabxt+]*)\3/g)) {
994      if (WRITE_MODE.test(m[4] ?? '')) {
995        found.push(literal(m[2]))
996      }
997    }
998
999    for (const m of code.matchAll(/(?:\bPath\(\s*(?:(['"])(.*?)\1)?[^)]*\)|\w+)\s*\.\s*write_(?:text|bytes)\(/g)) {
1000      found.push(literal(m[2]))
1001    }
1002
1003    if (/\binplace\s*=\s*True\b/.test(code)) {
1004      found.push(null)
1005    }
1006  }
1007
1008  if (lang === 'node') {
1009    for (const m of code.matchAll(/\b(?:writeFileSync|writeFile|appendFileSync|appendFile|createWriteStream|outputFileSync|writeJsonSync)\(\s*(?:(['"`])(.*?)\1)?/g)) {
1010      found.push(literal(m[2]))
1011    }
1012  }
1013
1014  if (lang === 'ruby') {
1015    for (const m of code.matchAll(/\b(?:File|IO)\.write\(\s*(?:(['"])(.*?)\1)?/g)) {
1016      found.push(literal(m[2]))
1017    }
1018
1019    for (const m of code.matchAll(/\bFile\.open\(\s*(?:(['"])(.*?)\1|[^,()]+)\s*,\s*(['"])([^'"]*)\3/g)) {
1020      if (WRITE_MODE.test(m[4] ?? '')) {
1021        found.push(literal(m[2]))
1022      }
1023    }
1024  }
1025
1026  if (lang === 'perl') {
1027    for (const m of code.matchAll(/\bopen\s*\(?\s*(?:my\s+)?[$\w]+\s*,\s*(['"])\s*\+?>{1,2}\s*(.*?)\1(?:\s*,\s*(['"])(.*?)\3)?/g)) {
1028      found.push(literal(m[4]) ?? literal(m[2]?.trim()))
1029    }
1030  }
1031
1032  return found
1033}
1034
1035const SCRIPT_LANGS: Record<string, ScriptLang> = { python: 'python', node: 'node', ruby: 'ruby', perl: 'perl' }
1036const READERS = new Set(['cat', 'head', 'tail', 'less', 'more'])
1037const READER_WITH_VALUE = ['-n', '-c', '--lines', '--bytes']
1038const FOLLOW_FLAGS = /^(?:-[a-zA-Z]*[fF][a-zA-Z]*|--follow(?:=.*)?|\+F)$/
1039const SHELLS = new Set(['bash', 'sh', 'zsh', 'dash', 'ksh'])
1040const FIND_EXEC = new Set(['-exec', '-execdir', '-ok', '-okdir'])
1041
1042// ---------------------------------------------------------------- analysis
1043
1044type Walk = { state: State; ctx: Context; depth: number; out: Finding[] }
1045
1046const push = (walk: Walk, kind: ShellEditKind, via: string, word: Word | null, options: { keepUnknown: boolean; mustExist?: boolean }): void => {
1047  if (word === null) {
1048    walk.out.push({ kind, via, target: null, path: null, mustExist: false })
1049    return
1050  }
1051
1052  const target = classify(word, walk.state, walk.ctx)
1053
1054  if (target.kind === 'file') {
1055    walk.out.push({ kind, via, target: target.text, path: target.path, mustExist: options.mustExist ?? false })
1056  } else if (target.kind === 'unknown' && options.keepUnknown) {
1057    walk.out.push({ kind, via, target: target.text === '{}' ? null : target.text, path: null, mustExist: false })
1058  }
1059}
1060
1061const pushScript = (walk: Walk, via: string, lang: ScriptLang, code: string): void => {
1062  for (const path of scriptWrites(lang, code)) {
1063    push(walk, 'script', via, path === null ? null : { type: 'word', text: path, isLiteral: true, isQuoted: true }, { keepUnknown: true })
1064  }
1065}
1066
1067const assign = (word: Word, state: State): void => {
1068  const at = word.text.indexOf('=')
1069  const name = word.text.slice(0, at)
1070  const value = word.text.slice(at + 1)
1071
1072  if (word.isLiteral) {
1073    state.vars.set(name, value)
1074    return
1075  }
1076
1077  const expanded = expandVars(value, state)
1078
1079  if (expanded === null) {
1080    state.vars.delete(name)
1081  } else {
1082    state.vars.set(name, expanded)
1083  }
1084}
1085
1086/** One simple command: its redirections, then what its program does to the files it names. */
1087const walkSegment = (segment: Segment, walk: Walk, isFed: boolean): void => {
1088  const { words, isFed: fedByWrapper } = unwrap(segment.words)
1089  const fed = isFed || fedByWrapper
1090  const head = words[0]
1091  const name = head === undefined ? '' : nameOf(head)
1092  const args = words.slice(1)
1093  const input = segment.redirs.find(redir => INPUT_OPS.has(redir.op))
1094
1095  if (head === undefined && segment.redirs.length === 0) {
1096    segment.words.filter(isAssignment).forEach(word => assign(word, walk.state))
1097    return
1098  }
1099
1100  for (const redir of segment.redirs) {
1101    const isDup = redir.op === '>&' && redir.target !== null && /^(?:\d+|-)$/.test(redir.target.text)
1102
1103    if (!OUTPUT_OPS.has(redir.op) && (redir.op !== '>&' || isDup)) {
1104      continue
1105    }
1106
1107    const via = input !== undefined && HEREDOC_OPS.has(input.op) ? `${name} <<${input.target?.text ?? 'EOF'} ${redir.op}` : `${name} ${redir.op}`
1108    push(walk, input !== undefined ? 'heredoc' : 'redirect', via.trim(), redir.target, { keepUnknown: false })
1109  }
1110
1111  if (head === undefined) {
1112    return
1113  }
1114
1115  const fedTarget = (files: readonly Word[]): readonly (Word | null)[] => (files.length === 0 && fed ? [null] : files)
1116
1117  switch (name) {
1118    case 'cd':
1119    case 'pushd': {
1120      const dir = args[0]
1121
1122      if (dir === undefined) {
1123        walk.state.cwd = walk.ctx.home ?? walk.state.cwd
1124      } else if (dir.isLiteral && dir.text !== '-') {
1125        walk.state.cwd = resolvePath(walk.state.cwd, dir.text.startsWith('~') && walk.ctx.home !== undefined ? walk.ctx.home + dir.text.slice(1) : dir.text)
1126      }
1127
1128      return
1129    }
1130    case 'export':
1131    case 'declare':
1132    case 'local':
1133    case 'readonly':
1134      args.filter(isAssignment).forEach(word => assign(word, walk.state))
1135      return
1136    case 'eval':
1137      nested(args.map(word => word.text).join(' '), walk)
1138      return
1139    case 'sed': {
1140      const files = sedInPlace(args)
1141
1142      if (files !== null) {
1143        fedTarget(files).forEach(file => push(walk, 'in-place', 'sed -i', file, { keepUnknown: true }))
1144      }
1145
1146      return
1147    }
1148    case 'awk': {
1149      const files = awkInPlace(args)
1150
1151      if (files !== null) {
1152        fedTarget(files).forEach(file => push(walk, 'in-place', 'awk -i inplace', file, { keepUnknown: true }))
1153      }
1154
1155      return
1156    }
1157    case 'perl': {
1158      const perl = perlArgs(args)
1159
1160      if (perl.isInPlace) {
1161        fedTarget(perl.files).forEach(file => push(walk, 'in-place', 'perl -i', file, { keepUnknown: true }))
1162      } else if (perl.script !== null) {
1163        pushScript(walk, 'perl -e', 'perl', perl.script)
1164      }
1165
1166      return
1167    }
1168    case 'tee': {
1169      const { flags, operands } = operandsOf(args, [])
1170      const via = flags.some(flag => flag === '-a' || flag === '--append') ? 'tee -a' : 'tee'
1171      operands.forEach(file => push(walk, 'tee', via, file, { keepUnknown: false }))
1172      return
1173    }
1174    case 'truncate': {
1175      const { operands } = operandsOf(args, ['-s', '-r', '--size', '--reference'])
1176      fedTarget(operands).forEach(file => push(walk, 'truncate', 'truncate', file, { keepUnknown: true }))
1177      return
1178    }
1179    case 'cp':
1180    case 'mv': {
1181      const { flags, operands } = operandsOf(args, ['-t', '-S', '--target-directory', '--suffix'])
1182      const dest = operands[operands.length - 1]
1183      const isIntoDir = flags.some(flag => flag === '-t' || flag.startsWith('--target-directory'))
1184
1185      if (!isIntoDir && operands.length >= 2 && dest !== undefined && !dest.text.endsWith('/')) {
1186        push(walk, 'overwrite', name, dest, { keepUnknown: false, mustExist: true })
1187      }
1188
1189      return
1190    }
1191    case 'find': {
1192      walkFind(args, walk)
1193      return
1194    }
1195    default:
1196      break
1197  }
1198
1199  if (SHELLS.has(name)) {
1200    const at = args.findIndex(word => /^-[a-z]*c[a-z]*$/.test(word.text))
types/index.d.ts 28 lines
1/** How a Bash command wrote (or, with blockReads, read) a file. */
2export type ShellEditKind = 'in-place' | 'redirect' | 'heredoc' | 'tee' | 'script' | 'truncate' | 'overwrite' | 'read'
3
4/** One file a command touched: the kind, the spelling the reason uses (`sed -i`, `echo >`) and the target as written. */
5export type ShellEditHit = {
6  kind: ShellEditKind
7  via: string
8  /** The path as the command spells it; null when the command does not name it (`xargs sed -i`). */
9  target: string | null
10}
11
12/** One flagged Bash command, as /no-shell-edits lists it. */
13export type ShellEditEvent = {
14  at: number
15  command: string
16  action: 'blocked' | 'warned'
17  hits: ShellEditHit[]
18}
19
20declare module 'claude-code' {
21  interface PluginState {
22    'no-shell-edits': {
23      events: ShellEditEvent[]
24      toasted: ShellEditKind[]
25    }
26  }
27}
28