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…

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
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.
| Caught | Example | |
|---|---|---|
| In-place editors | sed -i, sed --in-place, perl -pi, awk -i inplace, also through xargs and find -exec | |
| Redirections into a file | echo x > f, printf … >> f, cmd &> f, `> | , 1> f` |
| Heredocs into a file | cat > f <<'EOF' … EOF | |
tee | `… \ | tee f, tee -a f` |
| Inline scripts that write | python -c "open('f','w')…", Path('f').write_text, node -e "fs.writeFileSync('f')", ruby -e 'File.write("f")', python - <<EOF … EOF | |
truncate | truncate -s 0 f | |
cp/mv over an existing file | mv /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:
| Where | What |
|---|---|
| Toast | Once per kind per session: Blocked \sed -i\ on a.txt; Claude was told to use Edit |
/no-shell-edits | Every 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
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
Change them in /config under the plugin, or in settings.json under pluginConfigs:
| Field | Default | Meaning |
|---|---|---|
mode | deny | deny 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 |
blockReads | false | Also 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 |
statusLine | false | Pin no-shell-edits: N blocked under the prompt |
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.$.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.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:
echo x > "$out" passes. In-place editors (sed -i "$f") are flagged anyway, because editing is all they do.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.make > out.txt or git diff > changes.patch inside the project are refused. Add such paths to allowPaths, or switch to warn.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 показывает, что заблокировано за сессию.
MIT
hooks/register.ts 113 lines1import { 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}
113hooks/logic.ts 1442 lines1import 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 lines1/** 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