Files
codex-app-server/cloud/chv/approval_card.py
T
host 9759be47bb feat(chv): 对齐 Muse P12——审计轨迹与活动视图
统一审计记录模型(时间/主体/动作/对象/结果/原因/关联任务/
关联审批)+ 追加写 JSONL + SHA-256 哈希链(改写/删除/调序可
被 verify 发现)+ 字段级白名单清洗(密钥/token 值/对话原文
绝不进审计);事件分类覆盖任务派发、VM 生命周期、同步、审批、
支付、配额、记忆、后台、推理/遥测;from_legacy_row 承接现有
audit_log 行;导出选 JSON。

活动视图后端:计划登记(已做 vs 计划做,流转受控)+ 已批准
权限登记(接 P5 describe 形状)+ activity_summary 纯函数;
结构化审批卡数据(风险分级 + 接受/拒绝明确动作 + 五档授权
选项,永久档须显式 opt-in)+ card_matches_channel 校验卡与
P5 通道载荷一致。

只做后端 + 数据接口,前端视图留接口;P3/P5 不在本基线,
接线走 dict 契约,集成由 round-11 合并;审计持久化先落独立
JSONL,待 P7 状态库就绪后迁移。真机验证排队在 round-11 之后。

新测试 43/43,全量 cloud 套件 1910 OK(skipped=8);
mypy 在改动文件上干净。
2026-11-06 22:38:19 +00:00

