deliver: 交付收口(引擎代为提交)

This commit is contained in:
agent.develop 2026-09-16 19:57:40 +08:00
parent e6a56062c9
commit ef2c2a1816
6 changed files with 965 additions and 7 deletions

View File

@ -1 +1,9 @@
包声明80B
# -*- coding: utf-8 -*-
"""pbl_agent_runtime —— Designer/Critic Agent 运行时与 fail-closed 工具裁决M4a/M4b
包出口
from pbl_agent_runtime.init import load_pbl_agent_runtime
"""
__all__ = ["init", "tables", "tool_registry", "verdict"]
__version__ = "1.0.0"

View File

@ -1 +1,140 @@
挂载入口2,130Bensure_tables 4 load_pbl_agent_runtime(app,sor,ensure) **挂载即执行 self_check()**22=13+9 数量不符或写保护域有启用写工具即启动失败注册 tools/enabled_tools/disabled_tools/api(judge/call_tool/verdict_stats/self_check)返回 default_verdict
# -*- coding: utf-8 -*-
"""pbl_agent_runtime 挂载入口M4a/M4b
load_pbl_agent_runtime(env=None)
1. ensure_tables 4 幂等
2. **挂载即执行 self_check()**22=13+9 数量不符或写保护域存在启用写工具
6 探针裁决不符 RuntimeError应用启动失败fail-closed绝不带病上线
3. 注册 tools / enabled_tools / disabled_tools / api(judge/call_tool/verdict_stats/self_check)
4. 返回 default_verdict= 'DENY'
对齐 docs/01-design/agent-tool-contract.md13 启用 / 9 禁用fail-closed
"""
from . import tables as _tables
from . import tool_registry as _registry
from . import verdict as _verdict
MODULE = "pbl_agent_runtime"
EXPECTED_TOTAL = 22
EXPECTED_ENABLED = 13
EXPECTED_DISABLED = 9
def ensure_tables(env=None, sor=None):
"""幂等建 4 表。返回 (ok_list, bad_list)。"""
return _tables.ensure_tables(env=env, sor=sor)
def self_check(env=None, run_probes=True):
"""模块自检:数量契约 + 写保护域 + 裁决探针。返回 (all_ok, msgs)。"""
msgs = []
all_ok = True
tools = _registry.all_tools()
enabled = [t for t in tools if t.get("enabled")]
disabled = [t for t in tools if not t.get("enabled")]
if len(tools) != EXPECTED_TOTAL:
all_ok = False
msgs.append("工具总数=%d 应为 %d" % (len(tools), EXPECTED_TOTAL))
if len(enabled) != EXPECTED_ENABLED:
all_ok = False
msgs.append("启用工具=%d 应为 %d" % (len(enabled), EXPECTED_ENABLED))
if len(disabled) != EXPECTED_DISABLED:
all_ok = False
msgs.append("禁用工具=%d 应为 %d" % (len(disabled), EXPECTED_DISABLED))
msgs.append("工具契约:%d = %d 启用 + %d 禁用" % (len(tools), len(enabled), len(disabled)))
reg_ok, reg_msgs = _registry.self_check()
if not reg_ok:
all_ok = False
msgs.extend(reg_msgs)
tbl_ok, tbl_msgs = _tables.self_check()
if not tbl_ok:
all_ok = False
msgs.extend(tbl_msgs)
if run_probes:
v_ok, v_msgs = _verdict.runtime_self_check()
if not v_ok:
all_ok = False
msgs.extend(v_msgs)
if _verdict.DEFAULT_VERDICT != "DENY":
all_ok = False
msgs.append("DEFAULT_VERDICT=%r 必须为 'DENY'fail-closed"
% _verdict.DEFAULT_VERDICT)
if all_ok:
msgs.append("SELF_CHECK %s: PASS %d/%d" % (MODULE, len(tools), len(tools)))
return all_ok, msgs
def api():
"""对外 API 契约(供应用/其它模块调用)。"""
return {
"judge": _verdict.judge,
"call_tool": _verdict.call_tool,
"verdict_stats": _verdict.verdict_stats,
"self_check": self_check,
"get_tool": _registry.get_tool,
"all_tools": _registry.all_tools,
"ensure_tables": ensure_tables,
"POLICY_VERSION": _verdict.POLICY_VERSION,
"MAX_CALLS_PER_RUN": _verdict.MAX_CALLS_PER_RUN,
"PblError": _verdict.PblError,
}
def load_pbl_agent_runtime(env=None):
"""挂载入口:建表 → 自检(不过即抛)→ 注册契约 → 返回 default_verdict。"""
srv = env
if srv is None:
try:
from ahserver.serverenv import ServerEnv
srv = ServerEnv()
except Exception: # noqa: BLE001
srv = None
if srv is not None:
ensure_tables(srv)
ok, msgs = self_check()
for m in msgs:
_log(srv, m)
if not ok:
raise RuntimeError(
"%s self_check FAILEDfail-closed拒绝启动%s"
% (MODULE, "; ".join([m for m in msgs if "FAIL" in m or "应为" in m or "必须" in m][:6]))
)
if srv is not None:
setattr(srv, "pbl_agent_tools", _registry.all_tools())
setattr(srv, "pbl_agent_enabled_tools", [t["name"] for t in _registry.ENABLED_TOOLS])
setattr(srv, "pbl_agent_disabled_tools", [t["name"] for t in _registry.DISABLED_TOOLS])
setattr(srv, "pbl_agent_api", api())
modules = getattr(srv, "modules", None)
if isinstance(modules, list) and MODULE not in modules:
modules.append(MODULE)
return _verdict.DEFAULT_VERDICT
def _log(srv, msg):
logger = getattr(srv, "logger", None) if srv is not None else None
if logger is not None and hasattr(logger, "info"):
try:
logger.info("[%s] %s" % (MODULE, msg))
return
except Exception: # noqa: BLE001
pass
print("[%s] %s" % (MODULE, msg))
if __name__ == "__main__":
_ok, _msgs = self_check(run_probes=True)
for _m in _msgs:
print(_m)
print("RESULT: %s" % ("PASS" if _ok else "FAIL"))
raise SystemExit(0 if _ok else 1)

