| 1 | package builtin |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "encoding/json" |
| 6 | "fmt" |
| 7 | "regexp" |
| 8 | "strings" |
| 9 | "time" |
| 10 | |
| 11 | "reasonix/internal/permissionpreset" |
| 12 | "reasonix/internal/sandbox" |
| 13 | "reasonix/internal/tool" |
| 14 | ) |
| 15 | |
| 16 | func (b bash) Schema() json.RawMessage { |
| 17 | if b.resolved().Kind == sandbox.ShellPowerShell { |
| 18 | return json.RawMessage(`{"type":"object","properties":{"command":{"type":"string","description":"PowerShell command to execute"},"description":{"type":"string","description":"Clear 5-10 word active-voice description shown in the UI"},"timeout_ms":{"type":"integer","minimum":1,"description":"Optional foreground timeout in milliseconds, capped by the configured shell timeout"},"run_in_background":{"type":"boolean","description":"Run without a foreground timeout and return a pwsh job id immediately. Read it with job_output or stop it with job_kill."},"additional_write_dirs":{"type":"array","items":{"type":"string"},"description":"Directories this command must write outside the workspace. Directories only, no globs. Accepts absolute paths, workspace-relative paths, ~, and ${HOME}. Request the smallest set needed; the host will not infer paths from the command text."},"sandbox_permissions":{"type":"string","enum":["workspace-write","danger-full-access"],"description":"Optional per-call permission escalation. Use workspace-write for an authorized write while the session is read-only. danger-full-access is accepted only after a host-recorded denial and explicit authorization."},"justification":{"type":"string","description":"Required when additional_write_dirs or sandbox_permissions is set. Explain why the access is needed."},"denial_id":{"type":"string","description":"Host-issued denial identifier required when retrying with danger-full-access."}},"required":["command","description"]}`) |
| 19 | } |
| 20 | return json.RawMessage(`{"type":"object","properties":{"command":{"type":"string","description":"Shell command to execute"},"timeout_ms":{"type":"integer","minimum":1,"description":"Optional foreground timeout in milliseconds, capped by the configured shell timeout"},"run_in_background":{"type":"boolean","description":"Run detached: returns a job id immediately and keeps running across turns (no foreground timeout). Read it with job_output or stop it with job_kill."},"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."},"additional_write_dirs":{"type":"array","items":{"type":"string"},"description":"Directories this command must write outside the workspace. Directories only, no globs. Accepts absolute paths, workspace-relative paths, ~, and ${HOME}. Request the smallest set needed; the host will not infer paths from the command text."},"sandbox_permissions":{"type":"string","enum":["workspace-write","danger-full-access"],"description":"Optional per-call permission escalation. Use workspace-write for an authorized write while the session is read-only. danger-full-access is accepted only after a host-recorded denial and explicit authorization."},"justification":{"type":"string","description":"Required when additional_write_dirs or sandbox_permissions is set. Explain why the access is needed."},"denial_id":{"type":"string","description":"Host-issued denial identifier required when retrying with danger-full-access."}},"required":["command"]}`) |
| 21 | } |
| 22 | |
| 23 | func (b bash) DeclareWriteAccess(args json.RawMessage) (tool.WriteAccessDeclaration, error) { |
| 24 | var p bashParams |
| 25 | if err := json.Unmarshal(args, &p); err != nil { |
| 26 | return tool.WriteAccessDeclaration{}, fmt.Errorf("invalid args: %w", err) |
| 27 | } |
| 28 | if err := validateBashWriteDirs(p); err != nil { |
| 29 | return tool.WriteAccessDeclaration{}, err |
| 30 | } |
| 31 | return tool.WriteAccessDeclaration{ |
| 32 | Directories: append([]string(nil), p.AdditionalWriteDirs...), |
| 33 | Justification: strings.TrimSpace(p.Justification), |
| 34 | RequestedPreset: strings.TrimSpace(p.SandboxPermissions), |
| 35 | DenialID: strings.TrimSpace(p.DenialID), |
| 36 | }, nil |
| 37 | } |
| 38 | |
| 39 | func validateBashWriteDirs(p bashParams) error { |
| 40 | preset := strings.TrimSpace(p.SandboxPermissions) |
| 41 | if preset != "" && preset != string(permissionpreset.WorkspaceWrite) && preset != string(permissionpreset.DangerFullAccess) { |
| 42 | return fmt.Errorf("sandbox_permissions must be workspace-write or danger-full-access") |
| 43 | } |
| 44 | if preset != "" && strings.TrimSpace(p.Justification) == "" { |
| 45 | return fmt.Errorf("justification is required when sandbox_permissions is set") |
| 46 | } |
| 47 | if preset == string(permissionpreset.DangerFullAccess) && strings.TrimSpace(p.DenialID) == "" { |
| 48 | return fmt.Errorf("denial_id is required when sandbox_permissions is danger-full-access") |
| 49 | } |
| 50 | if len(p.AdditionalWriteDirs) == 0 { |
| 51 | return nil |
| 52 | } |
| 53 | if strings.TrimSpace(p.Justification) == "" { |
| 54 | return fmt.Errorf("justification is required when additional_write_dirs is set") |
| 55 | } |
| 56 | for _, dir := range p.AdditionalWriteDirs { |
| 57 | dir = strings.TrimSpace(dir) |
| 58 | if dir == "" { |
| 59 | return fmt.Errorf("additional_write_dirs entries must be non-empty directories") |
| 60 | } |
| 61 | if strings.ContainsAny(dir, "*?[") { |
| 62 | return fmt.Errorf("additional_write_dirs %q must be a concrete directory, not a glob", dir) |
| 63 | } |
| 64 | } |
| 65 | return nil |
| 66 | } |
| 67 | |
| 68 | func validateBashParams(p bashParams) error { |
| 69 | if p.Command == "" { |
| 70 | return fmt.Errorf("command is required") |
| 71 | } |
| 72 | if p.TimeoutMS < 0 { |
| 73 | return fmt.Errorf("timeout_ms must be positive") |
| 74 | } |
| 75 | return validateBashWriteDirs(p) |
| 76 | } |
| 77 | |
| 78 | func bashPreflightFailure(ex *tool.ShellExecution, start time.Time, err error) (tool.DetailedResult, error) { |
| 79 | ex.State = tool.ShellStateNotRun |
| 80 | ex.FailurePhase = tool.ShellPhasePreflight |
| 81 | ex.MutationRisk = tool.ShellMutationNotStarted |
| 82 | ex.DurationMs = time.Since(start).Milliseconds() |
| 83 | return tool.DetailedResult{Execution: ex}, err |
| 84 | } |
| 85 | |
| 86 | func bashLaunchFailure(ex *tool.ShellExecution, start time.Time, err error) (tool.DetailedResult, error) { |
| 87 | ex.State = tool.ShellStateNotRun |
| 88 | // prepareLaunch has not entered the native runner yet. Its failures are |
| 89 | // missing host dependencies (sandbox backend or session temp), not ACL/token |
| 90 | // authorization and not a child-process launch. |
| 91 | ex.FailurePhase = tool.ShellPhaseDependency |
| 92 | ex.MutationRisk = tool.ShellMutationNotStarted |
| 93 | ex.DurationMs = time.Since(start).Milliseconds() |
| 94 | return tool.DetailedResult{Execution: ex}, err |
| 95 | } |
| 96 | |
| 97 | func (b bash) appendWriteHints(ctx context.Context, out string, err error, p bashParams, wrapped bool) string { |
| 98 | out = appendSessionDataHint(out, b.guard.CommandHint(b.workDir, p.Command)) |
| 99 | if wrapped { |
| 100 | out = appendSandboxWriteHint(out, err, p, b.specForCall(ctx), string(sandbox.PermissionPresetFrom(ctx))) |
| 101 | } |
| 102 | return out |
| 103 | } |
| 104 | |
| 105 | func (b bash) specForCall(ctx context.Context) sandbox.Spec { |
| 106 | spec := b.sb |
| 107 | preset := sandbox.PermissionPresetFrom(ctx) |
| 108 | switch preset { |
| 109 | case permissionpreset.ReadOnly: |
| 110 | spec.Mode = "enforce" |
| 111 | spec.ReadOnly = true |
| 112 | spec.WriteRoots = nil |
| 113 | spec.MinimalWrites = true |
| 114 | case permissionpreset.WorkspaceWrite: |
| 115 | // Permission presets own the enforcement decision. A legacy |
| 116 | // [sandbox].bash="off" cannot silently turn workspace access into an |
| 117 | // unconfined shell. |
| 118 | spec.Mode = "enforce" |
| 119 | spec.ReadOnly = false |
| 120 | spec.MinimalWrites = true |
| 121 | if len(spec.WriteRoots) == 0 && strings.TrimSpace(b.workDir) != "" { |
| 122 | spec.WriteRoots = []string{b.workDir} |
| 123 | } |
| 124 | case permissionpreset.DangerFullAccess: |
| 125 | spec.Mode = "off" |
| 126 | spec.ReadOnly = false |
| 127 | } |
| 128 | // Windows has no OS-level shell sandbox: demanding one made every |
| 129 | // restricted-preset shell call fail closed (#10292). Presets stay tool-layer |
| 130 | // boundaries there and bash runs as the OS user after the approval gate. |
| 131 | if !sandbox.OSSandboxSupported() { |
| 132 | spec.Mode = "off" |
| 133 | } |
| 134 | if preset == permissionpreset.WorkspaceWrite { |
| 135 | if b.rootSet != nil { |
| 136 | spec.WriteRoots = b.rootSet.EffectiveSandboxRoots(ctx) |
| 137 | } else if extra := sandbox.PerCallWriteRoots(ctx); len(extra) > 0 { |
| 138 | spec.WriteRoots = sandbox.CollapseWriteRoots(append(append([]string{}, spec.WriteRoots...), extra...)) |
| 139 | } |
| 140 | } |
| 141 | if spec.ProtectedWriteRoots == nil && b.guard.stateRoot != "" { |
| 142 | spec.ProtectedWriteRoots = sandbox.ProtectedWriteRoots(b.guard.stateRoot) |
| 143 | } |
| 144 | return spec |
| 145 | } |
| 146 | |
| 147 | func bashWriteDeniedHint() string { |
| 148 | return "The OS sandbox blocked a write outside the approved writable roots. Retry the same command with structured additional_write_dirs naming the exact directories (no globs), plus a justification. Example: {\"command\":\"mkdir -p ~/.local/bin && cp tool ~/.local/bin/tool\",\"additional_write_dirs\":[\"~/.local\"],\"justification\":\"install the user-requested local command\"}. Do not retry unconfined and do not omit the directories." |
| 149 | } |
| 150 | |
| 151 | func looksLikeSandboxWriteDenial(out string, err error) bool { |
| 152 | if err == nil { |
| 153 | return false |
| 154 | } |
| 155 | msg := strings.ToLower(out) |
| 156 | if err != nil { |
| 157 | msg += "\n" + strings.ToLower(err.Error()) |
| 158 | } |
| 159 | for _, needle := range []string{ |
| 160 | "operation not permitted", |
| 161 | "read-only file system", |
| 162 | "erofs", |
| 163 | "access is denied", |
| 164 | "permissionerror: [errno 13] permission denied", |
| 165 | } { |
| 166 | if strings.Contains(msg, needle) { |
| 167 | return true |
| 168 | } |
| 169 | } |
| 170 | // A bare "permission denied" can be an HTTP response or application-level |
| 171 | // error. Accept it only in the standard local filesystem diagnostic shape |
| 172 | // emitted by shells and file utilities. |
| 173 | return localFilePermissionDenied.MatchString(msg) || windowsChildProcessDenied.MatchString(msg) |
| 174 | } |
| 175 | |
| 176 | var localFilePermissionDenied = regexp.MustCompile(`(?m)^(?:bash|zsh|sh|dash|fish|mkdir|touch|cp|mv|rm|ln|install|tee|cat|chmod|chown):[^\n]*permission denied\b`) |
| 177 | var windowsChildProcessDenied = regexp.MustCompile(`\b(?:spawn(?:sync)?|exec(?:file|sync)?)\s+eperm\b`) |
| 178 | |
| 179 | func appendSandboxWriteHint(out string, err error, p bashParams, spec sandbox.Spec, preset string) string { |
| 180 | if !spec.Enforce() || strings.TrimSpace(preset) == string(permissionpreset.DangerFullAccess) || !looksLikeSandboxWriteDenial(out, err) { |
| 181 | return out |
| 182 | } |
| 183 | hint := bashWriteDeniedHint() |
| 184 | if len(p.AdditionalWriteDirs) > 0 || windowsChildProcessDenied.MatchString(strings.ToLower(out+"\n"+err.Error())) { |
| 185 | hint = "The command encountered a permission denial under the OS sandbox. Additional writable directories may not resolve a child-process or named-object denial." |
| 186 | } |
| 187 | if denialID := sandbox.IssueDenial(p.Command, preset); denialID != "" { |
| 188 | hint += " If the command cannot be expressed with additional_write_dirs, request danger-full-access for this exact retry with denial_id " + denialID + "." |
| 189 | } |
| 190 | return appendSessionDataHint(out, hint) |
| 191 | } |
| 192 |