173 lines
6.5 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""结构化审批卡数据(feat-chv-muse-p12,叶子模块,纯标准库)。
接 Muse 官方要求(报告 §2.6):确定性 UI——结构化审批卡
(明确接受/拒绝动作);审批是严格能力,绑定特定连接器/
目的地与用例,支持一次性、会话级、任务级、时限级、永久级
授权,Sentinel 决定向用户呈现哪几种授权类型;审批 UI 不在
对话内,Sentinel 直接向客户端发审批请求,用户答复直接回
Sentinel(防止对话内容伪造审批)。
本模块只做卡的**数据**:把 P3 ``ApprovalQueue`` 的 pending
记录 dict 转成客户端可呈现的结构化卡。不 import P3/P5 模块
——它们不在本基线里,集成时由 round-11 合并;只按 dict
契约接线。
卡数据(纯数据 dict,可直接 JSON 序列化)::
{"pending_id": str, "action": str, "target": {...},
"reasons": [str, ...], "risk": "low|medium|high",
"actions": [{"id": "accept", "label": "接受"},
{"id": "deny", "label": "拒绝"}],
"grant_options": [{"tier": str, "label": str,
"requires_explicit_opt_in": bool}, ...],
"expires_at": float | None}
- ``risk`` 分级(确定性规则,不冒充模型分类器):写方法
(POST/PUT/PATCH/DELETE)+ 敏感路径(/admin、/settings、
/login、token 相关)或私有主机 → high;写方法但目标不
敏感 → medium;其余只读 → low。
- ``grant_options`` 的档位列表由调用方传入(Sentinel 决定
呈现哪几种);不传默认五档全列,永久档永远标注
``requires_explicit_opt_in: true``(P5 口径:永久默认拒绝,
须显式 opt-in)。
- ``card_matches_channel()``:校验卡数据与 P5 对话外通道载荷
(``ApprovalRequest.payload()``)一致——pending_id 相同且
目标字段(连接器/方法/scheme/主机/端口/路径)全等;不一
致即客户端不该呈现该卡。
"""
import ipaddress
# 五档授权类型(P3/P5 口径):一次/会话/任务/时限/永久。
TIERS = ("one-shot", "session", "task", "timed", "permanent")
TIER_LABELS = {
"one-shot": "仅这一次",
"session": "本次会话",
"task": "本任务期间",
"timed": "限时授权",
"permanent": "永久授权",
}
# 目标描述字段:卡与通道载荷比对只认这些键。
TARGET_KEYS = ("connector", "method", "scheme", "host", "port",
"path")
_WRITE_METHODS = ("POST", "PUT", "PATCH", "DELETE")
_SENSITIVE_PATHS = ("/admin", "/settings", "/login", "/token",
"/oauth", "/credential")
# 通道审批默认有效期(秒):15 分钟,与 P5 通道一致。
DEFAULT_TTL_SECONDS = 900
def _is_private_host(host: str) -> bool:
"""主机是否私有/本地:IP 段判断,localhost 视为私有。"""
if not host:
return False
lowered = host.lower()
if lowered in ("localhost", "localhost.localdomain"):
return True
try:
return ipaddress.ip_address(host).is_private
except ValueError:
return False
def _is_sensitive_path(path: str) -> bool:
"""路径是否敏感:命中敏感前缀即敏感。"""
lowered = (path or "").lower()
return any(lowered.startswith(p) for p in _SENSITIVE_PATHS)
def risk_level(queue_record: dict) -> str:
"""审批风险分级:high/medium/low(确定性规则)。
写方法 +(敏感路径或私有主机)→ high;写方法 → medium;
其余只读 → low。
"""
method = str(queue_record.get("method", "")).upper()
if method not in _WRITE_METHODS:
return "low"
if _is_sensitive_path(str(queue_record.get("path", ""))):
return "high"
if _is_private_host(str(queue_record.get("host", ""))):
return "high"
return "medium"
def _target_from_record(queue_record: dict) -> dict:
"""队列记录里只取目标描述字段,别的什么都不带出卡。"""
return {k: queue_record[k] for k in TARGET_KEYS
if k in queue_record}
def _grant_options(tiers: tuple | list | None) -> list:
"""档位选项:未知档位丢弃,永久档永远标注须显式 opt-in。"""
wanted = list(tiers) if tiers is not None else list(TIERS)
options = []
for tier in wanted:
if tier not in TIER_LABELS:
continue
options.append({
"tier": tier,
"label": TIER_LABELS[tier],
"requires_explicit_opt_in": tier == "permanent",
})
return options
def build_approval_card(queue_record: dict,
tiers: tuple | list | None = None,
ttl_seconds: int = DEFAULT_TTL_SECONDS
) -> dict:
"""P3 队列记录转结构化审批卡数据。
queue_record 是 ``ApprovalQueue.record()`` 记入的 dict:
目标描述(connector/method/scheme/host/port/path)+
pending_id + reasons + ts。缺 pending_id 抛 ValueError。
expires_at 由记录 ts + 有效期算出;无 ts 则为 None。
"""
pending_id = queue_record.get("pending_id")
if not pending_id:
raise ValueError("队列记录缺 pending_id")
target = _target_from_record(queue_record)
connector = str(target.get("connector", ""))
method = str(target.get("method", "")).upper()
action = "%s %s" % (connector, method) if connector else method
ts = queue_record.get("ts")
expires_at = None
if isinstance(ts, (int, float)):
expires_at = float(ts) + ttl_seconds
return {
"pending_id": str(pending_id),
"action": action,
"target": target,
"reasons": list(queue_record.get("reasons") or []),
"risk": risk_level(queue_record),
"actions": [
{"id": "accept", "label": "接受"},
{"id": "deny", "label": "拒绝"},
],
"grant_options": _grant_options(tiers),
"expires_at": expires_at,
}
def card_matches_channel(card: dict, channel_payload: dict) -> bool:
"""卡数据与 P5 通道载荷是否一致(pending_id + 目标全等)。
任一目标字段不一致、或 pending_id 不同,即返回 False。
客户端只应呈现校验通过的卡。
"""
if card.get("pending_id") != channel_payload.get("pending_id"):
return False
card_target = card.get("target") or {}
channel_target = channel_payload.get("target") or {}
for key in TARGET_KEYS:
if key not in card_target and key not in channel_target:
continue
if card_target.get(key) != channel_target.get(key):
return False
return True