from __future__ import annotations from pathlib import Path import pytest from lilith_agent.tools.files import ( _resolve_safe_write_path, find_files, glob_files, grep, ls, read_file, set_write_root, write_file, ) def test_relative_path_inside_root_is_allowed(tmp_path: Path): set_write_root(tmp_path) p = _resolve_safe_write_path("notes.txt") assert p == (tmp_path / "notes.txt").resolve() def test_nested_relative_path_allowed(tmp_path: Path): set_write_root(tmp_path) p = _resolve_safe_write_path("sub/dir/notes.txt") assert p == (tmp_path / "sub" / "dir" / "notes.txt").resolve() def test_absolute_path_rejected(tmp_path: Path): set_write_root(tmp_path) with pytest.raises(ValueError, match="absolute"): _resolve_safe_write_path("/etc/passwd") def test_dotdot_escape_rejected(tmp_path: Path): set_write_root(tmp_path) with pytest.raises(ValueError, match="escape"): _resolve_safe_write_path("../outside.txt") def test_deep_dotdot_escape_rejected(tmp_path: Path): set_write_root(tmp_path) with pytest.raises(ValueError, match="escape"): _resolve_safe_write_path("sub/../../outside.txt") def test_write_file_inside_root_succeeds(tmp_path: Path): set_write_root(tmp_path) result = write_file("note.txt", "hello") assert "Successfully wrote" in result assert (tmp_path / "note.txt").read_text() == "hello" def test_write_file_outside_root_returns_error_without_writing(tmp_path: Path): target = tmp_path / "outside.txt" set_write_root(tmp_path / "scratch") result = write_file(str(target), "hello") assert result.startswith("ERROR") assert not target.exists() def test_ls_expands_tilde(monkeypatch, tmp_path: Path): monkeypatch.setenv("HOME", str(tmp_path)) (tmp_path / "marker.txt").write_text("x") out = ls("~") assert "marker.txt" in out def test_read_file_expands_tilde(monkeypatch, tmp_path: Path): monkeypatch.setenv("HOME", str(tmp_path)) (tmp_path / "note.md").write_text("hello goddess") out = read_file("~/note.md") assert "hello goddess" in out def test_grep_expands_tilde(monkeypatch, tmp_path: Path): monkeypatch.setenv("HOME", str(tmp_path)) (tmp_path / "lore.txt").write_text("The She\nDemiurge\n") out = grep("She", "~/lore.txt") assert "The She" in out def test_glob_files_expands_tilde(monkeypatch, tmp_path: Path): monkeypatch.setenv("HOME", str(tmp_path)) (tmp_path / "a.lol").write_text("") (tmp_path / "sub").mkdir() (tmp_path / "sub" / "b.lol").write_text("") out = glob_files("~/**/*.lol") assert "a.lol" in out assert "b.lol" in out def test_glob_files_accepts_absolute_pattern(tmp_path: Path): (tmp_path / "莉莉丝女神.lol").write_text("") (tmp_path / "deep").mkdir() (tmp_path / "deep" / "莉莉丝女神.lol").write_text("") out = glob_files(f"{tmp_path}/**/莉莉丝女神.lol") assert "莉莉丝女神.lol" in out assert "deep" in out def test_find_files_locates_by_name(tmp_path: Path): (tmp_path / "a").mkdir() (tmp_path / "a" / "target.txt").write_text("") (tmp_path / "b").mkdir() (tmp_path / "b" / "other.md").write_text("") out = find_files("target.txt", str(tmp_path)) assert "target.txt" in out assert "other.md" not in out def test_find_files_expands_tilde(monkeypatch, tmp_path: Path): monkeypatch.setenv("HOME", str(tmp_path)) (tmp_path / "needle.lol").write_text("") out = find_files("needle.lol", "~") assert "needle.lol" in out def test_find_files_missing_root_errors(tmp_path: Path): out = find_files("anything", str(tmp_path / "does_not_exist")) assert out.startswith("ERROR") def test_find_files_excludes_node_modules(tmp_path: Path): (tmp_path / "node_modules").mkdir() (tmp_path / "node_modules" / "skip.txt").write_text("") (tmp_path / "src").mkdir() (tmp_path / "src" / "keep.txt").write_text("") out = find_files("*.txt", str(tmp_path)) assert "keep.txt" in out assert "skip.txt" not in out def test_find_files_excludes_dot_git(tmp_path: Path): (tmp_path / ".git").mkdir() (tmp_path / ".git" / "config").write_text("") (tmp_path / "src").mkdir() (tmp_path / "src" / "config").write_text("") out = find_files("config", str(tmp_path)) assert str(tmp_path / "src" / "config") in out assert str(tmp_path / ".git" / "config") not in out def test_find_files_truncation_sentinel(tmp_path: Path): for i in range(4): (tmp_path / f"file{i}.txt").write_text("") out = find_files("*.txt", str(tmp_path), max_results=3) assert "[truncated:" in out assert "of 4" in out def test_filesystem_tool_descriptions_mention_tilde(): from lilith_agent.config import Config from lilith_agent.tools import build_tools cfg = Config.from_env() tools_by_name = {t.name: t for t in build_tools(cfg)} for name in ("read_file", "ls", "grep", "glob_files", "find_files"): assert "~" in tools_by_name[name].description, f"{name} description must mention ~" def test_find_files_description_warns_against_root_slash(): from lilith_agent.config import Config from lilith_agent.tools import build_tools cfg = Config.from_env() tools_by_name = {t.name: t for t in build_tools(cfg)} desc = tools_by_name["find_files"].description assert "/" in desc and ("never" in desc.lower() or "do not" in desc.lower())