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…

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\.claude | Git 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=bar | export is not recognized |
| # | 工具 | 偵測到 | 處理方式 |
|---|---|---|---|
| 1 | Bash | C:\foo\bar 這種反斜線路徑 | 改寫成 C:/foo/bar 後照常執行 |
| 2 | Bash | node -e "…" 或 python -c "…" 的雙引號裡有反引號或 $( | 擋下,請模型先把腳本寫成檔案 |
| 3 | Bash | 沒加引號的 heredoc(<<EOF),內容又有 $ 或反引號 | 擋下,請模型改用 <<'EOF' |
| 4 | Bash | PowerShell 語法($env:、Get-ChildItem、2>$null…) | 擋下,請模型改用 PowerShell 工具 |
| 5 | PowerShell | Bash 語法(export X=、/dev/null、rm -rf、if [ ]; then…) | 擋下,請模型改用 Bash 工具 |
/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
hooks/register.ts 161 lines1import 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