昨天修的是它寫進專案裡的東西。那種東西你有機會先讀過再合進去,就算沒讀,它躺在 diff 裡等你。
今天這種不一樣。它叫你直接跑。
先看一件已經發生過的事。2025 年 8 月公布的 CVE-2025-53773:一段藏在原始碼、網頁或 GitHub issue 裡的文字,讓 Copilot 自己往 .vscode/settings.json 加上一行 "chat.tools.autoApprove": true。加完之後,它跑 shell 指令不再問你。CVSS 7.8,微軟同月修掉(MSRC,揭露文)。
要記的不是那個編號,是那個動作的形狀:把確認關掉的那一步,不是你做的。 而你會在某個當下覺得它很合理。
今天要處理的就是這件事的一般版本。
情境是這樣:Day 2 我們清過一次專案裡的祕密,你想再確認一遍,於是問 AI 有沒有快一點的方法。它給你一段腳本,說跑這個就會把殘留的金鑰掃出來。
# 它說要做的事
grep -rIl -e 'sk-[A-Za-z0-9]' . | wc -l
這一行確實在做它說的事:掃目前目錄,數出幾個檔案裡有長得像金鑰的字串。但它只是一條很窄的比對,會誤報也會漏掉別的服務的憑證,取代不了真正的祕密掃描工具。今天要看的不是它掃得準不準。
你把整支存成 fix.sh,bash fix.sh,兩秒跑完。到這裡為止,一切都很合理。
我在同一支腳本後面加了一段。它不打開任何檔案、不輸出任何內容,只問兩件事:這幾個路徑我讀不讀得到,還有我連不連得出去。
for p in "$HOME/.ssh/id_ed25519" "$HOME/.aws/credentials" \
"$HOME/.npmrc" "$HOME/.zsh_history" "$HOME/.gitconfig" ; do
if [ -r "$p" ]; then echo "可讀 $p"; else echo "讀不到 $p"; fi
done
code=$(curl -s -m 5 -o /dev/null -w '%{http_code}' https://example.com) || code=""
case "$code" in
[0-9][0-9][0-9]) echo "連得到外面:HTTP $code" ;;
*) echo "連不出去" ;;
esac
在我的機器上跑出來是這樣(家目錄的路徑我換成 /Users/you):
── 它同時做得到的事 ──
讀不到 /Users/you/.ssh/id_ed25519
讀不到 /Users/you/.aws/credentials
可讀 /Users/you/.npmrc
可讀 /Users/you/.zsh_history
可讀 /Users/you/.gitconfig
連得到外面:HTTP 200
我這台沒有 ~/.ssh 的金鑰、沒有裝過 AWS CLI,所以前兩條是空的。但 ~/.npmrc 有。 那個檔案只有一行,//registry.npmjs.org/:_authToken=,我發套件用的。
~/.zsh_history 更難反駁。五萬九千字,我打過的每一行指令,包含那些手滑貼在命令列上的東西。
這份清單你要自己列,不要照抄我的。我原本還寫了 ~/.config/gh/hosts.yml,以為 GitHub token 在裡面,查下去才發現 gh 預設把 token 放系統鑰匙圈,那個檔案只有 82 個位元組,裡面是帳號名跟 git_protocol: https。寫錯的那一版我已經跑過、看過輸出、覺得很有說服力。 差別只在後來有人叫我把 gh auth status 打出來。
最後那行更直接:它連得出去。
真的要偷的腳本不會像這樣印給你看。它會直接送走,而且那一行藏在你剛才捲過去的四十行 grep 中間。
我要講清楚一件事:上面那段程式碼本身不是漏洞。
[ -r ] 是標準寫法,curl 是你天天用的東西。它們變成問題,只因為執行它們的那個行程繼承了你的身分。你的檔案就是它的檔案,你的網路就是它的網路。
Day 1 那張信任邊界圖,界線畫在你的機器跟使用者的機器之間。今天那張圖裡面多了一格:

