mirror of
https://github.com/Twigpine/zero.git
synced 2026-11-12 19:13:28 -10:00
* docs: spec for additional write roots (--add-dir / /add-dir) Co-Authored-By: Claude Fable 5 <[email protected]> * docs: implementation plan for additional write roots Co-Authored-By: Claude Fable 5 <[email protected]> * sandbox: add Scope type for multi-root write access Co-Authored-By: Claude Fable 5 <[email protected]> * docs: write roots honored from global config only, union merge semantics Co-Authored-By: Claude Fable 5 <[email protected]> * sandbox: fix multi-root violation reporting and pin prefix-alias behavior - validate: prefer ViolationSymlinkTraversal over ViolationOutsideWorkspace when all roots deny; return original requestedPath in violation; append --add-dir hint only on outside_workspace results; extend doc comment to state that a symlink resolving inside any root is allowed. - normalizePrefixForRoot: dedupe insideRoot check via pathWithinRoot (Fix 3); widen EvalSymlinks comment to cover jump-over case; add POSIX-only note. - WorkspaceRoot: add doc comment noting immutability/lock safety (Fix 4). - Tests: pin multi-root traversal-preferred behavior, deterministic alias test (TestValidateResolvesAliasedPathPrefixes), Add symlink normalization test, and tilde-expansion error path test. Co-Authored-By: Claude Fable 5 <[email protected]> * sandbox: make engine path validation and risk scope-aware Thread *Scope through EngineOptions/Engine so Evaluate and Classify use multi-root scope validation instead of single-root validateWorkspacePaths, letting extra write roots (--add-dir / /add-dir) be honoured by the policy gate and risk classifier. Remove now-dead validateWorkspacePaths helper. Co-Authored-By: Claude Fable 5 <[email protected]> * sandbox: pin override-root scope isolation, tidy Evaluate scope handling Co-Authored-By: Claude Fable 5 <[email protected]> * sandbox: widen seatbelt/bubblewrap profiles and command cwd to scope roots Co-Authored-By: Claude Fable 5 <[email protected]> * sandbox: pin extra-root chdir behavior and document bind invariants Co-Authored-By: Claude Fable 5 <[email protected]> * tools: resolve paths against the shared write scope Add PathScope interface and scoped resolver helpers to workspace.go, then thread a scope field through all 8 file tools (read_file, list_directory, glob, grep, write_file, edit_file, apply_patch, bash). Each existing constructor delegates to a new NewScoped* variant; nil scope is byte-identical to the original behavior. Registry gains CoreReadOnlyToolsScoped, CoreWriteToolsScoped, CoreShellToolsScoped, and CoreToolsScoped. Co-Authored-By: Claude Fable 5 <[email protected]> * tools: restore fail-closed write-target symlink checks, share prefix normalization * tools: report absolute paths for extra-root changes, pin scoped traversal denial - resolveScopedPath and resolveScopedTargetPath now return the absolute path as the second value when the matched root is an extra (non-workspace) root, eliminating ambiguity in ChangedFiles, cwd meta, and display summaries downstream - Add TestScopedWriteRefusesSameRootSymlinkTraversal to pin that same-root symlink traversal within an extra root is denied (pre-existing behavior) - Add TestScopedWriteReportsAbsolutePathForExtraRoot to enforce the new absolute-path contract for ChangedFiles on extra-root writes - Extend doc comments on PathScope, ChangedFiles, changedFilesFromPatch, and both scoped resolvers to capture the workspace-vs-absolute invariant Co-Authored-By: Claude Fable 5 <[email protected]> * config: add sandbox.additionalWriteRoots (global config only, union merge) * cli: add repeatable --add-dir flag wiring scope into registry and engine Co-Authored-By: Claude Fable 5 <[email protected]> * cli: forward --add-dir across dispatch paths, pin scope wiring with tests Co-Authored-By: Claude Fable 5 <[email protected]> * cli: pin --add-dir dispatch forwarding end-to-end, close guard gaps Review fixes for the --add-dir dispatch commit: - Add TestRunAddDirDispatchForwardsGrantIntoExecScope: drives runWithDeps through both the "exec" and "-p" dispatch shapes with a provider that calls write_file inside the extra root, asserting the grant reaches the exec sandbox scope (and a negative control without --add-dir is denied fail-closed). Mutation-verified: dropping addDirFlagArgs forwarding from either case fails the test. - Remove help/version from the --add-dir allowlist so any non-TUI/ non-exec leading --add-dir hard-errors, matching the stated requirement; extend the rejection test with those cases. - Fail loud when --add-dir is hidden behind a stray non-flag arg on the --skip-permissions-unsafe path instead of silently dropping the grant. - Reject flag-like values in the inline --add-dir= spelling to match the space form (a directory named -foo stays reachable as ./-foo). - Move TestExecScopeReRegistrationSwapsCoreToolsByName out of the parse test file into the new exec_scope_test.go alongside the e2e test. Co-Authored-By: Claude Fable 5 <[email protected]> * tui: add /add-dir command for session write-root grants Co-Authored-By: Claude Fable 5 <[email protected]> * sandbox: surface write roots in zero sandbox status and plan snapshots Co-Authored-By: Claude Fable 5 <[email protected]> * cli: surface write-roots error in --effective --json, normalize fallback root Review fixes for the Task8 observability range: - runSandboxPolicyEffective JSON payload gains writeRootsError (omitempty) so --json consumers see the same fail-soft signal as the text write_roots_error line instead of a misleading workspace-only writeRoots list. - The fail-soft fallback now derives its write roots from a workspace-only Scope (which cannot fail) so the error path renders the same symlink-resolved root as the success path. - TestRunSandboxPolicyEffectiveWriteRootsFailSoft gains a --json leg asserting writeRootsError names the stale root with a workspace-only fallback; the configured-roots test asserts the key is absent for valid roots. - SandboxPlanSnapshot doc comment reworded to match the converter: no current builder populates WriteRoots. Co-Authored-By: Claude Fable 5 <[email protected]> * sandbox: seatbelt integration test for extra write roots Co-Authored-By: Claude Fable 5 <[email protected]> * docs: document --add-dir and /add-dir write-root grants Co-Authored-By: Claude Fable 5 <[email protected]> * sandbox: pass volume-qualified paths through prefix normalization verbatim NormalizePrefixForRoot's POSIX component walk mangled Windows drive paths (C:\Users -> C:Users), which downstream single-root checks treated as relative and joined under the workspace — failing the policy gate OPEN on Windows. Volume-qualified paths now bypass the walk entirely (the macOS /var alias problem it solves has no Windows equivalent) and windows-gated tests pin both the verbatim pass-through and the engine denial. Co-Authored-By: Claude Fable 5 <[email protected]> * sandbox: make prefix normalization volume-aware for Windows alias resolution The previous commit disabled NormalizePrefixForRoot on Windows, which broke it the other direction: a workspace created under an 8.3 short path (C:\Users\RUNNER~1\...) resolves via EvalSymlinks to its long form (runneradmin), so raw short-form requests escaped the long-form root and legitimate extra-root writes were denied. This is the same alias problem as macOS /var -> /private/var, which the helper already solves. Start the component walk from the volume root (C:\ or //host/share/) instead of "/" so it resolves the prefix on Windows too; POSIX VolumeName is empty so the walk reduces to the original "/"-rooted behavior byte-for-byte. Windows test now pins allow-inside-root + deny-outside both directions. Co-Authored-By: Claude Fable 5 <[email protected]> * sandbox: address PR review findings for additional write roots Resolve the CHANGES_REQUESTED review on PR #162: - engine: derive workspaceRoot from scope.Roots()[0] when NewEngine is given a Scope without an explicit WorkspaceRoot, so Evaluate's EnforceWorkspace/classification guards no longer silently skip (+ regression test). - tools/workspace: scopedRoots now fails closed (returns an error) when a non-nil PathScope exposes empty Roots(), instead of returning success with an empty path; threaded through the scoped resolve/recheck helpers and resolveGrepRoot. - tools/glob: emit absolute matches when cwd resolves into an extra root so results can't be fed back and resolve to a same-named workspace file (+ regression test). - tools/bash,grep,apply_patch: schema descriptions mention granted extra roots (relative -> workspace, absolute -> granted root). - docs/plan: fix markdownlint heading increment, malformed rule+heading, and bare code fence. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]> * tools/grep, tui: address PR review for additional write roots - grep now emits absolute, symlink-resolved paths for matches in a granted extra (non-workspace) root, mirroring the glob fix. A bare workspace-relative name like "report.go" otherwise resolves back under the workspace when fed to read_file/edit_file and hits the wrong file when the name exists in both roots. Covers content and files_with_matches output. - tui: only reset chatScrollOffset for a real submission, not an empty Enter (a no-op), so the viewport is no longer yanked to the bottom when nothing was submitted. Real submissions still snap back. - glob: schema description now mentions granted extra roots, matching the bash/grep/apply_patch wording so the --add-dir/-add-dir flow is discoverable. Regression tests: TestScopedGrepReturnsAbsoluteMatchesForExtraRoot, TestEmptySubmitKeepsChatScrollOffset. * docs: add /add-dir to the TUI commands quick-reference table The command was documented further down but missing from the scannable commands table, so users browsing the table wouldn't discover the additional-write-roots flow. --------- Co-authored-by: Claude Fable 5 <[email protected]>
340 lines
11 KiB
Go
340 lines
11 KiB
Go
package tools
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"regexp"
|
|
"sort"
|
|
"strings"
|
|
)
|
|
|
|
type grepTool struct {
|
|
baseTool
|
|
workspaceRoot string
|
|
scope PathScope
|
|
}
|
|
|
|
type grepMatch struct {
|
|
file string
|
|
line int
|
|
text string
|
|
hits int
|
|
}
|
|
|
|
func NewGrepTool(workspaceRoot string) Tool {
|
|
return NewScopedGrepTool(workspaceRoot, nil)
|
|
}
|
|
|
|
func NewScopedGrepTool(workspaceRoot string, scope PathScope) Tool {
|
|
return grepTool{
|
|
baseTool: baseTool{
|
|
name: "grep",
|
|
description: "Search file contents with a regular expression inside the workspace or an explicitly granted extra root.",
|
|
parameters: Schema{
|
|
Type: "object",
|
|
Properties: map[string]PropertySchema{
|
|
"pattern": {Type: "string", Description: "Regular expression pattern to search for."},
|
|
"path": {Type: "string", Description: "Directory or file to search. Relative paths stay in the workspace; use an absolute path to search a granted extra root. Defaults to workspace root.", Default: "."},
|
|
"glob": {Type: "string", Description: `Optional glob filter, for example "**/*.go".`},
|
|
"output_mode": {Type: "string", Description: "Output mode.", Enum: []string{"content", "files_with_matches", "count"}, Default: "content"},
|
|
"-i": {Type: "boolean", Description: "Case insensitive search.", Default: false},
|
|
"case_insensitive": {Type: "boolean", Description: "Case insensitive search.", Default: false},
|
|
"head_limit": {Type: "integer", Description: "Maximum content results to return.", Default: 50, Minimum: intPtr(1)},
|
|
},
|
|
Required: []string{"pattern"},
|
|
AdditionalProperties: false,
|
|
},
|
|
safety: readOnlySafety("Searches file paths and matching lines without modifying files."),
|
|
},
|
|
workspaceRoot: normalizeWorkspaceRoot(workspaceRoot),
|
|
scope: scope,
|
|
}
|
|
}
|
|
|
|
func (tool grepTool) Run(_ context.Context, args map[string]any) Result {
|
|
pattern, err := aliasedStringArg(args, []string{"pattern", "query", "regex", "search", "expression"}, "", true, false)
|
|
if err != nil {
|
|
return errorResult("Error: Invalid arguments for grep: " + err.Error())
|
|
}
|
|
// Optional with a "." default: treat an explicit empty path (a common
|
|
// weak-model quirk) the same as the key being absent rather than erroring.
|
|
targetPath, err := aliasedStringArg(args, []string{"path", "dir", "directory"}, ".", false, true)
|
|
if err != nil {
|
|
return errorResult("Error: Invalid arguments for grep: " + err.Error())
|
|
}
|
|
if targetPath == "" {
|
|
targetPath = "."
|
|
}
|
|
globPattern, err := stringArg(args, "glob", "", false)
|
|
if err != nil {
|
|
return errorResult("Error: Invalid arguments for grep: " + err.Error())
|
|
}
|
|
outputMode, err := stringArg(args, "output_mode", "content", false)
|
|
if err != nil {
|
|
return errorResult("Error: Invalid arguments for grep: " + err.Error())
|
|
}
|
|
if outputMode != "content" && outputMode != "files_with_matches" && outputMode != "count" {
|
|
return errorResult("Error: Invalid arguments for grep: output_mode must be content, files_with_matches, or count")
|
|
}
|
|
caseInsensitive, err := boolArg(args, "case_insensitive", false)
|
|
if err != nil {
|
|
return errorResult("Error: Invalid arguments for grep: " + err.Error())
|
|
}
|
|
shortInsensitive, err := boolArg(args, "-i", false)
|
|
if err != nil {
|
|
return errorResult("Error: Invalid arguments for grep: " + err.Error())
|
|
}
|
|
headLimit, err := intArg(args, "head_limit", 50, 1, 0)
|
|
if err != nil {
|
|
return errorResult("Error: Invalid arguments for grep: " + err.Error())
|
|
}
|
|
|
|
if caseInsensitive || shortInsensitive {
|
|
pattern = "(?i)" + pattern
|
|
}
|
|
compiled, err := regexp.Compile(pattern)
|
|
if err != nil {
|
|
return errorResult("Error running grep: " + err.Error())
|
|
}
|
|
|
|
target, displayRoot, err := resolveScopedPath(tool.workspaceRoot, tool.scope, targetPath)
|
|
if err != nil {
|
|
return errorResult("Error running grep: " + err.Error())
|
|
}
|
|
|
|
// Resolve the workspace root through symlinks ONCE so (a) confinement checks
|
|
// and (b) Rel computations both use the canonical root. tool.workspaceRoot is
|
|
// only Abs-normalized (no EvalSymlinks); using it directly would produce
|
|
// "../"-laden relative paths when the root itself lives under a symlink (e.g.
|
|
// macOS /tmp -> /private/tmp) and would not catch files that resolve outside.
|
|
// When a scope is present, pick the scope root that contains the resolved
|
|
// target so that confineGrepFile computes correct relative paths.
|
|
resolvedRoot, err := resolveGrepRoot(tool.workspaceRoot, tool.scope, target)
|
|
if err != nil {
|
|
return errorResult("Error running grep: " + err.Error())
|
|
}
|
|
|
|
var globMatcher *regexp.Regexp
|
|
if globPattern != "" {
|
|
globMatcher, err = compileGlob(globPattern)
|
|
if err != nil {
|
|
return errorResult("Error running grep: " + err.Error())
|
|
}
|
|
}
|
|
|
|
files, err := grepFiles(resolvedRoot, target, globMatcher)
|
|
if err != nil {
|
|
return errorResult("Error running grep: " + err.Error())
|
|
}
|
|
|
|
// resolveScopedPath returns an absolute displayRoot when the target resolved
|
|
// to an extra (non-workspace) granted root; emit absolute match paths there so
|
|
// they survive a round-trip through read_file/edit_file (mirrors glob).
|
|
matches := collectGrepMatches(resolvedRoot, filepath.IsAbs(displayRoot), files, compiled)
|
|
if len(matches) == 0 {
|
|
if outputMode == "count" {
|
|
return okResult("0 matches found")
|
|
}
|
|
return okResult("No matches found.")
|
|
}
|
|
|
|
switch outputMode {
|
|
case "count":
|
|
total := 0
|
|
for _, match := range matches {
|
|
total += match.hits
|
|
}
|
|
return okResult(fmt.Sprintf("%d matches found", total))
|
|
case "files_with_matches":
|
|
seen := map[string]bool{}
|
|
files := []string{}
|
|
for _, match := range matches {
|
|
if !seen[match.file] {
|
|
seen[match.file] = true
|
|
files = append(files, match.file)
|
|
}
|
|
}
|
|
sort.Strings(files)
|
|
return okResult(strings.Join(files, "\n"))
|
|
default:
|
|
lines := make([]string, 0, len(matches))
|
|
for _, match := range matches {
|
|
if len(lines) >= headLimit {
|
|
break
|
|
}
|
|
lines = append(lines, fmt.Sprintf("%s:%d: %s", match.file, match.line, match.text))
|
|
}
|
|
return Result{
|
|
Status: StatusOK,
|
|
Output: strings.Join(lines, "\n"),
|
|
Truncated: len(matches) > headLimit,
|
|
}
|
|
}
|
|
}
|
|
|
|
// resolveGrepRoot picks the scope root whose EvalSymlinks-resolved path contains
|
|
// the already-resolved target, so that confineGrepFile computes correct
|
|
// workspace-relative paths even when the target lives in an extra root.
|
|
// Falls back to EvalSymlinks(workspaceRoot) when no scoped root matches.
|
|
func resolveGrepRoot(workspaceRoot string, scope PathScope, resolvedTarget string) (string, error) {
|
|
roots, err := scopedRoots(workspaceRoot, scope)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
for _, root := range roots {
|
|
resolved, err := filepath.EvalSymlinks(root)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
rel, err := filepath.Rel(resolved, resolvedTarget)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
if rel == "." || (rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator)) && !filepath.IsAbs(rel)) {
|
|
return resolved, nil
|
|
}
|
|
}
|
|
// Fall back to the workspace root.
|
|
return filepath.EvalSymlinks(workspaceRoot)
|
|
}
|
|
|
|
// confineGrepFile resolves a candidate file through symlinks and returns its
|
|
// clean, slash-separated path RELATIVE to the (already symlink-resolved) root.
|
|
// It returns ok=false when the resolved file escapes the workspace root, so a
|
|
// symlink inside the workspace that points outside is never searched/returned —
|
|
// mirroring resolveWorkspaceTargetPath / read_file confinement. resolvedRoot must
|
|
// already be EvalSymlinks-resolved so the Rel result is "../"-free for in-root
|
|
// files even when the root lives under a symlink.
|
|
func confineGrepFile(resolvedRoot string, path string) (string, string, bool) {
|
|
resolved, err := filepath.EvalSymlinks(path)
|
|
if err != nil {
|
|
return "", "", false
|
|
}
|
|
relative, err := filepath.Rel(resolvedRoot, resolved)
|
|
if err != nil {
|
|
return "", "", false
|
|
}
|
|
if relative == ".." || strings.HasPrefix(relative, ".."+string(filepath.Separator)) || filepath.IsAbs(relative) {
|
|
return "", "", false
|
|
}
|
|
// Return the symlink-resolved absolute path too: callers must read THAT (not
|
|
// the unresolved input) so a symlink swap between this check and the read
|
|
// cannot escape the workspace boundary.
|
|
return filepath.ToSlash(relative), resolved, true
|
|
}
|
|
|
|
func grepFiles(resolvedRoot string, target string, globMatcher *regexp.Regexp) ([]string, error) {
|
|
info, err := os.Stat(target)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if !info.IsDir() {
|
|
relative, _, ok := confineGrepFile(resolvedRoot, target)
|
|
if !ok {
|
|
return []string{}, nil
|
|
}
|
|
if shouldSkipWorkspaceFile(relative) {
|
|
return []string{}, nil
|
|
}
|
|
// A single explicit file is matched by its base name so a pattern like
|
|
// "*.go" applies regardless of how deep the file sits under the workspace.
|
|
if globMatcher == nil || globMatcher.MatchString(filepath.Base(target)) {
|
|
return []string{target}, nil
|
|
}
|
|
return []string{}, nil
|
|
}
|
|
|
|
files := []string{}
|
|
err = filepath.WalkDir(target, func(path string, entry os.DirEntry, walkErr error) error {
|
|
if walkErr != nil {
|
|
if path == target {
|
|
return walkErr
|
|
}
|
|
if entry != nil && entry.IsDir() {
|
|
return filepath.SkipDir
|
|
}
|
|
return nil
|
|
}
|
|
if path == target {
|
|
return nil
|
|
}
|
|
if entry.IsDir() && shouldSkipDirectory(entry.Name()) {
|
|
return filepath.SkipDir
|
|
}
|
|
if entry.IsDir() {
|
|
return nil
|
|
}
|
|
// Confine each candidate through symlinks: a symlink inside the workspace
|
|
// pointing to a file OUTSIDE the root must be skipped, not searched.
|
|
relative, _, ok := confineGrepFile(resolvedRoot, path)
|
|
if !ok {
|
|
return nil
|
|
}
|
|
if shouldSkipWorkspaceFile(relative) {
|
|
return nil
|
|
}
|
|
// Match the glob against the path relative to the SEARCH directory, not the
|
|
// workspace root, so "*.go" with path="subdir" matches subdir/a.go (as
|
|
// "a.go") — matching ripgrep's --glob semantics. Falls back to the
|
|
// workspace-relative path if Rel fails.
|
|
globPath := relative
|
|
if rel, relErr := filepath.Rel(target, path); relErr == nil {
|
|
globPath = filepath.ToSlash(rel)
|
|
}
|
|
if globMatcher == nil || globMatcher.MatchString(globPath) {
|
|
files = append(files, path)
|
|
}
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
sort.Strings(files)
|
|
return files, nil
|
|
}
|
|
|
|
func collectGrepMatches(resolvedRoot string, absolutePaths bool, files []string, compiled *regexp.Regexp) []grepMatch {
|
|
matches := []grepMatch{}
|
|
for _, file := range files {
|
|
// Re-confine at read time (defense-in-depth) AND to compute the clean
|
|
// workspace-relative path used in output.
|
|
relative, resolvedPath, ok := confineGrepFile(resolvedRoot, file)
|
|
if !ok {
|
|
continue
|
|
}
|
|
if shouldSkipWorkspaceFile(relative) {
|
|
continue
|
|
}
|
|
// Read the symlink-RESOLVED path that confineGrepFile validated, not the
|
|
// raw candidate, so a symlink swapped in after the check can't escape.
|
|
content, err := os.ReadFile(resolvedPath)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
for index, line := range strings.Split(strings.ReplaceAll(string(content), "\r\n", "\n"), "\n") {
|
|
lineMatches := compiled.FindAllStringIndex(line, -1)
|
|
if len(lineMatches) == 0 {
|
|
continue
|
|
}
|
|
fileLabel := relative
|
|
if absolutePaths {
|
|
// Extra-root search: report the absolute, symlink-resolved path
|
|
// confineGrepFile already validated, so a bare workspace-relative
|
|
// name can't resolve under the workspace and hit the wrong file when
|
|
// the same name exists in both roots.
|
|
fileLabel = filepath.ToSlash(resolvedPath)
|
|
}
|
|
matches = append(matches, grepMatch{
|
|
file: fileLabel,
|
|
line: index + 1,
|
|
text: strings.TrimRight(line, "\r"),
|
|
hits: len(lineMatches),
|
|
})
|
|
}
|
|
}
|
|
return matches
|
|
}
|