OMP 內部設計導讀系列第 4 篇。前三篇拆了 agent loop 雙層迴圈、append-only context、四種 compaction 策略,這篇看「工具能不能跑」的決策邏輯。
TL;DR
-
三層決策順序(
resolveApproval,line 120-219):- 工具宣告層:每工具自己宣告
approval(tier 或 function),可帶policy: allow|deny|prompt、override: true、reason、policyKey。未宣告/格式錯 → 全部exec(fail-closed) - 使用者層:
tools.approval.<tool>: allow|deny|prompt覆寫;<policyKey>沒值時 fallback 回工具本體名(如xd://裝置派工用) - 模式層(
APPROVAL_MODE_MAX_TIER,line 37-41):always-ask只放read;write放read+write;yolo全放。yolo 中override: true不會強迫 prompt,但policy: deny|allow|prompt仍生效
- 工具宣告層:每工具自己宣告
-
鐵律:tool-side
deny與 user-sidedeny都不可被模式跨越;formatApprovalDetails把執行內容截到 2,000 字塞進 prompt;MCP 工具預設write、未知自訂工具預設exec;subagent 跑 headless yolo,父層task通過是唯一授權邊界 -
bash 的 token 化審批:
allow必須覆蓋整行、deny/prompt對&&;分段逐一比對;shell control syntax(&&|||&;newline、subshell)會阻擋allow但不阻擋deny/prompt -
同一工具切換審批:
lsp/dap/computer/write(xd://)用參數化policyKey把參數映射到使用者覆寫
情境
你寫了一個自訂工具,忘記加 approval 欄位。模型呼叫這個工具時,omp 會怎麼決定「讓不讓跑」?
再比如:你用 --yolo 啟動,以為所有工具都會自動通過。但某個工具內部宣告了 policy: "deny"——它還是會被擋下來嗎?
這就是 omp 審批系統要解決的問題:三層決策、fail-closed 預設、模式不等於「全通」。
問題
單純的 allow/deny 清單不夠,因為現實情況複雜:
- 工具作者想宣告「這工具預設需要審批、但某些參數下不需要」(如
write的xd://裝置派工) - 使用者想覆寫特定工具的行為(如
tools.approval.bash: allow) - 模式提供粗略分級(always-ask/write/yolo),但不能完全取代細粒度控制
- 安全性:未知工具、格式錯誤、缺欄位——都要預設拒絕/提示(fail-closed),不能偷偷放行
omp 的解法:三層疊加,優先級固定,deny 永遠贏。
解法:三層決策的精確分工
第一層:工具宣告層(getToolDecision,line 80-98)
// line 80-98
function getToolDecision(tool, args) {
const approval = tool.approval;
const decision = typeof approval === "function" ? approval(args) : approval;
return normalizeDecision(decision);
}
function normalizeDecision(value) {
if (isToolTier(value)) {
return { tier: value, override: false }; // 簡單 tier:read/write/exec
}
if (isObject(value)) {
// 物件形式:{ tier, policy, override, reason, policyKey }
const tier = isToolTier(record.tier) ? record.tier : "exec"; // 預設 exec!
const reason = record.reason;
const policy = normalizePolicy(record.policy); // allow/deny/prompt
const policyKey = record.policyKey;
return { tier, override: record.override === true, policy, reason, policyKey };
}
return { tier: "exec", override: false }; // 預設 exec!
}
關鍵點:
| 宣告形式 | 解析結果 |
|---|---|
字串 "read"/"write"/"exec" | { tier, override: false } |
物件 { tier: "write", policy: "deny" } | { tier, policy, override, reason, policyKey } |
函式 args => ({ tier: "write", policy: "allow" }) | 依 args 動態決定 |
| 未宣告 / 格式錯 / 非物件非字串 | { tier: "exec", override: false } ← fail-closed |
為什麼預設 exec? 因為 exec 是最高風險 tier(執行程式碼、shell、瀏覽器、spawn agent)。任何工具若不宣告「我安全」,系統假設它最危險。這比預設 read 再事後補洞安全得多。
第二層:使用者覆寫層(line 127-134)
const policyKey = decision.policyKey ?? tool.name;
const userPolicy = userConfig[policyKey] ? normalizePolicy(userConfig[policyKey]) : undefined;
const fallbackPolicy = (policyKey !== tool.name && !userPolicy && userConfig[tool.name])
? normalizePolicy(userConfig[tool.name]) : undefined;
const effectiveUserPolicy = userPolicy ?? fallbackPolicy;
policyKey:工具可自訂「使用者覆寫用哪個 key」。預設用工具名;xd://裝置派工用policyKey: "write",讓使用者的tools.approval.write生效- Fallback:若
policyKey沒對應的使用者設定,退回檢查tools.approval.<tool.name> - 生效優先級:
effectiveUserPolicy=userPolicy(優先)或fallbackPolicy
第三層:模式層(modeApprovesTier,line 100-102)
const APPROVAL_MODE_MAX_TIER: Record<ApprovalMode, ToolTier> = {
"always-ask": "read",
write: "write",
yolo: "exec",
};
function modeApprovesTier(mode, tier) {
return TIER_RANK[tier] <= TIER_RANK[APPROVAL_MODE_MAX_TIER[mode]];
}
| 模式 | 自動通過的 tier | 需 prompt 的 tier |
|---|---|---|
always-ask | read | write, exec |
write | read, write | exec |
yolo | read, write, exec | (無) |
注意:模式只決定「自動通過哪些 tier」。具體的 deny/prompt 仍由前兩層決定。
完整解析流程(resolveApproval,line 120-219)
export function resolveApproval(tool, args, mode, userConfig) {
const decision = getToolDecision(tool, args); // 第 1 層
const policyKey = decision.policyKey ?? tool.name;
const effectiveUserPolicy = ...; // 第 2 層
// 1. Tool-side deny:最高優先級,不可被覆寫
if (decision.policy === "deny") return { policy: "deny", source: "tool", ... };
// 2. User-side deny:第二優先級,不可被覆寫
if (effectiveUserPolicy === "deny") return { policy: "deny", source: "user", ... };
// 3. Yolo 模式特殊處理
if (mode === "yolo") {
if (decision.policy) { // tool 有 policy(allow/deny/prompt)
return { policy: decision.policy, source: "tool", ... };
}
// tool 無 policy → 看 user policy,否則 allow
return { policy: effectiveUserPolicy ?? "allow", source: effectiveUserPolicy ? "user" : "mode", ... };
}
// 4. Tool-side override=true:工具強制決定(但 deny 已在步驟 1 攔截)
if (decision.override) {
return { policy: decision.policy === "allow" ? "allow" : "prompt", override: true, source: "tool", ... };
}
// 5. Tool-side allow/prompt:工具明確宣告
if (decision.policy === "allow" || decision.policy === "prompt") {
return { policy: decision.policy, source: "tool", ... };
}
// 6. User-side allow/prompt:使用者覆寫
if (effectiveUserPolicy) {
return { policy: effectiveUserPolicy, source: "user", ... };
}
// 7. Mode-based:以上都沒決定,才看模式門檻
if (modeApprovesTier(mode, decision.tier)) {
return { policy: "allow", source: "mode" };
}
// 8. 預設:prompt
return { policy: "prompt", source: "mode", ... };
}
決策流程圖:
工具宣告 deny ──► DENY(不可跨越)
│
├─ 工具宣告 allow/prompt/override ──► 用該政策
│
└─ 工具無政策
│
├─ 使用者 deny ──► DENY(不可跨越)
│
├─ 使用者 allow/prompt ──► 用該政策
│
└─ 使用者無政策
│
├─ yolo 模式 ──► ALLOW(但 tool-side policy 仍生效!)
│
└─ 非 yolo ──► tier <= mode 門檻?→ ALLOW : PROMPT
關鍵設計細節
1. policyKey:同一工具在 read/write 間切換
lsp、dap、computer、write(xd://) 等工具,依 args 決定 tier,並用 policyKey 接到使用者覆寫:
// lsp/servers.ts LSP_READONLY_ACTIONS
approval(args) {
if (LSP_READONLY_ACTIONS.has(args.action)) return "read";
return { tier: "write", policyKey: "lsp" }; // 用 tools.approval.lsp 覆寫
}
效果:使用者設 tools.approval.lsp: allow,所有 LSP 動作(含 write tier)都通過;設 deny 全擋。不需要為每個 action 設定。
2. bash 的 token 化審批(bash.ts line 288-294)
function bashApprovalRuleMatches(command, rule) {
if (rule.approval === "allow") {
if (hasBashApprovalShellControl(command)) return false; // 有 shell control syntax → 不 allow
return commandMatchesBashApprovalPattern(command, rule.match); // 整行匹配
}
// deny/prompt:分段匹配
return commandSegmentMatchesBashApprovalPattern(command, rule.match);
}
function commandSegmentMatchesBashApprovalPattern(command, pattern) {
const regex = bashApprovalPatternToRegExp(pattern);
if (regex.test(normalizedCommand)) return true;
return bashCommandSegments(command).some(segment => regex.test(segment));
}
為什麼 allow 不准穿越 && ;?
allow=「我保證這整行安全」。但cd x && rm -rf /中,cd x安全、後面不安全。若allow只匹配前段,惡意段會溜過去。- 所以
allow必須整行匹配且無 shell control syntax(&&|||&;newline、subshell)。 deny/prompt=「我看見危險就擋」。分段檢查、cd x && rm -rf /中rm -rf /觸發 deny,整行擋下。
CRITICAL_BASH_PATTERNS(line 172-217):硬編碼的危險命令正則(rm -rf /、chmod -R 777 /、curl | bash、kill -9 1、shutdown、nc -e 等),無論使用者設什麼,永遠觸發 deny/prompt(在 pattern rules 之前先檢查)。
3. formatApprovalDetails:把執行內容塞給使用者看
// line 267-288
export function formatApprovalPrompt(tool, args, reason) {
const lines = [`Allow tool: ${tool.name}`];
if (tool.name.startsWith("mcp__") && tool.approval === undefined) {
lines.push("Origin: MCP server tool");
}
if (reason) lines.push(`Reason: ${reason}`);
const details = tool.formatApprovalDetails?.(args);
if (details) lines.push(details); // 截到 2000 字
return lines.join("\n");
}
每個工具可自訂 formatApprovalDetails(args),回傳要顯示給使用者的細節(如 bash 顯示命令、edit 顯示檔案路徑、diff)。預設截 2000 字,避免塞爆 prompt。
4. MCP 工具預設 write、未知自訂工具預設 exec
- MCP 伺服器工具:宣告
approval: "write"(可讀可改不可執行) - 使用者自訂工具若未宣告
approval:normalizeDecision回傳{ tier: "exec" }→ fail-closed
5. checkpoint / rewind:互含姊妹工具
// tools/index.ts createDefaultTools()
if (tool.name === "checkpoint") {
tool.approval = "read"; // 只讀 tier
}
if (tool.name === "rewind") {
tool.approval = "read";
}
// 註冊邏輯:強制互含
checkpoint 寫入 session 標記、rewind 切回標記。兩者必須同時存在,否則單獨用會破壞 session tree 一致性。註冊時強制互含,避免使用者只裝一個導致錯誤。
6. subagent:headless yolo,父層 task 是唯一授權邊界
// task/spawn-policy.ts, task/index.ts
// subagent 預設 headless yolo
// 父層 `task` 工具通過 = 唯一授權邊界
// subagent 內的 user `prompt` 會 reject call 而非偷偷放行
// tools.approval.eval 不被 bash.patterns 蓋到,要在 eval 內擋 shell 必須另外設
- subagent 跑在隔離 session,預設
--yolo(headless) - 父層通過
tasktool 就是授權;subagent 內部的審批不會偷偷升級 eval工具內的 shell 需單獨設tools.approval.eval,不受bash.patterns影響
學到的事
- 三層不是重複——工具宣告(語意)、使用者覆寫(偏好)、模式(粗略分級)各有語意,疊加才完整
- Fail-closed 是唯一安全的預設——未宣告 =
exec、格式錯 =exec、未知工具 =exec。寧可多擋、不可少擋 - Deny 永遠贏——tool-side deny、user-side deny 都不可被模式、override、yolo 跨越。這是安全基線
policyKey解決「同工具不同參數不同審批」——不需要拆成多個工具,一個工具內部動態決定 tier + policyKey- bash 的
allow整行匹配是為了防cd x && rm -rf /——shell control syntax 檢查在同一處(hasBashApprovalShellControl),邏輯集中 - yolo ≠ 無視所有政策——tool-side
policy: deny|prompt、user-sidedeny|prompt在 yolo 仍生效;只有override: true在 yolo 被忽略(不強迫 prompt)
參考資料
packages/coding-agent/src/tools/approval.ts—resolveApproval(120-219)、getToolDecision(80-98)、modeApprovesTier(100-102)、APPROVAL_MODE_MAX_TIER(37-41)、formatApprovalPrompt(267-288)、CRITICAL_BASH_PATTERNS(172-217)packages/coding-agent/src/tools/bash.ts—bashApprovalRuleMatches(288-294)、commandSegmentMatchesBashApprovalPattern(276-282)、bashCommandSegments(268-272)、CRITICAL_BASH_PATTERNSpackages/coding-agent/src/tools/checkpoint.ts—CheckpointTool、RewindTool互含註冊packages/coding-agent/src/tools/task/spawn-policy.ts/task/index.ts— subagent headless yolo、父層授權邊界docs/approval-mode.md— 官方審批模式文檔packages/agent/src/types.ts—AgentTool.approval(834)、ToolApproval(729-742)、ToolTier(706)
本文屬 OMP 內部設計導讀 系列第 4 篇。上一篇:四種 compaction 策略。下一篇:bash 的 token 化審批細節(待發布)
Loading...