deliver: 交付收口(引擎代为提交)
This commit is contained in:
parent
e6a56062c9
commit
ef2c2a1816
@ -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"
|
||||
|
||||
@ -1 +1,140 @@
|
||||
挂载入口(2,130B):ensure_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.md(13 启用 / 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 FAILED(fail-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)
|
||||
|
||||
@ -1 +1,161 @@
|
||||
4 表 DDL(6,062B):pbl_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
|
||||
|
||||
@ -1 +1,196 @@
|
||||
22 工具注册表(11,397B):WRITE_PROTECTED_DOMAINS 9 域;ROLES_DESIGNER/CRITIC/READONLY;13 启用工具(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/desc;9 禁用工具(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=9;self_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:
|
||||
* 未注册工具 → 一律 DENY(PBL-TOOL-0001)
|
||||
* 注册但 enabled=False → 一律 DENY(PBL-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
|
||||
|
||||
@ -1 +1,457 @@
|
||||
fail-closed 裁决(16,786B):POLICY_VERSION='v1'/DEFAULT_VERDICT='DENY'/MAX_CALLS_PER_RUN=200;judge() 九关链 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)
|
||||
* 执行异常不降级为 ALLOW;state=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 用其 code;TypeError/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] FAIL:verdict=%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 "-"))
|
||||
|
||||
# 合法读必须 ALLOW(teacher 角色读 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
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user