mirror of
https://github.com/Twigpine/zero.git
synced 2026-11-11 18:00:54 -10:00
* 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.
112 lines
3.7 KiB
Go
112 lines
3.7 KiB
Go
package tools
|
|
|
|
import (
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"errors"
|
|
"os"
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
// ErrFileChangedOnDisk is returned by CheckConflict when a file's current
|
|
// on-disk content no longer matches what a tool last read or wrote in this
|
|
// session — i.e. it was modified outside Zero. The write tools surface it with
|
|
// guidance to re-read before retrying, so a stale overwrite cannot silently
|
|
// clobber an external edit.
|
|
var ErrFileChangedOnDisk = errors.New("the file changed on disk since you last read it")
|
|
|
|
// FileVersion is what a tool last observed for a file: an authoritative content
|
|
// hash plus cheap stat metadata kept for diagnostics.
|
|
type FileVersion struct {
|
|
Hash string
|
|
Size int64
|
|
MTime time.Time
|
|
}
|
|
|
|
// FileTracker records, per absolute path, the version of each file a tool read
|
|
// or wrote within a single session. A later write compares the current on-disk
|
|
// content against this baseline to detect a modification made outside Zero.
|
|
//
|
|
// Construct with NewFileTracker. A nil *FileTracker is a valid no-op — every
|
|
// method tolerates it — so a caller that has not wired the feature (tests, MCP)
|
|
// needs no nil guards.
|
|
type FileTracker struct {
|
|
mu sync.Mutex
|
|
versions map[string]FileVersion
|
|
}
|
|
|
|
func NewFileTracker() *FileTracker {
|
|
return &FileTracker{versions: make(map[string]FileVersion)}
|
|
}
|
|
|
|
// Record stores the version of absPath given its content and optional stat info.
|
|
func (tracker *FileTracker) Record(absPath string, content []byte, info os.FileInfo) {
|
|
if tracker == nil {
|
|
return
|
|
}
|
|
version := FileVersion{Hash: HashContent(content)}
|
|
if info != nil {
|
|
version.Size = info.Size()
|
|
version.MTime = info.ModTime()
|
|
}
|
|
tracker.mu.Lock()
|
|
tracker.versions[absPath] = version
|
|
tracker.mu.Unlock()
|
|
}
|
|
|
|
// Version returns the recorded version for absPath and whether one exists.
|
|
func (tracker *FileTracker) Version(absPath string) (FileVersion, bool) {
|
|
if tracker == nil {
|
|
return FileVersion{}, false
|
|
}
|
|
tracker.mu.Lock()
|
|
defer tracker.mu.Unlock()
|
|
version, ok := tracker.versions[absPath]
|
|
return version, ok
|
|
}
|
|
|
|
// Forget drops any recorded version for absPath, so the next read re-establishes
|
|
// the baseline. Used after a tool rewrites a file by a path other than its own
|
|
// (e.g. apply_patch) where the new content is not cheaply available to Record.
|
|
func (tracker *FileTracker) Forget(absPath string) {
|
|
if tracker == nil {
|
|
return
|
|
}
|
|
tracker.mu.Lock()
|
|
delete(tracker.versions, absPath)
|
|
tracker.mu.Unlock()
|
|
}
|
|
|
|
// CheckConflict reports ErrFileChangedOnDisk when absPath has a recorded baseline
|
|
// and currentContent (the bytes the caller just read from disk) no longer matches
|
|
// it. An untracked path returns nil: with no baseline there is nothing to
|
|
// conflict against, so a first-touch write is always allowed.
|
|
func (tracker *FileTracker) CheckConflict(absPath string, currentContent []byte) error {
|
|
if tracker == nil {
|
|
return nil
|
|
}
|
|
version, ok := tracker.Version(absPath)
|
|
if !ok {
|
|
return nil
|
|
}
|
|
if HashContent(currentContent) != version.Hash {
|
|
return ErrFileChangedOnDisk
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// HashContent returns the hex-encoded SHA-256 of content. Exported so the write
|
|
// tools and tests share one definition of a file's identity.
|
|
func HashContent(content []byte) string {
|
|
sum := sha256.Sum256(content)
|
|
return hex.EncodeToString(sum[:])
|
|
}
|
|
|
|
// fileConflictMessage is the actionable error a write tool returns when a target
|
|
// changed on disk since it was last read, telling the model how to recover.
|
|
func fileConflictMessage(relativePath string) string {
|
|
return "Error writing " + relativePath + ": " + ErrFileChangedOnDisk.Error() +
|
|
" (it may have been edited outside Zero). Re-read it with read_file, then re-apply your change so you do not overwrite the newer content."
|
|
}
|