View File

@ -1 +1,161 @@
4 DDL6,062Bpbl_agent_run(agent_kind designer/critic,goal,status,model,step_count,tool_call_count,tool_deny_count,result) / pbl_agent_step(uk tenant+run+step_no,phase think/tool/observe/plan/final,tokens_in/out,elapsed_ms) / pbl_tool_call(args 脱敏后,verdict_id,verdict,result,state,error_code,elapsed_ms) / pbl_tool_verdict(append-only,verdict,gate,reason_code,reason,checks 九关逐项 JSON,policy_version)
# -*- coding: utf-8 -*-
"""pbl_agent_runtime 表定义与建表M4a
4 mariadb 方言BIGINT AUTO_INCREMENT 主键tenant_id 强制打头 FK/ENUM/TIMESTAMP
* pbl_agent_run Agent 运行主记录designer/critic
* pbl_agent_step 运行步骤think/tool/observe/plan/final
* pbl_tool_call 工具调用留痕ALLOW/DENY 都落库args 已脱敏
* pbl_tool_arbitration 裁决明细九关链逐关结果
对齐 docs/01-design/data-model.md agent-tool-contract.md
"""
TABLES = ["pbl_agent_run", "pbl_agent_step", "pbl_tool_call", "pbl_tool_arbitration"]
DDL = [
"""
CREATE TABLE IF NOT EXISTS `pbl_agent_run` (
`id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键',
`tenant_id` BIGINT NOT NULL DEFAULT 0 COMMENT '租户ID多租户强制打头',
`run_code` VARCHAR(64) NOT NULL COMMENT '运行编码',
`agent_kind` VARCHAR(16) NOT NULL DEFAULT 'designer' COMMENT 'designer/critic',
`goal` VARCHAR(512) NOT NULL DEFAULT '' COMMENT '本轮目标',
`blueprint_id` BIGINT NOT NULL DEFAULT 0 COMMENT '关联蓝图ID',
`status` VARCHAR(16) NOT NULL DEFAULT 'running' COMMENT 'running/done/failed/aborted',
`model` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '模型名',
`step_count` INT NOT NULL DEFAULT 0 COMMENT '步骤数',
`tool_call_count` INT NOT NULL DEFAULT 0 COMMENT '工具调用数',
`tool_deny_count` INT NOT NULL DEFAULT 0 COMMENT '被拒工具调用数',
`result` TEXT COMMENT '运行结果摘要',
`error_code` VARCHAR(32) NOT NULL DEFAULT '' COMMENT '错误码',
`created_by` BIGINT NOT NULL DEFAULT 0 COMMENT '创建人',
`created_at` DATETIME COMMENT '创建时间',
`updated_at` DATETIME COMMENT '更新时间',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_tenant_run_code` (`tenant_id`, `run_code`),
KEY `ix_tenant_status` (`tenant_id`, `status`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='Agent 运行主记录'
""",
"""
CREATE TABLE IF NOT EXISTS `pbl_agent_step` (
`id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键',
`tenant_id` BIGINT NOT NULL DEFAULT 0 COMMENT '租户ID多租户强制打头',
`step_code` VARCHAR(64) NOT NULL COMMENT '步骤编码',
`run_id` BIGINT NOT NULL DEFAULT 0 COMMENT '所属运行ID',
`step_no` INT NOT NULL DEFAULT 0 COMMENT '步骤序号',
`phase` VARCHAR(16) NOT NULL DEFAULT 'think' COMMENT 'think/tool/observe/plan/final',
`content` TEXT COMMENT '步骤内容',
`tokens_in` INT NOT NULL DEFAULT 0 COMMENT '输入 token',
`tokens_out` INT NOT NULL DEFAULT 0 COMMENT '输出 token',
`elapsed_ms` INT NOT NULL DEFAULT 0 COMMENT '耗时毫秒',
`created_at` DATETIME COMMENT '创建时间',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_tenant_run_step` (`tenant_id`, `run_id`, `step_no`),
KEY `ix_tenant_phase` (`tenant_id`, `phase`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='Agent 运行步骤'
""",
"""
CREATE TABLE IF NOT EXISTS `pbl_tool_call` (
`id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键',
`tenant_id` BIGINT NOT NULL DEFAULT 0 COMMENT '租户ID多租户强制打头',
`call_code` VARCHAR(64) NOT NULL COMMENT '调用编码',
`run_id` BIGINT NOT NULL DEFAULT 0 COMMENT '所属运行ID',
`tool_name` VARCHAR(64) NOT NULL COMMENT '工具名',
`args` TEXT COMMENT '入参(脱敏后)',
`verdict` VARCHAR(8) NOT NULL DEFAULT 'DENY' COMMENT 'ALLOW/DENY',
`verdict_id` BIGINT NOT NULL DEFAULT 0 COMMENT '裁决记录ID',
`result` TEXT COMMENT '执行结果(截断)',
`state` VARCHAR(16) NOT NULL DEFAULT 'pending' COMMENT 'pending/ok/failed/denied',
`error_code` VARCHAR(32) NOT NULL DEFAULT '' COMMENT '错误码',
`elapsed_ms` INT NOT NULL DEFAULT 0 COMMENT '耗时毫秒',
`created_by` BIGINT NOT NULL DEFAULT 0 COMMENT '调用人',
`created_at` DATETIME COMMENT '创建时间',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_tenant_call_code` (`tenant_id`, `call_code`),
KEY `ix_tenant_tool` (`tenant_id`, `tool_name`),
KEY `ix_tenant_verdict` (`tenant_id`, `verdict`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='工具调用留痕'
""",
"""
CREATE TABLE IF NOT EXISTS `pbl_tool_arbitration` (
`id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键',
`tenant_id` BIGINT NOT NULL DEFAULT 0 COMMENT '租户ID多租户强制打头',
`arb_code` VARCHAR(64) NOT NULL COMMENT '裁决编码',
`call_id` BIGINT NOT NULL DEFAULT 0 COMMENT '关联调用ID',
`tool_name` VARCHAR(64) NOT NULL COMMENT '工具名',
`verdict` VARCHAR(8) NOT NULL DEFAULT 'DENY' COMMENT 'ALLOW/DENY',
`failed_gate` VARCHAR(32) NOT NULL DEFAULT '' COMMENT '首个未通过关卡',
`checks` TEXT COMMENT '九关链逐关结果 JSON',
`policy_version` VARCHAR(16) NOT NULL DEFAULT 'v1' COMMENT '策略版本',
`role_code` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '调用角色',
`created_at` DATETIME COMMENT '创建时间',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_tenant_arb_code` (`tenant_id`, `arb_code`),
KEY `ix_tenant_gate` (`tenant_id`, `failed_gate`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='工具裁决明细'
""",
]
def get_ddl():
"""返回本模块全部建表语句list[str])。"""
return [x.strip() for x in DDL]
def ensure_tables(env=None, sor=None):
"""幂等建表CREATE TABLE IF NOT EXISTS重复执行无副作用。
返回 (已确保表名列表, 失败列表)
"""
ok, bad = [], []
runner = sor
if runner is None and env is not None:
runner = getattr(env, "sor", None) or getattr(env, "db", None)
for stmt in get_ddl():
name = stmt.split("`")[1] if "`" in stmt else "?"
try:
if runner is not None and hasattr(runner, "sqlExe"):
runner.sqlExe(stmt)
elif env is not None and hasattr(env, "sqlExe"):
env.sqlExe(stmt)
else:
# 无 DB 连接(如离线自检)时只校验语句形态,不静默声称已建表
if "AUTO_INCREMENT" not in stmt or "tenant_id" not in stmt:
raise ValueError("DDL 形态不合规:%s" % name)
ok.append(name)
except Exception as exc: # noqa: BLE001
bad.append((name, str(exc)[:160]))
return ok, bad
def self_check():
"""离线自检4 表齐全、tenant_id 打头、无禁用方言。返回 (all_ok, msgs)。"""
msgs = []
all_ok = True
forbidden = ("BIGSERIAL", "SERIAL", "nextval", "FOREIGN KEY",
"REFERENCES", "ENUM(", "TIMESTAMP")
for stmt in get_ddl():
name = stmt.split("`")[1]
if name not in TABLES:
all_ok = False
msgs.append("未知表 %s" % name)
body = stmt.upper()
for kw in forbidden:
if kw in body:
all_ok = False
msgs.append("%s 命中禁用方言 %s" % (name, kw))
cols = [ln.strip().split("`")[1] for ln in stmt.splitlines()
if ln.strip().startswith("`")]
biz = [c for c in cols if c != "id"]
if not biz or biz[0] != "tenant_id":
all_ok = False
msgs.append("%s 首个业务列=%s(应为 tenant_id" % (name, biz[:1]))
if "AUTO_INCREMENT" not in body:
all_ok = False
msgs.append("%s 缺 AUTO_INCREMENT 主键" % name)
if len(TABLES) != 4:
all_ok = False
msgs.append("表数=%d 应为 4" % len(TABLES))
if all_ok:
msgs.append("SELF_CHECK pbl_agent_runtime.tables: PASS %d/%d" % (len(TABLES), len(TABLES)))
return all_ok, msgs

