Skip to content

feat: 1.3.4 — 文件工具读后写保护(内容哈希校验) - #37

Merged
LangLang03 merged 3 commits into
masterfrom
dev
Oct 12, 2026
Merged

LangLang03 merged 3 commits into
masterfrom
dev

Conversation

@LangLang03

@LangLang03 LangLang03 commented Oct 12, 2026 •

Copy link
Copy Markdown
Owner

Summary / 概述

新增文件工具读后写保护并发布 v1.3.4:AI 覆盖或编辑一个已存在的文件之前,必须先读取它;读取成功时把整份文件内容的 SHA-256 记在内存里,写入前重新计算并比对,不一致就拒绝写入并要求 AI 重新读取,避免无声覆盖它从未看过的内容。

User-visible behavior / 用户可见行为

对模型(工具调用)可见的行为变化:

场景 之前 现在
file_write 覆盖已存在、从未读取的文件 直接覆盖 拒绝:Cannot modify <path>: the file already exists but has not been read. Read it with file_read first, then try again.
file_edit 编辑从未读取的文件 直接按 old_string 替换 同上,先要求读取
读取之后文件被外部改动(其他程序 / 其他 Agent / 用户) 按已读的旧内容覆盖,外部改动被吞掉 拒绝:Cannot modify <path>: the file changed after it was read. Read it again with file_read, then try again.
读取之后文件内容没有变化 — 允许(比对内容摘要,不看时间戳)
file_write 新建文件 允许 允许(新建不会丢弃任何内容,无需先读)
工具自己刚写入/编辑过该文件 — 记录立即刷新,连续修改不必反复重读
读取之后文件被外部删除 — 视为新建,可再次创建
file_delete 删除理由 + 用户确认 不变(本次范围只含写入类工具)

两条拒绝文案都有中英双语文案,通过既有的 ToolLanguage 与工具文案目录输出。

对用户可见:拒绝时工具卡片显示既有的错误态与上述可读文案;没有新增界面。

实现要点 / Design

  • application(新增)
    • src/application/file_content_hash.{h,cpp}:无第三方依赖的流式 SHA-256(Sha256Digest::Update / Hex、一次性 Sha256Hex),摘要与分块方式无关。
    • src/application/file_observation_store.{h,cpp}:线程安全的内存「绝对路径 → 内容摘要」表;容量上限 4096,超出后整表作废(记录只是许可提示,丢弃只会多读一次,绝不会放宽限制);不落盘,重启后从零开始,只会更严格。
  • application(file_tool_registry)
    • HashFile():以 64KB 分块读取计算整份文件摘要,大文件不会因识别版本而整份载入内存。
    • RememberObserved():记录工具读/写完之后的版本;无法计算摘要时改为遗忘,该文件按「未读」处理。
    • CheckObservedRevision():返回 allowed / not_read / changed;changed 同时覆盖「读过之后变了」和「无法读回校验」。
    • MutationRefused():统一产出本地化拒绝文案,始终带上补救办法。
    • 挂钩点:file_read 成功(整份读或 start_kb 窗口读)后记录;file_write 命中已存在文件、file_edit 进入替换之前检查;两者写入成功后刷新记录。
  • 可注入:FileToolRegistry 构造新增可选的 FileObservationStore(默认自建),FileToolContext 增加 observations 指针;需要时多个注册表可共享同一份「已读」记录(测试覆盖了共享注入)。
  • resources:default.properties / zh.properties 各新增 tool_file_write_needs_read、tool_file_write_stale_read;src/application/tool_text_catalog.{h,cpp} 由 tools/gen_tool_text_catalog.py 重新生成(76 → 78 键,kToolTextKeyCount 同步)。
  • 明确不改:工具描述保持与 legacy getDescription() 逐字一致(读后写要求放在拒绝文案里,不写进 schema 描述);file_delete 不在本次范围,它已经有删除理由 + 用户确认两道闸。
  • 移植性:Android NDK 的 libc++ 不支持透明比较符的异构 erase,FileObservationStore::Forget() 改为 find + 按迭代器删除——这个问题正是 :app:assembleDebug 跑出来并当场修掉的。

Validation / 测试与验证

  • GoogleTest suite passes with Ninja and -j8 —— 122/122(新增 file_observation_tests 目标后由 121 增至 122)
  • Fake AI server tests pass —— 29/29
  • Android lint passes —— :app:lintDebug 0 errors, 17 warnings(17 条均为既有项,与本改动无关)
  • Android debug build succeeds —— :app:assembleDebug BUILD SUCCESSFUL,且 BuildConfig 与 APK 元数据均为 VERSION_CODE=38、VERSION_NAME="1.3.4"
  • Verified on a device or emulator, or explained why this is not applicable —— 本机 adb devices 为空(无设备/模拟器);本次没有 UI 改动,用户可见部分只是既有工具卡片的错误态文案,因此未做视觉复核

Commands and environment used:

GCC 16.2.1 / CMake 4.4.4 / Ninja 1.13.2 / JDK 17.0.20.1 / HuxerUI 0.3.0 / NDK 29.0.14206865

cmake -S tests -B build/tests-ninja -G Ninja -DCMAKE_BUILD_TYPE=Release
cmake --build build/tests-ninja --parallel 8
ctest --test-dir build/tests-ninja --output-on-failure -j8      # 122/122 passed
(cd tools && python3 -m unittest test_fake_ai_server.py)        # 29 tests OK
python3 tools/gen_tool_text_catalog.py --check                  # 78 keys, matches
./platform/android/gradlew -p platform/android \
    :app:lintDebug :app:assembleDebug --max-workers=8           # BUILD SUCCESSFUL

