SLOPSHOPPER

shell-guard

Windows shell guard: fixes backslash drive paths in Bash, blocks quote-eating node -e / heredoc patterns, and catches PowerShell/Bash syntax sent to the wrong…

newguardtoast
v0.1.0MITupdated 2026-10-04youllook/ClaudeMods/plugins/shell-guard
A shopper browsing a rack in a slop shop
README

shell-guard

Windows 上的 Claude Code shell 防呆 mod。在指令執行之前,攔下 Bash 和 PowerShell 最常見的環境錯誤。

A Claude Code mod that catches the most common Windows shell mistakes — before the command runs.

為什麼需要

在 Windows 上,Claude Code 有 Bash(Git Bash) 和 PowerShell 兩種 shell,模型常常把兩邊的寫法搞混,或被 Bash 的引號規則坑到。下面這些情況會靜默出錯,沒有任何錯誤訊息:

寫法實際發生的事
echo C:\Users\me\.claudeGit Bash 把反斜線吃掉,印出 C:Usersme.claude
node -e "console.log(whoami)"Bash 先執行反引號裡的指令,node 收到的程式碼已經被改壞
cat > a.sh <<EOF + $HOME寫進檔案的是展開後的值,不是原本的 $HOME
Bash 裡寫 echo $env:PATH印出 :PATH
PowerShell 裡寫 export FOO=barexport is not recognized

它做什麼

