---
title: R2b 檢查結果：檔案取用與 agent 連線（jedi-detection ＋ jedi-remote-agent）
---

# R2b 檢查結果：檔案取用與 agent 連線（jedi-detection ＋ jedi-remote-agent）

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

## 🔴 一句話結論

**四條發現通過檢查、全部中等。** 其中三條指向**同一個根因**：客戶機房的代理程式來雲端拿檔案、或回報任務結果時，雲端**完全不驗證對方是誰**——身分就是請求自己在標頭上寫的那一行字。任何能連到 API 的人，只要知道一組代理程式編號與一個檔案編號，就能把客戶的**源碼壓縮包**下載走；只要知道一個任務編號，就能把別人的掃描標成「成功」並塞進假的檢測結果。第四條是**越界發現**：另一支套件 `jedi-issue` 的歷史檔裡有 GitLab 與 GitHub 的存取權杖，至今仍能從任何一份 clone 取出。

**與先前各棒的關係**：三條身分問題與 R1 是同一個根因（控制面端點零認證），但**本棒查的是不同的端點、不同的資料**，不是重複；F4 的兩個端點（`ack`／`result`）R1 已報過（R1 的 F2＋F5，修正卡 CM-1595），**這次是從任務狀態機那一側再看到同一條路，標「重複 R1 F2/F5」**。GitLab 權杖那條與 R1b 的 F2 是**同一份歷史檔的不同兩把鑰匙**（R1b 那把是寫死在程式碼裡的，這把在 `.env` 裡），需要一起處理。

**卡片重點①「同租戶內冒充」的答案**：成立，而且比卡片預想的更寬——**跨租戶也成立**。2026-09-14 那次修改（`fc1cadb`）確實讓執行身分被關進正確的租戶，但它是「先用你自己報的編號去查你是哪一家，再切成那一家」——**查出來的租戶是攻擊者選的**，所以租戶邊界仍是被攻擊者控制的值決定。詳見 F2／F3。

## 這一棒在檢查什麼

客戶機房裡裝了一個代理程式（agent），負責在客戶內網跑弱點掃描。它要做事，得先跟雲端拿兩樣東西：**要掃的源碼壓縮包**、**檢測設定檔**。這一棒檢查三件事：

1. **代理程式來拿檔案時，雲端憑什麼決定給不給**（授權判定在 `agent_file_access_service.py`）。
2. **雲端主動連回客戶機房的代理程式時**（測試連線、取消掃描），有沒有確認對方是真的那台、路上有沒有加密。
3. **「哪一次執行派給哪一台代理程式」這張對照表**有沒有做好不同客戶之間的隔離。

掃描目標 `~/Projects/Jedicogy/module/jedi-python-package`（掃描根目錄是整個 monorepo，範圍鎖在 13 個檔），revision `651e33c2`（branch `feature/review`，工作區有未提交改動故 stamp 標 `-dirty`，該改動是刻意的套件路徑覆寫、與掃描內容無關），mode `scan`，effort `low`，focus `attack-surface`。範圍＝`jedi-detection` 的代理程式相關 11 檔，加上重疊帶入 `jedi-remote-agent` 的 2 檔（任務狀態的 domain service 與 repository），檔數啟動前已核對為 13。

## Coverage

13 個檔以單一元件讀完——低強度跑法不做元件盤點、不做威脅建模、不跑額外的廣度掃描，`completenessCheckOutcome` 為 `not-applicable`（範圍掃描本就不適用）。**這份報告完全不能拿來說 monorepo 其他地方沒事**，連 `jedi-detection` 非代理程式相關的檔都不在內。

派出兩位研究員、兩位都回報。驗證跑一輪，6 個候選去重後 5 個，15 票全投出，沒有候選遺失、沒有候選未被審、沒有候選被交到下一輪。**一個候選被 2:1 駁回**（測試連線的明文退路，見下方「被駁回的候選」）。

**有兩條發現的起點在範圍外**：F2 與 F3 的路由在主專案（`compliance-manager-be/api/remote_agent/routes/agent_file_route.py` 與 `core/plugins/remote_agent.py`），F4 的請求入口在 `jedi-remote-agent` 自己的 route 與 app service。研究員與檢查員為了確認整條路徑有讀這些檔，**但那些檔沒有被稽核**，不能算進覆蓋率。F1 更是完全在範圍外（`jedi-issue`），是研究員在 repo 內工作時順手撈到的。

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

