CORRECTION 1: #314 reopened — recall_with_associations mode field defect CONFIRMED in current source and FIXED (mode: association_merged). CORRECTION 3: Gitea credential removed from remote URL (credential helper). TOKEN EXPOSED: yes (in diagnostic output). TOKEN ROTATED: not yet (requires Gitea web UI access). Remote URL now shows username only, no secret. CORRECTION 4: duplicate-release-commit defect ROOT CAUSE: convergence computed warranted bump from commits-since-tag without checking if master version already satisfies the bump. FIX: already-converged guard in version_convergence.py (master version > last tag → no commit). Repo-aware (args.repo passed to read_version/latest_tag). CORRECTION 6: lifecycle terminalization — execution exception now guarantees task terminalizes as failed with evidence (no immortal RUNNING). CORRECTION 7: route instruction task-scoped on RuneTask.route_instruction (NOT shared operator state). Read-only leading-verb override. Isolation tests prove no cross-contamination between interleaved tasks. PL1-01/02/03/05: pl1_records.py (route resolution, proof level, failure class, candidate status), metrics with honest NOT_YET_MEASURABLE semantics, route-instruction learning loop (PE-1789102658-0269 SEED). 1253 tests green; ratchet 352/80 (shrink); henxels clean.
275 lines
11 KiB
Python
275 lines
11 KiB
Python
"""Authority-boundary tests for local tools.
|
|
|
|
These tests intentionally exercise the public prepare/approve/reject API rather
|
|
than UI confirmation code. A model may prepare a mutation, but only an
|
|
approval may perform it.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import subprocess
|
|
import sys
|
|
import tempfile
|
|
import unittest
|
|
import json
|
|
from dataclasses import replace
|
|
from pathlib import Path
|
|
from unittest.mock import patch
|
|
|
|
|
|
ROOT = Path(__file__).resolve().parents[1]
|
|
sys.path.insert(0, str(ROOT / "src"))
|
|
|
|
from rune import tool_executor # noqa: E402
|
|
from rune.local_tool_runtime import LocalToolRuntime # noqa: E402
|
|
from rune.tool_protocol import ToolRequest, validate_tool_request # noqa: E402
|
|
|
|
|
|
class ToolAuthorityTests(unittest.TestCase):
|
|
def setUp(self) -> None:
|
|
self.tempdir = tempfile.TemporaryDirectory()
|
|
self.root = Path(self.tempdir.name) / "allowed"
|
|
self.root.mkdir()
|
|
self.undo = Path(self.tempdir.name) / "undo"
|
|
self.patches = [
|
|
patch.object(tool_executor, "ALLOWED_READ_DIRS", [str(self.root)]),
|
|
patch.object(tool_executor, "ALLOWED_WRITE_DIRS", [str(self.root)]),
|
|
patch.object(tool_executor, "BLOCKED_DIRS", []),
|
|
patch.object(tool_executor, "UNDO_DIR", str(self.undo)),
|
|
]
|
|
for item in self.patches:
|
|
item.start()
|
|
|
|
def tearDown(self) -> None:
|
|
for item in reversed(self.patches):
|
|
item.stop()
|
|
self.tempdir.cleanup()
|
|
|
|
def prepare(self, name: str, arguments: dict):
|
|
result = tool_executor.prepare_tool(name, arguments)
|
|
self.assertTrue(result["success"], result)
|
|
self.assertTrue(result["needs_confirm"], result)
|
|
return result["pending"]
|
|
|
|
def test_explicit_authority_sets_fail_closed_for_new_tools(self):
|
|
self.assertIn("read_file", tool_executor.READ_ONLY_TOOLS)
|
|
for name in {
|
|
"write_file",
|
|
"edit_file",
|
|
"format_code",
|
|
"git_commit",
|
|
"run_command",
|
|
}:
|
|
self.assertIn(name, tool_executor.MUTATING_TOOLS)
|
|
self.assertNotIn(name, tool_executor.READ_ONLY_TOOLS)
|
|
|
|
prepared = tool_executor.prepare_tool("future_tool", {"value": 1})
|
|
|
|
self.assertTrue(prepared["needs_confirm"])
|
|
self.assertFalse(tool_executor.approve_tool(prepared["pending"])["success"])
|
|
|
|
def test_read_tools_execute_during_prepare(self):
|
|
target = self.root / "note.txt"
|
|
target.write_text("hello", encoding="utf-8")
|
|
|
|
result = tool_executor.prepare_tool("read_file", {"path": str(target)})
|
|
|
|
self.assertTrue(result["success"])
|
|
self.assertNotIn("needs_confirm", result)
|
|
self.assertIn("hello", result["result"])
|
|
|
|
def test_each_mutator_preserves_bytes_until_approval(self):
|
|
target = self.root / "note.py"
|
|
original = "value=1\n"
|
|
target.write_text(original, encoding="utf-8")
|
|
|
|
cases = {
|
|
"write_file": {"path": str(target), "content": "value = 2\n"},
|
|
"edit_file": {"path": str(target), "search": "1", "replace": "2"},
|
|
"format_code": {"path": str(target), "language": "python"},
|
|
"git_commit": {"message": "model commit"},
|
|
"run_command": {"argv": ["python", "-c", "print('ok')"]},
|
|
}
|
|
|
|
with patch("subprocess.run") as run:
|
|
for name, arguments in cases.items():
|
|
with self.subTest(name=name):
|
|
result = tool_executor.prepare_tool(name, arguments)
|
|
self.assertTrue(result["needs_confirm"], result)
|
|
self.assertEqual(target.read_bytes(), original.encode())
|
|
run.assert_not_called()
|
|
|
|
def test_write_and_edit_approve_exactly_once_and_keep_backups(self):
|
|
target = self.root / "note.txt"
|
|
target.write_text("old", encoding="utf-8")
|
|
|
|
edit = self.prepare("edit_file", {"path": str(target), "search": "old", "replace": "mid"})
|
|
self.assertTrue(tool_executor.approve_tool(edit)["success"])
|
|
self.assertEqual(target.read_text(encoding="utf-8"), "mid")
|
|
self.assertFalse(tool_executor.approve_tool(edit)["success"])
|
|
|
|
write = self.prepare("write_file", {"path": str(target), "content": "new"})
|
|
self.assertTrue(tool_executor.approve_tool(write)["success"])
|
|
self.assertEqual(target.read_text(encoding="utf-8"), "new")
|
|
self.assertTrue(list(self.undo.glob("*.bak")))
|
|
|
|
def test_reject_and_expiry_preserve_bytes(self):
|
|
target = self.root / "note.txt"
|
|
target.write_text("old", encoding="utf-8")
|
|
rejected = self.prepare("write_file", {"path": str(target), "content": "rejected"})
|
|
expired = self.prepare("edit_file", {"path": str(target), "search": "old", "replace": "late"})
|
|
|
|
self.assertFalse(tool_executor.reject_tool(rejected)["success"])
|
|
self.assertFalse(tool_executor.approve_tool(replace(expired, expires_at=expired.created_at - 1))["success"])
|
|
self.assertEqual(target.read_bytes(), b"old")
|
|
|
|
def test_path_traversal_symlink_and_sibling_prefix_are_rejected(self):
|
|
outside = Path(self.tempdir.name) / "outside.txt"
|
|
outside.write_text("secret", encoding="utf-8")
|
|
sibling = Path(f"{self.root}-evil")
|
|
sibling.mkdir()
|
|
link = self.root / "link"
|
|
link.symlink_to(outside)
|
|
|
|
paths = [
|
|
self.root / ".." / "outside.txt",
|
|
sibling / "escape.txt",
|
|
link,
|
|
]
|
|
for path in paths:
|
|
with self.subTest(path=path):
|
|
result = tool_executor.prepare_tool("write_file", {"path": str(path), "content": "owned"})
|
|
self.assertFalse(result["success"], result)
|
|
self.assertEqual(outside.read_bytes(), b"secret")
|
|
|
|
def test_approval_revalidates_path_and_file_digest_against_toctou(self):
|
|
target = self.root / "note.txt"
|
|
target.write_text("old", encoding="utf-8")
|
|
pending = self.prepare("edit_file", {"path": str(target), "search": "old", "replace": "new"})
|
|
target.write_text("changed after preview", encoding="utf-8")
|
|
|
|
result = tool_executor.approve_tool(pending)
|
|
|
|
self.assertFalse(result["success"])
|
|
self.assertEqual(target.read_bytes(), b"changed after preview")
|
|
|
|
def test_approval_rejects_symlink_swap_after_preview(self):
|
|
target = self.root / "note.txt"
|
|
outside = Path(self.tempdir.name) / "outside.txt"
|
|
target.write_text("inside", encoding="utf-8")
|
|
outside.write_text("outside", encoding="utf-8")
|
|
pending = self.prepare("write_file", {"path": str(target), "content": "replacement"})
|
|
target.unlink()
|
|
target.symlink_to(outside)
|
|
|
|
result = tool_executor.approve_tool(pending)
|
|
|
|
self.assertFalse(result["success"])
|
|
self.assertEqual(outside.read_bytes(), b"outside")
|
|
|
|
def test_approval_rejects_tampered_operation_digest(self):
|
|
target = self.root / "note.txt"
|
|
target.write_text("old", encoding="utf-8")
|
|
pending = self.prepare("write_file", {"path": str(target), "content": "approved bytes"})
|
|
pending.arguments["content"] = "tampered bytes"
|
|
|
|
result = tool_executor.approve_tool(pending)
|
|
|
|
self.assertFalse(result["success"])
|
|
self.assertEqual(target.read_bytes(), b"old")
|
|
|
|
def test_argv_command_uses_shell_false_only_after_approval(self):
|
|
validate_tool_request(
|
|
ToolRequest(
|
|
"command-1",
|
|
"run_command",
|
|
{"argv": ["printf", "safe"]},
|
|
"ollama-native",
|
|
)
|
|
)
|
|
pending = self.prepare("run_command", {"argv": ["printf", "safe"]})
|
|
completed = subprocess.CompletedProcess(["printf", "safe"], 0, "safe", "")
|
|
|
|
with patch("subprocess.run", return_value=completed) as run:
|
|
result = tool_executor.approve_tool(pending)
|
|
|
|
self.assertTrue(result["success"])
|
|
run.assert_called_once()
|
|
self.assertIs(run.call_args.kwargs["shell"], False)
|
|
self.assertEqual(run.call_args.args[0], ["printf", "safe"])
|
|
|
|
def test_shell_syntax_requires_explicit_approved_shell_path(self):
|
|
target = self.root / "command-output"
|
|
ordinary = self.prepare("run_command", {"command": f"printf owned > {target}"})
|
|
|
|
with patch("subprocess.run") as run:
|
|
denied = tool_executor.approve_tool(ordinary)
|
|
run.assert_not_called()
|
|
self.assertFalse(denied["success"])
|
|
self.assertFalse(target.exists())
|
|
|
|
explicit = self.prepare(
|
|
"run_command",
|
|
{"command": f"printf owned > {target}", "shell": True},
|
|
)
|
|
completed = subprocess.CompletedProcess("command", 0, "", "")
|
|
with patch("subprocess.run", return_value=completed) as run:
|
|
approved = tool_executor.approve_tool(explicit)
|
|
self.assertTrue(approved["success"])
|
|
self.assertIs(run.call_args.kwargs["shell"], True)
|
|
|
|
def test_local_runtime_prepares_and_approves_through_authority_api(self):
|
|
target = self.root / "runtime.txt"
|
|
target.write_text("old", encoding="utf-8")
|
|
|
|
class Transport:
|
|
def __init__(self):
|
|
self.responses = [
|
|
{
|
|
"role": "assistant",
|
|
"content": "",
|
|
"tool_calls": [
|
|
{
|
|
"id": "write-1",
|
|
"function": {
|
|
"name": "write_file",
|
|
"arguments": {
|
|
"path": str(target),
|
|
"content": "new",
|
|
},
|
|
},
|
|
}
|
|
],
|
|
},
|
|
{"role": "assistant", "content": "done"},
|
|
]
|
|
|
|
def supports_tools(self, model):
|
|
return True
|
|
|
|
def supports_thinking(self, model):
|
|
return False
|
|
|
|
def chat(self, **kwargs):
|
|
return self.responses.pop(0)
|
|
|
|
runtime = LocalToolRuntime(transport=Transport(), executor=tool_executor.execute_tool, model="local")
|
|
|
|
pending = runtime.run([{"role": "user", "content": "change it"}])
|
|
|
|
self.assertEqual(target.read_bytes(), b"old")
|
|
json.dumps(pending.pending_state)
|
|
self.assertIn("prepared_tool", pending.pending_state)
|
|
|
|
completed = runtime.approve(pending.pending_state)
|
|
|
|
self.assertTrue(completed.success)
|
|
self.assertEqual(target.read_bytes(), b"new")
|
|
duplicate = runtime.approve(pending.pending_state)
|
|
self.assertFalse(duplicate.success)
|
|
self.assertEqual(target.read_bytes(), b"new")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|