| 1 | package builtin |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "encoding/json" |
| 6 | "fmt" |
| 7 | "os" |
| 8 | "path/filepath" |
| 9 | "regexp" |
| 10 | "strings" |
| 11 | "time" |
| 12 | |
| 13 | "reasonix/internal/permissionpreset" |
| 14 | "reasonix/internal/sandbox" |
| 15 | "reasonix/internal/tool" |
| 16 | ) |
| 17 | |
| 18 | func (b bash) Schema() json.RawMessage { |
| 19 | if b.resolved().Kind == sandbox.ShellPowerShell { |
| 20 | 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"]}`) |
| 21 | } |
| 22 | 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"]}`) |
| 23 | } |
| 24 | |
| 25 | func (b bash) DeclareWriteAccess(args json.RawMessage) (tool.WriteAccessDeclaration, error) { |
| 26 | var p bashParams |
| 27 | if err := json.Unmarshal(args, &p); err != nil { |
| 28 | return tool.WriteAccessDeclaration{}, fmt.Errorf("invalid args: %w", err) |
| 29 | } |
| 30 | if err := validateBashWriteDirs(p); err != nil { |
| 31 | return tool.WriteAccessDeclaration{}, err |
| 32 | } |
| 33 | return tool.WriteAccessDeclaration{ |
| 34 | Directories: append([]string(nil), p.AdditionalWriteDirs...), |
| 35 | Justification: strings.TrimSpace(p.Justification), |
| 36 | RequestedPreset: strings.TrimSpace(p.SandboxPermissions), |
| 37 | DenialID: strings.TrimSpace(p.DenialID), |
| 38 | }, nil |
| 39 | } |
| 40 | |
| 41 | func validateBashWriteDirs(p bashParams) error { |
| 42 | preset := strings.TrimSpace(p.SandboxPermissions) |
| 43 | if preset != "" && preset != string(permissionpreset.WorkspaceWrite) && preset != string(permissionpreset.DangerFullAccess) { |
| 44 | return fmt.Errorf("sandbox_permissions must be workspace-write or danger-full-access") |
| 45 | } |
| 46 | if preset != "" && strings.TrimSpace(p.Justification) == "" { |
| 47 | return fmt.Errorf("justification is required when sandbox_permissions is set") |
| 48 | } |
| 49 | if preset == string(permissionpreset.DangerFullAccess) && strings.TrimSpace(p.DenialID) == "" { |
| 50 | return fmt.Errorf("denial_id is required when sandbox_permissions is danger-full-access") |
| 51 | } |
| 52 | if len(p.AdditionalWriteDirs) == 0 { |
| 53 | return nil |
| 54 | } |
| 55 | if strings.TrimSpace(p.Justification) == "" { |
| 56 | return fmt.Errorf("justification is required when additional_write_dirs is set") |
| 57 | } |
| 58 | for _, dir := range p.AdditionalWriteDirs { |
| 59 | dir = strings.TrimSpace(dir) |
| 60 | if dir == "" { |
| 61 | return fmt.Errorf("additional_write_dirs entries must be non-empty directories") |
| 62 | } |
| 63 | if strings.ContainsAny(dir, "*?[") { |
| 64 | return fmt.Errorf("additional_write_dirs %q must be a concrete directory, not a glob", dir) |
| 65 | } |
| 66 | } |
| 67 | return nil |
| 68 | } |
| 69 | |
| 70 | func validateBashParams(p bashParams) error { |
| 71 | if p.Command == "" { |
| 72 | return fmt.Errorf("command is required") |
| 73 | } |
| 74 | if p.TimeoutMS < 0 { |
| 75 | return fmt.Errorf("timeout_ms must be positive") |
| 76 | } |
| 77 | return validateBashWriteDirs(p) |
| 78 | } |
| 79 | |
| 80 | func bashPreflightFailure(ex *tool.ShellExecution, start time.Time, err error) (tool.DetailedResult, error) { |
| 81 | ex.State = tool.ShellStateNotRun |
| 82 | ex.FailurePhase = tool.ShellPhasePreflight |
| 83 | ex.MutationRisk = tool.ShellMutationNotStarted |
| 84 | ex.DurationMs = time.Since(start).Milliseconds() |
| 85 | return tool.DetailedResult{Execution: ex}, err |
| 86 | } |
| 87 | |
| 88 | func bashLaunchFailure(ex *tool.ShellExecution, start time.Time, err error) (tool.DetailedResult, error) { |
| 89 | ex.State = tool.ShellStateNotRun |
| 90 | // prepareLaunch has not entered the native runner yet. Its failures are |
| 91 | // missing host dependencies (sandbox backend or session temp), not ACL/token |
| 92 | // authorization and not a child-process launch. |
| 93 | ex.FailurePhase = tool.ShellPhaseDependency |
| 94 | ex.MutationRisk = tool.ShellMutationNotStarted |
| 95 | ex.DurationMs = time.Since(start).Milliseconds() |
| 96 | return tool.DetailedResult{Execution: ex}, err |
| 97 | } |
| 98 | |
| 99 | func (b bash) appendWriteHints(ctx context.Context, out string, err error, p bashParams, wrapped bool) string { |
| 100 | out = appendSessionDataHint(out, b.guard.CommandHint(b.workDir, p.Command)) |
| 101 | if wrapped { |
| 102 | out = appendSandboxWriteHint(out, err, p, b.specForCall(ctx), string(sandbox.PermissionPresetFrom(ctx)), b.workDir) |
| 103 | } |
| 104 | return out |
| 105 | } |
| 106 | |
| 107 | func (b bash) specForCall(ctx context.Context) sandbox.Spec { |
| 108 | spec := b.sb |
| 109 | preset := sandbox.PermissionPresetFrom(ctx) |
| 110 | switch preset { |
| 111 | case permissionpreset.ReadOnly: |
| 112 | spec.Mode = "enforce" |
| 113 | spec.ReadOnly = true |
| 114 | spec.WriteRoots = nil |
| 115 | spec.MinimalWrites = true |
| 116 | case permissionpreset.WorkspaceWrite: |
| 117 | // Permission presets own the enforcement decision. A legacy |
| 118 | // [sandbox].bash="off" cannot silently turn workspace access into an |
| 119 | // unconfined shell. |
| 120 | spec.Mode = "enforce" |
| 121 | spec.ReadOnly = false |
| 122 | spec.MinimalWrites = true |
| 123 | if len(spec.WriteRoots) == 0 && strings.TrimSpace(b.workDir) != "" { |
| 124 | spec.WriteRoots = []string{b.workDir} |
| 125 | } |
| 126 | case permissionpreset.DangerFullAccess: |
| 127 | spec.Mode = "off" |
| 128 | spec.ReadOnly = false |
| 129 | } |
| 130 | // Windows has no OS-level shell sandbox: demanding one made every |
| 131 | // restricted-preset shell call fail closed (#10292). Presets stay tool-layer |
| 132 | // boundaries there and bash runs as the OS user after the approval gate. |
| 133 | if !sandbox.OSSandboxSupported() { |
| 134 | spec.Mode = "off" |
| 135 | } |
| 136 | if preset == permissionpreset.WorkspaceWrite { |
| 137 | if b.rootSet != nil { |
| 138 | spec.WriteRoots = b.rootSet.EffectiveSandboxRoots(ctx) |
| 139 | } else if extra := sandbox.PerCallWriteRoots(ctx); len(extra) > 0 { |
| 140 | spec.WriteRoots = sandbox.CollapseWriteRoots(append(append([]string{}, spec.WriteRoots...), extra...)) |
| 141 | } |
| 142 | } |
| 143 | if spec.ProtectedWriteRoots == nil && b.guard.stateRoot != "" { |
| 144 | spec.ProtectedWriteRoots = sandbox.ProtectedWriteRoots(b.guard.stateRoot) |
| 145 | } |
| 146 | return spec |
| 147 | } |
| 148 | |
| 149 | func bashWriteDeniedHint() string { |
| 150 | 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." |
| 151 | } |
| 152 | |
| 153 | func looksLikeSandboxWriteDenial(out string, err error) bool { |
| 154 | if err == nil { |
| 155 | return false |
| 156 | } |
| 157 | msg := strings.ToLower(out) |
| 158 | if err != nil { |
| 159 | msg += "\n" + strings.ToLower(err.Error()) |
| 160 | } |
| 161 | for _, needle := range []string{ |
| 162 | "operation not permitted", |
| 163 | "read-only file system", |
| 164 | "erofs", |
| 165 | "access is denied", |
| 166 | "permissionerror: [errno 13] permission denied", |
| 167 | } { |
| 168 | if strings.Contains(msg, needle) { |
| 169 | return true |
| 170 | } |
| 171 | } |
| 172 | // A bare "permission denied" can be an HTTP response or application-level |
| 173 | // error. Accept it only in the standard local filesystem diagnostic shape |
| 174 | // emitted by shells and file utilities. |
| 175 | return localFilePermissionDenied.MatchString(msg) || windowsChildProcessDenied.MatchString(msg) |
| 176 | } |
| 177 | |
| 178 | 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`) |
| 179 | var windowsChildProcessDenied = regexp.MustCompile(`\b(?:spawn(?:sync)?|exec(?:file|sync)?)\s+eperm\b`) |
| 180 | |
| 181 | func appendSandboxWriteHint(out string, err error, p bashParams, spec sandbox.Spec, preset, workDir string) string { |
| 182 | if !spec.Enforce() || strings.TrimSpace(preset) == string(permissionpreset.DangerFullAccess) { |
| 183 | return out |
| 184 | } |
| 185 | // git reports some refused config writes and still exits 0, so the note |
| 186 | // rides on the protected path's identity, not on the exit status. |
| 187 | if named := gitMetadataNamedIn(out, spec, workDir); len(named) > 0 { |
| 188 | hint := gitMetadataDeniedHint(named) |
| 189 | if denialID := sandbox.IssueDenial(p.Command, preset); denialID != "" { |
| 190 | hint += " If the user asked for exactly this change, request danger-full-access for this exact retry with denial_id " + denialID + "." |
| 191 | } |
| 192 | return appendSessionDataHint(out, hint) |
| 193 | } |
| 194 | if !looksLikeSandboxWriteDenial(out, err) { |
| 195 | return out |
| 196 | } |
| 197 | hint := bashWriteDeniedHint() |
| 198 | if windowsChildProcessDenied.MatchString(strings.ToLower(out + "\n" + err.Error())) { |
| 199 | hint = "The command encountered a permission denial under the OS sandbox. Additional writable directories may not resolve a child-process or named-object denial." |
| 200 | } else if dirs := gitWorktreeWriteDirs(workDir, out, spec.WriteRoots); len(dirs) > 0 { |
| 201 | paths, _ := json.Marshal(dirs) |
| 202 | hint = "Git worktree metadata is outside the writable workspace. Retry this command with additional_write_dirs: " + string(paths) + " and a justification; the host will request approval for these directories." |
| 203 | } else if len(p.AdditionalWriteDirs) > 0 { |
| 204 | hint = "The command encountered a permission denial under the OS sandbox. Additional writable directories may not resolve a child-process or named-object denial." |
| 205 | } |
| 206 | if denialID := sandbox.IssueDenial(p.Command, preset); denialID != "" { |
| 207 | hint += " If the command cannot be expressed with additional_write_dirs, request danger-full-access for this exact retry with denial_id " + denialID + "." |
| 208 | } |
| 209 | return appendSessionDataHint(out, hint) |
| 210 | } |
| 211 | |
| 212 | // gitMetadataNamedIn returns the protected Git metadata paths a failed |
| 213 | // command's output names, spelled as it names them. The paths are the host's |
| 214 | // own identities; nothing here reads the wording around them. |
| 215 | func gitMetadataNamedIn(output string, spec sandbox.Spec, workDir string) []string { |
| 216 | var named []string |
| 217 | for _, path := range sandbox.GitMetadataPaths(spec) { |
| 218 | for _, spelling := range gitMetadataSpellings(path, workDir) { |
| 219 | if containsPathToken(output, spelling) { |
| 220 | named = append(named, spelling) |
| 221 | break |
| 222 | } |
| 223 | } |
| 224 | } |
| 225 | return named |
| 226 | } |
| 227 | |
| 228 | // containsPathToken reports whether path occurs in output as a whole path, not |
| 229 | // as the tail or prefix of a longer one: `.git` inside `main/.git/objects` is |
| 230 | // not the workspace's `.git`. A directory spelling ends in a separator. |
| 231 | func containsPathToken(output, path string) bool { |
| 232 | dirSpelling := strings.HasSuffix(path, string(filepath.Separator)) |
| 233 | for from := 0; ; { |
| 234 | i := strings.Index(output[from:], path) |
| 235 | if i < 0 { |
| 236 | return false |
| 237 | } |
| 238 | start, end := from+i, from+i+len(path) |
| 239 | if (start == 0 || !isPathByte(output[start-1])) && (dirSpelling || end == len(output) || !isPathByte(output[end])) { |
| 240 | return true |
| 241 | } |
| 242 | from = start + 1 |
| 243 | } |
| 244 | } |
| 245 | |
| 246 | func isPathByte(c byte) bool { |
| 247 | return c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' || c >= '0' && c <= '9' || strings.IndexByte("._-/\\~", c) >= 0 |
| 248 | } |
| 249 | |
| 250 | func gitMetadataSpellings(path, workDir string) []string { |
| 251 | out := []string{path} |
| 252 | if base, err := sandbox.ResolveAbsPath(workDir); err == nil { |
| 253 | if rel, err := filepath.Rel(base, path); err == nil && filepath.IsLocal(strings.TrimSuffix(rel, string(filepath.Separator))) { |
| 254 | if strings.HasSuffix(path, string(filepath.Separator)) { |
| 255 | rel += string(filepath.Separator) |
| 256 | } |
| 257 | out = append(out, rel) |
| 258 | } |
| 259 | } |
| 260 | return out |
| 261 | } |
| 262 | |
| 263 | func gitMetadataDeniedHint(named []string) string { |
| 264 | return "[host] " + sandbox.GitMetadataDeniedCode + ": " + strings.Join(named, ", ") + |
| 265 | " is Git configuration or hooks that the host's own git reads, so the sandbox keeps it read-only; a write there did not happen even if the command exited 0. additional_write_dirs cannot grant it, and everything else under .git stays writable." |
| 266 | } |
| 267 | |
| 268 | func gitWorktreeWriteDirs(workDir, output string, writeRoots []string) []string { |
| 269 | gitDir, commonDir := linkedGitMetadataDirs(workDir) |
| 270 | if gitDir == "" { |
| 271 | return nil |
| 272 | } |
| 273 | match := gitWriteDeniedPath.FindStringSubmatch(output) |
| 274 | if len(match) != 2 || !filepath.IsAbs(match[1]) { |
| 275 | return nil |
| 276 | } |
| 277 | denied, err := sandbox.ResolveAbsPath(match[1]) |
| 278 | if err != nil { |
| 279 | return nil |
| 280 | } |
| 281 | objects := filepath.Join(commonDir, "objects") |
| 282 | refs := filepath.Join(commonDir, "refs") |
| 283 | logs := filepath.Join(commonDir, "logs") |
| 284 | if !sandbox.PathWithin(gitDir, denied) && !sandbox.PathWithin(objects, denied) && !sandbox.PathWithin(refs, denied) && !sandbox.PathWithin(logs, denied) { |
| 285 | return nil |
| 286 | } |
| 287 | paths := []string{gitDir, objects} |
| 288 | if sandbox.PathWithin(refs, denied) { |
| 289 | paths = append(paths, refs) |
| 290 | } |
| 291 | if sandbox.PathWithin(logs, denied) { |
| 292 | paths = append(paths, logs) |
| 293 | } |
| 294 | var missing []string |
| 295 | for _, path := range paths { |
| 296 | covered := false |
| 297 | for _, root := range writeRoots { |
| 298 | if sandbox.PathWithin(root, path) { |
| 299 | covered = true |
| 300 | break |
| 301 | } |
| 302 | } |
| 303 | if !covered { |
| 304 | missing = append(missing, path) |
| 305 | } |
| 306 | } |
| 307 | return missing |
| 308 | } |
| 309 | |
| 310 | var gitWriteDeniedPath = regexp.MustCompile(`(?im)['"]([^'"\n]+)['"]:[ \t]*(?:operation not permitted|read-only file system|permission denied)`) |
| 311 | |
| 312 | func linkedGitMetadataDirs(workDir string) (string, string) { |
| 313 | for dir := filepath.Clean(workDir); filepath.IsAbs(dir); dir = filepath.Dir(dir) { |
| 314 | gitPath := filepath.Join(dir, ".git") |
| 315 | pointer, err := readSmallGitFile(gitPath) |
| 316 | if err == nil { |
| 317 | if !strings.HasPrefix(pointer, "gitdir: ") { |
| 318 | return "", "" |
| 319 | } |
| 320 | gitDir := strings.TrimSpace(strings.TrimPrefix(pointer, "gitdir: ")) |
| 321 | if !filepath.IsAbs(gitDir) { |
| 322 | gitDir = filepath.Join(dir, gitDir) |
| 323 | } |
| 324 | gitDir, err = filepath.EvalSymlinks(gitDir) |
| 325 | if err != nil { |
| 326 | return "", "" |
| 327 | } |
| 328 | common, err := readSmallGitFile(filepath.Join(gitDir, "commondir")) |
| 329 | if err != nil { |
| 330 | return "", "" |
| 331 | } |
| 332 | commonDir := common |
| 333 | if !filepath.IsAbs(commonDir) { |
| 334 | commonDir = filepath.Join(gitDir, commonDir) |
| 335 | } |
| 336 | commonDir, err = filepath.EvalSymlinks(commonDir) |
| 337 | if err != nil || filepath.Dir(gitDir) != filepath.Join(commonDir, "worktrees") { |
| 338 | return "", "" |
| 339 | } |
| 340 | backlink, err := readSmallGitFile(filepath.Join(gitDir, "gitdir")) |
| 341 | if err != nil { |
| 342 | return "", "" |
| 343 | } |
| 344 | if !filepath.IsAbs(backlink) { |
| 345 | backlink = filepath.Join(gitDir, backlink) |
| 346 | } |
| 347 | backlink, err = sandbox.ResolveAbsPath(backlink) |
| 348 | if err != nil { |
| 349 | return "", "" |
| 350 | } |
| 351 | actualGitPath, err := sandbox.ResolveAbsPath(gitPath) |
| 352 | if err != nil || backlink != actualGitPath { |
| 353 | return "", "" |
| 354 | } |
| 355 | for _, path := range []string{"objects", "refs"} { |
| 356 | info, err := os.Lstat(filepath.Join(commonDir, path)) |
| 357 | if err != nil || !info.IsDir() { |
| 358 | return "", "" |
| 359 | } |
| 360 | } |
| 361 | if info, err := os.Lstat(filepath.Join(commonDir, "HEAD")); err != nil || !info.Mode().IsRegular() { |
| 362 | return "", "" |
| 363 | } |
| 364 | return gitDir, commonDir |
| 365 | } else if !os.IsNotExist(err) { |
| 366 | return "", "" |
| 367 | } |
| 368 | if filepath.Dir(dir) == dir { |
| 369 | break |
| 370 | } |
| 371 | } |
| 372 | return "", "" |
| 373 | } |
| 374 | |
| 375 | func readSmallGitFile(path string) (string, error) { |
| 376 | info, err := os.Lstat(path) |
| 377 | if err != nil { |
| 378 | return "", err |
| 379 | } |
| 380 | if !info.Mode().IsRegular() || info.Size() > 4096 { |
| 381 | return "", fmt.Errorf("not a small regular Git metadata file: %s", path) |
| 382 | } |
| 383 | data, err := os.ReadFile(path) |
| 384 | return strings.TrimSpace(string(data)), err |
| 385 | } |
| 386 |