fix(opencode): prevent destructive edit matches (#30932)

This commit is contained in:
Shoubhit Dash 2026-06-05 20:08:05 +05:30 committed by GitHub
parent a8adfb6305
commit 236cfcbbc3
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 89 additions and 17 deletions

View File

@ -89,10 +89,14 @@ export const EditTool = Tool.define(
Effect.gen(function* () {
if (params.oldString === "") {
const existed = yield* afs.existsSafe(filePath)
const source = existed ? yield* Bom.readFile(afs, filePath) : { bom: false, text: "" }
if (existed) {
throw new Error(
"oldString cannot be empty when editing an existing file. Provide the exact text to replace, or use write for an intentional full-file replacement.",
)
}
const next = Bom.split(params.newString)
const desiredBom = source.bom || next.bom
contentOld = source.text
const desiredBom = next.bom
contentOld = ""
contentNew = next.text
diff = trimDiff(createTwoFilesPatch(filePath, filePath, contentOld, contentNew))
yield* ctx.ask({
@ -111,7 +115,7 @@ export const EditTool = Tool.define(
yield* events.publish(FileSystem.Event.Edited, { file: filePath })
yield* events.publish(Watcher.Event.Updated, {
file: filePath,
event: existed ? "change" : "add",
event: "add",
})
return
}
@ -213,8 +217,8 @@ export const EditTool = Tool.define(
export type Replacer = (content: string, find: string) => Generator<string, void, unknown>
// Similarity thresholds for block anchor fallback matching
const SINGLE_CANDIDATE_SIMILARITY_THRESHOLD = 0.0
const MULTIPLE_CANDIDATES_SIMILARITY_THRESHOLD = 0.3
const SINGLE_CANDIDATE_SIMILARITY_THRESHOLD = 0.65
const MULTIPLE_CANDIDATES_SIMILARITY_THRESHOLD = 0.65
/**
* Levenshtein distance algorithm implementation
@ -296,6 +300,7 @@ export const BlockAnchorReplacer: Replacer = function* (content, find) {
const firstLineSearch = searchLines[0].trim()
const lastLineSearch = searchLines[searchLines.length - 1].trim()
const searchBlockSize = searchLines.length
const maxLineDelta = Math.max(1, Math.floor(searchBlockSize * 0.25))
// Collect all candidate positions where both anchors match
const candidates: Array<{ startLine: number; endLine: number }> = []
@ -307,7 +312,10 @@ export const BlockAnchorReplacer: Replacer = function* (content, find) {
// Look for the matching last line after this first line
for (let j = i + 2; j < originalLines.length; j++) {
if (originalLines[j].trim() === lastLineSearch) {
candidates.push({ startLine: i, endLine: j })
const actualBlockSize = j - i + 1
if (Math.abs(actualBlockSize - searchBlockSize) <= maxLineDelta) {
candidates.push({ startLine: i, endLine: j })
}
break // Only match the first occurrence of the last line
}
}
@ -324,7 +332,7 @@ export const BlockAnchorReplacer: Replacer = function* (content, find) {
const actualBlockSize = endLine - startLine + 1
let similarity = 0
let linesToCheck = Math.min(searchBlockSize - 2, actualBlockSize - 2) // Middle lines only
const linesToCheck = Math.min(searchBlockSize - 2, actualBlockSize - 2) // Middle lines only
if (linesToCheck > 0) {
for (let j = 1; j < searchBlockSize - 1 && j < actualBlockSize - 1; j++) {
@ -373,7 +381,7 @@ export const BlockAnchorReplacer: Replacer = function* (content, find) {
const actualBlockSize = endLine - startLine + 1
let similarity = 0
let linesToCheck = Math.min(searchBlockSize - 2, actualBlockSize - 2) // Middle lines only
const linesToCheck = Math.min(searchBlockSize - 2, actualBlockSize - 2) // Middle lines only
if (linesToCheck > 0) {
for (let j = 1; j < searchBlockSize - 1 && j < actualBlockSize - 1; j++) {
@ -675,6 +683,11 @@ export function replace(content: string, oldString: string, newString: string, r
if (oldString === newString) {
throw new Error("No changes to apply: oldString and newString are identical.")
}
if (oldString === "") {
throw new Error(
"oldString cannot be empty when editing an existing file. Provide the exact text to replace, or use write for an intentional full-file replacement.",
)
}
let notFound = true
@ -693,6 +706,11 @@ export function replace(content: string, oldString: string, newString: string, r
const index = content.indexOf(search)
if (index === -1) continue
notFound = false
if (isDisproportionateMatch(search, oldString)) {
throw new Error(
"Refusing replacement because the matched span is much larger than oldString. Re-read the file and provide the full exact oldString for the intended replacement.",
)
}
if (replaceAll) {
return content.replaceAll(search, newString)
}
@ -709,3 +727,11 @@ export function replace(content: string, oldString: string, newString: string, r
}
throw new Error("Found multiple matches for oldString. Provide more surrounding context to make the match unique.")
}
function isDisproportionateMatch(search: string, oldString: string) {
const oldLines = oldString.split("\n").length
const searchLines = search.split("\n").length
if (searchLines >= Math.max(oldLines + 3, oldLines * 2)) return true
if (oldLines === 1) return false
return search.trim().length > Math.max(oldString.trim().length + 500, oldString.trim().length * 4)
}

View File

@ -106,21 +106,20 @@ describe("tool.edit", () => {
}),
)
it.instance("preserves BOM when oldString is empty on existing files", () =>
it.instance("rejects empty oldString on existing files and leaves content unchanged", () =>
Effect.gen(function* () {
const test = yield* TestInstance
const filepath = path.join(test.directory, "existing.cs")
const bom = String.fromCharCode(0xfeff)
yield* put(filepath, `${bom}using System;\n`)
const original = `${bom}using System;\n`
yield* put(filepath, original)
const result = yield* run({ filePath: filepath, oldString: "", newString: "using Up;\n" })
expect(result.metadata.diff).toContain("-using System;")
expect(result.metadata.diff).toContain("+using Up;")
expect((yield* fail({ filePath: filepath, oldString: "", newString: "using Up;\n" })).message).toContain(
"oldString cannot be empty",
)
const content = yield* loadRaw(filepath)
expect(content.charCodeAt(0)).toBe(0xfeff)
expect(content.slice(1)).toBe("using Up;\n")
expect(content).toBe(original)
}),
)
@ -213,6 +212,53 @@ describe("tool.edit", () => {
}),
)
it.instance("rejects loose block-anchor matches and leaves content unchanged", () =>
Effect.gen(function* () {
const test = yield* TestInstance
const filepath = path.join(test.directory, "file.ts")
const original = [
"function configure() {",
" keepImportantState()",
" removeAllUserData()",
" archiveBackups()",
" auditLog()",
"}",
].join("\n")
yield* put(filepath, original)
expect(
(
yield* fail({
filePath: filepath,
oldString: ["function configure() {", " const enabled = true", "}"].join("\n"),
newString: ["function configure() {", " const enabled = false", "}"].join("\n"),
})
).message,
).toContain("Could not find oldString")
expect(yield* load(filepath)).toBe(original)
}),
)
it.instance("rejects block-anchor matches with unrelated middle content", () =>
Effect.gen(function* () {
const test = yield* TestInstance
const filepath = path.join(test.directory, "file.ts")
const original = ["function configure() {", " removeAllUserData()", "}"].join("\n")
yield* put(filepath, original)
expect(
(
yield* fail({
filePath: filepath,
oldString: ["function configure() {", " const enabled = true", "}"].join("\n"),
newString: ["function configure() {", " const enabled = false", "}"].join("\n"),
})
).message,
).toContain("Could not find oldString")
expect(yield* load(filepath)).toBe(original)
}),
)
it.instance("replaces all occurrences with replaceAll option", () =>
Effect.gen(function* () {
const test = yield* TestInstance