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 在改动文件上干净。
This commit is contained in:
host committed 2026-11-06 22:38:19 +00:00
1 parent 06c3e172fe
commit 9759be47bb
5 files changed
+1355

No files matched your search

+172
View File
@@ -0,0 +1,172 @@
"""结构化审批卡数据(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