diff --git a/adapters/native/backend.go b/adapters/native/backend.go index 20c0487..ff7edf7 100644 --- a/adapters/native/backend.go +++ b/adapters/native/backend.go @@ -15,6 +15,7 @@ import ( "agent-native/exec" "agent-native/runtime" "agent-native/runtime/filestate" + "agent-native/runtime/hooks" "agent-native/runtime/llm" "agent-native/runtime/mcp" "agent-native/runtime/promptx" @@ -59,6 +60,16 @@ type ObserveOptions struct { ExecutionID string } +// GateOptions 把直连已经使用的停止、提醒和工具钩子交给小队成员。零值不改变行为。 +type GateOptions struct { + BeforeTool func(call llm.ToolCall) error + AfterTool func(call llm.ToolCall, res llm.Message) + Stop func(last llm.Message, info runtime.StopInfo) runtime.StopDecision + Reminders func(turn int) []string + RepeatGuard bool + Hooks *hooks.Bus +} + // Options 构造后端所需的模型接入与执行参数(由组合根注入,无产品依赖)。 // 字段经嵌入提升,读取仍写 opts.Model;复合字面量要写子结构名。 type Options struct { @@ -66,6 +77,7 @@ type Options struct { ToolOptions SessionOptions ObserveOptions + GateOptions } // CompactConfig 声明长会话压缩策略(优化性质:压缩失败不阻断执行,但留日志)。 @@ -333,6 +345,9 @@ func (b *Backend) run(ctx context.Context, model string, budget *runtime.Budget, Log: b.logger(), ExecutionID: execID, Budget: budget, + Reminders: b.opts.Reminders, + RepeatGuard: b.opts.RepeatGuard, + Hooks: gateBus(b.opts.GateOptions), OnEvent: func(ev runtime.Event) { logs.WriteString(ev.Type) if ev.Tool != "" { @@ -423,6 +438,40 @@ func defaultSummarizer(stream llm.StreamFunc, model string) func(ctx context.Con } } +func gateBus(g GateOptions) *hooks.Bus { + bus := g.Hooks + if g.BeforeTool == nil && g.AfterTool == nil && g.Stop == nil { + return bus + } + if bus == nil { + bus = hooks.New() + } + if g.BeforeTool != nil { + fn := g.BeforeTool + bus.On(hooks.AdaptBefore(func(name, args string) error { return fn(llm.ToolCall{Name: name, Arguments: args}) })) + } + if g.AfterTool != nil { + fn := g.AfterTool + bus.On(hooks.AdaptAfter(func(name, args, result string, isError bool) { + fn(llm.ToolCall{Name: name, Arguments: args}, llm.Message{Content: result, IsError: isError}) + })) + } + if g.Stop != nil { + fn := g.Stop + bus.On(func(c hooks.Call) hooks.Outcome { + if c.Event != hooks.Stop { + return hooks.Outcome{} + } + d := fn(llm.Message{Role: llm.RoleAssistant, Content: c.LastText}, runtime.StopInfo{Continuations: c.Continuations, ToolCalls: c.ToolCalls}) + if d.Continue { + return hooks.Outcome{Decision: hooks.Block, Reason: d.Reason, Replacement: d.Replacement, Notice: d.Notice} + } + return hooks.Outcome{Decision: hooks.Allow, Notice: d.Notice} + }) + } + return bus +} + type errString string func (e errString) Error() string { return string(e) } diff --git a/adapters/native/backend_test.go b/adapters/native/backend_test.go index 589a9ab..80f0f51 100644 --- a/adapters/native/backend_test.go +++ b/adapters/native/backend_test.go @@ -2,6 +2,7 @@ package native import ( "context" + "errors" "os" "path/filepath" "strings" @@ -139,3 +140,36 @@ func TestBackendFailClosedWithoutConfig(t *testing.T) { t.Fatalf("want profile error, got %+v", res3) } } + +func TestBeforeToolReachesSquadMembers(t *testing.T) { + dir := t.TempDir() + var seen string + b := New(Options{ + ModelOptions: ModelOptions{ + Model: "fake", + Stream: script( + llm.Message{Role: llm.RoleAssistant, StopReason: llm.StopToolCalls, ToolCalls: []llm.ToolCall{{ + ID: "c1", Name: "write_file", Arguments: `{"path":"output.md","content":"nope"}`, + }}}, + llm.Message{Role: llm.RoleAssistant, Content: "blocked", StopReason: llm.StopEnd}, + ), + }, + GateOptions: GateOptions{BeforeTool: func(call llm.ToolCall) error { + seen = call.Name + return errors.New("计划模式拒绝了写工具") + }}, + }) + res, err := b.Execute(context.Background(), exec.Request{WorkDir: dir, Prompt: "写", Targets: []string{"output.md"}}) + if err != nil { + t.Fatal(err) + } + if seen != "write_file" { + t.Fatalf("before tool saw %q", seen) + } + if res.Success { + t.Fatal("denied write still succeeded") + } + if _, err := os.Stat(filepath.Join(dir, "output.md")); err == nil { + t.Fatal("write_file ran after deny") + } +} diff --git a/runtime/ab_gate_test.go b/runtime/ab_gate_test.go new file mode 100644 index 0000000..c9fa2e0 --- /dev/null +++ b/runtime/ab_gate_test.go @@ -0,0 +1,95 @@ +package runtime + +import ( + "context" + "errors" + "testing" + + "agent-native/runtime/hooks" + "agent-native/runtime/llm" + "agent-native/runtime/tools" +) + +func TestHookAdaptersMatchDirectCallbacks(t *testing.T) { + direct := runGate(t, false) + adapted := runGate(t, true) + if direct.stop != adapted.stop || direct.text != adapted.text { + t.Fatalf("stop/text direct=%s %q adapted=%s %q", direct.stop, direct.text, adapted.stop, adapted.text) + } + if direct.denies != adapted.denies || direct.reminders != adapted.reminders || direct.errors != adapted.errors { + t.Fatalf("counts direct=%+v adapted=%+v", direct, adapted) + } + if direct.denies != 1 || direct.reminders == 0 { + t.Fatalf("unexpected counts %+v", direct) + } +} + +type gateStats struct { + stop, text string + denies, reminders, errors int +} + +func runGate(t *testing.T, viaHooks bool) gateStats { + t.Helper() + var stats gateStats + before := func(call llm.ToolCall) error { + if call.Name == "read_file" { + stats.denies++ + return errors.New("denied") + } + return nil + } + reminders := func(int) []string { + stats.reminders++ + return []string{"记一笔"} + } + stop := func(last llm.Message, info StopInfo) StopDecision { + if info.Continuations < 1 { + return StopDecision{Continue: true, Reason: "再看一眼"} + } + return StopDecision{} + } + cfg := Config{ + Model: "fake", + Tools: tools.WorkspaceTools(t.TempDir()), + Stream: script( + llm.Message{Role: llm.RoleAssistant, StopReason: llm.StopToolCalls, ToolCalls: []llm.ToolCall{{ + ID: "c1", Name: "read_file", Arguments: `{"path":"a.md"}`, + }}}, + llm.Message{Role: llm.RoleAssistant, Content: "done", StopReason: llm.StopEnd}, + ), + Reminders: reminders, + } + if viaHooks { + bus := hooks.New() + bus.On(hooks.AdaptBefore(func(name, args string) error { + return before(llm.ToolCall{Name: name, Arguments: args}) + })) + bus.On(func(c hooks.Call) hooks.Outcome { + if c.Event != hooks.Stop { + return hooks.Outcome{} + } + d := stop(llm.Message{Content: c.LastText}, StopInfo{Continuations: c.Continuations, ToolCalls: c.ToolCalls}) + if d.Continue { + return hooks.Outcome{Decision: hooks.Block, Reason: d.Reason, Replacement: d.Replacement, Notice: d.Notice} + } + return hooks.Outcome{Decision: hooks.Allow, Notice: d.Notice} + }) + cfg.Hooks = bus + } else { + cfg.BeforeTool = before + cfg.Stop = stop + } + res, err := Run(context.Background(), cfg, llm.Message{Role: llm.RoleUser, Content: "读文件"}) + if err != nil { + t.Fatal(err) + } + stats.stop = string(res.StopReason) + stats.text = res.Text + for _, m := range res.Messages { + if m.Role == llm.RoleTool && m.IsError { + stats.errors++ + } + } + return stats +} diff --git a/runtime/hooks/hooks.go b/runtime/hooks/hooks.go new file mode 100644 index 0000000..7393f55 --- /dev/null +++ b/runtime/hooks/hooks.go @@ -0,0 +1,134 @@ +// Package hooks 是进程内钩子总线。决策按 block/deny、ask、modify、allow 取更强的一条。 +// 没有表态的处理函数不改变结果。 +package hooks + +import "sync" + +type Event string + +const ( + SessionStart Event = "SessionStart" + UserPromptSubmit Event = "UserPromptSubmit" + PreToolUse Event = "PreToolUse" + PermissionRequest Event = "PermissionRequest" + PostToolUse Event = "PostToolUse" + PostToolUseFailure Event = "PostToolUseFailure" + Stop Event = "Stop" + SubagentStart Event = "SubagentStart" + SubagentStop Event = "SubagentStop" + PreCompact Event = "PreCompact" +) + +type Decision string + +const ( + Allow Decision = "allow" + Deny Decision = "deny" + Ask Decision = "ask" + Modify Decision = "modify" + Block Decision = "block" +) + +// Call 是一次事件。工具字段只在工具事件上有意义。 +type Call struct { + Event Event + Tool string + Args string + Result string + IsError bool + Turn int + LastText string + Continuations int + ToolCalls int +} + +// Outcome 是一个处理函数的表态。空 Decision 表示不插手。 +type Outcome struct { + Decision Decision + Reason string + Args string + Notice string + Replacement string +} + +type Handler func(Call) Outcome + +type Bus struct { + mu sync.Mutex + hs []Handler +} + +func New() *Bus { return &Bus{} } + +func (b *Bus) On(h Handler) { + if b == nil || h == nil { + return + } + b.mu.Lock() + b.hs = append(b.hs, h) + b.mu.Unlock() +} + +// Emit 按优先级合并表态。deny 与 block 同级,先登记的保留。 +func (b *Bus) Emit(c Call) Outcome { + if b == nil { + return Outcome{} + } + b.mu.Lock() + hs := append([]Handler(nil), b.hs...) + b.mu.Unlock() + var merged Outcome + for _, h := range hs { + merged = prefer(merged, h(c)) + } + return merged +} + +func prefer(cur, next Outcome) Outcome { + if rank(next.Decision) > rank(cur.Decision) { + return next + } + return cur +} + +func rank(d Decision) int { + switch d { + case Block, Deny: + return 4 + case Ask: + return 3 + case Modify: + return 2 + case Allow: + return 1 + default: + return 0 + } +} + +// AdaptBefore 把「返回错误即拒绝」收成 PreToolUse 处理函数。 +func AdaptBefore(fn func(name, args string) error) Handler { + return func(c Call) Outcome { + if c.Event != PreToolUse || fn == nil { + return Outcome{} + } + if err := fn(c.Tool, c.Args); err != nil { + return Outcome{Decision: Deny, Reason: err.Error()} + } + return Outcome{Decision: Allow} + } +} + +// AdaptAfter 把工具结果观察收成 PostToolUse 或失败事件。它不改写结果。 +func AdaptAfter(fn func(name, args, result string, isError bool)) Handler { + return func(c Call) Outcome { + if fn == nil { + return Outcome{} + } + switch c.Event { + case PostToolUse, PostToolUseFailure: + fn(c.Tool, c.Args, c.Result, c.IsError) + } + return Outcome{} + } +} diff --git a/runtime/hooks/hooks_test.go b/runtime/hooks/hooks_test.go new file mode 100644 index 0000000..2a23556 --- /dev/null +++ b/runtime/hooks/hooks_test.go @@ -0,0 +1,40 @@ +package hooks + +import "testing" + +func TestDenyBeatsAllow(t *testing.T) { + b := New() + b.On(func(Call) Outcome { return Outcome{Decision: Allow} }) + b.On(func(c Call) Outcome { + if c.Event == PreToolUse { + return Outcome{Decision: Deny, Reason: "no"} + } + return Outcome{} + }) + out := b.Emit(Call{Event: PreToolUse, Tool: "write_file"}) + if out.Decision != Deny || out.Reason != "no" { + t.Fatalf("%+v", out) + } +} + +func TestAdaptBefore(t *testing.T) { + h := AdaptBefore(func(name, args string) error { + if name == "write_file" { + return errString("blocked") + } + return nil + }) + if h(Call{Event: PreToolUse, Tool: "read_file"}).Decision != Allow { + t.Fatal("read") + } + if h(Call{Event: PreToolUse, Tool: "write_file"}).Decision != Deny { + t.Fatal("write") + } + if h(Call{Event: Stop}).Decision != "" { + t.Fatal("stop should be ignored") + } +} + +type errString string + +func (e errString) Error() string { return string(e) } diff --git a/runtime/loop.go b/runtime/loop.go index 6809ea7..93caa78 100644 --- a/runtime/loop.go +++ b/runtime/loop.go @@ -8,6 +8,7 @@ import ( "sync" "time" + "agent-native/runtime/hooks" "agent-native/runtime/llm" "agent-native/runtime/reminders" "agent-native/runtime/session" @@ -104,6 +105,8 @@ type Config struct { ContextBuffer int // KeepToolResults 是清理时保留的最近工具结果条数。0 表示 5。 KeepToolResults int + // Hooks 是进程内钩子。空总线不改变停止、提醒和拒绝次数。 + Hooks *hooks.Bus } // StopInfo 是 Stop 钩子看到的本 run 进度。 @@ -275,8 +278,7 @@ func Run(ctx context.Context, cfg Config, user llm.Message) (Result, error) { } continue } - if cfg.Stop != nil { - decision := cfg.Stop(last, StopInfo{Continuations: stopContinues, ToolCalls: toolCalls}) + if decision, hooked := stopDecision(cfg, last, stopContinues, toolCalls); hooked { if decision.Continue && stopContinues < maxStopContinuations { stopContinues++ if decision.Replacement != "" && len(messages) > 0 { @@ -488,6 +490,56 @@ func rejectIncompleteToolCall(call llm.ToolCall) string { return "" } +func stopDecision(cfg Config, last llm.Message, continuations, toolCalls int) (StopDecision, bool) { + if cfg.Hooks != nil { + out := cfg.Hooks.Emit(hooks.Call{ + Event: hooks.Stop, LastText: last.Content, Continuations: continuations, ToolCalls: toolCalls, + }) + if out.Decision != "" { + return StopDecision{ + Continue: out.Decision == hooks.Block, Reason: out.Reason, + Replacement: out.Replacement, Notice: out.Notice, + }, true + } + } + if cfg.Stop != nil { + return cfg.Stop(last, StopInfo{Continuations: continuations, ToolCalls: toolCalls}), true + } + return StopDecision{}, false +} + +func gateTool(cfg Config, call llm.ToolCall) (llm.ToolCall, llm.Message, bool) { + if cfg.Hooks == nil { + return call, llm.Message{}, false + } + out := cfg.Hooks.Emit(hooks.Call{Event: hooks.PreToolUse, Tool: call.Name, Args: call.Arguments}) + switch out.Decision { + case hooks.Deny, hooks.Block, hooks.Ask: + why := out.Reason + if why == "" { + why = "工具调用被拒绝" + } + msg := llm.Message{Role: llm.RoleTool, Name: call.Name, ToolCallID: call.ID, Content: why, IsError: true} + return call, msg, true + case hooks.Modify: + if out.Args != "" { + call.Arguments = out.Args + } + } + return call, llm.Message{}, false +} + +func noteToolHook(cfg Config, call llm.ToolCall, msg llm.Message) { + if cfg.Hooks == nil { + return + } + ev := hooks.PostToolUse + if msg.IsError { + ev = hooks.PostToolUseFailure + } + cfg.Hooks.Emit(hooks.Call{Event: ev, Tool: call.Name, Args: call.Arguments, Result: msg.Content, IsError: msg.IsError}) +} + func runOne(ctx context.Context, cfg Config, call llm.ToolCall) llm.Message { if err := ctx.Err(); err != nil { return cancelledToolMessage(call, err) @@ -502,10 +554,21 @@ func runOne(ctx context.Context, cfg Config, call llm.ToolCall) llm.Message { return msg } emit(cfg, Event{Type: "tool_execution_start", Tool: call.Name, Detail: call.Arguments}) + if gated, msg, blocked := gateTool(cfg, call); blocked { + emit(cfg, Event{Type: "tool_execution_end", Tool: call.Name, Text: msg.Content}) + noteToolHook(cfg, call, msg) + if cfg.AfterTool != nil { + cfg.AfterTool(call, msg) + } + return msg + } else { + call = gated + } if cfg.BeforeTool != nil { if err := cfg.BeforeTool(call); err != nil { msg := llm.Message{Role: llm.RoleTool, Name: call.Name, ToolCallID: call.ID, Content: err.Error(), IsError: true} emit(cfg, Event{Type: "tool_execution_end", Tool: call.Name, Text: msg.Content}) + noteToolHook(cfg, call, msg) if cfg.AfterTool != nil { cfg.AfterTool(call, msg) } @@ -531,6 +594,7 @@ func runOne(ctx context.Context, cfg Config, call llm.ToolCall) llm.Message { IsError: res.IsError, } emit(cfg, Event{Type: "tool_execution_end", Tool: call.Name, Text: res.Content}) + noteToolHook(cfg, call, msg) if cfg.AfterTool != nil { cfg.AfterTool(call, msg) } diff --git a/runtime/policy/policy.go b/runtime/policy/policy.go new file mode 100644 index 0000000..2cf00f8 --- /dev/null +++ b/runtime/policy/policy.go @@ -0,0 +1,221 @@ +// Package policy 决定一次工具调用在当前权限模式下是拒绝、询问还是放行。 +// 默认模式不额外表态,调用方继续用原有画像策略。 +package policy + +import "strings" + +type Mode string + +const ( + ModeDefault Mode = "default" + ModePlan Mode = "plan" + ModeAutoEdit Mode = "auto_edit" +) + +type Effect string + +const ( + EffectNone Effect = "" + EffectDeny Effect = "deny" + EffectAsk Effect = "ask" + EffectAllow Effect = "allow" +) + +type Traits struct { + ReadOnly bool + Edits bool + Risk string +} + +type Rule struct { + Effect Effect + Tool string + ArgGlob string +} + +type Request struct { + Mode Mode + Name string + Args string + Traits Traits + SessionAllow bool +} + +func Normalize(mode string) Mode { + switch Mode(mode) { + case ModePlan, ModeAutoEdit, ModeDefault: + return Mode(mode) + default: + return ModeDefault + } +} + +// Decide 的优先级是拒绝规则、计划模式禁写、本会话放行、询问规则、自动改文件、高风险命令。 +// 都没有命中时返回空,表示沿用画像的拒绝、确认和放行。 +func Decide(req Request, rules []Rule) Effect { + var sawAsk, sawAllow bool + for _, rule := range rules { + if !matchTool(rule.Tool, req.Name) || !glob(rule.ArgGlob, req.Args) { + continue + } + switch rule.Effect { + case EffectDeny: + return EffectDeny + case EffectAsk: + sawAsk = true + case EffectAllow: + sawAllow = true + } + } + traits := mergeTraits(req.Name, req.Traits) + if commandRisk(req.Name, req.Args) == "high" { + traits.Risk = "high" + } + if Normalize(string(req.Mode)) == ModePlan && !planVisible(req.Name, traits.ReadOnly) { + return EffectDeny + } + if req.SessionAllow { + return EffectAllow + } + if sawAsk { + return EffectAsk + } + if Normalize(string(req.Mode)) == ModeAutoEdit && traits.Edits && traits.Risk != "high" { + return EffectAllow + } + if sawAllow { + return EffectAllow + } + if traits.Risk == "high" && (req.Name == "run_shell" || req.Name == "run_command") { + return EffectAsk + } + return EffectNone +} + +// Visible 报告计划模式下这个工具能否出现在清单里。其他模式一律可见。 +func Visible(mode string, name string, readOnly bool) bool { + if Normalize(mode) != ModePlan { + return true + } + return planVisible(name, readOnly) +} + +func planVisible(name string, readOnly bool) bool { + switch name { + case "update_plan", "update_todos", "ask_user", "exit_plan_mode": + return true + default: + return readOnly + } +} + +// TraitsFor 是内置工具的只读、改文件和风险声明。未知工具为零值。 +func TraitsFor(name string) Traits { + switch name { + case "read_file", "list_dir", "grep", "glob", "load_skill", + "fs_read_file", "fs_list_dir", "time_now", "knowledge_search", + "list_conversations", "read_conversation", "quality_review", + "web_search", "tavily_search", "fetch_url", "ask_user", "exit_plan_mode", + "update_plan", "update_todos": + return Traits{ReadOnly: true, Risk: "low"} + case "write_file", "edit_file", "multi_edit", "fs_write_file", "write_artifact", "register_artifact": + return Traits{Edits: true, Risk: "medium"} + case "run_shell", "run_command", "run_python": + return Traits{Risk: "medium"} + default: + return Traits{} + } +} + +func mergeTraits(name string, given Traits) Traits { + base := TraitsFor(name) + if given.ReadOnly { + base.ReadOnly = true + } + if given.Edits { + base.Edits = true + } + if given.Risk != "" { + base.Risk = given.Risk + } + return base +} + +// ClassifyCommand 按命令文本分级。高风险会在默认模式下改为询问。 +func ClassifyCommand(cmd string) string { + c := strings.ToLower(cmd) + for _, n := range []string{"rm -rf", "rm -fr", "sudo ", "mkfs", "shutdown", "reboot", "> /dev/", "| sh", "|sh", "chmod -r 777"} { + if strings.Contains(c, n) { + return "high" + } + } + for _, n := range []string{"rm ", "mv ", "chmod ", "chown ", "kill "} { + if strings.Contains(c, n) { + return "medium" + } + } + return "low" +} + +func commandRisk(name, args string) string { + if name != "run_shell" && name != "run_command" { + return "" + } + return ClassifyCommand(extractCommand(args)) +} + +func extractCommand(args string) string { + for _, key := range []string{`"command"`, `"cmd"`} { + i := strings.Index(args, key) + if i < 0 { + continue + } + rest := args[i+len(key):] + rest = strings.TrimLeft(rest, " \t\n:") + if !strings.HasPrefix(rest, `"`) { + continue + } + rest = rest[1:] + if j := strings.Index(rest, `"`); j >= 0 { + return rest[:j] + } + } + return args +} + +func matchTool(pattern, name string) bool { + if pattern == "" || pattern == "*" { + return true + } + return glob(pattern, name) +} + +func glob(pattern, s string) bool { + if pattern == "" || pattern == "*" { + return true + } + parts := strings.Split(pattern, "*") + if !strings.HasPrefix(pattern, "*") { + if !strings.HasPrefix(s, parts[0]) { + return false + } + } + if !strings.HasSuffix(pattern, "*") { + last := parts[len(parts)-1] + if !strings.HasSuffix(s, last) { + return false + } + } + idx := 0 + for _, part := range parts { + if part == "" { + continue + } + j := strings.Index(s[idx:], part) + if j < 0 { + return false + } + idx += j + len(part) + } + return true +} diff --git a/runtime/policy/policy_test.go b/runtime/policy/policy_test.go new file mode 100644 index 0000000..1395207 --- /dev/null +++ b/runtime/policy/policy_test.go @@ -0,0 +1,41 @@ +package policy + +import "testing" + +func TestPlanModeDeniesWrites(t *testing.T) { + effect := Decide(Request{Mode: ModePlan, Name: "write_file", Args: `{"path":"a.md"}`}, nil) + if effect != EffectDeny { + t.Fatalf("effect %q", effect) + } + if !Visible(string(ModePlan), "update_plan", false) { + t.Fatal("plan tool hidden") + } + if Visible(string(ModePlan), "write_file", false) { + t.Fatal("write visible in plan mode") + } +} + +func TestDefaultModeStaysQuiet(t *testing.T) { + if got := Decide(Request{Mode: ModeDefault, Name: "write_file"}, nil); got != EffectNone { + t.Fatalf("default write %q", got) + } + if got := Decide(Request{Mode: ModeDefault, Name: "run_shell", Args: `{"command":"rm -rf /"}`}, nil); got != EffectAsk { + t.Fatalf("high shell %q", got) + } +} + +func TestRulePrecedenceAndSessionAllow(t *testing.T) { + rules := []Rule{{Effect: EffectAllow, Tool: "write_file"}, {Effect: EffectDeny, Tool: "write_*", ArgGlob: "*secret*"}} + if got := Decide(Request{Name: "write_file", Args: `{"path":"secret.txt"}`}, rules); got != EffectDeny { + t.Fatalf("deny lost: %q", got) + } + if got := Decide(Request{Name: "http.fetch", SessionAllow: true}, []Rule{{Effect: EffectAsk, Tool: "http.*"}}); got != EffectAllow { + t.Fatalf("session allow %q", got) + } +} + +func TestAutoEditAllowsWorkspaceWrites(t *testing.T) { + if got := Decide(Request{Mode: ModeAutoEdit, Name: "fs_write_file"}, nil); got != EffectAllow { + t.Fatalf("auto edit %q", got) + } +}