一、为什么工具层是安全主战场

Prompt注入只是入口,工具调用才是攻击落地的最后一公里。攻击者费尽心机注入指令,最终目标都是让Agent去调用某个危险工具:删数据、发邮件、转账、执行命令。很多团队在提示词层面严防死守,工具层却"裸奔"——Agent能调什么、参数合不合法、调用有没有记录,一概不管。本文从工具层构建四道防线。

二、第一道防线:能力白名单

不是所有工具都该对Agent开放,更不该对所有人开放。用工具注册表 + 权限装饰器实现最小权限:

class ToolRegistry:
    """集中管理工具注册与权限"""
    def __init__(self):
        self._tools = {}      # name -> Tool
        self._perms = {}      # name -> set(permission)

    def register(self, name, permission="user", description="", schema=None):
        def decorator(fn):
            self._tools[name] = {
                "fn": fn, "permission": permission,
                "description": description, "schema": schema or {},
            }
            return fn
        return decorator

    def check_permission(self, name, user_roles: set) -> bool:
        """校验调用者角色是否满足工具所需权限"""
        tool = self._tools.get(name)
        if not tool:
            return False
        required = tool["permission"]
        # admin 拥有全部权限;user 仅能调用 user 级工具
        return required == "user" or "admin" in user_roles

registry = ToolRegistry()

@registry.register("read_file", permission="user", description="读取允许范围内的文件")
def read_file(path: str) -> str:
    return safe_read(path)

@registry.register("delete_record", permission="admin", description="删除数据库记录")
def delete_record(table: str, record_id: int) -> str:
    return db_delete(table, record_id)

def invoke_tool(name, args, user_roles):
    if not registry.check_permission(name, user_roles):
        return {"error": f"权限不足:工具 {name} 需要更高权限"}
    return registry._tools[name]["fn"](**args)

权限粒度建议:user(普通用户可用)、admin(管理员)、internal(仅系统内部调用,不暴露给模型自主选择)。Agent的system prompt里只放它当前角色能用的工具清单,看不到的就不会调用。

三、第二道防线:参数校验

大模型生成的参数经常"越界":路径穿越、超长输入、非法枚举值。进执行器之前必须过schema校验:

import json

def validate_args(tool_name: str, args: dict, schema: dict) -> tuple:
    """按JSON Schema校验工具参数,返回(是否通过, 错误信息)"""
    if not schema:
        return True, ""
    required = schema.get("required", [])
    for field in required:
        if field not in args or args[field] in (None, ""):
            return False, f"缺少必填参数: {field}"
    properties = schema.get("properties", {})
    for field, value in args.items():
        spec = properties.get(field, {})
        vtype = spec.get("type", "string")
        if vtype == "string" and not isinstance(value, str):
            return False, f"参数 {field} 应为字符串"
        if vtype == "integer" and not isinstance(value, int):
            return False, f"参数 {field} 应为整数"
        if vtype == "array" and not isinstance(value, list):
            return False, f"参数 {field} 应为数组"
        # 路径类参数防穿越
        if field in ("path", "file") and isinstance(value, str):
            if ".." in value or value.startswith("/"):
                return False, f"参数 {field} 包含非法路径"
        # 枚举约束
        if "enum" in spec and value not in spec["enum"]:
            return False, f"参数 {field} 不在允许范围内: {spec['enum']}"
        # 长度上限
        if "maxLength" in spec and isinstance(value, str) and len(value) > spec["maxLength"]:
            return False, f"参数 {field} 超长"
    return True, ""

def safe_invoke(name, args, user_roles):
    tool = registry._tools.get(name)
    if not tool:
        return {"error": "未知工具"}
    ok, msg = validate_args(name, args, tool["schema"])
    if not ok:
        log_security_event("arg_validation_failed", name=name, reason=msg)
        return {"error": msg}
    return invoke_tool(name, args, user_roles)

校验失败不仅要拒绝,还要记录安全事件——频繁的参数校验失败往往意味着攻击者在试探工具边界。

四、第三道防线:敏感操作审批门

高危工具(删除、转账、发信、改配置)不能由模型单方面触发,必须挂人工审批门

HIGH_RISK_TOOLS = {"delete_record", "transfer_money", "send_email", "modify_config"}

def invoke_with_approval(name, args, user_roles, approver_callback):
    """高危工具:先挂起,等待人工审批"""
    if name in HIGH_RISK_TOOLS:
        request_id = uuid.uuid4().hex[:12]
        # 生成审批请求,推送到审批人(企业微信/邮件/管理后台)
        approver_callback(request_id, name, args)
        return {"status": "pending_approval",
                "request_id": request_id,
                "message": f"操作 {name} 需要人工审批,审批通过后自动执行"}
    return safe_invoke(name, args, user_roles)

def approve_and_execute(request_id, name, args, user_roles, decision=True):
    """审批回调:通过才真正执行"""
    if not decision:
        log_security_event("approval_rejected", name=name, request_id=request_id)
        return {"error": "审批已拒绝"}
    log_security_event("approval_passed", name=name, request_id=request_id)
    return safe_invoke(name, args, user_roles)

审批门同时要设超时策略:超过5分钟未审批自动拒绝,防止恶意请求长时间挂起占用资源。

五、第四道防线:全量调用审计

所有工具调用——无论成败——必须留痕,这是事后追溯和红蓝演练的基础:

def audit_tool_call(trace_id, user_id, name, args, result, risk="low"):
    """全量审计日志:调用方、参数、结果、风险等级"""
    record = {
        "trace_id": trace_id, "user_id": user_id, "tool": name,
        "args": json.dumps(args, ensure_ascii=False)[:500],
        "result_preview": str(result)[:200],
        "risk": risk, "ts": time.strftime("%Y-%m-%d %H:%M:%S"),
    }
    # 写入独立的审计库(与业务库分离,且只追加不修改)
    audit_db.insert(record)
    if risk == "high":
        alert_security_team(record)
    return record

审计数据的三条要求:只追加不可篡改、保留至少180天、支持按trace_id/user_id/工具名检索。定期用审计日志做异常检测——例如"同一用户在1分钟内调用删除工具5次"这类行为模式。

六、纵深防御清单

防线拦截对象关键实现
能力白名单越权调用工具注册表+角色权限
参数校验非法/恶意参数JSON Schema+路径穿越检查
审批门高危操作人工审批+超时拒绝
调用审计事后追溯全量只追加日志+异常检测
**核心观点**:工具层安全遵循"默认拒绝"原则——没注册的工具调不了、没权限的角色调不了、参数不合规调不了、高危操作没人批调不了。配合每季度一次的红蓝演练,用真实攻击脚本打自己的Agent,比任何安全文章都管用。记住:提示词防线可以被绕过,工具层的硬约束不会。