Skip to content

fix(core): validate line ranges in write_text_file to prevent file corruption - #3208

Open
wh-whnb wants to merge 2 commits into
agentscope-ai:mainfrom
wh-whnb:fix/write-file-range-validation
Open

wh-whnb wants to merge 2 commits into
agentscope-ai:mainfrom
wh-whnb:fix/write-file-range-validation

Conversation

@wh-whnb

@wh-whnb wh-whnb commented Sep 19, 2026

Copy link
Copy Markdown

AgentScope-Java Version

2.0.4-SNAPSHOT

Description

Background

The write_text_file tool accepted invalid ranges values. After parsing [start, end] it only checked start <= fileLength; it did not check start >= 1 or start <= end, and it did not handle negative indexes (even though the sibling view_text_file tool documents and supports -100,-1 style ranges).

Because the ranges argument is generated by the model during autonomous file editing, malformed values occur in practice:

  • Reversed range [5,2] on a 5-line file silently duplicated lines (result: one two three four NEW three four five) and still reported success.
  • Zero-based range [0,2] silently deleted the first lines.
  • Negative range [-3,-1] threw an IndexOutOfBoundsException from subList(-1, 5) with an unhelpful message.

This is silent data corruption in a destructive tool, which makes the agent keep working on a damaged file.

Changes

  • Validate start >= 1 and start <= end before any file mutation; return a clear error result and leave the file untouched otherwise, matching the validation already performed by view_text_file.
  • Add WriteFileToolTest covering valid replacement, reversed range, zero-based start, negative range, start beyond file length, and new-file creation. The class previously had no unit tests.

How to test

mvn -pl agentscope-core test -Dtest='io.agentscope.core.tool.file.*Test' — 11 tests pass (6 new + 5 existing).

Checklist

  • Code has been formatted with mvn spotless:apply
  • All tests are passing (mvn test)
  • Javadoc comments are complete and follow project conventions
  • Related documentation has been updated (e.g. links, examples, etc.)
  • Code is ready for review

@CLAassistant

CLAassistant commented Sep 19, 2026

Copy link
Copy Markdown

CLA assistant check
All committers have signed the CLA.

@codecov

codecov Bot commented Sep 19, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

@oss-maintainer

Copy link
Copy Markdown
Collaborator

CLA Not Signed

The Contributor License Agreement (CLA) check is currently pending on this PR (license/cla: Contributor License Agreement is not signed yet.). This PR cannot be merged until the CLA is signed.

@wh-whnb please sign the CLA via the CLA assistant badge in the comment above, or visit https://cla-assistant.io/agentscope-ai/agentscope-java. Once signed, the license/cla status will turn green.


Automated check by github-manager-bot

@oss-maintainer oss-maintainer left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Summary

Verified the three failure modes described in the issue against the code: before this change write_text_file only checked start > originalLines.size(), so "5,2" took the end < size tail branch and duplicated lines, and "[-3,-1]" reached subList(-1, …) and threw IndexOutOfBoundsException. Both new guards sit before the first mutation (Files.writeString), so the "file untouched on rejection" claim in the new tests is real, and the tests assert it rather than just asserting the return value — that is the right shape for a destructive tool.

Commenting rather than approving: the license/cla status is still pending on 53681262 (and build (windows-latest) is still running), so this cannot be approved from my side until the CLA check goes green. The two [Warning] items below are independent of that.

Findings

  • [Warning] WriteFileTool.java:331 — the 1-based / no-negative rule is not stated in the ranges parameter description, while the sibling view_text_file documents and supports negative indices (ReadFileTool.java:96, normalisation at :166-176) — the model is being told two different grammars for the same ranges argument
  • [Warning] WriteFileToolTest.java:52 — the assertion pins a second silent mutation: String.join("\n", …) (WriteFileTool.java:379) drops the file's trailing newline on any range replace
  • [Info] WriteFileTool.java:341-352start beyond EOF errors but end beyond EOF is silently clamped; worth one test to record the intent
  • [Info] WriteFileToolTest.java:87-95assertNotEquals(ERROR, …) and the Files.exists(...) ? … : null ternary are weaker than they need to be

Suggestions

The schema fix is one string. In writeTextFile's @ToolParam(description = …):

"The range of lines to be replaced as [start, end], e.g., '[1,5]' or '1,5'. "
    + "Lines are 1-based and inclusive; negative indices are NOT supported for writes "
    + "(view_text_file accepts them, this tool does not). "
    + "If null or empty, the entire file will be overwritten."

If you would rather the two tools agree than diverge deliberately, lift ReadFileTool's negative-index block into FileToolUtils next to parseRanges and call it from both — write_text_file can then treat [-3,-1] as "the last three lines" with the same guards, and start < 1 only needs to reject the genuinely unusable cases.


