| 1 | package builtin |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "fmt" |
| 6 | "os" |
| 7 | "path/filepath" |
| 8 | "runtime" |
| 9 | "strings" |
| 10 | "time" |
| 11 | |
| 12 | "reasonix/internal/netclient" |
| 13 | "reasonix/internal/persistentshell" |
| 14 | "reasonix/internal/sandbox" |
| 15 | "reasonix/internal/secrets" |
| 16 | "reasonix/internal/sessiontemp" |
| 17 | "reasonix/internal/tool" |
| 18 | ) |
| 19 | |
| 20 | // ConfineBash returns the bash built-in bound to an OS-sandbox spec, overriding |
| 21 | // the unconfined instance registered at init. When the spec enforces, bash runs |
| 22 | // each command through the sandbox (see package sandbox). guard appends a |
| 23 | // warning to command output when the command references Reasonix's own session |
| 24 | // stores (see SessionDataGuard). |
| 25 | // |
| 26 | // Session-private temporary directories are bound separately via |
| 27 | // BindSessionTemp (or Workspace.SessionTemp) so the timeout variadic form stays |
| 28 | // stable for existing callers. |
| 29 | func ConfineBash(spec sandbox.Spec, guard SessionDataGuard, timeout ...time.Duration) tool.Tool { |
| 30 | shell := spec.Shell |
| 31 | if shell.Path == "" { |
| 32 | shell = sandbox.ResolveShell("", "", nil) |
| 33 | } |
| 34 | b := bash{sb: spec, shell: shell, guard: guard} |
| 35 | if shell.Kind == sandbox.ShellPowerShell { |
| 36 | b.name = "pwsh" |
| 37 | } |
| 38 | if len(timeout) > 0 { |
| 39 | b.timeout = timeout[0] |
| 40 | } |
| 41 | return b |
| 42 | } |
| 43 | |
| 44 | // AliasBash returns the same confined shell executor under a compatibility |
| 45 | // name. The alias shares every policy and sandbox binding with the primary tool. |
| 46 | func AliasBash(tl tool.Tool, name string) (tool.Tool, bool) { |
| 47 | b, ok := tl.(bash) |
| 48 | if !ok || strings.TrimSpace(name) == "" { |
| 49 | return nil, false |
| 50 | } |
| 51 | b.name = strings.TrimSpace(name) |
| 52 | return b, true |
| 53 | } |
| 54 | |
| 55 | // BindSessionTemp attaches a session-private temporary directory manager to a |
| 56 | // confined bash (and, when present, grep) tool. ok is false when tl is not a |
| 57 | // bash tool (including wrappers that do not unwrap). |
| 58 | func BindSessionTemp(tl tool.Tool, m *sessiontemp.Manager) (tool.Tool, bool) { |
| 59 | switch t := tl.(type) { |
| 60 | case bash: |
| 61 | t.sessionTemp = m |
| 62 | return t, true |
| 63 | case grepTool: |
| 64 | t.sessionTemp = m |
| 65 | return t, true |
| 66 | default: |
| 67 | return nil, false |
| 68 | } |
| 69 | } |
| 70 | |
| 71 | // BindPersistentShell attaches a session-scoped PTY manager to a bash tool. |
| 72 | // ok is false when tl is not a bash value (including wrappers that do not unwrap). |
| 73 | func BindPersistentShell(tl tool.Tool, m *persistentshell.Manager) (tool.Tool, bool) { |
| 74 | b, ok := tl.(bash) |
| 75 | if !ok { |
| 76 | return nil, false |
| 77 | } |
| 78 | b.persistent = m |
| 79 | return b, true |
| 80 | } |
| 81 | |
| 82 | // RebindBashWriteRoots returns a copy of bash with its complete write surface |
| 83 | // narrowed to roots. ok is false when tl is not a confined bash tool, when the |
| 84 | // sandbox is not enforcing (cannot honour narrower roots), or when roots is empty. |
| 85 | // Callers that wrap bash (e.g. foreground-only subagent wrappers) must unwrap |
| 86 | // before calling and re-wrap the result. |
| 87 | func RebindBashWriteRoots(tl tool.Tool, roots []string) (tool.Tool, bool) { |
| 88 | b, ok := tl.(bash) |
| 89 | if !ok || !b.sb.Enforce() { |
| 90 | return nil, false |
| 91 | } |
| 92 | rs := realRoots(roots) |
| 93 | if len(rs) == 0 { |
| 94 | return nil, false |
| 95 | } |
| 96 | spec := b.sb |
| 97 | spec.WriteRoots = rs |
| 98 | // Sub-agent claims are strict capability boundaries. Do not add the normal |
| 99 | // build-cache and temporary-directory allowances outside the claimed roots. |
| 100 | spec.MinimalWrites = true |
| 101 | b.sb = spec |
| 102 | // rootSet is preserved so later session grants still apply inside the claim. |
| 103 | // sessionTemp is preserved: sub-agent write-root rebinding must not drop |
| 104 | // the session-private temporary directory manager. |
| 105 | return b, true |
| 106 | } |
| 107 | |
| 108 | // ConfineWebFetch returns the web_fetch built-in bound to Reasonix proxy |
| 109 | // settings while preserving its SSRF-guarded dialer. |
| 110 | func ConfineWebFetch(proxySpec netclient.ProxySpec) tool.Tool { |
| 111 | return webFetch{proxySpec: proxySpec} |
| 112 | } |
| 113 | |
| 114 | // ConfineWriters returns the file-writing built-ins (write_file, edit_file, |
| 115 | // multi_edit, move_file, notebook_edit) bound to roots — the only directories they may |
| 116 | // modify. The composition root adds these to the per-run registry to override |
| 117 | // the unconfined instances registered at init time, so writes stay inside the |
| 118 | // workspace by default. roots may be relative; they are resolved to absolute, |
| 119 | // symlink-free paths once here. An empty roots slice yields unconfined writers. |
| 120 | // guard additionally rejects writes into Reasonix's own session stores even |
| 121 | // when the roots would allow them (see SessionDataGuard). managed names the |
| 122 | // Reasonix-owned config files writable outside the roots after a fresh human |
| 123 | // approval (see ManagedConfigPaths). |
| 124 | func ConfineWriters(roots []string, guard SessionDataGuard, managed ManagedConfigPaths) []tool.Tool { |
| 125 | rs := realRoots(roots) |
| 126 | return []tool.Tool{ |
| 127 | writeFile{roots: rs, guard: guard, managed: managed}, |
| 128 | editFile{roots: rs, guard: guard, managed: managed}, |
| 129 | multiEdit{roots: rs, guard: guard, managed: managed}, |
| 130 | moveFile{roots: rs, guard: guard, managed: managed}, |
| 131 | notebookEdit{roots: rs, guard: guard, managed: managed}, |
| 132 | deleteRange{roots: rs, guard: guard, managed: managed}, |
| 133 | deleteSymbol{roots: rs, guard: guard, managed: managed}, |
| 134 | } |
| 135 | } |
| 136 | |
| 137 | // ConfineReaders returns the read/list/search built-ins (read_file, glob, |
| 138 | // ls, code_index) bound to forbidRoots — directories the agent may not read or list. |
| 139 | // grep is handled separately by ConfineSearch so it can carry the |
| 140 | // sandbox spec for its ripgrep subprocess. |
| 141 | // An empty forbidRoots slice yields unconfined readers. |
| 142 | func ConfineReaders(forbidRoots []string) []tool.Tool { |
| 143 | rs := realRoots(forbidRoots) |
| 144 | return []tool.Tool{ |
| 145 | readFile{forbidRoots: rs}, |
| 146 | viewImage{forbidRoots: rs}, |
| 147 | listDir{forbidRoots: rs}, |
| 148 | globTool{forbidRoots: rs}, |
| 149 | codeIndex{forbidRoots: rs}, |
| 150 | } |
| 151 | } |
| 152 | |
| 153 | // confineRead reports whether target is inside any forbidRoot or, when the |
| 154 | // user enabled [secrets] protect_sensitive_files, matches Reasonix's built-in |
| 155 | // sensitive credential path denylist. An empty forbidRoots slice with the |
| 156 | // denylist off is unconfined (returns false). Callers should return a result |
| 157 | // that mimics the directory appearing empty, matching the tmpfs semantics the |
| 158 | // bubblewrap sandbox provides. Deny-side, so the check folds case on |
| 159 | // case-insensitive platforms (see withinFold): a case-variant of a forbidden |
| 160 | // path reaches the same bytes there. |
| 161 | func confineRead(forbidRoots []string, target string) bool { |
| 162 | protect := secrets.ProtectSensitiveFiles() |
| 163 | if len(forbidRoots) == 0 && !protect { |
| 164 | return false |
| 165 | } |
| 166 | abs, err := realPath(target) |
| 167 | if err != nil { |
| 168 | return false // can't resolve -> let the caller's normal error path handle it |
| 169 | } |
| 170 | if protect && sensitiveReadPath(abs) { |
| 171 | return true |
| 172 | } |
| 173 | for _, r := range forbidRoots { |
| 174 | if withinFold(r, abs) { |
| 175 | return true |
| 176 | } |
| 177 | } |
| 178 | return false |
| 179 | } |
| 180 | |
| 181 | // ReadPathForbidden applies the same resolved-path deny policy used by the |
| 182 | // built-in readers. Host-side viewers call this when they re-open a resource |
| 183 | // recorded by a trusted tool result so a later configuration or sensitive-file |
| 184 | // policy change cannot be bypassed by an old presentation card. |
| 185 | func ReadPathForbidden(forbidRoots []string, target string) bool { |
| 186 | return confineRead(realRoots(forbidRoots), target) |
| 187 | } |
| 188 | |
| 189 | func sensitiveReadPath(abs string) bool { |
| 190 | clean := filepath.Clean(abs) |
| 191 | name := strings.ToLower(filepath.Base(clean)) |
| 192 | switch name { |
| 193 | case ".env", ".git-credentials", ".netrc": |
| 194 | return true |
| 195 | } |
| 196 | for _, ext := range []string{".pem", ".key", ".p12", ".pfx"} { |
| 197 | if strings.HasSuffix(name, ext) { |
| 198 | return true |
| 199 | } |
| 200 | } |
| 201 | home, err := os.UserHomeDir() |
| 202 | if err == nil && home != "" { |
| 203 | if withinFold(filepath.Join(home, ".ssh"), clean) { |
| 204 | return true |
| 205 | } |
| 206 | } |
| 207 | return false |
| 208 | } |
| 209 | |
| 210 | // realRoots resolves each root to an absolute, symlink-free path, dropping any |
| 211 | // that cannot be made absolute. Resolving here (once) means the per-call check |
| 212 | // only has to resolve the target. |
| 213 | func realRoots(roots []string) []string { |
| 214 | out := make([]string, 0, len(roots)) |
| 215 | for _, r := range roots { |
| 216 | if real, err := realPath(r); err == nil { |
| 217 | out = append(out, real) |
| 218 | } |
| 219 | } |
| 220 | return out |
| 221 | } |
| 222 | |
| 223 | // confine reports an error when target resolves outside every root. An empty |
| 224 | // roots slice is unconfined (returns nil) — the safe default for the built-in |
| 225 | // templates before a run configures the workspace. The error text is written |
| 226 | // for the model: it names the boundary and how the user can widen it. |
| 227 | func confine(roots []string, target string) error { |
| 228 | if len(roots) == 0 { |
| 229 | return nil |
| 230 | } |
| 231 | abs, err := realPath(target) |
| 232 | if err != nil { |
| 233 | return fmt.Errorf("resolve %s: %w", target, err) |
| 234 | } |
| 235 | for _, r := range roots { |
| 236 | if within(r, abs) { |
| 237 | return nil |
| 238 | } |
| 239 | } |
| 240 | return fmt.Errorf("path %q is outside the writable roots (writes are confined to %s); "+ |
| 241 | "write inside the workspace or a configured allow_write root, or widen [sandbox] workspace_root / allow_write in reasonix.toml", |
| 242 | target, strings.Join(roots, ", ")) |
| 243 | } |
| 244 | |
| 245 | // confineWrite is the write-tool boundary check: workspace confinement first, |
| 246 | // then the session-data guard, so a write can be inside the roots (e.g. a |
| 247 | // home-directory workspace covering the state root) and still be refused when |
| 248 | // it targets Reasonix's own session stores. A target outside every root that |
| 249 | // matches a Reasonix-managed config file (see ManagedConfigPaths) may proceed |
| 250 | // after a fresh per-write human approval carried on ctx; without an approver it |
| 251 | // fails closed with the original confinement error semantics. |
| 252 | func confineWrite(ctx context.Context, roots []string, guard SessionDataGuard, managed ManagedConfigPaths, target string) error { |
| 253 | confineErr := confine(roots, target) |
| 254 | if confineErr == nil { |
| 255 | return guard.Check(target) |
| 256 | } |
| 257 | if !managed.Match(target) { |
| 258 | return confineErr |
| 259 | } |
| 260 | if err := guard.Check(target); err != nil { |
| 261 | return err |
| 262 | } |
| 263 | return managed.approve(ctx, target) |
| 264 | } |
| 265 | |
| 266 | // confinePreview is stricter than confineWrite because Preview runs before the |
| 267 | // permission gate and reads old content into the approval card and session. |
| 268 | // Managed config files outside the roots therefore stay unreadable here; only |
| 269 | // Execute may use their explicit, per-write approval escape hatch. |
| 270 | func confinePreview(roots []string, guard SessionDataGuard, _ ManagedConfigPaths, target string) error { |
| 271 | if err := confine(roots, target); err != nil { |
| 272 | return err |
| 273 | } |
| 274 | return guard.Check(target) |
| 275 | } |
| 276 | |
| 277 | // realPath resolves path to an absolute, symlink-free form. Because a write |
| 278 | // target need not exist yet (write_file creates it), it resolves the deepest |
| 279 | // existing ancestor with EvalSymlinks and re-appends the not-yet-existing tail. |
| 280 | // This stops a symlinked directory from smuggling a write outside a root. |
| 281 | func realPath(path string) (string, error) { |
| 282 | abs, err := filepath.Abs(path) |
| 283 | if err != nil { |
| 284 | return "", err |
| 285 | } |
| 286 | abs = filepath.Clean(abs) |
| 287 | tail := "" |
| 288 | cur := abs |
| 289 | for { |
| 290 | if real, err := filepath.EvalSymlinks(cur); err == nil { |
| 291 | return filepath.Join(real, tail), nil |
| 292 | } |
| 293 | parent := filepath.Dir(cur) |
| 294 | if parent == cur { |
| 295 | return abs, nil // nothing along the path exists; use the cleaned abs |
| 296 | } |
| 297 | tail = filepath.Join(filepath.Base(cur), tail) |
| 298 | cur = parent |
| 299 | } |
| 300 | } |
| 301 | |
| 302 | // within reports whether path is at or below root. Both must be absolute, |
| 303 | // cleaned, symlink-free. It uses filepath.Rel so it is correct across volumes |
| 304 | // and is not fooled by a prefix that only matches a partial path component |
| 305 | // (e.g. /work-other is not within /work). |
| 306 | func within(root, path string) bool { |
| 307 | rel, err := filepath.Rel(root, path) |
| 308 | if err != nil { |
| 309 | return false |
| 310 | } |
| 311 | return rel == "." || (rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator))) |
| 312 | } |
| 313 | |
| 314 | // foldPaths reports whether deny-side path checks on this platform must ignore |
| 315 | // case: the default filesystems on Windows (NTFS) and macOS (APFS/HFS+) are |
| 316 | // case-insensitive, so /X/SESSIONS and /x/sessions reach the same bytes and a |
| 317 | // case-variant must not slip past a deny rule. EvalSymlinks does NOT normalize |
| 318 | // case, so realPath alone cannot be relied on for this. |
| 319 | var foldPaths = runtime.GOOS == "windows" || runtime.GOOS == "darwin" |
| 320 | |
| 321 | // withinFold is within with platform case folding, for DENY-side checks only |
| 322 | // (forbid-read roots, the session-data guard). Allow-side checks (confine) |
| 323 | // keep the exact within: folding an allow rule on a case-sensitive filesystem |
| 324 | // would wave a genuinely different directory through, whereas folding a deny |
| 325 | // rule only ever refuses more. On a case-sensitive macOS volume this can |
| 326 | // refuse a legitimate same-letters-different-case path; the error text points |
| 327 | // at allow_write / forbid_read config as the way out. |
| 328 | func withinFold(root, path string) bool { |
| 329 | if foldPaths { |
| 330 | return within(strings.ToLower(root), strings.ToLower(path)) |
| 331 | } |
| 332 | return within(root, path) |
| 333 | } |
| 334 |