返回 DeepSeek-Reasonix
bash.go
根目录 / internal / tool / builtin / bash.go
1 package builtin
2
3 import (
4 "context"
5 "encoding/json"
6 "errors"
7 "fmt"
8 "io"
9 "os"
10 "os/exec"
11 "path/filepath"
12 "runtime"
13 "strings"
14 "sync"
15 "time"
16
17 "mvdan.cc/sh/v3/syntax"
18
19 "reasonix/internal/i18n"
20 "reasonix/internal/jobs"
21 "reasonix/internal/proc"
22 "reasonix/internal/sandbox"
23 "reasonix/internal/secrets"
24 "reasonix/internal/sessiontemp"
25 "reasonix/internal/shellparse"
26 "reasonix/internal/shellrun"
27 "reasonix/internal/tool"
28 )
29
30 const (
31 bashWaitDelay = 5 * time.Second
32 )
33
34 func init() { tool.RegisterBuiltin(bash{}) }
35
36 var bashShellPATH = cachedBashShellPATH
37
38 var (
39 bashSandboxCommand = sandbox.Command
40 bashSandboxEscapePromptEnabled = func() bool { return runtime.GOOS == "windows" }
41 )
42
43 // cachedBashShellPATH memoizes the login-shell PATH probe per login shell so a
44 // shell isn't spawned on every bash tool call (the probe runs up to three
45 // interactive-login shells with a 2s timeout each). Empty results are cached too,
46 // so a host without a usable login shell doesn't re-probe each command.
47 var (
48 bashPathMu sync.Mutex
49 bashPathCache = map[string]string{}
50 )
51
52 func cachedBashShellPATH(ctx context.Context) string {
53 key := loginShell()
54 bashPathMu.Lock()
55 if v, ok := bashPathCache[key]; ok {
56 bashPathMu.Unlock()
57 return v
58 }
59 bashPathMu.Unlock()
60
61 v := defaultBashShellPATH(ctx)
62
63 bashPathMu.Lock()
64 bashPathCache[key] = v
65 bashPathMu.Unlock()
66 return v
67 }
68
69 // bash runs a shell command. sb, when it enforces, wraps the command in an OS
70 // sandbox; the zero value registered at init runs unconfined and is overridden
71 // per run by ConfineBash. shell is the resolved interpreter (real bash, or
72 // PowerShell on a Windows host without bash); the zero value resolves lazily.
73 // workDir, when non-empty, is the directory the command runs in (cmd.Dir);
74 // empty uses the process cwd. timeout optionally caps foreground commands;
75 // zero or negative means no tool-local cap, while parent context cancellation
76 // still kills the process tree. guard appends a warning to the output of
77 // commands that reference Reasonix's own session stores (see SessionDataGuard).
78 // sessionTemp, when non-nil, supplies the logical-session private temporary
79 // directory shared across Bash calls (see package sessiontemp). A Manager on
80 // the execution context overrides this for sub-agent isolation.
81 type bash struct {
82 sb sandbox.Spec
83 shell sandbox.Shell
84 guard SessionDataGuard
85 workDir string
86 timeout time.Duration
87 // terminal, when non-nil, runs foreground commands in a host-owned terminal
88 // (ACP terminal/*). Only consulted when the local OS sandbox is not
89 // enforcing — a host terminal cannot honor the confinement configuration —
90 // and never for background jobs, which need the local job manager.
91 terminal TerminalRunner
92 sessionTemp *sessiontemp.Manager
93 }
94
95 type bashParams struct {
96 Command string `json:"command"`
97 RunInBackground bool `json:"run_in_background"`
98 PreserveBackgroundProcesses bool `json:"preserve_background_processes"`
99 }
100
101 func (bash) Name() string { return "bash" }
102
103 func (b bash) Description() string {
104 sh := b.resolved()
105 if sh.Kind == sandbox.ShellPowerShell {
106 shellName := "Windows PowerShell"
107 chaining := "';' runs both regardless; 'if ($?) { ... }' is conditional. '&&' and '||' are NOT parsed."
108 if sh.SupportsChaining() {
109 shellName = "PowerShell 7 (pwsh)"
110 chaining = "'&&' and '||' are parsed for conditional chaining; ';' runs both regardless."
111 }
112 return fmt.Sprintf("Execute a command in the shell and return combined stdout/stderr. "+
113 "NOTE: bash is not available on this host — commands run under %s, so write PowerShell, not bash:\n"+
114 " - chaining: %s\n"+
115 " - redirect/vars: $null not /dev/null; $env:VAR not $VAR; '2>$null' drops stderr.\n"+
116 " - file ops: Get-ChildItem (ls), Get-Content (cat), Remove-Item -Recurse -Force (rm -rf), Copy-Item (cp), Select-String (grep).\n"+
117 " - no head/tail/which/touch: use Select-Object -First/-Last N, (Get-Command x).Source, New-Item.\n"+
118 " - multi-line text to a native exe (e.g. git commit -m): use a single-quoted here-string @'...'@ (closing '@ at column 0)."+
119 bashToolSteer, shellName, chaining)
120 }
121 return "Execute a command in the shell and return combined stdout/stderr." + bashToolSteer
122 }
123
124 // bashToolSteer points the model at the cross-platform built-in tools instead of
125 // shell utilities, so it doesn't reach for grep/cat/ls/find (absent or different
126 // on native Windows) when a native tool already does the job everywhere.
127 const bashToolSteer = " Use for builds, tests, git, package managers, etc. To search/read/list/edit/move files, prefer the dedicated tools (grep, read_file, ls, glob, edit_file, move_file) over shell grep/cat/ls/find/sed/mv/Move-Item — they behave identically on every OS. For symbol search or architecture questions, prefer LSP/read tools and targeted grep before shell commands."
128
129 // resolved returns the bound shell, resolving lazily for the zero-value instance
130 // (e.g. a registry that never went through ConfineBash).
131 func (b bash) resolved() sandbox.Shell {
132 if b.shell.Path != "" {
133 return b.shell
134 }
135 if b.sb.Shell.Path != "" {
136 return b.sb.Shell
137 }
138 return sandbox.ResolveShell("", "", nil)
139 }
140
141 func (bash) Schema() json.RawMessage {
142 return json.RawMessage(`{"type":"object","properties":{"command":{"type":"string","description":"Shell command to execute"},"run_in_background":{"type":"boolean","description":"Run detached: returns a job id immediately and keeps running across turns (no foreground timeout). Read new output with bash_output, wait with wait, stop it with kill_shell. Use for long-running commands like servers, watchers, or builds you don't need to block on."},"preserve_background_processes":{"type":"boolean","description":"After the shell command exits normally, keep any process-group members it intentionally left behind. Use only for deliberate daemonization, browser/GUI/session launchers such as playwright-cli open, or nohup/disown/setsid; cancellation and timeouts still kill the process group."}},"required":["command"]}`)
143 }
144
145 // ReadOnly is false: bash's effect cannot be inferred from args (rm, curl,
146 // git commit, etc. are all reachable). Conservative even when a particular
147 // command happens to be read-only — the agent batch decision can't tell.
148 func (bash) ReadOnly() bool { return false }
149
150 // SnipHint keeps both ends of command output equally: a build/test run's
151 // failure usually sits at the tail while the command and early context sit at
152 // the head, so neither end can be favored.
153 func (bash) SnipHint() tool.SnipHint {
154 return tool.SnipHint{Head: 40, Tail: 40, HeadChars: 8000, TailChars: 8000}
155 }
156
157 // Execute is the compatibility wrapper: all structured metadata is produced by
158 // ExecuteDetailed and discarded here so plugin/hook callers keep the old shape.
159 func (b bash) Execute(ctx context.Context, args json.RawMessage) (string, error) {
160 res, err := b.ExecuteDetailed(ctx, args)
161 return res.Output, err
162 }
163
164 // ExecutionDescriptor returns shell identity for the bound interpreter without
165 // launching a process. Invalid args still yield a descriptor from the shell.
166 func (b bash) ExecutionDescriptor(args json.RawMessage) *tool.ShellExecution {
167 return shellrun.DescriptorFromShell(b.resolved())
168 }
169
170 // ExecuteDetailed runs the shell command and returns structured execution
171 // metadata for host UI / session persistence. Provider-visible output stays in
172 // DetailedResult.Output; metadata never enters tool schemas.
173 func (b bash) ExecuteDetailed(ctx context.Context, args json.RawMessage) (tool.DetailedResult, error) {
174 start := time.Now()
175 ex := shellrun.DescriptorFromShell(b.resolved())
176 ex.State = tool.ShellStateRunning
177 ex.MutationRisk = tool.ShellMutationUnknown
178 ex.Verification = tool.ShellVerificationNotVerification
179
180 var p bashParams
181 if err := json.Unmarshal(args, &p); err != nil {
182 ex.State = tool.ShellStateNotRun
183 ex.FailurePhase = tool.ShellPhasePreflight
184 ex.MutationRisk = tool.ShellMutationNotStarted
185 ex.DurationMs = time.Since(start).Milliseconds()
186 return tool.DetailedResult{Execution: ex}, fmt.Errorf("invalid args: %w", err)
187 }
188 if p.Command == "" {
189 ex.State = tool.ShellStateNotRun
190 ex.FailurePhase = tool.ShellPhasePreflight
191 ex.MutationRisk = tool.ShellMutationNotStarted
192 ex.DurationMs = time.Since(start).Milliseconds()
193 return tool.DetailedResult{Execution: ex}, fmt.Errorf("command is required")
194 }
195
196 sh := b.resolved()
197 if !sh.SupportsChaining() && (hasUnquotedSeq(p.Command, "&&") || hasUnquotedSeq(p.Command, "||")) {
198 ex.State = tool.ShellStateNotRun
199 ex.FailurePhase = tool.ShellPhasePreflight
200 ex.MutationRisk = tool.ShellMutationNotStarted
201 ex.DurationMs = time.Since(start).Milliseconds()
202 return tool.DetailedResult{Execution: ex}, fmt.Errorf("this shell is Windows PowerShell, which does not parse '&&' or '||'. " +
203 "Sequence with ';' (both run regardless of the first's result), use 'if ($?) { ... }' for " +
204 "conditional chaining, or issue the commands as separate calls")
205 }
206
207 // Pin the session-private temporary generation before any launch path so
208 // foreground, background, and host-terminal runs share one directory, and
209 // so a failed start still releases the lease.
210 prepared, lease, err := b.prepareLaunch(ctx, sh, p.Command, args)
211 if err != nil {
212 ex.State = tool.ShellStateNotRun
213 ex.FailurePhase = tool.ShellPhaseAuthorization
214 if strings.Contains(err.Error(), "session temporary") {
215 ex.FailurePhase = tool.ShellPhaseLaunch
216 }
217 ex.MutationRisk = tool.ShellMutationNotStarted
218 ex.DurationMs = time.Since(start).Milliseconds()
219 return tool.DetailedResult{Execution: ex}, err
220 }
221 // Background jobs take ownership of the lease until the job goroutine ends.
222 // Foreground/terminal paths release after the process exits.
223 releaseLease := true
224 defer func() {
225 if releaseLease && lease != nil {
226 lease.Release()
227 }
228 }()
229
230 // A host-owned terminal runs the command where the user watches it live.
231 // Never when the OS sandbox is enforcing (the host cannot honor the local
232 // confinement config), never when [secrets].filter_subprocess_env is on
233 // (the host terminal spawns with its own unfiltered environment, which
234 // would leak the credentials the user asked to strip), and never for
235 // background jobs. ok=false falls back to local execution unchanged.
236 if b.terminal != nil && !p.RunInBackground && !b.sb.Enforce() && !secrets.FilterSubprocessEnv() {
237 envMap := sandbox.SessionTempEnvMap(prepared.SessionTemp, prepared.LinuxSandboxed)
238 if out, ok, termErr := b.terminal.RunCommand(ctx, p.Command, b.workDir, b.timeout, envMap); ok {
239 out = appendSessionDataHint(out, b.guard.CommandHint(b.workDir, p.Command))
240 applyTerminalResult(ex, termErr)
241 ex.DurationMs = time.Since(start).Milliseconds()
242 return tool.DetailedResult{Output: out, Execution: ex}, termErr
243 }
244 }
245
246 argv, wrapped := prepared.Argv, prepared.Wrapped
247 cmdEnv := applyEnvOverrides(bashCommandEnv(ctx), prepared.EnvOverrides)
248
249 if p.RunInBackground {
250 jm, ok := jobs.FromContext(ctx)
251 if !ok {
252 ex.State = tool.ShellStateNotRun
253 ex.FailurePhase = tool.ShellPhaseDependency
254 ex.MutationRisk = tool.ShellMutationNotStarted
255 ex.DurationMs = time.Since(start).Milliseconds()
256 return tool.DetailedResult{Execution: ex}, fmt.Errorf("background execution is not available in this context")
257 }
258 workDir := b.workDir
259 // Transfer lease ownership to the job closure; it releases when the
260 // background process ends (including start failures inside the job).
261 jobLease := lease
262 releaseLease = false
263 // The job runs under the manager's session context (no foreground timeout), so it
264 // survives this turn; its combined output streams to the job buffer.
265 job := jm.StartForSession(jobs.SessionFromContext(ctx), "bash", commandPreview(p.Command), func(jobCtx context.Context, out io.Writer) (string, error) {
266 if jobLease != nil {
267 defer jobLease.Release()
268 }
269 cmd := exec.CommandContext(jobCtx, argv[0], argv[1:]...)
270 cmd.Dir = workDir
271 cmd.Env = cmdEnv
272 cmd.WaitDelay = bashWaitDelay
273 cmd.Stdout = out
274 cmd.Stderr = out
275 tracked, runErr := runShellProcess(jobCtx, cmd, sh, p.Command, shouldTrackShellProcess(wrapped, sh, p.Command, p.PreserveBackgroundProcesses))
276 if shouldReapAfterRun(jobCtx, sh, p.Command, p.PreserveBackgroundProcesses) {
277 reapShellProcess(cmd, tracked) // reap process-group stragglers the job left running (#3702)
278 }
279 return "", normalizeBashRunError(jobCtx, runErr, p.PreserveBackgroundProcesses)
280 })
281 msg := fmt.Sprintf("Started background job %q. It keeps running across turns; read new output with bash_output(job_id=%q), wait for it with wait, or stop it with kill_shell(job_id=%q).", job.ID, job.ID, job.ID)
282 // Background start is not a completed execution: completion is reported
283 // later by bash_output/wait. Do not masquerade as success with exit 0.
284 ex.State = tool.ShellStateBackgroundStarted
285 ex.MutationRisk = tool.ShellMutationUnknown
286 ex.DurationMs = time.Since(start).Milliseconds()
287 return tool.DetailedResult{
288 Output: appendSessionDataHint(msg, b.guard.CommandHint(b.workDir, p.Command)),
289 Execution: ex,
290 }, nil
291 }
292
293 out, runEx, err := b.runForegroundDetailed(ctx, p, sh, argv, wrapped, cmdEnv)
294 mergeRunInto(ex, runEx)
295 ex.DurationMs = time.Since(start).Milliseconds()
296 return tool.DetailedResult{
297 Output: appendSessionDataHint(out, b.guard.CommandHint(b.workDir, p.Command)),
298 Execution: ex,
299 }, err
300 }
301
302 func applyTerminalResult(ex *tool.ShellExecution, err error) {
303 if ex == nil {
304 return
305 }
306 if err == nil {
307 ex.State = tool.ShellStateCompleted
308 ex.ExitCode = tool.IntPtr(0)
309 ex.MutationRisk = tool.ShellMutationMayHaveCompleted
310 return
311 }
312 if errors.Is(err, context.Canceled) {
313 ex.State = tool.ShellStateCancelled
314 ex.FailurePhase = tool.ShellPhaseCancellation
315 ex.MutationRisk = tool.ShellMutationMayBePartial
316 return
317 }
318 var timeoutErr TerminalTimeoutError
319 if errors.As(err, &timeoutErr) || errors.Is(err, context.DeadlineExceeded) {
320 ex.State = tool.ShellStateTimedOut
321 ex.FailurePhase = tool.ShellPhaseTimeout
322 ex.MutationRisk = tool.ShellMutationMayBePartial
323 return
324 }
325 var exitErr TerminalExitError
326 if errors.As(err, &exitErr) {
327 code := exitErr.Code
328 ex.ExitCode = &code
329 ex.State = tool.ShellStateFailed
330 ex.FailurePhase = tool.ShellPhaseExecution
331 ex.MutationRisk = tool.ShellMutationMayBePartial
332 return
333 }
334 // Legacy plain errors from older host runners.
335 ex.State = tool.ShellStateFailed
336 ex.FailurePhase = tool.ShellPhaseExecution
337 ex.MutationRisk = tool.ShellMutationMayBePartial
338 }
339
340 func mergeRunInto(dst *tool.ShellExecution, src *tool.ShellExecution) {
341 if dst == nil || src == nil {
342 return
343 }
344 dst.State = src.State
345 dst.FailurePhase = src.FailurePhase
346 dst.ExitCode = src.ExitCode
347 dst.OutputTail = src.OutputTail
348 if src.MutationRisk != "" {
349 dst.MutationRisk = src.MutationRisk
350 }
351 }
352
353 // prepareLaunch acquires a session-temp lease (when a Manager is available),
354 // builds the sandboxed argv, and applies sandbox-escape approval. The caller
355 // owns the returned lease and must Release it after the process exits.
356 func (b bash) prepareLaunch(ctx context.Context, sh sandbox.Shell, command string, rawArgs json.RawMessage) (sandbox.Prepared, *sessiontemp.Lease, error) {
357 var lease *sessiontemp.Lease
358 sessionDir := ""
359 if m := b.sessionTempManager(ctx); m != nil {
360 l, err := m.Acquire()
361 if err != nil {
362 return sandbox.Prepared{}, nil, fmt.Errorf("session temporary directory: %w", err)
363 }
364 lease = l
365 sessionDir = l.Dir()
366 }
367
368 // bashSandboxCommand is injectable for tests; production points at
369 // sandbox.Command. Attach SessionTemp so Linux bwrap binds the private dir.
370 spec := b.sb
371 spec.SessionTemp = sessionDir
372 argv, wrapped := bashSandboxCommand(spec, sh, command)
373 linuxSB := wrapped && sessionDir != "" && runtime.GOOS == "linux"
374 prepared := sandbox.Prepared{
375 Argv: argv,
376 Wrapped: wrapped,
377 SessionTemp: sessionDir,
378 EnvOverrides: sandbox.SessionTempEnv(sessionDir, linuxSB),
379 LinuxSandboxed: linuxSB,
380 }
381
382 if b.sb.Enforce() && bashSandboxEscapeSessionAllowed(ctx, command, rawArgs) {
383 prepared.Argv = unconfinedShellArgv(sh, command)
384 prepared.Wrapped = false
385 // Escaped commands still inherit private temp env vars pointing at the
386 // host private directory (no virtual /tmp mapping).
387 prepared.LinuxSandboxed = false
388 prepared.EnvOverrides = sandbox.SessionTempEnv(sessionDir, false)
389 } else if b.sb.Enforce() && !prepared.Wrapped {
390 allow, reason, err := approveBashSandboxEscape(ctx, command, rawArgs, i18n.M.SandboxEscapeWrapReason)
391 if err != nil {
392 if lease != nil {
393 lease.Release()
394 }
395 return sandbox.Prepared{}, nil, err
396 }
397 if !allow {
398 if lease != nil {
399 lease.Release()
400 }
401 if reason != "" {
402 return sandbox.Prepared{}, nil, fmt.Errorf("%s", reason)
403 }
404 return sandbox.Prepared{}, nil, fmt.Errorf("%s", sandbox.UnavailableMessage())
405 }
406 prepared.Argv = unconfinedShellArgv(sh, command)
407 prepared.Wrapped = false
408 prepared.LinuxSandboxed = false
409 prepared.EnvOverrides = sandbox.SessionTempEnv(sessionDir, false)
410 }
411 return prepared, lease, nil
412 }
413
414 func (b bash) sessionTempManager(ctx context.Context) *sessiontemp.Manager {
415 if m := sessiontemp.FromContext(ctx); m != nil {
416 return m
417 }
418 return b.sessionTemp
419 }
420
421 func applyEnvOverrides(env, overrides []string) []string {
422 for _, kv := range overrides {
423 key, value, ok := strings.Cut(kv, "=")
424 if !ok || key == "" {
425 continue
426 }
427 env = setEnvValue(env, key, value)
428 }
429 return env
430 }
431
432 // appendSessionDataHint appends the session-data guard warning to command
433 // output; with no output the hint stands alone. An empty hint is a no-op.
434 func appendSessionDataHint(out, hint string) string {
435 if hint == "" {
436 return out
437 }
438 if strings.TrimSpace(out) == "" {
439 return hint
440 }
441 return out + "\n\n" + hint
442 }
443
444 func unconfinedShellArgv(sh sandbox.Shell, command string) []string {
445 argv, _ := sandbox.Command(sandbox.Spec{}, sh, command)
446 return argv
447 }
448
449 func approveBashSandboxEscape(ctx context.Context, command string, args json.RawMessage, reason string) (bool, string, error) {
450 if !bashSandboxEscapePromptEnabled() {
451 return false, "", nil
452 }
453 approver, ok := sandbox.EscapeApproverFrom(ctx)
454 if !ok {
455 return false, "", nil
456 }
457 return approver.ApproveSandboxEscape(ctx, sandbox.EscapeRequest{
458 Command: command,
459 Args: append(json.RawMessage(nil), args...),
460 Reason: reason,
461 })
462 }
463
464 func bashSandboxEscapeSessionAllowed(ctx context.Context, command string, args json.RawMessage) bool {
465 if !bashSandboxEscapePromptEnabled() {
466 return false
467 }
468 approver, ok := sandbox.EscapeApproverFrom(ctx)
469 if !ok {
470 return false
471 }
472 checker, ok := approver.(sandbox.EscapeSessionChecker)
473 if !ok {
474 return false
475 }
476 return checker.SandboxEscapeSessionAllowed(ctx, sandbox.EscapeRequest{
477 Command: command,
478 Args: append(json.RawMessage(nil), args...),
479 Reason: i18n.M.SandboxEscapeRuntimeReason,
480 })
481 }
482
483 // runForegroundDetailed uses the shared shellrun collector so model bash and
484 // user !command share exit-code / phase / output-tail classification.
485 func (b bash) runForegroundDetailed(ctx context.Context, p bashParams, sh sandbox.Shell, argv []string, wrapped bool, cmdEnv []string) (string, *tool.ShellExecution, error) {
486 ex := shellrun.DescriptorFromShell(sh)
487 var progress func(string)
488 if emit, ok := tool.ProgressFrom(ctx); ok {
489 progress = emit
490 }
491 track := shouldTrackShellProcess(wrapped, sh, p.Command, p.PreserveBackgroundProcesses)
492 res := shellrun.RunForeground(ctx, shellrun.Request{
493 Argv: argv,
494 Dir: b.workDir,
495 Env: cmdEnv,
496 Timeout: b.foregroundTimeout(),
497 WaitDelay: bashWaitDelay,
498 CommandPreview: commandPreview(p.Command),
499 ShellKind: sh.Kind.String(),
500 ShellPath: sh.Path,
501 Source: "bash_tool",
502 Track: track,
503 PreserveWaitDelay: p.PreserveBackgroundProcesses,
504 Progress: progress,
505 })
506 // A foreground command that spawned a lingering child (e.g. `bazel run`'s
507 // server) leaves it in the process group; Wait only reaped the shell leader.
508 // Kill the group so those don't accumulate into an OOM (#3702). On cancel/
509 // timeout the command's Cancel path already did this; this covers normal exit.
510 // shellrun owns the tool-local timeout context, so treat timed_out/cancelled
511 // as ctx.Err()!=nil for the reap decision.
512 reapCtx := ctx
513 if res.State == tool.ShellStateTimedOut || res.State == tool.ShellStateCancelled || ctx.Err() != nil {
514 // Force reap on forced stops even when preserve_background_processes is set.
515 reapShellProcess(res.Cmd, res.Tracked)
516 } else if shouldReapAfterRun(reapCtx, sh, p.Command, p.PreserveBackgroundProcesses) {
517 reapShellProcess(res.Cmd, res.Tracked)
518 }
519
520 ex.State = res.State
521 ex.FailurePhase = res.FailurePhase
522 ex.ExitCode = res.ExitCode
523 ex.OutputTail = res.OutputTail
524 switch res.State {
525 case tool.ShellStateCompleted:
526 ex.MutationRisk = tool.ShellMutationMayHaveCompleted
527 case tool.ShellStateNotRun:
528 ex.MutationRisk = tool.ShellMutationNotStarted
529 case tool.ShellStateFailed:
530 if res.FailurePhase == tool.ShellPhaseLaunch || res.FailurePhase == tool.ShellPhasePreflight {
531 ex.MutationRisk = tool.ShellMutationNotStarted
532 } else {
533 ex.MutationRisk = tool.ShellMutationMayBePartial
534 }
535 case tool.ShellStateTimedOut, tool.ShellStateCancelled:
536 ex.MutationRisk = tool.ShellMutationMayBePartial
537 default:
538 ex.MutationRisk = tool.ShellMutationUnknown
539 }
540 return res.Combined, ex, res.Err
541 }
542
543 func normalizeBashRunError(ctx context.Context, err error, preserveBackgroundProcesses bool) error {
544 if preserveBackgroundProcesses && ctx.Err() == nil && errors.Is(err, exec.ErrWaitDelay) {
545 return nil
546 }
547 return err
548 }
549
550 func shouldReapAfterRun(ctx context.Context, sh sandbox.Shell, command string, preserveBackgroundProcesses bool) bool {
551 if ctx.Err() != nil {
552 return true
553 }
554 if preserveBackgroundProcesses {
555 return false
556 }
557 return sh.Kind != sandbox.ShellBash || !hasExplicitBackgroundKeepalive(command)
558 }
559
560 // hasExplicitBackgroundKeepalive detects common shell-level daemonization intent
561 // without letting a plain "cmd &" bypass #3702's stray process cleanup.
562 func hasExplicitBackgroundKeepalive(command string) bool {
563 file, err := shellparse.ParseBash(command)
564 if err != nil {
565 return false
566 }
567
568 hasBackground := false
569 hasKeepaliveCommand := false
570 syntax.Walk(file, func(node syntax.Node) bool {
571 switch n := node.(type) {
572 case *syntax.Stmt:
573 if n.Background {
574 hasBackground = true
575 }
576 case *syntax.CallExpr:
577 name, ok := staticShellCallName(n)
578 if !ok {
579 break
580 }
581 switch name {
582 case "disown", "nohup", "setsid":
583 hasKeepaliveCommand = true
584 }
585 }
586 return !(hasBackground && hasKeepaliveCommand)
587 })
588 return hasBackground && hasKeepaliveCommand
589 }
590
591 func (b bash) foregroundTimeout() time.Duration {
592 if b.timeout <= 0 {
593 return 0
594 }
595 return b.timeout
596 }
597
598 func shouldTrackShellProcess(wrapped bool, sh sandbox.Shell, command string, preserveBackgroundProcesses bool) bool {
599 if preserveBackgroundProcesses {
600 return false
601 }
602 if runtime.GOOS == "windows" && wrapped {
603 return false
604 }
605 return sh.Kind != sandbox.ShellBash || !hasExplicitBackgroundKeepalive(command)
606 }
607
608 func runShellProcess(ctx context.Context, cmd *exec.Cmd, sh sandbox.Shell, command string, track bool) (*proc.TrackedCommand, error) {
609 return proc.RunCommand(ctx, cmd, proc.RunOptions{
610 Track: track,
611 CancelWaitGrace: bashWaitDelay + time.Second,
612 Source: "bash_tool",
613 ShellKind: sh.Kind.String(),
614 ShellPath: sh.Path,
615 CommandPreview: commandPreview(command),
616 })
617 }
618
619 func reapShellProcess(cmd *exec.Cmd, tracked *proc.TrackedCommand) {
620 if tracked != nil {
621 tracked.Kill()
622 return
623 }
624 proc.KillTree(cmd)
625 }
626
627 // hasUnquotedSeq reports whether seq appears in s outside any single- or
628 // double-quoted span, so a literal "a && b" string argument doesn't trip the
629 // PowerShell chaining guard.
630 func hasUnquotedSeq(s, seq string) bool {
631 var quote byte
632 for i := 0; i < len(s); i++ {
633 c := s[i]
634 if quote != 0 {
635 if c == quote {
636 quote = 0
637 }
638 continue
639 }
640 if c == '\'' || c == '"' {
641 quote = c
642 continue
643 }
644 if strings.HasPrefix(s[i:], seq) {
645 return true
646 }
647 }
648 return false
649 }
650
651 func staticShellCallName(call *syntax.CallExpr) (string, bool) {
652 for _, arg := range call.Args {
653 word, ok := shellparse.StaticWord(arg)
654 if !ok {
655 return "", false
656 }
657 if shellparse.IsAssignment(word) {
658 continue
659 }
660 base := shellparse.WordBase(word)
661 if base == "command" || base == "env" {
662 continue
663 }
664 return base, true
665 }
666 return "", false
667 }
668
669 // commandPreview is a short single-line label for a background bash job, surfaced
670 // in the status bar and completion notices.
671 func commandPreview(cmd string) string {
672 cmd = strings.TrimSpace(strings.ReplaceAll(cmd, "\n", " "))
673 const max = 48
674 r := []rune(cmd)
675 if len(r) > max {
676 return string(r[:max]) + "…"
677 }
678 return cmd
679 }
680
681 func bashCommandEnv(ctx context.Context) []string {
682 env := secrets.ProcessEnv()
683 if runtime.GOOS == "windows" {
684 return env
685 }
686 currentPath, _ := envValue(env, "PATH")
687 if shellPath := strings.TrimSpace(bashShellPATH(ctx)); shellPath != "" {
688 if merged := mergePathLists(shellPath, currentPath); merged != currentPath {
689 env = setEnvValue(env, "PATH", merged)
690 }
691 }
692 return env
693 }
694
695 func defaultBashShellPATH(ctx context.Context) string {
696 if runtime.GOOS == "windows" {
697 return ""
698 }
699 shell := loginShell()
700 if shell == "" {
701 return ""
702 }
703 const marker = "__REASONIX_BASH_PATH__="
704 script := "printf '\\n" + marker + "%s\\n' \"$PATH\""
705 for _, args := range [][]string{
706 {"-l", "-i", "-c", script},
707 {"-l", "-c", script},
708 {"-c", script},
709 } {
710 out := runShellPATHCommand(ctx, shell, args)
711 if path := parseShellPATH(out, marker); path != "" {
712 return path
713 }
714 }
715 return ""
716 }
717
718 func loginShell() string {
719 if shell := strings.TrimSpace(os.Getenv("SHELL")); shell != "" {
720 if hasPathSeparator(shell) {
721 if isExecutableFile(shell) {
722 return shell
723 }
724 } else if p, err := exec.LookPath(shell); err == nil {
725 return p
726 }
727 }
728 for _, shell := range []string{"/bin/zsh", "/bin/bash", "/bin/sh"} {
729 if isExecutableFile(shell) {
730 return shell
731 }
732 }
733 return ""
734 }
735
736 func runShellPATHCommand(parent context.Context, shell string, args []string) []byte {
737 ctx, cancel := context.WithTimeout(parent, 2*time.Second)
738 defer cancel()
739 cmd := exec.CommandContext(ctx, shell, args...)
740 // Explicit env so the login-shell probe honors [secrets]
741 // filter_subprocess_env instead of inheriting the full environment.
742 cmd.Env = secrets.ProcessEnv()
743 proc.PrepareShellPATHProbe(cmd)
744 cmd.Stdin = strings.NewReader("")
745 out, _ := cmd.CombinedOutput()
746 return out
747 }
748
749 func parseShellPATH(out []byte, marker string) string {
750 lines := strings.Split(strings.ReplaceAll(string(out), "\r\n", "\n"), "\n")
751 for i := len(lines) - 1; i >= 0; i-- {
752 if strings.HasPrefix(lines[i], marker) {
753 return strings.TrimSpace(strings.TrimPrefix(lines[i], marker))
754 }
755 }
756 return ""
757 }
758
759 func hasPathSeparator(s string) bool {
760 return strings.ContainsAny(s, `/\`)
761 }
762
763 func isExecutableFile(path string) bool {
764 info, err := os.Stat(path)
765 if err != nil || info.IsDir() {
766 return false
767 }
768 return info.Mode().Perm()&0o111 != 0
769 }
770
771 func setEnvValue(env []string, key, value string) []string {
772 out := make([]string, 0, len(env)+1)
773 replaced := false
774 for _, kv := range env {
775 k, _, ok := strings.Cut(kv, "=")
776 if ok && envKeyEqual(k, key) {
777 if !replaced {
778 out = append(out, key+"="+value)
779 replaced = true
780 }
781 continue
782 }
783 out = append(out, kv)
784 }
785 if !replaced {
786 out = append(out, key+"="+value)
787 }
788 return out
789 }
790
791 func envValue(env []string, key string) (string, bool) {
792 for i := len(env) - 1; i >= 0; i-- {
793 k, v, ok := strings.Cut(env[i], "=")
794 if ok && envKeyEqual(k, key) {
795 return v, true
796 }
797 }
798 return "", false
799 }
800
801 func envKeyEqual(a, b string) bool {
802 if runtime.GOOS == "windows" {
803 return strings.EqualFold(a, b)
804 }
805 return a == b
806 }
807
808 func mergePathLists(primary, secondary string) string {
809 var out []string
810 seen := map[string]bool{}
811 add := func(path string) {
812 for _, part := range filepath.SplitList(path) {
813 part = strings.TrimSpace(part)
814 if part == "" || seen[part] {
815 continue
816 }
817 seen[part] = true
818 out = append(out, part)
819 }
820 }
821 add(primary)
822 add(secondary)
823 return strings.Join(out, string(os.PathListSeparator))
824 }
825
825 lines GO