工具调用之前:为什么 Agent 读取一个文件,必须要先学会拒绝

记录 nipher-harness 第二阶段的第一部分:从工具协议与第一个 read_file 工具开始,建立工作区边界、敏感文件保护和可回灌的工具执行结果。

在上一篇文章里,nipher-harness 完成了第一次真实的 LLM 调用。

那时,程序能够把消息交给 DeepSeek,再把模型返回的文本打印出来。链路已经活了起来,但它仍然停留在 Chat 的边缘:模型可以说话,却还不能真正做事。

要让它开始行动,最自然的下一步是给它一件工具。

比如,一个 read_file

它看起来简单得几乎不值得单独写一篇文章:接收一个路径,打开文件,读取内容,再返回结果。但当这件事的调用者从一个人变成了 LLM,问题就不再只是“怎样读取文件”。

模型可以提出任意路径。

它可能请求工作区之外的文件,也可能请求工作区内的 .env,或者 Git 的内部配置。路径本身没有越界,并不代表内容就应该被交出去。

所以这一阶段真正要解决的问题是:

怎样给 Agent 一双手,同时让这双手知道哪些地方不能触碰。

这也是这一阶段暂时没有直接接入完整 Tool Calling 的原因。工具还没有被交给模型之前,我们先要确认:当它真正被交出去时,程序是否有能力约束它。

一、工具不是一段函数,而是一条完整链路

一个真正能够被 Agent 使用的工具,至少包含三个部分:

ToolDefinition

模型知道它是什么、如何调用

ToolCall

模型提出具体调用意图

实际工具函数

程序执行并返回 ToolExecutionResult

这三部分不能混为一谈。

ToolDefinition 是给模型看的说明书。它告诉模型工具的名称、用途,以及参数应该怎样填写。

ToolCall 是模型根据说明书填写出来的一次具体请求。例如:

{
  "name": "read_file",
  "arguments": "{\"path\":\"README.md\"}"
}

实际工具函数则是程序里的可执行代码。它不负责猜测模型想做什么,而是接收已经解析出来的参数,然后在程序规定的边界内完成工作。

这三者的关系可以这样理解:

说明书:告诉模型“有什么工具、应该怎样填写”
请求单:模型填写“这次要调用哪个工具、传什么参数”
执行器:程序决定“这张请求单是否允许执行,以及怎样执行”

这也是为什么工具系统不能只有一个 readFile() 函数。函数解决的是执行问题,ToolDefinition 解决的是模型理解问题,而安全策略解决的是权限问题。

如果把它们揉在一起,任何一个部分发生变化,其他部分都会被牵连。

二、先建立工具协议的类型边界

在实现第一个工具以前,项目先在 src/types.ts 中定义了工具相关的内部类型。

2.1 Tool Call:模型想做什么

DeepSeek 使用的是 OpenAI-compatible 的 Tool Calling 结构。外部 API 返回的字段是 tool_calls,每一次调用内部包含工具 ID、调用类型、名称和参数:

export type ToolCall = {
  id: string;
  type: 'function';
  function: {
    name: string;
    arguments: string;
  };
};

arguments 目前被定义为 string,因为在 API 边界上它通常还是一段 JSON 字符串:

'{"path":"README.md"}'

它还不是已经解析好的:

{
  path: 'README.md',
}

后者要等程序在执行工具之前,通过 JSON.parse() 得到。

2.2 Tool Definition:模型能够调用什么

给模型看的 read_file 工具说明书如下:

export const readFileDefinition: ToolDefinition = {
  type: 'function',
  function: {
    name: 'read_file',
    description: '读取工作区内一个 UTF-8 文本文件的内容,只允许使用相对于工作区根目录的路径',
    parameters: {
      type: 'object',
      properties: {
        path: {
          type: 'string',
          description: '要读取的、相对于工作区根目录的文件路径',
        },
      },
      required: ['path'],
    },
  },
};

这段定义并不是实际的读取代码。

它只是把下面这些信息交给模型:

  • 工具叫 read_file
  • 工具用于读取 UTF-8 文本文件;
  • path 是唯一参数;
  • path 是字符串;
  • path 必须提供;
  • 路径应该相对于工作区根目录。

尤其要注意,parameters 描述的是输入参数的格式,不是工具在程序内部的文件路径。

模型不需要知道:

src/tools/read-file.ts
某个函数的内存地址
服务器上的绝对路径

