
1. 從 s01 的 bash 單工具說起為什么文件操作會變成一場災(zāi)難如果你跟著 learn claude code 系列走到 S02大概率已經(jīng)踩過 s01 的坑整個 agent 只有一個 bash 工具所有文件操作都得走 shell。讀文件用cat寫文件用echo ... f.py編輯文件用sed -i s/old/new/ f.py??雌饋砟芘軐?shí)際用起來處處是雷。先說讀。cat file.py遇到大文件會直接截?cái)嘟財(cái)辔恢貌豢深A(yù)測模型拿到半截內(nèi)容就開始推理結(jié)果自然離譜。再說寫。echo ... f.py是轉(zhuǎn)義地獄內(nèi)容里只要出現(xiàn)引號、反斜杠、$符號shell 就會先替你解釋一遍寫進(jìn)去的東西和你想要的完全不是一回事。編輯更慘sed遇到/或直接崩你還得手動轉(zhuǎn)義轉(zhuǎn)義錯了就是靜默改錯。比笨拙更嚴(yán)重的是安全問題。bash 是一個不受約束的執(zhí)行面cat ../../etc/passwd可以讀取工作目錄之外的系統(tǒng)文件。s01 的黑名單只能攔截已知的危險(xiǎn)模式但無法約束去哪里——你不可能窮舉所有危險(xiǎn)路徑。這就是 S02 要解決的核心問題給 agent 一套專用的文件工具并用路徑沙箱把它的活動范圍鎖死在工作目錄內(nèi)。S02 新增了read_file、write_file、edit_file三個文件工具加上原有的bash一共四個。所有工具通過一個叫 dispatch map 的字典路由到對應(yīng)的 handler。而 agent loop 的 while 結(jié)構(gòu)、stop_reason 檢查、消息追加邏輯一行都沒改。這是 S02 最核心的洞察加工具不需要改循環(huán)。這篇筆記會圍繞 dispatch map、路徑沙箱、agent loop 三個熱詞把工具調(diào)用鏈路拆開交付可復(fù)制的settings.json與config.toml配置骨架并給出驗(yàn)證 dispatch map 生效與路徑沙箱邊界的操作步驟。適合已經(jīng)跑通 s01、想搞清楚工具調(diào)度機(jī)制的人也適合想在自己項(xiàng)目里復(fù)刻這套骨架的開發(fā)者。2. 前置準(zhǔn)備TaoToken 接入與本地環(huán)境在復(fù)現(xiàn) S02 之前你需要一個能穩(wěn)定調(diào)用 Claude 模型的入口。我用的是 TaoToken它提供兼容 Anthropic 的 API 接口配置方式和官方 SDK 一致省去了自己搭轉(zhuǎn)發(fā)層的麻煩。官網(wǎng)在 https://taotoken.net/?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_content 注冊后在控制臺生成 API Key 即可。拿到 Key 之后先把它寫進(jìn)環(huán)境變量別硬編碼在代碼里export TAOTOKEN_API_KEYsk-你的key export TAOTOKEN_BASE_URLhttps://taotoken.net/api本地環(huán)境需要 Python 3.10 以上因?yàn)閟afe_path用到了Path.is_relative_to()這個方法是 3.9 才引入的3.10 更穩(wěn)。依賴只需要anthropic和tomli如果你用 Python 3.11 以下讀 tomlpip install anthropic tomli目錄結(jié)構(gòu)建議這樣組織把工作目錄和 agent 代碼分開方便后面驗(yàn)證沙箱邊界learn-claude-code/ ├── s02/ │ ├── s02_tool_use.py │ └── config.toml ├── workspace/ # 這是 WORKDIRagent 只能在這里活動 │ └── foo.py └── settings.jsonworkspace/就是后面safe_path里的WORKDIR。所有文件操作都必須落在這個目錄之下任何試圖逃逸的路徑都會被拒絕。這個設(shè)計(jì)是 S02 安全性的地基后面會詳細(xì)拆。3. 可復(fù)制配置骨架settings.json 與 config.tomlS02 的配置分兩層settings.json管模型和 API 接入config.toml管工具開關(guān)和沙箱參數(shù)。分開的好處是換模型不用動工具配置調(diào)沙箱邊界不用碰 API Key。先看settings.json{ model: claude-sonnet-4-20250514, max_tokens: 4096, api_key_env: TAOTOKEN_API_KEY, base_url_env: TAOTOKEN_BASE_URL, workdir: ./workspace, tools: { bash: { enabled: true, timeout: 30 }, read_file: { enabled: true, max_output: 50000 }, write_file: { enabled: true, auto_mkdir: true }, edit_file: { enabled: true, replace_first_only: true } }, sandbox: { enforce: true, allow_symlink_escape: false } }幾個關(guān)鍵字段說明一下。workdir指向./workspace這就是沙箱的根。sandbox.enforce設(shè)為 true 時safe_path會強(qiáng)制校驗(yàn)每個路徑設(shè)為 false 只用于調(diào)試生產(chǎn)環(huán)境千萬別關(guān)。allow_symlink_escape控制符號鏈接是否允許逃逸默認(rèn) false因?yàn)閞esolve()會跟隨符號鏈接如果工作目錄里有個指向/etc的軟鏈不關(guān)掉這個開關(guān)就等于開了后門。再看config.toml它定義工具的參數(shù) schema也就是模型看到的工具描述[agent] max_turns 20 stop_on_end_turn true [tools.read_file] description Read a file within the workspace params [path, limit] [tools.write_file] description Write content to a file, creating parent dirs params [path, content] [tools.edit_file] description Replace first occurrence of old_text with new_text params [path, old_text, new_text] [tools.bash] description Run a shell command in the workspace params [command]max_turns是 agent loop 的硬上限防止模型陷入無限工具調(diào)用。params列表決定了 dispatch map 里 lambda 從**kw中提取哪些字段。注意read_file的limit是可選的所以 handler 里用kw.get(limit)而不是kw[limit]這個細(xì)節(jié)后面排障會用到。把這兩份配置放到項(xiàng)目根目錄代碼里讀取后構(gòu)造TOOL_HANDLERS字典。配置和代碼分離之后加新工具只需要在 toml 里加一段、在字典里加一行agent loop 完全不用動。4. 核心機(jī)制拆解dispatch map、路徑沙箱與 agent loop4.1 dispatch map用字典查找替代 if/elif 鏈dispatch map 本質(zhì)是一個字典鍵是工具名值是處理函數(shù)。模型返回的tool_useblock 里帶著block.name用它去字典里查 handlerTOOL_HANDLERS { bash: lambda **kw: run_bash(kw[command]), read_file: lambda **kw: run_read(kw[path], kw.get(limit)), write_file: lambda **kw: run_write(kw[path], kw[content]), edit_file: lambda **kw: run_edit(kw[path], kw[old_text], kw[new_text]), }為什么用 lambda 而不是直接引用函數(shù)因?yàn)槟P蛡魅氲膮?shù)字典block.input和 handler 函數(shù)的簽名不完全匹配。run_bash需要command參數(shù)但block.input是{command: ls}直接引用會因?yàn)閰?shù)名對不上而報(bào)錯。lambda 做了一層適配從統(tǒng)一的**kw字典里取出正確的參數(shù)映射到 handler 的簽名。**kw是 Python 的關(guān)鍵字參數(shù)解包收集所有命名參數(shù)到一個字典。當(dāng)模型調(diào)用read_file傳入{path: foo.py, limit: 50}lambda 收到kw {path: foo.py, limit: 50}然后提取需要的值。模型有時會自發(fā)添加額外字段lambda 的適配層能忽略掉這些噪音。不用 dispatch map 的話代碼會退化成一長串 if/elifif block.name bash: output run_bash(block.input[command]) elif block.name read_file: output run_read(block.input[path], block.input.get(limit)) elif block.name write_file: output run_write(block.input[path], block.input[content]) # ... 每加一個工具就多一個分支dispatch map 用一個字典查找替代了整條鏈。加新工具 在字典里加一行而不是在 if/elif 鏈里插分支。這就是開閉原則的最小實(shí)踐對擴(kuò)展開放對修改關(guān)閉。從 S02 到后面的章節(jié)工具越來越多但 agent loop 的代碼零改動。4.2 路徑沙箱用幾何約束替代黑名單safe_path是 S02 最重要的安全機(jī)制只有四行def safe_path(p: str) - Path: path (WORKDIR / p).resolve() if not path.is_relative_to(WORKDIR): raise ValueError(fPath escapes workspace: {p}) return path逐行看。WORKDIR / p用/運(yùn)算符拼接路徑Path 對象重載了這個運(yùn)算符Path(/home) / foo.txt得到Path(/home/foo.txt)。.resolve()解析為絕對路徑同時消除..和符號鏈接。Path(/project/../etc/passwd).resolve()會變成Path(/etc/passwd)沒有 resolve..不會真正被評估。.is_relative_to(WORKDIR)檢查路徑是否在工作目錄之下。攻擊面分析假設(shè)模型被誘導(dǎo)調(diào)用read_file(../../../etc/passwd)。WORKDIR / ../../../etc/passwd得到/project/../../../etc/passwd.resolve()之后變成/etc/passwdis_relative_to(WORKDIR)返回 False拋出 ValueError。這個防御不是基于黑名單而是基于幾何約束——只要 resolve 后的路徑不在工作目錄下就拒絕。黑名單永遠(yuǎn)有遺漏你不可能窮舉所有危險(xiǎn)路徑白名單只有一條規(guī)則無法繞過。4.3 agent loop循環(huán)不變只換 handleragent loop 的結(jié)構(gòu)在 S02 里一行沒改還是 while stop_reason 檢查 消息追加while True: response client.messages.create( modelMODEL, max_tokensMAX_TOKENS, messageshistory, toolsTOOL_SCHEMAS, ) history.append({role: assistant, content: response.content}) if response.stop_reason ! tool_use: break results [] for block in response.content: if block.type tool_use: handler TOOL_HANDLERS.get(block.name) output handler(**block.input) if handler else fUnknown tool: {block.name} results.append({ type: tool_result, tool_use_id: block.id, content: output, }) history.append({role: user, content: results})handler(**block.input)里的**是字典解包把字典展開為關(guān)鍵字參數(shù)。block.input {path: foo.py, limit: 50}等價(jià)于handler(pathfoo.py, limit50)。**和 lambda 的**kw是配對使用的一個展開字典一個收集參數(shù)。TOOL_HANDLERS.get(block.name)用.get()而非[block.name]是防御性編程。模型偶爾會幻覺出一個不存在的工具名.get()返回 None后續(xù)if handler檢查給出明確錯誤信息而不是拋 KeyError 讓整個循環(huán)崩掉。5. 驗(yàn)證請求確認(rèn) dispatch map 生效與沙箱邊界配置寫完得實(shí)際跑一遍確認(rèn)。分兩步先驗(yàn)證 dispatch map 路由正確再驗(yàn)證路徑沙箱攔得住逃逸。5.1 驗(yàn)證 dispatch map 生效寫一個最小測試腳本直接調(diào)用 handler 字典不經(jīng)過模型from pathlib import Path WORKDIR Path(./workspace).resolve() WORKDIR.mkdir(exist_okTrue) def run_read(path, limitNone): text safe_path(path).read_text() lines text.splitlines() if limit and limit len(lines): lines lines[:limit] [f... ({len(lines) - limit} more lines)] return \n.join(lines)[:50000] TOOL_HANDLERS { read_file: lambda **kw: run_read(kw[path], kw.get(limit)), } # 造一個測試文件 (WORKDIR / foo.py).write_text(def greet():\n print(hi)\n) # 模擬模型返回的 tool_use block block_input {path: foo.py, limit: 10} handler TOOL_HANDLERS.get(read_file) print(handler(**block_input))跑出來應(yīng)該看到def greet():和print(hi)。如果報(bào)KeyError: path說明 lambda 里的鍵名和block_input對不上如果報(bào)TypeError多半是kw.get(limit)寫成了kw[limit]而模型沒傳這個可選字段。5.2 驗(yàn)證路徑沙箱邊界這一步是重點(diǎn)。構(gòu)造幾個逃逸路徑確認(rèn)safe_path全部拒絕test_cases [ foo.py, # 正常應(yīng)通過 ../outside.py, # 逃逸一級應(yīng)拒絕 ../../etc/passwd, # 逃逸到系統(tǒng)應(yīng)拒絕 /etc/passwd, # 絕對路徑應(yīng)拒絕 ] for p in test_cases: try: result safe_path(p) print(fPASS: {p} - {result}) except ValueError as e: print(fBLOCKED: {p} - {e})預(yù)期輸出foo.py通過其余三個全部 BLOCKED。特別注意/etc/passwd這個絕對路徑WORKDIR / /etc/passwd在 Path 語義下會直接得到/etc/passwd絕對路徑覆蓋左側(cè)resolve()后依然在 WORKDIR 之外被正確攔截。如果../outside.py沒被攔住檢查WORKDIR是不是用了相對路徑且沒.resolve()。Path(./workspace)不 resolve 的話is_relative_to的比較會出錯。這是最常見的坑。5.3 端到端跑一輪 agent loop最后用真實(shí)模型跑一輪觀察工具調(diào)用鏈路。用戶輸入把 foo.py 里的 greet 函數(shù)改名為 hello預(yù)期模型的行為模式是讀→改→驗(yàn)證第 1 輪模型返回ToolUseBlock(nameread_file, input{path: foo.py})dispatch 到run_read結(jié)果追加到 history。第 2 輪模型看到文件內(nèi)容返回edit_fileold_textdef greet():new_textdef hello():dispatch 到run_editcontent.replace(old, new, 1)替換成功。第 3 輪模型再讀一次驗(yàn)證確認(rèn)改成def hello():。第 4 輪stop_reasonend_turn輸出已將 greet 改名為 hello循環(huán)退出。注意模型不是一步到位的而是分步進(jìn)行每一步的決策都基于上一步的結(jié)果。這就是 agent loop 的價(jià)值——模型可以試錯、驗(yàn)證、調(diào)整。而 dispatch map 保證了無論模型調(diào)哪個工具路由邏輯都是同一套。6. 本篇常見錯排查報(bào)錯一KeyError: limit或KeyError: path模型調(diào)用read_file時沒傳limit但 lambda 里寫的是kw[limit]??蛇x參數(shù)必須用kw.get(limit)必填參數(shù)才用kw[path]。對照config.toml里的params列表標(biāo)了可選的字段一律用.get()。報(bào)錯二ValueError: Path escapes workspace但路徑看起來正常多半是WORKDIR沒.resolve()。Path(./workspace)和Path(/abs/workspace)在is_relative_to比較時行為不同。統(tǒng)一在初始化時WORKDIR Path(cfg[workdir]).resolve()后面所有比較都用這個絕對路徑。報(bào)錯三edit_file返回Error: Text not found但文件里明明有檢查old_text是否包含不可見字符比如行尾空格或\r\n。模型從read_file拿到的內(nèi)容經(jīng)過splitlines()和join()換行符可能被規(guī)范化。建議run_edit里先做一次content.replace(\r\n, \n)歸一化再匹配。報(bào)錯四模型反復(fù)調(diào)用同一個工具循環(huán)不退出max_turns沒設(shè)或設(shè)太大。在 agent loop 里加計(jì)數(shù)器超過max_turns強(qiáng)制 break 并返回提示。另外檢查stop_reason判斷是不是寫成了! end_turn正確寫法是! tool_use就 break因?yàn)閑nd_turn和max_tokens都意味著不再有工具調(diào)用。報(bào)錯五Unknown tool: xxx模型幻覺出了不存在的工具名。這是.get()防御生效的表現(xiàn)不是 bug。如果頻繁出現(xiàn)檢查TOOL_SCHEMAS里的工具描述是否清晰模型對工具能力的理解偏差會導(dǎo)致它編造工具名。報(bào)錯六寫文件時父目錄不存在報(bào)錯run_write里漏了fp.parent.mkdir(parentsTrue, exist_okTrue)。parentsTrue遞歸創(chuàng)建所有中間目錄exist_okTrue目錄已存在時不報(bào)錯。少了任何一個模型寫src/utils/helpers.py時都會失敗。7. 下一步把骨架接到你的工作流S02 的骨架跑通之后你會發(fā)現(xiàn)它的擴(kuò)展性很強(qiáng)。加一個新工具只需要三步在config.toml里加一段[tools.xxx]在TOOL_SCHEMAS里加一個 schema在TOOL_HANDLERS里加一行 lambda。agent loop 完全不用碰。如果你打算長期用這套骨架做編碼或 Agent 任務(wù)建議把 API Key 管理、模型切換、工具開關(guān)都收斂到 TaoToken 控制臺統(tǒng)一配置省得每個項(xiàng)目重復(fù)填。接入文檔在 https://taotoken.net/doc?utm_sourcetaotoken_aicg_blog_endutm_contentdocutm_campaignrewrite API Key 在 https://taotoken.net/api-keys?utm_sourcetaotoken_aicg_blog_endutm_contentapi-keysutm_campaignrewrite 生成。想先驗(yàn)證模型對工具調(diào)用的理解可以直接在模型對話頁 https://taotoken.net/chat?utm_sourcetaotoken_aicg_blog_endutm_contentmodel_chatutm_campaignrewrite 里手動構(gòu)造 tool_use 請求試。如果是要跑長期的編碼 AgentCoding Plan https://taotoken.net/coding-plan?utm_sourcetaotoken_aicg_blog_endutm_contentcoding_planutm_campaignrewrite 的額度模型更適合持續(xù)調(diào)用。最后留一個我踩過的坑safe_path的resolve()會跟隨符號鏈接如果你的 workspace 里有指向外部的軟鏈即使路徑字符串看起來在工作目錄內(nèi)resolve 之后也會逃逸。allow_symlink_escape設(shè)為 false 時可以在safe_path里加一道path.is_symlink()檢查或者干脆在 workspace 初始化時掃描并拒絕所有軟鏈。這個細(xì)節(jié)在本地開發(fā)時容易忽略但一旦 agent 有了寫權(quán)限軟鏈就是一條隱蔽的逃逸通道。