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