package tools import ( "bytes" "context" "errors" "fmt" "log/slog" "os/exec" "path/filepath" "regexp" "strings" "time" "github.com/nextlevelbuilder/goclaw/internal/sandbox" "github.com/nextlevelbuilder/goclaw/internal/store" ) // Dangerous command patterns organized into configurable deny groups. // Defense-in-depth: patterns complement Docker hardening (cap-drop ALL, // no-new-privileges, pids-limit, memory limit). // Sources: OWASP Agentic AI Top 10, Claude Code CVE-2025-66032, MITRE ATT&CK, // PayloadsAllTheThings, Trail of Bits prompt-injection-to-RCE research. // Groups and patterns defined in shell_deny_groups.go. // DefaultDenyPatterns returns all patterns from groups where Default=true. // Backward-compatible wrapper for code that doesn't use per-agent overrides. func DefaultDenyPatterns() []*regexp.Regexp { return ResolveDenyPatterns(nil) } // ExecTool executes shell commands, optionally inside a sandbox container. type ExecTool struct { workingDir string timeout time.Duration pathDenyPatterns []*regexp.Regexp // always-on path-based denials (DenyPaths) denyExemptions []string // substrings that exempt a command from deny restrict bool sandboxMgr sandbox.Manager // nil = no sandbox, execute on host approvalMgr *ExecApprovalManager // nil = no approval needed agentID string // for approval request context secureCLIStore store.SecureCLIStore // nil = no credentialed exec } // NewExecTool creates an exec tool that runs commands directly on the host. func NewExecTool(workingDir string, restrict bool) *ExecTool { return &ExecTool{ workingDir: workingDir, timeout: 60 * time.Second, restrict: restrict, } } // NewSandboxedExecTool creates an exec tool that routes commands through a sandbox container. func NewSandboxedExecTool(workingDir string, restrict bool, mgr sandbox.Manager) *ExecTool { return &ExecTool{ workingDir: workingDir, timeout: 300 * time.Second, // sandbox allows longer timeout restrict: restrict, sandboxMgr: mgr, } } // SetSandboxKey is a no-op; sandbox key is now read from ctx (thread-safe). func (t *ExecTool) SetSandboxKey(key string) {} // DenyPaths adds always-on deny patterns that block commands referencing the given paths. // These are NOT configurable via deny groups — they always apply regardless of group config. func (t *ExecTool) DenyPaths(paths ...string) { for _, p := range paths { escaped := regexp.QuoteMeta(p) t.pathDenyPatterns = append(t.pathDenyPatterns, regexp.MustCompile(escaped)) } } // AllowPathExemptions adds substrings that exempt a command from deny pattern matches. func (t *ExecTool) AllowPathExemptions(substrings ...string) { t.denyExemptions = append(t.denyExemptions, substrings...) } // SetApprovalManager sets the exec approval manager for this tool. func (t *ExecTool) SetApprovalManager(mgr *ExecApprovalManager, agentID string) { t.approvalMgr = mgr t.agentID = agentID } // SetSecureCLIStore sets the credential store for credentialed exec. func (t *ExecTool) SetSecureCLIStore(s store.SecureCLIStore) { t.secureCLIStore = s } func (t *ExecTool) Name() string { return "exec" } func (t *ExecTool) Description() string { return "Execute a shell command and return its output" } func (t *ExecTool) Parameters() map[string]any { return map[string]any{ "type": "object", "properties": map[string]any{ "command": map[string]any{ "type": "string", "description": "The shell command to execute", }, "working_dir": map[string]any{ "type": "string", "description": "Working directory for the command (default: workspace root)", }, }, "required": []string{"command"}, } } func (t *ExecTool) Execute(ctx context.Context, args map[string]any) *Result { command, _ := args["command"].(string) if command == "" { return ErrorResult("command is required") } // Resolve deny patterns: per-agent overrides from context, fallback to all defaults. denyOverrides := store.ShellDenyGroupsFromContext(ctx) groupPatterns := ResolveDenyPatterns(denyOverrides) // Also resolve package_install patterns separately for approval routing. var pkgInstallPatterns []*regexp.Regexp if pkgGroup, ok := DenyGroupRegistry["package_install"]; ok && IsGroupDenied(denyOverrides, "package_install") { pkgInstallPatterns = pkgGroup.Patterns } // Combine group-based patterns + always-on path denials. allPatterns := make([]*regexp.Regexp, 0, len(groupPatterns)+len(t.pathDenyPatterns)) allPatterns = append(allPatterns, groupPatterns...) allPatterns = append(allPatterns, t.pathDenyPatterns...) // Check for dangerous commands (applies to both host and sandbox). for _, pattern := range allPatterns { if pattern.MatchString(command) { // Check if any exemption applies (e.g. skills-store within .goclaw) exempt := false for _, ex := range t.denyExemptions { if strings.Contains(command, ex) { exempt = true break } } if exempt { continue } // Package install commands: route through approval flow instead of hard deny. // This lets agents "request permission" from admin to install packages. if t.approvalMgr != nil && matchesAny(command, pkgInstallPatterns) { slog.Info("exec: package install requires approval", "command", truncateCmd(command, 100), "agent", t.agentID) decision, err := t.approvalMgr.RequestApproval(command, t.agentID, 2*time.Minute) if err != nil { return ErrorResult(fmt.Sprintf("package install approval: %v", err)) } if decision == ApprovalDeny { return ErrorResult("package installation denied by admin") } // Approved — skip deny, continue to execution. continue } return ErrorResult(fmt.Sprintf("command denied by safety policy: matches pattern %s", pattern.String())) } } // Credentialed exec: if command matches a configured binary, use Direct Exec Mode. // This bypasses approval (admin trust) and shell (security). if cred, binary, cmdArgs := t.lookupCredentialedBinary(ctx, command); cred != nil { cwd := ToolWorkspaceFromCtx(ctx) if cwd == "" { cwd = t.workingDir } if wd, _ := args["working_dir"].(string); wd != "" { if effectiveRestrict(ctx, t.restrict) { if resolved, err := resolvePath(wd, t.workingDir, true); err == nil { cwd = resolved } } else { cwd = wd } } sandboxKey := ToolSandboxKeyFromCtx(ctx) return t.executeCredentialed(ctx, cred, binary, cmdArgs, cwd, sandboxKey) } // Exec approval check (matching TS exec-approval.ts pipeline) if t.approvalMgr != nil { switch t.approvalMgr.CheckCommand(command) { case "deny": return ErrorResult("command denied by exec approval policy") case "ask": decision, err := t.approvalMgr.RequestApproval(command, t.agentID, 2*time.Minute) if err != nil { return ErrorResult(fmt.Sprintf("exec approval: %v", err)) } if decision == ApprovalDeny { return ErrorResult("command denied by user") } } } // Use per-user workspace from context if available, fallback to struct field cwd := ToolWorkspaceFromCtx(ctx) if cwd == "" { cwd = t.workingDir } if wd, _ := args["working_dir"].(string); wd != "" { if effectiveRestrict(ctx, t.restrict) { resolved, err := resolvePath(wd, t.workingDir, true) if err != nil { return ErrorResult(err.Error()) } cwd = resolved } else { cwd = wd } } // Sandbox routing (sandboxKey from ctx — thread-safe) sandboxKey := ToolSandboxKeyFromCtx(ctx) if t.sandboxMgr != nil && sandboxKey != "" { return t.executeInSandbox(ctx, command, cwd, sandboxKey) } // Host execution return t.executeOnHost(ctx, command, cwd) } // matchesAny checks if a command matches any pattern in the list. func matchesAny(command string, patterns []*regexp.Regexp) bool { for _, p := range patterns { if p.MatchString(command) { return true } } return false } // executeOnHost runs a command directly on the host (original behavior). func (t *ExecTool) executeOnHost(ctx context.Context, command, cwd string) *Result { ctx, cancel := context.WithTimeout(ctx, t.timeout) defer cancel() cmd := exec.CommandContext(ctx, "sh", "-c", command) cmd.Dir = cwd // Limit output to 1MB to prevent OOM from runaway commands. stdout := &limitedBuffer{max: 1 << 20} stderr := &limitedBuffer{max: 1 << 20} cmd.Stdout = stdout cmd.Stderr = stderr err := cmd.Run() var result string if stdout.Len() > 0 { result = stdout.String() } if stderr.Len() > 0 { if result != "" { result += "\n" } result += "STDERR:\n" + stderr.String() } if err != nil { if ctx.Err() == context.DeadlineExceeded { return ErrorResult(fmt.Sprintf("command timed out after %s", t.timeout)) } if result == "" { result = err.Error() } return ErrorResult(result) } if result == "" { result = "(command completed with no output)" } return SilentResult(result) } // executeInSandbox routes a command through a Docker sandbox container. func (t *ExecTool) executeInSandbox(ctx context.Context, command, cwd, sandboxKey string) *Result { sb, err := t.sandboxMgr.Get(ctx, sandboxKey, t.workingDir, SandboxConfigFromCtx(ctx)) if err != nil { if errors.Is(err, sandbox.ErrSandboxDisabled) { return t.executeOnHost(ctx, command, cwd) } // Docker unavailable (binary missing, daemon down) → fail closed. // Do NOT silently fallback to host — that defeats the purpose of sandboxing. slog.Warn("security.sandbox_unavailable", "error", err, "command", truncateCmd(command, 80), ) return ErrorResult(fmt.Sprintf("sandbox unavailable: %v (will not fall back to unsandboxed host execution)", err)) } // Map host workdir to container workdir containerCwd := "/workspace" if cwd != t.workingDir { rel, relErr := filepath.Rel(t.workingDir, cwd) if relErr == nil { containerCwd = filepath.Join("/workspace", rel) } } result, err := sb.Exec(ctx, []string{"sh", "-c", command}, containerCwd) //nolint: no ExecOption for normal exec if err != nil { return ErrorResult(fmt.Sprintf("sandbox exec: %v", err)) } // Format output same as host execution output := result.Stdout if result.Stderr != "" { if output != "" { output += "\n" } output += "STDERR:\n" + result.Stderr } if result.ExitCode != 0 { if output == "" { output = fmt.Sprintf("command exited with code %d", result.ExitCode) } return ErrorResult(output) } if output == "" { output = "(command completed with no output)" } return SilentResult(output) } // limitedBuffer caps output to prevent OOM from runaway commands. type limitedBuffer struct { buf bytes.Buffer max int truncated bool } func (lb *limitedBuffer) Write(p []byte) (int, error) { if lb.truncated { return len(p), nil } remaining := lb.max - lb.buf.Len() if remaining <= 0 { lb.truncated = true return len(p), nil } if len(p) > remaining { lb.buf.Write(p[:remaining]) lb.truncated = true return len(p), nil } return lb.buf.Write(p) } func (lb *limitedBuffer) String() string { s := lb.buf.String() if lb.truncated { s += "\n[output truncated at 1MB]" } return s } func (lb *limitedBuffer) Len() int { return lb.buf.Len() }