# 讓 AI 代理自動批准指令有多危險：Pixel Crew 的 Bash 圍欄是怎麼補的

> Pixel Crew 同時跑最多 20 個 Claude Code 與 Codex，自動批准不開沒辦法用。10 月 7 日那次安全審視抓到八種繞過，每一種附一行會繞過的指令、怎麼修，還有明講修不掉的部分。

- 網址：https://www.weibuilds.com/blog/2026-10-pixel-crew-auto-approve-fence/
- 作者：weiwei
- 日期：2026-10-27
- 主題：AI 與自動化
- 標籤：claude-code、codex、pixel-crew、安全

Pixel Crew 是我做的本機工具，把多個 Claude Code 與 Codex session 放進一間像素辦公室，最多同時 20 個。這種用法下自動批准一定要開，不然 20 個代理每做一件事都要你按一次允許，等於沒有自動化。但自動批准的另一面是，代理一旦被餵了惡意內容，或者單純判斷錯誤，它跑的指令不會再經過你。10 月 7 日我對這塊做了一次完整審視，抓到八種繞過，當天全部修掉。這篇一個一個講：怎麼繞、為什麼會漏、怎麼補。程式碼都在公開 repo，連結放在各段。

![像素辦公室裡一個 NPC 停在「允許執行這個指令？」的卡片前，等人按拒絕、本次皆允許或允許](./approval.webp)

*沒開自動批准的樣子：NPC 停在原地等你按。20 個 NPC 都這樣，你一整天就在按允許。*

## 先講結論：白名單放行，黑名單只負責說理由

第一版的想法很直覺，列一張危險指令清單，`rm -rf`、`sudo`、`mkfs`、`curl | bash`，沒命中就放行。審視之後整個反過來：自動批准只靠一張很窄的白名單，只放行認得出來的唯讀指令；危險指令清單還在，但它的工作變成「被拒絕的時候把理由講清楚」。

原因很簡單。黑名單只能擋你想得到的形狀，shell 能表達同一件事的方法太多了。下面八個洞，有六個都是「這條指令長得不像危險指令，但做的事是」。

Pixel Crew 現在有四檔：

| 模式 | 行為 |
|---|---|
| off | 每件事都問 |
| safe | 只放行白名單裡的唯讀指令，其餘都問 |
| full | 預設放行，只擋黑名單命中的毀滅性指令 |
| invincible | 什麼都不擋，等於 Claude Code 的 `--dangerously-skip-permissions` |

full 有一件事要講明：非 Bash 的工具呼叫，包括每一台接上來的 MCP server 的每一個工具，在 full 模式是無條件放行的。沒有逐工具的風險分級。所以 full 的意思不是「對 shell 寬鬆一點」，是「MCP server 能做的事都不再問」。這句話我寫在程式碼註解裡，也要求所有介面文案照這個意思寫。

safe 模式一條 Bash 指令要連過五關才會自動放行，任何一關沒過就回到問你。後面八個洞裡有五個，都是其中某一關原本漏了：

