iT邦幫忙

2026 iThome 鐵人賽

DAY 3
0
自我挑戰組

AI Agent 不該有萬能鑰匙:打造可稽核的 MCP 工具權限閘道系列 第 3

Day 03|拆開 Pi:工具呼叫到底從哪裡出發

  • 分享至 

  • xImage
  •  

昨天把授權者和請求者分開了:助理可以提出動作,啟動器決定它代表誰,閘道決定是否放行。今天要把這個分工接成一條真的能跑的呼叫路徑:模型提出工具呼叫,Pi 的擴充程式接收,橋接程式透過 MCP 把請求交給 Python 伺服器,最後才由閘道檢查並執行。

關鍵問題是檢查該放在哪裡。Pi 提供工具執行前的通知點,稱為 tool_call hook;但後面的處理器仍可能改寫參數。schema validation 指的是依欄位規格檢查參數,這項檢查不會在每次改寫後自動重跑。因此,早期通知點可以輔助檢查,真正授權仍要靠近接收最終參數、執行資料操作的一端。

實作上分成三個元件:擴充程式固定工具,伺服器端閘道執行政策,可信啟動器提供設定。下面先追 Pi 怎麼呼叫工具,再看我們接入的位置。

Pi 內部:兩條執行路徑與一個可以改寫參數的 hook

本篇的原始碼依據是 Pi v0.73.0,tag commit dbcb473d6fdb96f60570b9ebe73e7aa6316fa8fb,MIT 授權,Copyright 2025 Mario Zechner,upstream LICENSE 保留(Pi v0.73.0)。負責執行工具的 agent-loop.ts 裡有三個函式:executeToolCallsexecuteToolCallsSequentialexecuteToolCallsParallel。名稱就講清楚了,工具呼叫有循序與平行兩種執行方式,外面再包一層分派。

管理工作階段的 agent-session.ts 註冊 tool_call 事件,並且有 setActiveToolsByName。前者是「工具執行前的事件通知」,後者是「這一輪有哪些工具是活的」。直覺上很容易把這兩件事當成同一件事,實際上它們是兩個不同的控制面:一個是事件通知,一個是集合管理。你可以通知得很早,卻仍然有一批工具掛在那裡。

判斷這個設計選項的關鍵依據,是 Pi 上游定義擴充介面的 types.ts 對 tool_call handler 行為的描述。handler 能改參數,後面的 handler 看到的是改完的值,而這個改動不會自動重新通過 schema 驗證。如果我把授權判斷寫在最早的 handler,後面任何一個 handler 都還能把參數換掉,而先前的判斷結果不會跟著重算。單一 hook 因此只是應用層的一個關卡,不能當成最後一道。

拿一個假設性的參數改寫來看會更清楚:第一個 hook 看見 path=public/handbook.txt 並放行,後面的 handler 卻把它改成 private/customer-secrets.txt。早先那次檢查回答的是「公開手冊能不能讀」,對最終要求的私人文件沒有作過決定。

在本專案的呼叫路徑上,extension 會把最後拿到的參數交給 MCP,Python 閘道再依最終路徑執行政策檢查。因此,這個例子中的私人路徑仍會被拒。這是根據 hook 語意與本地授權程式推得的設計例子,沒有把它說成已跑過的惡意 extension 實驗。讀者應檢查的是資料流:執行前最後一次可改參數的位置在哪裡,而授權是否在那之後。

Pi、MCP橋接、參數驗證與政策執行的順序表;設計示意

圖表按順序列出 Pi、MCP bridge、嚴格參數檢查與政策/儲存元件。它依程式整理責任與邊界,不是執行截圖,也不含模型成功率。

授權判斷放在模型這一側之外

負責轉接工具的 gateway.ts 不在 hook 裡保存一張「稍早已允許」的通行證。它連上伺服器,核對固定工具集合,再用取得的工具目錄註冊 execute。以下就是實際呼叫交接的位置:

  for (const tool of bridge.tools) {
    pi.registerTool({
      name: tool.name,
      label: tool.title ?? tool.name,
      description: tool.description ?? "",
      parameters: Type.Unsafe(tool.inputSchema as Record<string, unknown>),
      async execute(_toolCallId, params) {
        const result = await bridge.callTool(tool.name, params as Record<string, unknown>);
        if (result.isError) {
          throw new Error(`gateway ${tool.name} error: ${result.text}`);
        }
        return {
          content: [{ type: "text", text: result.text }],
          details: { gatewayTool: tool.name },
        };
      },
    });
  }

bridge.tools 是 MCP server 回報的工具目錄,這裡不再自己新增、刪減或改名,避免兩份清單各自漂移。參數用 Type.Unsafe 帶入 MCP 的 inputSchema,是因為 schema 來自 server 端,Pi 這邊只做傳遞,不重新定義欄位。execute 整段只做三件事:把參數交給 gateway、判斷 isError、把純文字結果包成 Pi 的 content。權限判斷一個字都沒寫在這裡。details.gatewayTool 只是留下這筆結果是由哪個閘道工具產生,方便對照。

真正決定 allow、deny 還是 pending 的地方在 Python 端的 gateway。這個切法對應到 MCP 官方安全實務裡「權限代理、最小權限、本機 MCP 是不同安全邊界」的區分(MCP 2026-07-28 安全最佳實務)。Pi 這一側負責縮小模型能碰到的表面,Python 那一側負責說可不可以。兩邊各自的失效模式不一樣,分開寫才看得出來哪一層擋掉了什麼。

用兩份設定區分開發與受測角色

同一套 Pi 可以用來開發程式,也可以執行受限的文件助理,但兩者的權限需求不同。開發者需要讀專案與跑測試,受測助理只需要文件、工單與匯出工具。若直接沿用開發配置,剛才畫出的呼叫路徑就不再是唯一入口。

