mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-10-06 15:02:20 +02:00
Three of the six recorded failures were the same test bug: an unresolved /tmp path compared against a resolved /private/tmp one. macOS makes /tmp a symlink, so a fixture built with tempfile.mkdtemp(dir="/tmp") and a code path that resolves what it reports disagree about a file both found correctly. test_code_nav_tools builds its fixture unresolved and compares it against the reported path. One realpath fixes both of its failures. test_glob_confined_e2e is the same cause through a longer route: it mixed os.path.realpath(ws) with an unresolved secret directory, so relpath emitted "../../../../tmp/<absolute path>" and the assertion that the absolute path was absent from the output matched it as a substring. Resolving the secret directory puts both sides in one tree and the relative path stays short. macOS full suite goes from 6 failures to 3. The remaining three are an ffmpeg build without a WebP encoder, a socket test that needs a fast connection refusal, and the rich-text colour test that is still unexplained. The ledger is updated in the same change so it does not describe failures that no longer happen.
657 lines
27 KiB
Python
657 lines
27 KiB
Python
"""Workspace confinement.
|
|
|
|
The agent's per-turn workspace is a single context-local binding set in
|
|
execute_tool_block. The shared path resolvers (_resolve_tool_path /
|
|
_resolve_search_root) and the subprocess cwd helper (agent_cwd) read it, so
|
|
confinement is enforced in ONE place: a tool that uses the shared helpers is
|
|
confined automatically and a new tool cannot accidentally bypass it.
|
|
|
|
Covers: the resolver helper, the central binding (the safety net), end-to-end
|
|
confinement of read/write/edit/grep/ls + subprocess cwd via execute_tool_block,
|
|
the get_workspace tool, no-leak across calls, and the admin-gated browse route.
|
|
"""
|
|
import json
|
|
import os
|
|
from pathlib import Path
|
|
import tempfile
|
|
from types import SimpleNamespace
|
|
|
|
import pytest
|
|
|
|
from src.tool_execution import (
|
|
NO_TOOL_SECURITY_CONTEXT,
|
|
_AGENT_WORKDIR,
|
|
_active_workspace,
|
|
_resolve_search_root,
|
|
_resolve_tool_path,
|
|
_resolve_tool_path_in_workspace,
|
|
agent_cwd,
|
|
execute_tool_block as _execute_tool_block,
|
|
get_active_workspace,
|
|
)
|
|
|
|
|
|
async def execute_tool_block(*args, **kwargs):
|
|
kwargs.setdefault("security_context", NO_TOOL_SECURITY_CONTEXT)
|
|
return await _execute_tool_block(*args, **kwargs)
|
|
|
|
|
|
def _block(tool, content=""):
|
|
return SimpleNamespace(tool_type=tool, content=content)
|
|
|
|
|
|
@pytest.fixture
|
|
def ws():
|
|
d = tempfile.mkdtemp()
|
|
with open(os.path.join(d, "a.txt"), "w") as f:
|
|
f.write("x")
|
|
return d
|
|
|
|
|
|
@pytest.fixture
|
|
def admin(monkeypatch):
|
|
"""Pass the public-tool gate so file tools dispatch in tests."""
|
|
monkeypatch.setattr(
|
|
"src.tool_execution.owner_is_admin_or_single_user", lambda owner: True
|
|
)
|
|
|
|
|
|
# ── the resolver helper ────────────────────────────────────────────────
|
|
|
|
def test_resolver_confines(ws):
|
|
real = os.path.realpath(os.path.join(ws, "a.txt"))
|
|
assert _resolve_tool_path_in_workspace(ws, "a.txt") == real # relative
|
|
assert _resolve_tool_path_in_workspace(ws, os.path.join(ws, "a.txt")) == real # abs inside
|
|
outside = tempfile.mkdtemp()
|
|
with pytest.raises(ValueError): # abs outside
|
|
_resolve_tool_path_in_workspace(ws, os.path.join(outside, "x.txt"))
|
|
with pytest.raises(ValueError): # parent escape
|
|
_resolve_tool_path_in_workspace(ws, os.path.join("..", "..", "escape.txt"))
|
|
|
|
|
|
def test_resolver_blocks_sensitive_inside_workspace(ws):
|
|
os.makedirs(os.path.join(ws, ".ssh"), exist_ok=True)
|
|
with pytest.raises(ValueError):
|
|
_resolve_tool_path_in_workspace(ws, ".ssh/authorized_keys")
|
|
|
|
|
|
# ── the central binding: the safety net ─────────────────────────────────
|
|
|
|
def test_active_binding_confines_shared_resolvers(ws):
|
|
"""ANY tool resolving paths through the shared helpers is confined while the
|
|
binding is active, without doing anything workspace-specific itself. This is
|
|
what stops a newly added tool from accidentally ignoring the workspace."""
|
|
token = _active_workspace.set(ws)
|
|
try:
|
|
assert get_active_workspace() == ws
|
|
assert agent_cwd() == ws
|
|
assert _resolve_tool_path("a.txt") == os.path.realpath(os.path.join(ws, "a.txt"))
|
|
with pytest.raises(ValueError): # normally-allowed root, now outside ws
|
|
_resolve_tool_path("/tmp/whatever.txt")
|
|
assert _resolve_search_root("") == os.path.realpath(ws)
|
|
finally:
|
|
_active_workspace.reset(token)
|
|
|
|
|
|
def test_no_binding_uses_default_roots():
|
|
assert get_active_workspace() is None
|
|
assert agent_cwd() == _AGENT_WORKDIR
|
|
with pytest.raises(ValueError):
|
|
_resolve_tool_path("/etc/hosts")
|
|
|
|
|
|
# ── end-to-end via execute_tool_block (sets + resets the binding) ───────
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_read_write_edit_confined_e2e(ws, admin):
|
|
_, r = await execute_tool_block(_block("write_file", "note.txt\nhello"), owner="a", workspace=ws)
|
|
assert r["exit_code"] == 0 and os.path.isfile(os.path.join(ws, "note.txt"))
|
|
_, r = await execute_tool_block(_block("read_file", "note.txt"), owner="a", workspace=ws)
|
|
assert r["exit_code"] == 0 and r["output"] == "hello"
|
|
|
|
with open(os.path.join(ws, "f.txt"), "w") as f:
|
|
f.write("foo bar")
|
|
_, r = await execute_tool_block(
|
|
_block("edit_file", json.dumps({"path": "f.txt", "old_string": "foo", "new_string": "baz"})),
|
|
owner="a", workspace=ws,
|
|
)
|
|
assert r["exit_code"] == 0
|
|
with open(os.path.join(ws, "f.txt")) as f:
|
|
assert f.read() == "baz bar"
|
|
|
|
# outside the workspace is rejected, and nothing is created
|
|
outside = tempfile.mkdtemp()
|
|
of = os.path.join(outside, "secret.txt")
|
|
with open(of, "w") as f:
|
|
f.write("nope")
|
|
_, r = await execute_tool_block(_block("read_file", of), owner="a", workspace=ws)
|
|
assert r["exit_code"] == 1 and "outside the workspace" in r["error"]
|
|
escape = os.path.join(outside, "_esc.txt")
|
|
_, r = await execute_tool_block(_block("write_file", f"{escape}\nx"), owner="a", workspace=ws)
|
|
assert r["exit_code"] == 1 and "outside the workspace" in r["error"]
|
|
assert not os.path.exists(escape)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_apply_patch_confined_e2e(ws, admin):
|
|
with open(os.path.join(ws, "patchme.txt"), "w") as f:
|
|
f.write("alpha\nbeta\ngamma\n")
|
|
patch = """*** Begin Patch
|
|
*** Update File: patchme.txt
|
|
@@
|
|
alpha
|
|
-beta
|
|
+BETA
|
|
gamma
|
|
*** Add File: added.txt
|
|
+new file
|
|
*** End Patch"""
|
|
_, r = await execute_tool_block(_block("apply_patch", patch), owner="a", workspace=ws)
|
|
assert r["exit_code"] == 0
|
|
assert r["diff"]["added"] >= 2
|
|
with open(os.path.join(ws, "patchme.txt")) as f:
|
|
assert f.read() == "alpha\nBETA\ngamma\n"
|
|
with open(os.path.join(ws, "added.txt")) as f:
|
|
assert f.read() == "new file\n"
|
|
|
|
outside = tempfile.mkdtemp()
|
|
outside_file = os.path.join(outside, "x.txt")
|
|
with open(outside_file, "w") as f:
|
|
f.write("x\n")
|
|
escape_patch = f"""*** Begin Patch
|
|
*** Update File: {outside_file}
|
|
@@
|
|
-x
|
|
+y
|
|
*** End Patch"""
|
|
_, r = await execute_tool_block(_block("apply_patch", escape_patch), owner="a", workspace=ws)
|
|
assert r["exit_code"] == 1 and "outside the workspace" in r["error"]
|
|
with open(outside_file) as f:
|
|
assert f.read() == "x\n"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_todowrite_persists_session_list(tmp_path, monkeypatch, admin):
|
|
import src.agent_tools.coding_tools as coding_tools
|
|
|
|
monkeypatch.setattr(coding_tools, "_TODO_DIR", str(tmp_path))
|
|
payload = {
|
|
"todos": [
|
|
{"content": "Inspect code", "status": "completed", "priority": "high"},
|
|
{"content": "Patch code", "status": "in_progress", "priority": "high"},
|
|
]
|
|
}
|
|
_, r = await execute_tool_block(
|
|
_block("todowrite", json.dumps(payload)),
|
|
session_id="chat/one",
|
|
owner="a",
|
|
workspace=str(tmp_path),
|
|
)
|
|
assert r["exit_code"] == 0
|
|
assert "[>] Patch code" in r["output"]
|
|
saved = json.load(open(tmp_path / "chat_one.json", encoding="utf-8"))
|
|
assert saved["todos"][1]["status"] == "in_progress"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_grep_and_ls_confined_e2e(ws, admin):
|
|
with open(os.path.join(ws, "doc.txt"), "w") as f:
|
|
f.write("hello workspace\n")
|
|
_, r = await execute_tool_block(_block("grep", json.dumps({"pattern": "hello"})), owner="a", workspace=ws)
|
|
assert r["exit_code"] == 0 and "doc.txt" in r["output"]
|
|
assert ws not in r["output"]
|
|
assert "/workspace/doc.txt" in r["output"]
|
|
outside = tempfile.mkdtemp()
|
|
_, r = await execute_tool_block(_block("grep", json.dumps({"pattern": "x", "path": outside})), owner="a", workspace=ws)
|
|
assert r["exit_code"] == 1 and "outside the workspace" in r["error"]
|
|
_, r = await execute_tool_block(_block("ls", ""), owner="a", workspace=ws)
|
|
assert r["exit_code"] == 0 and "doc.txt" in r["output"]
|
|
assert ws not in r["output"]
|
|
assert r["output"].startswith("/workspace:")
|
|
_, r = await execute_tool_block(_block("ls", outside), owner="a", workspace=ws)
|
|
assert r["exit_code"] == 1 and "outside the workspace" in r["error"]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_glob_confined_e2e(ws, admin):
|
|
"""glob's literal fast-path must stay inside the workspace. A pattern with
|
|
../ or an absolute path outside the root would otherwise leak the existence
|
|
and full path of arbitrary host files (an oracle), even though read_file
|
|
blocks reading them."""
|
|
with open(os.path.join(ws, "found.py"), "w") as f:
|
|
f.write("x")
|
|
_, r = await execute_tool_block(_block("glob", json.dumps({"pattern": "found.py"})), owner="a", workspace=ws)
|
|
assert r["exit_code"] == 0 and "found.py" in r["output"]
|
|
assert ws not in r["output"]
|
|
assert "/workspace/found.py" in r["output"]
|
|
|
|
# a secret outside the workspace must not be discoverable via glob.
|
|
# realpath so this directory and os.path.realpath(ws) below sit in the same
|
|
# resolved tree. On macOS /tmp is a symlink to /private/tmp, and mixing a
|
|
# resolved workspace with an unresolved secret makes relpath emit
|
|
# "../../../../tmp/<abs path>", which trivially contains the absolute path
|
|
# the assertion is checking for.
|
|
outside = os.path.realpath(tempfile.mkdtemp())
|
|
secret = os.path.join(outside, "secret.txt")
|
|
with open(secret, "w") as f:
|
|
f.write("nope")
|
|
# An escaping pattern must come back as "No files" (the not-found message),
|
|
# not as a match that returns the file's path. The not-found message echoes
|
|
# the pattern the model supplied, so the signal is the absence of a match,
|
|
# not the absence of the path string.
|
|
rel = os.path.relpath(secret, os.path.realpath(ws))
|
|
_, r = await execute_tool_block(_block("glob", json.dumps({"pattern": rel})), owner="a", workspace=ws)
|
|
assert r["exit_code"] == 0 and "No files" in r["output"] and secret not in r["output"]
|
|
_, r = await execute_tool_block(_block("glob", json.dumps({"pattern": secret})), owner="a", workspace=ws)
|
|
assert r["exit_code"] == 0 and "No files" in r["output"]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_glob_skips_sensitive_files_in_workspace(ws, admin):
|
|
"""glob must not enumerate deny-listed sensitive files that live inside the
|
|
workspace. read_file/write_file/edit_file refuse them and grep skips them,
|
|
so glob surfacing their paths is an enumeration oracle for prompt-injection.
|
|
"""
|
|
with open(os.path.join(ws, "keep.py"), "w") as f:
|
|
f.write("x")
|
|
with open(os.path.join(ws, ".env"), "w") as f:
|
|
f.write("AWS_SECRET=xxx")
|
|
with open(os.path.join(ws, "id_rsa"), "w") as f: # non-dotfile key at root
|
|
f.write("KEY")
|
|
os.makedirs(os.path.join(ws, ".ssh"), exist_ok=True)
|
|
with open(os.path.join(ws, ".ssh", "authorized_keys"), "w") as f:
|
|
f.write("ssh-rsa AAAA")
|
|
|
|
# A recursive wildcard returns ordinary files but none of the sensitive
|
|
# ones. The pattern "**/*" contains no secret names, so a secret basename
|
|
# appearing in the output is a real leak (not the echoed not-found pattern).
|
|
_, r = await execute_tool_block(_block("glob", json.dumps({"pattern": "**/*"})), owner="a", workspace=ws)
|
|
assert r["exit_code"] == 0
|
|
assert "keep.py" in r["output"]
|
|
for leak in (".env", "id_rsa", "authorized_keys"):
|
|
assert leak not in r["output"], f"glob leaked sensitive file: {leak}"
|
|
|
|
# Directly targeting a sensitive file (literal fast-path and wildcard) must
|
|
# come back as the not-found message, never a match with the file's path.
|
|
for pat in (".env", "**/id_rsa", "**/authorized_keys"):
|
|
_, r = await execute_tool_block(_block("glob", json.dumps({"pattern": pat})), owner="a", workspace=ws)
|
|
assert r["exit_code"] == 0 and "No files" in r["output"]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_subprocess_cwd_is_workspace_e2e(ws, admin):
|
|
"""python tool runs with cwd = workspace (OS-agnostic probe)."""
|
|
_, r = await execute_tool_block(_block("python", "import os; print(os.getcwd())"), owner="a", workspace=ws)
|
|
assert r["exit_code"] == 0
|
|
assert os.path.realpath(r["output"].strip()) == os.path.realpath(ws)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_python_virtual_workspace_path_works_for_tmp_workspace(ws, admin):
|
|
"""The bwrap /tmp tmpfs must not hide a task workspace bound at /workspace."""
|
|
_, result = await execute_tool_block(
|
|
_block(
|
|
"python",
|
|
"from pathlib import Path; Path('/workspace/native.txt').write_text('ok')",
|
|
),
|
|
owner="a",
|
|
workspace=ws,
|
|
)
|
|
assert result["exit_code"] == 0, result
|
|
assert Path(ws, "native.txt").read_text() == "ok"
|
|
|
|
|
|
# ── get_workspace tool ──────────────────────────────────────────────────
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_workspace_tool(ws, admin):
|
|
_, r = await execute_tool_block(_block("get_workspace", ""), owner="a", workspace=ws)
|
|
assert r["exit_code"] == 0 and r["output"].startswith("/workspace") and "not sandboxed" in r["output"]
|
|
assert ws not in r["output"]
|
|
_, r = await execute_tool_block(_block("get_workspace", ""), owner="a") # none active
|
|
assert r["exit_code"] == 0 and "No workspace" in r["output"]
|
|
|
|
|
|
# ── no leak across calls ────────────────────────────────────────────────
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_binding_does_not_leak(ws, admin):
|
|
await execute_tool_block(_block("ls", ""), owner="a", workspace=ws)
|
|
assert get_active_workspace() is None
|
|
|
|
|
|
# ── tool selection: an active workspace is the file-work signal ─────────
|
|
# A vague ("low-signal") message like "look at the local project" matches no
|
|
# domain keywords, so retrieval is normally skipped. When a workspace is set it
|
|
# must still surface the file tools, otherwise the agent says it has no file
|
|
# access (the bug this guards against).
|
|
|
|
def _sent_tool_names(monkeypatch, *, workspace, message="look at the local project", force_keyword_fallback=False):
|
|
import asyncio
|
|
import src.agent_loop as al
|
|
|
|
monkeypatch.setattr(al, "get_setting", lambda key, default=None: default, raising=False)
|
|
monkeypatch.setattr(al, "get_mcp_manager", lambda: None, raising=False)
|
|
monkeypatch.setattr(al, "estimate_tokens", lambda *a, **k: 10, raising=False)
|
|
# Isolate the selection logic from owner gating (tested separately).
|
|
monkeypatch.setattr(al, "blocked_tools_for_owner", lambda owner: set(), raising=False)
|
|
if force_keyword_fallback:
|
|
import src.tool_index as ti
|
|
|
|
def _raise_get_tool_index():
|
|
raise RuntimeError("skip vector retrieval")
|
|
|
|
monkeypatch.setattr(ti, "get_tool_index", _raise_get_tool_index, raising=False)
|
|
|
|
captured = []
|
|
|
|
async def _fake_stream(_candidates, messages, **kwargs):
|
|
captured.append(kwargs.get("tools"))
|
|
yield "data: " + json.dumps({"delta": "ok"}) + "\n\n"
|
|
yield "data: [DONE]\n\n"
|
|
|
|
monkeypatch.setattr(al, "stream_llm_with_fallback", _fake_stream, raising=False)
|
|
|
|
async def _run():
|
|
gen = al.stream_agent_loop(
|
|
"https://api.openai.com/v1", "gpt-test",
|
|
[{"role": "user", "content": message}],
|
|
max_rounds=1, relevant_tools=None, owner="admin", workspace=workspace,
|
|
)
|
|
return [c async for c in gen]
|
|
|
|
asyncio.run(_run())
|
|
schemas = captured[0] or []
|
|
return {t["function"]["name"] for t in schemas if isinstance(t, dict) and "function" in t}
|
|
|
|
|
|
def test_low_signal_with_workspace_surfaces_readonly_file_tools(monkeypatch):
|
|
names = _sent_tool_names(monkeypatch, workspace="/tmp")
|
|
# read-only nav tools surface so the agent can explore
|
|
assert "read_file" in names
|
|
assert "get_workspace" in names
|
|
assert "grep" in names
|
|
# write/shell tools do NOT surface on a vague message
|
|
assert "write_file" not in names
|
|
assert "edit_file" not in names
|
|
assert "bash" not in names
|
|
assert "python" not in names
|
|
|
|
|
|
def test_workspace_coding_request_surfaces_edit_and_verify_tools(monkeypatch):
|
|
names = _sent_tool_names(
|
|
monkeypatch,
|
|
workspace="/tmp",
|
|
message="fix the failing frontend test in this repo",
|
|
force_keyword_fallback=True,
|
|
)
|
|
assert "get_workspace" in names
|
|
assert "read_file" in names
|
|
assert "grep" in names
|
|
assert "edit_file" in names
|
|
assert "write_file" in names
|
|
assert "apply_patch" in names
|
|
assert "todowrite" in names
|
|
assert "bash" in names
|
|
assert "python" in names
|
|
|
|
|
|
def test_low_signal_without_workspace_excludes_file_tools(monkeypatch):
|
|
names = _sent_tool_names(monkeypatch, workspace=None)
|
|
assert "read_file" not in names
|
|
assert "get_workspace" not in names
|
|
|
|
|
|
def test_explicit_workspace_request_without_workspace_stops(monkeypatch):
|
|
import asyncio
|
|
import src.agent_loop as al
|
|
|
|
monkeypatch.setattr(al, "get_setting", lambda key, default=None: default, raising=False)
|
|
monkeypatch.setattr(al, "get_mcp_manager", lambda: None, raising=False)
|
|
monkeypatch.setattr(al, "estimate_tokens", lambda *a, **k: 10, raising=False)
|
|
monkeypatch.setattr(al, "blocked_tools_for_owner", lambda owner: set(), raising=False)
|
|
|
|
async def _should_not_stream(*args, **kwargs):
|
|
raise AssertionError("LLM should not be called when explicit workspace is missing")
|
|
yield ""
|
|
|
|
monkeypatch.setattr(al, "stream_llm_with_fallback", _should_not_stream, raising=False)
|
|
|
|
async def _run():
|
|
gen = al.stream_agent_loop(
|
|
"https://api.openai.com/v1", "gpt-test",
|
|
[{"role": "user", "content": "In this workspace, fix a typo and verify it."}],
|
|
max_rounds=1, relevant_tools=None, owner="admin", workspace=None,
|
|
)
|
|
return [c async for c in gen]
|
|
|
|
chunks = asyncio.run(_run())
|
|
text = "".join(chunks)
|
|
assert "No active workspace is set" in text
|
|
assert "/workspace set /absolute/path" in text
|
|
assert '"missing_workspace": true' in text
|
|
|
|
|
|
def test_tui_host_workspace_does_not_trigger_missing_workspace_shortcut():
|
|
import src.agent_loop as al
|
|
|
|
assert not al._explicitly_references_missing_workspace(
|
|
"In this workspace, inspect the project.",
|
|
None,
|
|
client_runtime_context={
|
|
"surface": "odysseus-tui",
|
|
"session_cwd": "/home/tester/project",
|
|
"host_shell_bridge": {"url": "http://127.0.0.1:47475/run", "token": "x"},
|
|
},
|
|
)
|
|
|
|
|
|
def test_webui_workspace_command_accepts_short_path_form():
|
|
text = Path("static/js/slashCommands.js").read_text(encoding="utf-8")
|
|
assert "Match the TUI's short form" in text
|
|
assert "return setWorkspace(args.join(' ').trim())" in text
|
|
|
|
|
|
def test_workspace_coding_mode_prompt_is_injected(monkeypatch):
|
|
import src.agent_loop as al
|
|
|
|
monkeypatch.setattr(al, "get_setting", lambda key, default=None: default, raising=False)
|
|
monkeypatch.setattr(al, "get_mcp_manager", lambda: None, raising=False)
|
|
monkeypatch.setattr(al, "blocked_tools_for_owner", lambda owner: set(), raising=False)
|
|
al._cached_base_prompt = None
|
|
al._cached_base_prompt_key = None
|
|
|
|
messages, _ = al._build_system_prompt(
|
|
messages=[{"role": "user", "content": "fix the bug"}],
|
|
model="gpt-test",
|
|
active_document=None,
|
|
mcp_mgr=None,
|
|
relevant_tools={"get_workspace", "read_file", "grep", "edit_file", "write_file", "apply_patch", "todowrite", "bash"},
|
|
workspace="/tmp/example-repo",
|
|
)
|
|
system_text = "\n\n".join(m.get("content", "") for m in messages if m.get("role") == "system")
|
|
assert "## Workspace coding mode" in system_text
|
|
assert "Active workspace: `/tmp/example-repo`" in system_text
|
|
assert "call `todowrite`" in system_text
|
|
assert "Change repo files with `apply_patch`" in system_text
|
|
|
|
|
|
# ── browse route is admin-gated ─────────────────────────────────────────
|
|
|
|
def test_browse_is_admin_gated(monkeypatch):
|
|
from fastapi import HTTPException
|
|
import routes.workspace_routes as wr
|
|
|
|
router = wr.setup_workspace_routes()
|
|
browse = next(r.endpoint for r in router.routes if r.path == "/api/workspace/browse")
|
|
|
|
monkeypatch.setattr(wr, "get_current_user", lambda req: "bob")
|
|
monkeypatch.setattr(wr, "owner_is_admin_or_single_user", lambda owner: False)
|
|
with pytest.raises(HTTPException) as ei:
|
|
browse(request=object(), path="/")
|
|
assert ei.value.status_code == 403
|
|
|
|
monkeypatch.setattr(wr, "owner_is_admin_or_single_user", lambda owner: True)
|
|
out = browse(request=object(), path=os.path.expanduser("~"))
|
|
assert "dirs" in out and "path" in out
|
|
assert all("name" in d and "path" in d for d in out["dirs"])
|
|
|
|
|
|
# ── bind-time vetting of the workspace root ─────────────────────────────
|
|
|
|
def test_vet_workspace_accepts_normal_dir(ws):
|
|
from src.tool_execution import vet_workspace
|
|
assert vet_workspace(ws) == os.path.realpath(ws)
|
|
|
|
|
|
def test_vet_workspace_rejects_sensitive_root(tmp_path):
|
|
# The resolver deny-lists sensitive paths inside the workspace, but the
|
|
# empty-path search root is the workspace itself - a sensitive root must
|
|
# be rejected before it is bound or `ls` with no path would list it.
|
|
from src.tool_execution import vet_workspace
|
|
ssh_dir = tmp_path / ".ssh"
|
|
ssh_dir.mkdir()
|
|
assert vet_workspace(str(ssh_dir)) is None
|
|
|
|
|
|
def test_vet_workspace_rejects_nondir_and_empty(ws):
|
|
from src.tool_execution import vet_workspace
|
|
assert vet_workspace(os.path.join(ws, "a.txt")) is None # file, not dir
|
|
assert vet_workspace("/nonexistent/path/xyz") is None
|
|
assert vet_workspace("") is None
|
|
assert vet_workspace(" ") is None
|
|
|
|
|
|
def test_vet_workspace_rejects_filesystem_root():
|
|
# Binding / would make every absolute path "inside" the workspace,
|
|
# collapsing confinement into host-wide file access.
|
|
from src.tool_execution import vet_workspace
|
|
assert vet_workspace("/") is None
|
|
|
|
|
|
def test_browse_marks_root_unselectable_and_vet_endpoint(monkeypatch):
|
|
import routes.workspace_routes as wr
|
|
|
|
router = wr.setup_workspace_routes()
|
|
browse = next(r.endpoint for r in router.routes if r.path == "/api/workspace/browse")
|
|
vet = next(r.endpoint for r in router.routes if r.path == "/api/workspace/vet")
|
|
default = next(r.endpoint for r in router.routes if r.path == "/api/workspace/default")
|
|
|
|
monkeypatch.setattr(wr, "get_current_user", lambda req: "admin")
|
|
monkeypatch.setattr(wr, "owner_is_admin_or_single_user", lambda owner: True)
|
|
|
|
out = browse(request=object(), path="/")
|
|
assert out["selectable"] is False
|
|
out = browse(request=object(), path=os.path.expanduser("~"))
|
|
assert out["selectable"] is True
|
|
|
|
assert vet(request=object(), path="/") == {"ok": False, "path": None}
|
|
home = os.path.realpath(os.path.expanduser("~"))
|
|
assert vet(request=object(), path="~") == {"ok": True, "path": home}
|
|
|
|
from fastapi import HTTPException
|
|
monkeypatch.setattr(wr, "owner_is_admin_or_single_user", lambda owner: False)
|
|
with pytest.raises(HTTPException) as ei:
|
|
vet(request=object(), path="/tmp")
|
|
assert ei.value.status_code == 403
|
|
|
|
monkeypatch.setattr(wr, "owner_is_admin_or_single_user", lambda owner: True)
|
|
monkeypatch.setenv("ODYSSEUS_WORKSPACE_DEFAULT", home)
|
|
assert default(request=object()) == {"ok": True, "path": home}
|
|
|
|
monkeypatch.setenv("ODYSSEUS_WORKSPACE_DEFAULT", "/")
|
|
assert default(request=object()) == {"ok": False, "path": None}
|
|
|
|
|
|
# ── send-time privilege gate (no path oracle for non-admins) ────────────
|
|
|
|
def test_request_workspace_gate(ws, monkeypatch):
|
|
"""Non-admin chat callers must get a uniform drop with no vetting: the
|
|
workspace_rejected signal would otherwise reveal which host paths exist."""
|
|
import routes.chat_routes as cr
|
|
|
|
monkeypatch.setattr(cr, "get_current_user", lambda req: "bob")
|
|
vet_calls = []
|
|
import src.tool_execution as te
|
|
real_vet = te.vet_workspace
|
|
monkeypatch.setattr(te, "vet_workspace", lambda p: vet_calls.append(p) or real_vet(p))
|
|
|
|
import src.tool_security as ts
|
|
monkeypatch.setattr(ts, "owner_is_admin_or_single_user", lambda owner: False)
|
|
# Valid and invalid paths are indistinguishable for a non-admin: both
|
|
# drop silently, and the path never reaches the filesystem.
|
|
assert cr._resolve_request_workspace(object(), ws) == ("", "")
|
|
assert cr._resolve_request_workspace(object(), "/nonexistent/xyz") == ("", "")
|
|
assert vet_calls == []
|
|
|
|
monkeypatch.setattr(ts, "owner_is_admin_or_single_user", lambda owner: True)
|
|
assert cr._resolve_request_workspace(object(), ws) == (os.path.realpath(ws), "")
|
|
assert cr._resolve_request_workspace(object(), "/nonexistent/xyz") == ("", "/nonexistent/xyz")
|
|
|
|
|
|
def test_request_workspace_gate_uses_api_token_owner(ws, monkeypatch):
|
|
"""Bearer clients must bind workspaces using their real owner, not api."""
|
|
import routes.chat_routes as cr
|
|
import src.tool_security as ts
|
|
|
|
request = SimpleNamespace(
|
|
state=SimpleNamespace(
|
|
current_user="api",
|
|
api_token=True,
|
|
api_token_owner="admin",
|
|
)
|
|
)
|
|
seen = []
|
|
monkeypatch.setattr(ts, "owner_is_admin_or_single_user", lambda owner: seen.append(owner) or True)
|
|
|
|
assert cr._resolve_request_workspace(request, ws) == (os.path.realpath(ws), "")
|
|
assert seen == ["admin"]
|
|
|
|
|
|
def test_persisted_session_workspace_falls_back_to_cwd(monkeypatch):
|
|
import routes.chat_routes as cr
|
|
|
|
calls = []
|
|
|
|
def fake_resolve(_request, raw_value):
|
|
calls.append(raw_value)
|
|
return os.path.realpath(raw_value), ""
|
|
|
|
monkeypatch.setattr(cr, "_resolve_request_workspace", fake_resolve)
|
|
|
|
workspace, rejected = cr._resolve_persisted_session_workspace(
|
|
object(),
|
|
SimpleNamespace(cwd="/tmp/project"),
|
|
current_workspace="",
|
|
current_rejected="",
|
|
)
|
|
|
|
assert workspace == os.path.realpath("/tmp/project")
|
|
assert rejected == ""
|
|
assert calls == ["/tmp/project"]
|
|
|
|
|
|
def test_persisted_session_workspace_does_not_override_request_result(monkeypatch):
|
|
import routes.chat_routes as cr
|
|
|
|
calls = []
|
|
monkeypatch.setattr(
|
|
cr,
|
|
"_resolve_request_workspace",
|
|
lambda _request, raw_value: calls.append(raw_value) or ("/tmp/cwd", ""),
|
|
)
|
|
|
|
assert cr._resolve_persisted_session_workspace(
|
|
object(),
|
|
SimpleNamespace(cwd="/tmp/project"),
|
|
current_workspace="/tmp/requested",
|
|
current_rejected="",
|
|
) == ("/tmp/requested", "")
|
|
assert cr._resolve_persisted_session_workspace(
|
|
object(),
|
|
SimpleNamespace(cwd="/tmp/project"),
|
|
current_workspace="",
|
|
current_rejected="/bad/request",
|
|
) == ("", "/bad/request")
|
|
assert calls == []
|