新增/扩写的覆盖:

  • tests/file_observation_tests.cpp(新增目标):SHA-256 标准向量(空串、abc、56 字节跨两块、恰好一块、多块、含 0x00–0xFF 的二进制串)与「任意分块结果一致」;观察表的记录 / 覆盖 / 遗忘 / 清空 / 容量上限。
  • tests/file_tool_tests.cpp 第 13 节:未读拒绝、读取后放行、工具自身写入刷新记录、外部改动拒绝且文件内容未被改写、内容相同不算改动、窗口读取(start_kb)授权整份覆写、读取失败不授权、写入失败后仍可重试、读取后被外部删除可重建、file_edit 的未读与过期两条路径、中英文文案、共享记录注入到第二个注册表。
  • 既有 file_write / file_edit 用例按新规则先读取目标,语义不变。

Design and safety checklist

  • The change keeps domain, application, infrastructure, platform, and presentation responsibilities separated.(策略与存储都在 application 层;ToolFileAccess 端口未改,只用既有 ReadBytes)
  • New behavior is behind an abstraction or data-driven descriptor where extension is expected; it does not grow a UI if chain.(观察表独立成类且可注入;工具仍由既有描述符表驱动,没有新增按名字分支)
  • New or changed behavior has GoogleTest coverage.
  • No credential, signing key, personal data, generated build output, or incompatible third-party code is included.(摘要实现无外部依赖,未引入第三方代码)
  • User-facing text is available in both English and Chinese where applicable.
  • UI changes preserve the intended LineCode behavior and have been visually checked.(无 UI 改动,既有工具卡片渲染路径未改)

Screenshots or recordings

本次没有界面改动:改动全部发生在工具执行路径,用户能看到的是既有工具卡片在拒绝时的错误态与文案(中英文各一条)。本机也没有可用设备或模拟器,未附截图;如需实际界面复核,我可以在设备上补拍并更新本段。

CLA — required

CLA Signature: @LangLang03

Sourcery 摘要

通过要求当前的先前读取来保护现有文件变更,并将该变更作为 Android 1.3.4 版本发布。

新功能:

  • 要求在 file_write 覆盖现有文件或 file_edit 修改现有文件之前先读取文件;如果文件自记录读取后发生变化,则拒绝变更。
  • 为未读取文件和过期文件的变更拒绝添加本地化的英文和中文提示。

错误修复:

  • 防止文件工具在模型未读取内容或内容已被外部更改时静默覆盖文件。

增强功能:

  • 使用流式 SHA-256 内容哈希,在有界且线程安全的内存存储中跟踪已观察到的文件修订版本,并支持在多个注册表之间共享观察结果。
  • 保留对新创建文件和工具成功变更的授权,同时以保守方式处理验证或读取失败的情况。

构建:

  • 将 Android 应用升级为 versionCode 38 和 versionName 1.3.4。

文档:

  • 在变更日志中记录 v1.3.4 的文件变更保护和发布变更。

测试:

  • 增加 SHA-256 和观察存储覆盖率,以及针对读取后写入、过期内容、范围读取、重试、本地化、删除和共享观察状态的全面文件工具测试。
  • 通过原生测试套件、虚假 AI 服务器测试、Android lint 和 debug APK 构建验证这些变更。
Original summary in English

Sourcery 摘要

通过要求当前的先前读取来保护现有文件的修改,并拒绝可能覆盖未查看或已更改内容的写入操作。

新功能:

  • 要求在进行 file_write 替换或 file_edit 修改前读取现有文件;如果文件自记录读取后发生更改,则拒绝修改。
  • 为未读取、过时、不完整以及并发修改的文件操作添加本地化的英文和中文指导。

错误修复:

  • 防止文件工具静默覆盖模型尚未查看的内容,或覆盖读取后发生更改的内容。
  • 对非“未找到”文件状态或读取失败采取保守处理,而不是允许可能不安全的写入。

增强功能:

  • 使用有界的内存中 SHA-256 记录跟踪已观察到的文件版本,支持部分读取语义、修改声明,以及在多个注册表之间共享观察状态。

构建:

  • 发布 Android 版本 1.3.4,versionCode 为 38。

文档:

  • 在变更日志中记录文件修改保护功能和 v1.3.4 版本发布。

测试:

  • 增加对哈希计算、观察记录存储、读后写行为、过时和部分读取、重试、并发、本地化、删除以及共享注册表状态的测试覆盖。
Original summary in English

Summary by Sourcery

Protect existing file mutations by requiring a current prior read and rejecting writes that could overwrite unseen or changed content.

New Features:

  • Require existing files to be read before file_write replacement or file_edit modification, and reject changes when the file has changed since the recorded read.
  • Add localized English and Chinese guidance for unread, stale, incomplete, and concurrently modified file mutations.

Bug Fixes:

  • Prevent file tools from silently overwriting content that the model has not seen or that changed after it was read.
  • Treat non-not-found file status or read failures conservatively instead of allowing potentially unsafe writes.

Enhancements:

  • Track observed file revisions with bounded in-memory SHA-256 records, support partial-read semantics, mutation claims, and sharing observation state across registries.

