trashclaw / tests /test_tools.py
elyanlabs's picture
Source snapshot of github.com/Scottcjn/trashclaw@6dce9ed
53b2ac9 verified
Raw History Blame Contribute Delete
18.4 kB
# SPDX-License-Identifier: MIT
"""Tests for TrashClaw core tool functions.
Uses only pytest + stdlib. All file operations use tmp directories.
"""
import json
import os
import sys
import subprocess
import pytest
# Add project root to path so we can import from trashclaw
sys.path.insert(0, os.path.dirname(os.path.dirname(__file__)))
import trashclaw
# ── Fixtures ──
@pytest.fixture(autouse=True)
def set_cwd(tmp_path, monkeypatch):
"""Set TrashClaw's CWD to a temp directory for all tests."""
monkeypatch.setattr(trashclaw, "CWD", str(tmp_path))
return tmp_path
@pytest.fixture
def sample_file(tmp_path):
"""Create a sample file for read/write tests."""
f = tmp_path / "sample.txt"
f.write_text("line one\nline two\nline three\n")
return f
@pytest.fixture
def sample_py(tmp_path):
"""Create a sample Python file for search tests."""
f = tmp_path / "hello.py"
f.write_text('def greet(name):\n return f"Hello, {name}!"\n')
return f
# ── tool_read_file ──
class TestReadFile:
def test_read_full(self, sample_file):
result = trashclaw.tool_read_file(str(sample_file))
assert "line one" in result
assert "line two" in result
assert "line three" in result
def test_read_with_offset_limit(self, sample_file):
result = trashclaw.tool_read_file(str(sample_file), offset=2, limit=1)
assert "line two" in result
# Should not contain line one or three (only line 2)
assert "line one" not in result
def test_read_nonexistent(self):
result = trashclaw.tool_read_file("/tmp/nonexistent_trashclaw_test.txt")
assert "error" in result.lower() or "not found" in result.lower() or "no such" in result.lower()
# ── tool_write_file ──
class TestWriteFile:
def test_write_new(self, tmp_path):
path = str(tmp_path / "new_file.txt")
result = trashclaw.tool_write_file(path, "hello world")
assert os.path.exists(path)
assert "hello world" in open(path).read()
def test_write_creates_dirs(self, tmp_path):
path = str(tmp_path / "sub" / "dir" / "file.txt")
trashclaw.tool_write_file(path, "nested content")
assert os.path.exists(path)
assert "nested content" in open(path).read()
def test_write_overwrite(self, sample_file):
trashclaw.tool_write_file(str(sample_file), "replaced")
assert open(str(sample_file)).read() == "replaced"
# ── tool_edit_file ──
class TestEditFile:
def test_edit_replace(self, sample_file):
result = trashclaw.tool_edit_file(str(sample_file), "line two", "LINE TWO")
content = open(str(sample_file)).read()
assert "LINE TWO" in content
assert "line two" not in content
def test_edit_not_found(self, sample_file):
result = trashclaw.tool_edit_file(str(sample_file), "nonexistent text", "replacement")
assert "not found" in result.lower() or "error" in result.lower() or "no match" in result.lower()
# ── tool_run_command ──
class TestRunCommand:
def test_echo(self, monkeypatch):
# Bypass shell approval prompt
monkeypatch.setattr(trashclaw, "APPROVE_SHELL", False)
result = trashclaw.tool_run_command("echo hello_trashclaw_test")
assert "hello_trashclaw_test" in result
def test_exit_code(self, monkeypatch):
monkeypatch.setattr(trashclaw, "APPROVE_SHELL", False)
result = trashclaw.tool_run_command("false")
# Should contain some indication of failure or non-zero exit
assert isinstance(result, str)
class TestRunCommandAlwaysApprove:
"""An "always" approval must not cover chained/substituted commands."""
@pytest.fixture
def prompts(self, monkeypatch, tmp_path):
asked = []
def fake_input(prompt=""):
asked.append(prompt)
return "n"
monkeypatch.setattr(trashclaw, "APPROVE_SHELL", True)
monkeypatch.setattr(trashclaw, "APPROVED_COMMANDS", {"echo"})
monkeypatch.setattr("builtins.input", fake_input)
monkeypatch.setattr(trashclaw, "CWD", str(tmp_path))
return asked
def test_simple_approved_command_runs_without_prompt(self, prompts):
result = trashclaw.tool_run_command("echo approved_ok")
assert "approved_ok" in result
assert prompts == []
@pytest.mark.parametrize("command", [
"echo hi && echo pwned",
"echo hi; echo pwned",
"echo hi || echo pwned",
"echo hi | cat",
"echo hi & echo pwned",
"echo $(echo pwned)",
"echo `echo pwned`",
"echo ${HOME}",
"echo hi > out.txt",
"echo hi < in.txt",
"echo hi\necho pwned",
"echo (hi)",
])
def test_metachar_command_still_prompts(self, prompts, command):
result = trashclaw.tool_run_command(command)
assert len(prompts) == 1
assert result == "Command cancelled by user."
# Evasion attempts against the metacharacter check. subprocess runs
# /bin/sh here; on Windows cmd.exe its chaining operators (& | < >) and
# newlines are in the same set.
@pytest.mark.parametrize("command", [
" echo hi; touch PWNED", # leading whitespace
"\techo hi && touch PWNED", # leading tab
"echo\thi|touch PWNED", # tab instead of space
"echo hi\r\ntouch PWNED", # CRLF
"echo hi\rtouch PWNED", # bare CR
"echo hi >PWNED",
"echo $(touch PWNED)",
])
def test_evasions_prompt_and_do_not_run(self, prompts, tmp_path, command):
result = trashclaw.tool_run_command(command)
assert len(prompts) == 1
assert result == "Command cancelled by user."
assert not (tmp_path / "PWNED").exists()
# Globbing and tilde expansion don't run anything: the approved command
# runs without a prompt and nothing else is executed.
def test_globs_run_only_the_approved_command(self, prompts, tmp_path):
(tmp_path / "existing.txt").write_text("")
trashclaw.tool_run_command("echo * ~ touch PWNED")
assert prompts == []
assert not (tmp_path / "PWNED").exists()
# Anything outside printable ASCII always prompts: Python's str.split()
# breaks words on characters (\x0b, \x1c-\x1f, \x85, U+2028, ...) that
# /bin/sh keeps inside a word, so the approval key and the program the
# shell runs could differ.
@pytest.mark.parametrize("command", [
"echo hi\uff1b touch PWNED", # fullwidth semicolon
"echo hi\u037e touch PWNED", # Greek question mark (looks like ;)
"echo hi\uff06\uff06 touch PWNED", # fullwidth ampersands
"echo hi\u2028touch PWNED", # Unicode line separator
"echo hi\x0btouch PWNED", # vertical tab
"echo\x0b/p", "echo\x0c/p", "echo\x1c/p", "echo\x1f/p", "echo\x85/p",
])
def test_non_ascii_or_control_characters_prompt(self, prompts, tmp_path, command):
assert trashclaw.tool_run_command(command) == "Command cancelled by user."
assert len(prompts) == 1
assert not (tmp_path / "PWNED").exists()
@pytest.mark.skipif(os.name != "posix", reason="needs a POSIX shell")
@pytest.mark.parametrize("sep", ["\x0b", "\x0c", "\x1c", "\x1f"])
def test_word_split_mismatch_cannot_run_a_planted_script(self, prompts, tmp_path, sep):
"""Reviewer's reproduction: 'echo<VT>/p' is keyed as 'echo' by str.split()
but /bin/sh runs the planted script './echo<VT>/p'."""
planted = tmp_path / ("echo" + sep)
planted.mkdir()
script = planted / "p"
script.write_text("#!/bin/sh\ntouch PWNED\n")
script.chmod(0o755)
assert trashclaw.tool_run_command("echo" + sep + "/p") == "Command cancelled by user."
assert len(prompts) == 1
assert not (tmp_path / "PWNED").exists()
class TestAlwaysOnlyForReadOnlyTools:
""""always" is an allowlist of read-only tools; everything else prompts."""
@pytest.mark.parametrize("prefix", [
"bash", "sh", "python3", "/usr/bin/env", "env", "git", "find", "sed", "tar",
"xargs", "node", "perl", "sqlite3", "psql", "hg", "sftp", "flock", "setsid",
"stdbuf", "ionice", "valgrind", "tmux", "screen", "pre-commit", "tox", "nox",
"rake", "mvn", "gradle", "just", "gem", "bundle", "Rscript", "jshell",
"toybox", "pytest", "./script.sh", "make", "", "ECHO",
])
def test_refused(self, prefix):
assert trashclaw._allows_always(prefix) is False
@pytest.mark.parametrize("prefix", ["ls", "echo", "cat", "grep", "wc", "head", "tail", "pwd"])
def test_allowed(self, prefix):
assert trashclaw._allows_always(prefix) is True
@pytest.mark.parametrize("command", [
"sqlite3 :memory: '.shell touch PWNED'",
"psql -c '\\! touch PWNED'",
"hg --config 'alias.x=!touch PWNED' x",
"sftp -o 'ProxyCommand=touch PWNED' host",
"flock /tmp/lock touch PWNED",
"setsid touch PWNED",
"stdbuf -o0 touch PWNED",
"tmux new -d 'touch PWNED'",
"Rscript -e 'file.create(\"PWNED\")'",
"pytest",
])
def test_escalating_commands_prompt_even_if_previously_approved(
self, monkeypatch, tmp_path, command):
asked = []
monkeypatch.setattr(trashclaw, "APPROVE_SHELL", True)
# Even if one of these somehow ended up approved, it must still prompt.
monkeypatch.setattr(trashclaw, "APPROVED_COMMANDS", {
"echo", "sqlite3", "psql", "hg", "sftp", "flock", "setsid", "stdbuf",
"tmux", "Rscript", "pytest",
})
monkeypatch.setattr("builtins.input", lambda prompt="": asked.append(prompt) or "n")
monkeypatch.setattr(trashclaw, "CWD", str(tmp_path))
assert trashclaw.tool_run_command(command) == "Command cancelled by user."
assert len(asked) == 1
assert not (tmp_path / "PWNED").exists()
def test_always_answer_runs_once_without_persisting(self, monkeypatch, tmp_path):
answers = iter(["a", "n"])
monkeypatch.setattr(trashclaw, "APPROVE_SHELL", True)
monkeypatch.setattr(trashclaw, "APPROVED_COMMANDS", set())
monkeypatch.setattr("builtins.input", lambda prompt="": next(answers))
monkeypatch.setattr(trashclaw, "CWD", str(tmp_path))
command = "sh -c 'echo ran_once'"
assert "ran_once" in trashclaw.tool_run_command(command)
assert trashclaw.APPROVED_COMMANDS == set()
assert trashclaw.tool_run_command(command) == "Command cancelled by user."
def test_always_answer_persists_for_read_only_tools(self, monkeypatch, tmp_path):
monkeypatch.setattr(trashclaw, "APPROVE_SHELL", True)
monkeypatch.setattr(trashclaw, "APPROVED_COMMANDS", set())
monkeypatch.setattr("builtins.input", lambda prompt="": "a")
monkeypatch.setattr(trashclaw, "CWD", str(tmp_path))
trashclaw.tool_run_command("echo hi")
assert trashclaw.APPROVED_COMMANDS == {"echo"}
@pytest.mark.parametrize("command,key", [
("echo hi", "echo"), (" ls -la", "ls"), ("'ls' x", "ls"),
("echo\x0b/p", ""), ("./echo x", ""), ("/bin/ls", ""), ("echo 'unbalanced", ""),
])
def test_command_key(self, command, key):
assert trashclaw._command_key(command) == key
# ── tool_search_files ──
class TestSearchFiles:
def test_search_content(self, sample_py, tmp_path):
result = trashclaw.tool_search_files("greet", str(tmp_path))
assert "greet" in result
def test_search_no_match(self, tmp_path):
(tmp_path / "empty.txt").write_text("nothing here\n")
result = trashclaw.tool_search_files("zzz_nonexistent_zzz", str(tmp_path))
assert "no match" in result.lower() or "0 match" in result.lower() or result.strip() == ""
# ── tool_find_files ──
class TestFindFiles:
def test_find_by_glob(self, sample_py, tmp_path):
result = trashclaw.tool_find_files("*.py", str(tmp_path))
assert "hello.py" in result
def test_find_no_match(self, tmp_path):
result = trashclaw.tool_find_files("*.xyz", str(tmp_path))
assert "hello.py" not in result
# ── tool_list_dir ──
class TestListDir:
def test_list(self, sample_file, tmp_path):
result = trashclaw.tool_list_dir(str(tmp_path))
assert "sample.txt" in result
def test_list_empty(self, tmp_path):
empty = tmp_path / "empty_dir"
empty.mkdir()
result = trashclaw.tool_list_dir(str(empty))
# Should not error, may show empty or "no files"
assert isinstance(result, str)
# ── tool_git_status / tool_git_diff ──
class TestGitTools:
def test_git_status_no_repo(self, tmp_path):
"""git status in a non-repo dir should handle gracefully."""
result = trashclaw.tool_git_status()
# Either shows error or empty status
assert isinstance(result, str)
def test_git_diff_no_repo(self, tmp_path):
result = trashclaw.tool_git_diff()
assert isinstance(result, str)
# ── tool_patch_file ──
class TestPatchFile:
def test_patch_simple(self, sample_file):
patch = """--- a/sample.txt
+++ b/sample.txt
@@ -1,3 +1,3 @@
line one
-line two
+PATCHED LINE
line three
"""
result = trashclaw.tool_patch_file(str(sample_file), patch)
content = open(str(sample_file)).read()
# patch_file may or may not succeed depending on implementation
assert isinstance(result, str)
# ── _save_undo ──
class TestUndoSystem:
def test_save_undo(self, sample_file, monkeypatch):
monkeypatch.setattr(trashclaw, "UNDO_STACK", [])
trashclaw._save_undo(str(sample_file), "write")
assert len(trashclaw.UNDO_STACK) >= 1
assert trashclaw.UNDO_STACK[-1]["path"] == str(sample_file)
# ── _track_tool ──
class TestAchievements:
def test_track_tool(self, tmp_path, monkeypatch):
# Use temp dir for achievements
ach_file = str(tmp_path / "achievements.json")
monkeypatch.setattr(trashclaw, "ACHIEVEMENTS_FILE", ach_file)
monkeypatch.setattr(trashclaw, "ACHIEVEMENTS", trashclaw._load_achievements())
trashclaw._track_tool("read_file")
# Should not crash; achievements tracked in memory
assert True
# ── _load_config ──
class TestConfig:
def test_load_config_no_file(self, tmp_path, monkeypatch):
"""Config loading should not crash when no config file exists."""
monkeypatch.setattr(trashclaw, "CONFIG_FILE", str(tmp_path / "nonexistent.json"))
cfg = trashclaw._load_config(str(tmp_path))
assert isinstance(cfg, dict)
def test_load_config_with_file(self, tmp_path, monkeypatch):
config_file = tmp_path / "config.json"
config_file.write_text(json.dumps({"model": "test-model"}))
monkeypatch.setattr(trashclaw, "CONFIG_FILE", str(config_file))
cfg = trashclaw._load_config(str(tmp_path))
assert isinstance(cfg, dict)
assert cfg.get("model") == "test-model"
# ── tool_clipboard ──
class TestClipboard:
def test_clipboard_copy(self):
"""Clipboard copy should not crash even without display."""
result = trashclaw.tool_clipboard("copy", "test content")
assert isinstance(result, str)
# ── tool_git_commit ──
class TestGitCommit:
def test_git_commit_no_repo(self, tmp_path, monkeypatch):
monkeypatch.setattr(trashclaw, "CWD", str(tmp_path))
result = trashclaw.tool_git_commit("test commit")
assert isinstance(result, str)
def test_git_commit_empty_message(self, tmp_path, monkeypatch):
monkeypatch.setattr(trashclaw, "CWD", str(tmp_path))
result = trashclaw.tool_git_commit("")
assert isinstance(result, str)
def test_git_commit_in_real_repo(self, tmp_path, monkeypatch):
import subprocess
monkeypatch.setattr(trashclaw, "CWD", str(tmp_path))
subprocess.run(["git", "init"], cwd=str(tmp_path), capture_output=True)
subprocess.run(["git", "config", "user.email", "test@test.com"], cwd=str(tmp_path), capture_output=True)
subprocess.run(["git", "config", "user.name", "Test"], cwd=str(tmp_path), capture_output=True)
f = tmp_path / "test.txt"
f.write_text("hello")
subprocess.run(["git", "add", "."], cwd=str(tmp_path), capture_output=True)
result = trashclaw.tool_git_commit("initial commit")
assert isinstance(result, str)
# ── tab completion ──
class TestReadOnlyMode:
def test_available_tools_hides_side_effecting_tools(self, monkeypatch):
monkeypatch.setattr(trashclaw, "READ_ONLY_MODE", True)
names = {tool["function"]["name"] for tool in trashclaw._available_tools()}
assert "read_file" in names
assert "search_files" in names
assert "git_diff" in names
assert "write_file" not in names
assert "edit_file" not in names
assert "patch_file" not in names
assert "run_command" not in names
assert "git_commit" not in names
def test_read_only_blocks_write_and_shell(self, monkeypatch):
monkeypatch.setattr(trashclaw, "READ_ONLY_MODE", True)
assert trashclaw._read_only_blocked("write_file", {})
assert trashclaw._read_only_blocked("run_command", {})
assert trashclaw._read_only_blocked("git_commit", {})
assert not trashclaw._read_only_blocked("read_file", {})
assert not trashclaw._read_only_blocked("git_status", {})
def test_read_only_allows_only_clipboard_paste(self, monkeypatch):
monkeypatch.setattr(trashclaw, "READ_ONLY_MODE", True)
assert not trashclaw._read_only_blocked("clipboard", {"action": "paste"})
assert trashclaw._read_only_blocked("clipboard", {"action": "copy"})
class TestTabCompletion:
def test_setup_tab_completion_runs(self):
try:
trashclaw._setup_tab_completion()
except Exception:
pass # readline may not be available in CI
def test_setup_tab_completion_returns_none(self):
result = trashclaw._setup_tab_completion()
assert result is None