# 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/p' is keyed as 'echo' by str.split() but /bin/sh runs the planted script './echo/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