---
title: R2a 檢查結果：任務下發與狀態機＋控制面 route 與守門殼（jedi-remote-agent）
---

# R2a 檢查結果：任務下發與狀態機＋控制面 route 與守門殼（jedi-remote-agent）

> 檢查日期 2026-09-16｜對應卡片 CM-1593｜檢查範圍 30 個檔案

## 🔴 一句話結論

**五條發現全部通過檢查——三條嚴重、一條中等、一條輕微——但一條新問題都沒有。** 五條分別對應先前已經開好的兩張修正卡（CM-1595／CM-1596），這一棒的價值不在「又找到什麼」，而在**把 CM-1595 那條攻擊路徑的最後幾塊拼圖補齊了**：授權過期進入唯讀模式時這些端點照樣打得到、雲端會**主動去抓攻擊者指定的檔案**存成稽核證據、「你是哪一家客戶」除了代理程式編號與機器編號之外**還可以用任務編號來選**、以及管理頁面把機器指紋回傳出去等於送出冒充用的第二把鑰匙。

白話講整件事：客戶機房的代理程式（agent）跑完掃描要回報結果，接收回報的那幾個端點**完全不檢查來人是誰**；更麻煩的是，伺服器決定「這次動作算在哪一家客戶頭上」的依據，就是請求自己送上來的那個編號——**攻擊者填哪一家，伺服器就切成那一家**。

**這是 `jedi-remote-agent` 第一次真正跑完三個檢查員投票的一棒**。R1 那次面板全滅於額度上限（21 票 0 張投出），R1b 雖然跑完但驗證章被渲染退件；本棒 15 票全數投出、五條全 3:0 通過、驗證章蓋 `verified`、掃描當下工作區乾淨。所以這次的「這些問題是真的嗎」是三棒以來最扎實的一次。

## 這一棒在檢查什麼

客戶機房裡裝了一個代理程式，雲端把掃描工單派給它、它跑完回報結果，這一整條「下發—接收—狀態轉換」的鏈子就是本棒的主題，再加上 2026-09-11 套件拆分時搬進來的那層**對外 HTTP 介面與守門殼**。具體三件事：

1. **回報結果的那幾個端點有沒有檢查來人是誰**（`api/routes/` 與路由表 `api/routing.py`）。
2. **任務的狀態機**——從「待派」走到「成功／失敗」這條路上，有沒有任何一步確認「回報的這台，是不是當初被派工的那台」（`domain/agent_task/`、`app/service/agent_task_service.py`）。
3. **守門殼有沒有裝好**——套件自己不決定誰能進，它把守門這件事交給宿主（主專案）傳一個函式進來；本棒要確認的是「宿主如果忘了傳，會靜靜地變成沒人守門嗎」（`plugin/assembly.py`、`plugin/contract.py`）。

掃描目標 `~/Projects/Jedicogy/module/jedi-python-package/jedi-remote-agent`，revision `3cc966f8`（branch `feature/review`，**工作區乾淨、無未提交改動**），mode `scan`，effort `low`，focus `attack-surface`。範圍是 30 個受版控檔案，啟動前已核對；**這 30 檔從 `3cc966f8` 到現在的 HEAD 零漂移**，所以報告裡的行號現在打開還是對的。

刻意排除在本棒之外的，是代理程式的身分與註冊那一整條鏈（`agent_enroll*`、`remote_agent_service.py`、`common/agent_auth/`、`domain/remote_agent/`、`infra/remote_agent/`、`migrations/`）——那些在 R1 與 R1b 掃過了。

## Coverage

30 個檔以單一元件讀完。低強度跑法不做元件盤點、不做威脅建模、不跑額外的廣度掃描，`completenessCheckOutcome` 為 `not-applicable`（指定範圍的掃描本就不適用）。**這份報告完全不能拿來說 `jedi-remote-agent` 其他地方沒事**，身分與註冊那半邊要看 R1／R1b。