<figure>
<svg class="diagram" viewBox="0 0 760 250" role="img" aria-label="safe 模式的五關：指令名在白名單、旗標不在寫入或執行表、引號外沒有括號與萬用字元、串接每一段都唯讀、重導向只有丟棄輸出，全過才放行，任一關沒過就交給人" xmlns="http://www.w3.org/2000/svg">
  <defs>
    <marker id="ar" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto"><path d="M0 0L10 5L0 10z" fill="var(--ink-2)"/></marker>
    <marker id="ar-ok" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto"><path d="M0 0L10 5L0 10z" fill="var(--topic-infra)"/></marker>
    <marker id="ar-ask" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto"><path d="M0 0L10 5L0 10z" fill="var(--accent)"/></marker>
  </defs>
  <g font-size="13" font-family="inherit" fill="var(--ink)">
    <rect x="12" y="40" width="128" height="72" rx="6" fill="var(--paper-2)" stroke="var(--rule)"/>
    <text x="76" y="70" text-anchor="middle">指令名在</text><text x="76" y="90" text-anchor="middle">唯讀白名單</text>
    <rect x="164" y="40" width="128" height="72" rx="6" fill="var(--paper-2)" stroke="var(--rule)"/>
    <text x="228" y="70" text-anchor="middle">旗標不在</text><text x="228" y="90" text-anchor="middle">寫入／執行表</text>
    <rect x="316" y="40" width="128" height="72" rx="6" fill="var(--paper-2)" stroke="var(--rule)"/>
    <text x="380" y="62" text-anchor="middle">引號外沒有</text><text x="380" y="80" text-anchor="middle">( ) { } 與</text><text x="380" y="98" text-anchor="middle">萬用字元</text>
    <rect x="468" y="40" width="128" height="72" rx="6" fill="var(--paper-2)" stroke="var(--rule)"/>
    <text x="532" y="70" text-anchor="middle">串接的每一段</text><text x="532" y="90" text-anchor="middle">都唯讀</text>
    <rect x="620" y="40" width="128" height="72" rx="6" fill="var(--paper-2)" stroke="var(--rule)"/>
    <text x="684" y="70" text-anchor="middle">重導向只有</text><text x="684" y="90" text-anchor="middle">丟棄輸出</text>
    <line x1="140" y1="76" x2="162" y2="76" stroke="var(--ink-2)" stroke-width="1.6" marker-end="url(#ar)"/>
    <line x1="292" y1="76" x2="314" y2="76" stroke="var(--ink-2)" stroke-width="1.6" marker-end="url(#ar)"/>
    <line x1="444" y1="76" x2="466" y2="76" stroke="var(--ink-2)" stroke-width="1.6" marker-end="url(#ar)"/>
    <line x1="596" y1="76" x2="618" y2="76" stroke="var(--ink-2)" stroke-width="1.6" marker-end="url(#ar)"/>
    <line x1="684" y1="112" x2="684" y2="160" stroke="var(--topic-infra)" stroke-width="1.8" marker-end="url(#ar-ok)"/>
    <rect x="628" y="164" width="112" height="40" rx="20" fill="var(--topic-infra)"/>
    <text x="684" y="189" text-anchor="middle" fill="var(--paper)" font-weight="600">自動放行</text>
    <g stroke="var(--accent)" stroke-width="1.6" stroke-dasharray="4 3">
      <line x1="76" y1="112" x2="76" y2="150" marker-end="url(#ar-ask)"/>
      <line x1="228" y1="112" x2="228" y2="150" marker-end="url(#ar-ask)"/>
      <line x1="380" y1="112" x2="380" y2="150" marker-end="url(#ar-ask)"/>
      <line x1="532" y1="112" x2="532" y2="150" marker-end="url(#ar-ask)"/>
    </g>
    <line x1="76" y1="152" x2="532" y2="152" stroke="var(--accent)" stroke-width="1.6" stroke-dasharray="4 3"/>
    <line x1="304" y1="152" x2="304" y2="162" stroke="var(--accent)" stroke-width="1.6"/>
    <rect x="248" y="164" width="112" height="40" rx="20" fill="none" stroke="var(--accent)" stroke-width="1.6"/>
    <text x="304" y="189" text-anchor="middle" fill="var(--accent)" font-weight="600">交給人按</text>
    <text x="380" y="236" text-anchor="middle" fill="var(--ink-2)" font-size="12">超過 20,000 字元的指令不進這條流程，直接交給人。</text>
  </g>
</svg>
<figcaption>safe 模式的五關。虛線是任一關沒過的去向。</figcaption>
</figure>

## 洞一：唯讀指令配上旗標就會寫檔或執行程式