Build:

  • Release Android version 1.3.4 with versionCode 38.

Documentation:

  • Document the file mutation protection and v1.3.4 release in the changelog.

Tests:

  • Add coverage for hashing, observation storage, read-before-write behavior, stale and partial reads, retries, concurrency, localization, deletion, and shared registry state.

The built-in file tools let the model replace or edit any file in the
workspace, including one it had never read, so a stale plan could silently
discard content the user or another Agent had written in the meantime.

file_read now records the SHA-256 of the whole file -- streamed in 64KB
reads, so a large file is never loaded just to identify it -- in an
in-memory, mutex-guarded FileObservationStore keyed by the resolved
absolute path. file_write and file_edit consult it before changing a file
that already exists: no record means the file was never read, and a record
that no longer matches the bytes on disk means it changed after the read.
Both cases refuse the mutation and return a localized instruction to read
the file again, so the next step is a read of that revision instead of a
retry of the same blind overwrite.

Creating a file needs no read, a successful write or edit refreshes the
record so a run of edits does not re-read between steps, and a file that
disappeared after its read counts as a creation again. The record is not
persisted and is capped, so a long-lived process cannot grow without bound,
and dropping it only ever costs another read.

Verification: ctest (122/122, including the new file_observation_tests with
published SHA-256 vectors and the read-before-write cases in
file_tool_tests), :app:assembleDebug, and
tools/gen_tool_text_catalog.py --check.
versionCode 38 and versionName 1.3.4, with update.md carrying the release
notes for the file tools reading a target before they may overwrite or edit
it.
@sourcery-ai

sourcery-ai Bot commented Oct 12, 2026 •

Copy link
Copy Markdown

审查者指南

本 PR 在 application 层引入可注入、线程安全的文件版本观察机制和流式 SHA-256 校验,并将其挂接到 file_read、file_write 与 file_edit 生命周期中,使已存在文件必须基于当前已读版本才能修改;同时补充双语错误提示、全面测试覆盖并发布 Android 1.3.4。

读取后写入文件保护时序图

sequenceDiagram
    actor AI
    participant Registry as FileToolRegistry
    participant Store as FileObservationStore
    participant Files as ToolFileAccess

    AI->>Registry: file_read(path)
    Registry->>Files: ReadBytes(path, offset, 65536)
    Files-->>Registry: file content chunks
    Registry->>Files: ReadBytes(path, offset, 65536)
    Files-->>Registry: end of file
    Registry->>Store: Record(absolute_path, SHA-256)
    Registry-->>AI: file contents

    AI->>Registry: file_write(path, content)
    Registry->>Store: ObservedHash(absolute_path)
    Store-->>Registry: recorded hash
    Registry->>Files: ReadBytes(path, offset, 65536)
    Files-->>Registry: current file chunks
    Registry->>Registry: compare current hash with recorded hash
    alt hash matches
        Registry->>Files: write file
        Files-->>Registry: success
        Registry->>Store: Record(absolute_path, SHA-256)
        Registry-->>AI: success
    else file not read or hash changed
        Registry-->>AI: ToolFailure with file_read retry message
    end
Loading

文件变更保护结果流程图

flowchart TD
    Start["file_write or file_edit"] --> Exists{Existing file?}
    Exists -->|No| Mutate["Create or mutate file"]
    Exists -->|Yes| Observed{ObservedHash exists?}
    Observed -->|No| RefuseRead["Refuse mutation: use file_read first"]
    Observed -->|Yes| Hash["HashFile in 64 KB chunks"]
    Hash --> Match{Hash matches recorded revision?}
    Match -->|No| RefuseStale["Refuse mutation: read file again"]
    Match -->|Yes| Mutate
    Mutate --> Refresh["RememberObserved after successful write/edit"]
Loading

文件级变更

变更 详情 文件
为文件变更操作增加基于内容哈希的读后写保护,防止覆盖模型未读取或读取后已过期的文件版本。
  • 新增无第三方依赖、支持分块输入的 SHA-256 实现,并以 64KB 分块计算完整文件摘要。
  • 新增线程安全的内存观察表,按绝对路径记录摘要,限制 4096 条并支持共享注入。
  • file_read 成功后记录版本;file_write/file_edit 修改已存在文件前校验“已读取且摘要未变化”,成功后刷新记录;新建文件、工具自身连续修改和外部删除后重建按相应规则处理。
  • 统一接入中英文拒绝文案,并保持工具描述/schema 与既有内容不变。
src/application/file_content_hash.h
src/application/file_content_hash.cpp
src/application/file_observation_store.h
src/application/file_observation_store.cpp
src/application/file_tool_registry.h
src/application/file_tool_registry.cpp
resources/strings/default.properties
resources/strings/zh.properties
src/application/tool_text_catalog.h
src/application/tool_text_catalog.cpp
补充读后写保护、摘要实现和观察表的自动化覆盖。
  • 新增 SHA-256 标准向量、任意分块一致性、二进制输入和观察表容量/生命周期测试。
  • 覆盖未读拒绝、外部修改拒绝且不写入、内容未变放行、窗口读取授权、失败重试、删除后重建、file_edit、中英文文案及跨注册表共享记录。
tests/file_observation_tests.cpp
tests/file_tool_tests.cpp
tests/CMakeLists.txt
发布版本 1.3.4 并更新变更日志。
  • 将 Android versionCode 从 37 升至 38、versionName 更新为 1.3.4。
  • 新增 v1.3.4 的文件保护行为、测试覆盖和发布说明。