派出兩位研究員、兩位都回報。驗證跑一輪，7 個原始候選去重後 5 個，每一條都拿到完整的三票，**15 票全投出，沒有候選遺失、沒有候選未被審、沒有候選被交到下一輪**。沒有任何候選被駁回。面板調整了一條的嚴重度：F5 研究員原報中等，面板定為輕微（三票分別是輕微、輕微、中等）。

**有一條發現的位置在範圍外**：F5 的問題函式住在 `app/service/remote_agent_service.py`，那支屬於 R1 的範圍、不在本棒的 30 檔裡；研究員是從範圍內的路由檔追進去的，所以報出來但標為越界。

**這份報告沒有逐檔紀錄**：`coverage.research` 是 `null`，工具沒留下「哪位研究員把哪個檔讀到什麼程度」的帳。下方「卡片重點逐項」段由首腦逐項開檔補查，並標明哪些是工具報的、哪些是人工查的。

工具沒有實際執行任何程式碼：沒跑測試、沒發請求、沒示範攻擊。五條發現全部是讀原始碼推出來的。

## 掃到什麼（總覽）

| # | 嚴重度 | 這是什麼問題（白話） | 出事會怎樣 | 要先有什麼才打得到 | 位置 | 修正卡 |
|---|--------|---------------------|-----------|------------------|------|--------|
| [F1](#f1) | 🔴 嚴重 | 「我收到工作了」與「我做完了」這兩條回報管道，一個身分檢查都沒有 | 不需任何帳號，把別人的掃描標成「成功」，並讓雲端去抓攻擊者指定的檔案，當成合規稽核的證據存進案子裡 | 連得到 API＋知道一個還沒結束的任務編號（無認證的心跳端點就會回給你） | `api/routes/remote_agent_route.py:185`（收到）、`:192`（做完） | ✅ 已修（CM-2052；原重複 R1 對應卡 CM-1595 作廢） |
| [F2](#f2) | 🔴 嚴重 | 「報到」（心跳）這條管道也不檢查身分，而且回應裡夾帶已經解密的明文帳號密碼 | 拿走客戶自己機器的帳密（SonarQube 權杖、SSH／WinRM 帳密），同時把真代理程式的工作佇列搶過來 | 連得到 API＋知道一個代理程式編號或一個機器編號（VM 樣板複製出來的機器編號會一模一樣） | `api/routes/remote_agent_route.py:179` | ✅ 已修（CM-2052；原重複 R1 對應卡 CM-1595 作廢） |
| [F3](#f3) | 🔴 嚴重 | 「這次動作算在哪一家客戶頭上」，是用請求自己送來的任務編號查出來的，查的時候還刻意繞過客戶隔離 | A 客戶的合法代理程式，拿 B 客戶的任務編號就能用 B 的身分寫結果與證據；資料庫的隔離機制從頭到尾看不出異常 | 拿得到一個別家客戶的任務編號；多客戶部署才成立 | `app/service/agent_task_service.py:134` | ✅ 已修（CM-2052；原重複 R1 對應卡 CM-1595 作廢） |
| [F4](#f4) | 🟡 中等 | 同一條路的授權面：沒有檢查「這張工單是不是派給你的」 | 就算之後補上了身分驗證，任何一張合法的代理程式憑證，仍然可以替別台回報 | 有一張合法的代理程式憑證（今天連憑證都不用）＋知道一個任務編號 | `app/service/agent_task_service.py:134` | ✅ 已修（CM-2052；原重複 R1 對應卡 CM-1595 作廢） |
| [F5](#f5) | ⚪ 輕微 | 管理頁「測試連線」填什麼網址就打什麼，沒有任何位址限制 | 拿我們的伺服器當跳板探測內網，從回傳的狀態碼分辨「服務在但拒絕」與「完全不通」 | 需要租戶管理員權限（這是它只算輕微的原因） | `app/service/remote_agent_service.py:66`（⚠️ 越界） | ✅ 已修（CM-2370，1.21.1；原卡 CM-1596 作廢） |

**五條全部是既有問題的再次確認，淨新增 0。** 跨 arc 總表 §3.1 不加項。

## Findings

<a id="f1"></a>
### F1 — 回報工作的兩個端點沒有身分檢查，可以塞任意結果（嚴重，3:0）

**這是什麼問題。** 代理程式跑完掃描後會呼叫兩個端點：`/agents/tasks/<編號>/ack`（我收到這份工作了）與 `/agents/tasks/<編號>/result`（我做完了，結果在這）。這兩條在路由表裡標著 `needs_admin=False`，而掛載的那段程式**只對標 True 的項目加守門**——所以這兩個端點出貨時**一個守門裝飾器都沒有**，服務層也沒有任何一行確認來人是誰。

**出事會怎樣。** 客戶用來應付外部稽核的證據會被汙染。攻擊者把任務標成「成功」時可以附一個 `result_ref.upload_uid`（結果檔的編號），宿主的收尾程式（`on_task_succeeded`）會**主動照這個編號去把那個檔案抓回來，存成這次檢測工單的稽核證據**。也可以反過來標成「失敗」，讓真正跑出問題的掃描結果整份消失。真的代理程式稍後回報時，會因為「任務已在終態、這個狀態轉換不合法」被擋掉——**假的先到就贏**。

**要先有什麼才打得到。**
- 連得到 `/api/1.0/agents/tasks/<編號>/ack` 或 `/result`。**授權（license）過期進入唯讀模式也照樣打得到**——唯讀中介層的白名單明文豁免了整個 `/agents/` 前綴（`common/middleware/license_readonly_mw.py:76`）。
- 知道一個任務編號。這個不難拿——下面 F2 那個同樣沒有身分檢查的心跳端點，回應裡的 `pending_tasks` 每一筆就帶著任務編號。
- 該任務還沒結束（待派／已派／執行中）。

**在哪裡。** `jedi_remote_agent/api/routes/remote_agent_route.py:192`（`AgentTaskResultRoute.post`）與 `:185`（`AgentTaskAckRoute.post`）。路由表宣告在同套件 `api/routing.py:39-40`（兩條都是 `needs_admin=False`），只對 True 套守門的那行在 `:95`。

**怎麼修。** 讓這兩個端點跟其他資料面呼叫一樣要求證明身分——由入口驗過再傳下來的用戶端憑證，或套件自己就會簽的短效通行證（RS256 JWT）——並且在驗過身分之後，比對「這張工單記的代理程式」是不是就是來人，不符就拒絕。

**驗證。** 3/3 三位檢查員一致確認成立。

**首腦核對註記與判定。** 屬實，**重複 R1 的 F2＋F5，修正卡 CM-1595 已開，不另計新項。**

但這次有兩塊增量價值，已經 append 進 CM-1595：
1. **授權唯讀模式也豁免**——R1 只說「連得到 API 就打得到」，這次追到中介層的白名單（`license_readonly_mw.py:76`）明文放行 `/agents/`，代表授權過期的環境不但沒有變安全，這條路還一樣開著。
2. **宿主會主動去 fetch 攻擊者指定的東西**——R1 只寫到「可以偽造證據」，這次把 `result_ref.upload_uid` → 宿主 listener 去抓 blob → 存成稽核證據這一整段追完了。這是攻擊者從「能寫一筆假紀錄」升級成「能把自己準備的任意檔案塞進客戶的稽核案卷」的關鍵一步。

<a id="f2"></a>
### F2 — 心跳端點沒有身分檢查，回應夾帶解密後的明文帳密（嚴重，3:0）

**這是什麼問題。** 代理程式每隔一段時間會「報到」一次（心跳），順便領取待辦工作。這個端點在路由表裡同樣標著 `needs_admin=False`（`api/routing.py:38`），所以**沒有掛任何守門**；伺服器唯一用來判斷「你是哪一台」的依據，就是請求內容裡自己寫的 `agent_uid` 或 `device_uuid`。

`device_uuid` 這個尤其糟——它來自機器本身的識別碼（machine-id／product_uuid），**同一份 VM 樣板複製出來的機器，這個值會一模一樣**。

**出事會怎樣。** 回應會把該客戶待辦的掃描工單整包送出，裡面的 `credentials` 欄位是宿主的付載提供器**已經解密成明文**的內容——SonarQube 的存取權杖、客戶自己主機的 SSH 與 WinRM 帳號密碼。拿到的人可以直接登入客戶自己的機器。同一次呼叫還會覆寫那台代理程式的「最後上線時間」「版本」「能力清單」「裝置指紋」，等於**把真代理程式的工作佇列整條搶過來**。

**要先有什麼才打得到。**
- 連得到 `/api/1.0/agents/heartbeat`。落地版的對外入口 nginx 用一條 `location /api/1.0` 代理全部路徑，這條也在內。
- 知道一個代理程式編號，或一個機器編號。前者是任何登入使用者在代理程式管理頁就看得到的；後者在 VM 樣板複製的環境是共用的。
- 要拿到帳密還需要該代理程式當下至少有一筆待辦工作；單純冒充則什麼都不用。

**在哪裡。** `jedi_remote_agent/api/routes/remote_agent_route.py:179`（`AgentHeartbeatRoute.post`），路由表在 `api/routing.py:38`。

**怎麼修。** 在應用層要求一個可驗證的代理程式身分憑據，不要相信請求自報的編號：驗入口傳下來的用戶端憑證（這需要 nginx 開 `ssl_verify_client on`，**出貨的三個 nginx 設定檔一行都沒有**），或要求套件本來就會簽的短效通行證，並且拒絕任何「宣稱的身分」與「要被更新的那一列」對不上的心跳。

**驗證。** 3/3 三位檢查員一致確認成立。

**首腦核對註記與判定。** 屬實，**重複 R1 的 F1，併 CM-1595，不另計。**

**這是同一件事的第三次獨立確認**：R1 的研究員報過一次、首腦 2026-09-16 比對 `fc1cadb` 這筆修改的前後又核過一次、本棒面板 3:0 再確認一次。**三次都指向同一段程式碼、同一個結論**——這條沒有翻案空間。

<a id="f3"></a>
### F3 — 「算在哪一家客戶頭上」由請求自己送來的任務編號決定，跨客戶成立（嚴重，3:0）

**這是什麼問題。** 這是本棒最值得看的一條。收到回報之後，伺服器要決定「這次動作算在哪一家客戶頭上」，做法是：拿請求路徑上的那個任務編號，在一個**刻意繞過客戶隔離的提權範圍**裡（`elevated_readonly_scope()`）去查整張 `agent_tasks` 表，找到那一列之後，取它的 `tenant_id`，然後把整個執行環境切換成那一家客戶的機器身分（`tenant_context`）。

**整段程式沒有任何一行比對「呼叫者有沒有權碰這張單」或「呼叫者是不是這家客戶的」**——送上來的那把鑰匙同時決定了「要開哪扇門」和「用誰的身分開」。

**出事會怎樣。** 客戶之間的隔離在這條路上等於不存在。一個持有 A 客戶合法憑證的代理程式，只要拿到 B 客戶的一個任務編號，就能用 B 客戶的身分寫入終態與證據。**資料庫的隔離機制（RLS，就是「每個客戶只能看自己資料」的那套）從頭到尾不會報錯**——因為連線本身就被設定成 B 客戶了，它看到的一切都「合法」。

這一點也說明了：**就算之後把 nginx 的用戶端憑證驗證開起來，這條也還在**——憑證只證明「你是一台合法登記過的代理程式」，不證明「這張工單是你的」。

**要先有什麼才打得到。**
- 打得到 ack 或 result 端點（今天不需任何憑證，見 F1）。
- 知道一個屬於別台代理程式或別家客戶的任務編號。
- 多客戶部署才有跨客戶效果；單一客戶的部署只會看到「冒充別台」這一半。

**在哪裡。** `jedi_remote_agent/app/service/agent_task_service.py:134`，`AgentTaskService._task_tenant_context`。

**怎麼修。** 把順序倒過來：**先從憑證或已簽名的通行證解析出「呼叫者是哪一台代理程式」，再要求 `task.agent_id == 呼叫者.id` 且 `task.tenant_id == 呼叫者.tenant_id`，通過了才進 `tenant_context`。** 那個繞過隔離的提權查詢要收窄到「查呼叫者自己是誰」，**絕對不能用來查一個由呼叫者指名的物件**。

**驗證。** 3/3 三位檢查員一致確認成立。

**首腦核對註記與判定。** 屬實。**這正是首腦 2026-09-16 推翻 CM-1595 降級時說的那件事——當時是人工比對推出來的，這次工具的三個檢查員各自獨立讀完程式碼、3:0 坐實。**

與 R1 的 F3（心跳那條路的跨客戶問題）是**同一個根因、不同端點**，所以**併 CM-1595**。卡尾已補上一句總結，方便修的人一次看全：**三支端點（心跳／ack／result）的做法全部都是「用呼叫者送來的鍵去選客戶身分」，而這把鍵有三種形態——代理程式編號、機器編號、任務編號。**修的時候三種都要堵，只堵一種等於沒堵。

<a id="f4"></a>
### F4 — 同一條路的授權面：沒有檢查這張工單是不是派給你的（中等，3:0）

**這是什麼問題。** 位置與 F3 是同一行，看的角度不同。F3 講的是「客戶隔離被繞過去了」，F4 講的是**更基本的一件事：這裡根本沒有「歸屬檢查」**——沒有任何一步問「這張工單當初是派給誰的、你是不是他」。

**出事會怎樣。** 意義在於**它是「補了認證之後還剩下的洞」**。假設之後有人把 nginx 的用戶端憑證驗證開起來、端點也掛上守門，那時候任何一張合法的代理程式憑證（例如某個客戶機房裡被入侵的那一台）仍然可以替別台回報、把別人的檢測執行歷史與證據寫壞。

**要先有什麼才打得到。** 今天：什麼都不用（端點無認證）。補上認證之後：一張任何合法的代理程式憑證，加上一個任務編號。

**在哪裡。** `jedi_remote_agent/app/service/agent_task_service.py:134`，同 F3。

**怎麼修。** 把驗過的身分**一路傳到 domain service** 去做 `task.agent_id == 呼叫者.id` 的斷言。

**驗證。** 3/3 三位檢查員一致確認成立。

**首腦核對註記與判定。** 屬實，**重複 R1 的 F5，併 CM-1595。**

🔴 **與同日 R2b（CM-1601）的 F4 是同一條路的兩端**：R2b 是從狀態機那一側（`agent_task_domain_service.update_status`）看到的，本棒是從任務服務這一側看到的。兩邊合起來得到一個對修法很重要的結論——**只在 route 掛一個守門裝飾器不夠**。因為 `update_status` 這一層自己不檢查歸屬，只要之後有第二條呼叫路徑進來（例如未來新增的批次補登、維運工具、資料修復腳本），就會再一次繞過。**身分必須一路傳到 domain service 做斷言，或者乾脆把「歸屬」變成 `update_status` 的必填參數**，讓任何呼叫端都不可能轉換一張不屬於它的工單。

<a id="f5"></a>
### F5 — 測試連線可以被當成內網探測器（輕微，面板由中等降下，⚠️ 越界）

**這是什麼問題。** 管理頁上有個「測試連線」功能，會拿使用者填的網址直接去打一次（`httpx.get`），**不檢查協定、不檢查主機、不檢查位址範圍**，然後把對方回的狀態碼與最多 200 字的錯誤訊息回給呼叫者。

**出事會怎樣。** 攻擊者可以拿我們的伺服器當跳板去探測內部網路——從回應分辨「這個服務在，只是拒絕我」與「這裡根本沒東西」，逐一掃過位址範圍就能畫出內網有哪些服務、開了哪些埠。打得到的目標包含容器網路裡的資料庫、Redis、授權服務，雲端部署還包含中繼資料端點。

**要先有什麼才打得到。**
- 一個已登入、且持有 `remote-agent-manage.create` 權限的租戶管理員。**這就是它只算輕微的原因**——不是任何人都打得到。
- **授權（license）過期進入唯讀模式也照樣打得到**：這支在唯讀中介層的白名單裡（`common/middleware/license_readonly_mw.py:116`）。
- 代理程式認證設定是關閉狀態（`mode=none`），或填的網址不是 https——這兩種情況都會走到那個沒有任何限制的裸 `httpx.get`。

**在哪裡。** `jedi_remote_agent/app/service/remote_agent_service.py:66`，`RemoteAgentService.check_health`。入口是本棒範圍內的路由 `api/routes/remote_agent_route.py:76`。

**怎麼修。** 送出前先驗網址：只准 http(s)、把主機名稱解析出來後拒絕本機位址、link-local 與私有網段（**解析之後要再檢查一次**，避免解析結果被換掉），或者乾脆只准打已經登記在該客戶名下的代理程式位址。回傳改成一個「通不通」的布林值，不要把上游的狀態碼與原始錯誤訊息照原樣吐回去。

**驗證。** 3/3 三位檢查員一致確認成立。**研究員原報中等，面板降為輕微**（三票分別是輕微、輕微、中等）。

**首腦核對註記與判定。** ⚠️ **越界發現**——這個函式住在 `remote_agent_service.py`，屬於 R1 的範圍、不在本棒 30 檔內，研究員是從範圍內的路由追進去的。**重複 R1 的 F4，修正卡 CM-1596 已開，不另計。**

**同意面板降為輕微**（需要管理員權限，與 R1 當時把它排在 F1／F2 之後是同一個理由）。新增一點已 append 進 CM-1596：**這支在授權唯讀模式的白名單裡**（`license_readonly_mw.py:116`），所以授權過期的環境不會因此少一個攻擊面。

## 卡片重點逐項

🔴 **低強度只跑兩位研究員。卡片列的七個重點，工具實質碰到的是②、⑥，加上①的一部分（路由表那兩條 `needs_admin=False`）。其餘四項由首腦回頭開檔人工查證。**

### ① 守門殼會不會靜靜地變成沒人守門（**工具碰到一部分，首腦補完**）

**結論：乾淨，而且是意外的乾淨——它會在啟動時就直接炸掉，不會靜靜放行。**

套件自己不決定誰能進，它要求宿主（主專案）傳一個「守門函式」進來：`plugin/assembly.py:126` 的 `mount_routes(bp, adapters.admin_required)`。契約上 `RemoteAgentAdapters.admin_required` 的型別是**必填**的 `Callable`（`plugin/contract.py:90`），但 **`create_blueprint` 裡沒有任何一行 assert 去檢查它到底有沒有被傳進來**。

原本擔心的是：宿主漏傳（傳成 None）時，守門會靜靜地變成「不守」。**實查結果相反**——真的傳 None 的話，`_guard(resource, None)` 會在 `decorator(getattr(...))` 那一行直接丟 `TypeError: 'NoneType' object is not callable`，而且是在**掛載階段**就炸，服務根本起不來。這叫「出錯就大聲」（fail loudly），是好的那一種。

另外查了 `_guard` 只包五個 HTTP method 會不會漏——本套件的 Resource 沒有第六種 method，不漏。

**建議（非資安）**：照 `jedi-asset` 的做法在 `create_blueprint` 開頭補一個顯式 assert，讓錯誤訊息直接說「宿主沒有提供 admin_required」，而不是丟一個看不出原因的 `TypeError`。這是可維護性改善，不升為發現。

### ② 任務下發與回報端點的守門（**工具已報＝F1／F3／F4**）

**結論：成立。** 詳見上方三條。這是本棒唯一被工具吃得最透的一項。

### ③ 狀態機有沒有搶著寫的問題（**工具未報，人工查證**）

**結論：時間窗口確實存在，但後果是「第二個請求被拒（409）」，不是兩份資料互相蓋掉。不升為發現。**

`update_status` 的做法是三步：查這張單在不在 → 驗這個狀態轉換合不合法 → 寫進去。中間**沒有加鎖**（`with_for_update` 全套件 grep 零命中）。所以兩個回報同時進來時，理論上兩個都可能讀到「還沒結束」然後都往下走。

但實際跑起來的結果是：先寫進去的那個成功標成 succeeded，**後到的那個在第二步就會發現「已經是終態了」，拋出衝突錯誤（409）**——不會出現兩份結果互相覆蓋。真正會不會走到這個窗口，還取決於資料庫的交易隔離級別，**本次未實測**。

**處置：記為跨 arc 總表 §3.2 的「非資安小坑」候選，暫不登記，寫在這裡備查。**

### ④ 工單裡的 `agent_id` 是不是可以指到別家的代理程式（**工具未報，人工查證**）

**結論：不成立。**

`create_task(agent_id=plan["agent"].id)` 裡那台代理程式是從 `_pick_agent(tenant_id)` 挑出來的，它往下呼叫 `get_dispatchable_agents(tenant_id, …)`（`jedi-detection` 的 `:1355`），**查詢本身就帶客戶條件**。另一側 `list_dispatchable_for_agent` 的查詢也同時帶 `agent_id` 與 `tenant_id` 兩個條件。兩邊都不是破口。

### ⑤ 掃描工單的內容會不會被寫進 log（**工具未報，人工查證**）

**結論：乾淨。**

把 30 個檔裡所有 `logger.*` 的呼叫全部 grep 過一遍，**沒有任何一處把工單內容（`payload`／`params`）、帳號密碼或存取權杖印出來**。稽核事件那側（`common/audit.py`）也只帶編號與指紋，不帶內容。

### ⑥ 那個繞過客戶隔離的提權查詢本身是不是攻擊面（**工具已報＝F3**）

**結論：是，而且它就是 F3 的核心。** 提權查詢不是「順便做的效能優化」，它是這條攻擊路徑成立的必要條件——**因為它繞過隔離，攻擊者指名的任務編號才查得到、才切得過去**。詳見 F3 的修法：提權查詢要收窄到「查呼叫者自己是誰」，不能用來查呼叫者指名的物件。

### ⑦ 回傳給管理頁的欄位有沒有多給（**工具未報，人工查證**）

**結論：回傳裝置指紋等於送出冒充用的第二把鑰匙——是 F2 的放大器。**

`api/serializers/remote_agent.py` 裡，管理面的回應會帶出 `device_fingerprint`（`:28`）、`hardware_info`（`:32`）與 `base_url`（`:25`）。這些需要 `remote-agent-manage.create` 權限才看得到，**但 F2 那條攻擊只要拿到「代理程式編號**或**機器編號」其中之一就能冒充**——而機器編號（`device_uuid`）正是這裡回傳出去的東西。所以管理頁把指紋顯示出來，等於替 F2 多備了一把鑰匙。

（下拉選單用的那個精簡版 schema 刻意不回 `base_url`（`:9`），這一點做得對。）

**處置：併進 CM-1595 的修法要求——心跳端點不得再接受「機器編號」作為身分的替代依據**，把這把鑰匙作廢，回傳指紋就不再是問題。

## 這份結果可信到什麼程度

分兩層看。

**「這五條是真的嗎」→ 可信，而且是三棒以來最扎實的一次。** 15 票全數投出、五條全部 3:0 一致通過、驗證章蓋 `verified`、沒有任何候選被退件或延到下一輪。首腦逐條開檔核對過行號。其中 F2 與 F3 更是**第三次獨立確認**（R1 研究員、首腦 09-16 人工比對、本棒面板各一次）。

**「只有這五條嗎」→ 中等。** 好的一面：兩位研究員都完整回報、面板完整跑完，**這是 `jedi-remote-agent` 第一次真正把三個檢查員的流程從頭跑到尾的一棒**（R1 面板全滅、R1b 驗證章被渲染退件）。不能宣稱完整的一面：低強度跑法沒有元件盤點、沒有威脅建模、工具沒留逐檔紀錄（`coverage.research` 是 `null`），卡片列的七個重點工具只碰到三個，其餘四項是首腦人工補查的結論、不是工具給的。

**淨新增 0。** 五條全部併進既有的 CM-1595／CM-1596，跨 arc 總表 §3.1 不加項。但本棒把 CM-1595 那條攻擊鏈補完整了四點：授權唯讀模式也豁免、宿主會主動去抓攻擊者指定的檔案、任務編號是第三種可以用來選客戶身分的鍵、管理頁回傳指紋是冒充的第二把鑰匙。

## 執行概況

| 項目 | 數字 |
|---|---|
| 檢查範圍 | 30 個受版控檔案（任務下發鏈 ＋ 2026-09-11 拆套件時搬進來的控制面 HTTP 介面與 plugin 殼） |
| 檢查強度 | 最低（`low`），focus 設在攻擊面 |
| 候選問題 → 去除重複 | 7 → 5 |
| 投票數 | 15（5 條候選 × 3 位檢查員），全數投出，沒有漏投、沒有中斷、沒有被延到下一輪 |
| 五條投票結果 | 全部 3:0 通過 |
| 被駁回候選 | 0 |
| 被降低嚴重度的 | 1（F5：研究員報中等 → 面板定輕微，三票為輕微／輕微／中等） |
| 研究員派出 / 回收 | 2 / 2 |
| 驗證章狀態 | **`verified`**——無退件，是本套件三棒以來第一次完整通過 |
| 掃描耗時 | 7,082 秒（約 1 小時 58 分） |
| 掃描當下的程式碼版本 | commit `3cc966f8`，branch `feature/review`，**工作區乾淨**；30 檔自 `3cc966f8` 到現在的 HEAD 零漂移 |
| 首腦人工補查項目 | 卡片七個重點中的①（守門殼 assert）、③（狀態機無鎖）、④（`agent_id` soft-ref）、⑤（log 有無印帳密）、⑦（serializer 回傳指紋） |
| 對總表的影響 | §1 加 R2a 一列；**淨新增 0，§3.1 不加項**；CM-1595 卡尾 append 四點新細節；CM-1596 卡尾 append 唯讀白名單一點；§3.2 候選（狀態機無鎖）暫不登記 |

## 交付狀況

🔴 **本棒 runner 四件全未交**：沒有寫報告、沒有 commit、沒有回寫 Notion（CM-1593 仍停在「未開始」）、沒有回報。工具產物是首腦自己到掃描目錄撈出來的，五條發現的逐條開檔核對、卡片七個重點裡五項的人工查證、與同日 R2b 的接合判定，也全部由首腦完成。**本報告由首腦補寫，這是全計畫第十次 runner 交付掛零。**

---

原始工具產出：`~/Projects/Jedicogy/module/jedi-python-package/jedi-remote-agent/CLAUDE-SECURITY-20260916-125508/`
（`CLAUDE-SECURITY-RESULTS.md` 為工具原文，本報告為首腦整理版，含逐項人工查證。）
