目标
Index 02 的智能体会用工具了——读文件、写文件、执行 Shell 命令。但它也有了一个危险的能力:LLM 说 bash rm -rf /,AgentLoop 就会执行。
Index 03 的目标是给智能体装上安全阀,在工具执行前插入一道审批管线:
- 定义 PermissionRule 接口和 PermissionResult 三态枚举,让每条规则都能对工具调用做出 ALLOW / DENY / ASK 的判断
- 实现 PermissionPipeline 责任链模式,串联多个规则,收集所有结果再汇总——DENY 始终覆盖 ASK
- 实现三个内置规则——ToolCategoryRule(读写分类)、DangerousCommandRule(危险命令检测)、PathAllowlistRule(路径逃逸防护)
- 实现 ApprovalStore 记忆存储,用户的 "always allow" 决定不会在每次工具调用时重复询问
- 实现 ReadLinePrompter 终端交互,用简洁的交互界面让用户确认/拒绝/记住
- 通过回调注入 AgentLoop,不修改 AgentLoop 对 permission 包的依赖关系,保持零耦合
完成后的效果——用户重新掌握了控制权:
┌─ Permission Required ─────────────────────────────
│ Tool: bash
│ Input: {"command":"rm -rf /"}
│ Rule: dangerous-command
├────────────────────────────────────────────────────
│ [y] Yes — allow this time
│ [n] No — deny this time
│ [a] Always allow this tool
│ [d] Always deny this tool
└────────────────────────────────────────────────────
>读文件自动放行,rm -rf / 直接拒绝,写文件需要用户点头。安全、灵活、可记忆。
架构:可插拔的审批管线
Index 03 在 Index 02 的工具执行链路上插入了一个可选的审批步骤:
AgentLoop 工具执行循环
│
┌──────────────────────────────▼──────────────────────┐
│ executeOneTool(toolCall) │
│ │
│ ① onBeforeToolExecute? ───null─────→ 直接执行 │
│ │ (回调) │
│ ▼ │
│ ② PermissionPipeline.approve(toolCall) │
│ │ │
│ ├──③ 收集所有规则的 evaluate() 结果 │
│ │ │ │
│ │ ├─ToolCategoryRule → ALLOW / ASK │
│ │ ├─DangerousCommand → ALLOW / DENY │
│ │ └─PathAllowlistRule → ALLOW / DENY │
│ │ │
│ ├──④ 汇总结果 │
│ │ ├─任一 DENY → 返回 false (拒绝) │
│ │ ├─全部 ALLOW → 返回 true (放行) │
│ │ └─存在 ASK (无DENY) → 进入交互确认 │
│ │ │ │
│ │ ├─ApprovalStore 有记忆 → 直接决定 │
│ │ └─ReadLinePrompter → 用户选择 │
│ ▼ │
│ ⑤ 返回:true=执行 / false=Permission denied 错误 │
└──────────────────────────────────────────────────────┘| 层 | Index 02 状态 | Index 03 变更 | 行数变化 |
|---|---|---|---|
| 权限模型 | 无 | PermissionResult, PermissionRule, PermissionDecision, UserPrompter | +133 行 |
| 审批管线 | 无 | PermissionPipeline(收集-汇总-交互) | +104 行 |
| 记忆系统 | 无 | ApprovalStore(ConcurrentHashMap) | +51 行 |
| 终端交互 | 无 | ReadLinePrompter(y/n/a/d 提示) | +61 行 |
| 三个规则 | 无 | ToolCategoryRule + DangerousCommandRule + PathAllowlistRule | +150 行 |
| 智能体 | AgentLoop 工具循环 | 回调注入(12 行代码, 0 耦合) | 203→219 行 |
| REPL | 注册工具 + 启动循环 | 权限管线组装 + 注入 AgentLoop | +20 行 |
第一层:权限数据模型
PermissionResult:三态枚举
PermissionRule 对工具调用的评估结果是三个值之一:
enum class PermissionResult {
ALLOW, // 此规则认为可以执行
DENY, // 此规则认为不应执行(覆盖 ASK)
ASK, // 需要用户确认
}DENY 和 ASK 的区别是关键设计决策。DENY 是无条件的——无论其他规则怎么说,只要有一个规则返回 DENY,管线直接拒绝。ASK 则给用户留了选择空间,但如果另一个规则已经返回了 DENY,用户不会被打扰。
PermissionRule:单方法接口
interface PermissionRule {
val name: String
suspend fun evaluate(toolCall: ToolCall): PermissionResult
}极简接口,只有一个方法。name 用于日志和用户提示("Rule: dangerous-command")。evaluate() 是 suspend 函数——虽然当前实现全是同步的,但接口以协程设计为后续预留了空间(比如需要查远程 ACL 的规则)。
PermissionDecision:用户的四种选择
enum class PermissionDecision {
ALLOW, // 仅本次允许
DENY, // 仅本次拒绝
ALLOW_ALWAYS, // 始终允许此工具
DENY_ALWAYS // 始终拒绝此工具
}在交互环节,用户的选择比规则的评估结果多了两种——"记住"选项。ALLOW_ALWAYS 和 DENY_ALWAYS 会被写入 ApprovalStore,后续对同一工具名的调用直接跳过用户提示。
UserPrompter:交互接口
interface UserPrompter {
suspend fun ask(toolCall: ToolCall, ruleName: String): PermissionDecision
}与 PermissionRule 分离的独立接口。管线只依赖接口,不关心实现——REPL 下是 ReadLinePrompter(stdin/stdout),测试下是 FakePrompter(预设决策序列)。这个分离让 PermissionPipelineTest 可以在纯内存中运行,不需要终端。
第二层:PermissionPipeline — 收集-汇总-交互
权审批管线的核心逻辑分四步:
class PermissionPipeline(
private val rules: List<PermissionRule>,
private val approvalStore: ApprovalStore,
private val userPrompter: UserPrompter
) {
suspend fun approve(toolCall: ToolCall): Boolean {
// 1. 收集所有规则的评估结果
val results = rules.map { rule ->
try { rule.evaluate(toolCall) }
catch (e: Exception) { PermissionResult.DENY } // 异常→DENY
}
// 2. 汇总:任一 DENY → 拒绝
if (results.any { it == PermissionResult.DENY }) return false
// 3. 全部 ALLOW → 放行
val hasAsk = results.any { it == PermissionResult.ASK }
if (!hasAsk) return true
// 4. 存在 ASK → 查记忆 → 交互
val remembered = approvalStore.get(toolCall.name)
if (remembered != null) return remembered == PermissionResult.ALLOW
val decision = userPrompter.ask(toolCall, askRuleName)
// 处理 ALLOW_ALWAYS / DENY_ALWAYS → 写入记忆
...
}
}收集→汇总→交互 三步走的设计有几个隐蔽的优点:
异常容错。 rules.map 中的 try/catch 确保任何一个规则抛出异常不会导致整个审批崩溃——异常被视为 DENY,安全地拒绝工具调用。这防止了规则代码的 bug 变成安全漏洞。
DENY 优先级不受注册顺序影响。 无论 DangerousCommandRule 在规则列表的开头还是末尾,只要它返回 DENY,管线就拒绝。这避免了"过早 ALLOW"的问题——如果一个靠前的规则返回了 ALLOW,后面的 DENY 仍然有效。
ApprovalStore 位于 UserPrompter 之前。 记忆中已有的决策不会触发终端交互,即使在用户输入的 /clear 清空记忆之前不需要重复审批。
第三层:三个内置规则
ToolCategoryRule — 读写自动分类
class ToolCategoryRule : PermissionRule {
override val name = "tool-category"
override suspend fun evaluate(toolCall: ToolCall): PermissionResult {
val isReadOnly = try {
ToolRegistry.get(toolCall.name).isReadOnly
} catch (_: UnknownToolException) {
return PermissionResult.ASK // 未知工具→安全默认
}
return if (isReadOnly) PermissionResult.ALLOW
else PermissionResult.ASK
}
}利用 Tool 接口的 isReadOnly 属性做自动分类。读操作(read_file)自动放行,写操作(write_file、bash)标记为"需要确认"。未知工具也视为 ASK——如果 LLM 编造了一个不存在的工具名,用户会被提示。
这是管线中典型的 "base rule"——它不应该单独决定执行或拒绝,而是为后续规则提供初始信号。如果放在规则列表首位,后续的 DangerousCommandRule 可以在 ASK 已经标记的情况下进一步检查并返回 DENY。
DangerousCommandRule — 七种致命模式
class DangerousCommandRule : PermissionRule {
override val name = "dangerous-command"
override suspend fun evaluate(toolCall: ToolCall): PermissionResult {
if (toolCall.name != "bash") return PermissionResult.ALLOW
val command = toolCall.input["command"]?.toString() ?: ""
val isDangerous = DANGEROUS_PATTERNS.any { pattern ->
pattern.containsMatchIn(command)
}
return if (isDangerous) PermissionResult.DENY else PermissionResult.ALLOW
}
companion object {
private val DANGEROUS_PATTERNS = listOf(
Regex("rm\\s+-rf\\s+/"), // 递归强制删除根目录
Regex("chmod\\s+777\\s+/"), // 修改根目录权限
Regex("dd\\s+if="), // 磁盘直接写入
Regex("mkfs\\."), // 格式化文件系统
Regex(">\\s*/dev/sd[a-z]"), // 覆盖磁盘设备
Regex(":.*\\{\\s*:\\|:&\\s*\\};:"), // fork 炸弹
Regex("sudo\\s+rm\\s+-rf\\s+/") // sudo 删除根目录
)
}
}仅对 bash 工具生效,用七个正则表达式匹配已知危险模式。匹配结果直接返回 DENY——不给用户选择,因为普通用户也不应该为 "Do you want to run rm -rf /?" 这种问题做选择。
正则列表是硬编码的,不是可配置的。设计哲学:Deny by default for dangerous, Ask by default for unknown。不追求完美覆盖所有危险命令——漏掉的模式(false negative)仍然会被 ToolCategoryRule 标记为 ASK,让用户有机会判断;误杀(false positive)也可以通过用户选择 "Allow" 绕过。
PathAllowlistRule — 防止路径逃逸
class PathAllowlistRule : PermissionRule {
override val name = "path-allowlist"
private val projectRoot = Path.of(System.getProperty("user.dir")).normalize()
override suspend fun evaluate(toolCall: ToolCall): PermissionResult {
if (toolCall.name !in APPLICABLE_TOOLS) return PermissionResult.ALLOW
val pathString = extractPath(toolCall) ?: return PermissionResult.ALLOW
return try {
val resolved = projectRoot.resolve(pathString).normalize()
if (resolved.startsWith(projectRoot)) PermissionResult.ALLOW
else PermissionResult.DENY
} catch (_: Exception) { PermissionResult.ALLOW }
}
companion object {
private val APPLICABLE_TOOLS = setOf("write_file", "bash")
}
}对 write_file 和 bash 工具生效,检查操作的目标路径是否在项目根目录内。write_file("../outside/file.txt") 和 write_file("/etc/passwd") 都会被识别为路径逃逸并返回 DENY。
核心逻辑一句话:resolved.startsWith(projectRoot)。路径先 resolve 再 normalize,../ 和符号链接都会被正确解析。
保守策略:宁可漏过也不误杀。当无法提取路径时返回 ALLOW,当路径解析异常时返回 ALLOW。因为这个规则之后还有 ToolCategoryRule 的 ASK 兜底——漏过的写入行为仍然会触发用户确认。
第四层:ApprovalStore — 记住用户的决定
class ApprovalStore {
private val decisions = ConcurrentHashMap<String, PermissionResult>()
fun remember(toolName: String, decision: PermissionResult) { ... }
fun get(toolName: String): PermissionResult? { ... }
fun clear() { ... }
}用 ConcurrentHashMap 按工具名存储用户的 "always allow" / "always deny" 决定。选择 ConcurrentHashMap 而非 HashMap 的原因是 AgentLoop 支持并发工具执行——虽然权限检查发生在并发执行之前,但 ApprovalStore 是跨线程共享的状态,需要线程安全。
clear() 方法在 REPL 的 /clear 命令中被调用,清空对话历史的同时也清空权限记忆,给用户一个"重置"的选项。
当前是纯内存存储(重启后丢失),持久化留给 Index 12 Task System。
第五层:ReadLinePrompter — 终端交互
class ReadLinePrompter : UserPrompter {
override suspend fun ask(toolCall: ToolCall, ruleName: String): PermissionDecision {
println("┌─ Permission Required ─────────────────────────────")
println("│ Tool: ${toolCall.name}")
println("│ Input: ${formatInput(toolCall)}")
println("│ Rule: $ruleName")
println("├────────────────────────────────────────────────────")
println("│ [y] Yes — allow this time")
println("│ [n] No — deny this time")
println("│ [a] Always allow this tool")
println("│ [d] Always deny this tool")
println("└────────────────────────────────────────────────────")
// 读取用户输入,直到 y/n/a/d 之一
}
private fun formatInput(toolCall: ToolCall): String {
val raw = toolCall.input.toString()
return if (raw.length > 120) raw.take(120) + "..." else raw
}
}用终端 UI 展示待审批的工具调用详情。formatInput() 截断超过 120 字符的 JSON 输入,防止超长参数(比如 write_file 的整个文件内容)占满终端。
输入处理:大小写不敏感,Ctrl+D(EOF)视为拒绝。循环直到收到有效输入,提示信息明确显示 y/n/a/d 四个选项。
交互方式的选择:四个按键覆盖所有场景(允许/拒绝/始终允许/始终拒绝),不需要额外输入。后续可以考虑扩展为更丰富的交互("先问再记"、"仅本次记住"),但 Index 03 的设计以简洁优先。
第六层:回调注入 — 松耦合集成
这是 Index 03 最克制的设计决策。AgentLoop 不 import permission 包的任何内容:
class AgentLoop(
private val llmProvider: LLMProvider,
private val systemPrompt: String,
private val config: AgentConfig = AgentConfig(),
private val onBeforeToolExecute: (suspend (ToolCall) -> Boolean)? = null
)onBeforeToolExecute 是一个可空的函数类型参数。null(默认值)意味着"不做权限检查",保持 Index 01-02 的所有现有测试零改动。
在执行工具时,如果回调不为 null 且返回 false,工具被跳过并返回 "Permission denied: xxx" 错误消息:
private suspend fun executeOneTool(toolCall: ToolCall): ToolResult {
if (onBeforeToolExecute != null) {
val approved = onBeforeToolExecute.invoke(toolCall)
if (!approved) {
return ToolResult(toolCall.id, "Permission denied: ${toolCall.name}", isError = true)
}
}
// ... 执行工具
}Permission denied 被包装为 isError=true 的 ToolResult,作为 tool_result 回传给 LLM。LLM 收到这个错误后会调整行为——可能换一个安全的工具重试,或者告诉用户 "这个操作被拒绝了"。
依赖方向: AgentLoop → ?(callback) 不依赖 permission 包。ReplLoop 负责组装 PermissionPipeline 并注入为 lambda。如果将来要移除权限系统,只需在 ReplLoop 中传 null,不需要修改 AgentLoop 一行代码。
在 ReplLoop 中的组装代码:
class ReplLoop(...) {
fun start() {
// 注册工具
ToolRegistry.register(ReadFileTool())
ToolRegistry.register(WriteFileTool())
ToolRegistry.register(BashTool())
// 组装权限管线
approvalStore = ApprovalStore()
val pipeline = PermissionPipeline(
rules = listOf(
ToolCategoryRule(),
DangerousCommandRule(),
PathAllowlistRule()
),
approvalStore = approvalStore,
userPrompter = ReadLinePrompter()
)
// 注入 AgentLoop
agentLoop = AgentLoop(
llmProvider = llmProvider,
systemPrompt = systemPrompt,
config = config,
onBeforeToolExecute = { tc -> pipeline.approve(tc) }
)
// ...
}
}踩坑记录
1. PermissionPipelineTest 中 askRuleName 的获取
在 Pipeline 的实现中,当需要交互时,需要找出"哪个规则触发了 ASK",以便在用户提示中显示规则名。最初的设计是在 userPrompter.ask() 中传入 askRuleName 字符串。
实现时的挑战:如何在不重复执行 evaluate() 的情况下获取触发 ASK 的规则名。解决方法是重新遍历 rules,找到第一个返回 ASK 的规则:
val askRuleName = rules.firstOrNull { r ->
try { r.evaluate(toolCall) == PermissionResult.ASK }
catch (_: Exception) { false }
}?.name ?: "unknown"这段代码不可避免地需要重新执行 evaluate(),但这是可以接受的——规则评估本身是轻量级的(纯内存逻辑),不需要 I/O。如果未来有重量级规则(如远程 ACL 查询),可以考虑在第一步 collect 时同时存储规则名。
2. ConcurrentHashMap 的选择
ApprovalStore 初始实现使用了普通的 HashMap,但在重构中发现 AgentLoop 的并发工具执行会导致跨线程访问问题。虽然权限检查在 executeOneTool() 中是同步的(coroutineScope 内的 async 仍然在同一协程内执行),但从设计上 ApprovalStore 应该支持并发访问,避免未来引入 bug。
3. PathAllowlistRule 对 bash 工具的启发式路径提取
对 bash 工具,PathAllowlistRule 的 extractPath() 方法当前返回 null——因为从 shell 命令中可靠地提取文件路径是不现实的(命令可能是 cat ../outside/db.sqlite、cp /etc/passwd .、find / -name "*.key")。null 意味着跳过路径检查(返回 ALLOW),依赖 ToolCategoryRule 的 ASK 兜底。
这不是一个完美的解决方案——逃逸路径的 bash 命令不会自动拒绝,而是落入"用户确认"的环节。但这是有意为之:路径逃逸的用户提示给用户提供了判断机会,而自动拒绝可能阻塞合法操作(比如在项目内执行 cat /usr/share/dict/words,虽然不在项目内但是读操作,应该放行)。
4. PermissionPipelineTest 的 FakePrompter 设计
FakePrompter 的行为是"超出序列时重复最后一个决策"——与 Index 02 的 FakeLLMProvider 策略一致。在 PermissionPipelineTest 中,ASK 场景的测试只需要一个决策(用户选择 Allow 或 Deny),但"记忆"场景的测试中 FakePrompter 根本不会被调用。FakePrompter 的"溢出重复"设计让测试代码更简洁——不必为不会被调用的交互预设多余的决策。
5. ToolCategoryRule 对 ToolRegistry 的运行时依赖
ToolCategoryRule 依赖 ToolRegistry 来查询工具的 isReadOnly 属性。这意味着规则不能在工具注册之前执行——如果 AgentLoop 在 ReplLoop.start() 完成注册之前尝试审批,会抛出 UnknownToolException。事实上不会发生(AgentLoop 在 ReplLoop.register() 之后才创建),但如果有人从 AgentLoopTest 直接创建 AgentLoop 并注入 callback,需要在测试中记得注册工具。
测试:33 个新用例,99 总计
Index 03 新增 33 个测试,总用例数从 66 增长到 99。测试策略延续 Index 02 的 Fake 模式:
PermissionPipelineTest(11 个用例): 使用 FakeRule(假规则)和 FakePrompter(预设决策序列的假 UserPrompter),覆盖全部决策组合:
- 全部 ALLOW → 放行
- 任一 DENY → 拒绝(2 个:单独 DENY、DENY 覆盖 ASK)
- ASK + 记忆 ALLOW/DENY → 直接决定(2 个)
- ASK + 用户选择 Yes/No → 交互决定(2 个)
- ASK + 用户选择始终允许/始终拒绝 → 记忆存储(2 个)
- 规则抛异常 → 视为 DENY
- 空规则列表 → 默认放行
- ThrowingRule 和 FakeRule 的组合测试异常容错
三个规则的独立测试(14 个用例):
- ToolCategoryRule(3 个):只读→ALLOW、写入→ASK、未注册→ASK
- DangerousCommandRule(6 个):safe→ALLOW、rm -rf→DENY、sudo rm→DENY、dd→DENY、非 bash→ALLOW、npm test→ALLOW
- PathAllowlistRule(5 个):内部路径→ALLOW、../跳出→DENY、/etc/passwd→DENY、bash 无路径→ALLOW、非适用工具→ALLOW
ApprovalStore(5 个用例): 记忆→读取、覆盖、未记住返回 null、clear 清空、多次 remember 覆盖
AgentLoop 集成测试(3 个用例): 回调返回 true/false/null 三种场景
| 被测组件 | 测试方式 | Index 02 | Index 03 | 合计 |
|---|---|---|---|---|
EnvFile | 临时文件 | 8 | — | 8 |
ConfigResolver | Kotest | 10 | — | 10 |
Message | Kotest | 5 | — | 5 |
AnthropicProvider | MockEngine | 10 | — | 10 |
AgentLoop | FakeLLMProvider | 12 | +3 | 15 |
ToolRegistry | FakeEchoTool | 5 | — | 5 |
ReadFileTool | 临时文件 | 5 | — | 5 |
WriteFileTool | 临时目录 | 5 | — | 5 |
BashTool | ProcessBuilder | 5 | — | 5 |
PermissionPipeline | FakeRule + FakePrompter | — | 11 | 11 |
ApprovalStore | Kotest | — | 5 | 5 |
ToolCategoryRule | FakeTool + ToolRegistry | — | 3 | 3 |
DangerousCommandRule | ToolCall 构造 | — | 6 | 6 |
PathAllowlistRule | 临时目录计算 | — | 5 | 5 |
| 总计 | 65 | +33 | +1 |
EnvFileTest 从 Index 02 的 8 个增长到 9 个(由于前一阶段的一个小修复),所以实际索引 02 的基线是 66 个测试。新增 33 个后总计 99 个。
文件清单
src/main/kotlin/com/sepcai/code/
├── permission/ # [新建] 权限包
│ ├── PermissionResult.kt # ALLOW/DENY/ASK 三态枚举
│ ├── PermissionRule.kt # 权限规则接口
│ ├── UserPrompter.kt # 用户交互接口 + PermissionDecision 枚举
│ ├── ApprovalStore.kt # 审批记忆存储(ConcurrentHashMap)
│ ├── PermissionPipeline.kt # 审批管线(收集-汇总-交互)
│ ├── ReadLinePrompter.kt # 终端交互实现(y/n/a/d)
│ └── rules/
│ ├── ToolCategoryRule.kt # 读写自动分类规则
│ ├── DangerousCommandRule.kt # 危险命令检测规则(7 种模式)
│ └── PathAllowlistRule.kt # 路径逃逸防护规则
├── agent/
│ ├── AgentLoop.kt # [修改] +onBeforeToolExecute 回调, 203→219 行
└── repl/
└── ReplLoop.kt # [修改] +权限管线组装注入, 欢迎语 Index 03
src/test/kotlin/com/sepcai/code/
├── agent/
│ └── AgentLoopTest.kt # [修改] +3个权限集成测试, 12→15 个测试
├── permission/ # [新建] 权限测试包
│ ├── PermissionPipelineTest.kt # 11 tests (FakeRule + FakePrompter)
│ ├── ApprovalStoreTest.kt # 5 tests
│ └── rules/
│ ├── ToolCategoryRuleTest.kt # 3 tests
│ ├── DangerousCommandRuleTest.kt # 6 tests
│ └── PathAllowlistRuleTest.kt # 5 tests16 个源文件(9 新建 + 2 修改)· 5 个新测试文件 + 1 个测试修改 · 99 个测试 · 全部通过。
下一站
Index 03 的权限管线已经就位,但审批和工具执行之间还缺少一个关键环节——非功能性扩展点。用户可能想在每次工具执行前记录日志、测量性能,或在执行后发送通知。
Index 04: Hooks 将引入 HookManager,支持 PreToolUse 和 PostToolUse 两个扩展点。核心设计:
HookEvent统一事件模型:携带事件类型、工具调用、执行结果等上下文HookHandler函数类型:接收 HookEvent,返回 HookResult(允许修改 ToolResult)HookManager事件分发:fire 事件 → 收集处理结果 → 继续或中断工具执行
Permission(Index 03)和 Hooks(Index 04)是一对互补的扩展点——Permission 做安全决策(放行/拒绝),Hooks 做非功能性增强(日志/指标/通知)。两者共同构成了工具执行链路的前后扩展能力。


