Skip to main content

Safe File Editing for Agents: Conflict-Free Edits

The most dangerous agent tool is file editing. Without safeguards, an agent can silently corrupt code: it reads a file, the file changes, the agent edits based on stale information, and the conflict goes undetected. A robust file edit tool must prevent this. It checks that the text to replace matches the current file before writing, uses atomic transactions to ensure all-or-nothing updates, and logs every change for audit trails.

This article covers how to build file edit tools that agents can use without breaking the codebase.

The Core Problem: Stale Edits​

Here's how silently corrupted edits happen:

Time 0: Agent reads file contents (lines 1-50)
Time 1: Another process modifies the file (adds a line at position 5)
Time 2: Agent tries to edit line 10 (but it's now line 11 due to insertion)
Time 3: Agent replaces the wrong text, breaking code.
Result: Silent data corruption—no error, just broken code.

This is not hypothetical. In large teams, multiple processes (version control, CI/CD, other agents, humans) modify files constantly. An agent must assume the file has changed since it last read it.

Strategy 1: Exact-Match Validation (Safe Default)​

Before writing, require the agent to provide the exact text it intends to replace. The tool verifies the current file contains that text at that location:

def edit_file_safe(filepath: str,
old_text: str,
new_text: str,
description: str = "") -> dict:
"""
Edit a file safely: verify old_text matches before replacing.
Returns: {success: bool, error: str or None, before: str, after: str}
"""

# Verify file exists
if not os.path.exists(filepath):
return {
"success": False,
"error": f"File not found: {filepath}"
}

# Read current file contents
try:
with open(filepath, 'r') as f:
current_contents = f.read()
except Exception as e:
return {
"success": False,
"error": f"Cannot read file: {e}"
}

# Verify old_text is in the file at exactly one location
if old_text not in current_contents:
return {
"success": False,
"error": f"old_text not found in file. File may have changed.",
"file_preview": current_contents[:500]
}

# Check for multiple matches (ambiguous edit)
count = current_contents.count(old_text)
if count > 1:
return {
"success": False,
"error": f"old_text appears {count} times; cannot disambiguate edit.",
"suggestion": "Make old_text more specific (include surrounding context)."
}

# Perform the edit
new_contents = current_contents.replace(old_text, new_text)

# Atomic write: write to temp file first, then move
temp_path = filepath + ".tmp"
try:
with open(temp_path, 'w') as f:
f.write(new_contents)

# Atomic move (atomic on most filesystems)
os.replace(temp_path, filepath)
except Exception as e:
# Clean up temp file if it exists
if os.path.exists(temp_path):
try:
os.remove(temp_path)
except:
pass
return {
"success": False,
"error": f"Write failed: {e}"
}

# Log the change for audit
log_edit({
"filepath": filepath,
"timestamp": datetime.now().isoformat(),
"description": description,
"old_lines": old_text.count('\n') + 1,
"new_lines": new_text.count('\n') + 1,
"old_hash": hashlib.sha256(old_text.encode()).hexdigest(),
"new_hash": hashlib.sha256(new_text.encode()).hexdigest()
})

return {
"success": True,
"error": None,
"before_lines": old_text.count('\n') + 1,
"after_lines": new_text.count('\n') + 1
}

Key safety properties:

  • Exact-match validation: prevents off-by-one edits.
  • Ambiguity detection: fails if the text appears multiple times.
  • Atomic writes: file is never in a partially-written state.
  • Audit logging: every change is recorded.

Agent-facing behavior: If the agent reads a file, the file is modified by another process, and the agent tries to edit, the tool returns an error with a file preview. The agent can then re-read the file and adapt its edit.

Example agent flow:

def agent_add_import(filepath: str, import_stmt: str):
"""Agent task: add an import to a Python file."""

# Step 1: Read file
with open(filepath) as f:
content = f.read()

# Step 2: Identify where imports are
import_section_end = content.find('\n\n') # Imports before first blank line
insertion_point = content[:import_section_end]

# Step 3: Construct exact old_text
old_text = insertion_point
new_text = insertion_point + import_stmt + '\n'

# Step 4: Call edit tool with exact match
result = edit_file_safe(
filepath,
old_text=old_text,
new_text=new_text,
description=f"Add import: {import_stmt}"
)

if not result["success"]:
print(f"Edit failed: {result['error']}")
# Agent can retry by re-reading and recomputing
return False

return True

Strategy 2: Diff-Based Editing​

For larger edits, use unified diff format. The agent supplies a diff (old lines → new lines), and the tool applies it line-by-line with context checking:

import difflib

def edit_file_with_diff(filepath: str, diff_lines: str) -> dict:
"""
Apply a unified diff to a file, checking context to ensure correctness.
"""
try:
with open(filepath, 'r') as f:
original_lines = f.readlines()
except Exception as e:
return {"success": False, "error": f"Cannot read: {e}"}

# Parse unified diff
try:
diff = list(difflib.unified_diff(
original_lines,
parse_diff(diff_lines), # The agent-supplied new content
lineterm=''
))
except Exception as e:
return {"success": False, "error": f"Invalid diff: {e}"}

# Verify context (3 lines before/after each change)
# If context doesn't match, the file changed—reject the edit
matched = difflib.SequenceMatcher(
None,
original_lines,
parse_diff(diff_lines)
)

matching_ratio = matched.ratio()
if matching_ratio < 0.95: # Less than 95% match = file changed
return {
"success": False,
"error": "File changed since agent read it (matching ratio too low)",
"match_ratio": matching_ratio
}

# Apply diff
try:
new_content = ''.join(parse_diff(diff_lines))
with open(filepath + ".tmp", 'w') as f:
f.write(new_content)
os.replace(filepath + ".tmp", filepath)
except Exception as e:
return {"success": False, "error": f"Write failed: {e}"}

return {
"success": True,
"lines_added": len([l for l in diff_lines if l.startswith('+')]),
"lines_removed": len([l for l in diff_lines if l.startswith('-')])
}

def parse_diff(diff_str: str) -> list[str]:
"""Parse unified diff into list of lines."""
# Simplified; real implementation is more complex
lines = []
for line in diff_str.split('\n'):
if line.startswith('-'):
continue # Removed line
elif line.startswith('+'):
lines.append(line[1:] + '\n')
elif not line.startswith('@'):
lines.append(line + '\n')
return lines

Advantages:

  • Handles larger edits (full function replacements).
  • Context checking detects conflicts automatically.
  • Familiar format (standard unified diff).

Limitation:

  • Slightly harder for agents to construct correctly.

Strategy 3: Transaction-Based Editing​

For multi-file edits, wrap all file changes in a transaction: either all succeed or all roll back:

from contextlib import contextmanager
from pathlib import Path
import tempfile
import shutil

class FileEditTransaction:
"""Transactional editing: all files commit or all roll back."""

def __init__(self, work_dir: str):
self.work_dir = work_dir
self.edits = [] # List of (filepath, old_text, new_text)
self.snapshot_dir = None

def add_edit(self, filepath: str, old_text: str, new_text: str):
"""Queue an edit (not applied yet)."""
self.edits.append((filepath, old_text, new_text))

def commit(self) -> dict:
"""Apply all edits atomically."""

# Step 1: Create snapshot of all files we'll modify
self.snapshot_dir = tempfile.mkdtemp()
for filepath, _, _ in self.edits:
src = os.path.join(self.work_dir, filepath)
dst = os.path.join(self.snapshot_dir, filepath)
os.makedirs(os.path.dirname(dst), exist_ok=True)
shutil.copy(src, dst)

# Step 2: Validate all edits will succeed (before changing anything)
for filepath, old_text, new_text in self.edits:
full_path = os.path.join(self.work_dir, filepath)
with open(full_path) as f:
content = f.read()

if old_text not in content:
# Rollback: restore from snapshot
self._rollback()
return {
"success": False,
"error": f"Edit validation failed for {filepath}",
"rolled_back": True
}

# Step 3: Apply all edits
try:
for filepath, old_text, new_text in self.edits:
full_path = os.path.join(self.work_dir, filepath)
with open(full_path) as f:
content = f.read()

new_content = content.replace(old_text, new_text)

with open(full_path, 'w') as f:
f.write(new_content)
except Exception as e:
self._rollback()
return {
"success": False,
"error": f"Edit failed: {e}",
"rolled_back": True
}

# Step 4: Cleanup snapshot (all succeeded)
shutil.rmtree(self.snapshot_dir)

return {
"success": True,
"files_modified": len(self.edits),
"rolled_back": False
}

def _rollback(self):
"""Restore all files from snapshot."""
if self.snapshot_dir and os.path.exists(self.snapshot_dir):
for snapshot_path in os.listdir(self.snapshot_dir):
dst = os.path.join(self.work_dir, snapshot_path)
src = os.path.join(self.snapshot_dir, snapshot_path)
shutil.copy(src, dst)
shutil.rmtree(self.snapshot_dir)

Advantages:

  • Multi-file edits are atomic: either all change or none change.
  • Automatic rollback on failure.
  • No partial edits left behind.

Use case: Agent refactors a function across multiple files—if any edit fails, roll back all to prevent breakage.

Audit Logging​

Every edit should be logged for security and debugging:

def log_edit(change: dict):
"""Log file edit for audit trail."""

log_entry = {
"timestamp": datetime.now().isoformat(),
"filepath": change["filepath"],
"description": change["description"],
"old_hash": change["old_hash"],
"new_hash": change["new_hash"],
"lines_changed": change["new_lines"] - change["old_lines"]
}

# Append to audit log (json, one per line)
with open("/var/log/agent_edits.jsonl", "a") as f:
f.write(json.dumps(log_entry) + '\n')

# Also alert if changes are large
if abs(log_entry["lines_changed"]) > 50:
send_alert(f"Large edit to {change['filepath']}: {log_entry['lines_changed']} lines")

Key Takeaways​

  • Safe file editing requires exact-match validation: verify old_text before writing.
  • Use atomic writes (temp file + move) to prevent partial/corrupted files.
  • Diff-based editing with context checking handles larger edits safely.
  • Transactional editing ensures multi-file edits are all-or-nothing.
  • Audit logging enables debugging and security audits.

Frequently Asked Questions​

What if the agent's old_text is too broad (matches multiple places)?​

The tool detects this and rejects the edit, asking the agent to be more specific. The agent can include more context lines to disambiguate.

Can I edit binary files?​

No. Stick to text. For binary (compiled, image files), only allow read access. If an agent needs to modify binary data (e.g., protobuf), regenerate from source.

How do I handle concurrent edits from multiple agents?​

Use file-level locking: the first agent to read a file "locks" it; others wait or fail. Alternatively, use a version control system (git) with merge conflict detection. The second approach is more robust for large teams.

What if an agent wants to delete a file?​

Require the agent to provide the full file contents as old_text and empty string as new_text. This enforces the exact-match principle. Alternatively, implement a separate delete_file() tool with the same validation.

Further Reading​