aps-agent/server/agent_core/fallback_highrisk.py

419 lines
19 KiB
Python
Raw Permalink Normal View History

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