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 }