| 1 | // Package sandbox wraps a shell command in an OS-level jail so the model's |
| 2 | // `bash` calls are confined: reads stay mostly free, writes stay inside the |
| 3 | // writable roots (workspace, extras, temp and toolchain caches), forbid-read |
| 4 | // roots are hidden, and network egress is optional. It is the *enforcement* |
| 5 | // layer beneath the permission rules: a permitted command still cannot escape. |
| 6 | // macOS uses Seatbelt and Linux uses bubblewrap; where a backend is missing, |
| 7 | // restricted presets fail closed. Windows has no OS-level shell sandbox (see |
| 8 | // OSSandboxSupported). File-writer built-ins are confined in tool/builtin. |
| 9 | package sandbox |
| 10 | |
| 11 | import "runtime" |
| 12 | |
| 13 | // Spec describes how to confine one command. The zero value (Mode == "") does |
| 14 | // not enforce, so an unconfigured caller runs commands unchanged. |
| 15 | type Spec struct { |
| 16 | // Mode is "enforce" to wrap the command, anything else (incl. "off" and "") |
| 17 | // to run it unwrapped. |
| 18 | Mode string |
| 19 | // ReadOnly removes every ordinary writable mount/allowance. It is distinct |
| 20 | // from an empty WriteRoots slice, whose historical meaning is unconfigured. |
| 21 | ReadOnly bool |
| 22 | // WriteRoots are directories the command may write to (the workspace root |
| 23 | // plus any configured extras). Platforms may add command-scoped temp/cache |
| 24 | // roots so builds and package managers keep working without broad writes. |
| 25 | WriteRoots []string |
| 26 | // ForbidReadRoots are files or directories the command may not read from |
| 27 | // when confined. The OS sandbox denies access to these paths (macOS Seatbelt |
| 28 | // deny file-read* rules, Linux bubblewrap masks); on other platforms the |
| 29 | // in-process tools enforce this instead. |
| 30 | ForbidReadRoots []string |
| 31 | // Network allows network egress from inside the sandbox. Off blocks it so a |
| 32 | // command cannot exfiltrate or fetch; many dev commands (module/package |
| 33 | // downloads) need it, so it defaults on at the config layer. |
| 34 | Network bool |
| 35 | // MinimalWrites omits the broad build-tool cache write allowances used by |
| 36 | // the bash sandbox. MCP profiles set it and explicitly provide only their |
| 37 | // private state/temp directories (plus approved writer roots). |
| 38 | MinimalWrites bool |
| 39 | // Shell is the interpreter the bash tool runs under. A zero value (empty |
| 40 | // Path) means the tool resolves one itself; the composition root sets it from |
| 41 | // [tools.shell] so the configured choice rides along with the spec. |
| 42 | Shell Shell |
| 43 | // SessionTemp is the absolute path of the logical-session private temporary |
| 44 | // directory for this command. When set, Linux bubblewrap binds it at /tmp |
| 45 | // (instead of a fresh tmpfs), and all platforms export TMPDIR/TMP/TEMP so |
| 46 | // consecutive Bash calls in the same session share temporary files. Empty |
| 47 | // keeps the platform default (ephemeral tmpfs on Linux bwrap, host temp |
| 48 | // elsewhere). MCP and other independent sandboxes leave this empty. |
| 49 | SessionTemp string |
| 50 | // ProtectedWriteRoots are Reasonix session/state paths that stay read-only |
| 51 | // even when a broader WriteRoot such as the user's home directory would |
| 52 | // otherwise cover them. |
| 53 | ProtectedWriteRoots []string |
| 54 | } |
| 55 | |
| 56 | // Enforce reports whether the spec asks for confinement. |
| 57 | func (s Spec) Enforce() bool { return s.Mode == "enforce" } |
| 58 | |
| 59 | // OSSandboxSupported reports whether this platform can confine shell commands |
| 60 | // at the OS level. Windows cannot: its restricted-token backend is retired |
| 61 | // (same-user ACL denies locked hosts out; the token broke common toolchains). |
| 62 | func OSSandboxSupported() bool { return osSandboxSupportedForGOOS(runtime.GOOS) } |
| 63 | |
| 64 | func osSandboxSupportedForGOOS(goos string) bool { return goos != "windows" } |
| 65 | |
| 66 | // UnavailableMessage explains why an enforced shell sandbox cannot run and gives |
| 67 | // the platform-specific remediation. |
| 68 | func UnavailableMessage() string { |
| 69 | return "shell sandbox requested but unavailable on this host; refusing to run unconfined. " + UnavailableRemediation() |
| 70 | } |
| 71 | |
| 72 | // UnavailableRemediation is split out so status surfaces can append the same |
| 73 | // actionable hint without repeating the leading error. |
| 74 | func UnavailableRemediation() string { |
| 75 | switch runtime.GOOS { |
| 76 | case "linux": |
| 77 | return "Install bubblewrap (`bwrap`), or explicitly select Full access for an unconfined session." |
| 78 | case "darwin": |
| 79 | return "Ensure `sandbox-exec` is installed and usable (the host must allow `sandbox_apply`), or explicitly select Full access for an unconfined session." |
| 80 | case "windows": |
| 81 | return "Windows has no OS-level shell sandbox. Permission presets are enforced by Reasonix file tools and shell commands run as the current OS user; select Full access only when ordinary approval prompts should also be skipped." |
| 82 | default: |
| 83 | return "Restricted permission presets are unavailable on this platform; explicitly select Full access only when unconfined execution is intended." |
| 84 | } |
| 85 | } |
| 86 | |
| 87 | // BackendUnavailableReason is safe diagnostic copy for subsystems such as MCP |
| 88 | // that intentionally continue unconfined when the OS backend is missing. |
| 89 | func BackendUnavailableReason() string { |
| 90 | switch runtime.GOOS { |
| 91 | case "linux": |
| 92 | return "bubblewrap (bwrap) is unavailable on PATH" |
| 93 | case "darwin": |
| 94 | return "sandbox-exec is missing from PATH or unusable (sandbox_apply is restricted)" |
| 95 | case "windows": |
| 96 | return "Reasonix does not ship an OS-level sandbox on Windows" |
| 97 | default: |
| 98 | return "this platform has no supported Reasonix sandbox backend" |
| 99 | } |
| 100 | } |
| 101 |