aps-agent/server/agent_core/fallback_highrisk.py

419 lines
19 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.

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