白名單一開始認的是指令前綴：`git diff`、`git status`、`rg`、`ls`、`cat` 這些。問題是同一個指令配上某些旗標，就不唯讀了。實測過的兩個：

```bash
git diff --output=/tmp/outside.txt   # 會把外部檔案截斷成空檔
rg --pre ./evil.sh TODO              # 會對每個檔案執行 evil.sh
```

修法是在前綴比對之後再比對一張「寫入或執行旗標」的表，而且比對前先把引號跟反斜線剝掉，因為在 shell 裡 `"--output=x"`、`--out""put=x`、`\--output` 都等於 `--output=x`。tsc 的選項名稱不分大小寫，`--OUTDIR` 一樣有效，所以那條要忽略大小寫。npm、pnpm、yarn 的 `--script-shell`、`--node-options`、`--prefix` 也在表上。

`sed` 乾脆不用前綴放行。就算是 `sed -n`，也能用 `w` 指令寫檔、用 `e` 指令執行外部命令，例如 `echo x | sed -n "w /path"`。白名單只收 Codex 讀檔時慣用的那一種整句形式，`sed -n '1,200p' 檔案`，腳本只能是行號範圍加 `p`，檔案參數不能是選項、不能有萬用字元。

## 洞二：zsh 不需要分號就能執行另一個指令

白名單原本用一組 shell 中繼字元判斷「這條指令有沒有夾帶別的東西」：換行、`;`、`&`、`|`、`<`、`>`、反引號、`$(`、`${`。這在 bash 大致夠用，但 macOS 的預設 shell 是 zsh，Claude 的 Bash 工具也跑在上面，zsh 多了幾種不需要任何中繼字元的寫法：

```bash
ls *(e:'touch pwned':)    # glob qualifier，列檔案的同時執行 touch
cat =(cmd)                # process substitution
git diff --out{put,}=x    # brace expansion 拼出上面擋掉的旗標
```

修法：引號外出現 `(`、`)`、`{`、`}` 任何一個，就不算白名單安全指令。

## 洞三：萬用字元會展開成檔名，檔名可以長得像旗標

`rg TODO *.ts` 看起來無害。但 `*.ts` 會在執行前被 shell 展開成目錄裡的檔名，而檔名可以就叫 `--pre=x.ts`。洞一那張旗標表是在展開前比對的，看不到展開後才出現的選項。

修法只對有危險旗標的工具收緊：git、rg、eslint、tsc、npm、pnpm、yarn 這幾個，引號外出現 `*`、`?`、`[` 就不自動放行。`ls *.ts`、`cat *.md` 這類沒有能寫檔或執行的旗標，照舊可以用萬用字元，不然白名單會窄到沒人想用。

## 洞四：寫入重導向有很多種寫法

Pixel Crew 對檔案工具有一道圍欄：寫入目標逃出 workspace 就硬擋。Bash 沒有結構化的路徑參數，所以另外做了一個函式，掃出指令裡「寫入型重導向」的字面目標，逃出 workspace 就回 true。第一版只認 `>` 跟 `>>`。審視時補上的：

```bash
cmd 1> /etc/x        # 帶 fd 編號
cmd 2>> ~/.zshrc     # fd 加 append，目標還要先展開 ~
cmd &> $HOME/x       # stdout 跟 stderr 一起寫，$HOME 在雙引號內會展開、單引號內不會
cmd >| /tmp/x        # 強制覆寫，zsh 也認 >!
cmd >& /tmp/x        # >& 後面接檔名就是寫檔，不是 fd 編號
```

同時要排除不是寫檔的：`2>&1`、`>&2`、`>&-` 是 fd 複製或關閉，`/dev/null`、`/dev/stdout` 是丟棄輸出。`~user`、`~+` 這類靜態判定不了的，一律當成逃逸。