Automated review by github-manager-bot

logger.debug(
"Replacing lines {}-{} in file: {}", start, end, filePath);

if (start < 1) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Warning] The guard itself is correct — but nothing the model can see says so. The ranges @ToolParam description of write_text_file still reads only "The range of lines to be replaced as [start, end], e.g., '[1,5]' or '5'. If null or empty, the entire file will be overwritten.": no 1-based statement, no "negatives rejected". Meanwhile the sibling read tool actively advertises the opposite — ReadFileTool.java:96 ("Supports negative indices to view from the end") — and normalises them at ReadFileTool.java:166-176 (start = totalLines + start + 1). An agent that legally read the tail of a file with "[-3,-1]" and now rewrites that same range is following the documented schema, so it will keep hitting this error.

Cheapest fix: state the constraint where the model reads it, e.g. "1-based inclusive line range; negative indices are not supported for writes". More consistent fix: move ReadFileTool's normalisation into FileToolUtils (which already owns parseRanges) and share it, so [-3,-1] means the same thing in both tools and a write that only replaces the last 3 lines is possible at all. Either way, please don't leave the two tools documenting different range grammars.

ToolResultBlock result = tool.writeTextFile(file.toString(), "NEW", "2,3").block();

assertEquals(ToolResultState.RUNNING, result.getState());
assertEquals("one\nNEW\nfour\nfive", readFile());

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Warning] This assertion pins a different silent-mutation bug than the one the PR fixes. ORIGINAL_CONTENT ends with \n, and the rewrite path builds the file with String.join("\n", newContent) (WriteFileTool.java:379), which never appends a terminator — so any range replace drops the file's final newline, and "one\nNEW\nfour\nfive" (no trailing \n) is now the blessed expected value.

For a line-range editor that is the same class of harm as #3206-style corruption: the line count the agent saw on its previous view_text_file no longer matches, insert_text_file line numbering shifts, and git diff reports the last line as modified. Suggest preserving whatever terminator the original had:

String joinedContent = String.join("\n", newContent);
if (Files.readString(path, StandardCharsets.UTF_8).endsWith("\n")) {
    joinedContent += "\n";
}

If dropping it really is intended, then rename the test (replacesSpecifiedRange_dropsTrailingNewline) so the next contributor fixes it deliberately instead of tripping over the assertion.

Comment on lines +341 to +352
if (start > end) {
logger.warn(
"Invalid range: start {} > end {} for file: {}",
start,
end,
filePath);
return ToolResultBlock.error(
String.format(
"Invalid range: start line %d is greater than"
+ " end line %d.",
start, end));
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Info] Ordering is right (cheap, most-likely model error first, before the existing start > originalLines.size() check). One asymmetry worth pinning down: start beyond EOF errors, but end beyond EOF is silently clamped by the tail condition if (end < originalLines.size()) further below, so "3,999" on a 5-line file succeeds and replaces 3..EOF. I suspect that's what you want — it just isn't covered, and the two bounds behaving differently is exactly the kind of thing that gets "fixed" into a regression later. One test asserting the clamp would settle it.

Comment on lines +87 to +95
@Test
void createsNewFileWhenFileDoesNotExist() {
Path newFile = tempDir.resolve("new.txt");

ToolResultBlock result =
tool.writeTextFile(newFile.toString(), "fresh content", null).block();

assertNotEquals(ToolResultState.ERROR, result.getState());
assertEquals("fresh content", Files.exists(newFile) ? readFile(newFile) : null);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Info] Two small test-strength points: (1) assertNotEquals(ToolResultState.ERROR, result.getState()) is weaker than the positive assertEquals(ToolResultState.RUNNING, ...) used in the first test — worth matching it so an unexpected future state is caught; (2) the Files.exists(newFile) ? readFile(newFile) : null ternary duplicates what assertEquals already proves, and if the file is missing the failure message reads like a content mismatch rather than "file was not created" — assertTrue(Files.exists(newFile)) first, then assertEquals("fresh content", readFile(newFile)) says it more clearly.

…rruption

The write_text_file tool only checked that the start line did not exceed
the file length. Ranges such as [5,2] (reversed), [0,2] (zero-based) or
[-3,-1] (negative) passed validation and reached the content assembly
logic, which silently duplicated or deleted lines and still reported
success; negative indexes could surface an IndexOutOfBoundsException.

Add start >= 1 and start <= end checks before mutating the file,
matching the validation already performed by the view_text_file tool,
and add WriteFileToolTest covering invalid ranges and the valid path.
@wh-whnb
wh-whnb force-pushed the fix/write-file-range-validation branch from 5368126 to 4c102dd Compare September 19, 2026 14:27
wh-whnb pushed a commit to wh-whnb/agentscope-java that referenced this pull request Sep 19, 2026
Address review feedback on agentscope-ai#3208:

