Files
odysseus/tests/test_edit_file.py
Kenny Van de Maele 7443c36bd9 feat: Add edit_file tool + file-change diffs (#1239)
* Add edit_file tool + file-change diffs

edit_file is an exact old_string -> new_string replacement on a file on disk
(fails if old_string is missing or non-unique unless replace_all); write_file
also returns a unified diff. Diffs render collapsed in the tool bubble
(filename + +adds/-dels, theme colors); the raw JSON command box is hidden.

Security: edit_file is a sensitive filesystem-write tool, treated everywhere
write_file is —
  - added to NON_ADMIN_BLOCKED_TOOLS (is_public_blocked_tool / blocked_tools_for_owner),
    so on auth-enabled deployments a non-admin cannot run it; execute_tool_block
    refuses it for non-admin owners.
  - confined by the same path policy as read_file/write_file (allowlist +
    sensitive-file deny) via _resolve_tool_path.

Disambiguation in tool descriptions + bash prompt: edit_file/write_file are the
only way to write files (they show a diff) — never edit_document (editor panel)
or a bash heredoc/redirect.

Tests (tests/test_edit_file.py): non-admin block (policy + execution gate),
successful edit, not-found old_string, non-unique old_string (+ replace_all),
and path outside the allowed roots.

Files: src/tool_execution.py, src/agent_loop.py, src/tool_schemas.py,
src/agent_tools.py, src/tool_index.py, static/js/chat.js, static/style.css,
tests/test_edit_file.py.

* Drop redundant import os in write_file closure

os is already imported at module top.
2026-06-04 18:29:10 +02:00

88 lines
3.7 KiB
Python

"""edit_file: filesystem-write permission policy + behavior."""
import json
import os
import tempfile
import pytest
from src import tool_security
from src.tool_security import (
NON_ADMIN_BLOCKED_TOOLS,
is_public_blocked_tool,
blocked_tools_for_owner,
)
from src.tool_execution import _do_edit_file, execute_tool_block
from src.agent_tools import ToolBlock
# ── Permission policy ─────────────────────────────────────────────────────
def test_edit_file_is_sensitive_write_tool():
# Must be blocked for non-admins exactly like write_file.
assert "edit_file" in NON_ADMIN_BLOCKED_TOOLS
assert is_public_blocked_tool("edit_file") is True
def test_blocked_tools_for_owner_includes_edit_file_for_non_admin(monkeypatch):
monkeypatch.setattr(tool_security, "owner_is_admin_or_single_user", lambda owner: False)
blocked = blocked_tools_for_owner("bob")
assert "edit_file" in blocked and "write_file" in blocked
# Admin / single-user gets nothing blocked.
monkeypatch.setattr(tool_security, "owner_is_admin_or_single_user", lambda owner: True)
assert blocked_tools_for_owner("admin") == set()
@pytest.mark.asyncio
async def test_edit_file_blocked_at_execution_for_non_admin(monkeypatch):
# Execution-level gate: a non-admin owner must be refused even if the tool
# reaches execute_tool_block.
import src.tool_execution as te
monkeypatch.setattr(te, "_owner_is_admin", lambda owner: False)
ws = tempfile.mkdtemp()
p = os.path.join("/tmp", "ef_block.txt")
open(p, "w").write("a\n")
_desc, result = await execute_tool_block(
ToolBlock("edit_file", json.dumps({"path": p, "old_string": "a", "new_string": "b"})),
owner="bob",
)
assert result.get("exit_code") == 1 and "admin" in result.get("error", "").lower()
os.unlink(p)
# ── Behavior ──────────────────────────────────────────────────────────────
@pytest.mark.asyncio
async def test_edit_file_success():
p = os.path.join("/tmp", "ef_ok.py")
open(p, "w").write("def f():\n return 1\n")
res = await _do_edit_file(json.dumps({"path": p, "old_string": "return 1", "new_string": "return 2"}))
assert res["exit_code"] == 0
assert open(p).read() == "def f():\n return 2\n"
assert res["diff"]["added"] == 1 and res["diff"]["removed"] == 1 and res["diff"]["file"] == "ef_ok.py"
os.unlink(p)
@pytest.mark.asyncio
async def test_edit_file_not_found():
p = os.path.join("/tmp", "ef_nf.txt")
open(p, "w").write("hello\n")
res = await _do_edit_file(json.dumps({"path": p, "old_string": "nope", "new_string": "x"}))
assert res["exit_code"] == 1 and "not found" in res["error"]
os.unlink(p)
@pytest.mark.asyncio
async def test_edit_file_non_unique():
p = os.path.join("/tmp", "ef_dup.txt")
open(p, "w").write("x\nx\n")
res = await _do_edit_file(json.dumps({"path": p, "old_string": "x", "new_string": "y"}))
assert res["exit_code"] == 1 and "not unique" in res["error"]
# replace_all resolves it
res = await _do_edit_file(json.dumps({"path": p, "old_string": "x", "new_string": "y", "replace_all": True}))
assert res["exit_code"] == 0 and open(p).read() == "y\ny\n"
os.unlink(p)
@pytest.mark.asyncio
async def test_edit_file_outside_allowed_roots():
res = await _do_edit_file(json.dumps({"path": "/etc/hosts", "old_string": "x", "new_string": "y"}))
assert res["exit_code"] == 1 and ("outside the allowed roots" in res["error"] or "sensitive" in res["error"])