372 lines
17 KiB
Python
372 lines
17 KiB
Python
# -*- 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_schema(JSON 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 只 Propose,14.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.5):object_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]
|