- State in the ranges parameter description that line numbers are
  1-based and inclusive and that negative indices are not supported
  for writes, so write_text_file no longer advertises a different
  range grammar than view_text_file.
- Preserve the original file's trailing line terminator on range
  replacement; String.join("\n", ...) previously dropped it, which
  shifted line counts on subsequent reads/edits.
- Add tests for end-beyond-EOF clamping and for a file without a
  trailing newline, and strengthen the new-file test assertions.
@wh-whnb

wh-whnb commented Sep 19, 2026

Copy link
Copy Markdown
Author

Thanks for the thorough review, all four points are addressed in the latest push:

[Warning] ranges grammar (WriteFileTool.java) — took the documentation option: the ranges parameter description now states lines are 1-based and inclusive and that negative indices are not supported for writes (explicitly noting view_text_file accepts them). I deliberately did not lift the negative-index normalisation into FileToolUtils in this PR, since making a destructive tool accept negative indexes is a behavior/feature addition rather than a corruption fix; happy to open a follow-up PR for shared negative-index support if you'd prefer the two tools converge.

[Warning] trailing newline dropped — good catch, fixed. The range-replace path now restores the original file's terminator after String.join("\n", ...). Added tests in both directions: replacesSpecifiedRangeAndPreservesTrailingNewline (original ends with \n, it is kept) and doesNotAddTrailingNewlineWhenOriginalHadNone (original without \n does not gain one), so the rule is "preserve whatever terminator the file had".

[Info] end beyond EOF — confirmed the clamp to EOF is intended and pinned it with clampsEndBeyondFileLengthToEndOfFile (3,999 on a 5-line file replaces lines 3..EOF and succeeds), distinguishing it from start-beyond-EOF which errors.

[Info] weak new-file assertions — strengthened to assertEquals(ToolResultState.RUNNING, ...), assertTrue(Files.exists(newFile)), then the content assertion.

mvn -pl agentscope-core test -Dtest='io.agentscope.core.tool.file.*Test': 13 tests pass (8 WriteFileToolTest + 5 ReadFileToolTest), spotless clean.

On the CLA note: that comment was against the old commit 53681262; the commit author is now linked to this GitHub account (verified email) and license/cla is green on the current head.

Address review feedback on agentscope-ai#3208:

- State in the ranges parameter description that line numbers are
  1-based and inclusive and that negative indices are not supported
  for writes, so write_text_file no longer advertises a different
  range grammar than view_text_file.
- Preserve the original file's trailing line terminator on range
  replacement; String.join("\n", ...) previously dropped it, which
  shifted line counts on subsequent reads/edits.
- Add tests for end-beyond-EOF clamping and for a file without a
  trailing newline, and strengthen the new-file test assertions.
@wh-whnb
wh-whnb force-pushed the fix/write-file-range-validation branch from 1035152 to 7be1204 Compare September 19, 2026 14:51

@oss-maintainer oss-maintainer left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Summary

Re-reviewed the new head 7be12043. All four items from the previous pass are genuinely addressed: the ranges description now states 1-based/inclusive and that negatives are rejected for writes (and explicitly contrasts with view_text_file), the range-replace path restores the original file's trailing terminator, the 3,999 EOF clamp is pinned by clampsEndBeyondFileLengthToEndOfFile, and the new-file assertions are now assertEquals(RUNNING, …) + assertTrue(Files.exists(…)). The two guards still sit before the first mutation, so the "file untouched on rejection" assertions remain meaningful. CLA is green on this head and Check License / Check Module Sync / build (ubuntu-latest) / build (windows-latest) / codecov/patch all pass.

Approved with two non-blocking follow-up notes below — neither affects the corruption fix this PR is about.

Findings

  • [Warning] WriteFileTool.java:384 — the trailing-newline check re-reads the whole file just to derive one boolean that is already implied by the bytes read at :310
  • [Info] WriteFileTool.java:383 — terminator is preserved but separator style still is not (readAllLines + String.join("\n", …) rewrites CRLF files to LF); pre-existing, worth an explicit decision in a follow-up

Suggestions

For the re-read, deriving both values from a single snapshot is cheaper and keeps the check consistent with the line list:

String originalText = Files.readString(path, StandardCharsets.UTF_8);
boolean endsWithNewline = originalText.endsWith("\n");
List<String> originalLines = originalText.lines().toList();

Happy to see this merged as-is; the follow-up is a perf/normalisation nicety, not a blocker.


Automated review by github-manager-bot

// Write the new content, preserving the original file's
// trailing line terminator so line counts stay stable.
String joinedContent = String.join("\n", newContent);
if (Files.readString(path, StandardCharsets.UTF_8).endsWith("\n")) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Warning] Non-blocking, but this re-reads the whole file to learn one boolean. The same bytes were already read into originalLines a few lines above (Files.readAllLines(path, UTF_8) at :310), so a large file is now buffered twice per range-replace, and the readString result is discarded right after. Cheap fix: compute the flag from the text you already have, or read once and derive both, e.g.

