1557 lines
54 KiB
Python
1557 lines
54 KiB
Python
from __future__ import annotations
|
|
|
|
import json
|
|
from collections.abc import Iterator
|
|
from contextlib import contextmanager
|
|
from pathlib import Path
|
|
from types import SimpleNamespace
|
|
|
|
import pytest
|
|
|
|
from opensquilla.gateway.approval_queue import get_approval_queue, reset_approval_queue
|
|
from opensquilla.sandbox.config import SandboxSettings
|
|
from opensquilla.sandbox.integration import configure_runtime, get_runtime, reset_runtime
|
|
from opensquilla.sandbox.operation_runtime import SandboxOperation, SandboxOperationResult
|
|
from opensquilla.sandbox.path_validation import decide_path_access
|
|
from opensquilla.sandbox.run_context import RunContext
|
|
from opensquilla.sandbox.run_mode import RunMode
|
|
from opensquilla.sandbox.types import SandboxRequest
|
|
from opensquilla.tools.builtin import filesystem as fs
|
|
from opensquilla.tools.builtin import shell
|
|
from opensquilla.tools.types import CallerKind, ToolContext, current_tool_context
|
|
|
|
|
|
class _InlineExecutorLoop:
|
|
async def run_in_executor(self, executor: object, func: object, *args: object) -> object:
|
|
return func(*args) # type: ignore[operator]
|
|
|
|
|
|
class _FilesystemBackend:
|
|
name = "filesystem_backend"
|
|
|
|
def operation_domains_supported(self) -> frozenset[str]:
|
|
return frozenset({"filesystem"})
|
|
|
|
async def run_operation(self, operation: SandboxOperation) -> SandboxOperationResult:
|
|
request = getattr(operation, "request", None)
|
|
path = getattr(request, "path", None)
|
|
if path is None:
|
|
raise AssertionError("filesystem operation missing path")
|
|
if operation.kind == "read_file":
|
|
if not path.exists():
|
|
raise FileNotFoundError(f"File not found: {path}")
|
|
return SandboxOperationResult(message=path.read_text(encoding="utf-8"))
|
|
if operation.kind == "list_dir":
|
|
if not path.exists():
|
|
raise FileNotFoundError(f"Path not found: {path}")
|
|
entries = []
|
|
for entry in sorted(path.iterdir(), key=lambda item: item.name):
|
|
if entry.is_dir():
|
|
entries.append(f"[dir] {entry.name}/")
|
|
else:
|
|
entries.append(f"[file] {entry.name} ({entry.stat().st_size} bytes)")
|
|
return SandboxOperationResult(
|
|
message="\n".join(entries) if entries else f"{path}: (empty directory)"
|
|
)
|
|
if operation.kind == "write_text":
|
|
created = not path.exists()
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
path.write_text(request.content, encoding="utf-8")
|
|
return SandboxOperationResult(
|
|
message=f"Written {len(request.content)} bytes to {path}",
|
|
created=created,
|
|
)
|
|
if operation.kind == "edit_text":
|
|
original = path.read_text(encoding="utf-8")
|
|
updated = original.replace(request.old_text, request.new_text, 1)
|
|
path.write_text(updated, encoding="utf-8")
|
|
return SandboxOperationResult(
|
|
message=(
|
|
f"Edited {path}: replaced {len(request.old_text)} chars "
|
|
f"with {len(request.new_text)} chars"
|
|
)
|
|
)
|
|
if operation.kind == "grep_search":
|
|
matches = []
|
|
for entry in sorted(path.rglob("*")):
|
|
if entry.is_symlink() or not entry.is_file():
|
|
continue
|
|
try:
|
|
text = entry.read_text(encoding="utf-8")
|
|
except UnicodeDecodeError:
|
|
continue
|
|
for line_no, line in enumerate(text.splitlines(), start=1):
|
|
if request.pattern in line:
|
|
matches.append(f"{entry}:{line_no}:{line}")
|
|
return SandboxOperationResult(
|
|
message="\n".join(matches) if matches else "No matches"
|
|
)
|
|
raise AssertionError(f"unsupported filesystem operation: {operation.kind}")
|
|
|
|
|
|
def _install_filesystem_read_backend() -> None:
|
|
runtime = get_runtime()
|
|
assert runtime is not None
|
|
runtime.backend = _FilesystemBackend()
|
|
|
|
|
|
@contextmanager
|
|
def tool_context(
|
|
workspace: Path,
|
|
*,
|
|
run_mode: str | None = "standard",
|
|
sandbox_mounts: list[dict[str, object]] | None = None,
|
|
workspace_strict: bool = False,
|
|
) -> Iterator[ToolContext]:
|
|
ctx = ToolContext(
|
|
is_owner=True,
|
|
caller_kind=CallerKind.CLI,
|
|
workspace_dir=str(workspace),
|
|
workspace_strict=workspace_strict,
|
|
run_mode=run_mode,
|
|
session_key="s1",
|
|
sandbox_mounts=sandbox_mounts or [],
|
|
)
|
|
token = current_tool_context.set(ctx)
|
|
try:
|
|
yield ctx
|
|
finally:
|
|
current_tool_context.reset(token)
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def sandbox_runtime(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Iterator[None]:
|
|
from opensquilla.application import approval_queue as approval_queue_mod
|
|
|
|
monkeypatch.setattr(
|
|
approval_queue_mod,
|
|
"_DEFAULT_APPROVAL_QUEUE_PATH",
|
|
tmp_path / "approval_queue.sqlite",
|
|
)
|
|
reset_approval_queue()
|
|
workspace = tmp_path / "workspace"
|
|
workspace.mkdir(exist_ok=True)
|
|
runtime = configure_runtime(
|
|
SandboxSettings(run_mode="standard", backend="noop", allow_legacy_mode=True),
|
|
workspace=workspace,
|
|
)
|
|
runtime.backend = _FilesystemBackend()
|
|
try:
|
|
yield
|
|
finally:
|
|
reset_approval_queue()
|
|
reset_runtime()
|
|
|
|
|
|
def test_normal_sibling_path_requests_ro_mount(tmp_path: Path) -> None:
|
|
workspace = tmp_path / "workspace"
|
|
sibling = tmp_path / "sibling" / "notes.txt"
|
|
|
|
decision = decide_path_access(sibling, workspace=workspace)
|
|
|
|
assert decision.status == "request"
|
|
assert decision.access == "ro"
|
|
assert decision.normalized_path == str(sibling.resolve(strict=False))
|
|
|
|
|
|
def test_sensitive_ssh_path_is_blocked(tmp_path: Path) -> None:
|
|
workspace = tmp_path / "workspace"
|
|
target = Path.home() / ".ssh" / "id_rsa"
|
|
|
|
decision = decide_path_access(target, workspace=workspace)
|
|
|
|
assert decision.status == "blocked"
|
|
assert decision.reason == "sensitive_path"
|
|
|
|
|
|
def test_workspace_child_is_allowed(tmp_path: Path) -> None:
|
|
workspace = tmp_path / "workspace"
|
|
target = workspace / "src" / "app.py"
|
|
|
|
decision = decide_path_access(target, workspace=workspace)
|
|
|
|
assert decision.status == "allowed"
|
|
assert decision.access == "ro"
|
|
|
|
|
|
def test_default_container_workspace_child_is_allowed_before_root_block() -> None:
|
|
workspace = "/root/.opensquilla/workspace"
|
|
target = "/root/.opensquilla/workspace/project/src/app.py"
|
|
|
|
decision = decide_path_access(target, workspace=workspace)
|
|
|
|
assert decision.status == "allowed"
|
|
assert decision.access == "ro"
|
|
|
|
|
|
def test_sensitive_file_inside_default_container_workspace_stays_blocked() -> None:
|
|
workspace = "/root/.opensquilla/workspace"
|
|
target = "/root/.opensquilla/workspace/project/.env.local"
|
|
|
|
decision = decide_path_access(target, workspace=workspace)
|
|
|
|
assert decision.status == "blocked"
|
|
assert decision.reason == "sensitive_path"
|
|
|
|
|
|
def test_write_request_asks_for_rw_mount(tmp_path: Path) -> None:
|
|
workspace = tmp_path / "workspace"
|
|
sibling = tmp_path / "sibling" / "notes.txt"
|
|
|
|
decision = decide_path_access(sibling, workspace=workspace, write=True)
|
|
|
|
assert decision.status == "request"
|
|
assert decision.access == "rw"
|
|
|
|
|
|
def test_request_path_builds_structured_mount_escalation_choices(tmp_path: Path) -> None:
|
|
from opensquilla.sandbox.escalation import build_path_approval_params
|
|
|
|
workspace = tmp_path / "workspace"
|
|
sibling = tmp_path / "sibling" / "notes.txt"
|
|
decision = decide_path_access(sibling, workspace=workspace, write=True)
|
|
|
|
proposal = build_path_approval_params(
|
|
decision,
|
|
session_key="agent:main:webchat:abc",
|
|
workspace=str(workspace),
|
|
)
|
|
|
|
assert proposal is not None
|
|
assert proposal["approvalKind"] == "sandbox_path"
|
|
assert proposal["path"] == str(sibling.resolve(strict=False))
|
|
assert proposal["access"] == "rw"
|
|
assert [choice["id"] for choice in proposal["choices"]] == [
|
|
"allow_once",
|
|
"allow_same_type",
|
|
"deny",
|
|
]
|
|
assert [choice["label"] for choice in proposal["choices"]] == [
|
|
"Allow once",
|
|
"Allow same type",
|
|
"Deny",
|
|
]
|
|
assert proposal["choices"][0]["style"] == "primary"
|
|
|
|
|
|
def test_blocked_path_has_no_mount_escalation_choices(tmp_path: Path) -> None:
|
|
from opensquilla.sandbox.escalation import build_path_approval_params
|
|
|
|
workspace = tmp_path / "workspace"
|
|
decision = decide_path_access("/", workspace=workspace, write=False)
|
|
|
|
assert decision.status == "blocked"
|
|
assert build_path_approval_params(
|
|
decision,
|
|
session_key="agent:main:webchat:abc",
|
|
workspace=str(workspace),
|
|
) is None
|
|
|
|
|
|
def test_most_specific_rw_mount_allows_write_under_ro_parent(tmp_path: Path) -> None:
|
|
workspace = tmp_path / "workspace"
|
|
parent = tmp_path / "parent"
|
|
child = parent / "child"
|
|
target = child / "out.txt"
|
|
|
|
decision = decide_path_access(
|
|
target,
|
|
workspace=workspace,
|
|
mounts=[
|
|
{"path": str(parent), "access": "ro"},
|
|
{"path": str(child), "access": "rw"},
|
|
],
|
|
write=True,
|
|
)
|
|
|
|
assert decision.status == "allowed"
|
|
assert decision.access == "rw"
|
|
|
|
|
|
def test_most_specific_ro_mount_requests_write_under_rw_parent(tmp_path: Path) -> None:
|
|
workspace = tmp_path / "workspace"
|
|
parent = tmp_path / "parent"
|
|
child = parent / "child"
|
|
target = child / "out.txt"
|
|
|
|
decision = decide_path_access(
|
|
target,
|
|
workspace=workspace,
|
|
mounts=[
|
|
{"path": str(parent), "access": "rw"},
|
|
{"path": str(child), "access": "ro"},
|
|
],
|
|
write=True,
|
|
)
|
|
|
|
assert decision.status == "request"
|
|
assert decision.access == "rw"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_existing_ro_mount_allows_filesystem_read_and_list(tmp_path: Path) -> None:
|
|
workspace = tmp_path / "workspace"
|
|
workspace.mkdir(exist_ok=True)
|
|
mounted = tmp_path / "mounted"
|
|
mounted.mkdir()
|
|
missing_file = mounted / "missing.txt"
|
|
missing_dir = mounted / "missing-dir"
|
|
|
|
with tool_context(
|
|
workspace,
|
|
sandbox_mounts=[{"path": str(mounted), "access": "ro"}],
|
|
):
|
|
with pytest.raises(FileNotFoundError):
|
|
await fs.read_file(str(missing_file))
|
|
with pytest.raises(FileNotFoundError):
|
|
await fs.list_dir(str(missing_dir))
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_existing_ro_mount_allows_list_dir_when_workspace_strict(
|
|
tmp_path: Path,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
workspace = tmp_path / "workspace"
|
|
workspace.mkdir(exist_ok=True)
|
|
mounted = tmp_path / "mounted"
|
|
mounted.mkdir()
|
|
(mounted / "notes.txt").write_text("hello\n", encoding="utf-8")
|
|
monkeypatch.setattr(fs.asyncio, "get_event_loop", lambda: _InlineExecutorLoop())
|
|
|
|
with tool_context(
|
|
workspace,
|
|
sandbox_mounts=[{"path": str(mounted), "access": "ro"}],
|
|
workspace_strict=True,
|
|
):
|
|
result = await fs.list_dir(str(mounted))
|
|
|
|
assert "notes.txt" in result
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_filesystem_read_outside_workspace_requests_ro_mount(tmp_path: Path) -> None:
|
|
workspace = tmp_path / "workspace"
|
|
workspace.mkdir(exist_ok=True)
|
|
outside = tmp_path / "outside" / "notes.txt"
|
|
outside.parent.mkdir()
|
|
outside.write_text("outside body\n", encoding="utf-8")
|
|
|
|
with tool_context(workspace):
|
|
payload = json.loads(await fs.read_file(str(outside)))
|
|
|
|
assert payload["status"] == "approval_required"
|
|
assert payload["approval_id"]
|
|
assert payload["path"] == str(outside.resolve(strict=False))
|
|
assert payload["access"] == "ro"
|
|
assert payload["approvalKind"] == "sandbox_path"
|
|
assert [choice["id"] for choice in payload["choices"]] == [
|
|
"allow_once",
|
|
"allow_same_type",
|
|
"deny",
|
|
]
|
|
assert "outside the current workspace" in payload["message"]
|
|
assert str(workspace) in payload["message"]
|
|
assert "read-only or read/write access" in payload["message"]
|
|
assert "appears as /workspace" not in payload["message"]
|
|
pending = get_approval_queue().get(payload["approval_id"])
|
|
assert pending.params["approvalKind"] == "sandbox_path"
|
|
assert pending.params["path"] == str(outside.resolve(strict=False))
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_denied_sandbox_path_request_does_not_create_repeated_prompt(
|
|
tmp_path: Path,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
from types import SimpleNamespace
|
|
|
|
from opensquilla.gateway.rpc_approvals import _handle_exec_approval_resolve
|
|
|
|
workspace = tmp_path / "workspace"
|
|
workspace.mkdir(exist_ok=True)
|
|
outside = tmp_path / "outside"
|
|
outside.mkdir()
|
|
monkeypatch.setattr(fs.asyncio, "get_event_loop", lambda: _InlineExecutorLoop())
|
|
|
|
with tool_context(workspace):
|
|
first = json.loads(await fs.list_dir(str(outside)))
|
|
|
|
assert first["status"] == "approval_required"
|
|
assert first["approvalKind"] == "sandbox_path"
|
|
approval_id = first["approval_id"]
|
|
assert len(get_approval_queue().list_pending("exec")) == 1
|
|
|
|
await _handle_exec_approval_resolve(
|
|
{"id": approval_id, "approved": False, "choice": "deny"},
|
|
SimpleNamespace(session_manager=None, config=None),
|
|
)
|
|
|
|
with tool_context(workspace):
|
|
second = json.loads(await fs.list_dir(str(outside)))
|
|
|
|
assert second["status"] == "approval_denied"
|
|
assert second["approval_id"] == approval_id
|
|
assert "user denied" in second["message"].lower()
|
|
assert "do not ask" in second["message"].lower()
|
|
assert "Add the requested path" not in second["message"]
|
|
assert get_approval_queue().list_pending("exec") == []
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_denied_sandbox_path_request_can_be_requested_again_next_turn(
|
|
tmp_path: Path,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
from types import SimpleNamespace
|
|
|
|
from opensquilla.gateway.rpc_approvals import _handle_exec_approval_resolve
|
|
from opensquilla.sandbox.escalation import clear_sandbox_approval_denials
|
|
|
|
workspace = tmp_path / "workspace"
|
|
workspace.mkdir(exist_ok=True)
|
|
outside = tmp_path / "outside"
|
|
outside.mkdir()
|
|
monkeypatch.setattr(fs.asyncio, "get_event_loop", lambda: _InlineExecutorLoop())
|
|
|
|
with tool_context(workspace):
|
|
first = json.loads(await fs.list_dir(str(outside)))
|
|
|
|
assert first["status"] == "approval_required"
|
|
approval_id = first["approval_id"]
|
|
|
|
await _handle_exec_approval_resolve(
|
|
{"id": approval_id, "approved": False, "choice": "deny"},
|
|
SimpleNamespace(session_manager=None, config=None),
|
|
)
|
|
|
|
with tool_context(workspace):
|
|
same_turn = json.loads(await fs.list_dir(str(outside)))
|
|
|
|
assert same_turn["status"] == "approval_denied"
|
|
assert same_turn["approval_id"] == approval_id
|
|
|
|
clear_sandbox_approval_denials("s1")
|
|
|
|
with tool_context(workspace):
|
|
next_turn = json.loads(await fs.list_dir(str(outside)))
|
|
|
|
assert next_turn["status"] == "approval_required"
|
|
assert next_turn["approval_id"] != approval_id
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_denied_sandbox_path_request_clears_duplicate_pending_prompts(
|
|
tmp_path: Path,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
from types import SimpleNamespace
|
|
|
|
from opensquilla.gateway.rpc_approvals import _handle_exec_approval_resolve
|
|
|
|
workspace = tmp_path / "workspace"
|
|
workspace.mkdir(exist_ok=True)
|
|
outside = tmp_path / "outside"
|
|
outside.mkdir()
|
|
monkeypatch.setattr(fs.asyncio, "get_event_loop", lambda: _InlineExecutorLoop())
|
|
|
|
with tool_context(workspace):
|
|
first = json.loads(await fs.list_dir(str(outside)))
|
|
second = json.loads(await fs.list_dir(str(outside)))
|
|
|
|
assert first["status"] == "approval_required"
|
|
assert second["status"] == "approval_pending"
|
|
assert second["approval_id"] == first["approval_id"]
|
|
assert len(get_approval_queue().list_pending("exec")) == 1
|
|
|
|
await _handle_exec_approval_resolve(
|
|
{"id": first["approval_id"], "approved": False, "choice": "deny"},
|
|
SimpleNamespace(session_manager=None, config=None),
|
|
)
|
|
|
|
assert get_approval_queue().list_pending("exec") == []
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_filesystem_write_outside_workspace_requests_rw_mount(tmp_path: Path) -> None:
|
|
workspace = tmp_path / "workspace"
|
|
workspace.mkdir(exist_ok=True)
|
|
outside = tmp_path / "outside" / "notes.txt"
|
|
|
|
with tool_context(workspace):
|
|
payload = json.loads(await fs.write_file(str(outside), "outside body\n"))
|
|
|
|
assert payload["status"] == "approval_required"
|
|
assert payload["approval_id"]
|
|
assert payload["path"] == str(outside.resolve(strict=False))
|
|
assert payload["access"] == "rw"
|
|
assert payload["approvalKind"] == "sandbox_path"
|
|
assert [choice["id"] for choice in payload["choices"]] == [
|
|
"allow_once",
|
|
"allow_same_type",
|
|
"deny",
|
|
]
|
|
assert not outside.exists()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_trusted_sandbox_write_outside_workspace_auto_grants_rw_mount(
|
|
tmp_path: Path,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
workspace = tmp_path / "workspace"
|
|
workspace.mkdir(exist_ok=True)
|
|
outside = tmp_path / "outside" / "notes.txt"
|
|
monkeypatch.setattr(fs.asyncio, "get_event_loop", lambda: _InlineExecutorLoop())
|
|
|
|
with tool_context(workspace, run_mode="trusted") as ctx:
|
|
result = await fs.write_file(str(outside), "outside body\n")
|
|
|
|
assert "Written 13 bytes" in result
|
|
assert outside.read_text(encoding="utf-8") == "outside body\n"
|
|
assert get_approval_queue().list_pending("exec") == []
|
|
assert ctx.sandbox_mounts == [
|
|
{"path": str(outside.parent.resolve(strict=False)), "access": "rw"}
|
|
]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_existing_rw_mount_allows_write_file_without_legacy_approval(
|
|
tmp_path: Path,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
workspace = tmp_path / "workspace"
|
|
workspace.mkdir(exist_ok=True)
|
|
mounted = tmp_path / "mounted"
|
|
mounted.mkdir()
|
|
target = mounted / "out.txt"
|
|
monkeypatch.setattr(fs.asyncio, "get_event_loop", lambda: _InlineExecutorLoop())
|
|
|
|
with tool_context(
|
|
workspace,
|
|
sandbox_mounts=[{"path": str(mounted), "access": "rw"}],
|
|
):
|
|
result = await fs.write_file(str(target), "x")
|
|
|
|
assert "Written 1 bytes" in result
|
|
assert target.read_text(encoding="utf-8") == "x"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_existing_rw_mount_allows_edit_file_without_legacy_approval(
|
|
tmp_path: Path,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
workspace = tmp_path / "workspace"
|
|
workspace.mkdir(exist_ok=True)
|
|
mounted = tmp_path / "mounted"
|
|
mounted.mkdir()
|
|
target = mounted / "out.txt"
|
|
target.write_text("old\n", encoding="utf-8")
|
|
monkeypatch.setattr(fs.asyncio, "get_event_loop", lambda: _InlineExecutorLoop())
|
|
|
|
with tool_context(
|
|
workspace,
|
|
sandbox_mounts=[{"path": str(mounted), "access": "rw"}],
|
|
):
|
|
result = await fs.edit_file(str(target), "old", "new")
|
|
|
|
assert "Edited" in result
|
|
assert target.read_text(encoding="utf-8") == "new\n"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_existing_ro_mount_write_requests_rw_mount_not_legacy_approval(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
workspace = tmp_path / "workspace"
|
|
workspace.mkdir(exist_ok=True)
|
|
mounted = tmp_path / "mounted"
|
|
mounted.mkdir()
|
|
target = mounted / "out.txt"
|
|
|
|
with tool_context(
|
|
workspace,
|
|
sandbox_mounts=[{"path": str(mounted), "access": "ro"}],
|
|
):
|
|
payload = json.loads(await fs.write_file(str(target), "x"))
|
|
|
|
assert payload["status"] == "approval_required"
|
|
assert payload["path"] == str(target.resolve(strict=False))
|
|
assert payload["access"] == "rw"
|
|
assert payload["approval_id"]
|
|
assert not target.exists()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_list_dir_retry_accepts_path_approval_id_after_mount(
|
|
tmp_path: Path,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
workspace = tmp_path / "workspace"
|
|
workspace.mkdir(exist_ok=True)
|
|
mounted = tmp_path / "mounted"
|
|
mounted.mkdir()
|
|
(mounted / "notes.txt").write_text("hello\n", encoding="utf-8")
|
|
monkeypatch.setattr(fs.asyncio, "get_event_loop", lambda: _InlineExecutorLoop())
|
|
|
|
with tool_context(
|
|
workspace,
|
|
sandbox_mounts=[{"path": str(mounted), "access": "ro"}],
|
|
):
|
|
result = await fs.list_dir(str(mounted), approval_id="approved-path")
|
|
|
|
assert "notes.txt" in result
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_grep_search_does_not_follow_workspace_symlink_to_unmounted_path(
|
|
tmp_path: Path,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
workspace = tmp_path / "workspace"
|
|
workspace.mkdir(exist_ok=True)
|
|
outside = tmp_path / "outside"
|
|
outside.mkdir()
|
|
outside_file = outside / "secret.txt"
|
|
outside_file.write_text("needle secret-token\n", encoding="utf-8")
|
|
link = workspace / "linked-secret.txt"
|
|
try:
|
|
link.symlink_to(outside_file)
|
|
except OSError as exc:
|
|
if getattr(exc, "winerror", None) == 1314:
|
|
pytest.skip("creating symlinks requires Windows developer mode or elevation")
|
|
raise
|
|
monkeypatch.setattr(fs.asyncio, "get_event_loop", lambda: _InlineExecutorLoop())
|
|
|
|
with tool_context(workspace):
|
|
result = await fs.grep_search("needle", path=str(workspace))
|
|
|
|
assert "secret-token" not in result
|
|
assert "outside current sandbox view" in result or "No matches" in result
|
|
|
|
|
|
def test_shell_windows_null_redirection_does_not_request_write_access(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
from opensquilla.sandbox.operation_profile import OperationProfile
|
|
|
|
monkeypatch.setattr(shell, "_windows_sandbox_backend_active", lambda: True)
|
|
profile = OperationProfile("unknown_shell")
|
|
|
|
assert shell._shell_write_access_targets("chcp 65001 >nul && echo ok", profile) == ()
|
|
assert shell._shell_write_access_targets("where winget 2>NUL || echo missing", profile) == ()
|
|
assert shell._shell_write_access_targets("echo ok > output.txt", profile) == (
|
|
"output.txt",
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_shell_read_only_workdir_outside_workspace_requests_ro_mount(
|
|
tmp_path: Path,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
workspace = tmp_path / "workspace"
|
|
workspace.mkdir(exist_ok=True)
|
|
outside = tmp_path / "outside"
|
|
outside.mkdir()
|
|
backend_calls: list[object] = []
|
|
|
|
async def fail_backend(request: object, *, runtime: object = None) -> object:
|
|
backend_calls.append(request)
|
|
raise AssertionError("backend should not run before path access is granted")
|
|
|
|
monkeypatch.setattr(shell, "run_under_backend", fail_backend)
|
|
monkeypatch.setattr(
|
|
shell,
|
|
"check_safe_bin",
|
|
lambda command: SimpleNamespace(allowed=True, needs_approval=False, reason=""),
|
|
)
|
|
|
|
with tool_context(workspace):
|
|
payload = json.loads(await shell.exec_command("pwd", workdir=str(outside)))
|
|
|
|
assert payload["status"] == "approval_required"
|
|
approval_id = str(payload["approval_id"])
|
|
assert payload["path"] == str(outside.resolve(strict=False))
|
|
assert payload["access"] == "ro"
|
|
assert payload["approvalKind"] == "sandbox_path"
|
|
assert backend_calls == []
|
|
|
|
with tool_context(workspace):
|
|
pending = json.loads(
|
|
await shell.exec_command("pwd", workdir=str(outside), approval_id=approval_id)
|
|
)
|
|
|
|
assert pending["status"] == "approval_pending"
|
|
assert pending["approval_id"] == approval_id
|
|
assert backend_calls == []
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_shell_ro_workdir_mount_stays_read_only_in_policy(
|
|
tmp_path: Path,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
workspace = tmp_path / "workspace"
|
|
workspace.mkdir(exist_ok=True)
|
|
outside = tmp_path / "outside"
|
|
outside.mkdir()
|
|
backend_calls: list[SandboxRequest] = []
|
|
|
|
async def fake_backend(request: SandboxRequest, *, runtime: object = None) -> object:
|
|
backend_calls.append(request)
|
|
return SimpleNamespace(stdout="", stderr="", returncode=0, backend_notes=[])
|
|
|
|
monkeypatch.setattr(shell, "run_under_backend", fake_backend)
|
|
monkeypatch.setattr(
|
|
shell,
|
|
"check_safe_bin",
|
|
lambda command: SimpleNamespace(allowed=True, needs_approval=False, reason=""),
|
|
)
|
|
|
|
with tool_context(
|
|
workspace,
|
|
sandbox_mounts=[{"path": str(outside), "access": "ro"}],
|
|
):
|
|
await shell.exec_command(
|
|
"python -c 'open(\"x\", \"w\").write(\"1\")'",
|
|
workdir=str(outside),
|
|
)
|
|
|
|
assert len(backend_calls) == 1
|
|
request = backend_calls[0]
|
|
workspace_mount = next(
|
|
mount
|
|
for mount in request.policy.mounts
|
|
if str(mount.sandbox_path) == "/workspace"
|
|
)
|
|
outside_mount = next(
|
|
mount
|
|
for mount in request.policy.mounts
|
|
if mount.host_path == outside.resolve(strict=False)
|
|
)
|
|
assert request.cwd == outside.resolve(strict=False)
|
|
assert workspace_mount.host_path == workspace.resolve(strict=False)
|
|
assert workspace_mount.mode == "rw"
|
|
assert outside_mount.mode == "ro"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_shell_workdir_relative_write_requests_rw_mount(
|
|
tmp_path: Path,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
workspace = tmp_path / "workspace"
|
|
workspace.mkdir(exist_ok=True)
|
|
outside = tmp_path / "outside"
|
|
outside.mkdir()
|
|
backend_calls: list[object] = []
|
|
|
|
async def fail_backend(request: object, *, runtime: object = None) -> object:
|
|
backend_calls.append(request)
|
|
raise AssertionError("backend should not run before path access is granted")
|
|
|
|
monkeypatch.setattr(shell, "run_under_backend", fail_backend)
|
|
monkeypatch.setattr(
|
|
shell,
|
|
"check_safe_bin",
|
|
lambda command: SimpleNamespace(allowed=True, needs_approval=False, reason=""),
|
|
)
|
|
|
|
with tool_context(workspace):
|
|
payload = json.loads(await shell.exec_command("echo ok > out.txt", workdir=str(outside)))
|
|
|
|
assert payload["status"] == "approval_required"
|
|
assert payload["path"] == str(outside.resolve(strict=False))
|
|
assert payload["access"] == "rw"
|
|
assert payload["approvalKind"] == "sandbox_path"
|
|
assert backend_calls == []
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_standard_shell_simple_read_path_outside_workspace_requests_ro_mount(
|
|
tmp_path: Path,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
workspace = tmp_path / "workspace"
|
|
workspace.mkdir(exist_ok=True)
|
|
outside = tmp_path / "outside"
|
|
outside.mkdir()
|
|
backend_calls: list[object] = []
|
|
|
|
async def fail_backend(request: object, *, runtime: object = None) -> object:
|
|
backend_calls.append(request)
|
|
raise AssertionError("backend should not run before path access is granted")
|
|
|
|
monkeypatch.setattr(shell, "run_under_backend", fail_backend)
|
|
monkeypatch.setattr(
|
|
shell,
|
|
"check_safe_bin",
|
|
lambda command: SimpleNamespace(allowed=True, needs_approval=False, reason=""),
|
|
)
|
|
|
|
with tool_context(workspace, run_mode="standard"):
|
|
payload = json.loads(await shell.exec_command(f"ls {outside}"))
|
|
|
|
assert payload["status"] == "approval_required"
|
|
assert payload["path"] == str(outside.resolve(strict=False))
|
|
assert payload["access"] == "ro"
|
|
assert payload["approvalKind"] == "sandbox_path"
|
|
assert backend_calls == []
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_standard_shell_powershell_read_path_outside_workspace_requests_ro_mount(
|
|
tmp_path: Path,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
workspace = tmp_path / "workspace"
|
|
workspace.mkdir(exist_ok=True)
|
|
outside = tmp_path / "outside"
|
|
outside.mkdir()
|
|
backend_calls: list[object] = []
|
|
|
|
async def fail_backend(request: object, *, runtime: object = None) -> object:
|
|
backend_calls.append(request)
|
|
raise AssertionError("backend should not run before path access is granted")
|
|
|
|
monkeypatch.setattr(shell, "run_under_backend", fail_backend)
|
|
monkeypatch.setattr(
|
|
shell,
|
|
"check_safe_bin",
|
|
lambda command: SimpleNamespace(allowed=True, needs_approval=False, reason=""),
|
|
)
|
|
|
|
command = f'powershell -NoProfile -Command "Get-ChildItem -LiteralPath \'{outside}\'"'
|
|
with tool_context(workspace, run_mode="standard"):
|
|
payload = json.loads(await shell.exec_command(command))
|
|
|
|
assert payload["status"] == "approval_required"
|
|
assert payload["path"] == str(outside.resolve(strict=False))
|
|
assert payload["access"] == "ro"
|
|
assert payload["approvalKind"] == "sandbox_path"
|
|
assert backend_calls == []
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_trusted_filesystem_read_path_outside_workspace_auto_mounts_without_prompt(
|
|
tmp_path: Path,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
workspace = tmp_path / "workspace"
|
|
workspace.mkdir(exist_ok=True)
|
|
outside = tmp_path / "outside"
|
|
outside.mkdir()
|
|
target = outside / "notes.txt"
|
|
target.write_text("trusted read\n", encoding="utf-8")
|
|
_install_filesystem_read_backend()
|
|
monkeypatch.setattr(fs.asyncio, "get_event_loop", lambda: _InlineExecutorLoop())
|
|
|
|
with tool_context(workspace, run_mode="trusted") as ctx:
|
|
result = await fs.read_file(str(target))
|
|
|
|
assert "trusted read" in result
|
|
assert get_approval_queue().list_pending("exec") == []
|
|
assert ctx.sandbox_mounts == [{"path": str(target.resolve(strict=False)), "access": "ro"}]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_trusted_run_context_read_path_outside_workspace_auto_mounts_without_prompt(
|
|
tmp_path: Path,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
workspace = tmp_path / "workspace"
|
|
workspace.mkdir(exist_ok=True)
|
|
outside = tmp_path / "outside"
|
|
outside.mkdir()
|
|
target = outside / "notes.txt"
|
|
target.write_text("trusted context read\n", encoding="utf-8")
|
|
_install_filesystem_read_backend()
|
|
monkeypatch.setattr(fs.asyncio, "get_event_loop", lambda: _InlineExecutorLoop())
|
|
|
|
with tool_context(workspace, run_mode=None) as ctx:
|
|
ctx.sandbox_run_context = RunContext(
|
|
run_mode=RunMode.TRUSTED,
|
|
workspace=str(workspace),
|
|
)
|
|
result = await fs.read_file(str(target))
|
|
|
|
assert "trusted context read" in result
|
|
assert get_approval_queue().list_pending("exec") == []
|
|
assert ctx.sandbox_mounts == [{"path": str(target.resolve(strict=False)), "access": "ro"}]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_trusted_shell_simple_read_path_outside_workspace_auto_mounts_without_prompt(
|
|
tmp_path: Path,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
workspace = tmp_path / "workspace"
|
|
workspace.mkdir(exist_ok=True)
|
|
outside = tmp_path / "outside"
|
|
outside.mkdir()
|
|
backend_calls: list[object] = []
|
|
|
|
async def fake_backend(request: object, *, runtime: object = None) -> object:
|
|
backend_calls.append(request)
|
|
return SimpleNamespace(stdout="listed\n", stderr="", returncode=0, backend_notes=[])
|
|
|
|
monkeypatch.setattr(shell, "run_under_backend", fake_backend)
|
|
monkeypatch.setattr(
|
|
shell,
|
|
"check_safe_bin",
|
|
lambda command: SimpleNamespace(allowed=True, needs_approval=False, reason=""),
|
|
)
|
|
|
|
with tool_context(workspace, run_mode="trusted") as ctx:
|
|
result = await shell.exec_command(f"ls {outside}")
|
|
|
|
assert "exit_code=0" in result
|
|
assert "listed" in result
|
|
assert backend_calls
|
|
assert get_approval_queue().list_pending("exec") == []
|
|
assert ctx.sandbox_mounts == [{"path": str(outside.resolve(strict=False)), "access": "ro"}]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_trusted_shell_powershell_read_path_outside_workspace_auto_mounts_without_prompt(
|
|
tmp_path: Path,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
workspace = tmp_path / "workspace"
|
|
workspace.mkdir(exist_ok=True)
|
|
outside = tmp_path / "outside"
|
|
outside.mkdir()
|
|
backend_calls: list[object] = []
|
|
|
|
async def fake_backend(request: object, *, runtime: object = None) -> object:
|
|
backend_calls.append(request)
|
|
return SimpleNamespace(stdout="listed\n", stderr="", returncode=0, backend_notes=[])
|
|
|
|
monkeypatch.setattr(shell, "run_under_backend", fake_backend)
|
|
monkeypatch.setattr(
|
|
shell,
|
|
"check_safe_bin",
|
|
lambda command: SimpleNamespace(allowed=True, needs_approval=False, reason=""),
|
|
)
|
|
|
|
command = f'powershell -NoProfile -Command "Get-ChildItem -LiteralPath \'{outside}\'"'
|
|
with tool_context(workspace, run_mode="trusted") as ctx:
|
|
result = await shell.exec_command(command)
|
|
|
|
assert "exit_code=0" in result
|
|
assert "listed" in result
|
|
assert backend_calls
|
|
assert get_approval_queue().list_pending("exec") == []
|
|
assert ctx.sandbox_mounts == [{"path": str(outside.resolve(strict=False)), "access": "ro"}]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_trusted_shell_delete_existing_file_auto_mounts_file_without_prompt(
|
|
tmp_path: Path,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
workspace = tmp_path / "workspace"
|
|
workspace.mkdir(exist_ok=True)
|
|
outside = tmp_path / "outside" / "outside-sandbox-smoke.txt"
|
|
outside.parent.mkdir()
|
|
outside.write_text("hello\n", encoding="utf-8")
|
|
backend_calls: list[SandboxRequest] = []
|
|
|
|
async def fake_backend(request: SandboxRequest, *, runtime: object = None) -> object:
|
|
backend_calls.append(request)
|
|
return SimpleNamespace(stdout="", stderr="", returncode=0, backend_notes=[])
|
|
|
|
monkeypatch.setattr(shell, "run_under_backend", fake_backend)
|
|
monkeypatch.setattr(
|
|
shell,
|
|
"check_safe_bin",
|
|
lambda command: SimpleNamespace(allowed=True, needs_approval=True, reason=""),
|
|
)
|
|
|
|
with tool_context(workspace, run_mode="trusted") as ctx:
|
|
result = await shell.exec_command(f'del "{outside}"')
|
|
|
|
assert "exit_code=0" in result
|
|
assert backend_calls
|
|
assert get_approval_queue().list_pending("exec") == []
|
|
assert ctx.sandbox_mounts == [{"path": str(outside.resolve(strict=False)), "access": "rw"}]
|
|
request = backend_calls[0]
|
|
assert any(mount.host_path == outside for mount in request.policy.mounts)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_shell_write_to_protected_metadata_is_blocked_before_backend(
|
|
tmp_path: Path,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
workspace = tmp_path / "workspace"
|
|
workspace.mkdir(exist_ok=True)
|
|
repo = tmp_path / "repo"
|
|
(repo / ".git").mkdir(parents=True)
|
|
(repo / ".codex").mkdir()
|
|
backend_calls: list[SandboxRequest] = []
|
|
|
|
async def fake_backend(request: SandboxRequest, *, runtime: object = None) -> object:
|
|
backend_calls.append(request)
|
|
return SimpleNamespace(stdout="", stderr="", returncode=0, backend_notes=[])
|
|
|
|
monkeypatch.setattr(shell, "run_under_backend", fake_backend)
|
|
monkeypatch.setattr(
|
|
shell,
|
|
"check_safe_bin",
|
|
lambda command: SimpleNamespace(allowed=True, needs_approval=False, reason=""),
|
|
)
|
|
|
|
with tool_context(workspace, run_mode="trusted"):
|
|
git_result = await shell.exec_command(
|
|
f"sh -lc 'printf \"%s\\n\" blocked > {repo}/.git/_sandbox_should_not_write.txt'"
|
|
)
|
|
codex_result = await shell.exec_command(
|
|
f"sh -lc 'printf \"%s\\n\" blocked > {repo}/.codex/_sandbox_should_not_write.txt'"
|
|
)
|
|
|
|
git_payload = json.loads(git_result)
|
|
codex_payload = json.loads(codex_result)
|
|
assert git_payload["reason"] == "protected_metadata"
|
|
assert git_payload["protected_name"] == ".git"
|
|
assert codex_payload["reason"] == "protected_metadata"
|
|
assert codex_payload["protected_name"] == ".codex"
|
|
assert backend_calls == []
|
|
assert not (repo / ".git/_sandbox_should_not_write.txt").exists()
|
|
assert not (repo / ".codex/_sandbox_should_not_write.txt").exists()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize("metadata_dir", [".git", ".codex"])
|
|
async def test_full_host_access_shell_write_to_protected_metadata_uses_host(
|
|
tmp_path: Path,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
metadata_dir: str,
|
|
) -> None:
|
|
workspace = tmp_path / "workspace"
|
|
workspace.mkdir(exist_ok=True)
|
|
repo = tmp_path / "repo"
|
|
(repo / metadata_dir).mkdir(parents=True)
|
|
target = repo / metadata_dir / "_full_host_write_probe.txt"
|
|
host_calls: list[str] = []
|
|
backend_calls: list[SandboxRequest] = []
|
|
|
|
async def fail_backend(request: SandboxRequest, *, runtime: object = None) -> object:
|
|
backend_calls.append(request)
|
|
raise AssertionError("full host access should not use the sandbox backend")
|
|
|
|
async def fake_host(
|
|
command: str,
|
|
*,
|
|
cwd: str | None,
|
|
env: dict[str, str],
|
|
stdin_bytes: bytes | None,
|
|
effective_timeout: float,
|
|
) -> str:
|
|
host_calls.append(command)
|
|
return "host-ran"
|
|
|
|
monkeypatch.setattr(shell, "run_under_backend", fail_backend)
|
|
monkeypatch.setattr(shell, "_run_host_shell_command", fake_host)
|
|
monkeypatch.setattr(shell, "_windows_sandbox_backend_active", lambda runtime=None: True)
|
|
monkeypatch.setattr(
|
|
shell,
|
|
"check_safe_bin",
|
|
lambda command: SimpleNamespace(allowed=True, needs_approval=False, reason=""),
|
|
)
|
|
|
|
command = (
|
|
"powershell -NoProfile -Command "
|
|
f"\"Set-Content -LiteralPath '{target}' -Value full-host\""
|
|
)
|
|
with tool_context(workspace, run_mode="full"):
|
|
result = await shell.exec_command(command)
|
|
|
|
assert result == "host-ran"
|
|
assert host_calls == [command]
|
|
assert backend_calls == []
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_trusted_shell_delete_existing_file_under_rw_mount_adds_file_mount(
|
|
tmp_path: Path,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
workspace = tmp_path / "workspace"
|
|
workspace.mkdir(exist_ok=True)
|
|
mounted = tmp_path / "outside"
|
|
mounted.mkdir()
|
|
outside = mounted / "outside-sandbox-smoke.txt"
|
|
outside.write_text("hello\n", encoding="utf-8")
|
|
backend_calls: list[SandboxRequest] = []
|
|
|
|
async def fake_backend(request: SandboxRequest, *, runtime: object = None) -> object:
|
|
backend_calls.append(request)
|
|
return SimpleNamespace(stdout="", stderr="", returncode=0, backend_notes=[])
|
|
|
|
monkeypatch.setattr(shell, "run_under_backend", fake_backend)
|
|
monkeypatch.setattr(shell, "_windows_sandbox_backend_active", lambda runtime=None: True)
|
|
monkeypatch.setattr(
|
|
shell,
|
|
"check_safe_bin",
|
|
lambda command: SimpleNamespace(allowed=True, needs_approval=True, reason=""),
|
|
)
|
|
|
|
with tool_context(
|
|
workspace,
|
|
run_mode="trusted",
|
|
sandbox_mounts=[{"path": str(mounted.resolve(strict=False)), "access": "rw"}],
|
|
) as ctx:
|
|
result = await shell.exec_command(f'del "{outside}"')
|
|
|
|
assert "exit_code=0" in result
|
|
assert backend_calls
|
|
assert get_approval_queue().list_pending("exec") == []
|
|
assert ctx.sandbox_mounts == [
|
|
{"path": str(mounted.resolve(strict=False)), "access": "rw"},
|
|
{"path": str(outside.resolve(strict=False)), "access": "rw"},
|
|
]
|
|
request = backend_calls[0]
|
|
assert any(mount.host_path == outside for mount in request.policy.mounts)
|
|
|
|
|
|
def test_windows_shell_policy_ignores_deleted_active_file_mount(
|
|
tmp_path: Path,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
from opensquilla.sandbox.types import (
|
|
MountSpec,
|
|
NetworkMode,
|
|
ResourceLimits,
|
|
SandboxPolicy,
|
|
SecurityLevel,
|
|
)
|
|
|
|
workspace = tmp_path / "workspace"
|
|
workspace.mkdir(exist_ok=True)
|
|
stale = workspace / "sandbox_probe_workspace.txt"
|
|
stale.write_text("workspace-ok", encoding="utf-8")
|
|
stale.unlink()
|
|
policy = SandboxPolicy(
|
|
level=SecurityLevel.STANDARD,
|
|
network=NetworkMode.NONE,
|
|
mounts=(
|
|
MountSpec(workspace, workspace, mode="rw"),
|
|
MountSpec(stale, stale, mode="rw", required=False),
|
|
),
|
|
workspace_rw=True,
|
|
tmp_writable=True,
|
|
limits=ResourceLimits(),
|
|
env_allowlist=(),
|
|
require_approval=False,
|
|
)
|
|
monkeypatch.setattr(shell, "_windows_sandbox_backend_active", lambda runtime=None: True)
|
|
|
|
with tool_context(
|
|
workspace,
|
|
run_mode="trusted",
|
|
sandbox_mounts=[{"path": str(stale.resolve(strict=False)), "access": "rw"}],
|
|
):
|
|
updated = shell._policy_with_active_tool_mounts(policy)
|
|
|
|
assert stale not in {mount.host_path for mount in updated.mounts}
|
|
assert workspace in {mount.host_path for mount in updated.mounts}
|
|
|
|
|
|
def test_shell_policy_preserves_workspace_rw_absolute_alias(
|
|
tmp_path: Path,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
from opensquilla.sandbox.types import (
|
|
SANDBOX_WORKSPACE_PATH,
|
|
MountSpec,
|
|
NetworkMode,
|
|
ResourceLimits,
|
|
SandboxPolicy,
|
|
SecurityLevel,
|
|
)
|
|
|
|
workspace = tmp_path / "workspace"
|
|
workspace.mkdir(exist_ok=True)
|
|
policy = SandboxPolicy(
|
|
level=SecurityLevel.STANDARD,
|
|
network=NetworkMode.NONE,
|
|
mounts=(
|
|
MountSpec(
|
|
host_path=workspace,
|
|
sandbox_path=SANDBOX_WORKSPACE_PATH,
|
|
mode="rw",
|
|
required=True,
|
|
),
|
|
),
|
|
workspace_rw=True,
|
|
tmp_writable=True,
|
|
limits=ResourceLimits(),
|
|
env_allowlist=(),
|
|
require_approval=False,
|
|
)
|
|
monkeypatch.setattr(shell, "_windows_sandbox_backend_active", lambda runtime=None: False)
|
|
|
|
with tool_context(
|
|
workspace,
|
|
run_mode="trusted",
|
|
sandbox_mounts=[{"path": str(workspace), "access": "ro"}],
|
|
):
|
|
updated = shell._policy_with_active_tool_mounts(policy)
|
|
|
|
mounts_by_sandbox = {str(mount.sandbox_path): mount for mount in updated.mounts}
|
|
assert mounts_by_sandbox["/workspace"].mode == "rw"
|
|
assert mounts_by_sandbox[str(workspace)].mode == "rw"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_shell_copy_from_outside_workspace_requests_ro_mount_before_backend(
|
|
tmp_path: Path,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
workspace = tmp_path / "workspace"
|
|
workspace.mkdir(exist_ok=True)
|
|
outside = tmp_path / "outside"
|
|
outside.mkdir()
|
|
source = outside / "notes.txt"
|
|
target = workspace / "notes.txt"
|
|
backend_calls: list[object] = []
|
|
|
|
async def fail_backend(request: object, *, runtime: object = None) -> object:
|
|
backend_calls.append(request)
|
|
raise AssertionError("backend should not run before source path access is granted")
|
|
|
|
monkeypatch.setattr(shell, "run_under_backend", fail_backend)
|
|
monkeypatch.setattr(
|
|
shell,
|
|
"check_safe_bin",
|
|
lambda command: SimpleNamespace(allowed=True, needs_approval=False, reason=""),
|
|
)
|
|
|
|
with tool_context(workspace, run_mode="standard"):
|
|
payload = json.loads(await shell.exec_command(f"cp {source} {target}"))
|
|
|
|
assert payload["status"] == "approval_required"
|
|
assert payload["path"] == str(source.resolve(strict=False))
|
|
assert payload["access"] == "ro"
|
|
assert payload["approvalKind"] == "sandbox_path"
|
|
assert backend_calls == []
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_standard_shell_copy_to_outside_workspace_requests_rw_mount_before_backend(
|
|
tmp_path: Path,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
workspace = tmp_path / "workspace"
|
|
workspace.mkdir(exist_ok=True)
|
|
source = workspace / "notes.txt"
|
|
target = tmp_path / "outside" / "notes.txt"
|
|
backend_calls: list[object] = []
|
|
|
|
async def fail_backend(request: object, *, runtime: object = None) -> object:
|
|
backend_calls.append(request)
|
|
raise AssertionError("backend should not run before destination path access is granted")
|
|
|
|
monkeypatch.setattr(shell, "run_under_backend", fail_backend)
|
|
monkeypatch.setattr(
|
|
shell,
|
|
"check_safe_bin",
|
|
lambda command: SimpleNamespace(allowed=True, needs_approval=False, reason=""),
|
|
)
|
|
|
|
with tool_context(workspace, run_mode="standard"):
|
|
payload = json.loads(await shell.exec_command(f"cp {source} {target}"))
|
|
|
|
assert payload["status"] == "approval_required"
|
|
assert payload["path"] == str(target.resolve(strict=False))
|
|
assert payload["access"] == "rw"
|
|
assert payload["approvalKind"] == "sandbox_path"
|
|
assert backend_calls == []
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_trusted_shell_copy_to_outside_workspace_auto_mounts_rw(
|
|
tmp_path: Path,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
workspace = tmp_path / "workspace"
|
|
workspace.mkdir(exist_ok=True)
|
|
source = workspace / "notes.txt"
|
|
source.write_text("hello\n", encoding="utf-8")
|
|
target = tmp_path / "outside" / "notes.txt"
|
|
backend_calls: list[SandboxRequest] = []
|
|
|
|
async def fake_backend(request: SandboxRequest, *, runtime: object = None) -> object:
|
|
backend_calls.append(request)
|
|
return SimpleNamespace(stdout="", stderr="", returncode=0, backend_notes=[])
|
|
|
|
monkeypatch.setattr(shell, "run_under_backend", fake_backend)
|
|
monkeypatch.setattr(
|
|
shell,
|
|
"check_safe_bin",
|
|
lambda command: SimpleNamespace(allowed=True, needs_approval=False, reason=""),
|
|
)
|
|
|
|
with tool_context(workspace, run_mode="trusted") as ctx:
|
|
result = await shell.exec_command(f"cp {source} {target}")
|
|
|
|
assert "exit_code=0" in result
|
|
assert backend_calls
|
|
assert get_approval_queue().list_pending("exec") == []
|
|
assert ctx.sandbox_mounts == [
|
|
{"path": str(target.parent.resolve(strict=False)), "access": "rw"}
|
|
]
|
|
request = backend_calls[0]
|
|
assert any(mount.host_path == target.parent for mount in request.policy.mounts)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_trusted_shell_external_workdir_write_auto_mounts_rw(
|
|
tmp_path: Path,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
workspace = tmp_path / "workspace"
|
|
workspace.mkdir(exist_ok=True)
|
|
outside = tmp_path / "outside"
|
|
outside.mkdir()
|
|
backend_calls: list[SandboxRequest] = []
|
|
|
|
async def fake_backend(request: SandboxRequest, *, runtime: object = None) -> object:
|
|
backend_calls.append(request)
|
|
return SimpleNamespace(stdout="", stderr="", returncode=0, backend_notes=[])
|
|
|
|
monkeypatch.setattr(shell, "run_under_backend", fake_backend)
|
|
monkeypatch.setattr(
|
|
shell,
|
|
"check_safe_bin",
|
|
lambda command: SimpleNamespace(allowed=True, needs_approval=False, reason=""),
|
|
)
|
|
|
|
with tool_context(workspace, run_mode="trusted") as ctx:
|
|
result = await shell.exec_command("echo hi > out.txt", workdir=str(outside))
|
|
|
|
assert "exit_code=0" in result
|
|
assert backend_calls
|
|
assert get_approval_queue().list_pending("exec") == []
|
|
assert ctx.sandbox_mounts == [{"path": str(outside.resolve(strict=False)), "access": "rw"}]
|
|
request = backend_calls[0]
|
|
assert any(mount.host_path == outside for mount in request.policy.mounts)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_shell_absolute_redirection_requests_rw_mount_before_backend(
|
|
tmp_path: Path,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
workspace = tmp_path / "workspace"
|
|
workspace.mkdir(exist_ok=True)
|
|
target = tmp_path / "outside" / "out.txt"
|
|
backend_calls: list[object] = []
|
|
|
|
async def fail_backend(request: object, *, runtime: object = None) -> object:
|
|
backend_calls.append(request)
|
|
raise AssertionError("backend should not run before redirection target is granted")
|
|
|
|
monkeypatch.setattr(shell, "run_under_backend", fail_backend)
|
|
monkeypatch.setattr(
|
|
shell,
|
|
"check_safe_bin",
|
|
lambda command: SimpleNamespace(allowed=True, needs_approval=False, reason=""),
|
|
)
|
|
|
|
with tool_context(workspace, run_mode="standard"):
|
|
payload = json.loads(await shell.exec_command(f"echo hi > {target}"))
|
|
|
|
assert payload["status"] == "approval_required"
|
|
assert payload["path"] == str(target.resolve(strict=False))
|
|
assert payload["access"] == "rw"
|
|
assert payload["approvalKind"] == "sandbox_path"
|
|
assert backend_calls == []
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_shell_simple_read_path_full_host_access_does_not_request_mount(
|
|
tmp_path: Path,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
workspace = tmp_path / "workspace"
|
|
workspace.mkdir(exist_ok=True)
|
|
outside = tmp_path / "outside"
|
|
outside.mkdir()
|
|
backend_calls: list[object] = []
|
|
host_calls: list[tuple[str, str | None]] = []
|
|
|
|
async def fail_backend(request: object, *, runtime: object = None) -> object:
|
|
backend_calls.append(request)
|
|
raise AssertionError("full host access should not use the sandbox backend")
|
|
|
|
async def fake_host(
|
|
command: str,
|
|
*,
|
|
cwd: str | None,
|
|
env: dict[str, str],
|
|
stdin_bytes: bytes | None,
|
|
effective_timeout: float,
|
|
) -> str:
|
|
host_calls.append((command, cwd))
|
|
return "host-ran"
|
|
|
|
monkeypatch.setattr(shell, "run_under_backend", fail_backend)
|
|
monkeypatch.setattr(shell, "_run_host_shell_command", fake_host)
|
|
monkeypatch.setattr(
|
|
shell,
|
|
"check_safe_bin",
|
|
lambda command: SimpleNamespace(allowed=True, needs_approval=False, reason=""),
|
|
)
|
|
|
|
with tool_context(workspace, run_mode="full"):
|
|
result = await shell.exec_command(f"ls {outside}")
|
|
|
|
assert result == "host-ran"
|
|
assert host_calls == [(f"ls {outside}", str(workspace.resolve()))]
|
|
assert backend_calls == []
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_path_request_approval_does_not_mutate_until_choice_is_resolved(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
from opensquilla.gateway.rpc import RpcContext, get_dispatcher
|
|
from opensquilla.sandbox.run_context import get_run_context
|
|
|
|
workspace = tmp_path / "workspace"
|
|
workspace.mkdir(exist_ok=True)
|
|
outside = tmp_path / "outside" / "notes.txt"
|
|
manager = SimpleNamespace()
|
|
manager.node = SimpleNamespace(session_key="s1", agent_id="main", origin=None)
|
|
|
|
async def _get_session(session_key: str):
|
|
return manager.node if session_key == manager.node.session_key else None
|
|
|
|
async def _update(session_key: str, **fields):
|
|
for key, value in fields.items():
|
|
setattr(manager.node, key, value)
|
|
return manager.node
|
|
|
|
manager.get_session = _get_session
|
|
manager.update = _update
|
|
config = SimpleNamespace(
|
|
sandbox=SimpleNamespace(run_mode="standard", sandbox=True, security_grading=True),
|
|
permissions=SimpleNamespace(default_mode="off"),
|
|
)
|
|
|
|
with tool_context(workspace):
|
|
payload = json.loads(await fs.write_file(str(outside), "outside body\n"))
|
|
|
|
approval_id = str(payload["approval_id"])
|
|
pending = get_approval_queue().get(approval_id)
|
|
assert pending.resolved is False
|
|
assert outside.exists() is False
|
|
saved_before = await get_run_context(manager, "s1", config=config, workspace=str(workspace))
|
|
assert saved_before.mounts == ()
|
|
|
|
result = await get_dispatcher().dispatch(
|
|
"r1",
|
|
"exec.approval.resolve",
|
|
{"id": approval_id, "approved": True, "choice": "allow_same_type"},
|
|
RpcContext(conn_id="test", session_manager=manager, config=config),
|
|
)
|
|
assert result.error is None, result.error
|
|
|
|
saved_after = await get_run_context(manager, "s1", config=config, workspace=str(workspace))
|
|
assert [(mount.path, mount.access, mount.scope) for mount in saved_after.mounts] == [
|
|
(str(outside.resolve(strict=False)), "rw", "chat")
|
|
]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_write_retry_uses_resolved_rw_mount_even_with_stale_tool_context(
|
|
tmp_path: Path,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
from opensquilla.gateway.rpc import RpcContext, get_dispatcher
|
|
|
|
workspace = tmp_path / "workspace"
|
|
workspace.mkdir(exist_ok=True)
|
|
outside = tmp_path / "outside" / "notes.txt"
|
|
manager = SimpleNamespace()
|
|
manager.node = SimpleNamespace(session_key="s1", agent_id="main", origin=None)
|
|
|
|
async def _get_session(session_key: str):
|
|
return manager.node if session_key == manager.node.session_key else None
|
|
|
|
async def _update(session_key: str, **fields):
|
|
for key, value in fields.items():
|
|
setattr(manager.node, key, value)
|
|
return manager.node
|
|
|
|
manager.get_session = _get_session
|
|
manager.update = _update
|
|
config = SimpleNamespace(
|
|
sandbox=SimpleNamespace(run_mode="standard", sandbox=True, security_grading=True),
|
|
permissions=SimpleNamespace(default_mode="off"),
|
|
)
|
|
monkeypatch.setattr(fs.asyncio, "get_event_loop", lambda: _InlineExecutorLoop())
|
|
|
|
with tool_context(workspace):
|
|
payload = json.loads(await fs.write_file(str(outside), "outside body\n"))
|
|
approval_id = str(payload["approval_id"])
|
|
|
|
result = await get_dispatcher().dispatch(
|
|
"r1",
|
|
"exec.approval.resolve",
|
|
{"id": approval_id, "approved": True, "choice": "allow_same_type"},
|
|
RpcContext(conn_id="test", session_manager=manager, config=config),
|
|
)
|
|
assert result.error is None, result.error
|
|
|
|
retried = await fs.write_file(str(outside), "outside body\n", approval_id=approval_id)
|
|
|
|
assert "Written 13 bytes" in retried
|
|
assert outside.read_text(encoding="utf-8") == "outside body\n"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_write_request_rejects_removed_mount_choice(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
from opensquilla.gateway.rpc import RpcContext, get_dispatcher
|
|
|
|
workspace = tmp_path / "workspace"
|
|
workspace.mkdir(exist_ok=True)
|
|
outside = tmp_path / "outside" / "notes.txt"
|
|
manager = SimpleNamespace()
|
|
manager.node = SimpleNamespace(session_key="s1", agent_id="main", origin=None)
|
|
|
|
async def _get_session(session_key: str):
|
|
return manager.node if session_key == manager.node.session_key else None
|
|
|
|
async def _update(session_key: str, **fields):
|
|
for key, value in fields.items():
|
|
setattr(manager.node, key, value)
|
|
return manager.node
|
|
|
|
manager.get_session = _get_session
|
|
manager.update = _update
|
|
config = SimpleNamespace(
|
|
sandbox=SimpleNamespace(run_mode="standard", sandbox=True, security_grading=True),
|
|
permissions=SimpleNamespace(default_mode="off"),
|
|
)
|
|
|
|
with tool_context(workspace):
|
|
payload = json.loads(await fs.write_file(str(outside), "outside body\n"))
|
|
approval_id = str(payload["approval_id"])
|
|
|
|
result = await get_dispatcher().dispatch(
|
|
"r1",
|
|
"exec.approval.resolve",
|
|
{"id": approval_id, "approved": True, "choice": "mount_ro_chat"},
|
|
RpcContext(conn_id="test", session_manager=manager, config=config),
|
|
)
|
|
assert result.error is not None
|
|
|
|
assert outside.exists() is False
|