這個函式的註解我寫得很白：**刻意不做完整 shell 解析，這是防禦縱深，不是圍牆**。它只認字面重導向目標，對變數展開（`p=/etc; echo > $p`）、子殼、`python -c "open(...)"`、`cp`、`mv`、`tee` 的目的地，一律「認不出等於不擋」。測試檔裡有一個案例叫 `documented residual bypasses are NOT flagged`，專門確認這些繞過真的不會被擋，免得哪天有人以為圍欄比實際上厚。

<figure>
<svg class="diagram" viewBox="0 0 760 236" role="img" aria-label="寫入重導向圍欄擋得到的形狀與擋不到的形狀" xmlns="http://www.w3.org/2000/svg">
  <g font-family="inherit" fill="var(--ink)">
    <rect x="12" y="12" width="356" height="212" rx="8" fill="var(--paper-2)" stroke="var(--rule)"/>
    <rect x="12" y="12" width="356" height="34" rx="8" fill="var(--topic-infra)"/>
    <rect x="12" y="30" width="356" height="16" fill="var(--topic-infra)"/>
    <text x="30" y="35" font-size="14" font-weight="600" fill="var(--paper)">擋得到：字面上的寫入重導向</text>
    <g font-size="13" font-family="ui-monospace, Menlo, monospace">
      <text x="30" y="76">cmd &gt; /etc/x</text>
      <text x="30" y="100">cmd 2&gt;&gt; ~/.zshrc</text>
      <text x="30" y="124">cmd &amp;&gt; $HOME/x</text>
      <text x="30" y="148">cmd &gt;| /tmp/x</text>
      <text x="30" y="172">cmd &gt;&amp; /tmp/x</text>
    </g>
    <text x="30" y="206" font-size="12" fill="var(--ink-2)">目標逃出 workspace 就擋；2&gt;&amp;1、/dev/null 不算</text>
    <rect x="392" y="12" width="356" height="212" rx="8" fill="var(--paper-2)" stroke="var(--rule)"/>
    <rect x="392" y="12" width="356" height="34" rx="8" fill="var(--accent)"/>
    <rect x="392" y="30" width="356" height="16" fill="var(--accent)"/>
    <text x="410" y="35" font-size="14" font-weight="600" fill="var(--paper)">擋不到：認不出就不擋，寫在文件裡</text>
    <g font-size="13" font-family="ui-monospace, Menlo, monospace">
      <text x="410" y="76">p=/etc; echo x &gt; $p</text>
      <text x="410" y="100">python -c "open('/etc/x','w')"</text>
      <text x="410" y="124">echo x | tee /etc/x</text>
      <text x="410" y="148">cp x /etc/x</text>
      <text x="410" y="172">( echo x &gt; /etc/x )</text>
    </g>
    <text x="410" y="206" font-size="12" fill="var(--ink-2)">防禦縱深，不是圍牆；full 模式下這些會過</text>
  </g>
</svg>
<figcaption>寫入重導向圍欄的兩邊。右邊是刻意不處理、但明講的部分。</figcaption>
</figure>

## 洞五：檢查看的是卡片文字，不是完整指令

核准卡片顯示的指令有長度上限，20,000 字元。原本的檢查是對卡片上的文字跑，所以在危險片段前面塞兩萬個字的填充，檢查就只看得到無害的開頭。

修法兩條：危險指令的比對一律對完整指令跑；長度超過上限的指令不自動放行，直接交給人，理由寫「指令超過 20,000 字元，無法完整顯示與檢查」。卡片顯示不完整的東西，本來就不該有人幫你按允許。

## 洞六：Windows 的防線根本沒接上

黑名單有 Unix 的 `rm`、`mkfs`、`dd`，卻漏了 Windows 的對等指令，而 Windows 是 Pixel Crew 的主要平台之一。補上的：

```powershell
Remove-Item -Recurse -Force C:\repo\dist
Remove-Item build -r -fo        # PowerShell 參數可以縮寫
rd /s /q C:\repo\dist
del /f /s /q *.log
format D: /q
```