platform/android/app/build.gradle
update.md

提示与命令

与 Sourcery 交互

  • 触发新的审查: 在 pull request 中评论 @sourcery-ai review。
  • 继续讨论: 直接回复 Sourcery 的审查评论。
  • 根据审查评论生成 GitHub issue: 回复审查评论,请 Sourcery 根据该评论创建 issue。也可以回复 @sourcery-ai issue,根据该评论创建 issue。
  • 生成 pull request 标题: 在 pull request 标题的任意位置写入 @sourcery-ai,即可随时生成标题。也可以在 pull request 中评论 @sourcery-ai title,随时生成或重新生成标题。
  • 生成 pull request 摘要: 在 pull request 正文中任意位置写入 @sourcery-ai summary,即可在指定位置随时生成 PR 摘要。也可以在 pull request 中评论 @sourcery-ai summary,随时生成或重新生成摘要。
  • 生成审查者指南: 在 pull request 中评论 @sourcery-ai guide,即可随时生成或重新生成审查者指南。
  • 解决所有 Sourcery 评论: 在 pull request 中评论 @sourcery-ai resolve,解决所有 Sourcery 评论。如果你已经处理完所有评论且不想再看到它们,此命令会很有用。
  • 忽略所有 Sourcery 审查: 在 pull request 中评论 @sourcery-ai dismiss,忽略所有现有的 Sourcery 审查。如果你想从新的审查开始,这尤其有用——别忘了评论 @sourcery-ai review 来触发新的审查!

自定义使用体验

访问你的控制面板以:

  • 启用或禁用审查功能,例如 Sourcery 生成的 pull request 摘要、审查者指南等。
  • 更改审查语言。
  • 添加、删除或编辑自定义审查说明。
  • 调整其他审查设置。

获取帮助

Original review guide in English

Reviewer's Guide

本 PR 在 application 层引入可注入、线程安全的文件版本观察机制和流式 SHA-256 校验,并将其挂接到 file_read、file_write 与 file_edit 生命周期中,使已存在文件必须基于当前已读版本才能修改;同时补充双语错误提示、全面测试覆盖并发布 Android 1.3.4。

Sequence diagram for read-before-write file protection

sequenceDiagram
    actor AI
    participant Registry as FileToolRegistry
    participant Store as FileObservationStore
    participant Files as ToolFileAccess

    AI->>Registry: file_read(path)
    Registry->>Files: ReadBytes(path, offset, 65536)
    Files-->>Registry: file content chunks
    Registry->>Files: ReadBytes(path, offset, 65536)
    Files-->>Registry: end of file
    Registry->>Store: Record(absolute_path, SHA-256)
    Registry-->>AI: file contents

    AI->>Registry: file_write(path, content)
    Registry->>Store: ObservedHash(absolute_path)
    Store-->>Registry: recorded hash
    Registry->>Files: ReadBytes(path, offset, 65536)
    Files-->>Registry: current file chunks
    Registry->>Registry: compare current hash with recorded hash
    alt hash matches
        Registry->>Files: write file
        Files-->>Registry: success
        Registry->>Store: Record(absolute_path, SHA-256)
        Registry-->>AI: success
    else file not read or hash changed
        Registry-->>AI: ToolFailure with file_read retry message
    end
Loading

Flow diagram for file mutation guard outcomes

flowchart TD
    Start["file_write or file_edit"] --> Exists{Existing file?}
    Exists -->|No| Mutate["Create or mutate file"]
    Exists -->|Yes| Observed{ObservedHash exists?}
    Observed -->|No| RefuseRead["Refuse mutation: use file_read first"]
    Observed -->|Yes| Hash["HashFile in 64 KB chunks"]
    Hash --> Match{Hash matches recorded revision?}
    Match -->|No| RefuseStale["Refuse mutation: read file again"]
    Match -->|Yes| Mutate
    Mutate --> Refresh["RememberObserved after successful write/edit"]
Loading

File-Level Changes

Change Details Files
为文件变更操作增加基于内容哈希的读后写保护,防止覆盖模型未读取或读取后已过期的文件版本。
  • 新增无第三方依赖、支持分块输入的 SHA-256 实现,并以 64KB 分块计算完整文件摘要。
  • 新增线程安全的内存观察表,按绝对路径记录摘要,限制 4096 条并支持共享注入。
  • file_read 成功后记录版本;file_write/file_edit 修改已存在文件前校验“已读取且摘要未变化”,成功后刷新记录;新建文件、工具自身连续修改和外部删除后重建按相应规则处理。
  • 统一接入中英文拒绝文案,并保持工具描述/schema 与既有内容不变。
src/application/file_content_hash.h
src/application/file_content_hash.cpp
src/application/file_observation_store.h
src/application/file_observation_store.cpp
src/application/file_tool_registry.h
src/application/file_tool_registry.cpp
resources/strings/default.properties
resources/strings/zh.properties
src/application/tool_text_catalog.h
src/application/tool_text_catalog.cpp
补充读后写保护、摘要实现和观察表的自动化覆盖。
  • 新增 SHA-256 标准向量、任意分块一致性、二进制输入和观察表容量/生命周期测试。
  • 覆盖未读拒绝、外部修改拒绝且不写入、内容未变放行、窗口读取授权、失败重试、删除后重建、file_edit、中英文文案及跨注册表共享记录。
