本日對應公開 Repo:dev-container/
本文依實作當時的建置順序,從最初版 Dockerfile 開始;上方資料夾則是經過後續實戰調整後的目前版本。
一個 AI agent 在使用者全大寫反覆叮囑「不要再動」之後,刪除了正式環境的資料庫。這件事提醒我:光靠指令約束不夠,還要管住 agent 實際拿到的能力。
前面的日子裡我們把 Code Review 的 Skill 做出來,測完了,也評完了。接下來幾天,講它跑在什麼樣的環境裡,以及我怎麼逐步縮小放手讓它自己跑的風險。
Day 16 結尾埋的那道防火牆,戲份還在後面。今天先把容器的骨架搭起來;真正關得住哪些能力,接下來幾天再逐層驗證。
我們其實都有一種期待是希望 AI Agent 可以獨立執行完畢,期間最好不要一直打斷我、問我問題,例如:是否可以編輯這個檔案、下這個指令等等的。
Claude Code 一開始有提供 --dangerously-skip-permissions,讓 AI 可以呼叫什麼工具就用什麼工具。後來也提供風險相對小的 Auto Mode,由獨立的分類器模型在操作執行前進行審查。
官方文件:
但「放手讓它跑」這件事,業界已經付過幾次學費了:
rm -rf 一路刪出專案外,Documents、Downloads、Desktop 全都受到影響。來源:Replit 事件見 Replit 官方回應、Ars Technica與 Fortune;
Gemini CLI 事件見官方 Issue #4586 與 Issue #2617;
Matt Shumer 事件見他的原始貼文與 Full Access 回覆。
這三個案例的證據層級不同:Replit 事件有使用者紀錄、媒體報導與公司回應;Gemini CLI 是使用者在官方 issue tracker 提出的回報;Matt Shumer 則是本人公開自述。我把它們視為風險訊號,不拿來證明某個產品或模型必然會出事。
它們真正的共通點是:agent 拿得到足以造成真實損害的能力。Gemini CLI 的 Allow Always 與 Matt Shumer 使用的 Full Access,放寬了逐步確認;Replit 案例裡,agent 則能直接改動正式環境的資料庫。提示詞裡的一句「不要再動」,擋不住已經交出去的權限。
所以我的做法不是「更用力叮囑」,也不是「忍耐一直按允許」,而是換個思路:先把 AI 放進一個損害範圍可控、邊界可以驗證的環境,再決定哪些權限提示可以放寬。這個思路本身不新,業界叫它縱深防禦,「模型的判斷力不能拿來當安全機制」這句話也早就有人講完了。接下來這幾天不打算再論證一次,要做的是親手蓋一遍,然後蓋壞給你看。這就是今天的主角:dev-container。
先給全貌。後面幾天講的每一件事(憑證怎麼借、網路開多大、流量怎麼側錄),都是在拆這張圖的某一塊:
Dev Container 的可用示範容器其實 Anthropic 官方已經公開在 GitHub 中,並且有相應的說明文件。
我的 Dev Container 就是從這份範例延伸而來。
順著這個往上還有一階。NVIDIA 的 OpenShell 是一套專門把 agent 關起來的 runtime:檔案走 Landlock、行程走 seccomp、出網走它自己的 proxy,政策寫成一份宣告式的檔案。開箱即用的程度很高,這幾道邊界它一次就給齊。
我沒有用它,理由很單純:我這套環境在六月那場分享之前就在跑了。OpenShell 的第一筆 GitHub Release(v0.0.6) 出現在 2026 年 3 月 16 日;截至 2026 年 8 月 25 日,官方仍將它標示為 alpha,GitHub Releases 已累積 93 筆紀錄。當時它還太新,所以我選擇自己動手。而我補的東西也不是照著它那張清單長的:憑證怎麼借、網路開多大、流量怎麼側錄,是接下來這幾天一件一件撞出來的,撞到哪裡補到哪裡。
但也因為是自己補的,我很清楚每一道門是怎麼關的、關到哪裡。用現成工具的代價在另一個地方:你要懂它的設定值。「要不要真的擋」「擋不住的時候要不要繼續跑」通常都是一個參數,而參數有預設值。你以為門有鎖,結果一推開就開了。
官方用 node:20 我這邊改用 ubuntu:24.04。
這一條是 hunch,講不出硬理由:我想從一個通用的、不預先綁任何工具的底層開始裝。這顆容器裡要裝的東西很多,node 只是其中一樣,拿 node 的 image 當底,等於先替我決定了一件我還沒決定的事。
以下列出 Skill 會用到的工具,以及容器內常用的基礎工具:
| 編號 | 工具名稱 | 是否固定版本 |
|---|---|---|
| 1 | ruff | 是(0.16.1) |
| 2 | ty | 是(0.0.66) |
| 3 | oxlint | 是(1.77.0) |
| 4 | trivy | 否 |
| 5 | opengrep | 是(v1.26.0) |
| 6 | codegraph | 否 |
| 7 | Python 3.13 + pydantic | 是(pydantic 2.13.4) |
| 8 | shellcheck | 否(隨發行版) |
| 9 | Claude Code | 否 |
| 10 | nvm + Node 24 | 是(nvm v0.40.6、Node 24) |
| 11 | uv | 否 |
| 12 | rtk | 否 |
| 13 | mitmproxy | 是(12.2.3) |
| 14 | curl / git / jq / tzdata / vim | 否(apt 隨發行版) |
Dockerfile 置於
dev-container/資料夾下
以下先重現當時的第一版,對應公開 repo 的 d8d5c7e。該版本的 Dockerfile 結尾仍是 CMD ["bash"],所以可以直接用下面的 docker build/docker run 進入 shell。若你要執行目前 main 上的版本,請依資料夾 README 使用 ./build.sh 與 ./run-ncr-dev-container.sh。
cd dev-container
docker build . -t ncr-dev-container
docker run --rm -it ncr-dev-container
此時的 Dockerfile 結尾還是
CMD ["bash"],所以進去是 shell;本文最後加上 entrypoint 之後,啟動就會直接進 Claude。
由於我們在 Docker 環境中,它沒辦法幫我真的去開瀏覽器,所以我們自己複製貼到瀏覽器中開啟,完成 OAuth 授權後,它會提供一串代碼:
複製貼回 Claude Code 即完成登入:
當再次啟動 Container 並開啟 Claude 時會發現又要重新登入了⋯⋯ 😵💫
這樣沒有省到時間,所以我們接下來要想想怎麼把登入狀態持久化。
claude setup-token
此時會開啟瀏覽器走一次 OAuth 授權流程
完成授權之後,Terminal 就會出現 有效期一年 的 Token:
透過 UI 上呈現的指令將 Token 設定於當前的環境變數中:
export CLAUDE_CODE_OAUTH_TOKEN=<token>
這把 token 有效期一年,但可以撤銷:到 claude.ai 的 Settings → Claude Code → Authorization tokens,把對應那筆刪掉。我實測刪除後,正在跑的 CLI 下一個請求就收到
401 OAuth access token has been revoked.。在清單裡辨識它的方式:setup-token產生的那筆 scope 只有user:inference,一般/login的會多出profile、file_upload等好幾個。這個撤銷入口目前沒有寫在 authentication 文件裡(截至 2026 年 8 月 25 日),是從設定頁自己找到的。token 本身仍應當密碼對待:直接在終端機貼
export會讓它以明文進入 shell history;比較穩的做法,是以600權限建立只有自己可讀的 token 檔,再從檔案載入CLAUDE_CODE_OAUTH_TOKEN。這個 token 只能用於模型推論,不能建立 Remote Control session;也帶不動 claude.ai 帳號上的官方 Connector(remote MCP),因為它的 scope 只有user:inference,沒有/login那組的user:mcp_servers(#34575)。自己在 Claude Code 裡設定的 MCP server 不受影響。這個差別,後面講到網路邊界時會撞上一個真實案例。
但如果直接執行 Claude 它會又要你進行初始化設定(選主題跟登入方式),我們可以修改 ~/.claude.json 加入 "hasCompletedOnboarding": true。
我的裡面是沒有這個 Key 的,新增後存檔再次啟動 Claude Code 就可使用了!
做法上就是直接把本機上的 ~/.claude 資料夾 Mount 進去 Container 中的同一位置(~/.claude)。
要注意:
uid 例如 1001 要一致
macOS 的 Docker Desktop 不受此限,VirtioFS 檔案共享會自動映射擁有者。
Keychain 中,需要解出後再 Mount 入
# 1. 從 Keychain 解出 OAuth 憑證,寫成 Linux 版 Claude Code 認得的檔案 #(第一次執行會跳出 Keychain 授權視窗,按「允許」) security find-generic-password -s "Claude Code-credentials" -w > ~/.claude/.credentials.json chmod 600 ~/.claude/.credentials.json
再次啟動,-v 就是將資料夾、檔案 Mount 進去:
docker run --rm -it \
-v ~/.claude:/home/nathan/.claude \
-v ~/.claude.json:/home/nathan/.claude.json \
ncr-dev-container
這個做法優先換取便利,不是最小權限配置。
~/.claude與~/.claude.json都會以可寫方式掛入容器,後面的 run script 還會把目前的專案目錄以可寫方式掛入;agent 雖然離開了 host 的其餘檔案系統,仍能修改這幾個明確交出去的路徑。今天先完成可重現的執行環境,憑證、出網與驗證邊界會在後續幾天逐層補上。
啟動 Claude 就可直接使用了 🎉!
容器用完後,視需求把 host 上的明文憑證檔刪掉
(它只是解出來給容器用的複本,macOS 本體用的仍是 Keychain)
rm ~/.claude/.credentials.json
為了少打一些指令以及方便後續功能擴充,我們將啟動的指令寫成 run-ncr-dev-container.sh
還記得 Day 5 選 SAST 引擎時說過,Opengrep 是引擎、規則沿用 Semgrep 專案的嗎?這件事在容器裡要還債了。opengrep 的 binary 不帶規則檔,沒有規則它就是一把空槍,SAST 那條軌道會整個空轉。所以規則得從 host 餵進來,先把它 clone 下來:
# 你可以依自己的需求調整規則 repo 的存放路徑
mkdir -p ~/Projects
git clone --depth 1 https://github.com/semgrep/semgrep-rules.git ~/Projects/semgrep-rules
run script 預設會去
$HOME/semgrep-rules找。若照上面的範例放在$HOME/Projects/semgrep-rules,啟動前請設定NCR_OPENGREP_RULES=$HOME/Projects/semgrep-rules;你也可以依自己的需求改成其他路徑。
Run script 每次啟動前會對這份 clone 做一次 best-effort 的 git pull(離線或失敗就沿用現有版本,不擋啟動),然後以唯讀(:ro)Mount 進容器。
一個授權上的提醒:semgrep-rules 採 Semgrep Rules License,限內部使用。自己 clone 來給自己 review 用沒有問題,但不要把規則重新散布,或包進你對外釋出的產品裡。
同一格還要再放一個東西。trivy 也是 binary 不帶資料的:弱點資料庫要去 ghcr.io 抓,下載約 60MB,解開後落地超過 1GB。容器用完即丟,每場重抓重解一次划不來,所以 DB 同樣由容器外面供給。差別是這個不用手動 clone,run script 啟動前會自己更新一顆共用的 docker volume 再掛進去,更新失敗就沿用上一版。後面有一天我會把容器的網路整個鎖起來,到那天這份 cache 就不只是省時間的了。
走到本文這個 checkpoint,dev-container/ 已經形成三個核心檔案。公開 repo 的目前版本還包含後續幾天加入的防火牆、驗證與觀測元件;這裡先只整理今天建立的部分。
Dockerfile定義這個審查環境「有什麼」。
從 Ubuntu 24.04 出發,裝進兩類東西:審查工具鏈(ruff、ty、oxlint、trivy、opengrep、codegraph、shellcheck、pydantic)和基礎設施(Claude Code、Node、uv、rtk、vim、UTF-8 locale、台北時區)。
run-ncr-dev-container.sh每次啟動時跑:決定什麼東西可以搬進去
在本文這個階段,它負責跨越 host 與容器邊界的三件事。
第一是憑證:CLAUDE_CODE_OAUTH_TOKEN 環境變數優先,其次 macOS 從 Keychain 解出、Linux 直接用登入過落地的憑證檔,三者皆無就直接退出,不啟動一個註定登不進去的容器。
第二是 Opengrep 規則:啟動前對 host 的 semgrep-rules clone 做 best-effort 更新,再以唯讀方式 mount 進去。
第三是 Trivy 的弱點資料庫:啟動前先在容器外更新那顆共用的 cache volume,再掛進容器給掃描用。最後把你當前所在的專案資料夾掛進容器的工作目錄,docker run 起來。
把 ~/.claude 掛進容器,解決了登入狀態無法持久化的問題,卻也生成了新的問題。skill 是用 symlink 連進 ~/.claude/skills 的;掛載後,那個 symlink 仍指向 host 上的絕對路徑,在容器裡便成了斷鏈,skill 根本載入不了。
修法是把 symlink 的目標目錄用同一個絕對路徑唯讀掛進容器:路徑一致,symlink 原地復活,agents/ 底下的檔案也跟著活。唯讀是刻意的:skill 是 agent 要遵守的規則,容器裡的它不該改得動。這正是這幾天反覆出現的情況:解掉一個問題之後,新的邊界又會生成下一個問題。
entrypoint.shRun wrapper 成形之後,我才把啟動 Claude 的最後幾步收進 entrypoint.sh;它不是最初版 Dockerfile 一開始就有的。
容器內,每次啟動時跑:啟動儀式
在本文這個階段,它是容器裡的第一個程序,負責三件小事:報告 image 是哪個時間點蓋的(判斷環境新舊)、檢查憑證有沒有真的進來(沒有就先警告,免得進去才發現要登入)、然後 exec 交棒給預設指令 claude --dangerously-skip-permissions。
這一行只是不再逐次詢問;能不能安全地放寬煞車,仍取決於接下來逐層補上的外部邊界。
容器蓋好了,但 skill 審查要打 GitLab 的 API,token 要不要跟著進容器?明天講這題。