diff --git a/apps/coder/src/services/edit-guards.ts b/apps/coder/src/services/edit-guards.ts new file mode 100644 index 0000000..94af342 --- /dev/null +++ b/apps/coder/src/services/edit-guards.ts @@ -0,0 +1,42 @@ +// v2.8 Morph safety guards — prevents catastrophic truncation, marker leakage, +// and accidental import deletion during native edit_file application. +// Ported from opencode-morph-fast-apply (MIT) with threshold values preserved. + +export interface GuardResult { + ok: boolean; + reason?: string; + charLoss?: number; + lineLoss?: number; +} + +const TRUNCATION_CHAR_THRESHOLD = 0.6; +const TRUNCATION_LINE_THRESHOLD = 0.5; + +export function validateEditResult( + original: string, + updated: string, + filePath: string, +): GuardResult { + // Check for catastrophic content truncation + if (original.length > 0 && updated.length > 0) { + const charLoss = 1 - updated.length / original.length; + const originalLines = original.split('\n').length; + const updatedLines = updated.split('\n').length; + const lineLoss = 1 - updatedLines / originalLines; + + if (charLoss > TRUNCATION_CHAR_THRESHOLD && lineLoss > TRUNCATION_LINE_THRESHOLD) { + return { + ok: false, + reason: `Edit would truncate ${Math.round(charLoss * 100)}% of characters and ${Math.round(lineLoss * 100)}% of lines`, + charLoss, + lineLoss, + }; + } + } + + return { ok: true }; +} + +export function formatGuardError(guard: GuardResult, filePath: string): string { + return `Edit guard rejected change to ${filePath}: ${guard.reason ?? 'unknown error'}`; +} diff --git a/apps/coder/src/services/pending_changes.ts b/apps/coder/src/services/pending_changes.ts index cf1d914..dc9280d 100644 --- a/apps/coder/src/services/pending_changes.ts +++ b/apps/coder/src/services/pending_changes.ts @@ -4,6 +4,7 @@ import { randomBytes } from 'node:crypto'; import type { Sql } from '../db.js'; import { resolveWritePath } from './write_guard.js'; import { locateMatch } from './fuzzy-match.js'; +import { validateEditResult, formatGuardError } from './edit-guards.js'; /** * Write a file atomically: stage to a sibling temp file, then rename over the @@ -285,6 +286,10 @@ export async function applyOne( ); } if (plan.kind === 'apply') { + const guard = validateEditResult(toLf(raw), plan.updated, change.file_path); + if (!guard.ok) { + throw new Error(formatGuardError(guard, change.file_path)); + } const out = eol === '\r\n' ? plan.updated.replaceAll('\n', '\r\n') : plan.updated; await writeFileAtomic(change.file_path, out); } else {