NeoHorse-1 能读任务、也能调工具,这条链路真正的风险点不在读,而在读完之后把模型输出直接写回去。权限边界建议卡在结果回写之前,并且分两层:工具注册层的准入检查负责拦住不该调用的工具,回写层的字段级校验负责拦住不该落地的内容。两层任意一层拒绝,调用链就以失败收场,而不是先写进去再想办法回滚。
能读任务、能调工具的 Agent,权限卡点应放在结果回写之前,而不是事后审计。做法是先在工具注册层校验调用者身份与任务范围,再在回写函数前校验目标字段白名单、值范围与来源标记。验证方式是构造一次未授权写入,日志中应出现拒绝记录且目标资源无实际变化。边界上,只读工具可放宽,外发类工具建议默认拒绝、按需加白名单;拒绝信息要让 Agent 看懂,避免它反复重试。
列出所有工具并标注读写级别
先把 NeoHorse-1 实际能触达的工具列全,再按「是否改变状态、是否离开本系统」分级。只读工具可以默认允许,写类工具需要任务范围匹配,外发类工具建议默认拒绝、按需加白名单。分级时不要只看工具名,要看它作用的目标资源,以及参数里有没有用户可控的路径或 ID。
| 工具名 | 动作 | 目标资源 | 风险级别 |
|---|---|---|---|
| task.read | 读取单个任务详情 | 任务存储 | 只读 |
| task.list | 按条件列举任务 | 任务存储 | 只读 |
| tool.invoke | 转发到子工具 | 工具注册表 | 中,取决于子工具 |
| task.update | 修改任务字段 | 任务存储 | 写,中 |
| notify.send | 向外部通道发消息 | 外部通道 | 外发,高 |
| file.export | 导出内容到外部位置 | 外部存储 | 外发,高 |
像 tool.invoke 这种转发型工具,按它可能触达的最危险动作定级,不要按最常见的用法定级。清单不需要写具体业务密钥或通道凭据,只写资源类别即可,凭据引用留给运行环境注入。
在工具注册层加权限检查
权限检查放在工具注册层,也就是函数真正执行业务逻辑之前。这样即使模型构造了越权参数,也不会进入下游逻辑。实现上通常用一个装饰器包住每个工具函数,检查三件事:调用者身份是否可信、目标资源是否在任务范围内、外发类动作是否被显式允许。拒绝时返回统一结构,不要抛出各不相同的异常,否则 Agent 侧很难判断该不该重试。
EXTERNAL_ACTIONS = {'notify.send', 'file.export'}
def require_scope(action, resource_arg):
def deco(fn):
def inner(ctx, *args, **kwargs):
caller = ctx.get('caller_id')
scope = ctx.get('task_scope', [])
resource = kwargs.get(resource_arg)
if not caller:
return deny('NO_CALLER', action, resource)
if resource not in scope:
return deny('OUT_OF_SCOPE', action, resource)
if action in EXTERNAL_ACTIONS and not ctx.get('external_allowed'):
return deny('EXTERNAL_NOT_ALLOWED', action, resource)
return fn(ctx, *args, **kwargs)
return inner
return deco
def deny(code, action, resource):
return {'ok': False, 'code': code, 'action': action, 'resource': resource}
替换项有三处:caller_id 来自任务上下文而不是模型输出,task_scope 来自任务创建时声明的资源清单,EXTERNAL_ACTIONS 需要和上一步的工具清单保持一致。检查顺序建议先看身份再看范围,这样无身份的调用不会因为范围配置错误而被放过。
在结果回写前做二次确认
工具层拦住了不该调的工具,还有一类绕过:模型没有走工具,直接把结构化结果交给回写函数。回写路径往往被多处复用,人工补录、批处理也会走同一入口,所以不能默认调用方已经检查过。回写前建议独立做三项校验:目标字段是否在白名单内、值是否落在允许范围、来源标记是否允许自动写入。
WRITE_FIELDS = {'status', 'assignee', 'summary'}
VALUE_RULES = {
'status': {'todo', 'doing', 'done'},
'summary': lambda v: isinstance(v, str) and 1 <= len(v) <= 500,
}
def write_back(task_id, patch, source, ctx):
if task_id not in ctx.get('task_scope', []):
return reject('OUT_OF_SCOPE', task_id)
if not source.startswith('agent:'):
return reject('SOURCE_NOT_AUTO', source)
for field, value in patch.items():
if field not in WRITE_FIELDS:
return reject('FIELD_NOT_ALLOWED', field)
rule = VALUE_RULES.get(field)
if callable(rule) and not rule(value):
return reject('VALUE_OUT_OF_RANGE', field)
if isinstance(rule, set) and value not in rule:
return reject('VALUE_OUT_OF_RANGE', field)
return store.update(task_id, patch)
白名单是字段级的,不是表级的:允许写 status,不等于允许写 status 之外的任意列。来源标记用来区分人工写入和 Agent 推断,建议只让 agent: 前缀触发自动写入,其他来源转确认流程。
构造一次越权调用并观察日志
验证拦截是否真的生效,最直接的方式是主动构造一次越权调用:调用者 agent:neohorse-1 的当前任务范围只有 T-1001,却请求把 T-9001 的状态改成 done。
{
"caller_id": "agent:neohorse-1",
"tool": "task.update",
"args": {"task_id": "T-9001", "status": "done"}
}
预期结果是返回 ok 为 false、code 为 OUT_OF_SCOPE,并且目标任务没有任何字段变化。日志里至少检查这些字段:
- 时间戳与 request_id,方便把一次调用串起来;
- caller_id,确认发起者确实是 Agent 而不是内部批处理;
- tool 与 action,确认命中的是写类工具;
- resource,确认被拒的是 scope 外的任务;
- decision 与 reason,例如 DENY / OUT_OF_SCOPE;
- scope_snapshot,记录当时的任务范围,便于事后复现判断依据。
确认「无实际写入」不能只看返回值,还要看写入侧:目标任务的更新时间和审计记录都应保持不变。如果返回值是拒绝但更新记录仍然出现,说明校验放在了存储之后,需要把检查点前移。
把权限拒绝反馈给 Agent
拒绝信息要让模型知道下一步该做什么,而不是只丢一个 403 让它猜。建议在拒绝结构里固定带上 code、retryable 和 hint 三个字段,其中 retryable 是明确告诉 Agent 能不能重试,hint 给出替代路径。
{
"ok": false,
"code": "OUT_OF_SCOPE",
"retryable": false,
"hint": "仅可写入 task_scope 内的任务,可改用 task.read 输出建议"
}
重试上限建议按「同一 tool 加同一 resource」计数:连续被拒两次就停止重试,避免 Agent 换参数反复试探。外发类工具的拒绝直接设 retryable 为 false,一次都不重试。降级条件可以这样切:写类被拒后降级为 task.read,让 Agent 继续推理并产出建议文本;外发类被拒后不自动重试,转入人工确认队列,由人决定是否放行。这些阈值和降级动作都应有对应日志,方便回看 Agent 在失败后到底走了哪条路径。