NeoHorse-1 读任务、调工具 / 结果回写前先卡权限边界

文章导读
NeoHorse-1 能读任务、也能调工具,这条链路真正的风险点不在读,而在读完之后把模型输出直接写回去。权限边界建议卡在结果回写之前,并且分两层:工具注册层的准入检查负责拦住不该调用的工具,回写层的字段级校验负责拦住不该落地的内容。两层任意一层拒绝,调用链就以失败收场,而不是先写进去再想办法回滚。
📋 目录
  1. Ⅰ 列出所有工具并标注读写级别
  2. Ⅱ 在工具注册层加权限检查
  3. Ⅲ 在结果回写前做二次确认
  4. Ⅳ 构造一次越权调用并观察日志
  5. Ⅴ 把权限拒绝反馈给 Agent
A A

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 需要和上一步的工具清单保持一致。检查顺序建议先看身份再看范围,这样无身份的调用不会因为范围配置错误而被放过。

NeoHorse-1 读任务、调工具 / 结果回写前先卡权限边界

在结果回写前做二次确认

工具层拦住了不该调的工具,还有一类绕过:模型没有走工具,直接把结构化结果交给回写函数。回写路径往往被多处复用,人工补录、批处理也会走同一入口,所以不能默认调用方已经检查过。回写前建议独立做三项校验:目标字段是否在白名单内、值是否落在允许范围、来源标记是否允许自动写入。

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。

NeoHorse-1 读任务、调工具 / 结果回写前先卡权限边界
{
  "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 在失败后到底走了哪条路径。