# ============================================================ # 兜底高风险治理 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..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, }