mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-07-19 12:17:54 +00:00
Squash merge PR #100 after resolving changelog conflict with current dev. PR CI run 26703558537 passed release-versioning, go, and web.
737 lines
25 KiB
Go
737 lines
25 KiB
Go
package tools
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"log/slog"
|
|
"maps"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"regexp"
|
|
"runtime"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/nextlevelbuilder/goclaw/internal/config"
|
|
"github.com/nextlevelbuilder/goclaw/internal/sandbox"
|
|
"github.com/nextlevelbuilder/goclaw/internal/store"
|
|
"golang.org/x/text/unicode/norm"
|
|
)
|
|
|
|
const (
|
|
ExecDefaultTimeoutSeconds = 60
|
|
ExecMinTimeoutSeconds = 1
|
|
ExecMaxTimeoutSeconds = 3600
|
|
)
|
|
|
|
// 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 {
|
|
workspace string
|
|
timeout time.Duration
|
|
pathDenyPatterns []*regexp.Regexp // always-on path-based denials (DenyPaths)
|
|
pathDenyRoots []string // raw deny roots for nested workspace exemptions
|
|
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
|
|
policyMu sync.RWMutex
|
|
commandKeywordAllowlist []config.CommandKeywordAllowlistRule
|
|
// globalDenyGroups holds global shell deny-group toggles from config.tools.
|
|
// Per-agent overrides from context (store.WithShellDenyGroups) win per-key.
|
|
// Updated at startup and via TopicConfigChanged pub/sub for runtime reload.
|
|
globalDenyGroups map[string]bool
|
|
}
|
|
|
|
// SetCommandKeywordAllowlist replaces the scoped credentialed CLI keyword
|
|
// allowlist. The slice is defensively copied so config reload callers cannot
|
|
// mutate the running tool policy after assignment.
|
|
func (t *ExecTool) SetCommandKeywordAllowlist(rules []config.CommandKeywordAllowlistRule) {
|
|
t.policyMu.Lock()
|
|
defer t.policyMu.Unlock()
|
|
if len(rules) == 0 {
|
|
t.commandKeywordAllowlist = nil
|
|
return
|
|
}
|
|
t.commandKeywordAllowlist = slicesClone(rules)
|
|
}
|
|
|
|
// CommandKeywordAllowlistForTest exposes the effective global allowlist for
|
|
// cross-package config reload tests. Not for production callers.
|
|
func (t *ExecTool) CommandKeywordAllowlistForTest() []config.CommandKeywordAllowlistRule {
|
|
return t.commandKeywordAllowlistSnapshot()
|
|
}
|
|
|
|
func (t *ExecTool) commandKeywordAllowlistSnapshot() []config.CommandKeywordAllowlistRule {
|
|
t.policyMu.RLock()
|
|
defer t.policyMu.RUnlock()
|
|
return slicesClone(t.commandKeywordAllowlist)
|
|
}
|
|
|
|
// SetGlobalShellDenyGroups replaces the global shell deny-group toggles. The
|
|
// caller's map is defensively copied so later mutations cannot leak into the
|
|
// tool's internal state. Passing nil or an empty map clears the global config
|
|
// (per-agent context overrides, if any, still apply on their own).
|
|
func (t *ExecTool) SetGlobalShellDenyGroups(groups map[string]bool) {
|
|
t.policyMu.Lock()
|
|
defer t.policyMu.Unlock()
|
|
if len(groups) == 0 {
|
|
t.globalDenyGroups = nil
|
|
return
|
|
}
|
|
cp := make(map[string]bool, len(groups))
|
|
maps.Copy(cp, groups)
|
|
t.globalDenyGroups = cp
|
|
}
|
|
|
|
// effectiveDenyGroups merges the per-agent context override with the global
|
|
// config. Precedence: per-agent context (per-key) > global. When one side is
|
|
// empty, the other is returned directly (no allocation).
|
|
func (t *ExecTool) effectiveDenyGroups(ctx context.Context) map[string]bool {
|
|
agent := store.ShellDenyGroupsFromContext(ctx)
|
|
t.policyMu.RLock()
|
|
global := t.globalDenyGroups
|
|
if len(global) > 0 {
|
|
global = maps.Clone(global)
|
|
}
|
|
t.policyMu.RUnlock()
|
|
if len(global) == 0 {
|
|
return agent
|
|
}
|
|
if len(agent) == 0 {
|
|
return global
|
|
}
|
|
merged := make(map[string]bool, len(global)+len(agent))
|
|
maps.Copy(merged, global)
|
|
// agent wins per-key
|
|
maps.Copy(merged, agent)
|
|
return merged
|
|
}
|
|
|
|
// EffectiveDenyGroupsForTest exposes effectiveDenyGroups for cross-package tests
|
|
// (e.g. cmd pub/sub regression). Not for production callers.
|
|
func (t *ExecTool) EffectiveDenyGroupsForTest(ctx context.Context) map[string]bool {
|
|
return t.effectiveDenyGroups(ctx)
|
|
}
|
|
|
|
// NewExecTool creates an exec tool that runs commands directly on the host.
|
|
func NewExecTool(workspace string, restrict bool) *ExecTool {
|
|
return &ExecTool{
|
|
workspace: workspace,
|
|
timeout: time.Duration(ExecDefaultTimeoutSeconds) * time.Second,
|
|
restrict: restrict,
|
|
}
|
|
}
|
|
|
|
// NewSandboxedExecTool creates an exec tool that routes commands through a sandbox container.
|
|
func NewSandboxedExecTool(workspace string, restrict bool, mgr sandbox.Manager) *ExecTool {
|
|
return &ExecTool{
|
|
workspace: workspace,
|
|
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 {
|
|
seen := make(map[string]struct{}, 3)
|
|
for _, variant := range []string{p, filepath.ToSlash(p), filepath.FromSlash(p)} {
|
|
if variant == "" {
|
|
continue
|
|
}
|
|
if _, ok := seen[variant]; ok {
|
|
continue
|
|
}
|
|
seen[variant] = struct{}{}
|
|
escaped := regexp.QuoteMeta(variant)
|
|
t.pathDenyPatterns = append(t.pathDenyPatterns, regexp.MustCompile(escaped))
|
|
}
|
|
t.pathDenyRoots = append(t.pathDenyRoots, p)
|
|
}
|
|
}
|
|
|
|
// AllowPathExemptions adds path prefixes that exempt a command from deny pattern matches.
|
|
// Each shell argument is checked individually — commands like "cat .goclaw/skills-store/tool.py"
|
|
// are exempt because the argument ".goclaw/skills-store/tool.py" starts with the prefix.
|
|
func (t *ExecTool) AllowPathExemptions(prefixes ...string) {
|
|
t.denyExemptions = append(t.denyExemptions, prefixes...)
|
|
}
|
|
|
|
// normalizeCommand applies NFKC Unicode normalization and strips zero-width
|
|
// characters before deny pattern matching, preventing Unicode-based bypasses.
|
|
func normalizeCommand(s string) string {
|
|
// NFKC normalization: folds compatibility characters (e.g. fullwidth letters)
|
|
s = norm.NFKC.String(s)
|
|
// Strip zero-width characters that are invisible but can fragment tokens
|
|
s = strings.NewReplacer(
|
|
"\u200b", "", // zero-width space
|
|
"\u200c", "", // zero-width non-joiner
|
|
"\u200d", "", // zero-width joiner
|
|
"\u2060", "", // word joiner
|
|
"\ufeff", "", // BOM / zero-width no-break space
|
|
).Replace(s)
|
|
return s
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// HasSecureCLIStore reports whether a credential store is wired.
|
|
// Intended for wiring-check tests that verify subagent ExecTools also enforce
|
|
// the secure-CLI gate (Red Team F3).
|
|
func (t *ExecTool) HasSecureCLIStore() bool {
|
|
return t.secureCLIStore != nil
|
|
}
|
|
|
|
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")
|
|
}
|
|
|
|
// Reject NUL bytes — they cause silent shell truncation enabling injection.
|
|
if strings.ContainsRune(command, '\x00') {
|
|
return ErrorResult("command contains invalid NUL byte")
|
|
}
|
|
|
|
// Normalize command before all deny checks: NFKC + zero-width strip prevents
|
|
// Unicode-based pattern bypass while preserving functional command content.
|
|
normalizedCommand := normalizeCommand(command)
|
|
|
|
// Credentialed exec is argv-based, not shell-based. Route it before shell
|
|
// deny scanning so ordinary argument text cannot be mistaken for executable
|
|
// shell syntax.
|
|
if cred, binary, cmdArgs := t.lookupCredentialedBinary(ctx, command); cred != nil {
|
|
cwd := ToolWorkspaceFromCtx(ctx)
|
|
if cwd == "" {
|
|
cwd = t.workspace
|
|
}
|
|
if wd, _ := args["working_dir"].(string); wd != "" {
|
|
if effectiveRestrict(ctx, t.restrict) {
|
|
wsBase := ToolWorkspaceFromCtx(ctx)
|
|
if wsBase == "" {
|
|
wsBase = t.workspace
|
|
}
|
|
allowed := allowedWriteWithTeamWorkspace(ctx, nil)
|
|
resolved, err := resolvePathWithAllowed(wd, wsBase, true, allowed)
|
|
if err != nil {
|
|
return ErrorResult(err.Error())
|
|
}
|
|
cwd = resolved
|
|
} else {
|
|
cwd = wd
|
|
}
|
|
}
|
|
sandboxKey := ToolSandboxKeyFromCtx(ctx)
|
|
return t.executeCredentialed(ctx, cred, binary, cmdArgs, cwd, sandboxKey, command)
|
|
}
|
|
|
|
// Resolve deny patterns: merge per-agent context overrides with global
|
|
// config (per-key agent precedence), fallback to all registry defaults.
|
|
denyOverrides := t.effectiveDenyGroups(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...)
|
|
exemptions := append([]string{}, t.denyExemptions...)
|
|
exemptions = append(exemptions, t.dynamicPathExemptions(ctx)...)
|
|
|
|
// Check for dangerous commands (applies to both host and sandbox).
|
|
wordFields := parseExecCommandWords(normalizedCommand)
|
|
pathBaseDir := ToolWorkspaceFromCtx(ctx)
|
|
if pathBaseDir == "" {
|
|
pathBaseDir = t.workspace
|
|
}
|
|
for _, pattern := range allPatterns {
|
|
if pattern.MatchString(normalizedCommand) {
|
|
// Check if exemption applies. Only exempt if EVERY field that
|
|
// individually matches the deny pattern is covered by an exemption.
|
|
// This prevents pipe/comment bypass: "cat /app/data/skills-store/x | cat /app/data/secret"
|
|
// — the second field matches deny but has no exemption → denied.
|
|
// Strips surrounding quotes (LLMs often quote paths) and rejects
|
|
// path traversal ("..") to prevent exemption escape.
|
|
exempt := false
|
|
trimmed := strings.TrimSpace(normalizedCommand)
|
|
fields := wordFields
|
|
if len(fields) == 0 {
|
|
fields = strings.Fields(trimmed)
|
|
}
|
|
matchingFields := 0
|
|
exemptFields := 0
|
|
for _, field := range fields {
|
|
clean := strings.TrimSpace(field)
|
|
if !pattern.MatchString(clean) {
|
|
continue // field doesn't trigger this deny pattern
|
|
}
|
|
matchingFields++
|
|
if matchesAnyPathExemption(clean, exemptions, pathBaseDir) {
|
|
exemptFields++
|
|
}
|
|
}
|
|
// Exempt only if at least one field matched AND all matched fields are exempt.
|
|
if matchingFields > 0 && exemptFields == matchingFields {
|
|
exempt = true
|
|
}
|
|
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(normalizedCommand, 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()))
|
|
}
|
|
}
|
|
|
|
// Memory path hint: shell commands can't access DB-backed memory files.
|
|
if hint := MaybeMemoryExecHint(normalizedCommand); hint != "" {
|
|
return SilentResult(hint)
|
|
}
|
|
|
|
// Secure CLI gate: registered-but-not-granted binaries MUST NOT fall through
|
|
// to host exec with parent env. Works on the already-normalized command
|
|
// (Red Team F6) and unwraps shell wrappers up to depth 3 (Red Team F1).
|
|
// Fails CLOSED on DB error (Red Team F7).
|
|
if t.secureCLIStore != nil {
|
|
candidates, tooDeep := collectGateCandidates(normalizedCommand)
|
|
if tooDeep {
|
|
slog.Warn("security.credentialed_binary_wrapper_too_deep",
|
|
"command", truncateCmd(normalizedCommand, 80),
|
|
"agent_id", store.AgentIDFromContext(ctx))
|
|
return ErrorResult("Command nesting too deep (>3 shell wrappers). This looks adversarial; if legitimate, flatten the command.")
|
|
}
|
|
for _, c := range candidates {
|
|
if c.binary == "" {
|
|
continue
|
|
}
|
|
gctx, cancel := context.WithTimeout(ctx, 2*time.Second)
|
|
registered, rerr := t.secureCLIStore.IsRegisteredBinary(gctx, c.binary)
|
|
cancel()
|
|
if rerr != nil {
|
|
slog.Warn("security.credentialed_binary_gate_error",
|
|
"binary", c.binary, "error", rerr,
|
|
"agent_id", store.AgentIDFromContext(ctx))
|
|
return ErrorResult("Secure CLI gate temporarily unavailable. Retry in a moment.")
|
|
}
|
|
if registered {
|
|
slog.Warn("security.credentialed_binary_denied",
|
|
"binary", c.binary,
|
|
"wrapper", c.wrapper,
|
|
"agent_id", store.AgentIDFromContext(ctx),
|
|
"tenant_id", store.TenantIDFromContext(ctx),
|
|
"command_prefix", truncateCmd(normalizedCommand, 80))
|
|
return ErrorResult(fmt.Sprintf(
|
|
"Binary %q requires a secure CLI grant. Ask admin to grant access to this agent.",
|
|
c.binary))
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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.
|
|
// The context workspace is tenant-scoped; t.workspace is the global (master) workspace.
|
|
cwd := ToolWorkspaceFromCtx(ctx)
|
|
if cwd == "" {
|
|
cwd = t.workspace
|
|
}
|
|
if wd, _ := args["working_dir"].(string); wd != "" {
|
|
if effectiveRestrict(ctx, t.restrict) {
|
|
// Validate working_dir against the tenant-scoped workspace (not the
|
|
// global workspace) so non-master tenants can't escape their scope.
|
|
// Also allow team workspace as a valid target (same as filesystem tools).
|
|
wsBase := ToolWorkspaceFromCtx(ctx)
|
|
if wsBase == "" {
|
|
wsBase = t.workspace
|
|
}
|
|
// Shell is an arbitrary executor — a cross-chat cwd would let the
|
|
// command mutate files in another chat's workspace. Enforce the
|
|
// stricter write-allowed prefixes (team root excluded) to block
|
|
// cross-chat cwd even for "read-only" commands like cat, since we
|
|
// cannot prove the shell command will not write.
|
|
allowed := allowedWriteWithTeamWorkspace(ctx, nil)
|
|
resolved, err := resolvePathWithAllowed(wd, wsBase, true, allowed)
|
|
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)
|
|
}
|
|
|
|
type execSettings struct {
|
|
TimeoutSeconds *int `json:"timeout_seconds"`
|
|
}
|
|
|
|
// ValidateExecSettingsJSON validates the public settings contract for the exec
|
|
// builtin. Other tools are ignored so their flexible JSON settings stay intact.
|
|
func ValidateExecSettingsJSON(toolName string, raw json.RawMessage, allowNull bool) error {
|
|
if toolName != "exec" {
|
|
return nil
|
|
}
|
|
seconds, ok, err := parseExecTimeoutSeconds(raw, allowNull)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if !ok {
|
|
return nil
|
|
}
|
|
if seconds > ExecMaxTimeoutSeconds {
|
|
return fmt.Errorf("timeout_seconds must be <= %d", ExecMaxTimeoutSeconds)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func parseExecTimeoutSeconds(raw json.RawMessage, allowNull bool) (int, bool, error) {
|
|
if len(raw) == 0 {
|
|
return 0, false, nil
|
|
}
|
|
trimmed := strings.TrimSpace(string(raw))
|
|
if trimmed == "null" {
|
|
if allowNull {
|
|
return 0, false, nil
|
|
}
|
|
return 0, false, errors.New("settings must be a JSON object")
|
|
}
|
|
var settings execSettings
|
|
if err := json.Unmarshal(raw, &settings); err != nil {
|
|
return 0, false, errors.New("settings must be a JSON object with numeric timeout_seconds")
|
|
}
|
|
if settings.TimeoutSeconds == nil {
|
|
return 0, false, nil
|
|
}
|
|
if *settings.TimeoutSeconds < ExecMinTimeoutSeconds {
|
|
return 0, true, fmt.Errorf("timeout_seconds must be >= %d", ExecMinTimeoutSeconds)
|
|
}
|
|
return *settings.TimeoutSeconds, true, nil
|
|
}
|
|
|
|
func (t *ExecTool) effectiveTimeout(ctx context.Context) time.Duration {
|
|
settings := BuiltinToolSettingsFromCtx(ctx)
|
|
if raw, ok := settings["exec"]; ok {
|
|
if seconds, hasValue, err := parseExecTimeoutSeconds(json.RawMessage(raw), false); err == nil && hasValue {
|
|
if seconds > ExecMaxTimeoutSeconds {
|
|
seconds = ExecMaxTimeoutSeconds
|
|
}
|
|
return time.Duration(seconds) * time.Second
|
|
}
|
|
}
|
|
if t.timeout > 0 {
|
|
return t.timeout
|
|
}
|
|
return time.Duration(ExecDefaultTimeoutSeconds) * time.Second
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
func posixShellPath() (string, error) {
|
|
for _, candidate := range []string{"/bin/sh", "/usr/bin/sh"} {
|
|
if st, err := os.Stat(candidate); err == nil && !st.IsDir() && st.Mode()&0o111 != 0 {
|
|
return candidate, nil
|
|
}
|
|
}
|
|
return "", errors.New("no executable POSIX shell found at /bin/sh or /usr/bin/sh")
|
|
}
|
|
|
|
// executeOnHost runs a command directly on the host (original behavior).
|
|
// ctx cancellation (e.g. agent abort) triggers SIGTERM → 3s grace → SIGKILL on the
|
|
// entire process group so forked children are also cleaned up (no orphans).
|
|
func (t *ExecTool) executeOnHost(ctx context.Context, command, cwd string) *Result {
|
|
timeout := t.effectiveTimeout(ctx)
|
|
ctx, cancel := context.WithTimeout(ctx, timeout)
|
|
defer cancel()
|
|
|
|
// Pre-flight cmd.Dir validation. On Linux, Go's clone+chdir+execve failure
|
|
// path collapses every child-side error into "fork/exec PATH: <errno-string>"
|
|
// — so a missing cmd.Dir surfaces as if the shell binary itself were missing.
|
|
// Catch this case explicitly so the error names the real culprit.
|
|
if err := validateExecCwd(cwd); err != nil {
|
|
return ErrorResult(fmt.Sprintf("exec: %v", err))
|
|
}
|
|
|
|
// Use plain exec.Command (not CommandContext) so we control the kill sequence.
|
|
// CommandContext would SIGKILL only the direct child, leaving forked grandchildren alive.
|
|
// Route through the platform shell: cmd.exe on Windows, sh on POSIX.
|
|
var cmd *exec.Cmd
|
|
if runtime.GOOS == "windows" {
|
|
cmd = exec.Command("cmd", "/C", command)
|
|
} else {
|
|
shellPath, err := posixShellPath()
|
|
if err != nil {
|
|
return ErrorResult(err.Error())
|
|
}
|
|
cmd = exec.Command(shellPath, "-c", command)
|
|
}
|
|
cmd.Dir = cwd
|
|
|
|
// Scrub credential env vars so fall-through exec cannot exfiltrate
|
|
// host secrets (Red Team F4). Uses static deny list + dynamic keys
|
|
// discovered from any registered secure-cli binary for this tenant.
|
|
var dynKeys []string
|
|
if t.secureCLIStore != nil {
|
|
dynKeys = t.credentialEnvKeys(ctx)
|
|
} else {
|
|
dynKeys = staticCredentialEnvKeys
|
|
}
|
|
cmd.Env = scrubCredentialEnv(os.Environ(), dynKeys)
|
|
|
|
// Place the child in its own process group so killProcessGroup(-pgid, sig)
|
|
// reaches the shell and all of its forked children.
|
|
setProcessGroup(cmd)
|
|
|
|
// 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
|
|
|
|
if err := cmd.Start(); err != nil {
|
|
return ErrorResult(fmt.Sprintf("failed to start command: %v", err))
|
|
}
|
|
|
|
done := make(chan error, 1)
|
|
go func() { done <- cmd.Wait() }()
|
|
|
|
select {
|
|
case err := <-done:
|
|
// Normal completion (success or non-zero exit).
|
|
return buildHostResult(err, stdout, stderr, ctx, timeout)
|
|
|
|
case <-ctx.Done():
|
|
// Context cancelled or timed out — kill the process group gracefully then forcefully.
|
|
_ = killProcessGroup(cmd, syscallSIGTERM)
|
|
select {
|
|
case <-done:
|
|
// Exited cleanly after SIGTERM.
|
|
case <-time.After(3 * time.Second):
|
|
// Still alive after grace period — force kill.
|
|
_ = killProcessGroup(cmd, syscallSIGKILL)
|
|
<-done
|
|
}
|
|
if errors.Is(ctx.Err(), context.DeadlineExceeded) {
|
|
return ErrorResult(fmt.Sprintf("command timed out after %s", timeout))
|
|
}
|
|
return ErrorResult("command aborted")
|
|
}
|
|
}
|
|
|
|
// buildHostResult formats the result of a completed host command execution.
|
|
func buildHostResult(err error, stdout, stderr *limitedBuffer, ctx context.Context, timeout time.Duration) *Result {
|
|
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 errors.Is(ctx.Err(), context.DeadlineExceeded) {
|
|
return ErrorResult(fmt.Sprintf("command timed out after %s", timeout))
|
|
}
|
|
if result == "" {
|
|
result = err.Error()
|
|
}
|
|
return ErrorResult(result)
|
|
}
|
|
|
|
if result == "" {
|
|
result = "(command completed with no output)"
|
|
}
|
|
return SilentResult(capExecOutput(result, execMaxOutputChars))
|
|
}
|
|
|
|
// executeInSandbox routes a command through a Docker sandbox container.
|
|
func (t *ExecTool) executeInSandbox(ctx context.Context, command, cwd, sandboxKey string) *Result {
|
|
mountWorkspace, err := effectiveSandboxWorkspace(ctx, t.workspace)
|
|
if err != nil {
|
|
return ErrorResult(err.Error())
|
|
}
|
|
containerCwd, cwdErr := sandboxCwdForHostPath(cwd, mountWorkspace, sandbox.DefaultContainerWorkdir)
|
|
if cwdErr != nil {
|
|
return ErrorResult(fmt.Sprintf("sandbox path mapping: %v", cwdErr))
|
|
}
|
|
|
|
sb, err := t.sandboxMgr.Get(ctx, sandboxKey, mountWorkspace, 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))
|
|
}
|
|
|
|
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)
|
|
}
|
|
output += MaybeSandboxHint(result.ExitCode, output)
|
|
return ErrorResult(output)
|
|
}
|
|
if output == "" {
|
|
output = "(command completed with no output)"
|
|
}
|
|
|
|
return SilentResult(capExecOutput(output, execMaxOutputChars))
|
|
}
|
|
|
|
// 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() }
|