真正的工具实现路径由程序自己管理。模型只需要知道如何表达调用意图。

2.3 Tool Execution Result:工具做完了什么

工具执行结果使用一个统一的类型:

export type ToolExecutionResult = {
  content: string;
  isError: boolean;
};

为什么不直接返回一个字符串?

因为工具执行结果同时有两个消费者:

Agent Harness 程序
    → 需要机器可判断的成功/失败状态

LLM
    → 需要能够理解的具体内容

因此:

{
  content: '这是 README 的正文……',
  isError: false,
}

表示成功;

{
  content: '无法读取请求的文件:文件不存在或不可访问。',
  isError: true,
}

表示工具执行失败。

isError 不会自动替程序做决定。它只是一个明确的机器状态,之后程序可以根据它决定是否记录日志、限制重试、回灌给模型,或者在安全违规时停止执行。

content 仍然不可缺少。模型需要知道“为什么失败”,才能决定下一步是换一个路径、先列出目录,还是向用户说明问题。

三、第一道防线:只允许在工作区内活动

模型传入的路径,不能直接交给 Bun.file()

如果程序这样写:

const file = Bun.file(args.path);

那么模型实际上就获得了影响本机文件读取位置的能力。它可以尝试:

../.env
../../etc/passwd
/Users/nipher/private/passwords.txt

这不是工具的能力问题,而是工具边界没有被定义。

所以项目先实现了 resolveWorkspacePath()

import { isAbsolute, relative, resolve, sep } from 'node:path';

export function resolveWorkspacePath(
  workspaceRoot: string,
  requestedPath: string,
): string {
  if (isAbsolute(requestedPath)) {
    throw new Error('只允许使用相对于工作区的路径');
  }

  const rootPath = resolve(workspaceRoot);
  const targetPath = resolve(rootPath, requestedPath);
  const relativePath = relative(rootPath, targetPath);

  if (
    relativePath === '..' ||
    relativePath.startsWith(`..${sep}`)
  ) {
    throw new Error('路径超出工作区范围');
  }

  return targetPath;
}

它的工作过程不是看到 .. 就一概拒绝,而是先把路径规范化,再判断最终位置。

例如:

workspaceRoot = /project/workspace

请求:

docs/../README.md

规范化之后:

/project/workspace/README.md

它仍然位于工作区中,因此应该允许。

而请求:

../.env

规范化之后:

/project/.env

这已经离开工作区,因此必须拒绝。

这里没有使用简单的字符串前缀判断:

// 不可靠的思路
if (targetPath.startsWith(rootPath)) {
  // 允许
}

因为下面这个路径也会满足字符串前缀关系:

/project/workspace-secret

但它并不是:

/project/workspace

的子路径。

使用 relative() 判断路径关系,表达的是文件系统意义上的相对位置,而不是字符串长得像不像。

完整判断链路是:

flowchart TD A[模型传入 requestedPath] --> B{是否为绝对路径} B -- 是 --> C[拒绝] B -- 否 --> D[resolve 工作区根目录] D --> E[resolve 根目录与请求路径] E --> F[计算 targetPath 相对 rootPath 的路径] F --> G{是否为 .. 或以 .. 开头} G -- 是 --> C G -- 否 --> H[返回工作区内的规范化绝对路径]

resolveWorkspacePath() 返回的是一个安全候选路径,而不是“文件一定存在”的证明。

路径安全和文件存在,是两个不同的问题。

四、第二道防线:工作区内,也不是所有文件都能读

第一道防线解决的是:

模型能不能走到这个位置?

但它没有解决:

即使位置合法,这个文件是否仍然应该交给模型?

当工作区根目录就是项目根目录时,.env 可能正好位于工作区内部:

nipher-harness/
├── .env
├── .git/
├── src/
└── README.md

从路径边界来看:

.env
→ 没有越界
→ 位于工作区内

但从信息安全来看:

.env
→ 可能包含 DEEPSEEK_API_KEY
→ 不能交给模型

所以项目又增加了 isSensitiveWorkspacePath(),作为第二道防线。

当前的 v0 策略选择了安全优先,拒绝三类内容:

.env 和 .env.*
.git 目录中的任意文件
.pem、.key、.p12、.pfx 文件

核心实现如下:

import { relative, resolve, sep } from 'node:path';

const blockedExtensions = ['.pem', '.key', '.p12', '.pfx'];