String originalText = Files.readString(path, StandardCharsets.UTF_8);
boolean endsWithNewline = originalText.endsWith("\n");
List<String> originalLines = originalText.lines().toList();

That also keeps the terminator check and the line list guaranteed to come from the same snapshot.

String joinedContent = String.join("\n", newContent);
if (Files.readString(path, StandardCharsets.UTF_8).endsWith("\n")) {
joinedContent += "\n";
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Info] Terminator is now preserved, but the separator style still is not: originalLines comes from Files.readAllLines (which strips \r on CRLF files) and the rewrite joins with "\n", so a CRLF file has every line ending rewritten while this new check appends a bare "\n". That is pre-existing behaviour, not a regression from this PR — flagging only so a follow-up can decide explicitly (preserve original separator, or normalise to LF and say so in the tool description). No action needed to land this fix.

@wh-whnb

wh-whnb commented Sep 20, 2026

Copy link
Copy Markdown
Author

Thanks @oss-maintainer for the re-review and the approval! Both follow-up notes make sense, and I agree they belong outside this corruption fix:

  • Re-read for the trailing-newline flag (WriteFileTool.java:384) — agreed. Reading the file once and deriving both values from the same snapshot (Files.readString -> endsWith("\n") + .lines().toList()) avoids buffering a large file twice per range-replace and guarantees the flag and the line list come from the same bytes. I'll do this in a small follow-up PR so this one can land as approved.
  • CRLF -> LF separator normalization — confirmed this is pre-existing (readAllLines strips \r, then join("\n", ...) rewrites the file), not introduced here. I'll make it an explicit decision in the same follow-up: either preserve the original separator or document the normalization-to-LF behavior in the tool description, with tests covering a CRLF input.

No further changes pushed to this PR — leaving the approval intact. Happy for it to merge as-is.

@gmjneko gmjneko left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The range validation LGTM, but the new trailing-newline handling introduces a regression when the replacement reaches EOF and already ends with a newline. Please address the issue described in the inline comment and add regression tests before merging.

// trailing line terminator so line counts stay stable.
String joinedContent = String.join("\n", newContent);
if (Files.readString(path, StandardCharsets.UTF_8).endsWith("\n")) {
joinedContent += "\n";

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Avoid appending a duplicate trailing newline

When the replacement range reaches EOF and content already ends with a newline, this block appends another newline if the original file also had one. For example, replacing line 2 of "one\ntwo\n" with "NEW\n" produces "one\nNEW\n\n", adding an unintended blank line while reporting success. Even replacing the entire file with identical content changes the file.

Please avoid appending a duplicate newline when the replacement already supplies one, and add regression tests for both replacing the last line and replacing the entire file with identical content.

@oss-maintainer oss-maintainer left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Summary

Range validation (start < 1, start > end, clamp beyond EOF) and the new WriteFileToolTest coverage look solid. Re-reviewing to respond to the open inline feedback from @gmjneko: the duplicate-trailing-newline report is valid against the current head, and the same root cause (content added verbatim as a single element) also affects mid-file replacements. Recommending a small strip-at-source fix plus one regression test before this is merge-ready; the re-read at the append site is a minor efficiency nit.


Automated review by github-manager-bot

// trailing line terminator so line counts stay stable.
String joinedContent = String.join("\n", newContent);
if (Files.readString(path, StandardCharsets.UTF_8).endsWith("\n")) {
joinedContent += "\n";

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Warning] Confirms @gmjneko's point, and it is broader than the EOF case: content is added verbatim as one list element, so any trailing \n in it survives the join. File "one\ntwo\nthree\n" + write_text_file("NEW\n", "2,2") produces "one\nNEW\n\nthree\n" — an unintended blank line reported as success. Suggested fix: strip exactly one trailing \n from content before newContent.add(...), letting the EOF-preserving block below be the sole authority on the final terminator, and add a regression test for newline-suffixed content.

// Write the new content, preserving the original file's
// trailing line terminator so line counts stay stable.
String joinedContent = String.join("\n", newContent);
if (Files.readString(path, StandardCharsets.UTF_8).endsWith("\n")) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Info] The trailing-terminator flag requires one full pass over the file; compute it from a Files.readString(path, UTF_8).endsWith("\n") read taken before the edit (right where originalLines is loaded) and reuse the flag here, instead of a second Files.readString on the same path. Also note multi-line content ending in \n reaches endsWith("\n")==true and silently skips the append, so mid-range replacements of a whole trailing region can still drop the file's final newline — the strip-at-source fix above removes this ambiguity.

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.

4 participants