iT邦幫忙

2026 iThome 鐵人賽

DAY 9
0

Day 9|tools/list 是宣告面,不是權限面

https://ithelp.ithome.com.tw/upload/images/20260825/20183634clpNYRtUxl.png

我曾經有一個 agent,設定檔裡明明白白寫著它不能用某個工具。

[capabilities]
denied_tools = ["shell_exec"]

它用了。而且用得很順,沒有任何錯誤。

查了半天才發現:這個設定確實有被讀取,也確實有生效,但只生效在一個地方——啟動 CLI 的時候,它被翻譯成 --disallowedTools 傳給了 Claude Code。所以走 CLI 那條路的呼叫被擋住了。

而這個 agent 走的是另一條路:它透過 MCP 直接呼叫我自己的 server。那條路上,denied_tools 沒有任何一行程式碼在讀它。

設定是對的,執行是錯的,中間少了一道門。


三個面,經常被當成同一個

工具管理有三個層次,很多人(包括三個月前的我)把它們混為一談。

問題 出錯的後果
宣告面 模型知道有哪些工具? 太多:貴。太少:能力缺失
分派面 這個呼叫要送到哪個 handler? 送錯地方
執行面 這個呼叫者有權做這件事嗎? 安全漏洞

tools/list 是宣告面。它決定模型的視野,跟權限完全沒有關係

我犯的錯是以為「不宣告就等於禁用」。這在直覺上很合理:模型不知道有這個工具,怎麼會呼叫它?

實務上有三個破口。

為什麼「不宣告」擋不住

破口一:模型會猜。

工具名稱是有規律的。如果模型看過 task_listtask_createtask_update,它完全有可能自己拼出一個 task_delete 來試。我親眼看過這種事發生,模型呼叫了一個不在清單裡的工具名稱,理由是「照命名慣例應該有這個」。

如果你的 server 收到未知工具名稱時,是走到某個 getattr(self, name) 之類的動態分派,那就直接被打穿了。

破口二:清單會過期。

tools/list 通常在會話開始時抓一次。如果會話中途權限變了(授權被撤銷、任務結束、TTL 到期),模型手上那份清單還是舊的。它會照著舊清單呼叫,而你需要在執行的那一刻拒絕它。

我在自己的系統裡實作過一個「任務範圍授權」:某些工具需要一張有效的授權票,任務進入任何終止狀態(完成、拒絕、取消、升級給人)時,所有票立刻作廢。這種機制只有在執行面檢查才有意義,宣告面的過濾完全幫不上忙。

破口三:有別條路。

這就是我開頭那個 bug。同一個工具可能有多個進入點:CLI 旗標一條、MCP 分派一條、內部程式呼叫一條、HTTP API 又一條。你在其中一條加了檢查,其他三條還開著。

正確的分工

https://ithelp.ithome.com.tw/upload/images/20260825/20183634JPpUUIEFcN.png

宣告面(DECLARE)決定模型看得到什麼,它只管省 token。真正擋人的是後面兩個菱形:分派面(DISPATCH)認不得的工具直接拒絕,執行面(AUTHORIZE)檢查這個呼叫者有沒有權限。兩道都會留下稽核紀錄。

一句話總結:宣告面省錢,執行面保命。

還有一條配套規則:可發現的集合,必須是可呼叫集合的子集合。你可以隱藏一個能用的工具(省 token),但不能宣告一個會被拒絕的工具。後者會讓模型不斷嘗試、不斷被拒、不斷重試,浪費好幾輪對話,而它永遠不知道為什麼。

實作:一個門,所有路都經過

關鍵設計是單一檢查點。不要在每個 handler 裡各寫一份權限檢查,那樣遲早會漏掉一個。

from dataclasses import dataclass

@dataclass(frozen=True)
class Caller:
    """呼叫者身分。所有進入點都必須提供這個。"""
    agent_id: str
    scopes: frozenset[str]          # 這個呼叫者有哪些能力
    denied_tools: frozenset[str]    # 明確禁止的工具

# 工具 -> 需要的 scope。沒列在這裡的工具一律需要 admin。
TOOL_SCOPES = {
    "task_list":   "task:read",
    "task_update": "task:write",
    "shell_exec":  "system:admin",
}

class ToolDenied(Exception):
    pass

