mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-09-10 10:12:20 +02:00
* fix(security): keep agent file tools out of the app state directory
The agent's read tools (read_file, grep, glob, ls) resolved model-supplied
paths against a root list whose first entry was the whole data directory.
That directory holds the session store, the auth database, the app
encryption key and the settings file, so prompt-injected content could ask
for any of them. No approval prompt stood in the way: reads are classified
read_workspace and pass the untrusted-context gate untouched, which is
correct for reading a workspace and wrong for reading the app's own state.
The agent gets data/agent_workspace/ instead, and the subprocess cwd and
HOME move with it so bash and read_file agree on where scratch files live.
The deny itself is a property of the path, not of the root it arrived
through, because three routes reach the same bytes and closing only the
first leaves the other two working:
- the default root list
- a workspace bound at or above the data directory, which vet_workspace
accepted and chat_routes auto-binds from a path named in the message
- a tool_path_extra_roots setting covering the data directory
_resolve_search_root also returned the workspace root unchecked when the
path was empty, so a bare ls enumerated the directory whatever the deny
list said. It now resolves that case through the same guards.
A containment rule rather than a filename deny list, so state files added
later are covered without anyone remembering to list them, and so a user's
own settings.json or app.db inside a real workspace is not caught.
Four directories of user content stay readable, because the application
hands their paths to the model and tells it to open them: the chat upload
manifest, downloaded mail attachments, personal docs (which covers the
runbook) and personal uploads.
* fix: enforce state deny during recursive file search
* fix: bound protected filesystem searches
* fix(security): reject inode aliases and workspace redirects
* fix(security): harden partitioned agent searches
* fix(security): report fallback worker exits promptly
* fix(security): clean up search readers and retain relative data roots
---------
Co-authored-by: RaresKeY <158580472+RaresKeY@users.noreply.github.com>
210 lines
7.7 KiB
Python
210 lines
7.7 KiB
Python
"""Tests for the code-navigation tools (grep, glob, ls) + read_file line range."""
|
|
import os
|
|
import shutil
|
|
import asyncio
|
|
import tempfile
|
|
import pytest
|
|
|
|
os.environ.setdefault("DATABASE_URL", "sqlite:////tmp/test_code_nav.db")
|
|
|
|
from src.tool_execution import _direct_fallback
|
|
|
|
|
|
def _run(tool, content):
|
|
return asyncio.run(_direct_fallback(tool, content))
|
|
|
|
|
|
@pytest.fixture
|
|
def repo():
|
|
# Built under /tmp, which is on the default tool-path allowlist.
|
|
root = tempfile.mkdtemp(dir="/tmp", prefix="codenav_")
|
|
try:
|
|
with open(os.path.join(root, "a.py"), "w") as f:
|
|
f.write("import os\n# needle here\nprint('x')\n")
|
|
os.mkdir(os.path.join(root, "sub"))
|
|
with open(os.path.join(root, "sub", "b.txt"), "w") as f:
|
|
f.write("nothing\nNEEDLE upper\n")
|
|
os.mkdir(os.path.join(root, "sub", "deep"))
|
|
with open(os.path.join(root, "sub", "deep", "c.py"), "w") as f:
|
|
f.write("# deep python\n")
|
|
os.mkdir(os.path.join(root, "node_modules"))
|
|
with open(os.path.join(root, "node_modules", "dep.py"), "w") as f:
|
|
f.write("needle in dep\n")
|
|
g = os.path.join(root, ".git")
|
|
os.mkdir(g)
|
|
with open(os.path.join(g, "config"), "w") as f:
|
|
f.write("needle in git\n")
|
|
yield root
|
|
finally:
|
|
shutil.rmtree(root, ignore_errors=True)
|
|
|
|
|
|
# ── grep ──────────────────────────────────────────────────────────────────
|
|
|
|
def test_grep_finds_match(repo):
|
|
r = _run("grep", f'{{"pattern": "needle", "path": "{repo}"}}')
|
|
assert r["exit_code"] == 0
|
|
assert "a.py:2:" in r["output"]
|
|
|
|
|
|
def test_grep_skips_junk_dirs(repo):
|
|
r = _run("grep", f'{{"pattern": "needle", "path": "{repo}"}}')
|
|
assert "node_modules" not in r["output"]
|
|
assert ".git/config" not in r["output"]
|
|
|
|
|
|
def test_grep_ignore_case(repo):
|
|
r = _run("grep", f'{{"pattern": "needle", "ignore_case": true, "path": "{repo}"}}')
|
|
assert "b.txt:2:" in r["output"]
|
|
|
|
|
|
def test_grep_glob_filter(repo):
|
|
r = _run("grep", f'{{"pattern": "needle", "ignore_case": true, "glob": "*.py", "path": "{repo}"}}')
|
|
assert "a.py" in r["output"]
|
|
assert "b.txt" not in r["output"]
|
|
|
|
|
|
def test_grep_no_match(repo):
|
|
r = _run("grep", f'{{"pattern": "zzzznotfound", "path": "{repo}"}}')
|
|
assert r["exit_code"] == 0
|
|
assert "No matches" in r["output"]
|
|
|
|
|
|
def test_grep_requires_pattern(repo):
|
|
r = _run("grep", "{}")
|
|
assert r["exit_code"] == 1
|
|
assert "pattern is required" in r["error"]
|
|
|
|
|
|
def test_grep_path_outside_roots_rejected(repo):
|
|
r = _run("grep", '{"pattern": "x", "path": "/etc"}')
|
|
assert r["exit_code"] == 1
|
|
assert "outside the allowed roots" in r["error"]
|
|
|
|
|
|
def test_grep_python_fallback_when_no_rg(repo, monkeypatch):
|
|
monkeypatch.setattr(shutil, "which", lambda name: None)
|
|
r = _run("grep", f'{{"pattern": "needle", "path": "{repo}"}}')
|
|
assert r["exit_code"] == 0
|
|
assert "a.py:2:" in r["output"]
|
|
assert "node_modules" not in r["output"]
|
|
assert ".git/config" not in r["output"]
|
|
|
|
|
|
def test_grep_python_fallback_uses_relative_glob_paths(repo, monkeypatch):
|
|
monkeypatch.setattr(shutil, "which", lambda name: None)
|
|
r = _run(
|
|
"grep",
|
|
f'{{"pattern": "needle|python", "glob": "**/*.py", "path": "{repo}"}}',
|
|
)
|
|
assert r["exit_code"] == 0
|
|
assert "a.py" in r["output"]
|
|
assert "sub/deep/c.py" in r["output"]
|
|
|
|
|
|
@pytest.mark.skipif(shutil.which("rg") is None, reason="targets the ripgrep fast-path")
|
|
def test_grep_skips_case_variant_sensitive_files_rg(repo):
|
|
"""The rg fast-path must exclude deny-listed key files case-insensitively.
|
|
|
|
A file whose name is a case variant of a sensitive pattern (e.g. ID_RSA vs
|
|
id_rsa, Known_Hosts vs known_hosts) points at the same secret on a
|
|
case-insensitive filesystem, so grep must not return its contents. The
|
|
Python fallback already folds case via _is_sensitive_path; a plain --glob
|
|
exclusion is case-sensitive, so it would leak these — this pins the rg path.
|
|
"""
|
|
token = "GREPSECRET_TOKEN_ZZZ"
|
|
with open(os.path.join(repo, "notes.txt"), "w") as f:
|
|
f.write(f"see {token}\n")
|
|
with open(os.path.join(repo, "ID_RSA"), "w") as f:
|
|
f.write(f"PRIVATE {token}\n")
|
|
with open(os.path.join(repo, "Known_Hosts"), "w") as f:
|
|
f.write(f"host {token}\n")
|
|
r = _run("grep", f'{{"pattern": "{token}", "path": "{repo}"}}')
|
|
assert r["exit_code"] == 0
|
|
assert "notes.txt" in r["output"] # ordinary matches still returned
|
|
assert "ID_RSA" not in r["output"] # case-variant key excluded
|
|
assert "Known_Hosts" not in r["output"]
|
|
|
|
|
|
# ── glob ──────────────────────────────────────────────────────────────────
|
|
|
|
def test_glob_py(repo):
|
|
r = _run("glob", f'{{"pattern": "*.py", "path": "{repo}"}}')
|
|
assert r["exit_code"] == 0
|
|
assert "a.py" in r["output"]
|
|
|
|
|
|
def test_glob_recursive_skips_junk(repo):
|
|
r = _run("glob", f'{{"pattern": "**/*.py", "path": "{repo}"}}')
|
|
assert "a.py" in r["output"]
|
|
assert "node_modules" not in r["output"]
|
|
|
|
|
|
def test_glob_requires_pattern(repo):
|
|
r = _run("glob", "{}")
|
|
assert r["exit_code"] == 1
|
|
|
|
|
|
def test_glob_literal_in_subdir(repo):
|
|
"""Bare literal should match at any depth (like rglob), not only at root."""
|
|
r = _run("glob", f'{{"pattern": "b.txt", "path": "{repo}"}}')
|
|
assert r["exit_code"] == 0
|
|
assert "b.txt" in r["output"]
|
|
|
|
|
|
def test_glob_multi_segment_single_star(repo):
|
|
"""sub/*.txt matches sub/b.txt but NOT sub/deep/c.py (single * stays in one segment)."""
|
|
r = _run("glob", f'{{"pattern": "sub/*.txt", "path": "{repo}"}}')
|
|
assert r["exit_code"] == 0
|
|
assert "b.txt" in r["output"]
|
|
assert "c.py" not in r["output"]
|
|
|
|
|
|
def test_glob_star_does_not_cross_slash(repo):
|
|
"""src/*.py must NOT match src/a/b/x.py — * is single-segment only."""
|
|
r = _run("glob", f'{{"pattern": "sub/*.py", "path": "{repo}"}}')
|
|
assert r["exit_code"] == 0
|
|
# sub/ has no .py directly, only sub/deep/c.py — should NOT match
|
|
assert "No files matching" in r["output"]
|
|
|
|
|
|
def test_glob_double_star_matches_deep(repo):
|
|
"""**/*.py should match files at any depth."""
|
|
r = _run("glob", f'{{"pattern": "**/*.py", "path": "{repo}"}}')
|
|
assert r["exit_code"] == 0
|
|
assert "a.py" in r["output"]
|
|
assert "c.py" in r["output"]
|
|
|
|
|
|
# ── ls ────────────────────────────────────────────────────────────────────
|
|
|
|
def test_ls_lists_entries(repo):
|
|
r = _run("ls", f'{{"path": "{repo}"}}')
|
|
assert r["exit_code"] == 0
|
|
assert "a.py" in r["output"]
|
|
assert "sub/" in r["output"]
|
|
assert ".git" not in r["output"] # hidden skipped
|
|
|
|
|
|
def test_ls_path_outside_rejected(repo):
|
|
r = _run("ls", '{"path": "/etc"}')
|
|
assert r["exit_code"] == 1
|
|
assert "outside the allowed roots" in r["error"]
|
|
|
|
|
|
# ── read_file line range ───────────────────────────────────────────────────
|
|
|
|
def test_read_file_offset_limit(repo):
|
|
p = os.path.join(repo, "lines.txt")
|
|
with open(p, "w") as f:
|
|
f.write("\n".join(f"line{i}" for i in range(1, 11)) + "\n")
|
|
r = _run("read_file", f'{{"path": "{p}", "offset": 3, "limit": 2}}')
|
|
assert r["exit_code"] == 0
|
|
assert r["output"] == "line3\nline4\n"
|
|
|
|
|
|
def test_read_file_plain_path_backcompat(repo):
|
|
r = _run("read_file", os.path.join(repo, "a.py"))
|
|
assert r["exit_code"] == 0
|
|
assert "needle" in r["output"]
|