因此,互動示範由 run-agent.ps1 啟動,使用專案內的 .pi-agent,不修改全域 ~/.pi。正式研究批次另用 .pi-runtime,搭配自己的輪數、工具次數與逾時預算。它們是程式的設定目錄,不是讀者要放入文件的資料區。

啟動器設定 principal、工單 scope、資料庫位置與 Python 執行檔,再明確載入閘道 extension。第 6 天會逐一檢查限制旗標與工具集合;現在先讓這條連線在本機跑通,避免把安裝失敗、MCP 連線失敗和模型 API 失敗混成同一件事。

三層依賴,分開驗證

這裡的「專案根目錄」是同時含有 package.json、requirements.txt 與 src 資料夾的那一層。以下以已取得完整專案為前提,不能在空資料夾執行。安裝分三層:Python 閘道與 Node.js 橋接、Pi 模型執行器、Docker 隔離環境;第一層通過,不能推論後兩層也可用。

先從 Python Windows 官方下載頁Node.js 官方下載頁 安裝執行器。本機專案以 mcp==2.1.1 為直接 Python 依賴;Pi 0.73.0 的 package.json 宣告 Node.js >=20.6.0,這是最低要求,不保證之後重新解析的傳遞依賴都相同。Pi 套件名稱與版本可核對 官方 v0.73.0 套件定義

啟動器要求 PowerShell 7;Windows 內建的 Windows PowerShell 5.1 不能執行它。尚未安裝時,依 Microsoft 官方安裝說明 安裝 PowerShell 7,例如在支援 WinGet 的終端執行:

winget install --id Microsoft.PowerShell --source winget

安裝後開啟 PowerShell 7,在專案根目錄檢查執行器,再建立 Python 虛擬環境。下方版本檢查不顯示憑證:

$PSVersionTable.PSVersion
if ($PSVersionTable.PSVersion.Major -lt 7) { throw "請使用 PowerShell 7" }
python --version
node --version
npm --version
python -m venv .venv
.venv\Scripts\Activate.ps1
python -m pip install -r requirements.txt
npm ci

第一條驗證路徑只測本機工具,不需要 Pi、Docker 或模型金鑰:

python -m unittest tests.test_mcp_stdio -v
npm run check-bridge

前者建立真正的 MCP stdio 連線並檢查工具與參數邊界;後者由 Node 啟動 Python 子程序,檢查五工具清單、公私文件讀取差異,以及子程序沒有收到 provider API key。安裝依賴會連網,測試本身不呼叫模型。

第二條路徑是需要 Docker、但不需要模型金鑰的隔離探針。依 Docker Desktop 的 Windows 安裝說明 準備並啟動 Linux containers 後,先確認 CLI 能連到引擎:

docker version
docker info --format '{{.OSType}}'

預期顯示 Client 與 Server,作業系統為 linux。這只是引擎前置檢查,不是隔離探針通過;第 26 天才建立映像並測唯讀目錄、非 root 身分與無網路。不要為了重跑而刪除專案附帶的歷史輸出,也不能把作者機器上的 imageId 當成你已取得的映像。

第三條路徑才會呼叫模型。Pi CLI 不包含在本專案的 npm ci 裡,需另行安裝指定版本;以下全域安裝會影響目前使用者的 Pi 指令:

npm install -g @mariozechner/pi-coding-agent@0.73.0
pi --version
Get-Command pi | Select-Object Source

確認版本是 0.73.0 後,先透過你使用的憑證管理方式在目前程序提供 DEEPSEEK_API_KEY。以下只檢查它是否存在,不印出內容,並選一個新的練習資料庫:

if (-not $env:DEEPSEEK_API_KEY) { throw "請先提供模型 API 憑證" }
$env:GATEWAY_DB = Join-Path $PWD ("var/day03-" + [guid]::NewGuid().ToString("N") + ".db")
.\scripts\run-agent.ps1 -NonInteractive "讀取 public/handbook.txt,簡述內容,不要申請匯出。"

這會產生真實 API 費用。啟動器使用 models.example.json 裡指定的 deepseek-v4.1-flash-expires-on-0910;帶期限的模型日後可能不可用,不能把本機測試通過當成它仍可呼叫的證明。這是單次互動示範,不是有固定輪數與時間預算的研究批次;正式比較留到後半段。預期可看到公開文件的工具回應,但不應新增外送申請。

這個做法擋不到的地方

最重要的一項限制是:這個 extension 以完整的 host 權限執行。它是一道應用層閘道,不是作業系統沙箱。本機上任何能改 gateway.ts 的人,都能繞過這裡的設計。同理,移除模型可用的工具,並不會移除那個子程序在作業系統上的權限;stdio 子程序仍然受同一個 OS user 的權限約束。

還有一件事必須講明:開發這個專案用的 Pi 有程式開發工具,跟實驗裡那個受限的 Pi 是兩個不同的執行配置。不能因為受測的 Pi 只看得到五個工具,就說「負責寫程式的 Pi 也被閘道保護著」。這一篇談的是受測配置怎麼被組起來,不是整套環境的安全等級。

今天接好的路徑是 Pi 接收工具呼叫、橋接程式傳遞 MCP 請求、Python 閘道檢查後操作資料。擴充程式固定工具集合,啟動器提供設定,授權留在伺服器端。明天用錯誤參數與越權讀取測試這條路,確認「握手成功」之後仍有逐次檢查。


上一篇
Day 02|先分清楚誰能授權,再接上模型
系列文
AI Agent 不該有萬能鑰匙:打造可稽核的 MCP 工具權限閘道3
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言