本卡屬 FR-182(母卡 CM-3609 https://app.notion.com/p/FR-182-Agent-Agent-P1-License-Center-4-3-410346da4cd0816ea5e2c4757ebc4fbb),P2 Agent 端,卡 P2-1,Stage 4(P2 第一批,接在 P1 的批 1~3 之後;P1 LC 端 CM-3610~3613 已 Done 並上 189)。只改 evidence-agent。依賴:無(同批 P2-1/P2-2/P2-3 平行;P2-2 的 gate 介面以 P2-1 為準,合回後對一次)。
License Center 那邊(P1,已上 189)已經簽得出「Agent 照」——一台 Agent 一張、綁那台機器碼、有到期日、沒有功能分級。這張卡讓 Agent 認得這張照:開機時讀資料目錄裡的 agent.license,用已經編譯進程式的簽發公鑰驗簽章、確認型別是 agent_license(主系統的照拿來會被拒)、核對照上的機器碼等於本機的 device_uuid、按到期日算狀態(正常/到期提醒/寬限期/已過期)。沒照、照驗不過、照是別台的、過了寬限期:整個 sync 模組不起——不向主系統註冊、不心跳、不拉設定、不收指令(含 FR-181 新做的長輪詢);主控台照常起、能登入看機器碼(上傳照的畫面是 P2-2/P2-3)。閘門放在「起 sync 模組」那一層,一道門關全部,不逐條通道加 if——以後 sync 多一條什麼都自動被閘住。上傳對的照之後不用重啟:閘門在同一個行程內放行、把 sync 起來。決策者定調(design D6、D8):不擋啟動(擋了客戶看不到機器碼,開通流程走不下去);驗章用 Agent 既有的 common/integrity,不裝主系統在用的 jedi-license-runtime。
reuse:common/integrity/verify.py 的 verify_payload(signed, expected_type=…) 與編譯進去的 PUBLIC_KEYS(加常數 PAYLOAD_TYPE_AGENT_LICENSE = "agent_license" 到 verify.py 的型別清單旁);platform/fingerprint.compute_device_uuid;config.data_dir()。new:guidant_agent/license/(照 ARCHITECTURE §3:domain/status.py 純函式四態 valid/notice/grace/expired+LicenseState dataclass;app/store.py 讀寫 AGENT_DATA_DIR/license/agent.license(armor 或 JSON 都收,寫入原子 tmp+rename、0600);app/check.py evaluate(cfg) -> LicenseState:無檔→missing、驗章失敗→invalid(reason)、payload_type 不是 agent_license→invalid(wrong_type)、machine_fingerprint ≠ device_uuid→mismatch(bound_prefix)、其餘算四態;app/install.py install(bytes) -> LicenseState:先 evaluate 新檔內容(不落地),通過才寫檔並覆蓋,失敗保留原檔;__init__.py 申報 status_fields()(本期回空 dict——AGENT_STATUS_KEYS 白名單在 jedi-agent-protocol,P3 才加鍵;先把掛點做好)、register(app, container) 把 LicenseGate 單例放 app.extensions["agent_license"]+根容器 provider)。extend:platform/registry.py MODULES 加 _module("license", status_fields=True) 放 identity 之後 files 之前(登記型,本卡 owner);platform/bootstrap.py _setup_agent_auth 開頭:gate = app.extensions["agent_license"]; if not gate.allows_sync(): logger.warning(...); app.config["AGENT_STATUS"] = "unlicensed"; return——閘門在這一層,setup_agent_auth/enroll_for_auth/registry.py 的 sync 那列一字不動;閘門放行條件=valid/notice/grace。LicenseGate.on_installed 回呼:install 成功且閘門原本關著→同行程呼叫 _setup_agent_auth(app, container, components)+components.start_all()(bootstrap 把這兩個綁進 gate),不用重啟。.claude/rules/security/sync-and-identity.md 的 paths 補 guidant_agent/license/**(守衛 test_security_rules_cover_sources 會紅)。mode=none(demo)不閘。
repo:~/Projects/Billows/Audit-Manager/evidence-agent
改既有:guidant_agent/platform/registry.py(MODULES 加一列)
改既有:guidant_agent/platform/bootstrap.py(_setup_agent_auth 開頭加閘門+把重入綁給 gate)
改既有:common/integrity/verify.py(加 PAYLOAD_TYPE_AGENT_LICENSE 常數)
改既有:.claude/rules/security/sync-and-identity.md(paths 補 license/**)
新開:guidant_agent/license/__init__.py
新開:guidant_agent/license/domain/status.py
新開:guidant_agent/license/app/store.py
新開:guidant_agent/license/app/check.py
新開:guidant_agent/license/app/install.py
新開:guidant_agent/license/app/gate.py
新開:test/test_license_status.py
新開:test/test_license_check.py
新開:test/test_license_gate.py
docs/ARCHITECTURE.md §2 依賴方向、§3 模組內分層、§4 守衛清單;guidant_agent/platform/registry.py(_module() 介面、MODULES、status_fields 併法);guidant_agent/platform/bootstrap.py(_setup_agent_auth 在 MODULES register 完之後、_start_components 之前);guidant_agent/sync/__init__.py setup_agent_auth;common/integrity/verify.py 與 startup_gate.py(驗章怎麼呼叫、PAYLOAD_TYPE_* 怎麼擺);guidant_agent/platform/fingerprint.py;guidant_agent/identity/(一個小模組的完整形狀,照抄目錄結構)。common/integrity/verify.py:PAYLOAD_TYPE_AGENT_RELEASE 旁加 PAYLOAD_TYPE_AGENT_LICENSE = "agent_license",檔頭型別表補一列。只加常數,verify_payload 不動。guidant_agent/license/domain/status.py:LicenseState dataclass(status: str(missing/invalid/mismatch/valid/notice/grace/expired)、reason: str | None、license_id、customer_code、issued_to、type、starts_at、expires_at、days_to_expiry: int | None、grace_days_left: int | None、bound_fingerprint: str | None、expiry_policy: dict)+allows_sync property(valid/notice/grace);compute_status(expires_at, notify_days_before, grace_days, now) -> (status, days_to_expiry, grace_days_left):到期前 > notify → valid;≤ notify → notice;過期 ≤ grace → grace;超過 → expired。純函式、不 import 任何 app 層。guidant_agent/license/app/store.py:path(cfg) = os.path.join(config.data_dir(), "license", "agent.license");read() -> bytes | None;write(data: bytes):目錄 0700、tmp+os.replace、檔 0600。armor(-----BEGIN GUIDANT LICENSE-----)與純 JSON 信封都收——dearmor 照 LC core/crypto/engine.py dearmor_license 的規則(base64 中段)在 Agent 這邊寫一支小的(common/integrity 若已有同功能就 reuse,先 grep BEGIN GUIDANT)。guidant_agent/license/app/check.py:evaluate(data: bytes | None, device_uuid: str, now=None) -> LicenseState:None → missing;解析失敗 → invalid(malformed);verify_payload(signed, expected_type=PAYLOAD_TYPE_AGENT_LICENSE) 拋 → invalid(bad_signature) 或 invalid(wrong_type)(verify.py 的例外訊息分得出來就分,分不出就先 payload_type 自己看一眼再驗章);machine_fingerprint 空 → invalid(unbound);≠ device_uuid → mismatch(bound_fingerprint 帶完整值,API 層只回前 12 碼);其餘 compute_status。evaluate_installed(cfg, device_uuid) 包 store.read。guidant_agent/license/app/install.py:install(data: bytes, device_uuid: str) -> LicenseState:先 evaluate(data),allows_sync 才 store.write,否則 raise LicenseRejected(state)(app 層例外,API 層對映 400)。guidant_agent/license/app/gate.py:LicenseGate(state: LicenseState、allows_sync()、refresh()、install(data)(成功後若 _on_opened 有設且之前是關的 → 呼叫一次)、bind_opener(fn))。模組層不准可變全域(守衛)——單例掛 app.extensions["agent_license"],由 register() 建。guidant_agent/license/__init__.py:register(app, container)(建 gate、算一次狀態、log 一行 license gate: status=… sync_allowed=…)、status_fields() 回 {}(本期;P3 加鍵時改)。platform/registry.py:MODULES 在 _module("identity", …) 之後插 _module("license", status_fields=True)(沒有 migrations、沒有 observer)。platform/bootstrap.py _setup_agent_auth 開頭:gate = app.extensions.get("agent_license");cfg.mode == "full" 且 gate 存在且 not gate.allows_sync() → logger.warning("license gate: sync disabled (%s)", gate.state.status)、app.config["AGENT_STATUS"] = "unlicensed"、gate.bind_opener(lambda: (_setup_agent_auth(app, container, components), components.start_all()))、return。mode=none 不閘。🔴 components.start_all() 重入要確認 ComponentRegistry 對已 start 的元件是冪等的(看 platform/background.py),不是就只 start 新加的。.claude/rules/security/sync-and-identity.md paths 加 "guidant_agent/license/**/*.py";跑 test/test_security_rules_cover_sources.py 確認。test/test_license_status.py(四態邊界)、test/test_license_check.py(用 LC 的測試鑰?不行——公鑰編譯死的。做法:測試用 monkeypatch 把 common.integrity.public_keys.PUBLIC_KEYS 換成測試鑰對(cryptography 現產),自己簽一份 agent_license payload;主系統照型別、壞簽章、指紋不符、unbound 各一)、test/test_license_gate.py(閘門開關、install 後 opener 被叫一次且只一次)。突變:把 expected_type 拿掉 → 主系統照那條要紅;把指紋比對拿掉 → mismatch 那條要紅。