diff --git a/cmd/gateway.go b/cmd/gateway.go index 3b7af333..ab0c8404 100644 --- a/cmd/gateway.go +++ b/cmd/gateway.go @@ -114,6 +114,9 @@ func runGateway() { } } + // Detect server IPs for output scrubbing (prevents IP leaks via web_fetch, exec, etc.) + tools.DetectServerIPs(context.Background()) + // Create tool registry with all tools toolsReg := tools.NewRegistry() agentCfg := cfg.ResolveAgent("default") diff --git a/internal/tools/policy.go b/internal/tools/policy.go index cf0d0b41..5efa3325 100644 --- a/internal/tools/policy.go +++ b/internal/tools/policy.go @@ -8,25 +8,28 @@ import ( "github.com/nextlevelbuilder/goclaw/internal/providers" ) -// Tool groups map group names to tool names (matching TS tool-policy.ts TOOL_GROUPS). +// Tool groups map group names to tool names. var toolGroups = map[string][]string{ "memory": {"memory_search", "memory_get"}, "web": {"web_search", "web_fetch"}, - "fs": {"read_file", "write_file", "list_files", "edit_file", "search", "glob"}, - "runtime": {"exec", "process"}, - "sessions": {"sessions_list", "sessions_history", "sessions_send", "sessions_spawn", "session_status"}, - "ui": {"browser", "canvas"}, - "automation": {"cron", "gateway"}, + "fs": {"read_file", "write_file", "list_files", "edit"}, + "runtime": {"exec"}, + "sessions": {"sessions_list", "sessions_history", "sessions_send", "spawn", "session_status"}, + "ui": {"browser"}, + "automation": {"cron"}, "messaging": {"message", "create_forum_topic"}, - "nodes": {"nodes"}, - // Composite group: all goclaw native tools (excludes provider plugins). - // Matching TS group:goclaw. + "delegation": {"handoff", "delegate_search", "evaluate_loop"}, + "team": {"team_tasks", "team_message"}, + // Composite group: all goclaw native tools (excludes MCP/custom plugins). "goclaw": { - "browser", "canvas", "nodes", "cron", "message", "gateway", - "agents_list", "sessions_list", "sessions_history", "sessions_send", - "sessions_spawn", "session_status", - "memory_search", "memory_get", "web_search", "web_fetch", "read_image", "create_image", - "create_forum_topic", + "read_file", "write_file", "list_files", "edit", "exec", + "web_search", "web_fetch", "browser", + "memory_search", "memory_get", + "sessions_list", "sessions_history", "sessions_send", "spawn", "session_status", + "cron", "message", "create_forum_topic", + "read_image", "create_image", "skill_search", "tts", + "handoff", "delegate_search", "evaluate_loop", + "team_tasks", "team_message", }, } @@ -50,15 +53,17 @@ func UnregisterToolGroup(name string) { // Tool profiles define preset allow sets. var toolProfiles = map[string][]string{ "minimal": {"session_status"}, - "coding": {"group:fs", "group:runtime", "group:sessions", "group:memory", "read_image", "create_image"}, - "messaging": {"group:messaging", "sessions_list", "sessions_history", "sessions_send", "session_status"}, + "coding": {"group:fs", "group:runtime", "group:sessions", "group:memory", "group:web", "read_image", "create_image", "skill_search"}, + "messaging": {"group:messaging", "group:web", "sessions_list", "sessions_history", "sessions_send", "session_status", "read_image", "skill_search"}, "full": {}, // empty = no restrictions } // Tool aliases map alternative names to canonical names. var toolAliases = map[string]string{ - "bash": "exec", - "apply-patch": "apply_patch", + "bash": "exec", + "apply-patch": "apply_patch", + "edit_file": "edit", + "sessions_spawn": "spawn", } // Subagent deny lists — tools subagents cannot use. @@ -70,7 +75,7 @@ var subagentDenyList = []string{ // Leaf subagent deny — additional restrictions at max spawn depth. var leafSubagentDenyList = []string{ - "sessions_list", "sessions_history", "sessions_spawn", + "sessions_list", "sessions_history", "spawn", } // PolicyEngine evaluates tool access based on layered config policies. diff --git a/internal/tools/scrub.go b/internal/tools/scrub.go index 8d1fe66c..335d933a 100644 --- a/internal/tools/scrub.go +++ b/internal/tools/scrub.go @@ -1,6 +1,10 @@ package tools -import "regexp" +import ( + "regexp" + "strings" + "sync" +) // Credential patterns to scrub from tool output before returning to the LLM. // Inspired by zeroclaw's credential scrubbing system. @@ -33,11 +37,60 @@ var credentialPatterns = []*regexp.Regexp{ } const redactedPlaceholder = "[REDACTED]" +const serverIPPlaceholder = "[SERVER_IP]" -// ScrubCredentials replaces known credential patterns in text with [REDACTED]. +// dynamicScrubValues holds runtime-discovered values to scrub (e.g., server IPs). +var ( + dynamicScrubMu sync.RWMutex + dynamicScrubValues []string +) + +// AddDynamicScrubValues adds exact string values to the dynamic scrub list. +// Thread-safe. Deduplicates. Empty strings are ignored. +func AddDynamicScrubValues(values ...string) { + dynamicScrubMu.Lock() + defer dynamicScrubMu.Unlock() + + existing := make(map[string]bool, len(dynamicScrubValues)) + for _, v := range dynamicScrubValues { + existing[v] = true + } + for _, v := range values { + if v != "" && !existing[v] { + dynamicScrubValues = append(dynamicScrubValues, v) + existing[v] = true + } + } +} + +// DynamicScrubCount returns the number of dynamic scrub values registered. +func DynamicScrubCount() int { + dynamicScrubMu.RLock() + defer dynamicScrubMu.RUnlock() + return len(dynamicScrubValues) +} + +// ResetDynamicScrubValues clears all dynamic scrub values. For testing only. +func ResetDynamicScrubValues() { + dynamicScrubMu.Lock() + defer dynamicScrubMu.Unlock() + dynamicScrubValues = nil +} + +// ScrubCredentials replaces known credential patterns and dynamic values in text. func ScrubCredentials(text string) string { for _, pat := range credentialPatterns { text = pat.ReplaceAllString(text, redactedPlaceholder) } + + // Dynamic values (server IPs, etc.) + dynamicScrubMu.RLock() + vals := dynamicScrubValues + dynamicScrubMu.RUnlock() + + for _, v := range vals { + text = strings.ReplaceAll(text, v, serverIPPlaceholder) + } + return text } diff --git a/internal/tools/scrub_server.go b/internal/tools/scrub_server.go new file mode 100644 index 00000000..76298e75 --- /dev/null +++ b/internal/tools/scrub_server.go @@ -0,0 +1,124 @@ +package tools + +import ( + "context" + "io" + "log/slog" + "net" + "net/http" + "strings" + "time" +) + +// publicIPServices returns the caller's public IP as plain text. +// Tried in order; first successful response is used. +var publicIPServices = []string{ + "https://api.ipify.org", + "https://ifconfig.me/ip", + "https://icanhazip.com", + "https://checkip.amazonaws.com", +} + +// DetectServerIPs discovers the server's local and public IP addresses +// and registers them as dynamic scrub values. +// Local IPs are detected synchronously. Public IP runs in a goroutine. +func DetectServerIPs(ctx context.Context) { + // Phase 1: Local interface IPs (synchronous, microseconds) + localIPs := detectLocalIPs() + if len(localIPs) > 0 { + AddDynamicScrubValues(localIPs...) + slog.Info("security.server_ip_scrub: local IPs registered", + "count", len(localIPs), "ips", localIPs) + } + + // Phase 2: Public IP (async, HTTP call) + go func() { + pubIP := detectPublicIP(ctx) + if pubIP != "" { + AddDynamicScrubValues(pubIP) + slog.Info("security.server_ip_scrub: public IP registered", "ip", pubIP) + } else { + slog.Warn("security.server_ip_scrub: public IP detection failed") + } + }() +} + +// detectLocalIPs returns non-loopback, non-link-local IPs from local interfaces. +func detectLocalIPs() []string { + addrs, err := net.InterfaceAddrs() + if err != nil { + slog.Warn("security.server_ip_scrub: failed to list interfaces", "error", err) + return nil + } + + var ips []string + for _, addr := range addrs { + var ip net.IP + switch v := addr.(type) { + case *net.IPNet: + ip = v.IP + case *net.IPAddr: + ip = v.IP + default: + continue + } + + if ip.IsLoopback() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() || ip.IsUnspecified() { + continue + } + + ips = append(ips, ip.String()) + } + return ips +} + +// detectPublicIP tries multiple services to find the server's public IP. +func detectPublicIP(ctx context.Context) string { + client := &http.Client{ + Timeout: 5 * time.Second, + Transport: &http.Transport{ + MaxIdleConns: 2, + IdleConnTimeout: 10 * time.Second, + TLSHandshakeTimeout: 5 * time.Second, + }, + } + + for _, svcURL := range publicIPServices { + ip := tryPublicIPService(ctx, client, svcURL) + if ip != "" { + return ip + } + } + return "" +} + +// tryPublicIPService makes an HTTP GET to a service that returns the caller's IP as plain text. +func tryPublicIPService(ctx context.Context, client *http.Client, svcURL string) string { + req, err := http.NewRequestWithContext(ctx, "GET", svcURL, nil) + if err != nil { + return "" + } + req.Header.Set("User-Agent", "goclaw/ip-check") + + resp, err := client.Do(req) + if err != nil { + slog.Debug("security.server_ip_scrub: service failed", "url", svcURL, "error", err) + return "" + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return "" + } + + body, err := io.ReadAll(io.LimitReader(resp.Body, 64)) + if err != nil { + return "" + } + + candidate := strings.TrimSpace(string(body)) + if net.ParseIP(candidate) == nil { + return "" + } + return candidate +} diff --git a/internal/tools/scrub_test.go b/internal/tools/scrub_test.go index 97b39f77..3925515f 100644 --- a/internal/tools/scrub_test.go +++ b/internal/tools/scrub_test.go @@ -99,3 +99,60 @@ func TestScrubCredentials_EmptyString(t *testing.T) { t.Errorf("empty string changed: %q", got) } } + +func TestScrubCredentials_DynamicServerIPs(t *testing.T) { + ResetDynamicScrubValues() + defer ResetDynamicScrubValues() + + AddDynamicScrubValues("18.141.232.136", "34.160.111.145") + + tests := []struct { + name string + input string + want string + }{ + {"public IP", "Your IP: 18.141.232.136", "Your IP: [SERVER_IP]"}, + {"forwarded chain", "X-Forwarded-For: 18.141.232.136, 34.160.111.145", "X-Forwarded-For: [SERVER_IP], [SERVER_IP]"}, + {"unrelated IP", "Hello 1.2.3.4", "Hello 1.2.3.4"}, + {"IP in URL", "http://18.141.232.136:8080/api", "http://[SERVER_IP]:8080/api"}, + {"multiple occurrences", "src=18.141.232.136 dst=18.141.232.136", "src=[SERVER_IP] dst=[SERVER_IP]"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := ScrubCredentials(tt.input) + if got != tt.want { + t.Errorf("got %q, want %q", got, tt.want) + } + }) + } +} + +func TestAddDynamicScrubValues_Dedup(t *testing.T) { + ResetDynamicScrubValues() + defer ResetDynamicScrubValues() + + AddDynamicScrubValues("10.0.0.1", "10.0.0.1", "10.0.0.2") + if count := DynamicScrubCount(); count != 2 { + t.Errorf("expected 2 unique values, got %d", count) + } +} + +func TestAddDynamicScrubValues_IgnoresEmpty(t *testing.T) { + ResetDynamicScrubValues() + defer ResetDynamicScrubValues() + + AddDynamicScrubValues("", "", "10.0.0.1") + if count := DynamicScrubCount(); count != 1 { + t.Errorf("expected 1 value, got %d", count) + } +} + +func TestDetectLocalIPs_NoLoopback(t *testing.T) { + ips := detectLocalIPs() + for _, ipStr := range ips { + if ipStr == "127.0.0.1" || ipStr == "::1" { + t.Errorf("loopback IP should be excluded: %s", ipStr) + } + } +} diff --git a/ui/web/src/pages/agents/agent-detail/config-sections/tool-policy-section.tsx b/ui/web/src/pages/agents/agent-detail/config-sections/tool-policy-section.tsx index 59a57602..37236c2e 100644 --- a/ui/web/src/pages/agents/agent-detail/config-sections/tool-policy-section.tsx +++ b/ui/web/src/pages/agents/agent-detail/config-sections/tool-policy-section.tsx @@ -25,16 +25,17 @@ export function ToolPolicySection({ enabled, value, onToggle, onChange }: ToolPo onToggle={onToggle} >
- Profile + Profile
diff --git a/ui/web/src/types/agent.ts b/ui/web/src/types/agent.ts index 75a0373d..098a315c 100644 --- a/ui/web/src/types/agent.ts +++ b/ui/web/src/types/agent.ts @@ -3,7 +3,7 @@ // --- Per-agent config types (matching Go config structs) --- export interface ToolPolicyConfig { - profile?: string; // "default", "strict", "permissive" + profile?: string; // "full", "coding", "messaging", "minimal" allow?: string[]; deny?: string[]; alsoAllow?: string[];