def authorize(caller: Caller, tool: str) -> None:
    """單一檢查點。任何進入點都要先過這裡。fail-closed。"""
    if tool in caller.denied_tools:
        audit(caller, tool, "denied_by_config")
        raise ToolDenied(f"工具 {tool} 已被明確禁用")

    required = TOOL_SCOPES.get(tool)
    if required is None:
        # 未列舉的工具 = 未知工具 = 拒絕。這是 fail-closed 的核心
        audit(caller, tool, "unknown_tool")
        raise ToolDenied(f"未知工具:{tool}")

    if required not in caller.scopes:
        audit(caller, tool, "missing_scope", required=required)
        raise ToolDenied(f"缺少權限:{required}")

def dispatch(caller: Caller, tool: str, args: dict) -> dict:
    try:
        authorize(caller, tool)                    # ← 唯一的門
    except ToolDenied as e:
        return {"content": [{"type": "text", "text": str(e)}], "isError": True}
    return HANDLERS[tool](args)

TOOL_SCOPES.get(tool) 回傳 None 時直接拒絕,這一行是整段程式的重點。

新加一個工具的人,如果忘記在這張表裡登記,結果是這個工具不能用(有人會來抱怨,你去補上)。反過來設計的話,忘記登記的結果是這個工具誰都能用(沒有人會抱怨,你永遠不知道)。

安全設計的預設值要選那個會有人抱怨的方向。

稽核紀錄不能省

上面每個拒絕分支都呼叫了 audit()。這件事我一開始沒做,後來吃了苦頭:使用者問「為什麼 agent 說它不能做這件事」,我完全查不到,因為拒絕發生在一個沒有任何紀錄的分支裡。

import json, time, pathlib

AUDIT = pathlib.Path.home() / ".myagent" / "tool_calls.jsonl"

def audit(caller: Caller, tool: str, outcome: str, **extra) -> None:
    rec = {
        "ts": time.time(),
        "agent": caller.agent_id,
        "tool": tool,
        "outcome": outcome,          # ok / denied_by_config / unknown_tool / ...
        **extra,
    }
    with AUDIT.open("a", encoding="utf-8") as f:
        f.write(json.dumps(rec, ensure_ascii=False) + "\n")

成功的呼叫也要記。這份檔案後來在我的系統裡變得非常重要,它是唯一能回答「這個 agent 到底做過什麼」的證據來源。Day 18 整篇都在講它。

一個實務提醒:這份檔案會長很快,而且裡面可能有敏感內容(工具參數、回傳結果)。要做三件事:輪替(我設 16MB)、遮罩(金鑰、token 在寫入前先過一次遮罩)、權限(chmod 600)。遮罩要在截斷之前做,順序反了會把密鑰的前半截原封不動寫進去。

宣告面怎麼過濾

執行面顧好之後,宣告面就可以放心地為了省錢而積極過濾:

def handle_tools_list(caller: Caller) -> list[dict]:
    """只宣告這個呼叫者真的能用的工具(可發現 ⊆ 可呼叫)。"""
    out = []
    for tool in ALL_TOOLS:
        try:
            authorize(caller, tool["name"])       # 重用同一個判斷
        except ToolDenied:
            continue
        out.append(tool)
    return out

重用 authorize 是刻意的。如果宣告面自己寫一套過濾邏輯,兩套邏輯遲早會分岔,然後你就會宣告出一個呼叫時會被拒絕的工具。

代價

過濾得太積極,模型會不知道自己能做什麼。 我有一個 agent 因為過濾規則寫太緊,看不到任何寫入類工具,於是它每次都回答「我沒有權限修改,請你自己改」。使用者以為系統壞了。後來我在 system prompt 裡補了一句說明它的角色是唯讀顧問,抱怨就停了。能力邊界要讓模型知道,不然它會把「我看不到工具」表達成「這個系統做不到」。

每次呼叫都跑一次授權,有成本。 我的實作裡權限來源是一份設定檔,每次呼叫都重讀,加上檢查大約 0.1 毫秒。相對於一次 LLM 呼叫的幾秒鐘,這完全不值得優化。我刻意不快取,因為權限撤銷要立刻生效,晚一次呼叫都不行。


明天算一筆更大的帳:我的 server 現在有 200 個以上的工具,全部宣告出去要多少錢,以及怎麼在不砍功能的前提下把這個數字壓下來。


上一篇
手刻一個 MCP server
下一篇
200 個工具的隱藏成本
系列文
Claude Code 下班之後:30 天把 CLI 工具養成會自己交差的 AI 員工12
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言