tests/file_observation_tests.cpp
tests/file_tool_tests.cpp
tests/CMakeLists.txt
发布版本 1.3.4 并更新变更日志。
  • 将 Android versionCode 从 37 升至 38、versionName 更新为 1.3.4。
  • 新增 v1.3.4 的文件保护行为、测试覆盖和发布说明。
platform/android/app/build.gradle
update.md

Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

@sourcery-ai sourcery-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

嘿——我发现了 7 个问题

面向 AI Agent 的提示
请处理此次代码审查中的评论:

## 个别评论

### 评论 1
<location path="src/application/file_tool_registry.cpp" line_range="771" />
<code_context>
+  // cannot discard anything.
+  if (info.has_value()) {
+    const auto guard =
+        co_await CheckObservedRevision(context, target->absolute);
+    if (guard != MutationGuard::allowed)
+      co_return MutationRefused(context, guard, input_path);
</code_context>
<issue_to_address>
**并发更改会覆盖已验证的内容**

当其他写入者在版本检查之后、变更操作之前修改文件时,`CheckObservedRevision` 会在 `WriteText` 或 `file_edit` 的读取并重写序列之前完成,因此变更操作不会验证它实际修改的版本。并发写入也可能基于同一个摘要同时通过检查,后执行的写入会覆盖先执行的更改,而两者都报告成功。

请将版本检查与对应的写入或编辑设为原子操作,以便在文件于验证后发生更改时使变更失败。

另见 `src/application/file_tool_registry.cpp:772`、`src/application/file_tool_registry.cpp:855-857`。
</issue_to_address>

### 评论 2
<location path="src/application/file_tool_registry.cpp" line_range="769" />
<code_context>
+  // Read before write: an existing file may only be overwritten after a read
+  // that still matches what is on disk. A missing file is a creation, which
+  // cannot discard anything.
+  if (info.has_value()) {
+    const auto guard =
+        co_await CheckObservedRevision(context, target->absolute);
</code_context>
<issue_to_address>
**现有文件绕过了读取保护**

当对一个现有目标执行 `Stat` 失败,且错误不是 `not_found`,但后续写入成功时,`info.has_value()` 为 false,因此 `ExecuteFileWrite` 会将目标视为新文件并跳过 `CheckObservedRevision`。随后 `WriteText` 可能在没有记录读取的情况下覆盖现有文件。

请区分 `not_found` 与其他 `Stat` 错误;发生错误时应失败,而不是将目标当作不存在。
</issue_to_address>

### 评论 3
<location path="src/application/file_tool_registry.cpp" line_range="640" />
<code_context>
     }
+    // The read succeeded, so the model has seen this revision: record it before
+    // returning, which is what authorizes a later write of the same file.
+    co_await RememberObserved(context, target->absolute);
     co_return ToolSuccess(TruncateContent(AddLineNumbers(*content, 1)));
   }
</code_context>
<issue_to_address>
**未被看见的文件内容可能被覆盖**

当范围读取只返回文件的一部分,或整文件结果被截断,而模型随后替换或编辑文件时,`RememberObserved` 会记录整个文件的摘要,尽管模型只收到一个窗口或截断后的输出。`file_write` 或 `file_edit` 会接受该摘要作为模型已看到完整版本的证明,从而可能丢失未被看见的内容。

不要根据部分读取或截断读取授权整文件变更;只有在模型收到完整文件时才记录观察结果。

另见 `src/application/file_tool_registry.cpp:715-716`。
</issue_to_address>

### 评论 4
<location path="src/application/file_tool_registry.cpp" line_range="640" />
<code_context>
     }
+    // The read succeeded, so the model has seen this revision: record it before
+    // returning, which is what authorizes a later write of the same file.
+    co_await RememberObserved(context, target->absolute);
     co_return ToolSuccess(TruncateContent(AddLineNumbers(*content, 1)));
   }
</code_context>
<issue_to_address>
**未被看见的更改会被授权**

当文件在读取内容之后、`RememberObserved` 完成哈希之前发生更改时,`RememberObserved` 会独立于内容读取再次对路径进行哈希,因此可能记录模型并未收到的新版本。后续变更会通过保护检查,并可能覆盖这些未被看见的内容。

请将记录的摘要绑定到读取返回的确切字节,而不是之后再对路径进行哈希。

另见 `src/application/file_tool_registry.cpp:716`、`src/application/file_tool_registry.cpp:801`、`src/application/file_tool_registry.cpp:882`。
</issue_to_address>

### 评论 5
<location path="src/application/file_tool_registry.cpp" line_range="640" />
<code_context>
     }
+    // The read succeeded, so the model has seen this revision: record it before
+    // returning, which is what authorizes a later write of the same file.
+    co_await RememberObserved(context, target->absolute);
     co_return ToolSuccess(TruncateContent(AddLineNumbers(*content, 1)));
   }
</code_context>
<issue_to_address>
**并发编辑绕过版本保护**

当其他写入者在读取内容与 `RememberObserved` 之间、保护检查的哈希与变更之间,或写入与写入后的哈希之间修改文件时,`RememberObserved` 可能授权模型未收到的字节,而 `CheckObservedRevision` 可能验证一个在写入前已经改变的版本。工具随后会在未检测到更改的情况下覆盖未被看见的内容。

请将观察结果绑定到读取返回的确切字节,并将每个受保护的变更与其版本检查设为原子操作。

