mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-13 20:14:50 +00:00
feat: Refine tool policies with updated groups and aliases, and enhance credential scrubbing by dynamically detecting and redacting server IPs.
This commit is contained in:
1 parent
74d85c8dd5
commit
ae89239dd5
7 files changed
+270
-27
No files matched your search
@@ -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")
|
||||
|
||||
+24
-19
@@ -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.
|
||||
|
||||
+55
-2
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -25,16 +25,17 @@ export function ToolPolicySection({ enabled, value, onToggle, onChange }: ToolPo
|
||||
onToggle={onToggle}
|
||||
>
|
||||
<div className="space-y-2">
|
||||
<InfoLabel tip="Base tool profile. 'default' includes common tools, 'strict' limits to safe tools only, 'permissive' allows all tools.">Profile</InfoLabel>
|
||||
<InfoLabel tip="Base tool profile. 'full' allows all tools, 'coding' includes filesystem/runtime/sessions/memory, 'messaging' includes messaging/sessions, 'minimal' allows only session_status.">Profile</InfoLabel>
|
||||
<Select
|
||||
value={value.profile ?? ""}
|
||||
onValueChange={(v) => onChange({ ...value, profile: v || undefined })}
|
||||
>
|
||||
<SelectTrigger><SelectValue placeholder="default" /></SelectTrigger>
|
||||
<SelectTrigger><SelectValue placeholder="full" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="default">default</SelectItem>
|
||||
<SelectItem value="strict">strict</SelectItem>
|
||||
<SelectItem value="permissive">permissive</SelectItem>
|
||||
<SelectItem value="full">full</SelectItem>
|
||||
<SelectItem value="coding">coding</SelectItem>
|
||||
<SelectItem value="messaging">messaging</SelectItem>
|
||||
<SelectItem value="minimal">minimal</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
@@ -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[];
|
||||
|
||||
Reference in new issue
Block a user