| 1 | package builtin |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "errors" |
| 6 | "fmt" |
| 7 | "strings" |
| 8 | "time" |
| 9 | |
| 10 | "mvdan.cc/sh/v3/syntax" |
| 11 | |
| 12 | "reasonix/internal/persistentshell" |
| 13 | "reasonix/internal/sandbox" |
| 14 | "reasonix/internal/shellparse" |
| 15 | "reasonix/internal/shellrun" |
| 16 | "reasonix/internal/tool" |
| 17 | ) |
| 18 | |
| 19 | // shellResetNotice tells the model that session shell state is gone. Without it |
| 20 | // the model keeps issuing relative paths against a working directory that the |
| 21 | // reset already discarded. |
| 22 | const shellResetNotice = "The persistent shell was reset; the next bash call starts from the workspace with a fresh working directory and environment." |
| 23 | |
| 24 | func persistEnv(env []string) []string { |
| 25 | return applyEnvOverrides(env, []string{ |
| 26 | "TERM=dumb", |
| 27 | "NO_COLOR=1", |
| 28 | "PAGER=cat", |
| 29 | "GIT_PAGER=cat", |
| 30 | "BASH_SILENCE_DEPRECATION_WARNING=1", |
| 31 | }) |
| 32 | } |
| 33 | |
| 34 | func (b bash) persistentManager(ctx context.Context) *persistentshell.Manager { |
| 35 | if m := persistentshell.FromContext(ctx); m != nil { |
| 36 | return m |
| 37 | } |
| 38 | return b.persistent |
| 39 | } |
| 40 | |
| 41 | // hasBackgroundStatement reports an explicit `&` background operator. A session |
| 42 | // shell outlives the call, so its background children are neither reaped by |
| 43 | // #3702's process-group cleanup nor kept out of the next command's output: both |
| 44 | // contracts only hold in a one-shot process, so such commands stay there. |
| 45 | func hasBackgroundStatement(command string) bool { |
| 46 | file, err := shellparse.ParseBash(command) |
| 47 | if err != nil { |
| 48 | return false |
| 49 | } |
| 50 | background := false |
| 51 | syntax.Walk(file, func(node syntax.Node) bool { |
| 52 | if stmt, ok := node.(*syntax.Stmt); ok && stmt.Background { |
| 53 | background = true |
| 54 | } |
| 55 | return !background |
| 56 | }) |
| 57 | return background |
| 58 | } |
| 59 | |
| 60 | func (b bash) shouldUsePersistent(ctx context.Context, p bashParams, sh sandbox.Shell) bool { |
| 61 | if sh.Kind == sandbox.ShellPowerShell { |
| 62 | return false |
| 63 | } |
| 64 | if !persistentshell.Supports(sh) { |
| 65 | return false |
| 66 | } |
| 67 | if p.RunInBackground || p.PreserveBackgroundProcesses { |
| 68 | return false |
| 69 | } |
| 70 | if len(p.AdditionalWriteDirs) > 0 || strings.TrimSpace(p.SandboxPermissions) != "" { |
| 71 | return false |
| 72 | } |
| 73 | if sh.Kind.IsPOSIX() && hasBackgroundStatement(p.Command) { |
| 74 | return false |
| 75 | } |
| 76 | if sh.Kind == sandbox.ShellPowerShell && powerShellIsolated(p.Command) { |
| 77 | return false |
| 78 | } |
| 79 | m := b.persistentManager(ctx) |
| 80 | return m != nil && !m.Sealed() |
| 81 | } |
| 82 | |
| 83 | func rejectPowerShellChaining(ex *tool.ShellExecution, start time.Time, sh sandbox.Shell, command string) (tool.DetailedResult, error, bool) { |
| 84 | if sh.SupportsChaining() || (!hasUnquotedSeq(command, "&&") && !hasUnquotedSeq(command, "||")) { |
| 85 | return tool.DetailedResult{}, nil, false |
| 86 | } |
| 87 | ex.State = tool.ShellStateNotRun |
| 88 | ex.FailurePhase = tool.ShellPhasePreflight |
| 89 | ex.MutationRisk = tool.ShellMutationNotStarted |
| 90 | ex.DurationMs = time.Since(start).Milliseconds() |
| 91 | return tool.DetailedResult{Execution: ex}, fmt.Errorf("this shell is Windows PowerShell, which does not parse '&&' or '||'. " + |
| 92 | "Sequence with ';' (both run regardless of the first's result), use 'if ($?) { ... }' for " + |
| 93 | "conditional chaining, or issue the commands as separate calls"), true |
| 94 | } |
| 95 | |
| 96 | func (b bash) tryPersistent(ctx context.Context, p bashParams, sh sandbox.Shell, prepared sandbox.Prepared, cmdEnv []string, start time.Time, ex *tool.ShellExecution) (tool.DetailedResult, error, bool) { |
| 97 | out, runEx, err, used := b.runPersistent(ctx, p, sh, prepared, cmdEnv) |
| 98 | if !used { |
| 99 | return tool.DetailedResult{}, nil, false |
| 100 | } |
| 101 | mergeRunInto(ex, runEx) |
| 102 | ex.DurationMs = time.Since(start).Milliseconds() |
| 103 | return tool.DetailedResult{ |
| 104 | Output: b.appendWriteHints(ctx, out, err, p, prepared.Wrapped), |
| 105 | Execution: ex, |
| 106 | }, err, true |
| 107 | } |
| 108 | |
| 109 | func (b bash) runPersistent(ctx context.Context, p bashParams, sh sandbox.Shell, prepared sandbox.Prepared, cmdEnv []string) (string, *tool.ShellExecution, error, bool) { |
| 110 | if !b.shouldUsePersistent(ctx, p, sh) { |
| 111 | return "", nil, nil, false |
| 112 | } |
| 113 | m := b.persistentManager(ctx) |
| 114 | // The session-private temporary directory must reach the sandbox profile, |
| 115 | // not just the child environment: the spec that sets TMPDIR/GOCACHE must |
| 116 | // also bind (Linux) or allow (Seatbelt) that directory. |
| 117 | spec := b.specForCall(ctx) |
| 118 | launch := sandbox.PrepareShellArgs(spec, persistentshell.InteractiveArgv(sh), prepared.SessionTemp) |
| 119 | if spec.Enforce() && !launch.Wrapped { |
| 120 | ex := shellrun.DescriptorFromShell(sh) |
| 121 | ex.State = tool.ShellStateNotRun |
| 122 | ex.FailurePhase = tool.ShellPhaseLaunch |
| 123 | ex.MutationRisk = tool.ShellMutationNotStarted |
| 124 | return "", ex, fmt.Errorf("%s", sandbox.UnavailableMessage()), true |
| 125 | } |
| 126 | var progress = shellrun.NewProgressWriter(nil) |
| 127 | if emit, ok := tool.ProgressFrom(ctx); ok { |
| 128 | progress = shellrun.NewProgressWriter(emit) |
| 129 | } |
| 130 | defer progress.Flush() |
| 131 | res := m.Run(ctx, persistentshell.Request{ |
| 132 | Argv: launch.Argv, |
| 133 | Dir: b.workDir, |
| 134 | Env: applyEnvOverrides(cmdEnv, launch.EnvOverrides), |
| 135 | Command: p.Command, |
| 136 | Timeout: b.foregroundTimeoutFor(p), |
| 137 | Shell: sh, |
| 138 | Progress: progress, |
| 139 | }) |
| 140 | if !res.Started && res.Err != nil && !res.Reset { |
| 141 | var startup *persistentshell.StartupError |
| 142 | if !errors.As(res.Err, &startup) { |
| 143 | if sh.Kind == sandbox.ShellPowerShell { |
| 144 | out, ex, err := b.runForegroundDetailed(ctx, p, sh, prepared.Argv, prepared.Wrapped, cmdEnv) |
| 145 | return appendSessionDataHint(out, "Persistent PowerShell was unavailable before this command started. This call ran once in an isolated process; its directory and variable changes are not retained."), ex, err, true |
| 146 | } |
| 147 | return "", nil, nil, false |
| 148 | } |
| 149 | } |
| 150 | ex := shellrun.DescriptorFromShell(sh) |
| 151 | ex.State = res.State |
| 152 | ex.FailurePhase = res.FailurePhase |
| 153 | code := res.ExitCode |
| 154 | if res.ExitCodeKnown { |
| 155 | ex.ExitCode = &code |
| 156 | } |
| 157 | if res.State != tool.ShellStateCompleted && res.Output != "" { |
| 158 | ex.OutputTail = res.Output |
| 159 | if len(ex.OutputTail) > tool.OutputTailMaxBytes { |
| 160 | ex.OutputTail = ex.OutputTail[len(ex.OutputTail)-tool.OutputTailMaxBytes:] |
| 161 | } |
| 162 | } |
| 163 | switch res.State { |
| 164 | case tool.ShellStateCompleted: |
| 165 | ex.MutationRisk = tool.ShellMutationMayHaveCompleted |
| 166 | case tool.ShellStateNotRun: |
| 167 | ex.MutationRisk = tool.ShellMutationNotStarted |
| 168 | case tool.ShellStateFailed: |
| 169 | if res.FailurePhase == tool.ShellPhaseLaunch || res.FailurePhase == tool.ShellPhasePreflight { |
| 170 | ex.MutationRisk = tool.ShellMutationNotStarted |
| 171 | } else { |
| 172 | ex.MutationRisk = tool.ShellMutationMayBePartial |
| 173 | } |
| 174 | case tool.ShellStateTimedOut, tool.ShellStateCancelled: |
| 175 | ex.MutationRisk = tool.ShellMutationMayBePartial |
| 176 | default: |
| 177 | ex.MutationRisk = tool.ShellMutationUnknown |
| 178 | } |
| 179 | out := res.Output |
| 180 | if res.Reset { |
| 181 | out = appendSessionDataHint(out, shellResetNotice) |
| 182 | } |
| 183 | return out, ex, res.Err, true |
| 184 | } |
| 185 | |
| 186 | // Conservative isolation is safe for quoted mentions too; do not parse |
| 187 | // PowerShell background syntax with the Bash parser. |
| 188 | func powerShellIsolated(command string) bool { |
| 189 | lower := strings.ToLower(command) |
| 190 | for _, token := range []string{"start-job", "start-threadjob", "start-process", "-asjob"} { |
| 191 | if strings.Contains(lower, token) { |
| 192 | return true |
| 193 | } |
| 194 | } |
| 195 | var quote rune |
| 196 | var previous rune |
| 197 | runes := []rune(command) |
| 198 | for i, ch := range runes { |
| 199 | if quote != 0 { |
| 200 | if ch == quote { |
| 201 | quote = 0 |
| 202 | } |
| 203 | continue |
| 204 | } |
| 205 | if ch == '\'' || ch == '"' { |
| 206 | quote = ch |
| 207 | previous = ch |
| 208 | continue |
| 209 | } |
| 210 | if ch == '&' { |
| 211 | if i+1 < len(runes) && runes[i+1] == '&' || i > 0 && runes[i-1] == '&' { |
| 212 | continue |
| 213 | } |
| 214 | if previous != 0 && !strings.ContainsRune(";\n|({=", previous) { |
| 215 | return true |
| 216 | } |
| 217 | } |
| 218 | if ch != ' ' && ch != '\t' && ch != '\r' { |
| 219 | previous = ch |
| 220 | } |
| 221 | } |
| 222 | return false |
| 223 | } |
| 224 |