// checker_test.go -- 完整权限检查流程的单元测试. // // 覆盖场景: // - CheckToolPermission 各种工具类型的权限检查 // - bypass 模式直接放行 // - plan 模式直接拒绝 // - Bash 命令规则匹配 // - 文件操作权限检查 // - 只读工具默认允许 // - accept_edits 模式自动放行编辑 // - 复合命令任一被拒绝则整体拒绝 package permission import ( "strings" "testing" ) // TestCheckToolPermission_BypassMode 测试 bypass 模式 func TestCheckToolPermission_BypassMode(t *testing.T) { resp := CheckToolPermission("Bash", map[string]any{"command": "rm -rf /"}, nil, ModeBypass) if resp.Decision != DecisionAllow { t.Errorf("bypass 模式应放行, 实际: %s", resp.Decision) } } // TestCheckToolPermission_PlanMode 测试 plan 模式 func TestCheckToolPermission_PlanMode(t *testing.T) { resp := CheckToolPermission("Bash", map[string]any{"command": "echo hi"}, nil, ModePlan) if resp.Decision != DecisionDeny { t.Errorf("plan 模式应拒绝, 实际: %s", resp.Decision) } } // TestCheckToolPermission_ReadOnlyTools 测试只读工具默认允许 func TestCheckToolPermission_ReadOnlyTools(t *testing.T) { readOnlyTools := []string{"Glob", "Grep", "WebSearch"} for _, tool := range readOnlyTools { RegisterToolClass(tool, PermClassReadOnly) defer UnregisterToolClass(tool) } for _, tool := range readOnlyTools { resp := CheckToolPermission(tool, nil, nil, ModeDefault) if resp.Decision != DecisionAllow { t.Errorf("%s 应默认允许, 实际: %s (%s)", tool, resp.Decision, resp.Reason) } } } // TestCheckToolPermission_ReadTool 测试 Read 工具默认允许 func TestCheckToolPermission_ReadTool(t *testing.T) { RegisterToolClass("Read", PermClassFile) defer UnregisterToolClass("Read") resp := CheckToolPermission("Read", map[string]any{"file_path": "/src/main.go"}, nil, ModeDefault) if resp.Decision != DecisionAllow { t.Errorf("Read 工具应默认允许, 实际: %s", resp.Decision) } } // TestCheckToolPermission_BashWithRules 测试 Bash 命令规则匹配 func TestCheckToolPermission_BashWithRules(t *testing.T) { RegisterToolClass("Bash", PermClassBash) defer UnregisterToolClass("Bash") rules := []Rule{ {ToolName: "Bash", Content: "prefix:npm", Source: SourceProject, Behavior: DecisionAllow}, {ToolName: "Bash", Content: "prefix:rm", Source: SourceProject, Behavior: DecisionDeny}, } // npm 命令应被允许 resp := CheckToolPermission("Bash", map[string]any{"command": "npm install"}, rules, ModeDefault) if resp.Decision != DecisionAllow { t.Errorf("npm 命令应被允许, 实际: %s (%s)", resp.Decision, resp.Reason) } // rm 命令应被拒绝 resp = CheckToolPermission("Bash", map[string]any{"command": "rm -rf /tmp"}, rules, ModeDefault) if resp.Decision != DecisionDeny { t.Errorf("rm 命令应被拒绝, 实际: %s (%s)", resp.Decision, resp.Reason) } } // TestCheckToolPermission_BashCompound 测试复合命令,任一被拒绝则整体拒绝 func TestCheckToolPermission_BashCompound(t *testing.T) { RegisterToolClass("Bash", PermClassBash) defer UnregisterToolClass("Bash") rules := []Rule{ {ToolName: "Bash", Content: "prefix:npm", Source: SourceProject, Behavior: DecisionAllow}, {ToolName: "Bash", Content: "prefix:rm", Source: SourceProject, Behavior: DecisionDeny}, } // 复合命令中包含 rm 应被拒绝 resp := CheckToolPermission("Bash", map[string]any{"command": "npm install && rm -rf /tmp"}, rules, ModeDefault) if resp.Decision != DecisionDeny { t.Errorf("含 rm 的复合命令应被拒绝, 实际: %s (%s)", resp.Decision, resp.Reason) } } // TestCheckToolPermission_AcceptEditsMode 测试 accept_edits 模式 func TestCheckToolPermission_AcceptEditsMode(t *testing.T) { // 注册文件类工具 for _, tool := range []string{"Edit", "Write", "NotebookEdit"} { RegisterToolClass(tool, PermClassFile) defer UnregisterToolClass(tool) } RegisterToolClass("Bash", PermClassBash) defer UnregisterToolClass("Bash") // 编辑工具在 accept_edits 模式应自动放行 editTools := []string{"Edit", "Write", "NotebookEdit"} for _, tool := range editTools { input := map[string]any{"file_path": "/src/main.go"} resp := CheckToolPermission(tool, input, nil, ModeAcceptEdits) if resp.Decision != DecisionAllow { t.Errorf("%s 在 accept_edits 模式应放行, 实际: %s (%s)", tool, resp.Decision, resp.Reason) } } // Bash 命令在 accept_edits 模式仍需询问 resp := CheckToolPermission("Bash", map[string]any{"command": "echo hi"}, nil, ModeAcceptEdits) if resp.Decision == DecisionAllow { t.Error("Bash 在 accept_edits 模式不应自动放行") } } // TestCheckToolPermission_DangerousFile 测试危险文件路径检测 func TestCheckToolPermission_DangerousFile(t *testing.T) { RegisterToolClass("Edit", PermClassFile) defer UnregisterToolClass("Edit") resp := CheckToolPermission("Edit", map[string]any{"file_path": "/home/user/.bashrc"}, nil, ModeDefault) if resp.Decision != DecisionAsk { t.Errorf("编辑 .bashrc 应要求询问, 实际: %s", resp.Decision) } } // TestCheckToolPermission_EmptyBashCommand 测试空 Bash 命令 func TestCheckToolPermission_EmptyBashCommand(t *testing.T) { RegisterToolClass("Bash", PermClassBash) defer UnregisterToolClass("Bash") resp := CheckToolPermission("Bash", map[string]any{"command": ""}, nil, ModeDefault) if resp.Decision != DecisionAsk { t.Errorf("空命令应要求询问, 实际: %s", resp.Decision) } } // TestCheckToolPermission_WildcardRule 测试通配符规则 func TestCheckToolPermission_WildcardRule(t *testing.T) { RegisterToolClass("Bash", PermClassBash) defer UnregisterToolClass("Bash") rules := []Rule{ {ToolName: "*", Source: SourceSession, Behavior: DecisionAllow}, } resp := CheckToolPermission("Bash", map[string]any{"command": "any command"}, rules, ModeDefault) if resp.Decision != DecisionAllow { t.Errorf("通配符规则应匹配所有工具, 实际: %s (%s)", resp.Decision, resp.Reason) } } // TestCheckToolPermission_FileRuleWithGlob 测试文件路径 glob 规则 func TestCheckToolPermission_FileRuleWithGlob(t *testing.T) { RegisterToolClass("Edit", PermClassFile) defer UnregisterToolClass("Edit") rules := []Rule{ {ToolName: "Edit", Content: "/src/**", Source: SourceProject, Behavior: DecisionAllow}, } // /src/ 下的文件应被允许 resp := CheckToolPermission("Edit", map[string]any{"file_path": "/src/main.go"}, rules, ModeDefault) if resp.Decision != DecisionAllow { t.Errorf("/src/ 下的编辑应被允许, 实际: %s (%s)", resp.Decision, resp.Reason) } } // TestFilterRulesForTool 测试规则过滤 func TestFilterRulesForTool(t *testing.T) { rules := []Rule{ {ToolName: "Bash", Content: "prefix:npm"}, {ToolName: "Edit", Content: "/src/**"}, {ToolName: "*"}, {ToolName: "Bash", Content: "prefix:go"}, } filtered := filterRulesForTool("Bash", rules) // 应包含 Bash 规则和通配符规则 if len(filtered) != 3 { t.Errorf("期望 3 条规则, 实际 %d", len(filtered)) } } // TestCheckToolPermission_SubcommandLimit 测试 50 子命令上限 func TestCheckToolPermission_SubcommandLimit(t *testing.T) { RegisterToolClass("Bash", PermClassBash) defer UnregisterToolClass("Bash") // MaxSubcommands 常量应该为 50 if MaxSubcommands != 50 { t.Errorf("MaxSubcommands = %d, 期望 50", MaxSubcommands) } // 测试 checkBashPermission 的子命令上限逻辑: // 由于 AST 解析器对深度嵌套的 ; 链会受递归深度限制, // 这里用管道链构造大量子命令(管道在 AST 中是平铺的,不会嵌套太深) var parts []string for i := 0; i < 55; i++ { parts = append(parts, "echo ok") } longCmd := strings.Join(parts, " | ") resp := CheckToolPermission("Bash", map[string]any{"command": longCmd}, nil, ModeDefault) if resp.Decision != DecisionAsk { t.Errorf("55 个管道子命令应触发 Ask, 实际: %s", resp.Decision) } if !strings.Contains(resp.Reason, "sub-commands") { t.Errorf("Reason 应包含 'sub-commands', 实际: %s", resp.Reason) } // 5 个管道子命令不应触发限制(仍然 Ask 因为没有规则,但原因不同) resp5 := CheckToolPermission("Bash", map[string]any{"command": "echo a | echo b | echo c | echo d | echo e"}, nil, ModeDefault) if strings.Contains(resp5.Reason, "sub-commands") { t.Errorf("5 个子命令不应触发上限, 但 Reason 包含 'sub-commands': %s", resp5.Reason) } } // TestCheckToolPermission_SedIntegration 测试 sed 命令集成检查 func TestCheckToolPermission_SedIntegration(t *testing.T) { RegisterToolClass("Bash", PermClassBash) defer UnregisterToolClass("Bash") // 允许所有 sed 前缀的规则 rules := []Rule{ {ToolName: "Bash", Content: "prefix:sed", Source: SourceProject, Behavior: DecisionAllow}, } // 安全的 sed 替换--应被允许 resp := CheckToolPermission("Bash", map[string]any{"command": "sed 's/old/new/g' file.txt"}, rules, ModeDefault) if resp.Decision != DecisionAllow { t.Errorf("安全 sed 替换应被允许, 实际: %s (%s)", resp.Decision, resp.Reason) } // 危险的 sed e 标志--即使有 allow 规则也应被 Ask resp = CheckToolPermission("Bash", map[string]any{"command": "sed 's/old/new/e' file.txt"}, rules, ModeDefault) if resp.Decision == DecisionAllow { t.Errorf("危险 sed e 标志不应被允许") } if !strings.Contains(resp.Reason, "sed security") { t.Errorf("Reason 应包含 'sed security', 实际: %s", resp.Reason) } } // TestCheckToolPermission_DynamicRedirect 测试动态重定向目标检查 func TestCheckToolPermission_DynamicRedirect(t *testing.T) { RegisterToolClass("Bash", PermClassBash) defer UnregisterToolClass("Bash") // 含 $VAR 的重定向应触发 Ask resp := CheckToolPermission("Bash", map[string]any{"command": "echo hi > $OUTPUT"}, nil, ModeDefault) if resp.Decision != DecisionAsk { t.Errorf("动态重定向应触发 Ask, 实际: %s", resp.Decision) } } // TestIsEditTool 测试编辑工具判断 func TestIsEditTool(t *testing.T) { if !isEditTool("Edit") { t.Error("Edit 应为编辑工具") } if !isEditTool("Write") { t.Error("Write 应为编辑工具") } if !isEditTool("NotebookEdit") { t.Error("NotebookEdit 应为编辑工具") } if isEditTool("Read") { t.Error("Read 不应为编辑工具") } if isEditTool("Bash") { t.Error("Bash 不应为编辑工具") } }