# ============================================================ # 门禁 Harness v1(moduleId: core-harness, 可重生 ✅) # plan.md §3.3:所有写操作的唯一入口。M1 实现: # - 权力分级:P0/P1 放行;P2 生成确认卡(人工批准后才执行) # - 待确认队列:confirmId → 待执行动作(会话内存) # - 执行/驳回均写审计(§3.6) # ============================================================ from __future__ import annotations # 前向类型引用 import copy # 冻结审批参数 import hashlib # 审批参数摘要 import hmac # 常量时间比较确认信封摘要 import json # 稳定序列化审批参数 import os # TTL 配置 import time # 审批/凭据到期时间 import uuid # 确认卡 ID from collections.abc import Callable # 类型标注 from typing import Any from server.agent_core.approval_store import ( ApprovalBackend, ApprovalStore, get_approval_store, set_approval_store, utc_now, ) from server.contracts import UIAction, UIBlock # 确认卡的 UI 块契约 _approval_store = get_approval_store() # Compatibility aliases for existing diagnostics and tests. All mutations are # persisted by the public Harness operations below. _pending = getattr(_approval_store, "pending", {}) _execution_grants = getattr(_approval_store, "grants", {}) def _params_fingerprint(params: dict[str, Any]) -> str: payload = json.dumps( params, ensure_ascii=True, sort_keys=True, separators=(",", ":"), default=str, ).encode("utf-8") return hashlib.sha256(payload).hexdigest() def _confirmation_envelope_fingerprint(record: dict[str, Any]) -> str: projection = { key: copy.deepcopy(record.get(key)) for key in ( "sessionId", "action", "power", "paramsHash", "tenantUuid", "projectId", "worldKey", "evidenceRefs", "beforeSnapshot", "beforeFingerprint", "requester", "requiredApprovals", ) } payload = json.dumps( projection, ensure_ascii=True, sort_keys=True, separators=(",", ":"), default=str, ).encode("utf-8") return hashlib.sha256(payload).hexdigest() def verify_confirmation_envelope(record: dict[str, Any]) -> None: params = record.get("params") if not isinstance(params, dict): raise PermissionError("确认信封参数无效,请重新发起确认") params_hash = str(record.get("paramsHash") or "") if not params_hash or not hmac.compare_digest(params_hash, _params_fingerprint(params)): raise PermissionError("确认信封参数摘要不一致,请重新发起确认") expected = str(record.get("envelopeHash") or "") actual = _confirmation_envelope_fingerprint(record) if not expected or not hmac.compare_digest(expected, actual): raise PermissionError("确认信封完整性校验失败,请重新发起确认") action = str(record.get("action") or "") power = str(record.get("power") or "") if not action or power != power_of(action): raise PermissionError("确认信封动作或权力等级不一致,请重新发起确认") # 世界指纹:只对业务主数据计算,排除 append-only 日志/审计/同步流水(审批与执行之间这些字段会合法增长) _VOLATILE_WORLD_KEYS = frozenset({"auditEvents", "logs", "mesSyncJournal"}) def world_fingerprint(world: dict[str, Any]) -> str: """输入快照指纹:稳定投影的规范 JSON SHA256(漂移检测用)。 排除 append-only 字段(auditEvents/logs/mesSyncJournal),使「审批期间审计自然增长」 不会误报漂移;任何业务主数据变化都会改变指纹。 """ projection = { k: v for k, v in (world or {}).items() if k not in _VOLATILE_WORLD_KEYS } payload = json.dumps( projection, ensure_ascii=True, sort_keys=True, separators=(",", ":"), default=str, ).encode("utf-8") return hashlib.sha256(payload).hexdigest() _EVIDENCE_VERSION_PREFIX = "schedule-version:" def evidence_refs_for(params: dict[str, Any]) -> list[str]: """版本证据协议:参数含 versionId 的动作,统一生成 schedule-version: 证据引用。""" version_id = (params or {}).get("versionId") if version_id is None: return [] return [f"{_EVIDENCE_VERSION_PREFIX}{version_id}"] def _ttl_seconds(env_name: str, default: int) -> int: try: return max(1, int(os.environ.get(env_name) or default)) except ValueError: return default def _identity_ref() -> dict[str, Any]: from server.auth.context import get_identity identity = get_identity() return { "userId": str(identity.user_id), "username": identity.username, "fullname": identity.fullname, "roles": list(identity.roles), "authKind": identity.auth_kind, } def _approver_key(approver: dict[str, Any]) -> str: return str(approver.get("userId") or approver.get("username") or "") def _configured_roles(env_name: str, default: str) -> set[str]: raw = os.environ.get(env_name) or default return {value.strip().lower() for value in raw.split(",") if value.strip()} def _can_approve_current(action: str, power: str) -> bool: return _current_identity_has_approval_role("approve", action, power) def _can_initiate_current(action: str, power: str) -> bool: return _current_identity_has_approval_role("initiate", action, power) def require_can_initiate(action: str) -> None: """Fail-fast approval-initiation gate for staged actions. Call this before allocating checkpoints or performing any other staging-side effect. The check uses the current authenticated identity and the action's configured power level; denial is fail-closed and leaves staging untouched. """ power = power_of(action) if not _can_initiate_current(action, power): raise PermissionError("当前身份没有发起审批的角色权限") def _can_view_approval_history(action: str, power: str) -> bool: return _current_identity_has_approval_role("history", action, power) def _effective_world_key(project_id: str, owner_user_id: int) -> str: if project_id in ("default", "", "__personal__") and owner_user_id: return f"personal-{owner_user_id}" return project_id or "default" def configure_approval_store( path: str | None = None, *, store: ApprovalBackend | None = None ) -> ApprovalBackend: """Replace the process-local store, primarily for isolated runtimes/tests.""" global _approval_store, _pending, _execution_grants if path is not None and store is not None: raise ValueError("path and store are mutually exclusive") _approval_store = set_approval_store(ApprovalStore(path) if path else store) # Transitional diagnostics for the file backend only. Application code must # use ApprovalBackend operations because database state is never a mapping. _pending = getattr(_approval_store, "pending", {}) _execution_grants = getattr(_approval_store, "grants", {}) return _approval_store # 动作 → 权力等级 映射(封闭表:未登记的动作一律拒绝) # ⚠ 文档同步铁律(plan.md §12.9):改本表必同步 docs/architecture/harness.md 权力矩阵 _POWER_MAP: dict[str, str] = { "schedule.run": "P1", # 试排:写草稿版本(沙盒语义) "schedule.publish": "P2", # 发布:写主干世界状态 → 必须人工确认 "order.upsert": "P2", # 订单新增/编辑:写主干订单池 → 必须人工确认 "order.cancel": "P2", # 订单取消:影响后续排产输入 → 必须人工确认 "order.complete": "P2", # 订单完成:影响后续排产输入 → 必须人工确认 "order.delete": "P2", # 订单删除:物理移除订单池 → 必须人工确认 "order.clear": "P2", # 一键清空订单池 → 必须人工确认 "order.submit": "P1", # OR-03:提交审核(不写批准态) "order.approve": "P2", # OR-03:批准进排产池 "order.reject": "P2", # OR-03:驳回 "order.pool": "P0", # OR-03:订单池摘要 "rush.evaluate": "P1", # OR-04:插单影响快评(沙盒) "rush.apply": "P2", # OR-04:采用插单写主干+草稿排产 "forecast.query": "P0", # OR-05:预测订单只读 "forecast.upsert": "P2", # OR-05:新建/编辑预测 "forecast.delete": "P2", # OR-05:删除预测 "forecast.convert": "P2", # OR-05:预测转正为销售订单 "plan.buckets": "P0", # PL-01:时间分桶计划(只读) "plan.rccp": "P0", # PL-02:有限/无限粗能力对照(只读) "plan.feasibility": "P0", # PL-03:交期可行性(只读) "plan.inventory": "P0", # PL-04:库存投影(只读) "plan.leveling": "P0", # PL-05:产能平衡/削峰建议(只读) "plan.supply": "P0", # PL-06:产供方向决策(只读) "data.import": "P2", # 批量导入订单/物料:写主干 → 必须人工确认 "import.commit": "P2", # Excel/CSV 导入入库:写主干 → 必须人工确认 "folder.analyze": "P0", # 工程目录深度解析:只读 "folder.schedule": "P2", # 导入工程目录并试排 "flex.site.load": "P2", # 现场生产路线:替换 flex* 主数据 → 确认卡 "master.line.upsert": "P2", # 产线编辑/停启用:改后续新排产资源池 → 必须人工确认 "drawing.master.apply": "P2", # 图纸候选入库:解析候选写入物料/BOM/工艺主数据 → 必须人工确认 "master.material.upsert": "P2", # 物料新建/全字段/停用:改齐套与产品池 → 必须人工确认 "master.maintenance.upsert": "P2", # 维保新增/取消:改设备避让窗口 → 必须人工确认 "master.bom.upsert": "P2", # BOM 增删改:改齐套需求与采购建议 → 必须人工确认 "master.routing.upsert": "P2", # 工艺路线/步骤:改占槽工时与外协标记 → 必须人工确认 "master.operation.upsert": "P2", # 工序库:改路线可选步骤 → 必须人工确认 "master.lineProduct.upsert": "P2", # 产线-产品绑定:改选线依据 → 必须人工确认 "master.changeover.upsert": "P2", # MD-06:换型矩阵写入 → 必须人工确认 "master.workstation.upsert": "P2", # 工位新增/编辑/停用:改资源池 → 必须人工确认 "master.equipment.upsert": "P2", # 设备新增/编辑/停用:改资源池 → 必须人工确认 "master.bom.release": "P2", # BOM 版本发布:生成不可变快照 → 必须人工确认 "master.bom.rollback": "P2", # BOM 版本回滚:恢复已发布快照 → 必须人工确认 "master.routing.release": "P2", # 工艺路线版本发布:生成不可变快照 → 必须人工确认 "master.routing.rollback": "P2", # 工艺路线版本回滚:恢复已发布快照 → 必须人工确认 "master.calendar.holiday.upsert": "P2", # 节假日新增/编辑:改后续工作日历 → 必须人工确认 "master.calendar.template.create": "P2", # 日历周模板新建:定义班次/节假日 → 必须人工确认 "master.calendar.week.copy": "P2", # 复制周模板生成多周班次 → 必须人工确认 "master.clear": "P2", # 一键清理主数据板块(回种)→ 必须人工确认 "master.query": "P0", # 自然语言检索订单/主数据(只读) "changeover.query": "P0", # MD-06:换型矩阵只读 "campaign.preview": "P0", # SC-08:战役合并预览(只读) "params.update": "P2", # OR-02:排产参数/客户等级权重 → 必须人工确认 "params.query": "P0", # OR-02:查看当前排产参数(只读) "param.optimize": "P1", # 矩阵 87:参数优化闭环 split→回放→候选(GRAY 提案,不写主干参数) "param.experiment.promote": "P2", # 矩阵 87:参数实验升级 GRAY→ACTIVE/FULL(写 scheduleParams)→ 确认卡 "param.experiment.rollback": "P1", # 矩阵 87:参数实验手动回滚:恢复上一参数版本 "param.observation.record": "P1", # 矩阵 87:线上观测回调:观测落盘 + 连续劣化自动回滚 "constraint.profile.query": "P0", # SC-04:约束剖面只读 "constraint.profile.save": "P2", # SC-04:约束剖面保存 "constraint.rule.query": "P0", # SC-04:约束规则库只读 "constraint.rule.save": "P2", # SC-04:约束规则库扩充(新增/启停/删除) "governance.rule.create": "P2", # 治理规则创建:确认后写规则注册表 "governance.rule.update": "P2", # 治理规则更新:确认后替换规则定义 "governance.rule.delete": "P2", # 治理规则删除:确认后移除规则定义 "governance.rule.enable": "P2", # 治理规则启用:确认后参与自动化 tick "governance.rule.disable": "P2", # 治理规则停用:确认后退出自动化 tick "order.decompose": "P1", # 订单分解:产出采购/委外建议草稿,不碰主数据 "mesh.agent.create": "P1", # 多智能体:按需创建智能体(只写编排仓) "mesh.goal.create": "P1", # 多智能体:挂 Goal + 任务清单(只写编排仓) "mesh.dispatch": "P1", # 多智能体:分发执行(任务自带权力经 handle_intent) "mesh.reset": "P1", # 多智能体:恢复起始状态(清空编排仓) "mrp.release": "P2", # MRP 下达:草稿建议单转正式采购/委外 → 必须人工确认 "flex.schedule": "P1", # 柔性排产:能力池+虚拟产线,只写 flex* 草稿表 "flex.reschedule": "P2", # 分级重排 L2/L3/L4:影响面大 → 确认卡 "flex.swap": "P1", # L1 局部换机:只改正目标工单 "flex.capacity": "P0", # 瓶颈产能评估:只读能力池算日产能 "flex.simulate_due": "P0", # 交期承诺模拟:深拷贝沙盒试排,不碰主干 "flex.compare": "P0", # 三模式对比:沙盒试排,不碰主干 "flex.rush": "P1", # 紧急插单:写 flexOrders + 重排草稿 "flex.fault": "P1", # 设备故障:缩池 + 可选重排 "flex.resource.patch": "P1", # 柔性资源补丁:设备状态/区域、模具寿命/锁定 "flex.conflict.resolve": "P1", # EX-03:冲突安全修复(重排类另走 P2 stage) "conflict.list": "P0", # EX-03:冲突中心只读 "flex.adjust.preview": "P0", # EX-04:拖拽调程预览(只读校验) "flex.adjust.commit": "P2", # EX-04:拖拽调程提交(新草稿版本) "schedule.adjust.preview": "P0", # EX-04:固定轨调程预览 "schedule.adjust.commit": "P2", # EX-04:固定轨调程提交 "sap.status": "P0", # MD-05:SAP 连接状态(只读) "sap.sync.inbound": "P2", # MD-05:SAP→APS 拉单/库存 "sap.sync.outbound": "P3", # External SAP write: two approvers plus one-time grant "mes.status": "P0", # EX-05:MES 连接状态 "mes.dispatch": "P3", # EX-05:下发车间(外部副作用) "mes.report": "P1", # EX-09:报工回流写工单进度 "scenario.compare": "P1", # 方案对比:只写 Explore 沙盒(§5.1) "scenario.sensitivity": "P1", # SC-06:敏感性分析沙盒 Tornado "sop.compile": "P0", # IND-02:SOP 编译预览 "sop.apply": "P2", # IND-02:应用 SOP 规则包 "checkpoint.create": "P1", # 建档:只新增快照,不改世界 "checkpoint.rollback": "P2", # 回滚:整体替换主干世界 → 必须人工确认 "data.reset": "P2", # 重置数据:破坏性 → 必须人工确认 "readiness.query": "P0", # 数据齐备度检查:只读 "data.analyze": "P0", # 分析项目/文件数据与排产缺口:只读 "assistant.reply": "P0", # 通用助理:只读作答 "flex.time.update": "P2", # 工时维护:改柔性路线工时 → 确认卡 "knowledge.query": "P0", # 知识检索:只读(M3 §8.2) "knowledge.import": "P2", # RAG:导入知识文档切块入库 "skill.list": "P0", # 外部算法 skill 清单 "skill.health": "P0", # skill 健康检查 "skill.register": "P2", # 登记/更新 skill 配置 "skill.enable": "P2", # 启停 skill "rag.query": "P0", # RAG 查询:外部 skill 消费知识库(只读,带鉴权) "routing.template.apply": "P2", # 行业模板生成工艺路线 → 确认卡 "schedule.wizard": "P0", # 引导式排产向导(对话态,本身只读) "guidance.next": "P0", # AG-07:主动引导下一步建议(只读) "project.create": "P1", # AG-08:新建项目(不改世界) "project.delete": "P1", # AG-08:删项目元数据(不删世界) "session.create": "P1", # AG-08:新建会话 "message.append": "P1", # AG-08:追加/同步会话消息 "message.replace": "P1", # AG-08:整包替换会话消息 "workspace.replace": "P1", # AG-08:整包同步工作区(迁移用) "plan.trace": "P0", # 计划追溯:只读单据链 "report.generate": "P1", # 报告生成:产出文档并入知识库(M3 §9.10 规则3) "viewport.*": "P0", # 视口命令:纯视图状态 "query.*": "P0", # 查询:只读 "agent.fallback.propose": "P1", # 智能兜底:Pi 只读分析草稿(不写主干) "agent.fallback.execute": "P2", # 智能兜底:执行已批准计划(确认卡+checkpoint+计划锁+diff 验证) "agent.fallback.execute.highrisk": "P3", # 智能兜底高危执行:默认拒绝(P2 轮不开放白名单) "agent.fallback.policy.update": "P2", # FB-03:P3 白名单治理(改白名单必须过确认卡,编排器再生成完整文档) "agent.fallback.ops.config.apply": "P3", # FB-03:S7 受控配置变更(白名单放行 + 双人审批 + 执行授权) "agent.token.issue": "P2", # Pi Agent:人工确认后签发长期只读机器凭证 "agent.token.revoke": "P2", # Pi Agent:人工确认后吊销长期只读机器凭证 } # 各动作的中文说明(门禁管理台"权力矩阵"页签展示用;与 _POWER_MAP 键集合一致) _POLICY_DESC: dict[str, str] = { "schedule.run": "试排:只产草稿版本,不动已发布主干", "schedule.publish": "发布:写主干并推进订单状态,影响下游执行", "order.upsert": "新增/编辑销售订单:改后续排产输入", "order.cancel": "取消销售订单:从后续排产输入中排除", "order.complete": "完成销售订单:从后续排产输入中排除", "order.delete": "删除销售订单:从订单池物理移除", "order.clear": "一键清空全部销售订单与采购/委外建议", "order.submit": "订单提交审核:DRAFT/CHANGED → SUBMITTED", "order.approve": "订单批准:SUBMITTED/CHANGED → APPROVED(可排产)", "order.reject": "订单驳回:SUBMITTED/CHANGED → REJECTED", "order.pool": "订单池摘要:待审/已批/变更计数(只读)", "rush.evaluate": "插单快评:沙盒对比受影响订单/延迟/冲突(不改主干)", "rush.apply": "采用插单:写急单入池并生成 DRAFT 排产版本(写前自动建档)", "forecast.query": "预测订单查询:长周期需求台账(只读)", "forecast.upsert": "新建/编辑预测订单(不默认进正式排产)", "forecast.delete": "删除预测订单台账", "forecast.convert": "预测转正为 APPROVED 销售订单", "plan.buckets": "时间分桶计划:日/周/月/混合粗能力对照(只读)", "plan.rccp": "有限/无限产能粗评估对照(RCCP,只读)", "plan.feasibility": "交期可行性:累计粗能力能否按期(前置于排产)", "plan.inventory": "库存投影:物料随时间可用量曲线(只读)", "plan.leveling": "产能平衡/削峰:超载桶挪到空档建议(只读)", "plan.supply": "产供方向:加班/扩线/外协结构化建议(只读)", "data.import": "自然语言批量导入订单或物料主数据", "import.commit": "Excel/CSV 导入入库(校验通过行写入主干)", "folder.analyze": "工程目录深度解析:读表内容、样例与排产齐备度", "folder.schedule": "导入工程目录数据包并柔性试排", "flex.site.load": "加载现场完整生产路线:清空 flex* 演示种子并写入现场订单/路线/BOM", "master.line.upsert": "编辑/停启用产线:改后续新排产的资源池", "drawing.master.apply": "工程图纸候选入库:写入物料、BOM 与工艺主数据", "master.material.upsert": "新建/编辑/停用物料:改齐套检查与可选产品池", "master.maintenance.upsert": "新增/取消维保:改设备避让窗口", "master.bom.upsert": "BOM 头/明细增删改:改齐套需求与采购建议输入", "master.routing.upsert": "工艺路线/步骤增删改:改占槽工时与外协标记", "master.operation.upsert": "工序库新建/编辑:改路线可选步骤", "master.lineProduct.upsert": "产线-产品绑定:改排产选线依据(排产前必需)", "master.changeover.upsert": "换型矩阵:产品族切换准备时间(分钟)", "master.workstation.upsert": "工位新增/编辑/停用:停用后新排产不再选择", "master.equipment.upsert": "设备新增/编辑/停用:停用后新排产不再选择", "master.bom.release": "BOM 版本发布:生成不可变版本快照", "master.bom.rollback": "BOM 版本回滚:恢复已发布快照为默认", "master.routing.release": "工艺路线版本发布:生成不可变版本快照", "master.routing.rollback": "工艺路线版本回滚:恢复已发布快照为默认", "master.calendar.holiday.upsert": "节假日新增/编辑:影响后续周模板复制与新排产", "master.calendar.template.create": "日历周模板新建:班次/节假日模板", "master.calendar.week.copy": "复制周模板生成多周班次(排除节假日)", "master.clear": "一键清理主数据板块:按范围清空(非回种)", "master.query": "查询订单/物料/工序/BOM/工艺路线/主数据概览(只读)", "changeover.query": "换型矩阵只读查询", "campaign.preview": "战役/批次合并预览(同产品交期窗口)", "params.update": "排产参数:客户等级权重/目标权重/展望期(只影响新版本)", "params.query": "排产参数查询:只读当前客户等级与目标权重", "param.optimize": "参数优化闭环:训练/验证集隔离→回放→候选(产出 GRAY 灰度实验)", "param.experiment.promote": "参数实验升级:GRAY→ACTIVE/FULL 全量生效(需确认)", "param.experiment.rollback": "参数实验手动回滚:恢复上一参数版本", "param.observation.record": "线上观测回调:生产 KPI 采样落盘,连续劣化自动回滚实验", "constraint.profile.query": "约束配置查询:只读硬/软约束剖面", "constraint.profile.save": "约束配置保存:启停/硬软切换(只影响新版本与发布门禁)", "constraint.rule.query": "约束规则库查询:默认通用规则与计划员扩充规则概览", "constraint.rule.save": "约束规则库扩充:把计划员一句话登记为可复用规则(新增/启用/停用/删除,需确认)", "governance.rule.create": "创建治理规则:确认后写规则注册表", "governance.rule.update": "更新治理规则:确认后替换规则定义", "governance.rule.delete": "删除治理规则:确认后移除规则定义", "governance.rule.enable": "启用治理规则:确认后参与自动化 tick", "governance.rule.disable": "停用治理规则:确认后退出自动化 tick", "order.decompose": "订单分解:按 BOM/工艺产出采购与委外建议(草稿)", "mesh.agent.create": "多智能体编排:按需创建智能体(角色+能力边界)", "mesh.goal.create": "多智能体编排:挂 Goal,任务清单进入看门狗监控", "mesh.dispatch": "多智能体编排:按能力分发任务,缺人自动新建智能体", "mesh.reset": "多智能体编排:恢复起始状态(清空 Goal/消息/非系统智能体)", "mrp.release": "MRP 下达:草稿采购/委外建议单确认转为正式(RELEASED)", "flex.schedule": "柔性排产:设备能力池动态组虚拟产线,只产草稿版本", "flex.reschedule": "分级重排:L2短窗/L3日窗/L4全局(需确认)", "flex.swap": "L1局部换机:池内备机接手,他单不动", "flex.capacity": "瓶颈产能:按能力池实时算各工序日产能与限制性瓶颈", "flex.simulate_due": "交期承诺:询单沙盒试排,给乐观/预计/悲观完工与缺口建议", "flex.compare": "三模式对比:正排/倒排/瓶颈锚沙盒 KPI 对比", "flex.rush": "紧急插单:高优先柔性订单写入并瓶颈锚重排", "flex.fault": "设备故障:设备退出能力池并可选全量重排", "flex.resource.patch": "柔性资源补丁:设备停复机/调区、模具寿命与锁定", "flex.conflict.resolve": "冲突修复:L1换机/解锁模具等安全修复(重排类走确认卡)", "conflict.list": "冲突中心:列出最新版本未解决冲突与建议", "flex.adjust.preview": "甘特调程预览:边拖边校验,不改世界", "flex.adjust.commit": "甘特调程提交:生成新草稿版本(需确认)", "schedule.adjust.preview": "固定甘特调程预览:工位占槽/先后/维保校验", "schedule.adjust.commit": "固定甘特调程提交:生成新草稿版本(需确认)", "sap.status": "SAP 连接状态:Mock 桩只读探测", "sap.sync.inbound": "SAP 入站:拉生产订单与库存写入柔性世界", "sap.sync.outbound": "SAP 出站:工序开完工回写(Mock,幂等)", "mes.status": "MES 连接状态:Mock 桩只读探测", "mes.dispatch": "MES 下发:排产工单推送车间(外部副作用,幂等)", "mes.report": "MES 报工:进度回流更新工单状态", "scenario.compare": "方案对比:多策略试排全程在深拷贝沙盒", "scenario.sensitivity": "敏感性分析:参数扰动 Tornado(沙盒不写主干)", "sop.compile": "SOP→约束编译预览(不写主干)", "sop.apply": "应用 SOP 规则包到约束剖面/换型策略", "checkpoint.create": "建档:只新增成对快照文件", "checkpoint.rollback": "回滚:整体替换主干世界(时间旅行)", "data.reset": "重置:清空重播种子数据", "readiness.query": "数据齐备度:产品→路线→工时→资源→日历逐项检查,输出缺失清单", "data.analyze": "分析项目/附件数据:盘点现有主数据与订单,并列出排产缺口", "assistant.reply": "通用排产助理:结合项目上下文与知识库作答", "flex.time.update": "工时维护:更新产品×工序单件工时并标记来源(需确认)", "knowledge.query": "知识检索:只读命中知识资产,回答强制带出处", "knowledge.import": "导入知识文档:PDF/docx/md 切块入库(需确认)", "skill.list": "外部算法 skill 清单(只读)", "skill.health": "外部算法 skill 健康检查(只读)", "skill.register": "登记/更新外部算法 skill 端点配置", "skill.enable": "启用或停用外部算法 skill", "rag.query": "RAG 查询:命中知识 chunk + 出处(按 manifest ragScopes 鉴权)", "routing.template.apply": "行业工艺模板实例化为产品路线(工时标「模板」,需确认)", "schedule.wizard": "引导式排产向导:readiness→缺口引导→模板推荐→试排", "guidance.next": "主动引导:按空态/冲突/知识未命中给出下一步口令", "project.create": "新建项目与默认会话(只写工作区元数据,不改排产世界)", "project.delete": "删除项目及其会话/文件/消息(不删除世界状态)", "session.create": "在项目下新建会话话题", "message.append": "向会话追加一条消息", "message.replace": "整包替换会话消息(前端防抖同步)", "workspace.replace": "整包同步工作区(本地迁移到服务端)", "plan.trace": "计划追溯/钉扎:固定 SO 链或柔性 FO→BOM→VL→WO→设备负荷(OR-06)", "report.generate": "报告生成:冻结快照模板化产出,报告入知识库", "viewport.*": "视口命令:纯前端视图状态(模式/过滤/聚焦/高亮)", "query.*": "查询:只读(KPI/世界视图)", "agent.fallback.propose": "智能兜底(Pi Agent):意图未识别时拉起 Pi 只读分析,产出草稿报告(不写世界)", "agent.fallback.execute": "智能兜底执行:已批准计划逐步落主干(确认卡+成对快照+计划锁熔断回滚+diff 对账)", "agent.fallback.execute.highrisk": "智能兜底高危执行:默认拒绝,P2 轮不开放白名单", "agent.fallback.policy.update": "P3 白名单治理:改白名单唯一通道(确认卡 + 原子写 + 双端指纹比对)", "agent.fallback.ops.config.apply": "S7 受控配置变更:白名单放行 + 双人审批 + 执行授权 + 原子替换", "agent.token.issue": "Pi Agent 长期只读凭证签发:经确认卡登记租户/设备绑定与有效期", "agent.token.revoke": "Pi Agent 长期只读凭证吊销:经确认卡把登记记录置为 revoked", } _APPROVAL_POLICY_ENV = "APS_APPROVAL_ROLE_POLICIES" _APPROVAL_CAPABILITIES = ("initiate", "approve", "history") _GLOBAL_APPROVAL_ROLES = { "initiate": ("APS_APPROVAL_INITIATOR_ROLES", "planner,approver,admin"), "approve": ("APS_APPROVAL_ROLES", "planner,approver,admin"), "history": ("APS_APPROVAL_VIEW_ROLES", "planner,approver,admin,auditor"), } class ApprovalRolePolicyError(ValueError): """The scoped approval role configuration is malformed or inconsistent.""" def _reject_duplicate_json_keys(pairs: list[tuple[str, Any]]) -> dict[str, Any]: parsed: dict[str, Any] = {} for key, value in pairs: if key in parsed: raise ApprovalRolePolicyError(f"duplicate key {key!r}") parsed[key] = value return parsed def _normalize_policy_rule(scope: str, raw_rule: Any) -> dict[str, tuple[str, ...]]: if not isinstance(raw_rule, dict) or not raw_rule: raise ApprovalRolePolicyError(f"{scope} must be a non-empty object") unknown = set(raw_rule) - set(_APPROVAL_CAPABILITIES) if unknown: raise ApprovalRolePolicyError(f"{scope} has unknown fields: {sorted(unknown)}") normalized: dict[str, tuple[str, ...]] = {} for capability, raw_roles in raw_rule.items(): if not isinstance(raw_roles, list): raise ApprovalRolePolicyError(f"{scope}.{capability} must be an array") roles: list[str] = [] for raw_role in raw_roles: if not isinstance(raw_role, str) or not raw_role.strip(): raise ApprovalRolePolicyError( f"{scope}.{capability} roles must be non-empty strings" ) role = raw_role.strip().lower() if role in roles: raise ApprovalRolePolicyError(f"{scope}.{capability} contains duplicate role {role!r}") roles.append(role) normalized[capability] = tuple(sorted(roles)) return normalized def _approval_role_policy_config() -> dict[str, dict[str, dict[str, tuple[str, ...]]]]: raw = os.environ.get(_APPROVAL_POLICY_ENV) if raw is None or raw == "": return {"actions": {}, "powers": {}} if len(raw) > 64 * 1024: raise ApprovalRolePolicyError(f"{_APPROVAL_POLICY_ENV} exceeds 64 KiB") try: document = json.loads(raw, object_pairs_hook=_reject_duplicate_json_keys) except (json.JSONDecodeError, ApprovalRolePolicyError) as exc: raise ApprovalRolePolicyError(f"invalid {_APPROVAL_POLICY_ENV}: {exc}") from exc if not isinstance(document, dict): raise ApprovalRolePolicyError(f"{_APPROVAL_POLICY_ENV} must be a JSON object") unknown_root = set(document) - {"actions", "powers"} if unknown_root: raise ApprovalRolePolicyError(f"unknown policy sections: {sorted(unknown_root)}") actions = document.get("actions", {}) powers = document.get("powers", {}) if not isinstance(actions, dict) or not isinstance(powers, dict): raise ApprovalRolePolicyError("actions and powers must be JSON objects") normalized_actions: dict[str, dict[str, tuple[str, ...]]] = {} for action, rule in actions.items(): if action not in _POWER_MAP or action.endswith(".*"): raise ApprovalRolePolicyError(f"unknown or non-exact approval action {action!r}") if _POWER_MAP[action] not in ("P2", "P3"): raise ApprovalRolePolicyError(f"action {action!r} does not require approval") normalized_actions[action] = _normalize_policy_rule(f"actions.{action}", rule) normalized_powers: dict[str, dict[str, tuple[str, ...]]] = {} for power, rule in powers.items(): if power not in ("P2", "P3"): raise ApprovalRolePolicyError(f"approval power must be P2 or P3, got {power!r}") normalized_powers[power] = _normalize_policy_rule(f"powers.{power}", rule) return {"actions": normalized_actions, "powers": normalized_powers} def _resolve_approval_roles( capability: str, action: str, power: str, *, config: dict[str, dict[str, dict[str, tuple[str, ...]]]] | None = None, ) -> tuple[tuple[str, ...], str, str]: if capability not in _APPROVAL_CAPABILITIES: raise ApprovalRolePolicyError(f"unknown approval capability {capability!r}") canonical_power = power_of(action) if power != canonical_power: raise ApprovalRolePolicyError( f"approval record power mismatch for {action!r}: {power!r} != {canonical_power!r}" ) resolved = _approval_role_policy_config() if config is None else config action_rule = resolved["actions"].get(action, {}) if capability in action_rule: return action_rule[capability], "action", action power_rule = resolved["powers"].get(power, {}) if capability in power_rule: return power_rule[capability], "power", power env_name, default = _GLOBAL_APPROVAL_ROLES[capability] return tuple(sorted(_configured_roles(env_name, default))), "global", env_name def _current_identity_has_approval_role(capability: str, action: str, power: str) -> bool: from server.auth.context import get_identity try: allowed_roles, _source, _scope = _resolve_approval_roles(capability, action, power) except ApprovalRolePolicyError: return False roles = {role.lower() for role in get_identity().roles} return "system" in roles or bool(roles & set(allowed_roles)) def _approval_role_policy_projection( action: str, power: str, *, config: dict[str, dict[str, dict[str, tuple[str, ...]]]] | None = None, error: ApprovalRolePolicyError | None = None, ) -> dict[str, Any]: if error is not None: return { "valid": False, "systemBypass": False, "error": str(error), **{ capability: {"roles": [], "source": "invalid", "scope": _APPROVAL_POLICY_ENV} for capability in _APPROVAL_CAPABILITIES }, } projection: dict[str, Any] = {"valid": True, "systemBypass": True} for capability in _APPROVAL_CAPABILITIES: roles, source, scope = _resolve_approval_roles( capability, action, power, config=config, ) projection[capability] = { "roles": list(roles), "source": source, "scope": scope, } return projection def list_policy() -> list[dict[str, Any]]: """权力矩阵投影(门禁管理台数据源;P0 只读,§6.10.2)。 Returns: 原矩阵字段加 rolePolicy 投影,顺序与 _POWER_MAP 声明一致。 """ try: config = _approval_role_policy_config() policy_error = None except ApprovalRolePolicyError as exc: config = None policy_error = exc return [{ "action": action, # 动作名(支持前缀通配) "power": power, # 权力等级 P0-P3 "desc": _POLICY_DESC.get(action, ""), # 中文说明 "confirm": power in ("P2", "P3"), # 是否需要人工确认 "rolePolicy": ( _approval_role_policy_projection( action, power, config=config, error=policy_error, ) if power in ("P2", "P3") else None ), } for action, power in _POWER_MAP.items()] def power_of(action: str) -> str: """查动作的权力等级(前缀通配;未登记按最高 P3 拒绝——白名单原则)。""" if action in _POWER_MAP: # 精确命中 return _POWER_MAP[action] prefix = action.split(".")[0] + ".*" # 前缀通配(viewport.* / query.*) return _POWER_MAP.get(prefix, "P3") # 未登记 → P3(默认最严) def needs_confirm(action: str) -> bool: """该动作是否需要人工确认(P2/P3 强制过卡,§3.2 权力边界)。""" return power_of(action) in ("P2", "P3") # 写主干/外部副作用必须确认 def verify_pending_evidence( pending: dict[str, Any], *, checkpoint_store: Any = None, version_lookup: Any = None, current_world: dict[str, Any] | None = None, ) -> None: """确认执行前的统一证据校验(§3.4 证据链 v1,fail closed)。 校验项: - 版本证据绑定:普通版本动作必须携带 schedule-version:; SAP 出站必须携带其专用 sap-outbound-version:; - 前置快照存在:beforeSnapshot 指向的 checkpoint 必须存在(提供 checkpoint_store 时); - 版本一致性:versionId 指向的版本仍可解析(对象域校验,避免全局指纹误伤)。 Raises PermissionError;调用方负责写 DENIED 审计并保证无世界写入。 """ params = pending.get("params") or {} evidence_refs = list(pending.get("evidenceRefs") or []) version_id = params.get("versionId") if version_id is not None: expected_ref = ( f"sap-outbound-version:{version_id}" if pending.get("action") == "sap.sync.outbound" else f"{_EVIDENCE_VERSION_PREFIX}{version_id}" ) if expected_ref not in evidence_refs: raise PermissionError( "确认动作缺少版本证据" f"(期望 {expected_ref},实际 {evidence_refs or '无'})" ) before_snapshot = pending.get("beforeSnapshot") if ( before_snapshot and checkpoint_store is not None and checkpoint_store.get(before_snapshot) is None ): raise PermissionError("确认动作的前置快照不存在") if version_id is not None and version_lookup is not None and not version_lookup(version_id): raise PermissionError("确认版本不存在或已失效,请重新发起确认") masterdata_fingerprints = [ ref.removeprefix("masterdata-world:") for ref in evidence_refs if ref.startswith("masterdata-world:") ] if len(set(masterdata_fingerprints)) > 1: raise PermissionError("确认动作包含冲突的主数据指纹") # Global beforeFingerprint is retained as audit evidence, but it is too broad # for generic actions that legitimately stage several independent writes. # Master-data actions bind an explicit stable projection hash and therefore # opt into strict whole-world drift rejection here. before_fingerprint = masterdata_fingerprints[0] if masterdata_fingerprints else None if before_fingerprint and current_world is not None: current_fingerprint = world_fingerprint(current_world) if current_fingerprint != before_fingerprint: raise PermissionError("确认动作输入世界已漂移,请重新发起确认") def _capture_world_fingerprint(tenant_uuid: str, world_key: str) -> str | None: """出卡时捕获输入快照指纹(仅当 scoped store 已加载;否则 None 跳过漂移强制)。""" try: from server.state.store import peek_scoped_store store = peek_scoped_store(tenant_uuid, world_key) if store is None: return None return world_fingerprint(store.data) except Exception: # noqa: BLE001 - optional fingerprint capture must not block staging return None def refresh_confirmation_world_fingerprint(confirm_id: str, fingerprint: str) -> bool: """把待确认记录的 beforeFingerprint 推进到指定值(仅当原值非 None)。 用途(FB-02 真实冒烟实测缺陷修复):出卡流程自身的落账(如 GATE 出卡审计) 会改变世界指纹,若指纹停留在 stage_confirmation 捕获时刻,执行端漂移比对 永远误报。出卡方在完成全部出卡期写入后调用本函数,把冻结指纹对齐到 「卡片就绪时刻」。审批窗口内的后续业务改动仍会被漂移检测正常拦截。 返回是否实际刷新(仓结构不符/记录缺失/原值为 None 时安全返回 False)。 """ pending = getattr(_approval_store, "pending", None) if not isinstance(pending, dict): return False rec = pending.get(confirm_id) if not rec or not rec.get("beforeFingerprint"): return False rec["beforeFingerprint"] = fingerprint # 信封投影含 beforeFingerprint:字段推进后必须同步重算信封哈希,否则 # verify_confirmation_envelope 必判「完整性校验失败」(P3 S6 多卡场景实测暴露: # 第二张及以后的卡在出卡期捕获到非空指纹,被推进后信封自破)。 rec["envelopeHash"] = _confirmation_envelope_fingerprint(rec) save = getattr(_approval_store, "save", None) if callable(save): save() return True def stage_confirmation(session_id: str, action: str, params: dict[str, Any], title: str, summary_lines: list[str], *, evidence_refs: list[str] | None = None, before_snapshot: str | None = None, delegate_user_id: int | None = None) -> UIBlock: """把 P2 动作压入待确认队列并生成确认卡 UI 块(动作此刻并未执行)。 权力等级:P1(只登记意图与出卡,不触发任何写)。 证据协议(§3.4 证据链 v1):出卡时捕获输入快照指纹(世界已加载时)、 版本证据引用(显式或按 versionId 派生)与前置快照 ID,随记录冻结; 执行端据此做漂移/缺项/版本一致性校验(fail closed)。 delegate_user_id:审批委托(可选)——P2 确认卡可指定被委托人, 委托人不改变 P3 双人职责分离语义(P3 仍强制两个不同用户)。 """ now = time.time() ttl = _ttl_seconds("APS_APPROVAL_TTL_SECONDS", 24 * 60 * 60) frozen_params = copy.deepcopy(params) power = power_of(action) require_can_initiate(action) from server.auth.context import get_identity identity = get_identity() if identity.user_id: from server.state.projects import get_project_store project_id = get_project_store().active_world_key() else: project_id = "default" world_key = _effective_world_key(project_id, identity.user_id) refs = list(evidence_refs or evidence_refs_for(frozen_params)) before_fingerprint = _capture_world_fingerprint(identity.tenant_uuid, world_key) while True: confirm_id = uuid.uuid4().hex[:12] record = { # 登记待确认动作 "confirmId": confirm_id, "sessionId": session_id, "action": action, "power": power, "params": frozen_params, "paramsHash": _params_fingerprint(frozen_params), "tenantUuid": identity.tenant_uuid, "ownerUserId": identity.user_id, "delegateUserId": delegate_user_id, "projectId": project_id, "worldKey": world_key, "evidenceRefs": refs, "beforeSnapshot": before_snapshot, "beforeFingerprint": before_fingerprint, "requester": _identity_ref(), "approvals": [], "approvalStep": 0, "requiredApprovals": 2 if power == "P3" else 1, "createdAt": utc_now(now), "createdAtEpoch": now, "expiresAt": utc_now(now + ttl), "expiresAtEpoch": now + ttl, } record["envelopeHash"] = _confirmation_envelope_fingerprint(record) if _approval_store.stage(record, now_epoch=now): break # Agent 自动编排(矩阵 110 行):出卡时创建 L2 Plan 节点(尽力而为,不阻断门禁) try: from server.agent_core.plan_orchestration import stage_plan_node stage_plan_node( session_id=session_id, action=action, params=frozen_params, confirm_id=confirm_id, power=power, actor=str(identity.username if identity else "planner"), ) except Exception: # noqa: BLE001, S110 - optional plan linkage is best-effort pass return UIBlock( # 确认卡:前端渲染为审批卡片 blockId=f"confirm-{confirm_id}", # 块 ID 复用确认 ID type="confirm-card", # 块类型 props={ # 卡片内容 "confirmId": confirm_id, # 前端回传用 "title": title, # 卡片标题 "summary": summary_lines, # 影响面说明(逐行) "power": power, # 展示权力等级 "action": action, # 待执行动作名 }, actions=[ # 批准/驳回两个动作 UIAction(actionId="confirm", label="批准执行", power=power, # type: ignore[arg-type] payload={"confirmId": confirm_id}), UIAction(actionId="reject", label="驳回", power="P0", payload={"confirmId": confirm_id}), ], ) def _matches_current_scope(record: dict[str, Any]) -> bool: from server.auth.context import get_identity identity = get_identity() if record.get("tenantUuid") != identity.tenant_uuid: return False project_id = record.get("projectId") or "default" if project_id == "default": if record.get("power") == "P3": return True allowed_ids = (record.get("ownerUserId"), record.get("delegateUserId")) return identity.user_id in allowed_ids if not identity.user_id: return False from server.state.projects import get_project_store return get_project_store().active_world_key() == project_id def _approval_record_can_approve(record: dict[str, Any]) -> bool: return _matches_current_scope(record) and _can_approve_current( str(record.get("action") or ""), str(record.get("power") or ""), ) def take_confirmation(confirm_id: str, approve: bool = True, note: str | None = None) -> dict[str, Any] | None: """处理一重审批;P2 一次放行,P3 两次批准后才取出执行。 note:审批意见(可选),随批准/驳回记录进 approvals 与审批历史。 """ now = time.time() def allowed(record: dict[str, Any]) -> bool: if not _approval_record_can_approve(record): return False verify_confirmation_envelope(record) return True return _approval_store.decide( confirm_id, approve=approve, approver=_identity_ref(), allowed=allowed, now_epoch=now, grant_ttl_seconds=_ttl_seconds("APS_EXECUTION_GRANT_TTL_SECONDS", 5 * 60), note=note, ) def transfer_confirmation(confirm_id: str, to_user_id: int) -> dict[str, Any] | None: """转派待确认项给指定用户(P2;仅当前可审批者发起;P3 拒绝保 SOD)。""" now = time.time() return _approval_store.transfer( confirm_id, to_user_id=to_user_id, allowed=_approval_record_can_approve, now_epoch=now, actor=_identity_ref(), ) def is_confirmation_pending(confirm_id: str) -> bool: """确认令牌是否仍等待下一重审批(供 API 控制 refresh 与 UI 状态)。""" return _approval_store.pending_record( confirm_id, allowed=_approval_record_can_approve, now_epoch=time.time(), ) is not None def pending_confirmation(confirm_id: str) -> dict[str, Any] | None: """Return the current scoped record for trusted server-side action routing.""" return _approval_store.pending_record( confirm_id, allowed=_approval_record_can_approve, now_epoch=time.time(), ) def resolve_confirmation_store(confirm_id: str, fallback: Any) -> Any | None: """Resolve the immutable target world without trusting the approver workspace.""" pending = _approval_store.pending_record( confirm_id, allowed=_approval_record_can_approve, now_epoch=time.time(), ) if pending is None: return None tenant_uuid = str(pending.get("tenantUuid") or "platform") world_key = str( pending.get("worldKey") or _effective_world_key( str(pending.get("projectId") or "default"), int(pending.get("ownerUserId") or 0), ) ) if ( getattr(fallback, "tenant_uuid", tenant_uuid) == tenant_uuid and getattr(fallback, "world_key", world_key) == world_key ): return fallback from server.state.store import _safe_scope, _scoped_store return _scoped_store(tenant_uuid, _safe_scope(world_key, "default")) def consume_execution_grant( grant: str, *, confirm_id: str, action: str, params: dict[str, Any], ) -> bool: """Consume the scoped one-time receipt issued after a final P3 approval.""" return _approval_store.consume_grant( grant, confirm_id=confirm_id, action=action, params_hash=_params_fingerprint(params), allowed=_matches_current_scope, decided_by=_identity_ref(), now_epoch=time.time(), ) def list_pending() -> list[dict[str, Any]]: """列出全部待确认动作(门禁管理台"待审批"页签的数据源,§6.10.2)。""" from server.auth.context import get_identity identity = get_identity() if identity.user_id: from server.state.projects import get_project_store project_id = get_project_store().active_world_key() else: project_id = "default" return _approval_store.pending_items( tenant_uuid=identity.tenant_uuid, project_id=project_id, allowed=_approval_record_can_approve, now_epoch=time.time(), ) def list_approval_history(limit: int = 100) -> list[dict[str, Any]]: """Return recent approval decisions visible in the current scope.""" from server.auth.context import get_identity bounded = max(1, min(int(limit), 500)) identity = get_identity() if identity.user_id: from server.state.projects import get_project_store project_id = get_project_store().active_world_key() else: project_id = "default" return _approval_store.history_items( tenant_uuid=identity.tenant_uuid, project_id=project_id, allowed=lambda record: _matches_current_scope(record) and _can_view_approval_history( str(record.get("action") or ""), str(record.get("power") or ""), ), limit=bounded, now_epoch=time.time(), ) def guard(action: str, params: dict[str, Any], executor: Callable[[], Any]): """P0/P1 动作的直通执行护栏:校验权力等级后执行(P2/P3 禁止走此通道)。 权力等级:随 action 而定;本函数强制 P2/P3 抛错(必须走 stage_confirmation)。 """ if needs_confirm(action): # P2/P3 误入直通通道 raise PermissionError(f"动作 {action} 为 {power_of(action)},必须经确认卡执行") # 硬拒绝 return executor() # P0/P1 直接执行