另见 `src/application/file_tool_registry.cpp:715`、`src/application/file_tool_registry.cpp:771-773`、`src/application/file_tool_registry.cpp:801`、`src/application/file_tool_registry.cpp:855-857`、`src/application/file_tool_registry.cpp:882`。
</issue_to_address>

### 评论 6
<location path="src/application/file_tool_registry.cpp" line_range="467" />
<code_context>
+  Sha256Digest digest;
+  std::uint64_t offset = 0;
+  while (true) {
+    auto chunk = co_await files.ReadBytes(path, offset, kHashChunkBytes);
+    if (!chunk)
+      co_return std::unexpected(chunk.error());
</code_context>
<issue_to_address>
**大文件哈希会重复读取前缀**

当文件需要通过 `HuxToolFileAccess` 分多个哈希块处理时,`HashFile` 会以递增的偏移量调用 `ReadBytes`,而 `HuxToolFileAccess::ReadBytes` 每次调用都会打开新的流并消耗被跳过的前缀。哈希过程会反复重新读取之前的字节,使大文件读取和受保护的变更极其缓慢,甚至容易超时。

请通过单个顺序流进行哈希,或以其他方式确保每个字节只读取一次。
</issue_to_address>

### 评论 7
<location path="tests/file_tool_tests.cpp" line_range="1336-1342" />
<code_context>
+  invoked = co_await scenario->tools->Invoke("file_read",
+                                             R"({"file_path":"edit.txt"})");
+  EXPECT_EXPRESSION(invoked && !invoked->error);
   invoked = co_await scenario->tools->Invoke(
       "file_edit",
       R"({"file_path":"edit.txt","old_string":"world","new_string":"there"})");
</code_context>
<issue_to_address>
**短范围读取未被隔离**

当测试在此前成功读取 `large.txt` 的范围之后运行时,即使回归问题导致这次短范围读取不再被记录,测试仍会通过:此前第 836–839 行对 `large.txt` 的 `file_read` 使用同一个注册表,可能为后续的 `file_write` 留下有效观察结果。第 1341–1342 行的断言随后会使用此前的记录通过,而没有验证本次范围读取是否授权了写入。

请在短范围读取用例之前使用新的路径,或清空观察存储,以确保只有这次读取能够授权写入。
</issue_to_address>

Sourcery 评估

需要人工审查。 请先处理 7 个发现;新的基于哈希的保护机制控制现有文件是否可以被覆盖或编辑。如果它错误地接受了过期的观察结果,未被看见的工作区内容可能遭到破坏,并且即使回滚代码,这种破坏也会持续存在。回滚会移除保护机制,但不会恢复已经被更改的文件内容。

阻塞性发现:src/application/file_tool_registry.cpp:771、src/application/file_tool_registry.cpp:769、src/application/file_tool_registry.cpp:640、src/application/file_tool_registry.cpp:640、src/application/file_tool_registry.cpp:640,以及另外 2 个


Sourcery 对开源项目免费——如果您喜欢我们的审查,请考虑分享 ✨
Original comment in English

Hey - I've found 7 issues

Prompt for AI Agents
Please address the comments from this code review:

## Individual Comments

### Comment 1
<location path="src/application/file_tool_registry.cpp" line_range="771" />
<code_context>
+  // cannot discard anything.
+  if (info.has_value()) {
+    const auto guard =
+        co_await CheckObservedRevision(context, target->absolute);
+    if (guard != MutationGuard::allowed)
+      co_return MutationRefused(context, guard, input_path);
</code_context>
<issue_to_address>
**Concurrent changes overwrite validated content**

When another writer changes the file after the revision check and before the mutation, `CheckObservedRevision` finishes before `WriteText` or `file_edit`'s read-and-rewrite sequence, so the mutation does not verify the revision it changes. Concurrent writes can also both pass against the same digest, with the later one overwriting the earlier change while both report success.

Make the revision check and the corresponding write or edit atomic, so a mutation fails if the file changed after validation.

Also at `src/application/file_tool_registry.cpp:772`, `src/application/file_tool_registry.cpp:855-857`.
</issue_to_address>

### Comment 2
<location path="src/application/file_tool_registry.cpp" line_range="769" />
<code_context>
+  // Read before write: an existing file may only be overwritten after a read
+  // that still matches what is on disk. A missing file is a creation, which
+  // cannot discard anything.
+  if (info.has_value()) {
+    const auto guard =
+        co_await CheckObservedRevision(context, target->absolute);
</code_context>
<issue_to_address>
**Existing files bypass the read guard**

When `Stat` fails for an existing target with an error other than `not_found`, and the subsequent write succeeds, `info.has_value()` is false, so `ExecuteFileWrite` treats the target as new and skips `CheckObservedRevision`. `WriteText` can then overwrite an existing file without a recorded read.

Distinguish `not_found` from other `Stat` errors and fail on errors instead of treating them as a missing target.
</issue_to_address>

### Comment 3
<location path="src/application/file_tool_registry.cpp" line_range="640" />
<code_context>
     }
+    // The read succeeded, so the model has seen this revision: record it before
+    // returning, which is what authorizes a later write of the same file.
+    co_await RememberObserved(context, target->absolute);
     co_return ToolSuccess(TruncateContent(AddLineNumbers(*content, 1)));
   }
</code_context>
<issue_to_address>
**Unseen file content can be overwritten**

When a range read returns only part of a file, or a whole-file result is truncated, and the model then replaces or edits the file, `RememberObserved` records the digest of the whole file even though the model received only a window or truncated output. `file_write` or `file_edit` accepts that digest as proof the model saw the full revision, allowing unseen content to be lost.

Do not authorize a full-file mutation from a partial or truncated read; record an observation only when the model receives the complete file.

Also at `src/application/file_tool_registry.cpp:715-716`.
</issue_to_address>

### Comment 4
<location path="src/application/file_tool_registry.cpp" line_range="640" />
<code_context>
     }
