iT邦幫忙

2026 iThome 鐵人賽

DAY 5
0
自我挑戰組

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

Day 05|用一個小資料庫,固定文件與工單的世界

  • 分享至 

  • xImage
  •  

昨天把工具入口分成兩道檢查:參數合不合規格,以及動作有沒有權限。但要說明「可以讀哪份文件、可以改哪張工單」,我們還缺一個所有案例共用的小世界。今天用 SQLite 放進三份合成文件與三張合成工單,讓公開區、草稿區、私人區和部門範圍都有具體對象。

這些文件路徑是資料庫中的虛擬名稱,不會映射到讀者電腦上的真實目錄。固定的是初始文件內容、工單歸屬與政策測試條件;新資料庫的建立時間不必相同。之後看到一次放行或拒絕,就能對照這些初始資料,知道到底改了什麼。

先把今天的完成條件說清楚:從新資料庫開始,能取得三份文件與三張工單;操作過的資料關閉後再打開仍然存在;換一個新資料庫重建時,初始內容與部門歸屬相同。這樣後續每個案例才有可比較的起點。

每一輪都從同一組資料開始

後面幾天的實驗,問題大多長成同一個形狀:同一段提示、同一組工具,換一個後端或換一版政策,結果差在哪裡。如果文件內容、工單狀態或資料庫版本在兩次執行之間不一樣,那個差異就沒有意義,因為分不清是模型換了行為,還是環境自己動了。所以在寫授權邏輯之前,先做一個種子程式,把文件與工單一次鋪好。

這裡的資料全部是合成的。public/handbook.txt 的內容是一行說明自己合成的句子,private/customer-secrets.txt 的內容是 SYNTHETIC-PRIVATE-DO-NOT-EXPORT。刻意讓內容一看就知道不是真實資料,因為這些字串之後可能被工具回傳、進到終端輸出與模型 trace;稽核則保存內容摘要及小型中繼資料,不直接複製完整正文;任何一段被當成真資料引用都是誤會。

選擇 SQLite 而不是一組檔案,主要理由是交易。接下來要驗的幾件事——一筆核准只能被用掉一次、稽核寫不進去時業務資料不能先提交、outbox 列與稽核列要嘛一起出現要嘛一起消失——都需要在同一個交易裡同時改多張表。用檔案做這些事,得自己處理部分寫入的狀態,而我想把力氣留給授權本身。SQLite 的 交易文件 說明了明確交易與提交/回滾的行為;本專案是否把相關寫入放在一起,仍要由下面的 Store 實作與測試確認。

種子資料其實很小,可以先完整看一遍。表內的英文是合成內容或標題,文件末尾各有一個 LF 換行;工單一開始還沒有備註。

類型 名稱/編號 初始內容或歸屬
文件 public/handbook.txt Synthetic employee handbook. Public and readable.
文件 drafts/notes.txt Synthetic draft notes. Readable and writable.
文件 private/customer-secrets.txt SYNTHETIC-PRIVATE-DO-NOT-EXPORT
工單 T-1001T-1002 dept-a;分別為印表機故障(open)、VPN 問題(closed)
工單 T-2001 dept-b;薪資差異(open)

例如,後面要測「私人內容有沒有被拒絕」時,私人文件必須真的存在。若只對不存在的名稱取得 not_found,就算完全沒寫授權,也可能得到一個看似安全的失敗。反過來,公開手冊與草稿提供了正常任務的對照,不能把所有資料都鎖住再宣布測試成功。

虛擬路徑是 key,不是主機目錄

資料表只有幾張。documentspath 當主鍵,欄位是 contentversionupdated_atupdated_byticketsid 當主鍵,帶 scopetitlestatusticket_notesticket_id 外鍵指回工單,記 authorbodycreated_at。另外三張是 export_requestsoutboxaudit,先擱著,後面幾天會一個一個拆。

關鍵在於:private/customer-secrets.txt 這一串字,在整份 store 裡的身分就是 documents 的一筆主鍵。沒有任何一行把 publicdraftsprivate 接到 os.path.join,也沒有任何一行去建立這三個目錄。所謂的 root 是 config.py 裡的三個 tuple(READ_ROOTSWRITE_ROOTSALL_ROOTS),由路徑驗證與政策層比對完整第一段名稱,不是任意字串前綴。

這個選擇有個直接後果。之後在講路徑穿越時,問題的形狀跟一般檔案系統不同:../ 之類的輸入不會讓誰讀到主機上的東西,因為根本沒有對應的檔案;它被拒絕,是因為 normalize_virtual_path 明確排除 .. 段落,而不是作業系統攔了下來;root 授權是另一個檢查。寫測試的時候這一點很重要,否則很容易把「找不到檔案」誤當成「政策生效」。

合成文件與工單的權限對照;SQLite路徑是key

圖表列出 public、drafts、private 與兩張代表性工單的可做及被拒操作。它是依設定、種子與政策整理的設計對照,不是資料表結構圖或實測數值。

種子只播一次:程式節錄與逐行選擇

seed.py 的核心是這一段,逐字照錄:

def seed_if_empty(store, principal_id: str = "seed") -> bool:
    """Populate synthetic rows when the documents table is empty."""
    count = store.conn.execute("SELECT COUNT(*) AS n FROM documents").fetchone()["n"]
    if count:
        return False
    with store.txn():
        for path, content in SEED_DOCUMENTS.items():
            store.put_document(path, content, principal_id)
        for ticket_id, scope, title, status in SEED_TICKETS:
            store.conn.execute(
                "INSERT OR IGNORE INTO tickets (id, scope, title, status) VALUES (?,?,?,?)",
                (ticket_id, scope, title, status),
            )
    return True

