/ internal / tools / bash.go
bash.go
  1  package tools
  2  
  3  import (
  4  	"context"
  5  	"encoding/json"
  6  	"fmt"
  7  	"os"
  8  	"os/exec"
  9  	"strconv"
 10  	"strings"
 11  	"time"
 12  
 13  	"github.com/Kocoro-lab/ShanClaw/internal/agent"
 14  	"github.com/Kocoro-lab/ShanClaw/internal/cwdctx"
 15  	"github.com/Kocoro-lab/ShanClaw/internal/skills"
 16  )
 17  
 18  type BashTool struct {
 19  	approvalFn        func(command string) bool
 20  	ExtraSafeCommands []string
 21  	CWD               string // working directory for commands; if empty and no session CWD is set, bash runs in an isolated temp dir (NOT the process cwd)
 22  	MaxOutput         int    // max output chars; 0 = use default 30000
 23  	// DefaultTimeoutSecs is the fallback timeout (in seconds) when the
 24  	// per-call `timeout` arg is absent or zero. 0 = use built-in default 120.
 25  	// Wired from config.Tools.BashTimeout by register.go.
 26  	DefaultTimeoutSecs int
 27  	// SecretsStore, when set, supplies per-skill API keys as env vars
 28  	// for skills activated via use_skill in the current run. Values are
 29  	// fetched lazily at execution time and scoped to bash child processes
 30  	// only — they never enter prompt context or session transcripts.
 31  	SecretsStore *skills.SecretsStore
 32  }
 33  
 34  type bashArgs struct {
 35  	Command string `json:"command"`
 36  	Timeout int    `json:"timeout,omitempty"`
 37  }
 38  
 39  var safeCommands = []string{
 40  	"ls", "pwd", "which", "echo", "cat", "head", "tail", "wc",
 41  	"git status", "git diff", "git log", "git branch", "git show",
 42  	"go build", "go test", "go vet", "go fmt", "go mod",
 43  	"make", "cargo build", "cargo test", "npm test", "npm run",
 44  	"python -m pytest", "python -m py_compile",
 45  }
 46  
 47  // shellOperators are characters that chain or redirect commands.
 48  // Any command containing these is never auto-approved.
 49  var shellOperators = []string{"&&", "||", ";", "|", ">", "<", "`", "$(", "${", "&"}
 50  
 51  func isSafeCommand(cmd string, extraSafe []string) bool {
 52  	trimmed := strings.TrimSpace(cmd)
 53  	// Reject commands containing shell operators
 54  	for _, op := range shellOperators {
 55  		if strings.Contains(trimmed, op) {
 56  			return false
 57  		}
 58  	}
 59  	for _, safe := range safeCommands {
 60  		if trimmed == safe || strings.HasPrefix(trimmed, safe+" ") {
 61  			return true
 62  		}
 63  	}
 64  	for _, safe := range extraSafe {
 65  		if trimmed == safe || strings.HasPrefix(trimmed, safe+" ") {
 66  			return true
 67  		}
 68  	}
 69  	return false
 70  }
 71  
 72  func (t *BashTool) Info() agent.ToolInfo {
 73  	return agent.ToolInfo{
 74  		Name:        "bash",
 75  		Description: "Execute a shell command. Use for running scripts, data processing, file management, automation, and system operations. For multi-line Python with embedded quotes or regex, prefer writing a script via file_write and then running `python3 /path/to/script.py` — heredoc+quote nesting is a frequent source of shell-level syntax errors.",
 76  		Parameters: map[string]any{
 77  			"type": "object",
 78  			"properties": map[string]any{
 79  				"command": map[string]any{"type": "string", "description": "Shell command to execute"},
 80  				"timeout": map[string]any{"type": "integer", "description": "Timeout in seconds (default: 120)"},
 81  			},
 82  		},
 83  		Required: []string{"command"},
 84  	}
 85  }
 86  
 87  func (t *BashTool) Run(ctx context.Context, argsJSON string) (agent.ToolResult, error) {
 88  	var args bashArgs
 89  	if err := json.Unmarshal([]byte(argsJSON), &args); err != nil {
 90  		return agent.ToolResult{Content: fmt.Sprintf("invalid arguments: %v", err), IsError: true}, nil
 91  	}
 92  
 93  	// Timeout precedence: per-call args > tool default (from config) > 120s fallback.
 94  	timeout := 120 * time.Second
 95  	if t.DefaultTimeoutSecs > 0 {
 96  		timeout = time.Duration(t.DefaultTimeoutSecs) * time.Second
 97  	}
 98  	if args.Timeout > 0 {
 99  		timeout = time.Duration(args.Timeout) * time.Second
100  	}
101  
102  	ctx, cancel := context.WithTimeout(ctx, timeout)
103  	defer cancel()
104  
105  	cmd := exec.CommandContext(ctx, "sh", "-c", args.Command)
106  	dir := t.CWD
107  	if dir == "" {
108  		dir = cwdctx.FromContext(ctx)
109  	}
110  	// When no CWD is set (neither via tool config nor via session context),
111  	// do NOT let Go's exec package inherit the daemon process's cwd — that
112  	// would leak the `shan daemon start` directory into every scopeless
113  	// request. Run in the OS temp dir instead so the command has no
114  	// project-shaped filesystem around it.
115  	if dir == "" {
116  		dir = os.TempDir()
117  	}
118  	cmd.Dir = dir
119  	if envPairs := collectActivatedSkillEnv(ctx, t.SecretsStore); len(envPairs) > 0 {
120  		cmd.Env = append(os.Environ(), envPairs...)
121  	}
122  	output, err := cmd.CombinedOutput()
123  
124  	result := string(output)
125  	maxOut := t.MaxOutput
126  	if maxOut <= 0 {
127  		maxOut = 30000
128  	}
129  	if r := []rune(result); len(r) > maxOut {
130  		keepHead := maxOut * 3 / 4
131  		keepTail := maxOut / 4
132  		result = string(r[:keepHead]) + "\n\n[... truncated " +
133  			strconv.Itoa(len(r)-maxOut) + " chars ...]\n\n" +
134  			string(r[len(r)-keepTail:])
135  	}
136  
137  	if err != nil {
138  		if ctx.Err() == context.DeadlineExceeded {
139  			timeoutSecs := int(timeout.Seconds())
140  			return agent.TransientError(fmt.Sprintf("command timed out after %ds\n%s", timeoutSecs, result)), nil
141  		}
142  		return agent.ToolResult{
143  			Content: fmt.Sprintf("exit code: %v\n%s", err, result),
144  			IsError: true,
145  		}, nil
146  	}
147  
148  	return agent.ToolResult{Content: result}, nil
149  }
150  
151  func (t *BashTool) RequiresApproval() bool { return true }
152  
153  func (t *BashTool) IsReadOnlyCall(string) bool { return false }
154  
155  func (t *BashTool) IsSafe(command string) bool {
156  	return isSafeCommand(command, t.ExtraSafeCommands)
157  }
158  
159  func (t *BashTool) IsSafeArgs(argsJSON string) bool {
160  	var args bashArgs
161  	if err := json.Unmarshal([]byte(argsJSON), &args); err != nil {
162  		return false
163  	}
164  	return isSafeCommand(args.Command, t.ExtraSafeCommands)
165  }
166  
167  // collectActivatedSkillEnv returns KEY=VALUE pairs for every secret of every
168  // skill activated in the current agent run. Returns nil when no skill has
169  // been activated, no store is configured, or the store has no values.
170  // Called on every bash execution so newly-activated skills become visible
171  // to subsequent commands without restart.
172  func collectActivatedSkillEnv(ctx context.Context, store *skills.SecretsStore) []string {
173  	if store == nil {
174  		return nil
175  	}
176  	set := skills.ActivatedFromContext(ctx)
177  	if set == nil {
178  		return nil
179  	}
180  	names := set.Names()
181  	if len(names) == 0 {
182  		return nil
183  	}
184  	var envPairs []string
185  	for _, name := range names {
186  		for k, v := range store.Get(name) {
187  			envPairs = append(envPairs, k+"="+v)
188  		}
189  	}
190  	return envPairs
191  }