+    // The read succeeded, so the model has seen this revision: record it before
+    // returning, which is what authorizes a later write of the same file.
+    co_await RememberObserved(context, target->absolute);
     co_return ToolSuccess(TruncateContent(AddLineNumbers(*content, 1)));
   }
</code_context>
<issue_to_address>
**Unseen changes become authorized**

When the file changes after its content is read but before `RememberObserved` finishes hashing it, `RememberObserved` hashes the path separately from the content read, so it can record a newer revision the model did not receive. A later mutation passes the guard and can overwrite that unseen content.

Bind the recorded digest to the exact bytes returned by the read rather than hashing the path afterward.

Also at `src/application/file_tool_registry.cpp:716`, `src/application/file_tool_registry.cpp:801`, `src/application/file_tool_registry.cpp:882`.
</issue_to_address>

### Comment 5
<location path="src/application/file_tool_registry.cpp" line_range="640" />
<code_context>
     }
+    // The read succeeded, so the model has seen this revision: record it before
+    // returning, which is what authorizes a later write of the same file.
+    co_await RememberObserved(context, target->absolute);
     co_return ToolSuccess(TruncateContent(AddLineNumbers(*content, 1)));
   }
</code_context>
<issue_to_address>
**Concurrent edits bypass revision protection**

When another writer changes the file between the content read and `RememberObserved`, between the guard hash and the mutation, or between a write and its post-write hash, `RememberObserved` can authorize bytes the model did not receive, and `CheckObservedRevision` can validate a revision that changes before the write. The tool then overwrites unseen content without detecting the change.

Bind observations to the exact bytes returned and make each guarded mutation atomic with its revision check.

Also at `src/application/file_tool_registry.cpp:715`, `src/application/file_tool_registry.cpp:771-773`, `src/application/file_tool_registry.cpp:801`, `src/application/file_tool_registry.cpp:855-857`, `src/application/file_tool_registry.cpp:882`.
</issue_to_address>