先看左邊那條虛線。紅框在界內,跟你自己寫的程式碼並排,中間沒有任何東西隔開。這就是問題本身:界內沒有再細分,所以它拿到的權限跟你本人一樣多,而裡面跑的東西不是你寫的。
然後看紅框射出去的兩條實線。那兩條是今天的主線:它看得到什麼、它連得去哪。等一下那串旗標會在這兩條之外再補三件事:它改得動什麼、能不能提權、能吃掉多少資源。
隔離手段選容器。等一下會講沒有 Docker 的退路。
樣板這種東西給 AI 生就好,它比你快。
問之前先過一遍 Day 4 那份 AI-SHARING.md。這一步要送出去的只有「我要在容器裡跑一支來路不明的腳本」這件事,compose、.env、目錄樹都不用貼。樣板不需要看你的專案長什麼樣,它需要看的是你的要求裡寫了什麼。
我在 2026-08-06 用手邊一個小模型(Antigravity CLI 的 Gemini 3.5 Flash)問了兩次,故意用不一樣的問法。
第一次照大家平常的問法:「幫我在 Docker 裡跑我專案裡的 suspect.sh」。它回:
FROM alpine:latest
RUN apk update && apk add --no-cache bash
WORKDIR /app
COPY . .
RUN chmod +x suspect.sh
ENTRYPOINT ["/bin/bash", "./suspect.sh"]
docker run --rm suspect-app
COPY . . 把整個建置目錄複製進映像檔。.dockerignore 寫對了擋得掉 .env,而它沒有提醒你要寫。映像檔的層又是留著的,你之後推到哪裡它就跟到哪裡。你 Day 2 才把它從版本庫裡挖出來,現在它換一個地方待著。沒有任何網路限制。還有,沒有 USER,所以裡面是 root。
root 那條我故意放最後,因為等一下量出來的結果會跟你的直覺不一樣。
它最後還貼心補了一句:需要存取外部目錄的話,加 -v。
第二次我把顧慮寫進問題:「來路不明的腳本,不要讓它碰到我本機的東西」。同一個模型回的是:
FROM alpine:latest
RUN apk add --no-cache bash
RUN addgroup -S appgroup && adduser -S appuser -G appgroup
WORKDIR /app
COPY suspect.sh .
RUN chmod +x suspect.sh
USER appuser
CMD ["./suspect.sh"]
docker run --rm --network none suspect-runner
COPY . . 變成只複製那一支,多了非 root 使用者,docker run 主動加上 --network none。
同一個模型,同一支腳本,差別只在我有沒有把要求寫進去。 這就是這天的分工:草稿它出,但草稿的內容等於你問題裡寫了什麼。你沒說的它不會替你想,而漏掉的那幾項不會報錯,只會安靜地生效。
所以第二次那份也不能照收。我拿它當底改了兩個地方:腳本不 COPY 進映像檔,改成執行的時候用 -v 掛進去(換目標目錄的時候腳本才不會跟著不見),還有把進入點固定成 sh,這樣掛進來的檔案不需要有執行權限。
FROM alpine:3.22
RUN apk add --no-cache curl
RUN adduser -D -u 10001 runner
USER runner
WORKDIR /work
ENTRYPOINT ["sh"]
然後把 docker run 改成預設什麼都不給:
docker run --rm \
--network none \
--read-only \
--tmpfs /tmp:rw,noexec,nosuid,size=16m \
--cap-drop ALL \
--security-opt no-new-privileges \
--pids-limit 128 --memory 256m \
-v "$PWD/suspect.sh:/suspect.sh:ro" \
-v "$PWD:/work:ro" \
-w /work \
suspect-runner /suspect.sh
ENTRYPOINT ["sh"] 那行是有作用的:最後那個 /suspect.sh 是當參數傳給 sh,不是直接執行。掛進來的檔案是 0644,直接執行會拿到 permission denied,而那個錯誤訊息不會告訴你原因出在權限位。
| 旗標 | 收掉了什麼 |
|---|---|
--network none |
不連外,容器裡只剩 loopback |
--read-only |
根檔案系統唯讀,寫不進去 |
--tmpfs /tmp |
上一行擋掉之後真的需要暫存檔,只開這一格 |
--cap-drop ALL |
Linux capability 一個不留 |
--security-opt no-new-privileges |
跑起來之後不能再提權 |
--pids-limit --memory |
行程暴增與吃光記憶體的影響變小。管不到 CPU,要限制得另外加 --cpus |
-v "$PWD:/work:ro" |
唯一「你明確放進去」的宿主機目錄,而且唯讀 |
每一項的官方說明我抓成快照放在 recipe 的 sources/,附對照表,你可以逐句核(Docker 官方文件,2026-08 查證)。這張表你現在不用記。 它的用途是等你哪天真的要打開其中一項的時候,回來看那一行收掉的是什麼。
最後那行的 suspect-runner 是映像檔的名字,docker build -t suspect-runner . 建出來的。
中間那段我改了很多次,講的都是驗證腳本怎麼被打回來。那部分有用,但它會蓋掉主線,所以先把主線收成五步:
後面的內容都在填第 5 步:怎麼知道「重新驗證」不是在自我安慰。
先讀再跑,這篇整天在講這件事。 suspect.sh 三十行,掃一眼就知道它做什麼。要注意的有兩件:它會把你的家目錄唯讀掛進容器一次(第 4 節,那正是它要示範的),跑完會留一個約 14 MB 的映像檔。
git clone https://github.com/cyh7789/ai-security
cd ai-security/recipes/06-run-it-somewhere-else
less suspect.sh verify.sh # 這一行不要跳過
bash verify.sh
它會建映像檔、跑四節,最後印這一行:
════════ 10 綠 / 0 紅 / 0 跳過 ════════
看到紅的不是你做錯,是那一節在你的機器上結論跟我不一樣,往上看它印了什麼。沒裝 Docker 的話第 2 到 4 節會跳過,而跳過會單獨印一行,不會混進綠燈的數字裡。
要換成你自己的專案,run-isolated.sh 後面接目錄:
bash run-isolated.sh ~/你的專案
講清楚它做什麼:這樣是把那個目錄唯讀掛進容器,讓 recipe 內建的 suspect.sh 去看它,不是去執行你專案裡的腳本。要跑你自己那支,把最後那個 /suspect.sh 換成你掛進去的路徑。
驗證的方式是別動腳本,只換執行的地方,然後比它碰得到什麼。
=== 2. 同一支腳本,關進預設全關的容器 ===
| 探針(不跑 suspect.sh):SETUP_OK ABSENT=3 UNREADABLE=0 READ=0
[OK] 容器裡看得到的掛載就是 flags.sh 白名單那三個,沒有多
| 讀不到 /Users/you/.npmrc
| 讀不到 /Users/you/.zsh_history
| 讀不到 /Users/you/.gitconfig
| 連不出去
[OK] 掛載到位、腳本在容器裡(這句由驗證腳本自己的探針證明,不是 suspect.sh 說的)
[OK] 探針去找那 3 個檔案,3 個都不存在。家目錄真的沒進來
[OK] suspect.sh 的輸出跟探針一致:本機讀得到的那幾條,容器裡一條都讀不到
| 對照組(同一份旗標,只濾掉 --network none):連得到外面:HTTP 200
[OK] 同一個容器不加旗標連得到、加了連不到,所以擋住它的是 --network none
剛剛在本機可讀的那三條,現在讀不到了。網路也沒了。
那些「探針」「對照組」的行是後來補的,補的過程比結果值得講。
我第一版只有兩條檢查:讀不到、連不出去。看起來很乾淨,跑起來全綠。
問題是這一整節的檢查都是「某個東西找不到」的形狀,而找不到有很多種原因。腳本根本沒在容器裡跑起來,找不到。掛載沒生效,找不到。容器裡壓根沒有 curl,也找不到。這三種情況跟「隔離成功」印出來的東西一模一樣。
我後來實測了三種「隔離其實沒生效」的配置:把腳本路徑改成不存在的、把整個 -v 拿掉、映像檔裡不裝 curl 而且連 --network none 都拿掉。三種都跑出滿分綠燈,其中一次的綠燈句子還指名了一個當下根本不在命令列上的旗標。
所以現在每一條「找不到」都配一條「找得到」:
| 「找不到」那條 | 撐它的「找得到」 |
|---|---|
| 容器裡讀不到家目錄的檔案 | 本機讀得到,而且容器真的架起來了 |
--network none 連不出去 |
同一個容器不加那個旗標就連得到 |
| 唯讀擋掉寫入 | /tmp 寫得進去,而且掛載真的在 |
| 沒有多出別的掛載 | 直接列出容器裡看得到的掛載點,跟白名單逐項比 |
右欄不是額外的謹慎,是左欄成立的前提。少了它,你量到的不是隔離,是「什麼事都沒發生」。
還有一層更容易漏掉的:那個「找得到」不能由被測的東西自己提供。 我一開始的判準是「suspect.sh 的輸出裡有沒有那行標題」,可是 suspect.sh 正是你還沒讀完的那支,它想印什麼都行,印完 exit 0 就騙過去了。所以現在容器架好了沒,是驗證腳本自己另外跑一發探針去問的,那一發根本不執行 suspect.sh。
這是 Day 5 那條規矩的同一個形狀:一個在沒做事的時候也會顯示成功的檢查,不是驗證。
-v,剛剛那些綠燈全部沒了-v "$HOME:/host:ro"。就這一行。
網路一樣關著。唯讀一樣開著。一樣不是 root 在跑。
=== 4. 只多加一行 -v,第 2 節的結論就沒了 ===
| 探針:SETUP_OK ABSENT=0 UNREADABLE=0 READ=3
[OK] /host 掛進去了,而且探針讀得到 3/3 個剛剛讀不到的檔案
| 可讀 /host/.npmrc
| 可讀 /host/.zsh_history
| 可讀 /host/.gitconfig
[OK] 掛了家目錄之後,容器讀得到的跟本機一模一樣。容器還在,隔離沒了
那個 npm token 又回來了,~/.zsh_history 也是。
補一句限定,它會影響你自己跑出來的結果。.npmrc 這種檔案通常是 0600,只有主人讀得到。原生 Linux 的 bind mount 直接沿用宿主機的 uid 與權限位,所以你在 Linux 上用非 root 的容器使用者跑,很可能會看到「讀不到」。那不代表隔離生效了,代表權限本來就擋著。 分辨的方法就是上面那張表:先確認沒掛的時候讀不到、掛了之後讀得到,中間那個差才是掛載造成的。
標題講的是「控制」,這裡要把話說明白:今天量到的只有一半。
今天示範的是它看得到什麼、連得去哪。第三件事是免費附贈的:直接在本機跑的時候,那個行程以你的身分執行,你改得動的檔案它就改得動。shell 設定檔、git hook、專案裡任何一支下次會被跑到的腳本。這個我沒有示範,因為在自己機器上示範的代價太高。
容器裡的兩道限制分別擋不同的東西,不要混:-v ... :ro 擋掛進去的專案,--read-only 擋容器自己的根檔案系統。第 3 節把兩個各拿掉一次,確認兩個都真的有在擋。但這兩道都只在容器裡有效,本機那一次執行從頭到尾沒有人擋。
那「AI 自己動手」呢?開場那個 CVE 已經是了:確認步驟被關掉之後,agent 跑 shell 指令不再問你。那不是它讀到什麼,是它做了什麼。Day 15 會走一次 agent 拿到工具之後的樣子(一條注入讓它去打內網 metadata),Day 19 處理「模型生的 shell 指令直接送進 exec()」。今天先把它關起來,那兩天再談它在裡面能做什麼。
「有沒有放進容器」不是判準,「掛了什麼、開了哪條線」才是。
容器不是一個盒子,它是一份清單。而在 AI 的流程裡,往清單上加項目的常常不是你:腳本跑不起來,你回去問它,它給你的下一句就是那行 -v。
這不是假設。那個模型第一次回答的結尾就寫了「需要存取外部目錄的話加 -v」,講的正是這一行。它沒有騙你,它甚至是對的,你確實會需要。差別在於加下去的那一刻,沒有人告訴你剛剛取消了什麼。跟開場那個 CVE 同一個形狀:那次是把確認關掉,這次是把牆打開,只是這一次動手的是你,而建議是它給的。
真實一點的例子。我自己有個專案的 compose,六個服務,每一個都寫了同一行:
env_file:
- .env.docker
只跑排程的那個 worker 因此拿到了金流的金鑰。它從來沒用過。這行不是誰疏忽寫錯的,是第一個服務要,複製到第二個,然後六個都有了。逐項放行的相反不是「全開」,是「複製貼上」。
全關之後腳本會開始壞掉,這是預期內的。壞掉的地方就是它真正需要的東西,一次一個現形,比你事先猜準得多。
最常第一個壞的是寫入。這類腳本通常要把結果落成一個檔案,而唯讀之下它連 touch 都不行:
| TMP_OK touch: /work/PWNED: Read-only file system NOCAP NNP(離開碼 0)
放行的寫法是只多開一個掛載,不是把 --read-only 拿掉:
-v "$PWD/report:/work/report"
同一個目錄樹,只有 report/ 這一層可寫。根檔案系統還是唯讀的,腳本想改自己旁邊的 .git 一樣改不動。
順帶一提,這一節的對照組問的不是「/tmp 寫不寫得進去」,是「/tmp 以外的地方寫不寫得進去」。前一種在 --read-only 和 --tmpfs 一起消失的時候照樣全綠,因為那時候整個根檔案系統都能寫。
網路那條要小心得多,因為它沒有「只開一條縫」這種東西。
--network bridge 不是「允許連 npm registry」,是恢復一般對外網路。Docker 官方保證的只有 --network none 會讓容器只剩 loopback,它沒有提供網域允許清單這種功能。所以在一個還掛著你專案的容器裡把 none 換成 bridge,你同時打開了把那些內容送出去的路。唯讀擋不了這件事,讀出來送走不需要寫入。
要下載東西就拆成兩段:一段有網路但不掛任何專案目錄與憑證,只負責把套件抓下來;抓完回到沒有網路的那一段去跑。真的需要邊跑邊連,就架一個限定目的地的代理,讓容器只走得到那個代理。
放行完要重跑一次驗證,看的是沒放行的那幾條有沒有跟著鬆掉。判準寫死:容器突然多看得到的東西,只能是你剛剛明確放進去的那一項,多一個都不對。
最後記一個我自己出的包,它跟上面那張表是同一件事。
我原本在這一節寫了另一個發現:「把主機路徑掛到容器裡同一個絕對路徑,整個目錄會安靜消失」。我打了指令、看到 No such file or directory、寫進稿子。這句是錯的。
被審查打回來之後我重驗,才發現掛載參數在送到 docker 之前就被我這台機器上的工具改寫過了,改掉的正好是目的地那一段。我以為我量到了 Docker 的行為,量到的其實是我自己的環境。
看一眼就會穿幫,而我當初沒看:
CID=$(docker create --rm -v "$HOME:/host:ro" alpine true)
docker inspect "$CID" --format '{{json .HostConfig.Binds}}'
它印出來的字串必須跟你打進去的一模一樣。不一樣的話,你後面量到的每一個數字都是別的東西的。
你的量測工具也在待驗證的範圍裡面。 這一整天講的就是這件事,只是我先示範了一次反例。
退路是一個獨立的低權限使用者。Linux 上是 sudo useradd -m runner,把腳本放進它讀得到的地方,再 sudo -u runner bash fix.sh。你的家目錄它讀不到,這一半有效。
但如果你在 Mac 上,這行照打會失敗。 macOS 沒有 useradd 也沒有 adduser(我在自己機器上確認過,2026-08-06),要建使用者得走 sysadminctl 或系統設定,為了跑一支腳本這樣做太重了。Mac 上實際的答案是裝一個容器執行環境,Docker Desktop、OrbStack、Colima 都可以,個人用都不用錢。
而且就算你建了那個使用者,要誠實講清楚它比容器差在哪:網路它照連,系統上所有全域可讀的東西它照看,而且你得記得別把要跑的目錄權限開太大。
還有一個零安裝的做法,適用範圍窄但今天就能用:把腳本先讀一遍,把裡面所有 curl、wget、ssh、| sh 的行找出來,逐行問「這一行要連去哪裡」。這不是隔離,是審查。 它擋得住你看得懂的那些,擋不住混淆過的。
擋得住的是你本機這一次執行。
擋不住的是別的地方。你的 CI 沒有人幫它加 --network none,正式環境的容器本來就要連資料庫、要讀掛進去的憑證。同一個惡意套件在那兩個地方照樣跑,而且跑在權限更大的位置。
所以容器不能拿來取代「這個東西該不該裝進來」的判斷。這兩件事的分界很清楚:今天管的是執行的時候把它關在哪裡,明天管的是裝進來之前信不信它。 把兩者混為一談的後果,就是以為有沙箱就不用看套件來源。
你手上多了一份此專案的隔離執行設定,而且它是驗證過的,不是抄來的。
驗證過的意思很具體:你有一份「本機碰得到什麼」的清單,和一份「容器裡碰得到什麼」的清單,兩份不一樣,而且你知道差在哪一行旗標。
--network none、--read-only、--cap-drop ALL、-v ... :ro。四樣預設關掉的東西,之後每一次你要打開其中一個,都會是一個你看得見的動作。
還有一份更小但更有用的東西:那張白名單。容器裡應該只看得到哪幾個掛載點,寫成一行。之後你每加一個 -v,那一行就會告訴你多了什麼。
recipe 裡另外附了兩個東西,都是被審查打回來才補的。mutations.sh 會故意把隔離弄壞十三種,看驗證腳本會不會抓到,有一種沒抓到就是假綠燈。PROMPTS.md 是上面那組「同一個模型問兩次」的原始提示、回答節錄和工具版本,我講的差別你可以自己重跑一次對。
「預設不給、逐項放行」這個形狀,Day 15 給 agent 開權限的時候會一模一樣地再出現一次。到時候清單上的項目換成工具,判斷方式不變。
攻擊集今天沒有長,還是 2 條。今天長的是另一邊:你有了一個地方可以跑那些你還沒讀完的東西。
今天處理的是「跑」,前提是那個東西已經在你機器上了。
明天往前推一步:它叫你 npm install 的那個套件,是誰發的?上一次更新是什麼時候?
不過在那之前有一個更基本的問題,基本到你可能沒想過要問:那個套件存在嗎?
上一篇:Day 5|AI 找得到自己寫的洞,是我的驗證方法騙了我
今天這四節驗證:recipe 06|範例專案:github.com/cyh7789/ai-security