Files
zero/internal/tools/grep.go
KRATOSandClaude Fable 5 e381d8eab1 Additional write roots: --add-dir flag, global config key, /add-dir TUI command (#162)
* 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]>
2026-06-11 15:17:20 +05:30

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
}