程式先讀 COUNT(*),有文件就直接返回。在本次每案建立獨立資料庫、單一程序播種的流程中,這能避免重複初始化。它不是併發安全的初始化鎖:兩個程序若同時看到零列,都可能進入播種流程。也不能把它當成修復部分缺漏資料的工具;有一份文件時,即使工單缺漏也會跳過。重現案例應建立新資料庫,不要把既有資料清掉再期待這段補齊。

函式回傳 bool,呼叫端能分辨「這次真的播了種」和「本來就有資料」。跑實驗時這個差別要看得出來,否則沒辦法解釋某一次執行為什麼多了一批列。

with store.txn() 進的是 BEGIN IMMEDIATE,播種要嘛全部出現、要嘛全部消失。三份文件加三張工單不該有中間狀態,尤其是後面會用文件內容當比對基準。文件走 store.put_document(),由 store 決定 versionupdated_at,呼叫端不自己拼 INSERT——版本號的遞增規則只存在一個地方,日後要改也只改那裡。

工單用 INSERT OR IGNORE。因為前面已經擋掉有資料的情況,這行平常不會真的觸發;它擋的是「文件被清掉但工單還在」這種半殘狀態,讓重播種不會直接炸掉。

碰到舊資料庫時,先停下來

舊版 outbox 把待送內容放在外部 JSON 檔,資料表只存 file_path;目前版本把 payload 直接放進 SQLite。若自動沿著舊字串讀檔遷移,就要額外處理路徑可信度與部分完成的遷移狀態,超出這組合成案例的需要。現行做法會保留舊庫,回傳 LegacySchemaError,要求把 GATEWAY_DB 指到新的位置。

因此,_init_schema() 的第一件事是 _reject_legacy_schema(),跑在任何 DDL 之前:

def _init_schema(self) -> None:
    # Detect the legacy v1 schema BEFORE running any DDL.
    self._reject_legacy_schema()
    self.conn.executescript(SCHEMA)
    self.conn.execute(
        "INSERT OR REPLACE INTO meta (key, value) VALUES ('schema_version', ?)",
        (str(SCHEMA_VERSION),),
    )
    self.conn.commit()

(以上為 _init_schema 全文;_reject_legacy_schema 那兩行判斷是 "file_path" in cols and "payload" not in cols,成立就丟 LegacySchemaError,其餘是錯誤訊息文字,此處省略。)

為什麼不能先 executescript(SCHEMA) 再檢查?CREATE TABLE IF NOT EXISTS 對已經存在的 outbox 表不會有任何動作,但只要 schema 先被動過一次,可能先建立其他缺少的表或寫入 meta,違反「拒絕時不改舊庫」的目標。現行 SCHEMA 不會替既有 outbox 自動新增 payload;半套用遷移留下 payload 的風險,是來源註解記錄的舊遷移問題,不能說成 CREATE TABLE 本身會補欄位。PRAGMA journal_mode = WAL 也放在 schema 檢查與建立成功之後,被拒絕的舊檔連 journal mode 都不會被改到。

正常與失敗的對照

用一個新的 GATEWAY_DB 路徑啟動後,documents 有三列,tickets 有三列(T-1001、T-1002 屬 dept-a,T-2001 屬 dept-b),get_document("public/handbook.txt") 回得到那一列。

把同一個環境變數指回舊檔案,結果是 LegacySchemaError,而且原始檔案只被 PRAGMA 讀過,回歸測試確認舊 schema、原列與 schema_version 都保留,未切換為 WAL;測試沒有逐位元比對 DB 本體,所以不把它寫成全檔雜湊驗證。這是刻意的失敗方式:寧可拒絕啟動,也不要產生一個看起來成功、實際上狀態不明的資料庫。

還有一個對照容易被忽略。在新資料庫上,get_document("private/customer-secrets.txt") 是會成功的——store 不做授權,它只認 key 存不存在。這不是漏洞,是分層的結果:資料在不在、交易成不成立,是 store 的責任;能不能讀,是政策層的事。把兩者混在同一層,之後就沒辦法單獨驗證政策有沒有生效。

重開之後,正常工作還在不在

建立資料成功,和資料能保存,是兩個問題。在第 3 天準備好的 Python 環境、專案根目錄執行:

python -m unittest tests.test_durability -v

這個模組的 test_state_survives_reopen 寫入草稿、替工單新增備註,再建立一筆 pending 匯出申請。接著關閉 Store,重新連到同一個 SQLite 檔,確認三種狀態仍在。它的完整例子是「建立—操作—關閉—重開—讀回」,不是只檢查記憶體中的物件。

這個測試不會注入稽核故障,也不驗證同時播種的安全性。播種程式以文件表是否為空判斷要不要初始化;它不適合修補被手動刪了一半的資料,也不是多個程序共同啟動時的鎖。系列實驗採每案新資料庫、單一程序初始化,正是為了讓起點沒有這些歧義。

讀者重跑時同樣另選新資料庫,讓附帶的歷史案例保留原狀。表裡的文件名稱只用於查詢資料列,不能用本篇結果推論主機上的檔案權限。

今天固定了三份文件、三張工單與它們的初始狀態,並確認操作狀態能保存到重開之後。接下來要讓助理只能透過五個入口接觸這個小世界;明天會回到 Pi 啟動配置,關閉其他工具來源,檢查是否還有繞過閘道的路。


上一篇
Day 04|MCP 握手成功之後,權限檢查才要開始
系列文
AI Agent 不該有萬能鑰匙:打造可稽核的 MCP 工具權限閘道5
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言