| 1 | package builtin |
| 2 | |
| 3 | import ( |
| 4 | "path/filepath" |
| 5 | "strings" |
| 6 | "sync" |
| 7 | "time" |
| 8 | |
| 9 | "reasonix/internal/netclient" |
| 10 | "reasonix/internal/sandbox" |
| 11 | "reasonix/internal/sessiontemp" |
| 12 | "reasonix/internal/tool" |
| 13 | ) |
| 14 | |
| 15 | // Workspace builds a built-in tool set bound to a working directory, so several |
| 16 | // agents can run concurrently with independent path roots — a desktop front-end |
| 17 | // opening one tab per project, say. The process working directory is global and |
| 18 | // cannot be made per-agent (os.Chdir is process-wide), so each tool instead |
| 19 | // resolves relative paths against this directory and bash runs in it. |
| 20 | // |
| 21 | // Dir is that directory (empty yields process-cwd tools, byte-identical to the |
| 22 | // compile-time built-ins). WriteRoots confines the file-writers (as |
| 23 | // ConfineWriters); when empty and Dir is set, Dir itself becomes the sole write |
| 24 | // root, so writes stay inside the project by default. ForbidReadRoots confines |
| 25 | // the read/list/search built-ins so they cannot peek at the listed directories. |
| 26 | // Bash is the OS-sandbox spec for the bash tool (as ConfineBash). SessionGuard |
| 27 | // rejects writer-tool targets inside Reasonix's own session stores and makes |
| 28 | // bash warn when a command references them (see SessionDataGuard). |
| 29 | type Workspace struct { |
| 30 | Dir string |
| 31 | WriteRoots []string |
| 32 | ForbidReadRoots []string |
| 33 | Bash sandbox.Spec |
| 34 | BashTimeout time.Duration |
| 35 | Search SearchSpec |
| 36 | ProxySpec netclient.ProxySpec |
| 37 | ReadPaths *PathResolver |
| 38 | SessionGuard SessionDataGuard |
| 39 | // WriteRootSet is the live writable-root manager. When set, file writers |
| 40 | // and bash read Baseline+Session+per-call roots from it instead of a |
| 41 | // static WriteRoots snapshot. |
| 42 | WriteRootSet *sandbox.WritableRootSet |
| 43 | // ManagedConfig names the Reasonix-owned config files the file-writers may |
| 44 | // touch outside WriteRoots after a fresh per-write human approval (see |
| 45 | // ManagedConfigPaths). The zero value disables the escape hatch. |
| 46 | ManagedConfig ManagedConfigPaths |
| 47 | // FileOverlay, when non-nil, serves every file tool's content through the |
| 48 | // host transport (unsaved editor buffers) with disk fallback; Terminal, when |
| 49 | // non-nil, runs foreground bash in a host-owned terminal when the local OS |
| 50 | // sandbox is not enforcing. Both are nil outside host transports like ACP. |
| 51 | FileOverlay FileOverlay |
| 52 | Terminal TerminalRunner |
| 53 | // SessionTemp is the logical-session private temporary directory manager |
| 54 | // shared by bash and ripgrep-backed grep. Nil leaves those tools without a |
| 55 | // session-private temp (platform defaults apply). |
| 56 | SessionTemp *sessiontemp.Manager |
| 57 | // FileWriteReceipt receives prior-state evidence after a successful |
| 58 | // write_file mutation. It is instance-scoped so concurrent runtimes never |
| 59 | // record into another session's recovery ledger. |
| 60 | FileWriteReceipt func(path string, hadPrior bool, prior []byte) |
| 61 | } |
| 62 | |
| 63 | // Tools returns the built-in tools bound to the workspace, ready to Add to a |
| 64 | // per-run tool.Registry. An empty enabled list yields every built-in; otherwise |
| 65 | // only the named ones are returned (unknown names are ignored). This is the |
| 66 | // per-workspace analogue of the cli's process-cwd assembly — a desktop driver |
| 67 | // calls it once per agent instead of relying on the global working directory. |
| 68 | func (w Workspace) Tools(enabled ...string) []tool.Tool { |
| 69 | writeRoots := w.WriteRoots |
| 70 | if len(writeRoots) == 0 && w.Dir != "" { |
| 71 | writeRoots = []string{w.Dir} |
| 72 | } |
| 73 | roots := realRoots(writeRoots) |
| 74 | forbidRoots := realRoots(w.ForbidReadRoots) |
| 75 | |
| 76 | shell := w.Bash.Shell |
| 77 | if shell.Path == "" { |
| 78 | shell = sandbox.ResolveShell("", "", nil) |
| 79 | } |
| 80 | shellTool := bash{workDir: w.Dir, sb: w.Bash, shell: shell, timeout: w.BashTimeout, guard: w.SessionGuard, terminal: w.Terminal, sessionTemp: w.SessionTemp} |
| 81 | if shell.Kind == sandbox.ShellPowerShell { |
| 82 | shellTool.name = "pwsh" |
| 83 | } |
| 84 | legacyShell := tool.Tool(shellTool) |
| 85 | if shellTool.Name() == "pwsh" { |
| 86 | legacyShell, _ = AliasBash(shellTool, "bash") |
| 87 | } |
| 88 | overrides := map[string]tool.Tool{ |
| 89 | "view_image": viewImage{workDir: w.Dir, paths: w.ReadPaths, forbidRoots: forbidRoots}, |
| 90 | "present": present{workDir: w.Dir, paths: w.ReadPaths, forbidRoots: forbidRoots}, |
| 91 | "read_file": readFile{workDir: w.Dir, paths: w.ReadPaths, forbidRoots: forbidRoots, overlay: w.FileOverlay}, |
| 92 | "write_file": writeFile{workDir: w.Dir, roots: roots, guard: w.SessionGuard, managed: w.ManagedConfig, overlay: w.FileOverlay, receipt: w.FileWriteReceipt}, |
| 93 | "edit_file": editFile{workDir: w.Dir, roots: roots, guard: w.SessionGuard, managed: w.ManagedConfig, overlay: w.FileOverlay}, |
| 94 | "multi_edit": multiEdit{workDir: w.Dir, roots: roots, guard: w.SessionGuard, managed: w.ManagedConfig, overlay: w.FileOverlay}, |
| 95 | "move_file": moveFile{workDir: w.Dir, roots: roots, guard: w.SessionGuard, managed: w.ManagedConfig}, |
| 96 | "notebook_edit": notebookEdit{workDir: w.Dir, roots: roots, guard: w.SessionGuard, managed: w.ManagedConfig, overlay: w.FileOverlay}, |
| 97 | "delete_range": deleteRange{workDir: w.Dir, roots: roots, guard: w.SessionGuard, managed: w.ManagedConfig, overlay: w.FileOverlay}, |
| 98 | "delete_symbol": deleteSymbol{workDir: w.Dir, roots: roots, guard: w.SessionGuard, managed: w.ManagedConfig, overlay: w.FileOverlay}, |
| 99 | "code_index": codeIndex{workDir: w.Dir, forbidRoots: forbidRoots}, |
| 100 | "bash": legacyShell, |
| 101 | "pwsh": shellTool, |
| 102 | "ls": listDir{workDir: w.Dir, paths: w.ReadPaths, forbidRoots: forbidRoots}, |
| 103 | "glob": globTool{workDir: w.Dir, paths: w.ReadPaths, forbidRoots: forbidRoots}, |
| 104 | "grep": grepTool{workDir: w.Dir, paths: w.ReadPaths, rg: w.Search.RgPath, forbidRoots: forbidRoots, sb: w.Bash, sessionTemp: w.SessionTemp, overlay: w.FileOverlay}, |
| 105 | "web_fetch": webFetch{proxySpec: w.ProxySpec}, |
| 106 | } |
| 107 | all := tool.Builtins() |
| 108 | if len(enabled) == 0 { |
| 109 | for i, t := range all { |
| 110 | if bound, ok := overrides[t.Name()]; ok { |
| 111 | all[i] = BindWriteRootSet(bound, w.WriteRootSet) |
| 112 | } |
| 113 | } |
| 114 | if shellTool.Name() == "pwsh" { |
| 115 | primary := BindWriteRootSet(shellTool, w.WriteRootSet) |
| 116 | withoutLegacy := all[:0] |
| 117 | for _, t := range all { |
| 118 | if t.Name() != "bash" { |
| 119 | withoutLegacy = append(withoutLegacy, t) |
| 120 | } |
| 121 | } |
| 122 | all = append(withoutLegacy, primary) |
| 123 | } |
| 124 | return all |
| 125 | } |
| 126 | want := make(map[string]bool, len(enabled)) |
| 127 | for _, n := range enabled { |
| 128 | if tool.IsShellToolName(n) { |
| 129 | n = "bash" |
| 130 | } |
| 131 | want[n] = true |
| 132 | } |
| 133 | out := make([]tool.Tool, 0, len(enabled)) |
| 134 | for _, t := range all { |
| 135 | if want[t.Name()] { |
| 136 | if shellTool.Name() == "pwsh" && t.Name() == "bash" { |
| 137 | continue |
| 138 | } |
| 139 | if bound, ok := overrides[t.Name()]; ok { |
| 140 | t = BindWriteRootSet(bound, w.WriteRootSet) |
| 141 | } |
| 142 | out = append(out, t) |
| 143 | } |
| 144 | } |
| 145 | if shellTool.Name() == "pwsh" && want["bash"] { |
| 146 | primary := BindWriteRootSet(shellTool, w.WriteRootSet) |
| 147 | out = append(out, primary) |
| 148 | } |
| 149 | return out |
| 150 | } |
| 151 | |
| 152 | // resolveIn maps a tool's path/pattern argument into a working directory. With |
| 153 | // an empty workDir it returns p unchanged — the process-cwd behavior the |
| 154 | // compile-time built-ins have always had, so existing callers are unaffected. |
| 155 | // Otherwise a relative p is joined onto workDir; an absolute p is returned as-is |
| 156 | // (an explicit absolute path is honored verbatim — the write-confiner, not this, |
| 157 | // enforces the workspace boundary). An empty p resolves to workDir itself, so a |
| 158 | // defaulted "." (ls/grep) targets the workspace root. |
| 159 | func resolveIn(workDir, p string) string { |
| 160 | if workDir == "" { |
| 161 | return p |
| 162 | } |
| 163 | if p == "" || p == "." { |
| 164 | return workDir |
| 165 | } |
| 166 | if filepath.IsAbs(p) { |
| 167 | return p |
| 168 | } |
| 169 | return filepath.Join(workDir, p) |
| 170 | } |
| 171 | |
| 172 | // PathResolver maps session-authorized token paths to local read-only roots. |
| 173 | // It is intentionally used only by read tools; write tools continue to rely on |
| 174 | // WriteRoots confinement and never resolve these aliases. |
| 175 | type PathResolver struct { |
| 176 | mu sync.RWMutex |
| 177 | roots map[string]string |
| 178 | } |
| 179 | |
| 180 | // NewPathResolver returns a resolver whose root set can be updated after the |
| 181 | // workspace tools have been registered. |
| 182 | func NewPathResolver() *PathResolver { |
| 183 | return &PathResolver{roots: map[string]string{}} |
| 184 | } |
| 185 | |
| 186 | // RegisterReadRoot authorizes token and all of its local subpaths to resolve |
| 187 | // under root for read-only tools in this session. |
| 188 | func (r *PathResolver) RegisterReadRoot(token, root string) { |
| 189 | if r == nil { |
| 190 | return |
| 191 | } |
| 192 | token = normalizeReadToken(token) |
| 193 | root = filepath.Clean(strings.TrimSpace(root)) |
| 194 | if token == "" || root == "" { |
| 195 | return |
| 196 | } |
| 197 | r.mu.Lock() |
| 198 | defer r.mu.Unlock() |
| 199 | if r.roots == nil { |
| 200 | r.roots = map[string]string{} |
| 201 | } |
| 202 | r.roots[token] = root |
| 203 | } |
| 204 | |
| 205 | // Resolve maps a submitted path or pattern to a local path when it begins with |
| 206 | // a registered token. ok is false for ordinary workspace paths. |
| 207 | func (r *PathResolver) Resolve(path string) (ResolvedPath, bool) { |
| 208 | if r == nil { |
| 209 | return ResolvedPath{}, false |
| 210 | } |
| 211 | key := normalizeReadToken(path) |
| 212 | if key == "" { |
| 213 | return ResolvedPath{}, false |
| 214 | } |
| 215 | r.mu.RLock() |
| 216 | defer r.mu.RUnlock() |
| 217 | if root, ok := r.roots[key]; ok { |
| 218 | return ResolvedPath{Path: root, DisplayPath: key, Root: root, DisplayRoot: key, External: true}, true |
| 219 | } |
| 220 | for token, root := range r.roots { |
| 221 | if !strings.HasPrefix(key, token+"/") { |
| 222 | continue |
| 223 | } |
| 224 | sub, ok := cleanReadSubpath(strings.TrimPrefix(key, token+"/")) |
| 225 | if !ok { |
| 226 | return ResolvedPath{}, false |
| 227 | } |
| 228 | return ResolvedPath{ |
| 229 | Path: filepath.Join(root, filepath.FromSlash(sub)), |
| 230 | DisplayPath: token + "/" + sub, |
| 231 | Root: root, |
| 232 | DisplayRoot: token, |
| 233 | External: true, |
| 234 | }, true |
| 235 | } |
| 236 | return ResolvedPath{}, false |
| 237 | } |
| 238 | |
| 239 | // ResolvedPath carries both the local path used for I/O and the token path that |
| 240 | // should appear in tool output. |
| 241 | type ResolvedPath struct { |
| 242 | Path string |
| 243 | DisplayPath string |
| 244 | Root string |
| 245 | DisplayRoot string |
| 246 | External bool |
| 247 | } |
| 248 | |
| 249 | func (p ResolvedPath) DisplayFor(path string) string { |
| 250 | if !p.External { |
| 251 | return path |
| 252 | } |
| 253 | rel, err := filepath.Rel(p.Root, path) |
| 254 | if err != nil || !filepath.IsLocal(rel) { |
| 255 | return path |
| 256 | } |
| 257 | if rel == "." { |
| 258 | return p.DisplayRoot |
| 259 | } |
| 260 | return filepath.ToSlash(filepath.Join(p.DisplayRoot, rel)) |
| 261 | } |
| 262 | |
| 263 | func (p ResolvedPath) ErrorText(err error) string { |
| 264 | if err == nil { |
| 265 | return "" |
| 266 | } |
| 267 | msg := err.Error() |
| 268 | if !p.External { |
| 269 | return msg |
| 270 | } |
| 271 | return strings.ReplaceAll(msg, p.Root, p.DisplayRoot) |
| 272 | } |
| 273 | |
| 274 | func resolveReadablePath(workDir, path string, resolver *PathResolver) ResolvedPath { |
| 275 | if rp, ok := resolver.Resolve(path); ok { |
| 276 | return rp |
| 277 | } |
| 278 | p := resolveIn(workDir, path) |
| 279 | return ResolvedPath{Path: p, DisplayPath: p, Root: p, DisplayRoot: p} |
| 280 | } |
| 281 | |
| 282 | func normalizeReadToken(token string) string { |
| 283 | token = strings.TrimSpace(token) |
| 284 | token = strings.TrimPrefix(token, "@") |
| 285 | token = filepath.ToSlash(token) |
| 286 | token = strings.TrimRight(token, "/") |
| 287 | return token |
| 288 | } |
| 289 | |
| 290 | func cleanReadSubpath(sub string) (string, bool) { |
| 291 | sub = strings.TrimPrefix(filepath.ToSlash(strings.TrimSpace(sub)), "/") |
| 292 | if sub == "" || sub == "." { |
| 293 | return ".", true |
| 294 | } |
| 295 | cleaned := filepath.Clean(filepath.FromSlash(sub)) |
| 296 | if cleaned == "." { |
| 297 | return ".", true |
| 298 | } |
| 299 | if !filepath.IsLocal(cleaned) { |
| 300 | return "", false |
| 301 | } |
| 302 | return filepath.ToSlash(cleaned), true |
| 303 | } |
| 304 | |
| 305 | // vendorDirs are directory names grep and glob skip during a recursive walk: |
| 306 | // dependency, VCS, and build-cache trees that almost never hold the searched |
| 307 | // source and would otherwise dominate the walk (node_modules alone can be 100k+ |
| 308 | // files) and fill the result cap with noise. Only skipped when nested — a walk |
| 309 | // rooted directly at one (an explicit `grep node_modules`) still searches it. |
| 310 | var vendorDirs = map[string]bool{ |
| 311 | ".git": true, ".svn": true, ".hg": true, ".jj": true, |
| 312 | "node_modules": true, "vendor": true, ".venv": true, |
| 313 | "__pycache__": true, ".mypy_cache": true, ".pytest_cache": true, |
| 314 | } |
| 315 | |
| 316 | // skipWalkDir reports whether a directory should be pruned from a recursive walk |
| 317 | // rooted at root. The root itself is never pruned, so explicitly targeting a |
| 318 | // vendor dir still works. |
| 319 | func skipWalkDir(root, path, name string) bool { |
| 320 | if path == root { |
| 321 | return false |
| 322 | } |
| 323 | return vendorDirs[name] || isProtectedDir(absClean(path)) |
| 324 | } |
| 325 | |
| 326 | // skipForbidDir reports whether a directory should be pruned from a recursive |
| 327 | // walk because it is within any forbid-read root. forbidRoots are pre-resolved |
| 328 | // absolute paths; empty means unconfined. |
| 329 | func skipForbidDir(path string, forbidRoots []string) bool { |
| 330 | return confineRead(forbidRoots, path) |
| 331 | } |
| 332 |