package builtin import ( "bytes" "context" "encoding/json" "errors" "fmt" "io" "os" "os/exec" "path/filepath" "runtime" "strings" "sync" "time" "mvdan.cc/sh/v3/syntax" "reasonix/internal/i18n" "reasonix/internal/jobs" "reasonix/internal/proc" "reasonix/internal/sandbox" "reasonix/internal/secrets" "reasonix/internal/shellparse" "reasonix/internal/tool" ) const ( bashWaitDelay = 5 * time.Second // windowsBackgroundSandboxLockWait is the Windows sandbox root-lock wait // budget for background jobs. A detached job blocks nobody while it queues, // so it keeps the patient wait; a foreground command uses the sandbox's // short default and fails fast with the lock holder named instead of // hanging the whole turn. windowsBackgroundSandboxLockWait = 10 * time.Minute ) var errBashTimeout = errors.New("bash foreground timeout") func init() { tool.RegisterBuiltin(bash{}) } var bashShellPATH = cachedBashShellPATH var ( bashSandboxCommand = sandbox.Command bashSandboxEscapePromptEnabled = func() bool { return runtime.GOOS == "windows" } bashWindowsSandboxRuntimeFailure = isWindowsSandboxRuntimeFailure ) // cachedBashShellPATH memoizes the login-shell PATH probe per login shell so a // shell isn't spawned on every bash tool call (the probe runs up to three // interactive-login shells with a 2s timeout each). Empty results are cached too, // so a host without a usable login shell doesn't re-probe each command. var ( bashPathMu sync.Mutex bashPathCache = map[string]string{} ) func cachedBashShellPATH(ctx context.Context) string { key := loginShell() bashPathMu.Lock() if v, ok := bashPathCache[key]; ok { bashPathMu.Unlock() return v } bashPathMu.Unlock() v := defaultBashShellPATH(ctx) bashPathMu.Lock() bashPathCache[key] = v bashPathMu.Unlock() return v } // bash runs a shell command. sb, when it enforces, wraps the command in an OS // sandbox; the zero value registered at init runs unconfined and is overridden // per run by ConfineBash. shell is the resolved interpreter (real bash, or // PowerShell on a Windows host without bash); the zero value resolves lazily. // workDir, when non-empty, is the directory the command runs in (cmd.Dir); // empty uses the process cwd. timeout optionally caps foreground commands; // zero or negative means no tool-local cap, while parent context cancellation // still kills the process tree. guard appends a warning to the output of // commands that reference Reasonix's own session stores (see SessionDataGuard). type bash struct { sb sandbox.Spec shell sandbox.Shell guard SessionDataGuard workDir string timeout time.Duration // terminal, when non-nil, runs foreground commands in a host-owned terminal // (ACP terminal/*). Only consulted when the local OS sandbox is not // enforcing — a host terminal cannot honor the confinement configuration — // and never for background jobs, which need the local job manager. terminal TerminalRunner } type bashParams struct { Command string `json:"command"` RunInBackground bool `json:"run_in_background"` PreserveBackgroundProcesses bool `json:"preserve_background_processes"` } func (bash) Name() string { return "bash" } func (b bash) Description() string { sh := b.resolved() if sh.Kind == sandbox.ShellPowerShell { shellName := "Windows PowerShell" chaining := "';' runs both regardless; 'if ($?) { ... }' is conditional. '&&' and '||' are NOT parsed." if sh.SupportsChaining() { shellName = "PowerShell 7 (pwsh)" chaining = "'&&' and '||' are parsed for conditional chaining; ';' runs both regardless." } return fmt.Sprintf("Execute a command in the shell and return combined stdout/stderr. "+ "NOTE: bash is not available on this host — commands run under %s, so write PowerShell, not bash:\n"+ " - chaining: %s\n"+ " - redirect/vars: $null not /dev/null; $env:VAR not $VAR; '2>$null' drops stderr.\n"+ " - file ops: Get-ChildItem (ls), Get-Content (cat), Remove-Item -Recurse -Force (rm -rf), Copy-Item (cp), Select-String (grep).\n"+ " - no head/tail/which/touch: use Select-Object -First/-Last N, (Get-Command x).Source, New-Item.\n"+ " - multi-line text to a native exe (e.g. git commit -m): use a single-quoted here-string @'...'@ (closing '@ at column 0)."+ bashToolSteer, shellName, chaining) } return "Execute a command in the shell and return combined stdout/stderr." + bashToolSteer } // bashToolSteer points the model at the cross-platform built-in tools instead of // shell utilities, so it doesn't reach for grep/cat/ls/find (absent or different // on native Windows) when a native tool already does the job everywhere. 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." // resolved returns the bound shell, resolving lazily for the zero-value instance // (e.g. a registry that never went through ConfineBash). func (b bash) resolved() sandbox.Shell { if b.shell.Path != "" { return b.shell } if b.sb.Shell.Path != "" { return b.sb.Shell } return sandbox.ResolveShell("", "", nil) } func (bash) Schema() json.RawMessage { 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"]}`) } // ReadOnly is false: bash's effect cannot be inferred from args (rm, curl, // git commit, etc. are all reachable). Conservative even when a particular // command happens to be read-only — the agent batch decision can't tell. func (bash) ReadOnly() bool { return false } // SnipHint keeps both ends of command output equally: a build/test run's // failure usually sits at the tail while the command and early context sit at // the head, so neither end can be favored. func (bash) SnipHint() tool.SnipHint { return tool.SnipHint{Head: 40, Tail: 40, HeadChars: 8000, TailChars: 8000} } func (b bash) Execute(ctx context.Context, args json.RawMessage) (string, error) { var p bashParams if err := json.Unmarshal(args, &p); err != nil { return "", fmt.Errorf("invalid args: %w", err) } if p.Command == "" { return "", fmt.Errorf("command is required") } sh := b.resolved() if !sh.SupportsChaining() && (hasUnquotedSeq(p.Command, "&&") || hasUnquotedSeq(p.Command, "||")) { return "", fmt.Errorf("this shell is Windows PowerShell, which does not parse '&&' or '||'. " + "Sequence with ';' (both run regardless of the first's result), use 'if ($?) { ... }' for " + "conditional chaining, or issue the commands as separate calls") } // A host-owned terminal runs the command where the user watches it live. // Never when the OS sandbox is enforcing (the host cannot honor the local // confinement config), never when [secrets].filter_subprocess_env is on // (the host terminal spawns with its own unfiltered environment, which // would leak the credentials the user asked to strip), and never for // background jobs. ok=false falls back to local execution unchanged. if b.terminal != nil && !p.RunInBackground && !b.sb.Enforce() && !secrets.FilterSubprocessEnv() { if out, ok, err := b.terminal.RunCommand(ctx, p.Command, b.workDir, b.timeout); ok { return appendSessionDataHint(out, b.guard.CommandHint(b.workDir, p.Command)), err } } // Wrap in the OS sandbox when configured; otherwise argv is just the shell. sbSpec := b.sb if p.RunInBackground { sbSpec.WindowsLockWait = windowsBackgroundSandboxLockWait } argv, wrapped := bashSandboxCommand(sbSpec, sh, p.Command) if b.sb.Enforce() && bashSandboxEscapeSessionAllowed(ctx, p.Command, args) { argv = unconfinedShellArgv(sh, p.Command) wrapped = false } else if b.sb.Enforce() && !wrapped { allow, reason, err := approveBashSandboxEscape(ctx, p.Command, args, i18n.M.SandboxEscapeWrapReason) if err != nil { return "", err } if !allow { if reason != "" { return "", fmt.Errorf("%s", reason) } return "", fmt.Errorf("%s", sandbox.UnavailableMessage()) } argv = unconfinedShellArgv(sh, p.Command) } cmdEnv := bashCommandEnv(ctx) if p.RunInBackground { jm, ok := jobs.FromContext(ctx) if !ok { return "", fmt.Errorf("background execution is not available in this context") } workDir := b.workDir // The job runs under the manager's session context (no foreground timeout), so it // survives this turn; its combined output streams to the job buffer. job := jm.StartForSession(jobs.SessionFromContext(ctx), "bash", commandPreview(p.Command), func(jobCtx context.Context, out io.Writer) (string, error) { cmd := exec.CommandContext(jobCtx, argv[0], argv[1:]...) cmd.Dir = workDir cmd.Env = cmdEnv cmd.WaitDelay = bashWaitDelay cmd.Stdout = out cmd.Stderr = out tracked, runErr := runShellProcess(jobCtx, cmd, sh, p.Command, shouldTrackShellProcess(wrapped, sh, p.Command, p.PreserveBackgroundProcesses)) if shouldReapAfterRun(jobCtx, sh, p.Command, p.PreserveBackgroundProcesses) { reapShellProcess(cmd, tracked) // reap process-group stragglers the job left running (#3702) } return "", normalizeBashRunError(jobCtx, runErr, p.PreserveBackgroundProcesses) }) 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) return appendSessionDataHint(msg, b.guard.CommandHint(b.workDir, p.Command)), nil } out, err := b.runForeground(ctx, p, sh, argv, wrapped, cmdEnv) if bashWindowsSandboxRuntimeFailure(argv, out, err) { allow, reason, approveErr := approveBashSandboxEscape(ctx, p.Command, args, i18n.M.SandboxEscapeRuntimeReason) if approveErr != nil { return out, approveErr } if !allow { if reason != "" { return out, fmt.Errorf("%s", reason) } return out, err } out, err = b.runForeground(ctx, p, sh, unconfinedShellArgv(sh, p.Command), false, cmdEnv) } return appendSessionDataHint(out, b.guard.CommandHint(b.workDir, p.Command)), err } // appendSessionDataHint appends the session-data guard warning to command // output; with no output the hint stands alone. An empty hint is a no-op. func appendSessionDataHint(out, hint string) string { if hint == "" { return out } if strings.TrimSpace(out) == "" { return hint } return out + "\n\n" + hint } func unconfinedShellArgv(sh sandbox.Shell, command string) []string { argv, _ := sandbox.Command(sandbox.Spec{}, sh, command) return argv } func approveBashSandboxEscape(ctx context.Context, command string, args json.RawMessage, reason string) (bool, string, error) { if !bashSandboxEscapePromptEnabled() { return false, "", nil } approver, ok := sandbox.EscapeApproverFrom(ctx) if !ok { return false, "", nil } return approver.ApproveSandboxEscape(ctx, sandbox.EscapeRequest{ Command: command, Args: append(json.RawMessage(nil), args...), Reason: reason, }) } func bashSandboxEscapeSessionAllowed(ctx context.Context, command string, args json.RawMessage) bool { if !bashSandboxEscapePromptEnabled() { return false } approver, ok := sandbox.EscapeApproverFrom(ctx) if !ok { return false } checker, ok := approver.(sandbox.EscapeSessionChecker) if !ok { return false } return checker.SandboxEscapeSessionAllowed(ctx, sandbox.EscapeRequest{ Command: command, Args: append(json.RawMessage(nil), args...), Reason: i18n.M.SandboxEscapeRuntimeReason, }) } func isWindowsSandboxRuntimeFailure(argv []string, out string, err error) bool { if !bashSandboxEscapePromptEnabled() || err == nil { return false } code, ok := bashExitCode(err) if !ok || code != 126 { return false } marker, ok := sandbox.WindowsSandboxFailureMarkerFromCommand(argv) return ok && strings.Contains(out, marker+" windows sandbox:") } func bashExitCode(err error) (int, bool) { var exitErr *exec.ExitError if !errors.As(err, &exitErr) { return 0, false } return exitErr.ExitCode(), true } func (b bash) runForeground(ctx context.Context, p bashParams, sh sandbox.Shell, argv []string, wrapped bool, cmdEnv []string) (string, error) { runCtx := ctx timeout := b.foregroundTimeout() if timeout > 0 { var cancel context.CancelFunc runCtx, cancel = context.WithTimeoutCause(ctx, timeout, errBashTimeout) defer cancel() } cmd := exec.CommandContext(runCtx, argv[0], argv[1:]...) cmd.Dir = b.workDir // "" lets exec use the process working directory cmd.Env = cmdEnv cmd.WaitDelay = bashWaitDelay var buf bytes.Buffer w := io.Writer(&buf) if emit, ok := tool.ProgressFrom(ctx); ok { w = io.MultiWriter(&buf, newProgressWriter(emit)) } cmd.Stdout = w cmd.Stderr = w tracked, err := runShellProcess(runCtx, cmd, sh, p.Command, shouldTrackShellProcess(wrapped, sh, p.Command, p.PreserveBackgroundProcesses)) // A foreground command that spawned a lingering child (e.g. `bazel run`'s // server) leaves it in the process group; Wait only reaped the shell leader. // Kill the group so those don't accumulate into an OOM (#3702). On cancel/ // timeout the command's Cancel path already did this; this covers normal exit. if shouldReapAfterRun(runCtx, sh, p.Command, p.PreserveBackgroundProcesses) { reapShellProcess(cmd, tracked) } err = normalizeBashRunError(runCtx, err, p.PreserveBackgroundProcesses) out := buf.String() if errors.Is(context.Cause(runCtx), errBashTimeout) { return out, fmt.Errorf("command timed out (> %s)", timeout) } if err != nil { // Non-zero exit: feed output and error back so the model can self-correct. return out, fmt.Errorf("command exited: %w", err) } return out, nil } func normalizeBashRunError(ctx context.Context, err error, preserveBackgroundProcesses bool) error { if preserveBackgroundProcesses && ctx.Err() == nil && errors.Is(err, exec.ErrWaitDelay) { return nil } return err } func shouldReapAfterRun(ctx context.Context, sh sandbox.Shell, command string, preserveBackgroundProcesses bool) bool { if ctx.Err() != nil { return true } if preserveBackgroundProcesses { return false } return sh.Kind != sandbox.ShellBash || !hasExplicitBackgroundKeepalive(command) } // hasExplicitBackgroundKeepalive detects common shell-level daemonization intent // without letting a plain "cmd &" bypass #3702's stray process cleanup. func hasExplicitBackgroundKeepalive(command string) bool { file, err := shellparse.ParseBash(command) if err != nil { return false } hasBackground := false hasKeepaliveCommand := false syntax.Walk(file, func(node syntax.Node) bool { switch n := node.(type) { case *syntax.Stmt: if n.Background { hasBackground = true } case *syntax.CallExpr: name, ok := staticShellCallName(n) if !ok { break } switch name { case "disown", "nohup", "setsid": hasKeepaliveCommand = true } } return !(hasBackground && hasKeepaliveCommand) }) return hasBackground && hasKeepaliveCommand } func (b bash) foregroundTimeout() time.Duration { if b.timeout <= 0 { return 0 } return b.timeout } func shouldTrackShellProcess(wrapped bool, sh sandbox.Shell, command string, preserveBackgroundProcesses bool) bool { if preserveBackgroundProcesses { return false } if runtime.GOOS == "windows" && wrapped { return false } return sh.Kind != sandbox.ShellBash || !hasExplicitBackgroundKeepalive(command) } func runShellProcess(ctx context.Context, cmd *exec.Cmd, sh sandbox.Shell, command string, track bool) (*proc.TrackedCommand, error) { return proc.RunCommand(ctx, cmd, proc.RunOptions{ Track: track, CancelWaitGrace: bashWaitDelay + time.Second, Source: "bash_tool", ShellKind: sh.Kind.String(), ShellPath: sh.Path, CommandPreview: commandPreview(command), }) } func reapShellProcess(cmd *exec.Cmd, tracked *proc.TrackedCommand) { if tracked != nil { tracked.Kill() return } proc.KillTree(cmd) } // progressWriter forwards each chunk the command writes to a tool.ProgressFunc, // so foreground bash output streams to the frontend as it is produced. type progressWriter struct{ emit tool.ProgressFunc } func newProgressWriter(emit tool.ProgressFunc) *progressWriter { return &progressWriter{emit: emit} } func (w *progressWriter) Write(p []byte) (int, error) { w.emit(string(p)) return len(p), nil } // hasUnquotedSeq reports whether seq appears in s outside any single- or // double-quoted span, so a literal "a && b" string argument doesn't trip the // PowerShell chaining guard. func hasUnquotedSeq(s, seq string) bool { var quote byte for i := 0; i < len(s); i++ { c := s[i] if quote != 0 { if c == quote { quote = 0 } continue } if c == '\'' || c == '"' { quote = c continue } if strings.HasPrefix(s[i:], seq) { return true } } return false } func staticShellCallName(call *syntax.CallExpr) (string, bool) { for _, arg := range call.Args { word, ok := shellparse.StaticWord(arg) if !ok { return "", false } if shellparse.IsAssignment(word) { continue } base := shellparse.WordBase(word) if base == "command" || base == "env" { continue } return base, true } return "", false } // commandPreview is a short single-line label for a background bash job, surfaced // in the status bar and completion notices. func commandPreview(cmd string) string { cmd = strings.TrimSpace(strings.ReplaceAll(cmd, "\n", " ")) const max = 48 r := []rune(cmd) if len(r) > max { return string(r[:max]) + "…" } return cmd } func bashCommandEnv(ctx context.Context) []string { env := secrets.ProcessEnv() if runtime.GOOS == "windows" { return env } currentPath, _ := envValue(env, "PATH") if shellPath := strings.TrimSpace(bashShellPATH(ctx)); shellPath != "" { if merged := mergePathLists(shellPath, currentPath); merged != currentPath { env = setEnvValue(env, "PATH", merged) } } return env } func defaultBashShellPATH(ctx context.Context) string { if runtime.GOOS == "windows" { return "" } shell := loginShell() if shell == "" { return "" } const marker = "__REASONIX_BASH_PATH__=" script := "printf '\\n" + marker + "%s\\n' \"$PATH\"" for _, args := range [][]string{ {"-l", "-i", "-c", script}, {"-l", "-c", script}, {"-c", script}, } { out := runShellPATHCommand(ctx, shell, args) if path := parseShellPATH(out, marker); path != "" { return path } } return "" } func loginShell() string { if shell := strings.TrimSpace(os.Getenv("SHELL")); shell != "" { if hasPathSeparator(shell) { if isExecutableFile(shell) { return shell } } else if p, err := exec.LookPath(shell); err == nil { return p } } for _, shell := range []string{"/bin/zsh", "/bin/bash", "/bin/sh"} { if isExecutableFile(shell) { return shell } } return "" } func runShellPATHCommand(parent context.Context, shell string, args []string) []byte { ctx, cancel := context.WithTimeout(parent, 2*time.Second) defer cancel() cmd := exec.CommandContext(ctx, shell, args...) // Explicit env so the login-shell probe honors [secrets] // filter_subprocess_env instead of inheriting the full environment. cmd.Env = secrets.ProcessEnv() proc.PrepareShellPATHProbe(cmd) cmd.Stdin = strings.NewReader("") out, _ := cmd.CombinedOutput() return out } func parseShellPATH(out []byte, marker string) string { lines := strings.Split(strings.ReplaceAll(string(out), "\r\n", "\n"), "\n") for i := len(lines) - 1; i >= 0; i-- { if strings.HasPrefix(lines[i], marker) { return strings.TrimSpace(strings.TrimPrefix(lines[i], marker)) } } return "" } func hasPathSeparator(s string) bool { return strings.ContainsAny(s, `/\`) } func isExecutableFile(path string) bool { info, err := os.Stat(path) if err != nil || info.IsDir() { return false } return info.Mode().Perm()&0o111 != 0 } func setEnvValue(env []string, key, value string) []string { out := make([]string, 0, len(env)+1) replaced := false for _, kv := range env { k, _, ok := strings.Cut(kv, "=") if ok && envKeyEqual(k, key) { if !replaced { out = append(out, key+"="+value) replaced = true } continue } out = append(out, kv) } if !replaced { out = append(out, key+"="+value) } return out } func envValue(env []string, key string) (string, bool) { for i := len(env) - 1; i >= 0; i-- { k, v, ok := strings.Cut(env[i], "=") if ok && envKeyEqual(k, key) { return v, true } } return "", false } func envKeyEqual(a, b string) bool { if runtime.GOOS == "windows" { return strings.EqualFold(a, b) } return a == b } func mergePathLists(primary, secondary string) string { var out []string seen := map[string]bool{} add := func(path string) { for _, part := range filepath.SplitList(path) { part = strings.TrimSpace(part) if part == "" || seen[part] { continue } seen[part] = true out = append(out, part) } } add(primary) add(secondary) return strings.Join(out, string(os.PathListSeparator)) }