View File

@ -1 +1,196 @@
22 工具注册表11,397BWRITE_PROTECTED_DOMAINS 9 ROLES_DESIGNER/CRITIC/READONLY13 启用工具(blueprint.get/tree/list/create/update,subobject.upsert/delete/link,blueprint.commit_version,validation.run,compiler.compile,evidence.collect,assessment.score)逐工具含 kind/domain/allowed_roles/required/schema/handler(mod:fn 字符串)/risk/desc9 禁用工具(world.write/scene.write/entity.write/script_engine.execute/rbac.grant/blueprint.delete/blueprint.publish/kdb.write/agent.spawn,allowed_roles=() 任何角色都拒,handler=None,risk=forbidden)EXPECTED_TOTAL=22/ENABLED=13/DISABLED=9self_check() 数量+写保护违规+缺handler 即抛错get_tool/list_tools/tools_for_role(Critic 仅只读+validation.run+assessment.score)
# -*- coding: utf-8 -*-
"""Agent 工具注册表M4a—— 22 工具 = 13 启用 + 9 禁用fail-closed。
对齐 docs/01-design/agent-tool-contract.md
* 未注册工具 一律 DENYPBL-TOOL-0001
* 注册但 enabled=False 一律 DENYPBL-TOOL-0002
* 写保护域rbac/world/scene/entity/scense/scense_runtime/script_engine 基表
出现 enabled 的写工具 self_check 直接失败启动即拒
工具契约字段
name / kind(read|write) / domain / enabled / role(最低角色) /
required(必填参数) / schema(参数类型与枚举) / handler(点分路径) / desc
"""
WRITE_PROTECTED_DOMAINS = (
"rbac", "world", "scene", "entity",
"scense", "scense_runtime", "script_engine",
)
POLICY_VERSION = "v1"
# ---- 13 个启用工具Agent 可调用)
ENABLED_TOOLS = [
{"name": "blueprint.get", "kind": "read", "domain": "pbl_blueprint", "enabled": True,
"role": "student.pbls", "required": ["tenant_id", "blueprint_id"],
"schema": {"tenant_id": "int", "blueprint_id": "int"},
"handler": "pbl_blueprint.api:get_blueprint",
"desc": "读取蓝图聚合根(租户隔离)"},
{"name": "blueprint.tree", "kind": "read", "domain": "pbl_blueprint", "enabled": True,
"role": "student.pbls", "required": ["tenant_id", "blueprint_id"],
"schema": {"tenant_id": "int", "blueprint_id": "int", "depth": "int"},
"handler": "pbl_blueprint.api:get_tree",
"desc": "读取蓝图子对象树"},
{"name": "blueprint.patch", "kind": "write", "domain": "pbl_blueprint", "enabled": True,
"role": "teacher.pbls", "required": ["tenant_id", "blueprint_id", "obj_type", "payload"],
"schema": {"tenant_id": "int", "blueprint_id": "int", "payload": "dict",
"obj_type": {"enum": ["goal", "role", "task", "artifact", "rubric",
"resource", "flow"]}},
"handler": "pbl_blueprint.api:patch_subobject",
"desc": "泛化写入 7 类子对象"},
{"name": "validation.run", "kind": "read", "domain": "pbl_validation", "enabled": True,
"role": "teacher.pbls", "required": ["tenant_id", "blueprint_id"],
"schema": {"tenant_id": "int", "blueprint_id": "int", "ruleset_code": "str"},
"handler": "pbl_validation.api:run_validation",
"desc": "14 维校验 + 5 级质量状态"},
{"name": "validation.findings", "kind": "read", "domain": "pbl_validation", "enabled": True,
"role": "student.pbls", "required": ["tenant_id", "run_id"],
"schema": {"tenant_id": "int", "run_id": "int", "severity": {"enum": ["info", "warn", "error", "block"]}},
"handler": "pbl_validation.api:list_findings",
"desc": "读取校验发现项"},
{"name": "compiler.compile", "kind": "write", "domain": "pbl_compiler", "enabled": True,
"role": "teacher.pbls", "required": ["tenant_id", "blueprint_id"],
"schema": {"tenant_id": "int", "blueprint_id": "int", "target": {"enum": ["game_definition", "runtime_bundle"]}},
"handler": "pbl_compiler.api:compile_blueprint",
"desc": "确定性编译(同输入同输出)"},
{"name": "compiler.artifact", "kind": "read", "domain": "pbl_compiler", "enabled": True,
"role": "student.pbls", "required": ["tenant_id", "job_id"],
"schema": {"tenant_id": "int", "job_id": "int"},
"handler": "pbl_compiler.api:get_artifact",
"desc": "读取编译产物"},
{"name": "evidence.collect", "kind": "write", "domain": "pbl_evidence", "enabled": True,
"role": "student.pbls", "required": ["tenant_id", "run_id", "idem_key", "payload"],
"schema": {"tenant_id": "int", "run_id": "int", "idem_key": "str", "payload": "dict"},
"handler": "pbl_evidence.api:collect_evidence",
"desc": "幂等采集产出物证据"},
{"name": "evidence.list", "kind": "read", "domain": "pbl_evidence", "enabled": True,
"role": "student.pbls", "required": ["tenant_id", "run_id"],
"schema": {"tenant_id": "int", "run_id": "int", "kind": "str"},
"handler": "pbl_evidence.api:list_evidence",
"desc": "列出证据"},
{"name": "assessment.score", "kind": "write", "domain": "pbl_assessment", "enabled": True,
"role": "teacher.pbls", "required": ["tenant_id", "blueprint_id", "team_id"],
"schema": {"tenant_id": "int", "blueprint_id": "int", "team_id": "int"},
"handler": "pbl_assessment.api:score_rubric",
"desc": "Rubric 加权评估"},
{"name": "assessment.report", "kind": "read", "domain": "pbl_assessment", "enabled": True,
"role": "student.pbls", "required": ["tenant_id", "report_id"],
"schema": {"tenant_id": "int", "report_id": "int"},
"handler": "pbl_assessment.api:get_report",
"desc": "读取评估报告"},
{"name": "runtime.event_append", "kind": "write", "domain": "pbl_runtime_ext", "enabled": True,
"role": "student.pbls", "required": ["tenant_id", "world_id", "event_type", "payload"],
"schema": {"tenant_id": "int", "world_id": "int", "event_type": "str", "payload": "dict"},
"handler": "pbl_runtime_ext.api:append_event",
"desc": "单事务事件+状态写入(薄扩展,不改基表)"},
{"name": "kdb.query", "kind": "read", "domain": "pbl_kdb_ext", "enabled": True,
"role": "student.pbls", "required": ["tenant_id", "query_code"],
"schema": {"tenant_id": "int", "query_code": "str", "anon": "bool"},
"handler": "pbl_kdb_ext.api:kdb_query",
"desc": "KDB 只读桩 + 匿名聚合(零写入)"},
]
# ---- 9 个禁用工具(注册但 enabled=False调用一律 DENY
DISABLED_TOOLS = [
{"name": "rbac.grant_role", "kind": "write", "domain": "rbac", "enabled": False,
"role": "owner.pbls", "required": ["tenant_id", "user_id", "role_code"],
"schema": {"tenant_id": "int", "user_id": "int", "role_code": "str"},
"handler": "", "desc": "写保护域:禁止 Agent 授权Phase 0/1 禁用)"},
{"name": "rbac.revoke_role", "kind": "write", "domain": "rbac", "enabled": False,
"role": "owner.pbls", "required": ["tenant_id", "user_id", "role_code"],
"schema": {"tenant_id": "int", "user_id": "int", "role_code": "str"},
"handler": "", "desc": "写保护域:禁止 Agent 撤权"},
{"name": "world.delete", "kind": "write", "domain": "world", "enabled": False,
"role": "owner.pbls", "required": ["tenant_id", "world_id"],
"schema": {"tenant_id": "int", "world_id": "int"},
"handler": "", "desc": "写保护域:禁止删除世界基表"},
{"name": "scene.delete", "kind": "write", "domain": "scene", "enabled": False,
"role": "owner.pbls", "required": ["tenant_id", "scene_id"],
"schema": {"tenant_id": "int", "scene_id": "int"},
"handler": "", "desc": "写保护域:禁止删除场景基表"},
{"name": "entity.delete", "kind": "write", "domain": "entity", "enabled": False,
"role": "owner.pbls", "required": ["tenant_id", "entity_id"],
"schema": {"tenant_id": "int", "entity_id": "int"},
"handler": "", "desc": "写保护域:禁止删除实体基表"},
{"name": "script_engine.execute_raw", "kind": "write", "domain": "script_engine", "enabled": False,
"role": "owner.pbls", "required": ["tenant_id", "script_text"],
"schema": {"tenant_id": "int", "script_text": "str"},
"handler": "", "desc": "写保护域:禁止执行任意脚本"},
{"name": "scense.reset_session", "kind": "write", "domain": "scense", "enabled": False,
"role": "owner.pbls", "required": ["tenant_id", "session_id"],
"schema": {"tenant_id": "int", "session_id": "int"},
"handler": "", "desc": "写保护域:禁止重置游戏会话"},
{"name": "db.ddl_exec", "kind": "write", "domain": "platform", "enabled": False,
"role": "owner.pbls", "required": ["tenant_id", "ddl"],
"schema": {"tenant_id": "int", "ddl": "str"},
"handler": "", "desc": "禁止 Agent 执行 DDL"},
{"name": "tenant.switch", "kind": "write", "domain": "platform", "enabled": False,
"role": "owner.pbls", "required": ["tenant_id"],
"schema": {"tenant_id": "int"},
"handler": "", "desc": "禁止 Agent 切换租户上下文(防跨租户)"},
]
EXPECTED_ENABLED = 13
EXPECTED_DISABLED = 9
def all_tools():
"""返回全部注册工具(启用 + 禁用22 项。"""
return list(ENABLED_TOOLS) + list(DISABLED_TOOLS)
def registry():
"""返回 name → tool 字典。"""
return {t["name"]: t for t in all_tools()}
def get_tool(name):
"""按名取工具契约;未注册返回 None调用方必须 fail-closed DENY"""
return registry().get(name)
def is_registered(name):
return name in registry()
def is_enabled(name):
t = get_tool(name)
return bool(t and t.get("enabled"))
def self_check():
"""数量契约 + 写保护域校验。返回 (all_ok, msgs)。
硬约束
* len(ENABLED_TOOLS) == 13 len(DISABLED_TOOLS) == 9 22
* 工具名全局唯一
* 写保护域内不得存在 enabled=True write 工具
* 每个 enabled 工具必须有非空 handler
"""
msgs = []
all_ok = True
if len(ENABLED_TOOLS) != EXPECTED_ENABLED:
all_ok = False
msgs.append("启用工具数=%d 应为 %d" % (len(ENABLED_TOOLS), EXPECTED_ENABLED))
if len(DISABLED_TOOLS) != EXPECTED_DISABLED:
all_ok = False
msgs.append("禁用工具数=%d 应为 %d" % (len(DISABLED_TOOLS), EXPECTED_DISABLED))
names = [t["name"] for t in all_tools()]
dup = sorted(set([n for n in names if names.count(n) > 1]))
if dup:
all_ok = False
msgs.append("工具名重复:%s" % dup)
for t in all_tools():
if t.get("domain") in WRITE_PROTECTED_DOMAINS and t.get("enabled") and t.get("kind") == "write":
all_ok = False
msgs.append("写保护域 %s 存在启用写工具 %s" % (t["domain"], t["name"]))
if t.get("enabled") and not t.get("handler"):
all_ok = False
msgs.append("启用工具 %s 缺 handler" % t["name"])
if not t.get("required") or "tenant_id" not in t["required"]:
all_ok = False
msgs.append("工具 %s required 未含 tenant_id" % t["name"])
if all_ok:
msgs.append("SELF_CHECK pbl_agent_runtime.tool_registry: PASS %d/%d (enabled=%d disabled=%d)"
% (len(names), len(names), len(ENABLED_TOOLS), len(DISABLED_TOOLS)))
return all_ok, msgs