幾個細節。`del`、`erase`、`rd`、`rmdir`、`ri` 都是 `Remove-Item` 的別名，要一起認。`format` 後面要直接接磁碟代號才算，不然會誤中到處都是的 `--format` 旗標。Windows 的指令與別名解析不分大小寫，`Git Push --Force` 跟 `git push --force` 一樣，所以整組比對都要忽略大小寫。

路徑也有 Windows 專屬的洞。判斷「目標有沒有逃出 workspace」用的是 `path.relative()`，看結果是不是 `..` 開頭。但跨磁碟（`D:\`）、UNC（`\\server\share`）、`\\?\` 路徑跟 workspace 沒有共同的根，`relative()` 會直接回傳目標的絕對路徑，不是 `..` 開頭，第一版就放過去了。修法多一個條件：結果是絕對路徑也算逃逸。

## 洞七：對外提供檔案的目錄不能是 symlink

每個 NPC 有一個 outbox 目錄，裡面的檔案可以從網頁下載。路徑檢查會擋 `..`，但如果 outbox 本身是個 symlink 指到別處，檢查過了、讀到的卻是別的地方的檔案。修法是提供檔案前先確認 outbox 目錄不經過 symlink，workspace 裡的路徑檢查本來就會逐層 `lstat`，這裡補成一致。

## 洞八：Read 跟 WebFetch 不該預先放行

為了少跳提示，第一版把 Read、Glob、Grep、WebSearch、WebFetch 當成唯讀工具預先放行。問題是 Read 可以讀 workspace 外的檔案，WebFetch 可以抓任意網址，前者外洩，後者是把外部內容餵給代理的入口。現在這兩個在 safe 模式一樣要經過批准，只有讀 workspace 內的檔案才直接過。

## 還有一個不是洞的坑：Codex 的指令外面包了一層殼

Codex CLI 回報的每一條指令都包成 `/bin/zsh -lc <cmd>`，內層有單引號時改用雙引號、單字時不加引號。前綴白名單永遠對不上這個外殼，等於 Codex 在 safe 模式什麼都要問。修法是只拆「一層」，而且只拆引號內不可能有跳脫或展開的形式；拆不乾淨就回 null，照一般判定走，不會因此多放行任何東西。

## 修完之後的規則

- 自動放行只看窄白名單；黑名單負責拒絕理由。
- 白名單比對指令名、也比對旗標；比對前剝引號與反斜線。
- 引號外出現 `(){}` 或對旗標敏感的工具遇到萬用字元，不放行。
- 串接指令逐段檢查，每一段都唯讀才整條放行；只容忍 `2>&1`、`2>/dev/null` 這類丟棄輸出。
- 寫入重導向逃出 workspace 就擋，認不出的形狀不擋，但寫在文件裡。
- 看完整指令，超過 20,000 字元交給人。
- Windows 的刪除與格式化指令、跨磁碟與 UNC 路徑，跟 Unix 同等待遇。
- 只有使用者親手切換才能進 full 與 invincible，介面要說清楚 full 對 MCP 工具是全放行。

殘餘的風險我也列在文件裡：變數展開、子殼、直譯器內的檔案操作、`tee` 與 `cp` 的目的地，這些在 full 模式下會過。開 full 的人要知道自己換到的是什麼。

改到的檔案：[dangerousCommand.ts](https://github.com/juinwei7/Pixel-Crew/blob/main/server/src/dangerousCommand.ts)、[bashWriteFence.ts](https://github.com/juinwei7/Pixel-Crew/blob/main/server/src/bashWriteFence.ts)、[safeLocalPath.ts](https://github.com/juinwei7/Pixel-Crew/blob/main/server/src/safeLocalPath.ts)，以及對應的測試。發現繞過請走 [私密弱點回報](https://github.com/juinwei7/Pixel-Crew/security/advisories/new)，七天內回覆。