#工具偵測到處理方式
1BashC:\foo\bar 這種反斜線路徑改寫成 C:/foo/bar 後照常執行
2Bashnode -e "…" 或 python -c "…" 的雙引號裡有反引號或 $(擋下,請模型先把腳本寫成檔案
3Bash沒加引號的 heredoc(<<EOF),內容又有 $ 或反引號擋下,請模型改用 <<'EOF'
4BashPowerShell 語法($env:、Get-ChildItem、2>$null…)擋下,請模型改用 PowerShell 工具
5PowerShellBash 語法(export X=、/dev/null、rm -rf、if [ ]; then…)擋下,請模型改用 Bash 工具
  • 擋下時,原因會回傳給模型,模型看到就會自己改寫重試。畫面上也會跳一個 🛡 通知。
  • 不會誤擋:單引號和雙引號裡的文字(例如 commit 訊息提到 /dev/null)、heredoc 的內容、明確轉交給另一個 shell 的指令(powershell -Command …、bash -c …)。
  • 逃生口:確實需要那種寫法時,在指令裡加上 # shell-guard: allow 就會原樣放行。

安裝

需要 Claude Code v2.1.287 以上(Mods 從這一版開始提供)。

claude plugin marketplace add youllook/ClaudeMods
claude plugin install shell-guard@claude-mods

在 session 裡也可以用 /plugin marketplace add youllook/ClaudeMods,再從 /plugin 安裝。如果 session 已經開著,裝完打 /reload-plugins 就會載入。

它能碰到什麼

claude plugin validate 的輸出:

hooks: tool.call{tool=Bash}, tool.call{tool=PowerShell}
calls: $.ui.toast

只攔 Bash 和 PowerShell 兩個工具的呼叫,唯一用到的能力是跳通知。不讀寫檔案、不執行程式、不連網路。

測試

node --experimental-strip-types --no-warnings tests/rules.test.mts

34 個案例,涵蓋上表 5 條規則,以及「不該誤擋」的情況。

授權

MIT

Source 1 files
hooks/register.ts 161 lines
1import type { Register } from 'claude-code'
2
3// shell-guard: Windows shell mistakes, caught before the command runs.
4//
5//   Bash tool
6//     1. rewrite  C:\foo\bar -> C:/foo/bar           (Git Bash eats backslashes)
7//     2. deny     node -e "..." / python -c "..." holding ` or $(   (shell eats them)
8//     3. deny     unquoted heredoc (<<EOF) whose body holds ` or $  (shell expands them)
9//     4. deny     PowerShell syntax ($env:, Get-ChildItem, 2>$null, ...)
10//   PowerShell tool
11//     5. deny     Bash syntax (export X=, /dev/null, if [ ... ]; then, ...)
12//
13// Escape hatch: a command containing `shell-guard: allow` (e.g. as a trailing
14// comment) passes untouched, for the rare case the pattern is intended.
15
16const ALLOW = /shell-guard:\s*allow/
17
18// ---------- helpers ----------
19
20/** Split at the first heredoc operator (`<<`, not `<<<`): [command part, heredoc part]. */
21function splitHeredoc(command: string): [string, string] {
22  const m = /<<(?!<)/.exec(command)
23  return m ? [command.slice(0, m.index), command.slice(m.index)] : [command, '']
24}
25
26/** Blank out quoted spans so pattern checks only see bare shell text. */
27function stripQuoted(text: string): string {
28  return text.replace(/'[^']*'|"(?:[^"\\]|\\.)*"/g, m => ' '.repeat(m.length))
29}
30
31// ---------- 1. drive paths ----------
32
33const DRIVE_PATH = /(?<![A-Za-z0-9_])([A-Za-z]):((?:\\{1,2}[^\\\s'"`|;&<>()$]*)+)/g
34
35export function fixPaths(command: string): string {
36  const [head, tail] = splitHeredoc(command)
37  // Odd indexes are single-quoted spans: literal in bash, and where sed/regex live.
38  const fixed = head
39    .split(/('[^']*')/)
40    .map((part, i) =>
41      i % 2 === 1 ? part : part.replace(DRIVE_PATH, (_, d: string, rest: string) => `${d}:${rest.replace(/\\{1,2}/g, '/')}`),
42    )
43    .join('')
44  return fixed + tail
45}
46
47// ---------- 2. inline interpreter code ----------
48
49const INLINE = /\b(node|python3?|py)(?:\.exe)?\s+(-e|--eval|-p|--print|-c)\s+"((?:[^"\\]|\\.)*)"/g
50
51export function checkInline(command: string): string | null {
52  const [head] = splitHeredoc(command)
53  for (const m of head.matchAll(INLINE)) {
54    const body = m[3]
55    if (body.includes('`') || body.includes('$(')) {
56      return (
57        `shell-guard: \`${m[1]} ${m[2]} "..."\` 的雙引號內含反引號或 $(,Bash 會先把它當指令替換吃掉,程式碼會被靜默改壞。` +
58        `請把腳本寫成檔案(Write 工具,或 heredoc 用 <<'EOF' 單引號版)再執行該檔案。`
59      )
60    }
61  }
62  return null
63}
64
65// ---------- 3. unquoted heredoc ----------
66
67const HEREDOC = /<<(?!<)-?\s*(['"]?)([A-Za-z_][A-Za-z0-9_]*)\1/g
68
69export function checkHeredoc(command: string): string | null {
70  for (const m of command.matchAll(HEREDOC)) {
71    if (m[1]) continue // quoted delimiter: body is literal
72    const delim = m[2]
73    const after = command.slice((m.index ?? 0) + m[0].length)
74    const lines = after.split(/\r?\n/).slice(1)
75    const end = lines.findIndex(l => l.trim() === delim)
76    const body = (end === -1 ? lines : lines.slice(0, end)).join('\n')
77    if (body.includes('`') || /\$[({A-Za-z_]/.test(body)) {
78      return (
79        `shell-guard: heredoc <<${delim} 沒加引號,內容裡的反引號 / $變數 會被 Bash 展開或執行。` +
80        `寫檔請用 <<'${delim}'(單引號版)。若確實要展開變數,在指令加註解 \`# shell-guard: allow\`。`
81      )
82    }
83  }
84  return null
85}
86
87// ---------- 4. PowerShell syntax in Bash ----------
88
89const PS_IN_BASH: [RegExp, string][] = [
90  [/\$env:[A-Za-z_]/, '$env:'],
91  [/\b(Get|Set|New|Remove|Test|Copy|Move|Select|Where|ForEach|Write|Out|Invoke|Start|Stop)-[A-Z][A-Za-z]+\b/, 'PowerShell cmdlet'],
92  [/2>\$null\b|\$null\s*=|\|\s*Out-Null\b/, '$null'],
93  [/\$LASTEXITCODE\b|\$PSScriptRoot\b|\$true\b|\$false\b/, 'PowerShell 變數'],
94  [/(^|[\s;|&])(dir|type|del|copy)\s+[A-Za-z]:\\/, 'cmd 指令'],
95]
96
97export function checkPsInBash(command: string): string | null {
98  const [head] = splitHeredoc(command)
99  if (/\b(powershell|pwsh)(\.exe)?\b/i.test(head)) return null // explicitly delegating
100  const bare = stripQuoted(head)
101  for (const [re, label] of PS_IN_BASH) {
102    if (re.test(bare)) {
103      return `shell-guard: Bash 工具收到 PowerShell 語法(${label})。這是 Git Bash,請改用 PowerShell 工具,或改寫成 Bash 寫法。`
104    }
105  }
106  return null
107}
108
109// ---------- 5. Bash syntax in PowerShell ----------
110
111const BASH_IN_PS: [RegExp, string][] = [
112  [/(^|[;\n])\s*export\s+[A-Za-z_]\w*=/, 'export VAR='],
113  [/\/dev\/null\b/, '/dev/null'],
114  [/\bif\s+\[\[?\s/, 'if [ ... ]'],
115  [/;\s*then\b|(^|[;\n])\s*fi\s*($|[;\n])/, 'then / fi'],
116  [/;\s*do\b|(^|[;\n])\s*done\s*($|[;\n])/, 'do / done'],
117  [/(^|[\s;|&])(cat|ls|rm|cp|mv)\s+-[a-zA-Z]*[rfla]\b/, 'Unix 旗標 (-rf/-la)'],
118]
119
120export function checkBashInPs(command: string): string | null {
121  if (/\b(bash|sh|wsl)(\.exe)?\s+-c\b/.test(command)) return null // explicitly delegating
122  const bare = stripQuoted(command)
123  for (const [re, label] of BASH_IN_PS) {
124    if (re.test(bare)) {
125      return `shell-guard: PowerShell 工具收到 Bash 語法(${label})。請改用 Bash 工具,或改寫成 PowerShell 寫法(例如 $env:X='y'、$null、Remove-Item -Recurse -Force)。`
126    }
127  }
128  return null
129}
130
131// ---------- hooks ----------
132
133export const register: Register = on => {
134  on('tool.call', { tool: 'Bash' }, ($, e, next) => {
135    if (ALLOW.test(e.command)) return next(e)
136
137    const reason = checkInline(e.command) ?? checkHeredoc(e.command) ?? checkPsInBash(e.command)
138    if (reason) {
139      $.ui.toast('🛡 shell-guard 擋下一個會出錯的 Bash 指令')
140      return { deny: reason }
141    }
142
143    const command = fixPaths(e.command)
144    if (command !== e.command) {
145      $.ui.toast('🔧 shell-guard:Windows 路徑反斜線已轉正斜線')
146      return next({ ...e, command })
147    }
148    return next(e)
149  })
150
151  on('tool.call', { tool: 'PowerShell' }, ($, e, next) => {
152    if (ALLOW.test(e.command)) return next(e)
153    const reason = checkBashInPs(e.command)
154    if (reason) {
155      $.ui.toast('🛡 shell-guard 擋下一個會出錯的 PowerShell 指令')
156      return { deny: reason }
157    }
158    return next(e)
159  })
160}
161