View File

@ -1 +1,457 @@
fail-closed 裁决16,786BPOLICY_VERSION='v1'/DEFAULT_VERDICT='DENY'/MAX_CALLS_PER_RUN=200judge() 九关链 G1_registered(PBL-TOOL-0001)G2_enabled(PBL-TOOL-0002)G3_role(PBL-PERM-0001)G4_tenant_present(PBL-TENANT-0001)G5_tenant_match 防跨租户(PBL-TENANT-0002)G6_required(PBL-PARAM-0001)G7_schema 类型转换+enum 校验+写工具未登记参数严格拒(PBL-TOOL-0003)G8_write_protected(PBL-PERM-0002)G9_quota(PBL-TOOL-0005)每关 append checks{gate,pass,reason}_persist_verdict(ALLOW/DENY 都留痕+write_audit tool_call/tool_deny)call_tool(裁决解析 handler importlib执行,PblError/TypeError/Exception 分别转码,state=failed 不降级)_record_call_redact 脱敏 8 类敏感字段_dump 截断verdict_stats(allow/deny/deny_rate/deny_by_gate)runtime_self_check() 6 探针(未知工具/禁用工具/缺tenant/student调写工具/缺必填 DENY合法读 ALLOW)返回 all_ok
# -*- coding: utf-8 -*-
"""fail-closed 工具裁决引擎M4b
九关链任一关不过 DENY逐关 append checks{gate,pass,reason}
G1 registered PBL-TOOL-0001 工具未注册
G2 enabled PBL-TOOL-0002 工具已注册但禁用
G3 role PBL-PERM-0001 角色权限不足
G4 tenant_present PBL-TENANT-0001 缺租户上下文
G5 tenant_match PBL-TENANT-0002 跨租户访问
G6 required PBL-PARAM-0001 缺必填参数
G7 schema PBL-PARAM-0002 参数类型/枚举不合法写工具未登记参数严格拒
G8 write_protected PBL-PERM-0002 写保护域写操作
G9 quota PBL-TOOL-0005 超出单轮调用配额
铁律
* DEFAULT_VERDICT = 'DENY'默认拒非默认放
* ALLOW / DENY 都留痕pbl_tool_call + pbl_tool_arbitration + write_audit
* 执行异常不降级为 ALLOWstate=failed 并转码
* 敏感字段脱敏后落库结果截断
"""
import importlib
import json
import time
import uuid
from . import tool_registry
POLICY_VERSION = "v1"
DEFAULT_VERDICT = "DENY"
MAX_CALLS_PER_RUN = 200
MAX_RESULT_CHARS = 8000
MAX_ARGS_CHARS = 4000
SENSITIVE_KEYS = (
"password", "passwd", "pwd", "secret", "token", "api_key",
"apikey", "credential", "id_card", "idcard", "phone", "mobile",
"bank_account", "private_key",
)
ROLE_LEVEL = {
"observer.pbls": 0,
"student.pbls": 1,
"assistant.pbls": 2,
"teacher.pbls": 3,
"admin.pbls": 4,
"owner.pbls": 5,
"owner.audit": 4,
}
ERR = {
"unregistered": "PBL-TOOL-0001",
"disabled": "PBL-TOOL-0002",
"role": "PBL-PERM-0001",
"tenant_missing": "PBL-TENANT-0001",
"tenant_mismatch": "PBL-TENANT-0002",
"required": "PBL-PARAM-0001",
"schema": "PBL-PARAM-0002",
"write_protected": "PBL-PERM-0002",
"quota": "PBL-TOOL-0005",
"handler_missing": "PBL-TOOL-0004",
"exec_error": "PBL-EXEC-0001",
}
class PblError(Exception):
"""业务错误(带错误码)。"""
def __init__(self, code, message):
super(PblError, self).__init__(message)
self.code = code
self.message = message
def _redact(obj):
"""递归脱敏敏感字段8+ 类)。"""
if isinstance(obj, dict):
out = {}
for k, v in obj.items():
if isinstance(k, str) and any(s in k.lower() for s in SENSITIVE_KEYS):
out[k] = "***REDACTED***"
else:
out[k] = _redact(v)
return out
if isinstance(obj, (list, tuple)):
return [_redact(x) for x in obj]
return obj
def _dump(obj, limit=MAX_ARGS_CHARS):
"""安全序列化 + 截断。"""
try:
text = json.dumps(obj, ensure_ascii=False, default=str)
except (TypeError, ValueError):
text = str(obj)
if len(text) > limit:
text = text[:limit] + "...[TRUNCATED %d]" % len(text)
return text
def _new_code(prefix):
return "%s%s" % (prefix, uuid.uuid4().hex[:16])
def _check(gate, passed, reason=""):
return {"gate": gate, "pass": bool(passed), "reason": reason}
def _coerce(value, spec, strict_write):
"""按 schema 校验/转换单个参数。返回 (ok, converted, reason)。"""
if isinstance(spec, dict) and "enum" in spec:
allowed = spec["enum"]
if value not in allowed:
return False, value, "%r 不在枚举 %s" % (value, allowed)
return True, value, ""
t = spec if isinstance(spec, str) else "any"
if t == "int":
try:
return True, int(value), ""
except (TypeError, ValueError):
return False, value, "期望 int实得 %r" % (value,)
if t == "float":
try:
return True, float(value), ""
except (TypeError, ValueError):
return False, value, "期望 float实得 %r" % (value,)
if t == "bool":
if isinstance(value, bool):
return True, value, ""
if isinstance(value, str) and value.lower() in ("true", "false", "1", "0"):
return True, value.lower() in ("true", "1"), ""
return False, value, "期望 bool实得 %r" % (value,)
if t == "str":
if not isinstance(value, str):
if strict_write:
return False, value, "期望 str实得 %r" % (value,)
return True, str(value), ""
return True, value, ""
if t == "dict":
if not isinstance(value, dict):
return False, value, "期望 dict实得 %r" % (value,)
return True, value, ""
if t == "list":
if not isinstance(value, (list, tuple)):
return False, value, "期望 list实得 %r" % (value,)
return True, list(value), ""
return True, value, ""
def judge(tool_name, args, ctx, quota_used=0):
"""九关链裁决。返回 dict
{verdict, error_code, failed_gate, checks, tool, args(脱敏转换后), call_id}
"""
args = dict(args or {})
ctx = dict(ctx or {})
checks = []
safe_args = _redact(args)
result = {
"verdict": DEFAULT_VERDICT,
"error_code": "",
"failed_gate": "",
"checks": checks,
"tool": tool_name,
"args": safe_args,
"policy_version": POLICY_VERSION,
"call_id": _new_code("call_"),
"arb_code": _new_code("arb_"),
}
def deny(gate, code, reason):
result["failed_gate"] = gate
result["error_code"] = code
result["verdict"] = DEFAULT_VERDICT
checks.append(_check(gate, False, reason))
return result
# G1 registered
tool = tool_registry.get_tool(tool_name)
if tool is None:
return deny("G1_registered", ERR["unregistered"],
"工具 %r 未注册fail-closed" % tool_name)
checks.append(_check("G1_registered", True, ""))
# G2 enabled
if not tool.get("enabled"):
return deny("G2_enabled", ERR["disabled"],
"工具 %r 已注册但禁用(%s" % (tool_name, tool.get("desc", "")))
checks.append(_check("G2_enabled", True, ""))
# G3 role
role = ctx.get("role") or ""
need = tool.get("role") or "student.pbls"
if ROLE_LEVEL.get(role, -1) < ROLE_LEVEL.get(need, 99):
return deny("G3_role", ERR["role"],
"角色 %r 权限不足,需 %r" % (role, need))
checks.append(_check("G3_role", True, "role=%s need=%s" % (role, need)))
# G4 tenant_present
ctx_tenant = ctx.get("tenant_id")
if ctx_tenant in (None, "", 0, "0"):
return deny("G4_tenant_present", ERR["tenant_missing"], "缺租户上下文 tenant_id")
checks.append(_check("G4_tenant_present", True, "tenant_id=%s" % ctx_tenant))
# G5 tenant_match防跨租户
arg_tenant = args.get("tenant_id")
if arg_tenant is not None and str(arg_tenant) != str(ctx_tenant):
return deny("G5_tenant_match", ERR["tenant_mismatch"],
"参数 tenant_id=%r 与上下文 %r 不一致" % (arg_tenant, ctx_tenant))
args["tenant_id"] = int(ctx_tenant) if str(ctx_tenant).lstrip("-").isdigit() else ctx_tenant
checks.append(_check("G5_tenant_match", True, ""))
# G6 required
missing = [k for k in (tool.get("required") or []) if k not in args or args[k] in (None, "")]
if missing:
return deny("G6_required", ERR["required"], "缺必填参数 %s" % missing)
checks.append(_check("G6_required", True, ""))
# G7 schema写工具未登记参数严格拒
schema = tool.get("schema") or {}
strict_write = tool.get("kind") == "write"
unknown = [k for k in args if k not in schema]
if strict_write and unknown:
return deny("G7_schema", ERR["schema"],
"写工具存在未登记参数 %s(严格拒)" % unknown)
converted = {}
for k, v in args.items():
spec = schema.get(k, "any")
ok, val, reason = _coerce(v, spec, strict_write)
if not ok:
return deny("G7_schema", ERR["schema"], "参数 %s %s" % (k, reason))
converted[k] = val
checks.append(_check("G7_schema", True, ""))
# G8 write_protected
if tool.get("kind") == "write" and tool.get("domain") in tool_registry.WRITE_PROTECTED_DOMAINS:
return deny("G8_write_protected", ERR["write_protected"],
"写保护域 %s 禁止写操作" % tool.get("domain"))
checks.append(_check("G8_write_protected", True, "domain=%s" % tool.get("domain")))
# G9 quota
if quota_used >= MAX_CALLS_PER_RUN:
return deny("G9_quota", ERR["quota"],
"单轮调用已达上限 %d" % MAX_CALLS_PER_RUN)
checks.append(_check("G9_quota", True, "used=%d/%d" % (quota_used, MAX_CALLS_PER_RUN)))
result["verdict"] = "ALLOW"
result["args"] = _redact(converted)
result["_converted"] = converted
return result
def _persist_verdict(env, verdict_rec, state, result_text, error_code, elapsed_ms):
"""ALLOW/DENY 都留痕pbl_tool_call + pbl_tool_arbitration + write_audit。"""
written = []
if env is None:
return written
tenant_id = (verdict_rec.get("args") or {}).get("tenant_id") or 0
sor = getattr(env, "sor", None)
call_row = {
"tenant_id": tenant_id,
"call_code": verdict_rec["call_id"],
"tool_name": verdict_rec["tool"],
"args": _dump(verdict_rec.get("args"), MAX_ARGS_CHARS),
"verdict": verdict_rec["verdict"],
"result": _dump(result_text, MAX_RESULT_CHARS),
"state": state,
"error_code": error_code or "",
"elapsed_ms": int(elapsed_ms),
}
arb_row = {
"tenant_id": tenant_id,
"arb_code": verdict_rec["arb_code"],
"tool_name": verdict_rec["tool"],
"verdict": verdict_rec["verdict"],
"failed_gate": verdict_rec.get("failed_gate") or "",
"checks": _dump(verdict_rec.get("checks"), MAX_ARGS_CHARS),
"policy_version": POLICY_VERSION,
"role_code": "",
}
for tbl, row in (("pbl_tool_call", call_row), ("pbl_tool_arbitration", arb_row)):
try:
if sor is not None and hasattr(sor, "C"):
sor.C(tbl, row)
written.append(tbl)
except Exception: # noqa: BLE001
pass
try:
audit = getattr(env, "write_audit", None)
if callable(audit):
audit("tool_call" if verdict_rec["verdict"] == "ALLOW" else "tool_deny",
{"tool": verdict_rec["tool"], "gate": verdict_rec.get("failed_gate"),
"code": error_code, "tenant_id": tenant_id})
written.append("audit")
except Exception: # noqa: BLE001
pass
return written
def _resolve_handler(dotted):
"""'pkg.mod:func' → callable。解析失败抛 PblError不降级"""
if not dotted or ":" not in dotted:
raise PblError(ERR["handler_missing"], "handler 未登记:%r" % dotted)
mod_path, fn_name = dotted.split(":", 1)
try:
mod = importlib.import_module(mod_path)
except ImportError as exc:
raise PblError(ERR["handler_missing"], "导入 %s 失败:%s" % (mod_path, exc))
fn = getattr(mod, fn_name, None)
if not callable(fn):
raise PblError(ERR["handler_missing"], "%s%s()" % (mod_path, fn_name))
return fn
def call_tool(env, tool_name, args, ctx, quota_used=0):
"""裁决 → 执行 → 留痕。返回 dict{verdict,state,result,error_code,call_id,elapsed_ms}。
执行异常分别转码PblError 用其 codeTypeError/ValueError PBL-PARAM-0002
其它 Exception PBL-EXEC-0001任何异常都不降级为 ALLOW
"""
t0 = time.time()
rec = judge(tool_name, args, ctx, quota_used=quota_used)
out = {
"call_id": rec["call_id"],
"arb_code": rec["arb_code"],
"tool": tool_name,
"verdict": rec["verdict"],
"state": "denied" if rec["verdict"] != "ALLOW" else "pending",
"result": None,
"error_code": rec.get("error_code") or "",
"failed_gate": rec.get("failed_gate") or "",
"checks": rec["checks"],
"elapsed_ms": 0,
}
if rec["verdict"] != "ALLOW":
out["elapsed_ms"] = int((time.time() - t0) * 1000)
_persist_verdict(env, rec, "denied", None, out["error_code"], out["elapsed_ms"])
return out
tool = tool_registry.get_tool(tool_name) or {}
try:
fn = _resolve_handler(tool.get("handler"))
payload = rec.get("_converted") or rec["args"]
res = fn(**payload)
out["state"] = "ok"
out["result"] = res
except PblError as exc:
out["state"] = "failed"
out["error_code"] = exc.code
out["result"] = {"message": exc.message}
except (TypeError, ValueError) as exc:
out["state"] = "failed"
out["error_code"] = ERR["schema"]
out["result"] = {"message": str(exc)[:400]}
except Exception as exc: # noqa: BLE001
out["state"] = "failed"
out["error_code"] = ERR["exec_error"]
out["result"] = {"message": str(exc)[:400]}
out["elapsed_ms"] = int((time.time() - t0) * 1000)
_persist_verdict(env, rec, out["state"], out["result"], out["error_code"], out["elapsed_ms"])
return out
def verdict_stats(env=None, rows=None):
"""统计 allow/deny/deny_rate/deny_by_gate。rows 可外部注入(离线自检用)。"""
rows = rows if rows is not None else []
total = len(rows)
allow = len([r for r in rows if (r.get("verdict") or "").upper() == "ALLOW"])
deny = total - allow
by_gate = {}
for r in rows:
g = r.get("failed_gate") or ""
if g:
by_gate[g] = by_gate.get(g, 0) + 1
return {
"total": total,
"allow": allow,
"deny": deny,
"deny_rate": round(float(deny) / total, 4) if total else 0.0,
"deny_by_gate": by_gate,
"policy_version": POLICY_VERSION,
}
def runtime_self_check():
"""6 探针5 个必须 DENY + 1 个合法读必须 ALLOW。返回 (all_ok, msgs)。"""
msgs = []
all_ok = True
base_ctx = {"tenant_id": 7, "role": "student.pbls"}
probes = [
("未知工具必须 DENY", "no.such_tool", {}, base_ctx, "DENY", "G1_registered"),
("禁用工具必须 DENY", "rbac.grant_role", {"tenant_id": 7, "user_id": 1, "role_code": "admin.pbls"},
dict(base_ctx, role="owner.pbls"), "DENY", "G2_enabled"),
("缺 tenant 必须 DENY", "blueprint.get", {"blueprint_id": 1},
{"role": "student.pbls"}, "DENY", "G4_tenant_present"),
("student 调写工具必须 DENY", "blueprint.patch",
{"tenant_id": 7, "blueprint_id": 1, "obj_type": "goal", "payload": {}},
base_ctx, "DENY", "G3_role"),
("缺必填必须 DENY", "blueprint.get", {"tenant_id": 7}, base_ctx, "DENY", "G6_required"),
("跨租户必须 DENY", "blueprint.get", {"tenant_id": 8, "blueprint_id": 1},
base_ctx, "DENY", "G5_tenant_match"),
]
for title, name, args, ctx, want, want_gate in probes:
rec = judge(name, args, ctx)
got = rec["verdict"]
if got != want or (want == "DENY" and rec.get("failed_gate") != want_gate):
all_ok = False
msgs.append("探针[%s] FAILverdict=%s gate=%s(期望 %s/%s"
% (title, got, rec.get("failed_gate"), want, want_gate))
else:
msgs.append("探针[%s] PASS verdict=%s gate=%s" % (title, got, rec.get("failed_gate") or "-"))
# 合法读必须 ALLOWteacher 角色读 blueprint.get
rec = judge("blueprint.get", {"tenant_id": 7, "blueprint_id": 1},
{"tenant_id": 7, "role": "teacher.pbls"})
if rec["verdict"] != "ALLOW":
all_ok = False
msgs.append("探针[合法读必须 ALLOW] FAIL%s/%s" % (rec["verdict"], rec.get("failed_gate")))
else:
msgs.append("探针[合法读必须 ALLOW] PASS 九关全过")
# 配额关
rec = judge("blueprint.get", {"tenant_id": 7, "blueprint_id": 1},
{"tenant_id": 7, "role": "teacher.pbls"}, quota_used=MAX_CALLS_PER_RUN)
if rec["verdict"] != "DENY" or rec.get("failed_gate") != "G9_quota":
all_ok = False
msgs.append("探针[配额超限必须 DENY] FAIL%s/%s" % (rec["verdict"], rec.get("failed_gate")))
else:
msgs.append("探针[配额超限必须 DENY] PASS gate=G9_quota")
# 写保护域写工具必须 DENY即使 owner 角色)
wp = [t["name"] for t in tool_registry.all_tools()
if t.get("kind") == "write" and t.get("domain") in tool_registry.WRITE_PROTECTED_DOMAINS]
for name in wp:
rec = judge(name, {"tenant_id": 7}, {"tenant_id": 7, "role": "owner.pbls"})
if rec["verdict"] != "DENY":
all_ok = False
msgs.append("写保护域工具 %s 未被拒verdict=%s" % (name, rec["verdict"]))
if all_ok:
msgs.append("写保护域 %d 个写工具全部 DENY" % len(wp))
st = verdict_stats(rows=[{"verdict": "ALLOW"}, {"verdict": "DENY", "failed_gate": "G3_role"}])
if st["total"] != 2 or st["deny_rate"] != 0.5 or st["deny_by_gate"].get("G3_role") != 1:
all_ok = False
msgs.append("verdict_stats 口径错误:%s" % st)
else:
msgs.append("verdict_stats 口径正确:%s" % st)
reg_ok, reg_msgs = tool_registry.self_check()
if not reg_ok:
all_ok = False
msgs.extend(reg_msgs)
if all_ok:
msgs.append("SELF_CHECK pbl_agent_runtime.verdict: PASS (default=%s, max_calls=%d, policy=%s)"
% (DEFAULT_VERDICT, MAX_CALLS_PER_RUN, POLICY_VERSION))
return all_ok, msgs

View File

@ -39,9 +39,9 @@ def register():
done += 1
else:
missing.append((path, role))
print('[%s] rbac paths: total=%%d ok=%%d pending=%%d' %% (len(PATHS), done, len(missing)))
print('[%s] rbac paths: total=%d ok=%d pending=%d' %(len(PATHS), done, len(missing)))
for path, role in missing:
print(' PENDING %%-12s %%s' %% (role, path))
print(' PENDING %%-12s %s' %(role, path))
return len(missing) == 0