Files
odysseus/tests/test_edit_file.py
Alexandre Teixeira 795782917f fix(tests): call live tool_execution module in edit-file gate test
Calls execute_tool_block through the live src.tool_execution module in the edit-file admin-gate test so the monkeypatched _owner_is_admin seam and the called function belong to the same module object. Fixes the scoped #2580 CI-order edit-file failure. Remaining Python failure is the unrelated cookbook fallback-chain environment test.
2026-06-04 23:22:02 +01:00

95 lines
4.1 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
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. edit_file stays admin-gated by tool_security
# after #2684 (ALWAYS_AVAILABLE only changed advertisement, not execution).
#
# Resolve execute_tool_block from the live module object (te) rather than a
# top-level import: other test modules pop src.tool_execution from
# sys.modules and re-import it, so a stale top-level reference would call a
# different module's function than the one monkeypatch targets — silently
# bypassing the admin gate.
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 te.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"])