2026-09-17 23:44:00 +08:00

372 lines
17 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# -*- coding: utf-8 -*-
"""
pbl_agent_runtime M4a — fail-closed 工具裁决8 步default-deny
裁决顺序modules/pbl_agent_runtime.md §4 六步 + 本任务要求的 8 步细化,顺序不可变):
S1 租户上下文有效 → PBL_E_TENANT_MISSING
S2 Agent 已注册且 enabled → PBL_E_FORBIDDEN
S3 工具已注册(白名单外拒)→ PBL_E_FORBIDDEN
S4 工具 status=enabled → PBL_E_FORBIDDEN返回 disable_reason
S5 required_permission → PBL_E_FORBIDDEN越权拒绝
S6 allowed_agents + Critic 零写权限 → PBL_E_FORBIDDEN
S7 require_approval=1 须 approved → PBL_E_STATE_ILLEGAL
S8 input_schema 契约校验 → PBL_E_VALIDATION
全通过 → 执行后端映射 → 轨迹落库7 要素)
铁律:
* default-deny —— 任何一步失败立即返回拒绝,绝不"默认放行"
* 被拒调用同样写轨迹proposed_action + result=deny + 原因可审计US-20
* Agent 不能自批S7 只认 actor_type=user 决出的 approved 记录)
"""
from .m4a_kernel import (
get_store, one, fail, now_ts, dumps, loads, write_audit, gen_no,
PblError, ADJUDICATION_STEPS,
E_FORBIDDEN, E_STATE_ILLEGAL, E_TENANT_MISSING, E_VALIDATION, E_NOT_FOUND,
TOOL_STATUS_ENABLED, TOOL_STATUS_DISABLED, AGENT_STATUS_ENABLED,
TRACE_STATUS_DENY, ACTOR_AGENT,
)
from .m4a_registry import get_tool, get_agent_def, CRITIC, DESIGNER
from . import m4a_trace as trace_mod
from . import m4a_approval as approval_mod
from . import m4a_backend as backend_mod
# ---------------------------------------------------------------------------
# 裁决结果对象
# ---------------------------------------------------------------------------
class Verdict(object):
"""一次裁决的完整结论allow / deny + 到达步 + 原因),可序列化入轨迹。"""
def __init__(self, allowed, step, check, reason_code, reason, tool=None,
agent=None, approval=None):
self.allowed = allowed
self.step = step # S1~S8 / EXECUTED
self.check = check # 检查项名
self.reason_code = reason_code
self.reason = reason
self.tool = tool or {}
self.agent = agent or {}
self.approval = approval
def to_dict(self):
return {
"allowed": self.allowed,
"step_reached": self.step,
"check": self.check,
"reason_code": self.reason_code,
"reason": self.reason,
"tool_code": self.tool.get("tool_code"),
"tool_status": self.tool.get("status"),
"disable_reason": self.tool.get("disable_reason"),
"agent_code": self.agent.get("agent_code"),
"approval_no": (self.approval or {}).get("approval_no"),
}
@staticmethod
def allow(step, tool, agent, approval=None):
return Verdict(True, step, "pass", None, "裁决通过", tool, agent, approval)
@staticmethod
def deny(step, check, code, reason, tool=None, agent=None):
return Verdict(False, step, check, code, reason, tool, agent)
def _deny(step_id, check, code, reason, tool=None, agent=None):
return Verdict.deny(step_id, check, code, reason, tool, agent)
# ---------------------------------------------------------------------------
# 8 步裁决链
# ---------------------------------------------------------------------------
def adjudicate(ctx, agent_code, tool_code, args=None, trace_no=None,
approval_no=None, store=None):
"""
执行 fail-closed 8 步裁决。返回 Verdict不抛异常便于把 deny 落轨迹)。
args 仅用于 S8 契约校验,不做任何副作用。
"""
st = store or get_store()
args = args if isinstance(args, dict) else {}
# ---- S1 租户上下文 ----
if ctx is None or not getattr(ctx, "tenant_id", None) \
or not str(ctx.tenant_id).strip():
return _deny("S1", "tenant_context", E_TENANT_MISSING,
"租户上下文缺失或非法tenant_id 强制打头)",
tool={"tool_code": tool_code},
agent={"agent_code": agent_code})
tenant_id = str(ctx.tenant_id).strip()
# ---- S2 Agent 已注册且 enabled ----
try:
agent = get_agent_def(agent_code, ctx=ctx, store=st)
except PblError:
agent = None
if not agent:
return _deny("S2", "agent_registered", E_FORBIDDEN,
"Agent 未注册(本迭代仅 designer / critic 两个13.1 章)",
tool={"tool_code": tool_code},
agent={"agent_code": agent_code})
if agent.get("status") != AGENT_STATUS_ENABLED:
return _deny("S2", "agent_registered", E_FORBIDDEN,
"Agent 已停用: %s" % agent_code, tool={"tool_code": tool_code},
agent=agent)
# ---- S3 工具已注册default-deny白名单外一律拒 ----
tool = get_tool(tool_code, ctx=ctx, store=st, tenant_id=tenant_id)
if not tool:
return _deny("S3", "tool_registered", E_FORBIDDEN,
"工具未注册default-deny 拒绝(第 31 章裁剪白名单外不可用)",
tool={"tool_code": tool_code}, agent=agent)
# ---- S4 工具 enabled ----
if tool.get("status") != TOOL_STATUS_ENABLED:
return _deny("S4", "tool_enabled", E_FORBIDDEN,
"工具已禁用out_of_scope%s"
% (tool.get("disable_reason") or "无说明"),
tool=tool, agent=agent)
# ---- S5 权限域 ----
perm = tool.get("required_permission")
if perm and not ctx.has_perm(perm):
return _deny("S5", "permission_domain", E_FORBIDDEN,
"越权拒绝:调用方缺少权限域 %s" % perm, tool=tool, agent=agent)
# ---- S6 Agent 作用域 + Critic 零写权限 ----
allowed_agents = tool.get("allowed_agents") or []
denied_agents = agent.get("denied_tools") or []
if tool_code in denied_agents:
return _deny("S6", "agent_scope", E_FORBIDDEN,
"该 Agent 显式禁用此工具: %s%s" % (agent_code, tool_code),
tool=tool, agent=agent)
if allowed_agents and agent_code not in allowed_agents:
return _deny("S6", "agent_scope", E_FORBIDDEN,
"工具 %s 仅允许 %s 调用,%s 越界"
% (tool_code, "/".join(allowed_agents), agent_code),
tool=tool, agent=agent)
# Critic 零写权限14.1):双重防线——即使注册表误配也拒绝
if agent.get("write_allowed") in (0, False) and tool.get("write_operation") in (1, True):
return _deny("S6", "agent_scope", E_FORBIDDEN,
"Critic 零写权限14.1):不得调用写操作工具 %s" % tool_code,
tool=tool, agent=agent)
if agent_code == CRITIC and tool.get("write_operation") in (1, True):
return _deny("S6", "agent_scope", E_FORBIDDEN,
"Critic 零写权限14.1):不得调用写操作工具 %s" % tool_code,
tool=tool, agent=agent)
# ---- S7 强制人工审批 ----
approval = None
if tool.get("require_approval") in (1, True):
approval = approval_mod.find_approved(
tool.get("approval_action_type") or "execute",
args.get("blueprint_id") or args.get("object_id") or tool_code,
ctx=ctx, store=st, tenant_id=tenant_id, approval_no=approval_no,
trace_no=trace_no, tool_code=tool_code)
if approval is None:
return _deny("S7", "approval_gate", E_STATE_ILLEGAL,
"工具 %s 需人工审批(%s),无 approved 记录 → 拒绝执行14.2"
% (tool_code, tool.get("approval_action_type") or "execute"),
tool=tool, agent=agent)
# ---- S8 入参契约 ----
errs = validate_args(tool.get("input_schema") or {}, args)
if errs:
return _deny("S8", "input_contract", E_VALIDATION,
"入参不满足工具契约: %s" % "; ".join(errs), tool=tool, agent=agent)
return Verdict.allow("S8", tool, agent, approval)
# ---------------------------------------------------------------------------
# S8 入参契约校验(轻量 JSON Schema 子集required / type / enum / minItems
# ---------------------------------------------------------------------------
_TYPE_MAP = {
"string": str, "integer": int, "number": (int, float),
"boolean": bool, "object": dict, "array": (list, tuple),
}
def _type_ok(value, tname):
if tname not in _TYPE_MAP:
return True
expect = _TYPE_MAP[tname]
if tname == "integer" and isinstance(value, bool):
return False
if tname == "string":
return isinstance(value, str)
return isinstance(value, expect)
def validate_args(schema, args):
"""返回错误列表(空=通过。schema 为 input_schemaJSON Schema 子集)。"""
errs = []
if not isinstance(schema, dict) or not schema:
return errs
args = args if isinstance(args, dict) else {}
props = schema.get("properties") or {}
for name in schema.get("required") or []:
if name not in args or args[name] is None or args[name] == "":
errs.append("缺少必填参数 %s" % name)
for name, spec in props.items():
if name not in args or args[name] is None:
continue
val = args[name]
tname = spec.get("type")
if tname and not _type_ok(val, tname):
errs.append("参数 %s 类型应为 %s,实为 %s"
% (name, tname, type(val).__name__))
continue
if spec.get("enum") and val not in spec["enum"]:
errs.append("参数 %s 取值须在 %s 内,实为 %r"
% (name, spec["enum"], val))
if tname == "array":
if spec.get("minItems") and len(val) < spec["minItems"]:
errs.append("参数 %s 至少 %s" % (name, spec["minItems"]))
item_spec = spec.get("items") or {}
if isinstance(item_spec, dict) and item_spec.get("required"):
for i, item in enumerate(val):
if not isinstance(item, dict):
errs.append("参数 %s[%d] 应为对象" % (name, i))
continue
for rq in item_spec["required"]:
if rq not in item or item[rq] is None:
errs.append("参数 %s[%d] 缺少 %s" % (name, i, rq))
return errs
# ---------------------------------------------------------------------------
# 工具调用(裁决 + 执行 + 轨迹)
# ---------------------------------------------------------------------------
def invoke_tool(ctx, agent_code, tool_code, args=None, trace_no=None,
session_no=None, approval_no=None, store=None,
input_context=None):
"""
Agent 请求工具调用的唯一入口。
流程:开轨迹(若未给 trace_no→ 8 步裁决 → 拒绝则落 deny 轨迹并抛 PblError
→ 通过则执行后端映射 → tool_calls/result/final_output 落轨迹 → 审计。
"""
st = store or get_store()
args = args if isinstance(args, dict) else {}
own_trace = trace_no is None
if not trace_no:
trace_no = trace_mod.start_trace(
agent_code, session_no=session_no,
input_context=input_context or {"tool_code": tool_code, "args": args},
ctx=ctx, store=st)
tr = trace_mod.get_trace(trace_no, ctx=ctx, store=st, internal=True)
verdict = adjudicate(ctx, agent_code, tool_code, args=args, trace_no=trace_no,
approval_no=approval_no, store=st)
# 提案动作Agent 只 Propose14.1)——通过与拒绝都记
proposed = {
"agent_code": agent_code, "tool_code": tool_code, "args": args,
"authority": "propose_only",
"note": "Agent 非真相源写操作经工具落到权威系统14.1",
}
trace_mod.append_trace(trace_no, "proposed_action", proposed, ctx=ctx, store=st)
if not verdict.allowed:
trace_mod.append_trace(trace_no, "tool_calls", {
"tool_code": tool_code, "args": args, "executed": False,
"denied_at": verdict.step, "check": verdict.check,
"reason_code": verdict.reason_code, "reason": verdict.reason,
"disable_reason": verdict.tool.get("disable_reason"),
}, ctx=ctx, store=st)
trace_mod.append_trace(trace_no, "result", {
"allow": False, "deny": True, "step_reached": verdict.step,
"error_code": verdict.reason_code, "reason": verdict.reason,
}, ctx=ctx, store=st)
_finish_own(trace_no, own_trace, status=TRACE_STATUS_DENY,
step_reached=verdict.step,
deny_code=verdict.reason_code,
deny_reason=verdict.reason, ctx=ctx, store=st)
fail(verdict.reason_code, verdict.reason,
tool_code=tool_code, agent_code=agent_code,
step_reached=verdict.step, check=verdict.check,
disable_reason=verdict.tool.get("disable_reason"),
trace_no=trace_no)
# ---- 执行后端映射 ----
try:
result = backend_mod.execute(ctx, verdict.tool, args, trace_no=trace_no,
approval=verdict.approval, store=st)
exec_err = None
except PblError as e:
result, exec_err = None, e
except Exception as e: # 后端异常不外泄细节,统一错误码
result, exec_err = None, PblError(E_STATE_ILLEGAL,
"工具后端执行失败: %s" % e.__class__.__name__,
tool_code=tool_code)
trace_mod.append_trace(trace_no, "tool_calls", {
"tool_code": tool_code, "args": args, "executed": exec_err is None,
"backend_mapping": verdict.tool.get("backend_mapping"),
"output": result if exec_err is None else None,
"error": exec_err.to_dict() if exec_err else None,
"called_at": now_ts(),
}, ctx=ctx, store=st)
if verdict.approval:
trace_mod.append_trace(trace_no, "approval", {
"approval_no": verdict.approval.get("approval_no"),
"action_type": verdict.approval.get("action_type"),
"status": verdict.approval.get("status"),
"decided_by": verdict.approval.get("decided_by"),
"approver_type": verdict.approval.get("approver_type"),
"note": "人工审批通过后方可执行14.2",
}, ctx=ctx, store=st)
if exec_err is not None:
trace_mod.append_trace(trace_no, "result", {
"allow": True, "executed": False, "error_code": exec_err.code,
"reason": exec_err.message,
}, ctx=ctx, store=st)
_finish_own(trace_no, own_trace, status=TRACE_STATUS_DENY,
step_reached="EXECUTED", deny_code=exec_err.code,
deny_reason=exec_err.message, ctx=ctx, store=st)
raise exec_err
trace_mod.append_trace(trace_no, "result", {
"allow": True, "executed": True, "tool_code": tool_code, "output": result,
}, ctx=ctx, store=st)
trace_mod.append_trace(trace_no, "final_output", result, ctx=ctx, store=st)
_finish_own(trace_no, own_trace, status="done", step_reached="EXECUTED",
ctx=ctx, store=st)
# 审计联动§4.5object_type=tool_code
write_audit(ctx, "tool.invoke", tool_code, trace_no,
{"agent_code": agent_code, "trace_no": trace_no,
"approval_no": (verdict.approval or {}).get("approval_no")},
store=st)
out = dict(result) if isinstance(result, dict) else {"value": result}
out.setdefault("trace_no", trace_no)
out["_verdict"] = verdict.to_dict()
return out
def _finish_own(trace_no, own, **kw):
"""仅当本次 invoke_tool 自己开的轨迹才收口父轨迹Designer/Critic 流程)
由父流程负责收口避免子工具调用把外层轨迹提前冻结append-only"""
if not own:
return None
return trace_mod.finish_trace(trace_no, **kw)
def adjudicate_report(ctx, agent_code, tool_code, args=None, store=None):
"""
只裁决不执行(供前端"权限预检"与自动化测试锚点:越权工具调用拒绝)。
返回 Verdict.to_dict()。
"""
v = adjudicate(ctx, agent_code, tool_code, args=args, store=store)
return v.to_dict()
def explain_chain():
"""输出 8 步裁决顺序说明(供文档/前端展示,与 ADJUDICATION_STEPS 同源)。"""
return [{"step": s[0], "check": s[1], "description": s[2], "error_code": s[3]}
for s in ADJUDICATION_STEPS]