export function isSensitiveWorkspacePath(
  workspaceRoot: string,
  targetPath: string,
): boolean {
  const rootPath = resolve(workspaceRoot);
  const relativePath = relative(rootPath, targetPath);
  const segments = relativePath.split(sep);
  const fileName = segments.at(-1) ?? '';

  if (segments.includes('.git')) {
    return true;
  }

  if (
    fileName === '.env' ||
    fileName.startsWith('.env.')
  ) {
    return true;
  }

  if (blockedExtensions.some((extension) => fileName.endsWith(extension))) {
    return true;
  }

  return false;
}

这里使用了 Guard Clauses,也就是守卫式提前返回:

命中一条拒绝规则
→ 立即返回 true

所有规则都没有命中
→ 最后返回 false

这不是低级的写法,反而适合当前规则数量不多、规则类型又各不相同的情况:

  • .git 是目录路径规则;
  • .env 是文件名规则;
  • .pem 等是扩展名规则。

如果现在为了追求“高级设计”,立即引入策略类、抽象工厂或复杂的规则对象,代码未必会因此更专业。对只有几条清楚规则的 v0 实现来说,多个清晰的 Guard Clause 比层层嵌套更容易审查。

当然,这并不是完整的安全系统。

当前策略仍然没有覆盖:

  • 名为 passwords.txt 的普通文件;
  • 内容扫描;
  • 符号链接指向工作区外的情况;
  • 文件类型与大小限制;
  • 更复杂的权限和人工确认。

但工程上的安全,不是等到所有问题都解决之后才开始。先把明确、确定、代价低的风险挡住,再继续向更复杂的边界推进。

五、把两道防线接入 readFile

策略模块单独存在还不够。

如果 readFile() 不调用它们,那么它们只是两份通过测试的代码,并没有真正保护读取动作。

readFile() 的执行顺序现在是:

模型传入 args.path

resolveWorkspacePath()

拒绝绝对路径和路径穿越

isSensitiveWorkspacePath()

拒绝 .env、.git、私钥与证书文件

Bun.file(safePath)

检查文件是否存在

读取 UTF-8 文本

返回 ToolExecutionResult

核心代码如下:

export async function readFile(
  workspaceRoot: string,
  args: ReadFileArgs,
): Promise<ToolExecutionResult> {
  let safePath: string;

  try {
    safePath = resolveWorkspacePath(workspaceRoot, args.path);
  } catch {
    return {
      content: '无法读取请求的文件:路径不被允许',
      isError: true,
    };
  }

  if (isSensitiveWorkspacePath(workspaceRoot, safePath)) {
    return {
      content: '无法读取请求的文件:该文件属于受保护内容。',
      isError: true,
    };
  }

  const file = Bun.file(safePath);

  if (!(await file.exists())) {
    return {
      content: '无法读取请求的文件:文件不存在或不可访问。',
      isError: true,
    };
  }

  try {
    const content = await file.text();

    return {
      content,
      isError: false,
    };
  } catch {
    return {
      content: '无法读取请求的文件:读取文件内容时发生错误。',
      isError: true,
    };
  }
}

这里有一个重要的错误处理边界。

文件不存在、文件读取失败,属于一次工具执行失败;它们不一定意味着整个 Agent 任务失败。因此 readFile() 返回的是:

{
  content: '无法读取请求的文件:文件不存在或不可访问。',
  isError: true,
}

而不是直接把异常抛到最外层。

未来进入 ReAct Loop 后,这个结果可以作为 Tool Message 回灌给模型。模型看到失败原因之后,可以选择:

换一个路径
→ 调用其他工具
→ 向用户询问
→ 结束任务并说明原因

但是,安全边界拒绝和普通读取失败仍然需要被区分:

文件不存在
→ 普通工具执行失败
→ 可以回灌给模型

路径越界或敏感文件
→ 安全策略拒绝
→ 工具不能执行
→ 后续是否继续,由更高层安全策略决定

此时工具函数还没有真正连接到模型,但它已经具备了一个重要性质:

它不会因为模型的一次错误请求,就把程序的边界交出去。

六、用测试证明“拒绝”确实发生了

安全代码不能只靠阅读确认。

尤其是路径处理,肉眼很容易漏掉某一种边界。于是项目分别为路径规范化、敏感路径策略和真实文件读取编写了测试。

6.1 路径边界测试

路径测试覆盖:

README.md                         → 允许
 docs/../README.md                → 规范化后允许
../.env                           → 拒绝
../../etc/passwd                  → 拒绝
/etc/passwd                      → 拒绝
.                                 → 工作区根目录,允许

这里的 docs/../README.md 很重要。

它证明策略不是简单地“看到 .. 就拒绝”,而是会判断路径规范化之后的最终位置。

测试使用的是虚拟路径:

const workspaceRoot = '/project/workspace';

因为这个测试只验证路径关系,并不需要真实文件存在。

6.2 敏感路径策略测试

敏感策略测试覆盖:

README.md                 → false
.env                      → true
config/.env.local         → true
.git/config               → true
certs/server.pem          → true
certs/private.key         → true
config/app.json           → false

它验证的是策略模块本身,不涉及文件读取。

6.3 readFile() 集成测试

仅仅证明策略函数能返回 true 还不够。还需要证明真实的 readFile() 确实调用了它。

因此测试会在系统临时目录中为每个用例创建一个随机工作区,并写入假的测试文件:

README.md
.env
.git/config
certs/server.pem

其中 .env 的内容只是:

DEEPSEEK_API_KEY=fake-test-key

这是测试数据,不是项目真实凭证。

随后测试验证:

README.md       → 成功返回正文
NOT_FOUND.md    → 返回 isError: true
../.env         → 返回路径不允许
.env            → 返回受保护内容
.git/config     → 返回受保护内容
server.pem      → 返回受保护内容

测试结束之后,临时工作区会被递归删除。这样测试既能触碰真实文件系统,又不会依赖项目目录,也不会读取真实 .env

最终执行:

bun test src/tools

得到结果:

21 pass
0 fail
21 expect() calls

这 21 个测试证明了:

路径边界测试       → 6 个
敏感策略测试       → 7 个
readFile 集成测试  → 8 个
总计               → 21 个

它们不能证明整个 Agent 已经完成,也不能证明符号链接、二进制文件、超大文件等边界已经解决。但它们确实证明了当前阶段声称的事情:

在当前定义的安全规则下,第一个文件读取工具能够限制工作区范围,并拒绝最明确的敏感内容。

七、这一阶段完成了什么

这一阶段没有完成 ToolRegistry,也没有完成真正的模型工具调用。

它完成的是一个更靠近地基的部分:让工具在被开放给模型之前,先拥有自己的边界。

当前已经形成的代码结构是:

src/
├── types.ts
└── tools/
    ├── path-security.ts
    ├── path-security.test.ts
    ├── sensitive-path-policy.ts
    ├── sensitive-path-policy.test.ts
    ├── read-file.ts
    └── read-file.test.ts

对应的职责是:

模块职责
types.ts定义 Tool Call、Tool Definition、工具执行结果等内部类型
path-security.ts将模型路径限制在工作区内
sensitive-path-policy.ts拒绝工作区内的明确敏感文件
read-file.ts在安全检查通过后读取 UTF-8 文本
*.test.ts验证路径、策略和实际读取链路

从能力上看,项目已经从:

只能调用 LLM

走到了:

拥有一个可以被约束、被测试的本地文件读取工具

这一步仍然没有让 Agent 真正行动起来,却让“行动”第一次拥有了边界。

八、下一步:把工具交给模型

现在,工具说明书和实际执行器已经分别存在:

readFileDefinition
readFile(workspaceRoot, args)

它们之间还缺少一个连接者:

ToolRegistry

下一阶段需要解决的问题会变成:

程序如何登记工具?
模型返回 read_file 后,程序如何根据 name 找到对应执行函数?
arguments 如何从 JSON 字符串解析成 ReadFileArgs?
执行结果如何关联 toolCallId?
工具结果如何回灌给模型?

直到这些问题被解决,read_file 才不再只是一个“存在于代码中的工具”,而会成为模型能够真正调用的工具。

再往后,才是完整的 ReAct Loop:

模型决定行动

程序执行工具

结果回到模型

模型继续判断

继续行动,或输出最终答案

第一篇文章写的是让 LLM 真正的进入了项目。

那这一篇文章做的,是在它即将伸手之前,先把边界画出来,边界并不是行动的对立面。

对于一个真正要进入现实环境的 Agent 来说,边界本身就是行动能够被信任的前提。

输入关键词搜索全部文章 · 支持快捷键 / 唤起