Files
zero/internal/tools/file_tracker_test.go
KRATOS 06ea4f004b Edit safety: file read/write tracking, external-modification detection, post-apply hooks (#172)
* Edit safety: file version tracking, conflict detection, hook wiring

Phase 1 — FileTracker (internal/tools/file_tracker.go): per-session
map[absPath]{sha256, size, mtime}; CheckConflict flags drift only against
a known baseline, so first-touch writes are unaffected. nil = no-op.

Phase 2 — external-modification detection: read_file records the
whole-file baseline; edit_file and write_file --overwrite refuse to
clobber a file whose on-disk content drifted from it and tell the model
to re-read first, then re-baseline to what they wrote. apply_patch
forgets the files it rewrote (git apply has its own context guard).
Threaded via RunOptions/agent.Options FileTracker, built once per session
in the exec, spec, and TUI paths.

Phase 3 — post-apply validation via hooks: the dormant hooks.Dispatcher
is wired into executeToolCall. beforeTool hooks can veto a call
(DenialHookBlocked); afterTool hook output is scrubbed and surfaced to
the model. DispatchOutcome now collects hook stdout/stderr. Per-session
dispatcher built from merged user+project hooks.json; fails open and runs
nothing until a hook is configured.

* edit-safety: address review — fail closed on read, redact hook reason

- write_file overwrite: if a tracked file cannot be re-read to verify it,
  fail CLOSED (refuse the overwrite) instead of silently proceeding — the
  read error previously skipped the conflict check and could clobber an
  externally modified or replaced file.
- blockedByHookResult: scrub outcome.Reason through redaction before placing
  it in model-visible ToolResult.Output; this intercepted path bypassed the
  registry's normal output-redaction boundary.
- TestHashContentIsStableAndDistinguishing: compare separately stored hashes
  so the stability check is not a same-expression comparison (SA4000).

* edit-safety: flag ToolResult.Redacted when hook output is scrubbed

appendHookFeedback now reports whether scrubbing changed the afterTool
feedback, and blockedByHookResult reports whether it scrubbed the reason;
both set ToolResult.Redacted so the hook-intercepted paths match the
registry's redaction contract. Adds tests asserting a secret-bearing hook
reason/feedback is scrubbed and flips Redacted.
2026-06-11 20:34:35 +05:30

111 lines
3.6 KiB
Go

package tools
import (
"os"
"path/filepath"
"testing"
)
func TestFileTrackerRecordsAndReadsBackVersion(t *testing.T) {
tracker := NewFileTracker()
path := filepath.Join(t.TempDir(), "a.txt")
content := []byte("hello world")
if err := os.WriteFile(path, content, 0o644); err != nil {
t.Fatal(err)
}
info, err := os.Stat(path)
if err != nil {
t.Fatal(err)
}
tracker.Record(path, content, info)
version, ok := tracker.Version(path)
if !ok {
t.Fatal("expected a recorded version")
}
if version.Hash != HashContent(content) {
t.Fatalf("hash = %q, want %q", version.Hash, HashContent(content))
}
if version.Size != int64(len(content)) {
t.Fatalf("size = %d, want %d", version.Size, len(content))
}
if version.MTime.IsZero() {
t.Fatal("expected mtime to be recorded from stat info")
}
}
func TestCheckConflictAllowsUntrackedPath(t *testing.T) {
tracker := NewFileTracker()
// No Record call: a first-touch write has no baseline to conflict against.
if err := tracker.CheckConflict("/nowhere/x.txt", []byte("anything")); err != nil {
t.Fatalf("untracked path should not conflict, got %v", err)
}
}
func TestCheckConflictAllowsMatchingContent(t *testing.T) {
tracker := NewFileTracker()
content := []byte("stable content")
tracker.Record("/repo/x.txt", content, nil)
if err := tracker.CheckConflict("/repo/x.txt", content); err != nil {
t.Fatalf("unchanged content should not conflict, got %v", err)
}
}
func TestCheckConflictBlocksDriftedContent(t *testing.T) {
tracker := NewFileTracker()
tracker.Record("/repo/x.txt", []byte("version one"), nil)
if err := tracker.CheckConflict("/repo/x.txt", []byte("version two — changed underneath us")); err != ErrFileChangedOnDisk {
t.Fatalf("drifted content should report ErrFileChangedOnDisk, got %v", err)
}
}
func TestForgetClearsBaselineSoNextWriteIsAllowed(t *testing.T) {
tracker := NewFileTracker()
tracker.Record("/repo/x.txt", []byte("version one"), nil)
tracker.Forget("/repo/x.txt")
if _, ok := tracker.Version("/repo/x.txt"); ok {
t.Fatal("Forget should drop the recorded version")
}
if err := tracker.CheckConflict("/repo/x.txt", []byte("version two")); err != nil {
t.Fatalf("forgotten path should behave as untracked, got %v", err)
}
}
func TestRecordOverwritesBaselineWithNewContent(t *testing.T) {
tracker := NewFileTracker()
tracker.Record("/repo/x.txt", []byte("old"), nil)
tracker.Record("/repo/x.txt", []byte("new"), nil)
// After re-recording, the new content is the baseline and matches.
if err := tracker.CheckConflict("/repo/x.txt", []byte("new")); err != nil {
t.Fatalf("re-recorded content should be the new baseline, got %v", err)
}
if err := tracker.CheckConflict("/repo/x.txt", []byte("old")); err != ErrFileChangedOnDisk {
t.Fatal("the superseded content should now conflict")
}
}
func TestNilFileTrackerIsANoop(t *testing.T) {
var tracker *FileTracker
tracker.Record("/repo/x.txt", []byte("x"), nil) // must not panic
tracker.Forget("/repo/x.txt")
if _, ok := tracker.Version("/repo/x.txt"); ok {
t.Fatal("nil tracker should report no version")
}
if err := tracker.CheckConflict("/repo/x.txt", []byte("x")); err != nil {
t.Fatalf("nil tracker should never conflict, got %v", err)
}
}
func TestHashContentIsStableAndDistinguishing(t *testing.T) {
// Store results of separate calls before comparing so the stability check is
// not a same-expression comparison (staticcheck SA4000).
first := HashContent([]byte("a"))
second := HashContent([]byte("a"))
if first != second {
t.Fatal("hash must be stable for identical content")
}
if first == HashContent([]byte("b")) {
t.Fatal("hash must differ for different content")
}
}