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_jobupdate_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,第一版擇一。


§1

前置:關鍵發現與決策(runner 必讀)

項目 定案 來源
派工模式 Pull(心跳夾帶待辦 + result endpoint),非雲端主動推 D3
agent→cloud 認證 純 mTLS,route 不掛 @jwt_required()(同既有 heartbeat/register) Explore
心跳改法 BE heartbeat() return 前插 pending_tasks 查詢;Agent _heartbeat_oncereturn resp.json() 交 executor Explore
executor 執行緒 另開 threadThreadPoolExecutor/individual thread),不阻塞心跳迴圈 Explore
狀態機範本 ssp_excel_parse_job_domain_serviceupdate_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.pyhttpx.Client(cert=..., verify=...)


§2

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

§3

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

agent_taskscompliance 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 驗證。

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"))
git commit -m "feat(fr056): agent_tasks table + state machine + agent capabilities (T-3.1)"

§4

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

app/remote_agent/service/agent_enrollment_service.pyheartbeat(),在 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_configscrypto.decrypt(credentials_encrypted) 回明文 dict。

D9 安全註記: 憑證只在 mTLS 加密通道下發,Agent 端不持久化(executor 用完即丟)。雲端仍是憑證唯一保管處(加密存放),符合「租戶在雲端 UI 統一設定」的產品體驗。

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_refFR-056.3 不新建檔案傳輸通道,複用既有 blob。FR-056.4 的轉證據 handler 再把這個 upload 轉成 job_evidences。

@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) 註記。

手測(脫離 UI,直接塞派工驗證):

# 1. 手動 INSERT 一筆 agent_tasks(pending)到 DEV
# 2. 模擬 agent 心跳(mTLS)→ 應回 pending_tasks 含該筆
# 3. 模擬 ack → status 變 dispatched
# 4. 模擬 result(succeeded)→ status 變 succeeded
# 每步 psql 查 status 確認
git commit -m "feat(fr056): heartbeat task dispatch + ack + result endpoints (T-3.2)"

§5

Task 3(T-3.3): Agent executor 模組骨架(evidence-agent repo)

此 Task 在 evidence-agent repo ~/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

core/enroll.py:

def _heartbeat_once(cfg, device_uuid, state) -> dict:
    ...
    resp = client.post(url, json={...})
    resp.raise_for_status()
    return resp.json()   # 原本丟棄,改回傳
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)
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 上傳實作補(既有能力,非新建)。

先用假 connector(回固定 bytes)驗證整鏈:塞派工 → agent 心跳領到 → ack → 假掃描 → 上傳 → result → 雲端 status succeeded。connector 真身在 Task 4。

git commit -m "feat(fr056): agent task executor skeleton (T-3.3)

心跳迴圈接 executor:領 pending_tasks → thread 執行 → ack/result 回報。connector 為 Task 4。"

§6

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__.pyget_connector factory)
  • Create: core/task_executor_connectors/base.py(connector 介面)
  • Create: core/task_executor_connectors/openvas.py(OpenVAS 實作)

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 組裝(需在心跳查詢後補解密步驟)。

get_connector(detection_tool_id, params, credentials) 依工具 code 回對應 connector。第一版只有 openvas。

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 加。

塞派工(含真實掃描目標)→ agent 領到 → 連真 OpenVAS 跑掃描 → 報告上傳 → 雲端 status succeeded + result_ref 有 upload uid。這是 FR-056.3 的最終驗收:脫離 UI 的完整 dispatch→scan→result 鏈通

git commit -m "feat(fr056): OpenVAS connector (T-3.4)"

§7

完成後收尾(等 user 下令)


§8

決策狀態(D9/D10 已定案)

  1. 憑證下發到 AgentD9 定案:(a) 雲端解密後隨派工下發(走 mTLS,Agent 用完即丟不落地)。 已回寫 Task 2 Step 2 的 pending_tasks 組裝。(原因:要客戶在 Agent 端自設不實際,雲端 UI 統一設定才符合產品體驗。)
  2. OpenVAS 整合方式D10 定案:API(python-gvm,GVM protocol)。
  3. executor DI vs plain module(Task 3):維持 plain module 與既有心跳一致——實作風格,runner 逕行,非阻塞決策。

§9

附錄:範本來源

  • 狀態機:domain/oscal/service/ssp_excel_parse_job_domain_service.pyupdate_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 模組)