diff --git a/pbl_agent_runtime/__init__.py b/pbl_agent_runtime/__init__.py index bf7d1d4..6ff9149 100644 --- a/pbl_agent_runtime/__init__.py +++ b/pbl_agent_runtime/__init__.py @@ -1 +1,9 @@ -包声明(80B) \ No newline at end of file +# -*- 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" diff --git a/pbl_agent_runtime/init.py b/pbl_agent_runtime/init.py index be8075a..e94966d 100644 --- a/pbl_agent_runtime/init.py +++ b/pbl_agent_runtime/init.py @@ -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 \ No newline at end of file +# -*- 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) diff --git a/pbl_agent_runtime/tables.py b/pbl_agent_runtime/tables.py index 0e3c5c4..5b97ebb 100644 --- a/pbl_agent_runtime/tables.py +++ b/pbl_agent_runtime/tables.py @@ -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) \ No newline at end of file +# -*- 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 diff --git a/pbl_agent_runtime/tool_registry.py b/pbl_agent_runtime/tool_registry.py index 8426a9d..1b78613 100644 --- a/pbl_agent_runtime/tool_registry.py +++ b/pbl_agent_runtime/tool_registry.py @@ -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) \ No newline at end of file +# -*- 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 diff --git a/pbl_agent_runtime/verdict.py b/pbl_agent_runtime/verdict.py index 4e751f2..7f98adf 100644 --- a/pbl_agent_runtime/verdict.py +++ b/pbl_agent_runtime/verdict.py @@ -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 \ No newline at end of file +# -*- 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 diff --git a/scripts/load_path.py b/scripts/load_path.py index df7cac2..392bb76 100644 --- a/scripts/load_path.py +++ b/scripts/load_path.py @@ -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