Files
zero/internal/tools/write_tools_test.go
KRATOSandClaude Opus 4.8 4427152b83 audit2: tools fixes (4 high, 1 med)
grep no longer follows in-workspace symlinks to files outside the root (confinement) and computes clean workspace-relative match paths under a symlinked root (HIGH x2); apply_patch ChangedFiles + MutationTargets are workspace-relative when cwd!="." (HIGH); aliasedStringArg(allowEmpty) skips an empty primary so a populated alias wins — no more write_file content/path data loss (HIGH); registry.Without isolates a fresh update_plan for sub-agents so a task child can't clobber the parent's plan (MED). TDD incl -race; build/vet/full-suite green.

Refs #102

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-06-07 10:01:26 +05:30

484 lines
14 KiB
Go

package tools
import (
"context"
"os"
"path/filepath"
"strings"
"testing"
)
func TestCoreToolsExposeWriteAndPlanTools(t *testing.T) {
toolset := CoreTools(t.TempDir())
byName := make(map[string]Tool, len(toolset))
for _, tool := range toolset {
byName[tool.Name()] = tool
}
for _, name := range []string{"write_file", "edit_file", "apply_patch"} {
tool, ok := byName[name]
if !ok {
t.Fatalf("expected core tools to include %s", name)
}
if tool.Safety().SideEffect != SideEffectWrite {
t.Fatalf("%s side effect = %s, want write", name, tool.Safety().SideEffect)
}
if tool.Safety().Permission != PermissionPrompt {
t.Fatalf("%s permission = %s, want prompt", name, tool.Safety().Permission)
}
}
planTool, ok := byName["update_plan"]
if !ok {
t.Fatalf("expected core tools to include update_plan")
}
if planTool.Safety().Permission != PermissionAllow {
t.Fatalf("update_plan permission = %s, want allow", planTool.Safety().Permission)
}
}
func TestRegistryBlocksPromptToolsWithoutGrant(t *testing.T) {
root := t.TempDir()
target := filepath.Join(root, "blocked.txt")
registry := NewRegistry()
registry.Register(NewWriteFileTool(root))
result := registry.Run(context.Background(), "write_file", map[string]any{
"path": "blocked.txt",
"content": "nope",
})
if result.Status != StatusError {
t.Fatalf("expected error status, got %s", result.Status)
}
if !strings.Contains(result.Output, "Permission required for write_file") {
t.Fatalf("expected permission error, got %q", result.Output)
}
if _, err := os.Stat(target); !os.IsNotExist(err) {
t.Fatalf("expected file to remain absent, stat err=%v", err)
}
}
func TestRegistryRunsPromptToolsWithGrant(t *testing.T) {
root := t.TempDir()
registry := NewRegistry()
registry.Register(NewWriteFileTool(root))
result := registry.RunWithOptions(context.Background(), "write_file", map[string]any{
"path": "allowed.txt",
"content": "hello",
}, RunOptions{PermissionGranted: true})
if result.Status != StatusOK {
t.Fatalf("expected ok status, got %s: %s", result.Status, result.Output)
}
content, err := os.ReadFile(filepath.Join(root, "allowed.txt"))
if err != nil {
t.Fatal(err)
}
if string(content) != "hello" {
t.Fatalf("expected written content, got %q", string(content))
}
}
func TestWriteFileToolCreatesAndProtectsExistingFiles(t *testing.T) {
root := t.TempDir()
tool := NewWriteFileTool(root)
created := tool.Run(context.Background(), map[string]any{
"path": "nested/file.txt",
"content": "first",
})
if created.Status != StatusOK {
t.Fatalf("expected create ok, got %s: %s", created.Status, created.Output)
}
if !strings.Contains(created.Output, "Created nested/file.txt") {
t.Fatalf("unexpected create output: %q", created.Output)
}
refused := tool.Run(context.Background(), map[string]any{
"path": "nested/file.txt",
"content": "second",
})
if refused.Status != StatusError {
t.Fatalf("expected overwrite refusal, got %s", refused.Status)
}
content, err := os.ReadFile(filepath.Join(root, "nested", "file.txt"))
if err != nil {
t.Fatal(err)
}
if string(content) != "first" {
t.Fatalf("expected original content, got %q", string(content))
}
overwrote := tool.Run(context.Background(), map[string]any{
"path": "nested/file.txt",
"content": "second",
"overwrite": true,
})
if overwrote.Status != StatusOK {
t.Fatalf("expected overwrite ok, got %s: %s", overwrote.Status, overwrote.Output)
}
content, err = os.ReadFile(filepath.Join(root, "nested", "file.txt"))
if err != nil {
t.Fatal(err)
}
if string(content) != "second" {
t.Fatalf("expected overwritten content, got %q", string(content))
}
}
func TestWriteFileToolAllowsEmptyContent(t *testing.T) {
root := t.TempDir()
result := NewWriteFileTool(root).Run(context.Background(), map[string]any{
"path": "empty.txt",
"content": "",
})
if result.Status != StatusOK {
t.Fatalf("expected ok status, got %s: %s", result.Status, result.Output)
}
content, err := os.ReadFile(filepath.Join(root, "empty.txt"))
if err != nil {
t.Fatal(err)
}
if string(content) != "" {
t.Fatalf("expected empty file, got %q", string(content))
}
}
func TestWriteFileToolReportsTypeErrorsForEmptyAllowedStrings(t *testing.T) {
result := NewWriteFileTool(t.TempDir()).Run(context.Background(), map[string]any{
"path": "bad.txt",
"content": 42,
})
if result.Status != StatusError {
t.Fatalf("expected error status, got %s", result.Status)
}
if !strings.Contains(result.Output, "content must be a string") {
t.Fatalf("expected string type error, got %q", result.Output)
}
}
func TestWriteFileToolRejectsOutsideWorkspace(t *testing.T) {
root := t.TempDir()
outside := filepath.Join(t.TempDir(), "outside.txt")
result := NewWriteFileTool(root).Run(context.Background(), map[string]any{
"path": outside,
"content": "secret",
})
if result.Status != StatusError {
t.Fatalf("expected error status, got %s", result.Status)
}
if !strings.Contains(result.Output, "must stay inside the workspace") {
t.Fatalf("expected workspace error, got %q", result.Output)
}
if _, err := os.Stat(outside); !os.IsNotExist(err) {
t.Fatalf("expected outside file to remain absent, stat err=%v", err)
}
}
func TestWriteFileToolRejectsSymlinkParent(t *testing.T) {
root := t.TempDir()
realDirectory := filepath.Join(root, "real")
if err := os.MkdirAll(realDirectory, 0o755); err != nil {
t.Fatal(err)
}
if err := os.Symlink(realDirectory, filepath.Join(root, "link")); err != nil {
t.Skipf("symlink unavailable: %v", err)
}
result := NewWriteFileTool(root).Run(context.Background(), map[string]any{
"path": "link/escape.txt",
"content": "secret",
})
if result.Status != StatusError {
t.Fatalf("expected error status, got %s", result.Status)
}
if !strings.Contains(result.Output, "must not traverse symlink") {
t.Fatalf("expected symlink error, got %q", result.Output)
}
if _, err := os.Stat(filepath.Join(realDirectory, "escape.txt")); !os.IsNotExist(err) {
t.Fatalf("expected symlink target file to remain absent, stat err=%v", err)
}
}
func TestEditFileToolReplacesExactStrings(t *testing.T) {
root := t.TempDir()
path := filepath.Join(root, "code.go")
writeTestFile(t, path, "const a = 1\nconst b = 2\n")
result := NewEditFileTool(root).Run(context.Background(), map[string]any{
"path": "code.go",
"old_string": "const a = 1",
"new_string": "const a = 42",
})
if result.Status != StatusOK {
t.Fatalf("expected edit ok, got %s: %s", result.Status, result.Output)
}
content, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
if string(content) != "const a = 42\nconst b = 2\n" {
t.Fatalf("unexpected edited content: %q", string(content))
}
}
func TestEditFileToolAllowsDeletingRegions(t *testing.T) {
root := t.TempDir()
path := filepath.Join(root, "notes.txt")
writeTestFile(t, path, "keep\nremove\nkeep\n")
result := NewEditFileTool(root).Run(context.Background(), map[string]any{
"path": "notes.txt",
"old_string": "remove\n",
"new_string": "",
})
if result.Status != StatusOK {
t.Fatalf("expected edit ok, got %s: %s", result.Status, result.Output)
}
content, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
if string(content) != "keep\nkeep\n" {
t.Fatalf("unexpected edited content: %q", string(content))
}
}
func TestEditFileToolRejectsMissingAndAmbiguousMatches(t *testing.T) {
root := t.TempDir()
path := filepath.Join(root, "dup.txt")
writeTestFile(t, path, "x\nx\n")
tool := NewEditFileTool(root)
missing := tool.Run(context.Background(), map[string]any{
"path": "dup.txt",
"old_string": "missing",
"new_string": "y",
})
if missing.Status != StatusError || !strings.Contains(missing.Output, "Could not find") {
t.Fatalf("expected missing error, got %s: %s", missing.Status, missing.Output)
}
ambiguous := tool.Run(context.Background(), map[string]any{
"path": "dup.txt",
"old_string": "x",
"new_string": "y",
})
if ambiguous.Status != StatusError || !strings.Contains(ambiguous.Output, "matches 2 locations") {
t.Fatalf("expected ambiguity error, got %s: %s", ambiguous.Status, ambiguous.Output)
}
all := tool.Run(context.Background(), map[string]any{
"path": "dup.txt",
"old_string": "x",
"new_string": "y",
"replace_all": true,
})
if all.Status != StatusOK {
t.Fatalf("expected replace_all ok, got %s: %s", all.Status, all.Output)
}
content, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
if string(content) != "y\ny\n" {
t.Fatalf("expected all replacements, got %q", string(content))
}
}
func TestApplyPatchToolAppliesUnifiedDiff(t *testing.T) {
root := t.TempDir()
writeTestFile(t, filepath.Join(root, "hello.txt"), "hello\nold\n")
patch := strings.Join([]string{
"diff --git a/hello.txt b/hello.txt",
"--- a/hello.txt",
"+++ b/hello.txt",
"@@ -1,2 +1,2 @@",
" hello",
"-old",
"+new",
"",
}, "\n")
result := NewApplyPatchTool(root).Run(context.Background(), map[string]any{
"patch": patch,
})
if result.Status != StatusOK {
t.Fatalf("expected patch ok, got %s: %s", result.Status, result.Output)
}
content, err := os.ReadFile(filepath.Join(root, "hello.txt"))
if err != nil {
t.Fatal(err)
}
if strings.ReplaceAll(string(content), "\r\n", "\n") != "hello\nnew\n" {
t.Fatalf("unexpected patched content: %q", string(content))
}
}
func TestApplyPatchToolRejectsSymlinkPath(t *testing.T) {
root := t.TempDir()
realDirectory := filepath.Join(root, "real")
if err := os.MkdirAll(realDirectory, 0o755); err != nil {
t.Fatal(err)
}
if err := os.Symlink(realDirectory, filepath.Join(root, "link")); err != nil {
t.Skipf("symlink unavailable: %v", err)
}
patch := strings.Join([]string{
"diff --git a/link/new.txt b/link/new.txt",
"new file mode 100644",
"index 0000000..e965047",
"--- /dev/null",
"+++ b/link/new.txt",
"@@ -0,0 +1 @@",
"+hello",
"",
}, "\n")
result := NewApplyPatchTool(root).Run(context.Background(), map[string]any{
"patch": patch,
})
if result.Status != StatusError {
t.Fatalf("expected error status, got %s", result.Status)
}
if !strings.Contains(result.Output, "must not traverse symlink") {
t.Fatalf("expected symlink error, got %q", result.Output)
}
if _, err := os.Stat(filepath.Join(realDirectory, "new.txt")); !os.IsNotExist(err) {
t.Fatalf("expected symlink target file to remain absent, stat err=%v", err)
}
}
func TestApplyPatchToolRejectsOutsideWorkspace(t *testing.T) {
root := t.TempDir()
outside := t.TempDir()
result := NewApplyPatchTool(root).Run(context.Background(), map[string]any{
"cwd": outside,
"patch": strings.Join([]string{
"diff --git a/nope.txt b/nope.txt",
"--- a/nope.txt",
"+++ b/nope.txt",
"@@ -0,0 +1 @@",
"+nope",
"",
}, "\n"),
})
if result.Status != StatusError {
t.Fatalf("expected error status, got %s", result.Status)
}
if !strings.Contains(result.Output, "must stay inside the workspace") {
t.Fatalf("expected workspace error, got %q", result.Output)
}
}
// Finding 3: apply_patch with cwd != "." must report WORKSPACE-relative
// ChangedFiles (cwd-prefixed), not cwd-relative paths. Otherwise the session's
// rewind/diff layer keys off the wrong path.
func TestApplyPatchReportsWorkspaceRelativeChangedFilesUnderCwd(t *testing.T) {
root := t.TempDir()
subdir := filepath.Join(root, "sub", "dir")
if err := os.MkdirAll(subdir, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(subdir, "a.txt"), []byte("one\n"), 0o644); err != nil {
t.Fatal(err)
}
patch := "--- a/a.txt\n+++ b/a.txt\n@@ -1 +1 @@\n-one\n+two\n"
res := NewApplyPatchTool(root).Run(context.Background(), map[string]any{"patch": patch, "cwd": "sub/dir"})
if res.Status != StatusOK {
t.Skipf("git apply unavailable or failed: %s", res.Output)
}
if len(res.ChangedFiles) != 1 || res.ChangedFiles[0] != "sub/dir/a.txt" {
t.Fatalf("ChangedFiles = %v, want [sub/dir/a.txt]", res.ChangedFiles)
}
}
func TestWriteFileReportsChangedFileAndDisplay(t *testing.T) {
root := t.TempDir()
res := NewWriteFileTool(root).Run(context.Background(), map[string]any{"path": "notes.txt", "content": "hello"})
if res.Status != StatusOK {
t.Fatalf("status=%s output=%s", res.Status, res.Output)
}
if len(res.ChangedFiles) != 1 || res.ChangedFiles[0] != "notes.txt" {
t.Fatalf("ChangedFiles = %v, want [notes.txt]", res.ChangedFiles)
}
if res.Display.Kind != "file" {
t.Errorf("Display.Kind = %q, want file", res.Display.Kind)
}
if res.Display.Summary == "" {
t.Error("expected a non-empty Display.Summary")
}
}
func TestEditFileReportsChangedFileAndDisplay(t *testing.T) {
root := t.TempDir()
if err := os.WriteFile(filepath.Join(root, "f.txt"), []byte("alpha beta"), 0o644); err != nil {
t.Fatal(err)
}
res := NewEditFileTool(root).Run(context.Background(), map[string]any{"path": "f.txt", "old_string": "alpha", "new_string": "gamma"})
if res.Status != StatusOK {
t.Fatalf("status=%s output=%s", res.Status, res.Output)
}
if len(res.ChangedFiles) != 1 || res.ChangedFiles[0] != "f.txt" {
t.Fatalf("ChangedFiles = %v, want [f.txt]", res.ChangedFiles)
}
if res.Display.Kind != "diff" {
t.Errorf("Display.Kind = %q, want diff", res.Display.Kind)
}
}
func TestApplyPatchReportsChangedFiles(t *testing.T) {
root := t.TempDir()
if err := os.WriteFile(filepath.Join(root, "a.txt"), []byte("one\n"), 0o644); err != nil {
t.Fatal(err)
}
patch := "--- a/a.txt\n+++ b/a.txt\n@@ -1 +1 @@\n-one\n+two\n"
res := NewApplyPatchTool(root).Run(context.Background(), map[string]any{"patch": patch})
if res.Status != StatusOK {
t.Skipf("git apply unavailable or failed: %s", res.Output) // environment guard
}
found := false
for _, f := range res.ChangedFiles {
if f == "a.txt" {
found = true
}
}
if !found {
t.Fatalf("expected a.txt in ChangedFiles, got %v", res.ChangedFiles)
}
if res.Display.Kind != "diff" {
t.Errorf("Display.Kind = %q, want diff", res.Display.Kind)
}
}
func TestWriteFileAcceptsContentAlias(t *testing.T) {
root := t.TempDir()
// minimax-style: content under an alias key instead of "content".
res := NewWriteFileTool(root).Run(context.Background(), map[string]any{
"path": "shop.html",
"contents": "<html>hi</html>",
})
if res.Status != StatusOK {
t.Fatalf("alias content should write, got %s: %s", res.Status, res.Output)
}
got, _ := os.ReadFile(filepath.Join(root, "shop.html"))
if string(got) != "<html>hi</html>" {
t.Fatalf("file content = %q", got)
}
}