### Comment 6
<location path="src/application/file_tool_registry.cpp" line_range="467" />
<code_context>
+  Sha256Digest digest;
+  std::uint64_t offset = 0;
+  while (true) {
+    auto chunk = co_await files.ReadBytes(path, offset, kHashChunkBytes);
+    if (!chunk)
+      co_return std::unexpected(chunk.error());
</code_context>
<issue_to_address>
**Large-file hashing rereads prefixes**

When a file requires multiple hash chunks through `HuxToolFileAccess`, `HashFile` calls `ReadBytes` at increasing offsets, and `HuxToolFileAccess::ReadBytes` opens a new stream and consumes the skipped prefix on each call. Hashing rereads earlier bytes repeatedly, making large-file reads and guarded mutations extremely slow or prone to timing out.

Hash through one sequential stream, or otherwise read each byte only once.
</issue_to_address>

### Comment 7
<location path="tests/file_tool_tests.cpp" line_range="1336-1342" />
<code_context>
+  invoked = co_await scenario->tools->Invoke("file_read",
+                                             R"({"file_path":"edit.txt"})");
+  EXPECT_EXPRESSION(invoked && !invoked->error);
   invoked = co_await scenario->tools->Invoke(
       "file_edit",
       R"({"file_path":"edit.txt","old_string":"world","new_string":"there"})");
</code_context>
<issue_to_address>
**Short range read is not isolated**

When the test runs after the earlier successful range read of `large.txt`, a regression that stops recording this short range read still passes: the earlier `file_read` of `large.txt` at lines 836–839 uses the same registry and can leave a valid observation for the later `file_write`. The assertions at lines 1341–1342 then pass using that earlier record, without verifying that this range read authorized the write.

Use a fresh path or clear the observation store before the short range-read case so only that read can authorize the write.
</issue_to_address>

Sourcery assessment

Needs a human reviewer. 7 findings to address first, and the new hash-based guard controls whether existing files may be overwritten or edited; if it incorrectly accepts a stale observation, unseen workspace content can be corrupted and that damage persists after reverting the code. Reverting removes the guard, but it does not restore any file contents already changed.

Blocking findings: src/application/file_tool_registry.cpp:771, src/application/file_tool_registry.cpp:769, src/application/file_tool_registry.cpp:640, src/application/file_tool_registry.cpp:640, src/application/file_tool_registry.cpp:640, and 2 more


Sourcery is free for open source - if you like our reviews please consider sharing them ✨

Comment thread src/application/file_tool_registry.cpp Outdated
// cannot discard anything.
if (info.has_value()) {
const auto guard =
co_await CheckObservedRevision(context, target->absolute);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 高 · 并发更改会覆盖已验证的内容

当其他写入者在版本检查之后、变更操作之前修改文件时,CheckObservedRevision 会在 WriteText 或 file_edit 的读取并重写序列之前完成,因此变更操作不会验证它实际修改的版本。并发写入也可能基于同一个摘要同时通过检查,后执行的写入会覆盖先执行的更改,而两者都报告成功。

请将版本检查与对应的写入或编辑设为原子操作,以便在文件于验证后发生更改时使变更失败。

另见 src/application/file_tool_registry.cpp:772、src/application/file_tool_registry.cpp:855-857。

面向 AI Agent 的提示
在 `src/application/file_tool_registry.cpp` 第 771 行:

**并发更改会覆盖已验证的内容**

当其他写入者在版本检查之后、变更操作之前修改文件时,`CheckObservedRevision` 会在 `WriteText` 或 `file_edit` 的读取并重写序列之前完成,因此变更操作不会验证它实际修改的版本。并发写入也可能基于同一个摘要同时通过检查,后执行的写入会覆盖先执行的更改,而两者都报告成功。

请将版本检查与对应的写入或编辑设为原子操作,以便在文件于验证后发生更改时使变更失败。

另见 `src/application/file_tool_registry.cpp:772`、`src/application/file_tool_registry.cpp:855-857`。
Original comment in English

🟠 High · Concurrent changes overwrite validated content

When another writer changes the file after the revision check and before the mutation, CheckObservedRevision finishes before WriteText or file_edit's read-and-rewrite sequence, so the mutation does not verify the revision it changes. Concurrent writes can also both pass against the same digest, with the later one overwriting the earlier change while both report success.

Make the revision check and the corresponding write or edit atomic, so a mutation fails if the file changed after validation.

Also at src/application/file_tool_registry.cpp:772, src/application/file_tool_registry.cpp:855-857.

Prompt for AI agents
In `src/application/file_tool_registry.cpp` at line 771:

**Concurrent changes overwrite validated content**

When another writer changes the file after the revision check and before the mutation, `CheckObservedRevision` finishes before `WriteText` or `file_edit`'s read-and-rewrite sequence, so the mutation does not verify the revision it changes. Concurrent writes can also both pass against the same digest, with the later one overwriting the earlier change while both report success.

Make the revision check and the corresponding write or edit atomic, so a mutation fails if the file changed after validation.

Also at `src/application/file_tool_registry.cpp:772`, `src/application/file_tool_registry.cpp:855-857`.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

已修复(提交 9d13fa2)。

  • 进程内:新增按路径的变更占用标记(FileObservationStore::Claim/Release + RAII 的 MutationClaim),覆盖「读取 → 校验 → 写入 → 记录」整段。并发中的第二个工具调用会得到 Cannot modify …: another tool call is already changing this file. Try again.,而不是两次都通过校验、后者覆盖前者却都报成功。
  • 校验与变更用同一份字节:CheckObservedRevision 现在比较的是本次变更自己已经读到的那份内容(也是 file_edit 用来替换、file_write 用来生成 diff 的同一份),校验与写入之间只剩 WriteText 这一个 co_await。
  • 残留窗口:ToolFileAccess 端口没有 compare-and-swap,进程之外的写入者在读取与写入之间改动文件无法被拦截。这一点已写在 MutationClaim 的注释和 update.md 里;要彻底消除需要给端口增加原子替换原语(replaceIfVersion 之类),本次没有改动端口契约。

Comment thread src/application/file_tool_registry.cpp Outdated
Comment thread src/application/file_tool_registry.cpp Outdated
Comment thread src/application/file_tool_registry.cpp Outdated
Comment thread src/application/file_tool_registry.cpp Outdated
Comment thread src/application/file_tool_registry.cpp Outdated
Comment thread tests/file_tool_tests.cpp Outdated
Address the review of the read-before-write guard. The shape of the
protection stays the same; what changes is what authorizes a mutation and
what happens while one is in flight.

- Record the digest of the exact text the read returned instead of hashing
  the path again afterwards, so a record can never describe a revision that
  appeared between the read and the record. The guard likewise compares
  against the bytes the mutation already read for its own work, which
  removes a second look at the file and a window between the check and the
  write.
- Track whether the model received the whole revision. file_write replaces
  the target outright, so it now requires a complete view: a windowed
  `start_kb`/`end_kb` read, or a whole-file read whose rendered output was
  truncated, records a partial observation, and a replacement is refused in
  favour of file_edit. file_edit keeps accepting a partial view because it
  carries over every byte it does not replace -- without that, files above
  the 50KB single-read limit could never be edited at all.
- Hold a per-path claim across read, check, write and record. Two tool calls
  that both passed the freshness check used to both write, and the later one
  silently dropped the earlier one's change while both reported success; the
  second call is now refused with a retry hint. A writer outside the process
  cannot be excluded, because the file capability offers no compare-and-swap;
  that remaining window is documented at the claim and in update.md.
- Treat a Stat failure that is not `not_found` as an error instead of a
  missing target, so a permission or I/O failure can no longer turn an
  existing file into a "creation" that skips the guard.

The streaming hash helper is gone with the second look at the path, which
also removes its repeated prefix reads.

Tests: the range-read case now clears the record and reads a fresh path, so
only that read can authorize what follows. New coverage for partial versus
complete views, the truncated view, the exact digest recorded, a held claim
and its release, and Stat failures on both write and edit.

Verification: ctest 122/122, :app:lintDebug 0 errors,
tools/gen_tool_text_catalog.py --check (80 keys), :app:assembleDebug, and
the fake AI server tests 29/29.
@LangLang03
LangLang03 merged commit a0ede86 into master Oct 12, 2026
5 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant