工具调用之前:为什么 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() 判断路径关系,表达的是文件系统意义上的相对位置,而不是字符串长得像不像。
完整判断链路是:
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 来说,边界本身就是行动能够被信任的前提。