工具沒有實際執行任何程式碼：沒跑測試、沒發請求、沒示範攻擊。四條發現全部是讀原始碼、讀 git 歷史、以及對 DEV 資料庫做唯讀查詢推出來的。

### 關於驗證章的狀態

stamp `CLAUDE-SECURITY-REVISION-651e33c20870-dirty.json` 蓋的是 `status: unverified`，拒收理由原話：`1 finding(s) were refused at render and are absent from this report: F1`。

**這不是面板失敗，是渲染那一步退件。** 與 R1b 同一種型態（交接文件自檢第 6 題的第三種）：F1 指的是 git 歷史裡的檔案，現在的工作目錄已經沒有它，渲染器的規則認不得歷史檔，就把那一條退掉、整份蓋 `unverified`。**面板本身完整跑完、15 票全投出**，F1 自己是 3:0 通過的。機器可讀的 JSONL／SARIF 只收了 3 條，人讀的 markdown 報告四條全在。

## 掃到什麼（總覽）

| # | 嚴重度 | 這是什麼問題（白話） | 出事會怎樣 | 要先有什麼才打得到 | 位置 | 修正卡 |
|---|--------|---------------------|-----------|------------------|------|--------|
| [F1](#f1) | 中等 | 兩把外部平台的存取權杖被明文提交進版控，刪檔沒改歷史，每份 clone 都還拿得到 | 拿到的人能以該帳號身分讀（可能寫）該帳號能碰的所有 GitLab／GitHub 專案 | 任何一份 monorepo 的 clone，或 Nexus 上的舊套件檔 | `jedi-issue/jedi_issue/.env:4`（歷史檔） | ✅ 已處置（GitLab／GitHub 權杖已撤銷，2026-09-20；SUMMARY M01-6） |
| [F2](#f2) | 中等 | 代理程式下載檔案的端點零認證，身分就是請求標頭自己寫的那行字；伺服器拿它繞過隔離查出租戶再切過去 | 不需任何帳號，把客戶的源碼壓縮包與檢測設定檔下載走，**跨客戶也成立** | 連得到 API＋知道一組代理程式編號與一個檔案編號＋該任務還沒結束 | `jedi-detection/.../agent_file_access_service.py:127` | ✅ 已修（CM-2052，與 CM-1595 同線） |
| [F3](#f3) | 中等 | 同一條路徑的授權判定面：判定依據仍是攻擊者控制的那個編號 | 同上（跨客戶外洩源碼包與檢測設定檔） | 同上 | `jedi-detection/.../agent_file_access_service.py:172` | ✅ 已修（CM-2052，同 F2） |
| [F4](#f4) | 中等 | 任務狀態轉換不檢查回報者是不是當初派工的那台 | 不需任何帳號，把別人的掃描標成「成功」並塞進偽造的檢測結果，真結果之後被擋掉 | 連得到 API＋知道一個還沒結束的任務編號 | `jedi-remote-agent/.../agent_task_domain_service.py:93` | ✅ 已修（CM-2052；原卡 CM-1595 作廢） |

## Findings

<a id="f1"></a>
### F1 — GitLab 與 GitHub 存取權杖明文留在版控歷史裡（中等，3:0，⚠️ 越界＝檔案屬 `jedi-issue`，不在任何一棒範圍內）

**這是什麼問題。** 一個開發者用的 `.env` 設定檔，裡面放著 GitLab 的存取權杖（`glpat-` 開頭）與 GitHub 的存取權杖（`ghp_` 開頭），在 2025-04-16（commit `b740989`）被提交進版控，直到 2026-07-25（commit `d6a8d09`）才刪掉。**但刪的只是檔案，沒有改寫歷史**——`git merge-base --is-ancestor d6a8d09 HEAD` 為真，且 `git ls-tree d6a8d09^` 仍解得出那個 blob，所以**每一份 clone 都還帶著這兩把明文鑰匙**。兩個值都符合各自平台的權杖格式、長度正確（26 與 40 字元），不是佔位符。

**出事會怎樣。** 拿到的人就以那個帳號的身分持有兩個平台的 API 存取權：能讀該帳號碰得到的所有專案源碼，若權杖範圍含寫入，還能往那些專案裡塞程式碼。暴露窗口約十五個月。更麻煩的是**刪除那筆 commit 自己的訊息寫著該檔曾被打包進發佈的 wheel**，所以值不只在 git 裡，也在內部 Nexus 上的舊套件檔裡。

**要先有什麼才打得到。**
- 拿得到 monorepo 的任何一份 clone，或內部 Nexus 上任何一個舊版 `jedi-issue` 套件檔。
- 歷史從未被改寫——已實證：`d6a8d09` 是 HEAD 的祖先，blob 解得出來。
- 刪除那筆 commit 的訊息聲稱權杖已撤銷，**這句話無法從 repo 驗證**，必須去兩個平台的稽核紀錄確認。

**在哪裡。** `jedi-issue/jedi_issue/.env:4`（GitLab）與同檔第 8 行（GitHub），存在於 `d6a8d09^` 之前的歷史。

**怎麼修。** **先去 gitlab.com 與 github.com 撤銷這兩把權杖，並調閱兩個帳號自 2025-04-16 起的稽核紀錄**——撤銷才是有效的控制，因為值已經散佈在每一份 clone 與已發佈的套件檔裡，清歷史是次要動作。程式那側維持既有的「呼叫端注入」寫法（`get_gitlab_client(private_token=...)`、`get_github_client(token=...)`），拿掉 `os.getenv` 的退路，並加一個提交前的密鑰掃描，讓 `glpat-`／`ghp_` 這類字串不可能再被提交。

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

**首腦核對註記與判定。** ⚠️ **越界發現**（檔案屬 `jedi-issue`，不在 FR-077 任何一棒的範圍內），但屬實。**與 R1b 的 F2 是同一份歷史檔的不同兩把鑰匙**——R1b 那把是寫死在 `.py` 程式碼裡的 GitLab 權杖（總表第 83 項），這裡是 `.env` 裡的 GitLab＋GitHub 兩把。**處置：併進總表第 83 項，把描述從「一把」擴為「同一支套件的歷史裡至少三把」，撤銷動作一次做完。** 不另計新項。

<a id="f2"></a>
### F2 — 代理程式下載檔案的端點零認證，租戶由攻擊者指定的編號決定（中等，3:0）

**這是什麼問題。** 客戶機房的代理程式來雲端下載「這次任務要掃的源碼壓縮包」時，雲端唯一用來判斷「你是哪一台」的依據，就是請求標頭裡的 `X-Agent-Uid` 這一行字——**那是請求自己寫的，誰都能寫**。而且這個端點**完全沒有掛任何認證**：主專案的路由 `agent_file_route.py:48` 只有文件註解與注入兩個裝飾器，沒有 `jwt_required`，掛載處 `core/plugins/remote_agent.py:132` 也是裸掛，繞過了套件自己那張有守門的路由表。

伺服器拿到這行字之後做兩步：先**提權**（繞過資料庫的客戶隔離）去查「這個編號是哪一家客戶的」，再切換成那一家的身分去撈檔案。**所以租戶邊界是由攻擊者填的那個值決定的。**

**出事會怎樣。** 不需要任何帳號、任何憑證，就能把客戶上傳的**源碼壓縮包**與**檢測設定檔**下載回來，連同檔名、大小、SHA-256 摘要。因為租戶是從攻擊者指名的那一列查出來的，**填別家客戶的代理程式編號，就讀到別家客戶的檔**——跨客戶成立。

**要先有什麼才打得到。**
- 連得到 `/api/1.0/agents/files/<uid>`。落地版的對外入口是 nginx，而出貨的三個設定檔（前端 repo 的 `nginx.onprem.conf`、`nginx.conf`、`nginx.e2e.conf`）**一行 `ssl_verify_client` 都沒有**（首腦 2026-09-16 重新 grep 確認，連 `scripts/installer/` 也沒有），所以程式註解裡「靠 mTLS 在上游證明身分」這個前提，在出貨的部署裡**根本沒有被執行**。
- 知道一組代理程式編號與一個檔案編號。兩者都是 UUID、猜不到，必須先取得——但它們對任何登入使用者都看得到（代理程式管理頁、檢測綁定的回應），而且產品自己的待辦紀錄寫著後端會把請求內容明文寫進 log。
- 該任務還在進行中（pending／dispatched／running）；任務結束後參照就失效。

**在哪裡。** `jedi-detection/jedi_detection/app/service/agent_file_access_service.py:127`，`AgentFileAccessService.get_file_for_agent`。上游路由在主專案 `api/remote_agent/routes/agent_file_route.py:48`、掛載在 `core/plugins/remote_agent.py:132`。

**怎麼修。** 不要再把請求標頭當成身分聲明。改成要求雲端本來就會簽的那種短效通行證（`jedi_remote_agent.common.agent_auth.jwt_util` 簽的 RS256 JWT），驗 `aud` 是否等於宣稱的代理程式編號、驗 `bound_fp` 是否等於存檔的裝置指紋，**代理程式編號從驗過的通行證裡取，不要從標頭取**。如果部署上真的打算靠 mTLS，那對外入口就必須真的開起來（`ssl_verify_client on` 加內部 CA），並把驗過的身分傳進應用層。在這兩者其中之一存在之前，**這個端點等於沒有認證**。

**驗證。** 3/3 三位檢查員一致確認成立，嚴重度中等。

**首腦核對註記與判定。** 屬實，逐檔開過，三處都對得上：路由 `:48` 確實只有 `@doc` 與 `@inject`；`_tenant_id_for_agent`（`:136-157`）確實在 `elevated_readonly_scope()` 裡查 `remote_agents`；`get_file_for_agent`（`:127`）確實拿查出來的租戶去 `tenant_context`。

**🔴 這條直接回答卡片重點①，而且答案比卡片預想的更寬。** 卡片說「租戶由伺服器查出、resolver 跑在受隔離身分下，那一半已成立；要驗的變成同租戶內冒充」——**但實查的結論是：查租戶那一步用的是攻擊者給的編號、且刻意繞過隔離，所以跨租戶並沒有被堵住。** 程式的 docstring 辯護「指認成別台也只拿得到那台名下任務的檔」這句話本身是對的，但它默認了「別台」只會是同一家客戶的別台——實際上填哪一家的編號，就切到哪一家。**卡片內「不要引用跨租戶已堵」這句提醒是正確的，本棒實證了它。**

**與 R1 的關係**：R1 報的是註冊／心跳／ack／result 四個控制面端點零認證，**這個檔案下載端點是第五個，R1 沒掃到**（它在 `jedi-detection` 且路由留在主專案）。同一根因、不同端點、不同資料（前者洩掃描工具帳密，這裡洩客戶源碼），**算新發現、不算重複**。

<a id="f3"></a>
### F3 — 同一條路徑的授權判定面：判定依據仍是攻擊者控制的編號（中等，2:1）

**這是什麼問題。** 與 F2 是**同一條請求路徑**，差別在報的位置：F2 報在「查租戶」那一步，F3 報在「決定給不給」那一步。授權判定長這樣——逐一問兩個判定器，任一個點頭就放行；判定器問的是「這個檔案編號，是不是這台代理程式名下還沒結束的任務正在引用的？」。問題在於「這台」是誰，仍然是攻擊者在標頭上寫的那個編號。

**出事會怎樣。** 跨客戶洩漏源碼壓縮包與檢測設定檔。因為查租戶那一步跑在提權範圍裡（把 `app.is_super_admin` 設成 `t`，等於暫時擁有超級管理員視角），**指名任何一家客戶的代理程式，就取得那一家的租戶身分**。

**要先有什麼才打得到。** 與 F2 相同：連得到那個端點（無認證裝飾器）、知道一組代理程式編號與一個 `upload_files` 編號、目標檔案正被該代理程式進行中的任務引用、部署未真正啟用 mTLS 客戶端驗證。

**在哪裡。** `jedi-detection/jedi_detection/app/service/agent_file_access_service.py:172`，`AgentFileAccessService._get_file_for_agent`（提權查詢在 `:147-151`）。

**怎麼修。** 與 F2 同一個修法：在 `agent_uid` 被用於任何判斷之前，先把請求綁到一個經密碼學驗證過的身分——短效通行證（驗 `aud == agent_uid`、`iss`、`exp`、`bound_fp`），或由代理伺服器傳下來的已驗證客戶端憑證；當 `AGENT_AUTH_MODE=full` 卻沒帶證明時直接拒絕。

**驗證。** 2/3 通過。**異議的那位檢查員認為第 172 行是防護而不是破口**——他的論點是：這一行如果兩個判定器都不認，就回 404，所以它擋住了「任意檔案讀取」。這個觀察是對的，但它針對的是「能不能拿任意檔」，而 F2／F3 講的是「能不能冒充成別人去拿他名下的檔」，兩者不衝突。研究員原報高風險，**面板降為中等**（兩張確認票都給中等）。因為只有 2 票通過，confidence 封頂在中等。

**首腦核對註記與判定。** 屬實。**與 F2 是同一條路徑的兩個切面，開修正卡時併成一條處理、不要開兩張。** 保留兩條分別呈現的理由：F2 說明「租戶怎麼被攻擊者選定」，F3 說明「授權判定為何攔不住」，修的時候兩處都要動（查租戶前先驗身分，判定時用驗過的身分）。

<a id="f4"></a>
### F4 — 任務狀態轉換不檢查回報者是不是當初派工的那台（中等，3:0，⚠️ 重疊帶入檔＝R2a 也在掃）

**這是什麼問題。** 代理程式跑完掃描後，會回報「這個任務做完了、結果在這裡」。接收這個回報的兩個端點（`/agents/tasks/<uid>/ack` 與 `/agents/tasks/<uid>/result`）在路由表裡標著 `needs_admin=False`，而掛載函式**只對標著 True 的項目加守門**——所以這兩個端點**一個裝飾器都沒有**。請求一路流進 `update_status`，那裡只檢查兩件事：任務存在嗎、這個狀態轉換合法嗎。**從頭到尾沒有任何一步檢查「你是不是當初被派這個工的那台」。**

**出事會怎樣。** 不需要任何帳號，就能把別家客戶的掃描任務標成「成功」，並附上自己挑的結果參照；或標成「失敗」，讓真正的掃描結果消失。更進一步：偽造的結果參照裡的檔案編號，之後會被拼進代理程式的取檔路徑（`detection_result_handler._fetch_blob` 的 `path = f"/blob/{upload_uid}"`），取回來的內容會被當成**合規稽核的證據**掛進案子裡。真正的代理程式稍後回報時，會因為「任務已在終態、轉換不合法」而被擋掉——**假的先到就贏**。

**要先有什麼才打得到。**
- 連得到 `/api/1.0/agents/tasks/<uid>/ack` 或 `/result`（兩者都無認證）。
- 知道一個還在 pending／dispatched／running 的任務編號。
- 資料流的起點在本棒範圍外（`jedi_remote_agent/api/routes/remote_agent_route.py` 與 `app/service/agent_task_service.py`，同套件）。

**在哪裡。** `jedi-remote-agent/jedi_remote_agent/domain/agent_task/service/agent_task_domain_service.py:93`，`AgentTaskDomainService.update_status`。路由表在同套件 `api/routing.py:39-40`，掛載邏輯在 `:95`。

**怎麼修。** 把驗證過的代理程式身分（短效通行證的 `aud`／`bound_fp`，或已驗證的客戶端憑證）傳進 `AgentTaskService.ack_task` 與 `receive_result`，在呼叫 `update_status` 之前先斷言 `task.agent_id` 與它相符。或者把「歸屬」變成 `update_status` 的必要參數，讓任何呼叫端都不可能轉換一個不屬於它的任務。

**驗證。** 3/3 三位檢查員一致確認成立，嚴重度中等。

**首腦核對註記與判定。** ⚠️ **重疊帶入檔（R2a 也在掃）**，且**這兩個端點 R1 已經報過**——R1 的 F2＋F5「ack／result 無認證、不查歸屬」，修正卡 CM-1595 已開。**本條標「重複 R1 F2/F5」，不另計新項。**

但這次有增量價值，值得寫進 CM-1595：R1 是從**端點**那一側看到的，本棒是從**狀態機**那一側看到的，證實了「即使有人之後在端點補上認證，`update_status` 這一層仍然不檢查歸屬」——所以修法不能只在端點掛裝飾器，**必須把身分一路傳到 domain service 做歸屬斷言**，否則第二個呼叫路徑（例如未來新增的批次補登、維運工具）會再次繞過。

**卡片重點⑦問的「授權窗口能不能被延長」**：見下方人工查證段。

## 被駁回的候選（2:1，不計入發現）

**測試連線的明文退路**（`agent_probe_client.py:83`）。研究員報的是：當代理程式認證處於關閉狀態（`mode=none`）時，`_post` 走的是裸 `httpx.post`，沒有加密驗證也沒有帶通行證，而請求內容裡帶著檢測工具的明文帳密。

兩位檢查員駁回，理由一致且已由首腦覆核為**正確**：安裝程式**強制**把 `AGENT_AUTH_MODE` 設成 `full`——`scripts/installer/install.sh:1657` 在全新安裝時寫入，升級時走 `:1645` 的分支（已有就保留、沒有就補上），`.env.sample:157` 也是 `full`。所以出貨的部署不會停在 `none`。

**首腦補充兩點**（駁回成立，但要留紀錄）：
1. **程式碼的預設值是 `none`**（`config/config.py:257` 的 `os.getenv("AGENT_AUTH_MODE", "none")`），安全是靠安裝程式補上去的，不是靠程式本身。開發環境、手動部署、或任何沒跑過 `install.sh` 的環境，都會停在關閉狀態。
2. `install.sh:1645` 的分支是「設定檔裡已經有這個值就保留現值不覆寫」——**如果某台機器的設定檔在更早以前被寫成 `none`，升級不會把它改回 `full`**。這是設計上刻意的（不覆寫客戶設定），但意味著「升級過的機器一定是 full」這句話不成立。

兩點都不推翻駁回（出貨路徑確實是 `full`），列在這裡供修正卡評估時參考。

## 卡片重點逐項人工查證

🔴 **低強度只跑兩位研究員，卡片列的七個重點只碰到其中三個（①②⑦的一部分）。其餘由首腦回頭開檔、連 DEV 資料庫唯讀查證補上。**

### ① 檔案授權論證的後半——同租戶內冒充（**工具已報＝F2／F3**）

**結論：成立，且範圍比卡片預想的更寬（跨租戶也成立）。** 詳見 F2 首腦註記。

補答卡片問的「那台名下的檔本身值不值得偷」：兩個判定器認的分別是 `params["_source_file"].uid`（**客戶上傳的源碼壓縮包**）與 `params["_profile"].uid`（**檢測設定檔**）。源碼包是客戶自己的程式碼，價值明確。門檻是「知道一台代理程式的編號」，而編號出現在：代理程式管理頁（任何登入者可見）、代理程式主機自己的設定檔、後端請求 log（產品自己的待辦紀錄已載明 log 會印明文請求內容）、以及註冊與心跳的回應。

### ② 提權查詢是不是跨租戶探測管道（**工具未報，人工查證**）

**結論：不成立，這一點程式做對了。**

逐行核對 `_tenant_id_for_agent`（`:136-157`）：編號為空 → 404；查無 → 404；狀態為 `revoked` → 404；`tenant_id` 為 `None` → 404；提權範圍不可用（`ElevatedSessionUnavailable`）→ 404。往下走之後，判定器全部不認 → 也是 404（`:172-177`）。**五種失敗回同一個錯誤碼 `DETECTION_TOOL_AGENT_FILE_NOT_FOUND`，不區分「不存在」與「無權限」**，docstring 明文寫著這是刻意的。

時間差方面：查無的路徑比通過的路徑**少**跑一次租戶切換與最多兩次任務查詢，理論上存在時間差，但要用它來區分「這個編號是不是一台活的代理程式」需要穩定的計時樣本，而真正的差異（一次 DB 查詢）遠小於網路抖動。**不構成實用的探測管道，不另報。**

### ③ 判定器的擴充性與 `params` 可控性（**工具未報，人工查證**）

**結論：這條路走不通，但理由不是守門，是「任務不是使用者建的」。**

`_resolvers` 確實是一個清單、兩個判定器都比對 `params[key].uid`（`:77-93`）。卡片問「有管理面權限的人能不能把任意 `upload_files.uid` 塞進某張任務的 `params`」——實查：`agent_tasks` 的 `params` **不是由任何對外 API 直接寫入的**，它是在檢測工單展開時由服務端組出來的（源碼包編號來自該次執行綁定的上傳檔、profile 編號來自綁定的檢測設定），沒有一個端點接受使用者提供的 `params` 整包。

**所以「自己給自己開授權」需要先有能直接寫 `agent_tasks.params` 的能力，那已經是資料庫層級的存取**，不是這條路徑的問題。不另報。

**但順帶更正卡片背景一處**（見下方⑥）。

### ④ 未接線靜默降級（**工具報了一半——候選被駁回，首腦補完**）

**結論：駁回正確（出貨是 `full`），但有兩個值得記錄的邊角，已寫在「被駁回的候選」段。**

逐項回答卡片問的：
- **`_DisabledSettings` 確實等同不加認證**（`agent_auth.py:47-51`，`enabled = False`、`mode = "none"`），警告只發一次（`_warned_not_configured` 旗標，`:57-68`）。
- **三個消費點在關閉狀態下退化成什麼**：`agent_probe_client._post:83` 與 `agent_cancel_client._post:68` 都是 `httpx.post(url, ...)` **裸呼叫、不驗憑證、不帶通行證**；且兩支開頭那道「認證開啟時必須是 https」的檢查（probe `:45-47`、cancel `:44-46`）**本身就掛在 `enabled` 之下**，所以關閉狀態下連 https 都不強制——**可以是明文 http**。打的位址是 `agent.base_url`，那是代理程式**自己註冊時報上來的**，所以確實存在伺服器端請求偽造的面（與 R1 F4 `check_health` 同型，修正卡 CM-1596）。
- **安裝程式有沒有強制 `full`**：有，`install.sh:1657`（全新安裝）與 `:1645` 的保留分支（升級）。**但程式碼預設值是 `none`**（`config/config.py:257`）。

### ⑤ 兩個連線器的出站請求（**工具未報成立發現，人工逐支查證**）

**結論：兩支形狀完全一致，認證開啟時做對了，關閉時兩支都退化成裸明文。**

| 檢查項 | `agent_probe_client.py` | `agent_cancel_client.py` |
|--------|------------------------|--------------------------|
| 開啟時走 mTLS | ✅ `build_cloud_mtls_context(settings)`（`:92`） | ✅ 同（`:77`） |
| 開啟時簽通行證 | ✅ `jwt_util.mint(...)`（`:86-90`） | ✅ 同（`:71-75`） |
| 關閉時 | ⚠️ 裸 `httpx.post`（`:83`） | ⚠️ 裸 `httpx.post`（`:68`） |
| 位址怎麼組 | `agent.base_url` + 固定路徑，**base_url 由代理程式自報** | 同 |
| 逾時 | ✅ 70 秒（有設，且註解說明理由） | ✅ 30 秒 |
| 例外有沒有被吞 | ⚠️ `httpx.HTTPError` 與 `ValueError` 都被接住轉成 `success=False`，**但有寫 log**（warning／error），呼叫端拿得到失敗訊號 | 同，且檔頭註明「取消失敗必須讓呼叫端知道」 |

**FR-039 那條規矩（所有雲端→代理程式的呼叫要走 mTLS＋JWT）兩支都遵守了**，卡片擔心的「第三處裸 httpx」**不成立**——兩支的裸呼叫都只在認證關閉時才走到，與既有的 `check_health` 同型。**沒有新發現。**

### ⑥ `job_execution_detection_tool_agent` 這條線（**工具未報，人工查證＋DEV 唯讀查詢**）

**結論：隔離做好了，但 soft-ref 的疑慮成立、影響有限。**

- **有沒有帶租戶**：model 確實繼承 `TenantScopedMixinModel`（`model/job_execution_detection_tool_agent.py:12`）。DEV 唯讀查詢（2026-09-16）：`job_execution_detection_tool_agents` 的 `relrowsecurity = t`（隔離已開、未開強制模式），有一條 `jedta_tenant_isolation` policy 涵蓋全部操作（`ALL`），內容是「超級管理員逃生門 OR 本客戶」。**隔離是有的。**
- **repository 層**：`list_by_binding_ordered` 的 `tenant_id` 是**選填參數**，沒傳就不加這個條件——但因為資料庫層隔離已開，沒傳時仍由 RLS 擋住，不是破口。
- **`agent_uid` 是 soft-ref**：model 的欄位註解明說「跨 schema 不建外鍵，有效性驗證由應用層負責」。**跨租戶的編號塞進來會怎樣**：這張表本身有租戶隔離，所以塞進來的那一列會被記在攻擊者自己的租戶下；展開成工單時會去查那台代理程式，而查詢同樣受隔離擋住 → 查不到 → 工單派不出去。**結果是自己的工單壞掉，不是拿到別人的東西。不另報。**

**🔴 順帶更正卡片背景一處**：卡片寫「跨 arc 總表第 47 項：`upload_files` 零隔離、無租戶欄」。DEV 唯讀實查（2026-09-16）：`upload_files` **有** `tenant_id` 欄、`relrowsecurity = t`、四條 policy（select／insert／update／delete）齊全，select 的條件是「超級管理員 OR 本客戶 OR `storage_scope='system'`」。**「零隔離」這個描述已經過期**（推測是 2026-09-16 當天稍早的 commit `a48b41cc`「背景與系統上傳補租戶身分，加 upload_files RLS 守衛測試」補上的）。總表第 47 項需要重新確認狀態。

**這不影響 F2／F3 的成立**——那兩條的問題是「租戶身分被攻擊者選定」，隔離本身有沒有開不是重點，因為攻擊者是**合法地切進了目標租戶**。

### ⑦ 重疊帶入兩支——與檔案授權的接縫（**工具已報 F4，接縫問題人工查證**）

**結論：查詢條件做對了；授權窗口確實可被延長，但延長者只能是那台代理程式自己。**

- **`list_for_agent_by_status(agent_id, tenant_id, status)` 真的同時用兩個條件嗎**：✅ 是。`agent_task_domain_service.py:146-155` 把三個值一起包進 `AgentTaskQueryEntity(agent_id=..., tenant_id=..., status=...)` 交給 `get_all_by_fields`，三個條件都會進 WHERE。而且 `agent_tasks` 表的 RLS 也開著（DEV 實查 `relrowsecurity = t`，一條 `agent_tasks_tenant_isolation` 涵蓋 `ALL`）。**這一步不是破口。**
- **`_ACTIVE_TASK_STATUSES` 的授權窗口能不能被延長**：窗口＝`pending`／`dispatched`／`running` 三種狀態（`agent_file_access_service.py:56`）。要讓任務永遠不進終態，就是永遠不回報 `succeeded`／`failed`／`cancelled`——**而回報是那台代理程式自己的動作，它只要不回報，窗口就一直開著**。程式裡沒有看到任何逾時清理機制把久未回報的任務強制收尾。

  **但這個「延長」的價值有限**：窗口開著只代表「該任務引用的那幾個檔」持續可下載，不會擴大到別的檔。而且配合 F4，**任何人都能替它回報終態把窗口關掉**（這反而是另一個問題）。**不另報為獨立發現**，但建議寫進 F2／F3 的修正卡：加一個任務逾時機制，讓授權窗口有上界。

## What was verified

兩位研究員讀完 13 個檔案、提報 6 個候選（去重後 5 個），接著由固定的三位檢查員（分別從「打不打得到」「影響有多大」「有沒有防護擋住」三個角度）投票，一輪共 15 票全數投出。F1、F2、F4 三票全過；F3 兩票過、一票異議（異議在「第 172 行算破口還是防護」，不在程式路徑本身），因此 F3 的把握度封頂在中等。一個候選被 2:1 駁回並經首腦覆核成立（測試連線的明文退路，被安裝程式的預設值擋住）。面板把 F1 與 F3 的嚴重度從研究員原報的「高」降為「中等」，**本報告採用面板的最終評級，不還原**。

驗證章 `unverified`，原因是渲染那一步退掉了 F1（它指向的檔案只存在於 git 歷史、不在現在的工作目錄），**不是面板失敗**；機器可讀格式只含 3 條，本報告四條齊全。

沒有任何程式碼被執行：沒跑測試、沒發請求、沒示範攻擊。所有結論來自讀原始碼、讀 git 歷史、以及對 DEV 資料庫的唯讀查詢。

---

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