mirror of
https://github.com/Twigpine/zero.git
synced 2026-11-12 08:41:32 -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.
128 lines
4.9 KiB
Go
128 lines
4.9 KiB
Go
package tools
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
)
|
|
|
|
type writeFileTool struct {
|
|
baseTool
|
|
workspaceRoot string
|
|
scope PathScope
|
|
}
|
|
|
|
func NewWriteFileTool(workspaceRoot string) Tool {
|
|
return NewScopedWriteFileTool(workspaceRoot, nil)
|
|
}
|
|
|
|
func NewScopedWriteFileTool(workspaceRoot string, scope PathScope) Tool {
|
|
return writeFileTool{
|
|
baseTool: baseTool{
|
|
name: "write_file",
|
|
description: "Create a new file, refusing to overwrite existing files unless overwrite is true.",
|
|
parameters: Schema{
|
|
Type: "object",
|
|
Properties: map[string]PropertySchema{
|
|
"path": {Type: "string", Description: "Absolute or relative path of the file to write."},
|
|
"content": {Type: "string", Description: "Full file contents to write."},
|
|
"overwrite": {Type: "boolean", Description: "Whether to allow overwriting an existing file.", Default: false},
|
|
},
|
|
Required: []string{"path", "content"},
|
|
AdditionalProperties: false,
|
|
},
|
|
safety: promptSafety(SideEffectWrite, "Creates or overwrites files."),
|
|
},
|
|
workspaceRoot: normalizeWorkspaceRoot(workspaceRoot),
|
|
scope: scope,
|
|
}
|
|
}
|
|
|
|
func (tool writeFileTool) Run(ctx context.Context, args map[string]any) Result {
|
|
return tool.RunWithOptions(ctx, args, RunOptions{})
|
|
}
|
|
|
|
func (tool writeFileTool) RunWithOptions(_ context.Context, args map[string]any, options RunOptions) Result {
|
|
requestedPath, err := aliasedStringArg(args, []string{"path", "file", "file_path", "filename"}, "", true, false)
|
|
if err != nil {
|
|
return errorResult("Error: Invalid arguments for write_file: " + err.Error())
|
|
}
|
|
content, err := fileContentArg(args)
|
|
if err != nil {
|
|
return errorResult("Error: Invalid arguments for write_file: " + err.Error())
|
|
}
|
|
overwrite, err := boolArg(args, "overwrite", false)
|
|
if err != nil {
|
|
return errorResult("Error: Invalid arguments for write_file: " + err.Error())
|
|
}
|
|
|
|
absolutePath, relativePath, err := resolveScopedTargetPath(tool.workspaceRoot, tool.scope, requestedPath)
|
|
if err != nil {
|
|
return errorResult("Error writing file " + requestedPath + ": " + err.Error())
|
|
}
|
|
|
|
existed := false
|
|
if _, err := os.Stat(absolutePath); err == nil {
|
|
existed = true
|
|
if !overwrite {
|
|
return errorResult("Error: " + relativePath + " already exists. Pass overwrite: true to replace it.")
|
|
}
|
|
} else if !os.IsNotExist(err) {
|
|
return errorResult("Error writing file " + relativePath + ": " + err.Error())
|
|
}
|
|
|
|
// On overwrite, refuse to clobber a tracked file that changed on disk outside
|
|
// Zero since it was last read — the new content was likely composed against a
|
|
// stale view. Only read current bytes when there is a baseline to compare,
|
|
// so a first-touch create/overwrite stays a single write with no extra read.
|
|
if existed {
|
|
if _, tracked := options.FileTracker.Version(absolutePath); tracked {
|
|
// Fail CLOSED: if the tracked file can't be re-read to verify it, refuse
|
|
// the overwrite rather than clobbering a file whose current state is
|
|
// unknown (it may have been replaced or removed out from under us).
|
|
current, rerr := os.ReadFile(absolutePath)
|
|
if rerr != nil {
|
|
return errorResult(fileConflictMessage(relativePath))
|
|
}
|
|
if cerr := options.FileTracker.CheckConflict(absolutePath, current); cerr != nil {
|
|
return errorResult(fileConflictMessage(relativePath))
|
|
}
|
|
}
|
|
}
|
|
|
|
if err := os.MkdirAll(filepath.Dir(absolutePath), 0o755); err != nil {
|
|
return errorResult("Error writing file " + relativePath + ": " + err.Error())
|
|
}
|
|
if err := recheckScopedWriteTarget(tool.workspaceRoot, tool.scope, requestedPath); err != nil {
|
|
return errorResult("Error writing file " + relativePath + ": " + err.Error())
|
|
}
|
|
if err := os.WriteFile(absolutePath, []byte(content), 0o644); err != nil {
|
|
return errorResult("Error writing file " + relativePath + ": " + err.Error())
|
|
}
|
|
// Baseline the freshly written content so a later edit/overwrite in this
|
|
// session compares against what is now on disk.
|
|
newInfo, _ := os.Stat(absolutePath)
|
|
options.FileTracker.Record(absolutePath, []byte(content), newInfo)
|
|
|
|
verb := "Created"
|
|
if existed {
|
|
verb = "Overwrote"
|
|
}
|
|
summary := fmt.Sprintf("%s %s (%d bytes).", verb, relativePath, len([]byte(content)))
|
|
result := okResult(summary)
|
|
result.ChangedFiles = []string{relativePath}
|
|
result.Display = Display{Summary: summary, Kind: "file"}
|
|
return result
|
|
}
|
|
|
|
// fileContentArg reads the file body from "content" or a common alias that weaker
|
|
// models sometimes use instead (contents/text/body/data/file_content). It
|
|
// delegates to the shared aliasedStringArg so the present-but-non-string type
|
|
// error ("content must be a string") and the required-but-missing error
|
|
// ("content is required") stay consistent with every other tool. An empty string
|
|
// is allowed (writing an empty file), so allowEmpty is true.
|
|
func fileContentArg(args map[string]any) (string, error) {
|
|
return aliasedStringArg(args, []string{"content", "contents", "text", "body", "data", "file_content"}, "", true, true)
|
|
}
|