有效 JSON 格式不保证参数类型正确;作者展示了三个隐蔽危险场景(字符串型 user_id、缺失 dry_run 检查、未知 admin 字段),并给出执行前参数校验代码示例。
一段合法的 JSON 对象并不等于一次安全的工具调用。
免费模型可以输出一段格式正确的字符串,但其中仍然可能存在类型错误、必填字段缺失或未知参数。直接执行这样的调用,一个错误的整数就可能命中错误的记录。修复方案不是更大的 Prompt,而是一份在派发之前运行的小型参数契约。
以下三类错误在 JSON 中看起来无害,但在执行器中却会变得危险:
user_id: "42" 能通过 JSON 解析器,但无法通过整数校验。
dry_run: false 会让破坏性操作在人工审批之前直接执行。
admin: true 是一个额外字段,宽松的接口可能会默默接受它。
模型不需要恶意才能导致这些失败,它只需要从几个示例中做了一次泛化。下面的守卫会在工具被调用之前捕获这些失败。
Disclosure: This article was prepared as part of MonkeyCode's product outreach. The sample tool calls in this article are generated by a free model endpoint available on MonkeyCode's free server. The guard does not depend on any specific model or endpoint; it takes one JSON object and decides whether that object may run.
脚本维护了一份声明式的工具规格。每个参数都有类型、必填标志、数值范围或长度限制。工具规格还存储了影响配额(effect budget)以及破坏性操作的 dry-run 要求。
from dataclasses import dataclass
from typing import Optional, Union, Dict, Any, Tuple
import json
@dataclass(frozen=True)
class ParamSpec:
type: str
required: bool = True
min_value: Optional[Union[int, float]] = None
max_value: Optional[Union[int, float]] = None
enum: Optional[tuple] = None
max_length: Optional[int] = None
min_length: Optional[int] = None
@dataclass(frozen=True)
class ToolSpec:
name: str
params: Dict[str, ParamSpec]
max_effect: Optional[int] = None
require_dry_run: bool = False
TOOLS = {
'update_user_email': ToolSpec(
name='update_user_email',
require_dry_run=True,
max_effect=1,
params={
'user_id': ParamSpec('integer', min_value=1),
'email': ParamSpec('string', max_length=320, min_length=5),
'dry_run': ParamSpec('boolean'),
},
),
'delete_user': ToolSpec(
name='delete_user',
require_dry_run=True,
max_effect=1,
params={
'user_id': ParamSpec('integer', min_value=1),
'reason': ParamSpec('string', max_length=200, min_length=3),
'dry_run': ParamSpec('boolean'),
},
),
'bulk_update_emails': ToolSpec(
name='bulk_update_emails',
require_dry_run=True,
max_effect=5,
params={
'user_ids': ParamSpec('array', max_length=10, min_length=1),
'new_email': ParamSpec('string', max_length=320, min_length=5),
'dry_run': ParamSpec('boolean'),
},
),
}
def _matches_type(value: Any, expected: str) -> bool:
if expected == 'integer':
return isinstance(value, int) and not isinstance(value, bool)
if expected == 'number':
return isinstance(value, (int, float)) and not isinstance(value, bool)
if expected == 'string':
return isinstance(value, str)
if expected == 'boolean':
return isinstance(value, bool)
if expected == 'array':
return isinstance(value, list)
if expected == 'object':
return isinstance(value, dict)
return False
def _estimate_effect(args: Dict[str, Any]) -> int:
if 'user_ids' in args and isinstance(args['user_ids'], list):
return len(args['user_ids'])
if 'limit' in args and isinstance(args['limit'], int):
return args['limit']
return 1
def validate_tool_call(call: Dict[str, Any], tools: Dict[str, ToolSpec]) -> Tuple[bool, list]:
errors: list = []
if not isinstance(call, dict):
return False, ['call must be an object']
name = call.get('name')
if not isinstance(name, str) or name not in tools:
return False, ['unknown or missing tool name']
spec = tools[name]
raw_args = call.get('arguments', {})
if isinstance(raw_args, str):
try:
raw_args = json.loads(raw_args)
except json.JSONDecodeError:
return False, ['arguments is not valid JSON']
if not isinstance(raw_args, dict):
return False, ['arguments must be an object']
unknown = set(raw_args) - set(spec.params)
if unknown:
errors.append(f'unknown arguments: {sorted(unknown)}')
for pname, pspec in spec.params.items():
if pname not in raw_args:
if pspec.required:
errors.append(f'missing required argument: {pname}')
continue
value = raw_args[pname]
if not _matches_type(value, pspec.type):
errors.append(f'{pname} must be {pspec.type}')
continue
if pspec.min_value is not None and value < pspec.min_value:
errors.append(f'{pname} must be >= {pspec.min_value}')
if pspec.max_value is not None and value > pspec.max_value:
errors.append(f'{pname} must be <= {pspec.max_value}')
if pspec.max_length is not None and len(value) > pspec.max_length:
errors.append(f'{pname} length must be <= {pspec.max_length}')
if pspec.min_length is not None and len(value) < pspec.min_length:
errors.append(f'{pname} length must be >= {pspec.min_length}')
if pspec.enum is not None and value not in pspec.enum:
errors.append(f'{pname} must be one of: {pspec.enum}')
if spec.require_dry_run:
dry_run = raw_args.get('dry_run')
if dry_run is not True:
errors.append('dry_run must be true')
effect = _estimate_effect(raw_args)
if spec.max_effect is not None and effect > spec.max_effect:
errors.append(f'effect {effect} exceeds max_effect {spec.max_effect}')
return len(errors) == 0, errors
用 python tool_contract.py 运行它。脚本只依赖 Python 标准库。
影响配额不是 Prompt 工程,而是一个运行时限制。模型可能因为合并了两个用户请求而在一次调用中请求更大的批量。守卫会在执行器触碰任何记录之前拒绝该调用。
这个守卫验证的是意图,而非副作用。执行层仍然需要超时、 rollback 和审计日志。本例中的邮箱校验只是长度检查,生产代码应该使用邮箱解析器或域名校验。守卫还假设执行器只调用经过验证的对象。直接代码路径可以绕过它。
免费模型的 JSON 格式可能随时间漂移。监控未知参数和工具名的突然变化。
如果你的系统只生成文本而从不调用工具,跳过这个方案。如果你的执行器已经强制执行类型化接口和 dry-run 门控,跳过这个方案。在免费模型输出流向 Shell 命令、SQL、API 或文件写入时使用它。
同样的守卫适用于任何发出工具调用 JSON 的模型,所以在换用其他端点时契约可以跟着你迁移。