FR-056.3 Agent 執行能力擴充 Implementation Plan
For agentic workers / runner: REQUIRED SUB-SKILL: superpowers:subagent-driven-development / executing-plans.
- [ ]checkbox syntax.依賴:FR-056.1(需 detection_tools 目錄)。可與 FR-056.2 並行。跨兩 repo:BE + evidence-agent。 母案設計見
design.md。
Goal: 讓客戶端 Agent 能領到掃描派工、執行 OpenVAS、回傳報告。雲端建 agent_tasks 派工表 + 狀態機 + 心跳夾帶待辦 + 結果回收 endpoint;Agent 端(evidence-agent repo)在心跳迴圈接 executor + OpenVAS connector。此階段做到「手動塞一筆派工 → Agent 跑完回報」全鏈通,不接 UI(UI 編排在 FR-056.4)。
Architecture: Pull 模式——Agent 主動心跳、雲端回應夾帶 pending_tasks、Agent 領走執行、再用獨立 result endpoint 回傳。認證全沿用既有 mTLS(agent→cloud 方向純 mTLS,不掛 user JWT,與既有 heartbeat/register 一致)。雲端狀態機 mirror ssp_excel_parse_job 的 update_status() + 語意 wrapper。Agent executor 與 OpenVAS connector 是全新模組(evidence-agent 內零 subprocess/外部 API 先例)。
Tech Stack: BE 同前;evidence-agent = Flask + httpx(mTLS client 已有);OpenVAS 整合走其 API(GVM/gvm-tools)或 CLI,第一版擇一。
前置:關鍵發現與決策(runner 必讀)
| 項目 | 定案 | 來源 |
|---|---|---|
| 派工模式 | Pull(心跳夾帶待辦 + result endpoint),非雲端主動推 | D3 |
| agent→cloud 認證 | 純 mTLS,route 不掛 @jwt_required()(同既有 heartbeat/register) |
Explore |
| 心跳改法 | BE heartbeat() return 前插 pending_tasks 查詢;Agent _heartbeat_once 改 return resp.json() 交 executor |
Explore |
| executor 執行緒 | 另開 thread(ThreadPoolExecutor/individual thread),不阻塞心跳迴圈 |
Explore |
| 狀態機範本 | ssp_excel_parse_job_domain_service 的 update_status() + mark_running/write_result/write_error wrapper |
Explore |
| agent_tasks 狀態 | pending → dispatched → running → succeeded / failed |
design §5 |
| executor 走不走 DI | evidence-agent 心跳邏輯目前是 plain module(非 DI);executor 維持 plain module 一致,除非要暴露 route | 見 Task 4 |
| 憑證下發(D9 定案) | (a) 雲端解密後隨派工下發(走 mTLS 通道)。雲端在 list_pending_for_agent 組派工時,把該租戶 tenant_detection_tool_configs 憑證解密放進 task payload。不採 (b) Agent 自持——要客戶在 Agent 端自設不實際,雲端 UI 統一設定才符合產品體驗 |
user 拍板 2026-07-26 |
| OpenVAS 整合(D10 定案) | 走 API(python-gvm,GVM protocol),非 CLI。程式化控制掃描比解析 CLI 輸出穩定;FR-056.1 seed 的 connection_type=API 一致 |
user 拍板 2026-07-26 |
⚠️ 全新模組警示: evidence-agent repo 內 grep subprocess/requests 皆無,只有 core/enroll.py 用 httpx 打雲端。executor + OpenVAS connector 沒有可抄的先例,是綠地。唯一可參考的 HTTP client 慣例是 core/enroll.py 的 httpx.Client(cert=..., verify=...)。
Task 分佈(對應 design T-3.x)
| Task | 對應 | 產出 | 依賴 | Repo |
|---|---|---|---|---|
| Task 1 | T-3.1 | agent_tasks 表 + DDD 全層 + 狀態機 + Agent capability | FR-056.1 | BE |
| Task 2 | T-3.2 | 心跳夾帶待辦 + ack + 結果回收 endpoint | Task 1 | BE |
| Task 3 | T-3.3 | Agent executor 模組骨架(領任務/回報/送 result) | Task 2 | evidence-agent |
| Task 4 | T-3.4 | OpenVAS connector(API/CLI) | Task 3 | evidence-agent |
Task 1(T-3.1): agent_tasks 表 + DDD 全層 + 狀態機
Files:
- Create: scripts/sql/2026-07-26-fr056-3-agent-tasks.sql
- Create: infra/domain/app 全層 agent_task 模組(複製 remote_agent 骨架)
- Modify: infra/remote_agent/model/remote_agent.py(加 capabilities 欄位標記能執行掃描)
- Create: scripts/sql/2026-07-26-fr056-3-remote-agents-capabilities.sql(remote_agents 加欄位)
- Test: test/test_agent_task_service.py
- Step 1: migration 建 agent_tasks + remote_agents 加 capabilities
agent_tasks 放 compliance schema(與 remote_agents 同 schema,派工是 agent 領域):
-- Date: 2026-07-26
-- FR-056.3 Agent 派工表 + 狀態機
CREATE TABLE compliance.agent_tasks (
id BIGSERIAL PRIMARY KEY,
uid VARCHAR(36) NOT NULL UNIQUE,
tenant_id BIGINT NOT NULL,
agent_id BIGINT NOT NULL, -- soft-ref → compliance.remote_agents.id
job_execution_uid VARCHAR(50), -- soft-ref → 對應稽核任務(FR-056.4 才填,本階段可 null)
detection_tool_id BIGINT NOT NULL, -- soft-ref → config.detection_tools.id
params JSONB NOT NULL DEFAULT '{}'::jsonb, -- 掃描參數快照
status VARCHAR(20) NOT NULL DEFAULT 'pending', -- pending/dispatched/running/succeeded/failed
result_ref JSONB, -- 結果檔參照(report upload uid 等)
error_message TEXT,
dispatched_at TIMESTAMPTZ,
started_at TIMESTAMPTZ,
finished_at TIMESTAMPTZ,
created_user VARCHAR(255), updated_user VARCHAR(255),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
COMMENT ON TABLE compliance.agent_tasks IS 'FR-056.3 Agent 掃描派工單 + 狀態機';
GRANT SELECT, INSERT, UPDATE, DELETE ON compliance.agent_tasks TO cm_app;
GRANT USAGE, SELECT ON SEQUENCE compliance.agent_tasks_id_seq TO cm_app;
ALTER TABLE compliance.agent_tasks ENABLE ROW LEVEL SECURITY;
CREATE POLICY agent_tasks_tenant_isolation ON compliance.agent_tasks
USING (current_setting('app.is_super_admin', TRUE) = 'true'
OR tenant_id = ANY (string_to_array(current_setting('app.allowed_tenant_paths', TRUE), ',')::BIGINT[]));
INSERT INTO public.schema_migrations(filename, note) VALUES
('2026-07-26-fr056-3-agent-tasks.sql', 'FR-056.3 Agent 派工表') ON CONFLICT (filename) DO NOTHING;
另一支 migration 給 remote_agents 加 capabilities(標記哪些 agent 能執行掃描):
-- Date: 2026-07-26
-- FR-056.3 remote_agents 加 capabilities(agent 能力標記,如 ["file_storage","detection_scan"])
ALTER TABLE compliance.remote_agents ADD COLUMN IF NOT EXISTS capabilities JSONB NOT NULL DEFAULT '["file_storage"]'::jsonb;
COMMENT ON COLUMN compliance.remote_agents.capabilities IS 'FR-056.3 agent 能力清單,派掃描任務只給含 detection_scan 者';
INSERT INTO public.schema_migrations(filename, note) VALUES
('2026-07-26-fr056-3-remote-agents-capabilities.sql', 'FR-056.3 remote_agents 加 capabilities') ON CONFLICT (filename) DO NOTHING;
套進 DEV + psql 驗證。
-
[ ] Step 2: DDD 全層 agent_task 模組(複製 remote_agent 骨架,tenant-scoped)。
-
[ ] Step 3: 狀態機 domain service(mirror ssp_excel_parse_job)
domain/agent_task/service/agent_task_domain_service.py,統一入口 + 語意 wrapper:
def update_status(self, uid, status, user, **fields):
entity = AgentTaskEntity(uid=uid, status=status, updated_user=user, **fields)
return self._repo.update(entity)
def mark_dispatched(self, uid, user):
return self.update_status(uid, "dispatched", user, dispatched_at=datetime.now())
def mark_running(self, uid, user):
return self.update_status(uid, "running", user, started_at=datetime.now())
def write_success(self, uid, result_ref, user):
return self.update_status(uid, "succeeded", user, result_ref=result_ref, finished_at=datetime.now())
def write_failure(self, uid, error_message, user):
return self.update_status(uid, "failed", user, error_message=error_message, finished_at=datetime.now())
def list_pending_for_agent(self, agent_id, tenant_id):
return self._repo.get_all_by_fields(AgentTaskQueryEntity(agent_id=agent_id, tenant_id=tenant_id, status="pending"))
-
[ ] Step 4: 寫 failing test(狀態轉換 + list_pending) → FAIL → 實作 app service(
@transaction包)→ PASS。 -
[ ] Step 5: Commit
git commit -m "feat(fr056): agent_tasks table + state machine + agent capabilities (T-3.1)"
Task 2(T-3.2): 心跳夾帶待辦 + ack + 結果回收 endpoint
Files:
- Modify: app/remote_agent/service/agent_enrollment_service.py(heartbeat 回應加 pending_tasks)
- Modify: api/remote_agent/routes/remote_agent_route.py(加 ack + result route)
- Modify: api/remote_agent/__init__.py(掛新 route)
- Modify: api/remote_agent/serializers/(result payload schema)
- Test: test/test_agent_heartbeat_dispatch.py
-
[ ] Step 1: 寫 failing test(心跳回應含 pending_tasks + result 回收改狀態) → FAIL。
-
[ ] Step 2: heartbeat 回應夾帶待辦
app/remote_agent/service/agent_enrollment_service.py 的 heartbeat(),在 return {"status": "ok"} 前改(注意此 method 已在 @transaction + 顯式帶 tenant_id):
pending = self._agent_task_domain.list_pending_for_agent(agent.id, agent.tenant_id)
tasks = []
for t in pending:
# D9:解密該租戶工具憑證,隨派工下發(走 mTLS 通道,Agent 用完即丟不落地)
creds = self._resolve_tool_credentials(t.detection_tool_id, agent.tenant_id) # 查 tenant_detection_tool_configs → crypto.decrypt
tasks.append({"uid": t.uid, "detection_tool_id": t.detection_tool_id,
"params": t.params, "credentials": creds})
return {"status": "ok", "pending_tasks": tasks}
constructor 注入 agent_task_domain_service + tenant_config_domain_service + crypto(DI 加)。_resolve_tool_credentials 查該租戶對應工具的 tenant_detection_tool_configs、crypto.decrypt(credentials_encrypted) 回明文 dict。
D9 安全註記: 憑證只在 mTLS 加密通道下發,Agent 端不持久化(executor 用完即丟)。雲端仍是憑證唯一保管處(加密存放),符合「租戶在雲端 UI 統一設定」的產品體驗。
- Step 3: ack + result endpoint(不掛 jwt,純 mTLS)
api/remote_agent/routes/remote_agent_route.py 加兩支(mirror AgentHeartbeatRoute,無 @jwt_required()):
class AgentTaskAckRoute(MethodResource):
@inject
def post(self, uid, agent_task_service=Provide[...]):
# agent 領到任務後 ack → dispatched
return return_response(True, agent_task_service.ack_task(uid))
class AgentTaskResultRoute(MethodResource):
@inject
def post(self, uid, agent_task_service=Provide[...]):
payload = request.get_json(silent=True) or {}
# payload: {status: "succeeded"|"failed", result_ref?: {...}, error_message?: str}
# 報告檔上傳走既有 blob 資料面(Agent 端已有),此處只收結果 metadata
return return_response(True, agent_task_service.receive_result(uid, payload))
掛進 api/remote_agent/__init__.py:
api.add_resource(AgentTaskAckRoute, '/agents/tasks/<string:uid>/ack')
api.add_resource(AgentTaskResultRoute, '/agents/tasks/<string:uid>/result')
報告檔怎麼傳: Agent 端已有 blob 上傳能力(
/blob*資料面)。掃描報告先走既有 blob 通道上傳成 upload_file,Agent 再把回傳的 upload uid 放進 result payload 的result_ref。FR-056.3 不新建檔案傳輸通道,複用既有 blob。FR-056.4 的轉證據 handler 再把這個 upload 轉成 job_evidences。
- Step 4: app service
ack_task/receive_result(@transaction,呼叫 Task 1 的狀態 wrapper):
@transaction
def ack_task(self, uid):
return self._domain.mark_dispatched(uid, "agent")
@transaction
def receive_result(self, uid, payload):
if payload.get("status") == "succeeded":
return self._domain.write_success(uid, payload.get("result_ref"), "agent")
return self._domain.write_failure(uid, payload.get("error_message") or "unknown", "agent")
FR-056.4 掛鉤點:
receive_result成功時,FR-056.4 會在此觸發「轉證據 + 通知 + 完成模式分岔」。本階段先只更新狀態,留 TODO(FR-056.4) 註記。
- Step 5: 跑 test 通過 + 手測整鏈
手測(脫離 UI,直接塞派工驗證):
# 1. 手動 INSERT 一筆 agent_tasks(pending)到 DEV
# 2. 模擬 agent 心跳(mTLS)→ 應回 pending_tasks 含該筆
# 3. 模擬 ack → status 變 dispatched
# 4. 模擬 result(succeeded)→ status 變 succeeded
# 每步 psql 查 status 確認
- Step 6: Commit
git commit -m "feat(fr056): heartbeat task dispatch + ack + result endpoints (T-3.2)"
Task 3(T-3.3): Agent executor 模組骨架(evidence-agent repo)
此 Task 在
evidence-agentrepo~/Projects/Billows/Audit-Manager/evidence-agent/。全新模組,無先例可抄,唯一參考core/enroll.py的 httpx mTLS client 慣例。
決策點(runner 動工前定,或問 user): executor 走不走 DI 容器?evidence-agent 現況心跳是 plain module(core/enroll.py function-based,未走 DI)。建議:executor 維持 plain module 與心跳一致(心跳迴圈直接 import 呼叫),除非之後要暴露「手動觸發」route 才需 DI wire。本計畫按 plain module 寫。
Files:
- Create: core/task_executor.py(領任務 → 呼叫 connector → 回報)
- Modify: core/enroll.py(_heartbeat_once 讀 response、_heartbeat_loop 交給 executor)
- Create: common/code/agent_task_error_code.py(若 repo 有 error code 慣例,比照既有 common/code/agent_error_code.py)
- Step 1:
_heartbeat_once改回傳 response body
core/enroll.py:
def _heartbeat_once(cfg, device_uuid, state) -> dict:
...
resp = client.post(url, json={...})
resp.raise_for_status()
return resp.json() # 原本丟棄,改回傳
- Step 2:
_heartbeat_loop接 executor(fire-and-forget thread)
from core.task_executor import dispatch_tasks
...
def _heartbeat_loop(cfg, device_uuid, state):
while True:
try:
body = _heartbeat_once(cfg, device_uuid, state)
tasks = body.get("pending_tasks") or []
if tasks:
dispatch_tasks(tasks, cfg, state) # 內部另開 thread,不阻塞迴圈
except httpx.HTTPStatusError as e:
if e.response.status_code == 404:
# 既有重註冊邏輯
...
time.sleep(cfg.heartbeat_interval)
- Step 3:
core/task_executor.py骨架
import json, threading
import httpx
from core.task_executor_connectors import get_connector # Task 4 提供
def dispatch_tasks(tasks, cfg, state):
for task in tasks:
threading.Thread(target=_run_one, args=(task, cfg, state), daemon=True).start()
def _run_one(task, cfg, state):
task_uid = task["uid"]
_ack(task_uid, cfg, state) # → dispatched
try:
connector = get_connector(task["detection_tool_id"], task["params"]) # Task 4
report_bytes = connector.run() # 執行掃描(阻塞在這個 thread,不影響心跳)
upload_uid = _upload_report(report_bytes, cfg, state) # 走既有 blob 通道
_result(task_uid, {"status": "succeeded", "result_ref": {"upload_uid": upload_uid}}, cfg, state)
except Exception as e:
_result(task_uid, {"status": "failed", "error_message": str(e)}, cfg, state)
def _ack(task_uid, cfg, state):
_post(f"/api/1.0/agents/tasks/{task_uid}/ack", {}, cfg, state)
def _result(task_uid, payload, cfg, state):
_post(f"/api/1.0/agents/tasks/{task_uid}/result", payload, cfg, state)
def _post(path, payload, cfg, state):
url = f"{cfg.cloud_endpoint}{path}"
with httpx.Client(cert=(state.cert_file, state.key_file), verify=state.ca_file, timeout=60) as client:
r = client.post(url, json=payload); r.raise_for_status(); return r.json()
def _upload_report(report_bytes, cfg, state):
# 走既有 blob 資料面上傳(參考 api/blob 的既有上傳;回傳 upload uid)
...
_upload_report細節 runner 依 evidence-agent 既有 blob 上傳實作補(既有能力,非新建)。
- Step 4: 手測(mock connector)
先用假 connector(回固定 bytes)驗證整鏈:塞派工 → agent 心跳領到 → ack → 假掃描 → 上傳 → result → 雲端 status succeeded。connector 真身在 Task 4。
- Step 5: Commit(evidence-agent repo,顯式 git add)
git commit -m "feat(fr056): agent task executor skeleton (T-3.3)
心跳迴圈接 executor:領 pending_tasks → thread 執行 → ack/result 回報。connector 為 Task 4。"
Task 4(T-3.4): OpenVAS connector(evidence-agent repo)
全新模組,evidence-agent 無 subprocess/外部 API 先例。 OpenVAS 走 API(python-gvm,GVM protocol)(D10 定案),非 CLI。
Files:
- Create: core/task_executor_connectors/__init__.py(get_connector factory)
- Create: core/task_executor_connectors/base.py(connector 介面)
- Create: core/task_executor_connectors/openvas.py(OpenVAS 實作)
- Step 1: connector 介面(三層 adapter 的介面層)
core/task_executor_connectors/base.py:
from abc import ABC, abstractmethod
class DetectionConnector(ABC):
def __init__(self, params: dict, credentials: dict):
self.params = params
self.credentials = credentials
@abstractmethod
def run(self) -> bytes:
"""執行檢測,回傳報告原始 bytes(XML/PDF)。失敗 raise。"""
憑證怎麼到 agent(D9 定案=(a)): 雲端在 Task 2 的
list_pending_for_agent組派工時,把該租戶tenant_detection_tool_configs.credentials_encrypted解密後放進 task payload(走既有 mTLS 加密通道下發)。Agent 執行時從 payload 取憑證用完即用,不落地持久化。get_connector收到的credentials就來自 payload。此決定已回寫 Task 2 Step 2 的 pending_tasks 組裝(需在心跳查詢後補解密步驟)。
- Step 2: factory
get_connector(detection_tool_id, params, credentials) 依工具 code 回對應 connector。第一版只有 openvas。
- Step 3: OpenVAS connector 實作
core/task_executor_connectors/openvas.py——用 python-gvm(GVM protocol)連 OpenVAS:連線 → 建 target(掃描 IP/範圍來自 params)→ 建 task → 啟動 → 輪詢完成 → 取報告 XML → 回 bytes。逾時/連線失敗 raise。
依賴: evidence-agent
pyproject.toml要加python-gvm(或走 CLI 就不用)。這是新依賴,runner 加。
- Step 4: 手測整鏈(真 OpenVAS)
塞派工(含真實掃描目標)→ agent 領到 → 連真 OpenVAS 跑掃描 → 報告上傳 → 雲端 status succeeded + result_ref 有 upload uid。這是 FR-056.3 的最終驗收:脫離 UI 的完整 dispatch→scan→result 鏈通。
- Step 5: Commit(evidence-agent repo)
git commit -m "feat(fr056): OpenVAS connector (T-3.4)"
完成後收尾(等 user 下令)
- STG/POC 套 agent_tasks + capabilities migration。
- evidence-agent 部署到客戶端測試機(含 python-gvm 依賴)。
- FR-056.4 掛鉤:
receive_result成功時觸發轉證據(本階段留的 TODO(FR-056.4))。 - Notion FR-056.3 子卡標「修正待驗證」+ 回寫。
決策狀態(D9/D10 已定案)
- 憑證下發到 Agent → D9 定案:(a) 雲端解密後隨派工下發(走 mTLS,Agent 用完即丟不落地)。 已回寫 Task 2 Step 2 的 pending_tasks 組裝。(原因:要客戶在 Agent 端自設不實際,雲端 UI 統一設定才符合產品體驗。)
- OpenVAS 整合方式 → D10 定案:API(python-gvm,GVM protocol)。
- executor DI vs plain module(Task 3):維持 plain module 與既有心跳一致——實作風格,runner 逕行,非阻塞決策。
附錄:範本來源
- 狀態機:
domain/oscal/service/ssp_excel_parse_job_domain_service.py(update_status+ 語意 wrapper) - 心跳既有:
app/remote_agent/service/agent_enrollment_service.py::heartbeat - agent route 慣例(不掛 jwt):
api/remote_agent/routes/remote_agent_route.py::AgentHeartbeatRoute - 雲端→agent mTLS client(反向參考):
infra/upload_file/remote_agent_adapter.py::_request - Agent 心跳迴圈:
evidence-agent/core/enroll.py::_heartbeat_once/_heartbeat_loop - DDD 九層:同 FR-056.1 附錄 A(remote_agent 模組)