Files
goclaw/internal/tools/shell_global_deny_groups_test.go
viettranx df5d48c78d feat(tools/shell): add runtime-reloadable global shell deny-groups via config.tools
Adds ToolsConfig.ShellDenyGroups (map[string]bool) so operators can toggle
shell deny groups (package_install, env_dump, etc.) from the /config Web UI
without a process restart.

Wiring:
- ExecTool gains globalDenyGroups + SetGlobalShellDenyGroups (defensive copy).
- effectiveDenyGroups merges per-agent context overrides with global, with
  per-key agent precedence — multi-tenant invariant preserved.
- setupToolRegistry applies the initial config before any request can arrive.
- subscribeShellDenyGroupsReload (extracted helper for testability) reacts to
  bus.TopicConfigChanged and re-applies the new toggles.

Regression coverage the original PR #1005 attempt missed:
- 6 unit tests for the merge / defensive-copy semantics in internal/tools.
- 3 pub/sub dispatch tests in cmd: happy path, wrong event name, wrong
  payload type.
2026-04-24 20:59:08 +07:00

101 lines
3.7 KiB
Go

package tools
import (
"context"
"testing"
"github.com/nextlevelbuilder/goclaw/internal/store"
)
// TestExecToolEffectiveDenyGroups_GlobalOnly: with an empty agent context,
// effectiveDenyGroups must return the global toggles set via SetGlobalShellDenyGroups.
func TestExecToolEffectiveDenyGroups_GlobalOnly(t *testing.T) {
tool := NewExecTool("/tmp", false)
tool.SetGlobalShellDenyGroups(map[string]bool{"package_install": false})
got := tool.effectiveDenyGroups(context.Background())
if v, ok := got["package_install"]; !ok || v != false {
t.Fatalf("expected global package_install=false in effective map, got %v", got)
}
}
// TestExecToolEffectiveDenyGroups_AgentOverridesGlobal: per-agent context
// override wins per-key over global; non-overridden global keys must remain.
func TestExecToolEffectiveDenyGroups_AgentOverridesGlobal(t *testing.T) {
tool := NewExecTool("/tmp", false)
tool.SetGlobalShellDenyGroups(map[string]bool{
"package_install": false,
"env_dump": false,
})
ctx := store.WithShellDenyGroups(context.Background(), map[string]bool{"package_install": true})
got := tool.effectiveDenyGroups(ctx)
if v, ok := got["package_install"]; !ok || v != true {
t.Errorf("expected agent override package_install=true, got %v (ok=%v)", v, ok)
}
if v, ok := got["env_dump"]; !ok || v != false {
t.Errorf("expected global env_dump=false preserved, got %v (ok=%v)", v, ok)
}
}
// TestExecToolEffectiveDenyGroups_NilGlobalReturnsAgent: when no global
// is configured, return the agent map (preserving existing per-agent semantics).
func TestExecToolEffectiveDenyGroups_NilGlobalReturnsAgent(t *testing.T) {
tool := NewExecTool("/tmp", false)
agent := map[string]bool{"foo": true}
ctx := store.WithShellDenyGroups(context.Background(), agent)
got := tool.effectiveDenyGroups(ctx)
if v, ok := got["foo"]; !ok || v != true {
t.Fatalf("expected agent map returned when global empty, got %v", got)
}
}
// TestExecToolEffectiveDenyGroups_EmptyAgentReturnsGlobal: when agent
// context has no overrides, return the global map.
func TestExecToolEffectiveDenyGroups_EmptyAgentReturnsGlobal(t *testing.T) {
tool := NewExecTool("/tmp", false)
tool.SetGlobalShellDenyGroups(map[string]bool{"foo": true})
got := tool.effectiveDenyGroups(context.Background())
if v, ok := got["foo"]; !ok || v != true {
t.Fatalf("expected global map returned when agent empty, got %v", got)
}
}
// TestExecToolSetGlobalShellDenyGroups_DefensiveCopy: mutating the caller's
// map after SetGlobalShellDenyGroups must not affect the tool's internal state.
func TestExecToolSetGlobalShellDenyGroups_DefensiveCopy(t *testing.T) {
tool := NewExecTool("/tmp", false)
src := map[string]bool{"package_install": true}
tool.SetGlobalShellDenyGroups(src)
// Mutate the caller's map AFTER passing it in.
src["package_install"] = false
src["env_dump"] = true
got := tool.effectiveDenyGroups(context.Background())
if v := got["package_install"]; v != true {
t.Errorf("expected internal copy to be insulated from caller mutation; got package_install=%v", v)
}
if _, ok := got["env_dump"]; ok {
t.Errorf("expected internal copy to be insulated from new caller-side keys; got %v", got)
}
}
// TestExecToolSetGlobalShellDenyGroups_EmptyClears: passing an empty map
// must clear the internal state, not retain a stale copy.
func TestExecToolSetGlobalShellDenyGroups_EmptyClears(t *testing.T) {
tool := NewExecTool("/tmp", false)
tool.SetGlobalShellDenyGroups(map[string]bool{"foo": true})
tool.SetGlobalShellDenyGroups(map[string]bool{})
got := tool.effectiveDenyGroups(context.Background())
if len(got) != 0 {
t.Fatalf("expected cleared global to yield empty effective map, got %v", got)
}
}