package builtin import ( "path/filepath" "strings" "sync" "time" "reasonix/internal/netclient" "reasonix/internal/sandbox" "reasonix/internal/tool" ) // Workspace builds a built-in tool set bound to a working directory, so several // agents can run concurrently with independent path roots — a desktop front-end // opening one tab per project, say. The process working directory is global and // cannot be made per-agent (os.Chdir is process-wide), so each tool instead // resolves relative paths against this directory and bash runs in it. // // Dir is that directory (empty yields process-cwd tools, byte-identical to the // compile-time built-ins). WriteRoots confines the file-writers (as // ConfineWriters); when empty and Dir is set, Dir itself becomes the sole write // root, so writes stay inside the project by default. ForbidReadRoots confines // the read/list/search built-ins so they cannot peek at the listed directories. // Bash is the OS-sandbox spec for the bash tool (as ConfineBash). SessionGuard // rejects writer-tool targets inside Reasonix's own session stores and makes // bash warn when a command references them (see SessionDataGuard). type Workspace struct { Dir string WriteRoots []string ForbidReadRoots []string Bash sandbox.Spec BashTimeout time.Duration Search SearchSpec ProxySpec netclient.ProxySpec ReadPaths *PathResolver SessionGuard SessionDataGuard // ManagedConfig names the Reasonix-owned config files the file-writers may // touch outside WriteRoots after a fresh per-write human approval (see // ManagedConfigPaths). The zero value disables the escape hatch. ManagedConfig ManagedConfigPaths // FileOverlay, when non-nil, serves read_file/write_file content through the // host transport (unsaved editor buffers) with disk fallback; Terminal, when // non-nil, runs foreground bash in a host-owned terminal when the local OS // sandbox is not enforcing. Both are nil outside host transports like ACP. FileOverlay FileOverlay Terminal TerminalRunner } // Tools returns the built-in tools bound to the workspace, ready to Add to a // per-run tool.Registry. An empty enabled list yields every built-in; otherwise // only the named ones are returned (unknown names are ignored). This is the // per-workspace analogue of the cli's process-cwd assembly — a desktop driver // calls it once per agent instead of relying on the global working directory. func (w Workspace) Tools(enabled ...string) []tool.Tool { writeRoots := w.WriteRoots if len(writeRoots) == 0 && w.Dir != "" { writeRoots = []string{w.Dir} } roots := realRoots(writeRoots) forbidRoots := realRoots(w.ForbidReadRoots) overrides := map[string]tool.Tool{ "read_file": readFile{workDir: w.Dir, paths: w.ReadPaths, forbidRoots: forbidRoots, overlay: w.FileOverlay}, "write_file": writeFile{workDir: w.Dir, roots: roots, guard: w.SessionGuard, managed: w.ManagedConfig, overlay: w.FileOverlay}, "edit_file": editFile{workDir: w.Dir, roots: roots, guard: w.SessionGuard, managed: w.ManagedConfig}, "multi_edit": multiEdit{workDir: w.Dir, roots: roots, guard: w.SessionGuard, managed: w.ManagedConfig}, "move_file": moveFile{workDir: w.Dir, roots: roots, guard: w.SessionGuard, managed: w.ManagedConfig}, "notebook_edit": notebookEdit{workDir: w.Dir, roots: roots, guard: w.SessionGuard, managed: w.ManagedConfig}, "delete_range": deleteRange{workDir: w.Dir, roots: roots, guard: w.SessionGuard, managed: w.ManagedConfig}, "delete_symbol": deleteSymbol{workDir: w.Dir, roots: roots, guard: w.SessionGuard, managed: w.ManagedConfig}, "code_index": codeIndex{workDir: w.Dir, forbidRoots: forbidRoots}, "bash": bash{workDir: w.Dir, sb: w.Bash, timeout: w.BashTimeout, guard: w.SessionGuard, terminal: w.Terminal}, "ls": listDir{workDir: w.Dir, paths: w.ReadPaths, forbidRoots: forbidRoots}, "glob": globTool{workDir: w.Dir, paths: w.ReadPaths, forbidRoots: forbidRoots}, "grep": grepTool{workDir: w.Dir, paths: w.ReadPaths, rg: w.Search.RgPath, forbidRoots: forbidRoots, sb: w.Bash}, "web_fetch": webFetch{proxySpec: w.ProxySpec}, } all := tool.Builtins() if len(enabled) == 0 { for i, t := range all { if bound, ok := overrides[t.Name()]; ok { all[i] = bound } } return all } want := make(map[string]bool, len(enabled)) for _, n := range enabled { want[n] = true } out := make([]tool.Tool, 0, len(enabled)) for _, t := range all { if want[t.Name()] { if bound, ok := overrides[t.Name()]; ok { t = bound } out = append(out, t) } } return out } // resolveIn maps a tool's path/pattern argument into a working directory. With // an empty workDir it returns p unchanged — the process-cwd behavior the // compile-time built-ins have always had, so existing callers are unaffected. // Otherwise a relative p is joined onto workDir; an absolute p is returned as-is // (an explicit absolute path is honored verbatim — the write-confiner, not this, // enforces the workspace boundary). An empty p resolves to workDir itself, so a // defaulted "." (ls/grep) targets the workspace root. func resolveIn(workDir, p string) string { if workDir == "" { return p } if p == "" || p == "." { return workDir } if filepath.IsAbs(p) { return p } return filepath.Join(workDir, p) } // PathResolver maps session-authorized token paths to local read-only roots. // It is intentionally used only by read tools; write tools continue to rely on // WriteRoots confinement and never resolve these aliases. type PathResolver struct { mu sync.RWMutex roots map[string]string } // NewPathResolver returns a resolver whose root set can be updated after the // workspace tools have been registered. func NewPathResolver() *PathResolver { return &PathResolver{roots: map[string]string{}} } // RegisterReadRoot authorizes token and all of its local subpaths to resolve // under root for read-only tools in this session. func (r *PathResolver) RegisterReadRoot(token, root string) { if r == nil { return } token = normalizeReadToken(token) root = filepath.Clean(strings.TrimSpace(root)) if token == "" || root == "" { return } r.mu.Lock() defer r.mu.Unlock() if r.roots == nil { r.roots = map[string]string{} } r.roots[token] = root } // Resolve maps a submitted path or pattern to a local path when it begins with // a registered token. ok is false for ordinary workspace paths. func (r *PathResolver) Resolve(path string) (ResolvedPath, bool) { if r == nil { return ResolvedPath{}, false } key := normalizeReadToken(path) if key == "" { return ResolvedPath{}, false } r.mu.RLock() defer r.mu.RUnlock() if root, ok := r.roots[key]; ok { return ResolvedPath{Path: root, DisplayPath: key, Root: root, DisplayRoot: key, External: true}, true } for token, root := range r.roots { if !strings.HasPrefix(key, token+"/") { continue } sub, ok := cleanReadSubpath(strings.TrimPrefix(key, token+"/")) if !ok { return ResolvedPath{}, false } return ResolvedPath{ Path: filepath.Join(root, filepath.FromSlash(sub)), DisplayPath: token + "/" + sub, Root: root, DisplayRoot: token, External: true, }, true } return ResolvedPath{}, false } // ResolvedPath carries both the local path used for I/O and the token path that // should appear in tool output. type ResolvedPath struct { Path string DisplayPath string Root string DisplayRoot string External bool } func (p ResolvedPath) DisplayFor(path string) string { if !p.External { return path } rel, err := filepath.Rel(p.Root, path) if err != nil || !filepath.IsLocal(rel) { return path } if rel == "." { return p.DisplayRoot } return filepath.ToSlash(filepath.Join(p.DisplayRoot, rel)) } func (p ResolvedPath) ErrorText(err error) string { if err == nil { return "" } msg := err.Error() if !p.External { return msg } return strings.ReplaceAll(msg, p.Root, p.DisplayRoot) } func resolveReadablePath(workDir, path string, resolver *PathResolver) ResolvedPath { if rp, ok := resolver.Resolve(path); ok { return rp } p := resolveIn(workDir, path) return ResolvedPath{Path: p, DisplayPath: p, Root: p, DisplayRoot: p} } func normalizeReadToken(token string) string { token = strings.TrimSpace(token) token = strings.TrimPrefix(token, "@") token = filepath.ToSlash(token) token = strings.TrimRight(token, "/") return token } func cleanReadSubpath(sub string) (string, bool) { sub = strings.TrimPrefix(filepath.ToSlash(strings.TrimSpace(sub)), "/") if sub == "" || sub == "." { return ".", true } cleaned := filepath.Clean(filepath.FromSlash(sub)) if cleaned == "." { return ".", true } if !filepath.IsLocal(cleaned) { return "", false } return filepath.ToSlash(cleaned), true } // vendorDirs are directory names grep and glob skip during a recursive walk: // dependency, VCS, and build-cache trees that almost never hold the searched // source and would otherwise dominate the walk (node_modules alone can be 100k+ // files) and fill the result cap with noise. Only skipped when nested — a walk // rooted directly at one (an explicit `grep node_modules`) still searches it. var vendorDirs = map[string]bool{ ".git": true, ".svn": true, ".hg": true, ".jj": true, "node_modules": true, "vendor": true, ".venv": true, "__pycache__": true, ".mypy_cache": true, ".pytest_cache": true, } // skipWalkDir reports whether a directory should be pruned from a recursive walk // rooted at root. The root itself is never pruned, so explicitly targeting a // vendor dir still works. func skipWalkDir(root, path, name string) bool { if path == root { return false } return vendorDirs[name] || isProtectedDir(absClean(path)) } // skipForbidDir reports whether a directory should be pruned from a recursive // walk because it is within any forbid-read root. forbidRoots are pre-resolved // absolute paths; empty means unconfined. func skipForbidDir(path string, forbidRoots []string) bool { return confineRead(forbidRoots, path) }