fix(builtin): bind file views to complete returned reads

Problem: whole-file rewrites can discard changes hidden by a later overlay
version, a paged read, or an oversized read that clears the previous guard.

Root cause: read_file renders one read and records another. Missing and
incomplete observations also collapse into the permissive never-seen path.

Fix: observe the renderer's decoded stream with bounded capture, refresh only
complete returned reads, retain older guards, and track first partial reads
explicitly. Keep never-seen compatibility and disabled protection behavior.

Verification: compiling old-owner controls fail five behavior regressions;
fixed focused race tests and real boot.Build checks pass. Full builtin,
root-package tests, go vet, make check and full lint are recorded in the PR.
Written bilingual security bounds and release notes accompany this slice.

A first partial read refuses replacement with the file's line count and the
read_file limit that certifies it. Without that, a default-limited read of a
file longer than 2000 lines can never be certified by rereading as told.

Refs #14025
Refs #14133
This commit is contained in:
SivanCola committed 2026-10-25 21:21:51 -07:00
1 parent 33abf2ef03
commit 3d27a1a4e1
6 files changed
+498 -28

No files matched your search

+96
View File
@@ -0,0 +1,96 @@
---
owner: @SivanCola
backup: @esengine
status: active
reviewed: 2026-10-24
---
# File-view provenance / 文件读取版本来源
## Cause and scope / 根因与范围
`read_file` previously rendered one read, then independently reopened the file
or editor overlay to record its overwrite guard. A later buffer version could
therefore be certified without reaching the caller. A page also certified the
unreturned tail; an oversized read removed a previous guard.
旧实现展示内容后再次读取文件或编辑器缓冲区来记录版本,因此可能记录代理没
看到的后续内容。分页会误更新整个文件的记录,超限读取还会清掉已有保护。
The fix observes the same decoded reader consumed by the renderer:
- A successful complete read from offset zero updates the digest, within the
existing 8 MiB decoded-content bound.
- Partial, oversized and failed reads retain the earlier digest.
- A first incomplete successful read records that incompleteness; `write_file`
requires a complete reread or a targeted edit instead of treating it as unseen.
- Capture is bounded and streaming; it does not drain an unreturned tail.
- A nil `FileViews` leaves the reader unwrapped.
修复直接观察渲染器使用的解码流。只有成功、从零开始、完整返回且不超过
8 MiB 的读取才更新摘要;分页、超限和失败读取保留先前记录。首次不完整读取
会记录其不完整状态,完整覆盖前必须完整重读或使用局部编辑。捕获过程有界,
不额外读完未展示的尾部;禁用保护时保持原读取路径。
## Authority and sinks / 权限与写入入口
The host still binds `FileViews` through `boot.bindFileViews` when changed-file
protection is enabled. The input boundaries remain:
| Input | Existing boundary |
| --- | --- |
| Disk reader | Path resolution, read confinement, network roots, forbidden roots and opened-file checks precede capture. |
| Editor buffer | The host's `FileOverlay` is consulted after its existing path checks. |
| Observation | A digest is stored under the resolved path; decoded bytes live only for that call. No write authority is granted. |
宿主仍按原配置绑定保护记录。路径解析、读取范围、网络根、禁止目录及已打开
文件检查继续在捕获前执行;编辑器来源也沿用原路径检查。该改动不增加权限,
记录仅保存解析路径对应的摘要,解码内容只在调用期间临时保留。
The consumer remains `FileViews.checkOverwrite` in `write_file`; this patch
changes which observation can refresh it. Existing edit/write routes, encoding,
permissions, hooks, approval and protection opt-out remain in their owners.
There is no new shell command, disk format, wire field, tool schema or prefix
mutation. See #14133 for the preserved complete implementation. This independent
slice does not activate the pending transaction or isolation layers.
消费方仍是 `write_file` 的覆盖检查;本批只修正更新记录的来源。编辑、写回、
编码、权限、hooks、审批及关闭保护的行为继续由原模块负责。不新增命令、
持久格式、协议字段或工具定义;事务、隔离层仍分批审查。
## Limits and review evidence / 限制与审查证据
The guard has explicit limits that need the later runtime and provenance slices:
| Boundary | Remaining limit |
| --- | --- |
| Atomicity | This is neither compare-and-swap nor a file lock. External writes can race after the overwrite check. |
| Actor ownership | Parent and child registries can still share a view record. |
| Never-seen files | Legacy overwrites without any prior successful read remain allowed. A successful partial read now requires a complete reread or targeted edit. |
| Model delivery | Downstream truncation or summaries are outside this tool-level receipt; rendered bytes are not proof of later model visibility. |
| Authorization | The guard grants no access and does not replace permission checks. |
这仍是过期视图检查,不是原子比较写入、文件锁或权限控制;外部写入仍可能与
检查后的写回竞争。完全未读过的文件沿用原覆盖规则。父子代理独立记录、所有
已有文件的强制读取,以及下游截断后的模型可见性,均留给后续批次;不能把本批
称为整套并发问题已解决。
Deterministic regressions reproduce a changing overlay, three paging shapes,
and loss of the old guard on oversized input. These cases fail on the unmodified
owner before the fix.
The real `boot.Build` regression follows the production path:
1. Read the whole file, then apply an external edit.
2. Read only the header, then attempt a stale overwrite.
3. Check retained user bytes and the refusal delivered to the provider.
Neighbour checks cover complete reads, empty files, final newlines, CRLF, BOM,
UTF-16, nested verbatim reads, first partial reads, streaming, own edits and the
protection opt-out.
确定性回归覆盖变化的编辑器缓冲区、三种分页及超限读取。真实装配测试在完整
读取后模拟外部修改,只重读首行再尝试覆盖,并同时检查用户内容和模型收到的
拒绝结果;旧实现会失败。相邻测试覆盖完整读取、空文件、换行、编码、嵌套
读取、流式读取、自身编辑及原有关闭保护行为。作者说明不等于独立安全审批。
@@ -0,0 +1,77 @@
package boot
import (
"context"
"os"
"path/filepath"
"strings"
"testing"
"reasonix/internal/contract/event"
"reasonix/internal/contract/provider"
"reasonix/internal/session/control"
)
func TestEffectPagedReadDoesNotRefreshUnseenFileContent(t *testing.T) {
t.Parallel()
home, dir := statedBootHome(t), robustTempDir(t)
target := filepath.Join(dir, "notes.md")
writeFile(t, dir, "notes.md", "header\noriginal tail\n")
const current = "header\nthe user's new tail\n"
rec := &browserScriptProvider{rounds: []func(string) *provider.ToolCall{
func(string) *provider.ToolCall {
return browserCall("whole", "read_file", map[string]any{"path": target})
},
func(last string) *provider.ToolCall {
if !strings.Contains(last, "original tail") {
t.Errorf("whole read did not reach the provider: %q", last)
}
if err := os.WriteFile(target, []byte(current), 0600); err != nil {
t.Error(err)
}
return browserCall("page", "read_file", map[string]any{"path": target, "limit": 1})
},
func(last string) *provider.ToolCall {
if !strings.Contains(last, "header") || strings.Contains(last, "new tail") {
t.Errorf("page did not reach the provider as a partial read: %q", last)
}
return browserCall("overwrite", "write_file", map[string]any{"path": target, "content": "changed header\noriginal tail\n"})
},
}}
kind := "boot-fileview-" + strings.ToLower(t.Name())
provider.Register(kind, func(provider.Config) (provider.Provider, error) { return rec, nil })
writeFile(t, dir, "reasonix.toml", `
default_model = "test-model"
[codegraph]
enabled = false
[[providers]]
name = "test-model"
kind = "`+kind+`"
model = "x"
`)
approveWorkspaceAt(t, home, dir)
ctrl, err := Build(t.Context(), Options{Home: home, WorkspaceRoot: dir, Sink: event.Discard})
if err != nil {
t.Fatal(err)
}
defer ctrl.Close()
ctrl.SetToolApprovalMode(control.ToolApprovalYolo)
runErr := ctrl.Run(context.Background(), "update the header in notes.md")
got, err := os.ReadFile(target)
if err != nil || string(got) != current {
t.Fatalf("unseen user edit was overwritten: %q; read=%v; run=%v", got, err, runErr)
}
if runErr != nil {
t.Fatal(runErr)
}
reqs := rec.requests()
if len(reqs) < 4 {
t.Fatalf("missing requests: %d", len(reqs))
}
results := effectToolResults(reqs[len(reqs)-1])
if len(results) != 3 || !strings.Contains(results[2], "modified after you last read or wrote it") {
t.Fatalf("stale overwrite refusal did not reach the provider: %q", results)
}
}
+77 -18
View File
@@ -1,12 +1,13 @@
package builtin
import (
"context"
"bytes"
"crypto/sha256"
"errors"
"fmt"
"os"
"io"
"path/filepath"
"strings"
"sync"
"reasonix/internal/contract/tool"
@@ -16,18 +17,26 @@ import (
// holds what the agent last read or wrote there.
var ErrFileChangedSinceSeen = errors.New("file changed since the agent last saw it")
// ErrFileNotFullySeen marks a whole-file overwrite after an incomplete read.
var ErrFileNotFullySeen = fmt.Errorf("%w: not read in full", ErrFileChangedSinceSeen)
// FileViews is what one run's agent last saw of each file: the content a read
// returned it from, or what one of its own writes left there. write_file checks
// it so a whole-file overwrite cannot discard a change made since. A nil
// *FileViews records and checks nothing.
type FileViews struct {
mu sync.Mutex
seen map[string][sha256.Size]byte
seen map[string]fileView
}
type fileView struct {
content [sha256.Size]byte
complete bool
}
// NewFileViews returns an empty record.
func NewFileViews() *FileViews {
return &FileViews{seen: map[string][sha256.Size]byte{}}
return &FileViews{seen: map[string]fileView{}}
}
func (v *FileViews) saw(path, content string) {
@@ -36,27 +45,70 @@ func (v *FileViews) saw(path, content string) {
}
v.mu.Lock()
defer v.mu.Unlock()
v.seen[filepath.Clean(path)] = sha256.Sum256([]byte(content))
v.seen[filepath.Clean(path)] = fileView{content: sha256.Sum256([]byte(content)), complete: true}
}
// maxViewBytes bounds the files a read records: past it the view is dropped
// rather than paid for with a second whole-file read.
func (v *FileViews) sawPartial(path string) {
v.mu.Lock()
defer v.mu.Unlock()
path = filepath.Clean(path)
if _, ok := v.seen[path]; !ok {
v.seen[path] = fileView{}
}
}
// maxViewBytes bounds decoded content retained while rendering a read.
const maxViewBytes = 8 << 20
// sawFile records the file's current content, as the edit tools would read it.
func (v *FileViews) sawFile(ctx context.Context, overlay FileOverlay, path string) {
// Only a complete returned read can replace an earlier observation. A page,
// failed read or oversize input must not certify content the caller did not see.
func (v *FileViews) captureRead(path string, emit func(io.Reader, int, int) (string, error)) func(io.Reader, int, int) (string, error) {
if v == nil {
return
return emit
}
if info, err := os.Stat(path); err == nil && info.Size() <= maxViewBytes {
if src, err := readEditSource(ctx, overlay, path); err == nil {
v.saw(path, src.content)
return
return func(src io.Reader, offset, limit int) (string, error) {
if offset != 0 {
out, err := emit(src, offset, limit)
if err == nil {
v.sawPartial(path)
}
return out, err
}
read := &fileViewRead{src: src}
out, err := emit(read, offset, limit)
if err != nil {
return out, err
}
v.sawPartial(path)
data := read.data
if read.eof && len(data) <= maxViewBytes {
lines := bytes.Count(data, []byte{'\n'})
if len(data) > 0 && data[len(data)-1] != '\n' {
lines++
}
if limit <= 0 || lines <= limit {
v.saw(path, string(data))
}
}
return out, nil
}
v.mu.Lock()
delete(v.seen, filepath.Clean(path))
v.mu.Unlock()
}
type fileViewRead struct {
src io.Reader
data []byte
eof bool
}
func (r *fileViewRead) Read(p []byte) (int, error) {
n, err := r.src.Read(p)
if remaining := maxViewBytes + 1 - len(r.data); remaining > 0 {
r.data = append(r.data, p[:min(n, remaining)]...)
}
if err == io.EOF {
r.eof = true
}
return n, err
}
// checkOverwrite refuses replacing current when the agent saw different
@@ -69,7 +121,14 @@ func (v *FileViews) checkOverwrite(path, current string) error {
v.mu.Lock()
want, ok := v.seen[filepath.Clean(path)]
v.mu.Unlock()
if !ok || want == sha256.Sum256([]byte(current)) {
if ok && !want.complete {
lines := strings.Count(current, "\n")
if current != "" && !strings.HasSuffix(current, "\n") {
lines++
}
return fmt.Errorf("%w: write_file replaces all %d lines of %s; read it whole first (read_file with offset 0 and limit >= %d), or use edit_file for a targeted change", ErrFileNotFullySeen, lines, path, lines)
}
if !ok || want.content == sha256.Sum256([]byte(current)) {
return nil
}
return fmt.Errorf("%w: %s was modified after you last read or wrote it, by the user or another process; read it again and apply your change to what it holds now", ErrFileChangedSinceSeen, path)
@@ -0,0 +1,237 @@
package builtin
import (
"context"
"encoding/json"
"errors"
"os"
"path/filepath"
"strings"
"testing"
fileenc "reasonix/internal/base/fileutil/encoding"
"reasonix/internal/contract/tool"
)
type changingReadOverlay struct {
reads int
writes int
}
func (o *changingReadOverlay) ReadTextFile(context.Context, string) (string, bool) {
o.reads++
if o.reads == 1 {
return "before\n", true
}
return "another writer\n", true
}
func (o *changingReadOverlay) WriteTextFile(context.Context, string, string) (bool, error) {
o.writes++
return true, nil
}
func TestFileViewTracksTheReturnedReadRatherThanASecondRead(t *testing.T) {
path := filepath.Join(t.TempDir(), "a.txt")
if err := os.WriteFile(path, []byte("before\n"), 0600); err != nil {
t.Fatal(err)
}
views, overlay := NewFileViews(), &changingReadOverlay{}
if err := runViewTool(t, readFile{views: views, overlay: overlay}, map[string]string{"path": path}); err != nil {
t.Fatal(err)
}
err := runViewTool(t, writeFile{views: views, overlay: overlay}, map[string]string{"path": path, "content": "overwrite\n"})
if !errors.Is(err, ErrFileChangedSinceSeen) || overlay.writes != 0 {
t.Fatalf("stale read certified a later version: %v; writes=%d", err, overlay.writes)
}
}
func TestPagedReadDoesNotCertifyUnseenFileContent(t *testing.T) {
for _, page := range []string{
`{"path":"a.txt","limit":1}`,
`{"path":"a.txt","offset":1}`,
`{"path":"a.txt","offset":99}`,
} {
t.Run(page, func(t *testing.T) {
root := t.TempDir()
path := filepath.Join(root, "a.txt")
if err := os.WriteFile(path, []byte("header\noriginal tail\n"), 0600); err != nil {
t.Fatal(err)
}
views := NewFileViews()
reader := readFile{workDir: root, views: views}
if _, err := reader.Execute(t.Context(), json.RawMessage(`{"path":"a.txt"}`)); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path, []byte("header\nother writer's tail\n"), 0600); err != nil {
t.Fatal(err)
}
if _, err := reader.Execute(t.Context(), json.RawMessage(page)); err != nil {
t.Fatal(err)
}
_, err := (writeFile{workDir: root, views: views}).Execute(t.Context(), json.RawMessage(`{"path":"a.txt","content":"edited header\noriginal tail\n"}`))
if !errors.Is(err, ErrFileChangedSinceSeen) {
t.Fatalf("paged read authorized stale overwrite: %v", err)
}
got, err := os.ReadFile(path)
if err != nil || string(got) != "header\nother writer's tail\n" {
t.Fatalf("lost unseen content: %q; %v", got, err)
}
})
}
}
func TestOversizeReadDoesNotForgetAnEarlierFileView(t *testing.T) {
root := t.TempDir()
path := filepath.Join(root, "a.txt")
if err := os.WriteFile(path, []byte("before\n"), 0600); err != nil {
t.Fatal(err)
}
views := NewFileViews()
reader := readFile{workDir: root, views: views}
if _, err := reader.Execute(t.Context(), json.RawMessage(`{"path":"a.txt"}`)); err != nil {
t.Fatal(err)
}
current := strings.Repeat("external writer\n", maxViewBytes/16+1)
if err := os.WriteFile(path, []byte(current), 0600); err != nil {
t.Fatal(err)
}
if _, err := reader.Execute(t.Context(), json.RawMessage(`{"path":"a.txt","limit":1}`)); err != nil {
t.Fatal(err)
}
if err := views.checkOverwrite(path, current); !errors.Is(err, ErrFileChangedSinceSeen) {
t.Fatalf("oversize read forgot the previous view: %v", err)
}
}
func TestWholeReadRefreshesTheFileView(t *testing.T) {
for _, tc := range []struct {
name string
content string
enc fileenc.Kind
nested bool
}{
{name: "empty"},
{name: "final newline", content: "new\n"},
{name: "no final newline", content: "new"},
{name: "CRLF", content: "new\r\n"},
{name: "UTF8 BOM", content: "new\n", enc: fileenc.UTF8BOM},
{name: "UTF16", content: "new\n", enc: fileenc.UTF16LE},
{name: "nested verbatim", content: "new\r\n", nested: true},
} {
t.Run(tc.name, func(t *testing.T) {
path := filepath.Join(t.TempDir(), "a.txt")
if err := writeFileEncoded(path, tc.content, tc.enc); err != nil {
t.Fatal(err)
}
views := NewFileViews()
views.saw(path, "before\n")
ctx := t.Context()
if tc.nested {
ctx = tool.MarkNested(ctx)
}
args, err := json.Marshal(map[string]any{"path": path, "limit": 1})
if err != nil {
t.Fatal(err)
}
if _, err := (readFile{views: views}).Execute(ctx, args); err != nil {
t.Fatal(err)
}
if err := runViewTool(t, writeFile{views: views}, map[string]string{"path": path, "content": "edited\n"}); err != nil {
t.Fatalf("whole returned read did not refresh the view: %v", err)
}
})
}
}
func TestFileViewCaptureKeepsPagedReadsStreaming(t *testing.T) {
content := strings.Repeat("line\n", maxViewBytes)
src := strings.NewReader(content)
views := NewFileViews()
views.saw("a.txt", "before\n")
out, err := views.captureRead("a.txt", (readFile{}).scan)(src, 0, 1)
if err != nil || !strings.Contains(out, "1→line") {
t.Fatalf("read page: %q; %v", out, err)
}
if consumed := len(content) - src.Len(); consumed > readFileDetectSample {
t.Fatalf("recording a page consumed %d unseen bytes", consumed)
}
if err := views.checkOverwrite("a.txt", content); !errors.Is(err, ErrFileChangedSinceSeen) {
t.Fatalf("streamed page certified unread content: %v", err)
}
}
func TestFirstIncompleteReadCannotPermitALaterStaleOverwrite(t *testing.T) {
for _, args := range []string{
`{"path":"a.txt","limit":1}`,
`{"path":"a.txt","offset":1}`,
} {
t.Run(args, func(t *testing.T) {
root := t.TempDir()
path := filepath.Join(root, "a.txt")
if err := os.WriteFile(path, []byte("before\ntail\n"), 0600); err != nil {
t.Fatal(err)
}
views := NewFileViews()
if _, err := (readFile{workDir: root, views: views}).Execute(t.Context(), json.RawMessage(args)); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path, []byte("user edit\ntail\n"), 0600); err != nil {
t.Fatal(err)
}
err := runViewTool(t, writeFile{workDir: root, views: views}, map[string]string{"path": path, "content": "stale rewrite"})
if !errors.Is(err, ErrFileChangedSinceSeen) {
t.Fatalf("first incomplete read permitted a stale overwrite: %v", err)
}
})
}
}
func TestFirstPartialReadRequiresAWholeReread(t *testing.T) {
root := t.TempDir()
path := filepath.Join(root, "a.txt")
if err := os.WriteFile(path, []byte("header\nunseen tail\n"), 0600); err != nil {
t.Fatal(err)
}
views := NewFileViews()
read := readFile{workDir: root, views: views}
write := writeFile{workDir: root, views: views}
if _, err := read.Execute(t.Context(), json.RawMessage(`{"path":"a.txt","limit":1}`)); err != nil {
t.Fatal(err)
}
err := runViewTool(t, write, map[string]string{"path": path, "content": "replacement\n"})
if !errors.Is(err, ErrFileChangedSinceSeen) {
t.Fatalf("unseen tail was certified for replacement: %v", err)
}
if _, err := read.Execute(t.Context(), json.RawMessage(`{"path":"a.txt"}`)); err != nil {
t.Fatal(err)
}
if err := runViewTool(t, write, map[string]string{"path": path, "content": "replacement\n"}); err != nil {
t.Fatalf("whole reread did not permit replacement: %v", err)
}
}
func TestDefaultLimitedReadNamesTheReadThatCertifiesReplacement(t *testing.T) {
root := t.TempDir()
path := filepath.Join(root, "long.txt")
lines := readFileDefaultLimit + 500
if err := os.WriteFile(path, []byte(strings.Repeat("line\n", lines)), 0600); err != nil {
t.Fatal(err)
}
views := NewFileViews()
read := readFile{workDir: root, views: views}
write := writeFile{workDir: root, views: views}
if _, err := read.Execute(t.Context(), json.RawMessage(`{"path":"long.txt"}`)); err != nil {
t.Fatal(err)
}
err := runViewTool(t, write, map[string]string{"path": path, "content": "short\n"})
if !errors.Is(err, ErrFileNotFullySeen) || !strings.Contains(err.Error(), "limit >= 2500") {
t.Fatalf("a default-limited read was refused without naming the read that certifies it: %v", err)
}
if _, err := read.Execute(t.Context(), json.RawMessage(`{"path":"long.txt","limit":2500}`)); err != nil {
t.Fatal(err)
}
if err := runViewTool(t, write, map[string]string{"path": path, "content": "short\n"}); err != nil {
t.Fatalf("the named read did not certify replacement: %v", err)
}
}
+2 -10
View File
@@ -93,16 +93,7 @@ func (r readFile) ReadTarget(args json.RawMessage) string {
}
func (r readFile) Execute(ctx context.Context, args json.RawMessage) (string, error) {
out, err := r.read(ctx, args)
if err == nil && r.views != nil {
var p struct {
Path string `json:"path"`
}
if json.Unmarshal(args, &p) == nil {
r.views.sawFile(ctx, r.overlay, resolveReadablePath(r.workDir, p.Path, r.paths).Path)
}
}
return out, err
return r.read(ctx, args)
}
// refuse is the read boundary: outside the read roots, or a forbidden path that
@@ -146,6 +137,7 @@ func (r readFile) read(ctx context.Context, args json.RawMessage) (string, error
p.Offset = 0
}
emit, limit := r.render(ctx, p.Limit)
emit = r.views.captureRead(p.Path, emit)
p.Limit = limit
// The host overlay (unsaved editor buffers) wins over the disk when it can
@@ -0,0 +1,9 @@
## 修复
- File change protection records the complete returned read; pages and oversized
reads preserve the earlier guard. (#14025)
- 文件变更保护现在记录完整读取实际返回的内容,避免二次读取误记录后续文件或
编辑器版本;分页和超限读取会保留已有的过期覆盖保护。 (#14025)
- After a partial read, read the file in full before a whole-file overwrite, or
use a targeted edit. (#14025)
- 首次只读取一部分内容后,完整覆盖前需要完整重读,或改用局部编辑。 (#14025)