419 lines
19 KiB
Python
419 lines
19 KiB
Python
|
|
# ============================================================
|
|||
|
|
# 兜底高风险治理 v1(moduleId: core-fallback-highrisk, 可重生 ✅)
|
|||
|
|
# GOAL-P3 交付 1/5 + P3-DESIGN §2/§4:
|
|||
|
|
# P3 白名单(fallback-highrisk.json)加载与裁决、角色判定、白名单文档校验。
|
|||
|
|
#
|
|||
|
|
# 语义铁律(与 features.json 相反,两文件两加载器语义隔离,互不 import):
|
|||
|
|
# - features.json 是「可用性/可见性」开关,fail-open,不是权限边界;
|
|||
|
|
# - fallback-highrisk.json 是**权限边界的一部分**(P3 放行唯一依据),
|
|||
|
|
# **fail-closed**:文件缺失 / JSON 损坏 / 未知场景键 / 未知字段 / 类型错误 /
|
|||
|
|
# 引用未登记意图 → 整份文件判损坏,默认全拒,error 显式带原因。
|
|||
|
|
# 白名单里任何无法解释的内容都按攻击面处理(不做「忽略未知键」)。
|
|||
|
|
# - 每次裁决现读文件(不缓存):白名单经确认卡落盘后下一次裁决即生效;
|
|||
|
|
# 返回结构携带 path/sha256,出卡/执行双端比对,审批窗口内变更 → 执行拒绝。
|
|||
|
|
# ============================================================
|
|||
|
|
from __future__ import annotations
|
|||
|
|
|
|||
|
|
import hashlib
|
|||
|
|
import json
|
|||
|
|
import os
|
|||
|
|
from pathlib import Path
|
|||
|
|
from typing import Any
|
|||
|
|
|
|||
|
|
WHITELIST_VERSION = 1
|
|||
|
|
SCENARIOS = ("S4", "S6", "S7")
|
|||
|
|
_PATH_ENV = "APS_FALLBACK_HIGHRISK_PATH"
|
|||
|
|
|
|||
|
|
# 场景允许的 power 集(P3-DESIGN §3.3③):
|
|||
|
|
# S4 只许 P0/P1 沙盒意图;S6 只许 P1/P2 补录意图;S7 只许白名单内 P2/P3 治理动作。
|
|||
|
|
_SCENARIO_POWER_SETS: dict[str, tuple[str, ...]] = {
|
|||
|
|
"S4": ("P0", "P1"),
|
|||
|
|
"S6": ("P1", "P2"),
|
|||
|
|
"S7": ("P2", "P3"),
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
_TOP_LEVEL_KEYS = {"whitelistVersion", "updatedAt", "updatedBy", "scenarios"}
|
|||
|
|
_SCENARIO_KEYS = {"enabled", "intents", "roles", "maxItemsPerRun"}
|
|||
|
|
|
|||
|
|
DEFAULT_S6_MAX_ITEMS = 20
|
|||
|
|
|
|||
|
|
|
|||
|
|
class HighriskDenied(Exception):
|
|||
|
|
"""白名单裁决拒绝(fail-closed)。调用方归并为 PlanError / DeviationError / 显式失败。"""
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ---------------------------------------------------------------------------
|
|||
|
|
# 路径与文件指纹
|
|||
|
|
# ---------------------------------------------------------------------------
|
|||
|
|
|
|||
|
|
|
|||
|
|
def default_whitelist_path() -> Path:
|
|||
|
|
"""白名单路径:APS_FALLBACK_HIGHRISK_PATH 覆盖,默认 path_under_data(与 features.json 同目录并列)。"""
|
|||
|
|
configured = (os.environ.get(_PATH_ENV) or "").strip()
|
|||
|
|
if configured:
|
|||
|
|
return Path(configured).expanduser().resolve()
|
|||
|
|
from server.aps_home import path_under_data
|
|||
|
|
|
|||
|
|
return path_under_data("fallback-highrisk.json")
|
|||
|
|
|
|||
|
|
|
|||
|
|
def file_sha256(path: str | Path) -> str | None:
|
|||
|
|
"""文件内容 sha256;文件不存在/不可读 → None(调用方按 fail-closed 处理)。"""
|
|||
|
|
try:
|
|||
|
|
return hashlib.sha256(Path(path).read_bytes()).hexdigest()
|
|||
|
|
except OSError:
|
|||
|
|
return None
|
|||
|
|
|
|||
|
|
|
|||
|
|
def canonical_sha256(obj: Any) -> str:
|
|||
|
|
"""canonical JSON(排序键 + 紧凑分隔符)的 sha256(文档冻结指纹口径)。"""
|
|||
|
|
blob = json.dumps(obj, ensure_ascii=True, sort_keys=True,
|
|||
|
|
separators=(",", ":"), default=str)
|
|||
|
|
return hashlib.sha256(blob.encode("utf-8")).hexdigest()
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ---------------------------------------------------------------------------
|
|||
|
|
# 白名单文档校验(出卡前复用:改白名单的新文档必须全过才允许出卡)
|
|||
|
|
# ---------------------------------------------------------------------------
|
|||
|
|
|
|||
|
|
|
|||
|
|
def validate_whitelist_doc(doc: Any) -> str | None:
|
|||
|
|
"""逐条强制 schema(P3-DESIGN §2.1 字段规则表)。合法 → None;非法 → 显式原因。
|
|||
|
|
|
|||
|
|
任何一条不过都意味着「整份文件判损坏」——调用方不得做局部放行。
|
|||
|
|
intent 必须在 harness._POWER_MAP 登记(防「先放行后登记」倒置)。
|
|||
|
|
"""
|
|||
|
|
from server.agent_core.harness import _POWER_MAP
|
|||
|
|
|
|||
|
|
if not isinstance(doc, dict):
|
|||
|
|
return "白名单顶层不是 JSON 对象"
|
|||
|
|
if doc.get("whitelistVersion") != WHITELIST_VERSION:
|
|||
|
|
return f"whitelistVersion 必须为 {WHITELIST_VERSION}(实际 {doc.get('whitelistVersion')!r})"
|
|||
|
|
unknown_top = sorted(set(doc) - _TOP_LEVEL_KEYS)
|
|||
|
|
if unknown_top:
|
|||
|
|
return f"白名单含未知顶层字段: {unknown_top}"
|
|||
|
|
scenarios = doc.get("scenarios")
|
|||
|
|
if not isinstance(scenarios, dict):
|
|||
|
|
return "白名单 scenarios 段不是对象"
|
|||
|
|
unknown_scenarios = sorted(set(scenarios) - set(SCENARIOS))
|
|||
|
|
if unknown_scenarios:
|
|||
|
|
return f"白名单含未知场景键: {unknown_scenarios}(只允许 {'/'.join(SCENARIOS)})"
|
|||
|
|
for name, entry in scenarios.items():
|
|||
|
|
if not isinstance(entry, dict):
|
|||
|
|
return f"场景 {name} 配置不是对象"
|
|||
|
|
unknown_fields = sorted(set(entry) - _SCENARIO_KEYS)
|
|||
|
|
if unknown_fields:
|
|||
|
|
return f"场景 {name} 含未知字段: {unknown_fields}"
|
|||
|
|
if not isinstance(entry.get("enabled"), bool):
|
|||
|
|
return f"场景 {name}.enabled 必须是 bool"
|
|||
|
|
intents = entry.get("intents")
|
|||
|
|
if not isinstance(intents, list) or not all(isinstance(i, str) and i for i in intents):
|
|||
|
|
return f"场景 {name}.intents 必须是非空字符串数组"
|
|||
|
|
for intent in intents:
|
|||
|
|
if intent not in _POWER_MAP:
|
|||
|
|
return f"场景 {name}.intents 引用了未在权力矩阵登记的意图: {intent}"
|
|||
|
|
roles = entry.get("roles")
|
|||
|
|
if not isinstance(roles, list) or not roles \
|
|||
|
|
or not all(isinstance(r, str) and r.strip() for r in roles):
|
|||
|
|
return f"场景 {name}.roles 必须是非空字符串数组"
|
|||
|
|
if "maxItemsPerRun" in entry:
|
|||
|
|
cap = entry["maxItemsPerRun"]
|
|||
|
|
if not isinstance(cap, int) or isinstance(cap, bool) or cap <= 0:
|
|||
|
|
return f"场景 {name}.maxItemsPerRun 必须是正整数"
|
|||
|
|
return None
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ---------------------------------------------------------------------------
|
|||
|
|
# fail-closed 加载器(每次现读,不缓存)
|
|||
|
|
# ---------------------------------------------------------------------------
|
|||
|
|
|
|||
|
|
|
|||
|
|
def load_highrisk_whitelist(path: str | Path | None = None) -> dict[str, Any]:
|
|||
|
|
"""读取并裁决白名单(P3-DESIGN §2.1 fail-closed 裁决表的全部出口)。
|
|||
|
|
|
|||
|
|
返回 {"ok": bool, "error": str | None, "grants": {场景: 归一化配置},
|
|||
|
|
"path": str, "sha256": str | None, "doc": 原始文档 | None}。
|
|||
|
|
任何配置问题 → ok=False(全拒)+ error 显式带原因,绝不放行。
|
|||
|
|
"""
|
|||
|
|
resolved = Path(path) if path else default_whitelist_path()
|
|||
|
|
result: dict[str, Any] = {
|
|||
|
|
"ok": False, "error": None, "grants": {},
|
|||
|
|
"path": str(resolved), "sha256": None, "doc": None,
|
|||
|
|
}
|
|||
|
|
try:
|
|||
|
|
if not resolved.is_file():
|
|||
|
|
result["error"] = "白名单文件缺失(fail-closed 默认全拒)"
|
|||
|
|
return result
|
|||
|
|
result["sha256"] = file_sha256(resolved)
|
|||
|
|
try:
|
|||
|
|
doc = json.loads(resolved.read_text(encoding="utf-8"))
|
|||
|
|
except (json.JSONDecodeError, UnicodeDecodeError) as exc:
|
|||
|
|
result["error"] = f"白名单 JSON 解析失败({type(exc).__name__}),fail-closed 全拒"
|
|||
|
|
return result
|
|||
|
|
error = validate_whitelist_doc(doc)
|
|||
|
|
if error:
|
|||
|
|
result["error"] = f"白名单判损坏:{error}(fail-closed 全拒)"
|
|||
|
|
return result
|
|||
|
|
grants: dict[str, dict[str, Any]] = {}
|
|||
|
|
for name, entry in (doc.get("scenarios") or {}).items():
|
|||
|
|
grants[name] = {
|
|||
|
|
"enabled": bool(entry.get("enabled")),
|
|||
|
|
"intents": tuple(str(i) for i in entry.get("intents") or ()),
|
|||
|
|
"roles": tuple(str(r).strip().lower() for r in entry.get("roles") or ()),
|
|||
|
|
"maxItemsPerRun": entry.get("maxItemsPerRun"),
|
|||
|
|
}
|
|||
|
|
result.update({"ok": True, "grants": grants, "doc": doc})
|
|||
|
|
return result
|
|||
|
|
except Exception as exc: # noqa: BLE001 - 任何异常(含 IO)→ 全拒(宁可误关同款哲学)
|
|||
|
|
result["error"] = f"白名单加载异常({type(exc).__name__}: {exc}),fail-closed 全拒"
|
|||
|
|
return result
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ---------------------------------------------------------------------------
|
|||
|
|
# 裁决(出卡闸 / 执行闸共用的唯一放行路径)
|
|||
|
|
# ---------------------------------------------------------------------------
|
|||
|
|
|
|||
|
|
|
|||
|
|
def scenario_grant(whitelist: dict[str, Any], scenario: str) -> dict[str, Any] | None:
|
|||
|
|
"""取场景放行配置(未启用/白名单不可用 → None)。"""
|
|||
|
|
if not whitelist.get("ok"):
|
|||
|
|
return None
|
|||
|
|
grant = (whitelist.get("grants") or {}).get(scenario)
|
|||
|
|
if not grant or not grant.get("enabled"):
|
|||
|
|
return None
|
|||
|
|
return grant
|
|||
|
|
|
|||
|
|
|
|||
|
|
def intent_allowed(whitelist: dict[str, Any], scenario: str, intent: str) -> bool:
|
|||
|
|
grant = scenario_grant(whitelist, scenario)
|
|||
|
|
return bool(grant) and intent in (grant.get("intents") or ())
|
|||
|
|
|
|||
|
|
|
|||
|
|
def check_scenario_step(scenario: str, intent: str, power: str,
|
|||
|
|
whitelist: dict[str, Any]) -> None:
|
|||
|
|
"""P3 场景步骤的统一裁决(P3-DESIGN §3.3)。任一条件不过 → HighriskDenied。
|
|||
|
|
|
|||
|
|
① 白名单必须可用(fail-closed);② 场景必须 enabled;
|
|||
|
|
③ intent 必须在场景放行集;④ power 不得越场景允许集。
|
|||
|
|
"""
|
|||
|
|
if scenario not in SCENARIOS:
|
|||
|
|
raise HighriskDenied(f"场景 {scenario!r} 非高风险场景({ '/'.join(SCENARIOS) })")
|
|||
|
|
if not whitelist.get("ok"):
|
|||
|
|
raise HighriskDenied(whitelist.get("error") or "白名单不可用(fail-closed 默认全拒)")
|
|||
|
|
grant = (whitelist.get("grants") or {}).get(scenario)
|
|||
|
|
if not grant or not grant.get("enabled"):
|
|||
|
|
raise HighriskDenied(f"{scenario} 未在白名单启用")
|
|||
|
|
if intent not in (grant.get("intents") or ()):
|
|||
|
|
raise HighriskDenied(f"意图 {intent} 未列入 {scenario} 白名单放行集")
|
|||
|
|
allowed_powers = _SCENARIO_POWER_SETS[scenario]
|
|||
|
|
if power not in allowed_powers:
|
|||
|
|
raise HighriskDenied(
|
|||
|
|
f"意图 {intent} 权力等级 {power} 超出 {scenario} 允许集 {'/'.join(allowed_powers)}")
|
|||
|
|
|
|||
|
|
|
|||
|
|
def scenario_power_set(scenario: str) -> tuple[str, ...]:
|
|||
|
|
return _SCENARIO_POWER_SETS.get(scenario, ())
|
|||
|
|
|
|||
|
|
|
|||
|
|
def s6_max_items(whitelist: dict[str, Any], *, env_cap: int = DEFAULT_S6_MAX_ITEMS) -> int:
|
|||
|
|
"""S6 单轮出卡上限:白名单 maxItemsPerRun(缺省 20)与 env 全局封顶取小。"""
|
|||
|
|
grant = scenario_grant(whitelist, "S6") or {}
|
|||
|
|
configured = grant.get("maxItemsPerRun") or DEFAULT_S6_MAX_ITEMS
|
|||
|
|
try:
|
|||
|
|
cap = int(env_cap)
|
|||
|
|
except (TypeError, ValueError):
|
|||
|
|
cap = DEFAULT_S6_MAX_ITEMS
|
|||
|
|
return max(1, min(int(configured), cap))
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ---------------------------------------------------------------------------
|
|||
|
|
# 角色判定(复用 server/auth 既有身份体系,不新造权限模型)
|
|||
|
|
# ---------------------------------------------------------------------------
|
|||
|
|
|
|||
|
|
|
|||
|
|
def current_identity_roles() -> tuple[str, ...]:
|
|||
|
|
"""当前身份角色元组(小写归一)。无 HTTP 上下文 → ("system",)(测试/脚本通道)。"""
|
|||
|
|
from server.auth.context import get_identity
|
|||
|
|
|
|||
|
|
return tuple(str(r).lower() for r in get_identity().roles)
|
|||
|
|
|
|||
|
|
|
|||
|
|
def identity_roles_match(whitelist: dict[str, Any], scenario: str,
|
|||
|
|
roles: tuple[str, ...] | None = None) -> bool:
|
|||
|
|
"""身份是否在场景可见/可发起角色集内(白名单 scenarios.<S>.roles 是唯一数据源)。
|
|||
|
|
|
|||
|
|
system 角色放行(harness.py:486 同款测试/脚本通道例外,P3-DESIGN §4.1.4)。
|
|||
|
|
"""
|
|||
|
|
grant = scenario_grant(whitelist, scenario)
|
|||
|
|
if not grant:
|
|||
|
|
return False
|
|||
|
|
current = roles if roles is not None else current_identity_roles()
|
|||
|
|
lowered = {str(r).lower() for r in current}
|
|||
|
|
if "system" in lowered:
|
|||
|
|
return True
|
|||
|
|
return bool(lowered & set(grant.get("roles") or ()))
|
|||
|
|
|
|||
|
|
|
|||
|
|
def is_ops_identity(whitelist: dict[str, Any],
|
|||
|
|
roles: tuple[str, ...] | None = None) -> bool:
|
|||
|
|
"""是否运维入口可见身份(S7.roles ∩ 当前身份)。"""
|
|||
|
|
return identity_roles_match(whitelist, "S7", roles)
|
|||
|
|
|
|||
|
|
|
|||
|
|
def roles_for_scenario(whitelist: dict[str, Any], scenario: str) -> tuple[str, ...]:
|
|||
|
|
grant = scenario_grant(whitelist, scenario)
|
|||
|
|
return tuple(grant.get("roles") or ()) if grant else ()
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ---------------------------------------------------------------------------
|
|||
|
|
# S6 补录清单项校验(出卡前逐项;任一不过 → 显式失败不出卡)
|
|||
|
|
# ---------------------------------------------------------------------------
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _find_work_order(world: dict[str, Any], wo_id: Any) -> dict[str, Any] | None:
|
|||
|
|
for table in ("flexWorkOrders", "workOrders"):
|
|||
|
|
for wo in world.get(table) or []:
|
|||
|
|
if isinstance(wo, dict) and wo.get("id") == wo_id:
|
|||
|
|
return wo
|
|||
|
|
return None
|
|||
|
|
|
|||
|
|
|
|||
|
|
def validate_backlog_item(item: Any, world: dict[str, Any]) -> str | None:
|
|||
|
|
"""补录项合法性(P3-DESIGN §6.3 逐项校验)。合法 → None;非法 → 显式原因。
|
|||
|
|
|
|||
|
|
规则:woId 存在;externalWoId 申报时必须与工单 mesExternalId 对得上;
|
|||
|
|
progressPct ∈ [0,100];finish 布尔;progressPct 与 finish 至少声明其一。
|
|||
|
|
"""
|
|||
|
|
if not isinstance(item, dict):
|
|||
|
|
return "补录项不是对象"
|
|||
|
|
wo_id = item.get("woId")
|
|||
|
|
if not isinstance(wo_id, int) or isinstance(wo_id, bool):
|
|||
|
|
return f"补录项 woId 必须是整数(实际 {wo_id!r})"
|
|||
|
|
wo = _find_work_order(world or {}, wo_id)
|
|||
|
|
if wo is None:
|
|||
|
|
return f"补录项 woId={wo_id} 在工单表中不存在"
|
|||
|
|
declared_ext = item.get("externalWoId")
|
|||
|
|
if declared_ext is not None and str(declared_ext) != str(wo.get("mesExternalId") or ""):
|
|||
|
|
return (f"补录项 woId={wo_id} 的 externalWoId 与系统下发记录对不上"
|
|||
|
|
f"(申报 {declared_ext!r},系统 {wo.get('mesExternalId')!r})")
|
|||
|
|
has_progress = item.get("progressPct") is not None
|
|||
|
|
has_finish = item.get("finish") is not None
|
|||
|
|
if not (has_progress or has_finish):
|
|||
|
|
return f"补录项 woId={wo_id} 必须声明 progressPct 或 finish 之一"
|
|||
|
|
if has_progress:
|
|||
|
|
pct = item.get("progressPct")
|
|||
|
|
if not isinstance(pct, (int, float)) or isinstance(pct, bool) \
|
|||
|
|
or not (0 <= pct <= 100):
|
|||
|
|
return f"补录项 woId={wo_id} 的 progressPct 必须在 [0,100](实际 {pct!r})"
|
|||
|
|
if has_finish and not isinstance(item.get("finish"), bool):
|
|||
|
|
return f"补录项 woId={wo_id} 的 finish 必须是 bool"
|
|||
|
|
if "offlineBooking" in item and not isinstance(item.get("offlineBooking"), bool):
|
|||
|
|
return f"补录项 woId={wo_id} 的 offlineBooking 必须是 bool"
|
|||
|
|
return None
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ---------------------------------------------------------------------------
|
|||
|
|
# S7 改配置:受控文档校验与白名单 diff 再生成
|
|||
|
|
# ---------------------------------------------------------------------------
|
|||
|
|
|
|||
|
|
# config.apply 允许替换的文件白名单(fallback-highrisk.json 只走 policy.update 专属通道)
|
|||
|
|
CONFIG_APPLY_FILES = ("features.json",)
|
|||
|
|
|
|||
|
|
|
|||
|
|
def validate_features_config_doc(doc: Any) -> str | None:
|
|||
|
|
"""features.json 新文档的独立结构校验(P3-DESIGN §7.3)。
|
|||
|
|
|
|||
|
|
刻意不 import feature_flags:那是 fail-open 的「可用性开关」加载语义,
|
|||
|
|
这里是 fail-closed 的「整文档替换」出卡校验——版本必须合法、features 段
|
|||
|
|
必须是「键 → bool」对象,否则拒绝出卡/执行。
|
|||
|
|
"""
|
|||
|
|
if not isinstance(doc, dict):
|
|||
|
|
return "配置文档顶层不是 JSON 对象"
|
|||
|
|
if doc.get("version") != 1:
|
|||
|
|
return f"配置文档 version 必须为 1(实际 {doc.get('version')!r})"
|
|||
|
|
features = doc.get("features")
|
|||
|
|
if not isinstance(features, dict) or not features:
|
|||
|
|
return "配置文档 features 段必须是非空对象"
|
|||
|
|
bad = sorted(k for k, v in features.items() if not isinstance(v, bool))
|
|||
|
|
if bad:
|
|||
|
|
return f"配置文档 features 含非 bool 取值: {bad}"
|
|||
|
|
return None
|
|||
|
|
|
|||
|
|
|
|||
|
|
def validate_config_apply_params(params: Any, *, check_hashes: bool = True) -> str | None:
|
|||
|
|
"""agent.fallback.ops.config.apply 冻结参数校验(出卡闸/出卡组装/执行共用)。
|
|||
|
|
|
|||
|
|
check_hashes=False(出卡闸对 Pi 计划):只校验文件白名单与文档合法性——
|
|||
|
|
contentSha256/beforeSha256 由编排器在出卡组装时按机器再生成原则填充
|
|||
|
|
(K 轮实测教训:Pi 没有计算 sha256 的工具,要求其申报指纹 = 物理不可用)。
|
|||
|
|
check_hashes=True(组装后/执行端):指纹必须与内容重算一致。
|
|||
|
|
"""
|
|||
|
|
if not isinstance(params, dict):
|
|||
|
|
return "config.apply 参数必须是对象"
|
|||
|
|
target = str(params.get("file") or "")
|
|||
|
|
if target not in CONFIG_APPLY_FILES:
|
|||
|
|
return f"config.apply 目标文件 {target!r} 不在允许集 {list(CONFIG_APPLY_FILES)}"
|
|||
|
|
content = params.get("content")
|
|||
|
|
err = validate_features_config_doc(content)
|
|||
|
|
if err:
|
|||
|
|
return f"config.apply 新文档非法:{err}"
|
|||
|
|
if not check_hashes:
|
|||
|
|
return None
|
|||
|
|
declared = str(params.get("contentSha256") or "")
|
|||
|
|
if canonical_sha256(content) != declared:
|
|||
|
|
return "config.apply contentSha256 与 content 重算不符(指纹虚报)"
|
|||
|
|
before = params.get("beforeSha256")
|
|||
|
|
if before is not None and not isinstance(before, str):
|
|||
|
|
return "config.apply beforeSha256 必须是字符串或 null"
|
|||
|
|
return None
|
|||
|
|
|
|||
|
|
|
|||
|
|
def apply_policy_diff(current_doc: dict[str, Any] | None,
|
|||
|
|
diff: dict[str, Any]) -> dict[str, Any]:
|
|||
|
|
"""从「当前白名单 + diff 声明」再生成完整新白名单文档(P3-DESIGN §2.4)。
|
|||
|
|
|
|||
|
|
Pi 只产 diff 声明,文档本体由编排器计算(卡片内容机器再生成原则)。
|
|||
|
|
diff = {"scenario", "addIntents"?, "removeIntents"?, "enabled"?,
|
|||
|
|
"roles"?, "maxItemsPerRun"?}。调用方必须再过 validate_whitelist_doc。
|
|||
|
|
Raises HighriskDenied:diff 自身形态非法。
|
|||
|
|
"""
|
|||
|
|
if not isinstance(diff, dict):
|
|||
|
|
raise HighriskDenied("policy diff 必须是对象")
|
|||
|
|
unknown = sorted(set(diff) - {"scenario", "addIntents", "removeIntents",
|
|||
|
|
"enabled", "roles", "maxItemsPerRun"})
|
|||
|
|
if unknown:
|
|||
|
|
raise HighriskDenied(f"policy diff 含未知字段: {unknown}")
|
|||
|
|
scenario = str(diff.get("scenario") or "")
|
|||
|
|
if scenario not in SCENARIOS:
|
|||
|
|
raise HighriskDenied(f"policy diff 场景 {scenario!r} 非法(只允许 {'/'.join(SCENARIOS)})")
|
|||
|
|
for key in ("addIntents", "removeIntents", "roles"):
|
|||
|
|
if key in diff and diff[key] is not None and not isinstance(diff[key], list):
|
|||
|
|
raise HighriskDenied(f"policy diff.{key} 必须是数组")
|
|||
|
|
|
|||
|
|
base = current_doc if isinstance(current_doc, dict) else {}
|
|||
|
|
scenarios = json.loads(json.dumps(base.get("scenarios") or {}))
|
|||
|
|
entry = scenarios.get(scenario) or {
|
|||
|
|
"enabled": False, "intents": [], "roles": ["admin"],
|
|||
|
|
}
|
|||
|
|
intents = [str(i) for i in entry.get("intents") or []]
|
|||
|
|
for intent in diff.get("addIntents") or []:
|
|||
|
|
if str(intent) not in intents:
|
|||
|
|
intents.append(str(intent))
|
|||
|
|
for intent in diff.get("removeIntents") or []:
|
|||
|
|
intents = [i for i in intents if i != str(intent)]
|
|||
|
|
entry["intents"] = intents
|
|||
|
|
if diff.get("enabled") is not None:
|
|||
|
|
entry["enabled"] = bool(diff["enabled"])
|
|||
|
|
if diff.get("roles") is not None:
|
|||
|
|
entry["roles"] = [str(r) for r in diff["roles"]]
|
|||
|
|
if diff.get("maxItemsPerRun") is not None:
|
|||
|
|
entry["maxItemsPerRun"] = diff["maxItemsPerRun"]
|
|||
|
|
scenarios[scenario] = entry
|
|||
|
|
|
|||
|
|
return {
|
|||
|
|
"whitelistVersion": WHITELIST_VERSION,
|
|||
|
|
"updatedAt": base.get("updatedAt"),
|
|||
|
|
"updatedBy": base.get("updatedBy"),
|
|||
|
|
"scenarios": scenarios,
|
|||
|
|
}
|