510 lines
16 KiB
Python
510 lines
16 KiB
Python
from __future__ import annotations
|
|
|
|
import json
|
|
from types import SimpleNamespace
|
|
|
|
import pytest
|
|
|
|
from opensquilla.tools.types import CallerKind, ToolContext, ToolError, current_tool_context
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_trusted_sandbox_does_not_mark_shell_host_elevated(monkeypatch) -> None:
|
|
from opensquilla.tools.builtin import shell
|
|
|
|
calls: list[tuple[str, object]] = []
|
|
|
|
class _Runtime:
|
|
effective = SimpleNamespace(sandbox_enabled=True)
|
|
|
|
async def _fake_gate_action(**kwargs):
|
|
calls.append(("gate", kwargs))
|
|
policy = SimpleNamespace()
|
|
request = SimpleNamespace(cwd="/tmp", action_kind="shell.exec", policy=policy)
|
|
return object(), policy, request
|
|
|
|
async def _fake_run_under_backend(request, *, runtime=None):
|
|
calls.append(("backend", request))
|
|
return SimpleNamespace(
|
|
returncode=0,
|
|
stdout="sandboxed\n",
|
|
stderr="",
|
|
backend_notes=(),
|
|
)
|
|
|
|
monkeypatch.setattr(shell, "get_runtime", lambda: _Runtime())
|
|
monkeypatch.setattr(shell, "gate_action", _fake_gate_action)
|
|
monkeypatch.setattr(shell, "run_under_backend", _fake_run_under_backend)
|
|
monkeypatch.setattr(
|
|
shell,
|
|
"check_safe_bin",
|
|
lambda command: SimpleNamespace(allowed=True, needs_approval=False, reason=""),
|
|
)
|
|
|
|
token = current_tool_context.set(
|
|
ToolContext(is_owner=True, caller_kind=CallerKind.WEB, session_key="s1", run_mode="trusted")
|
|
)
|
|
try:
|
|
result = await shell.exec_command("echo hi")
|
|
finally:
|
|
current_tool_context.reset(token)
|
|
|
|
assert "sandboxed" in result
|
|
assert [name for name, _ in calls] == ["gate", "backend"]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_ordinary_approval_result_does_not_carry_elevated_mode(monkeypatch) -> None:
|
|
from opensquilla.application.approval_queue import ApprovalQueue
|
|
|
|
queue = ApprovalQueue(db_path=":memory:")
|
|
try:
|
|
approval_id = queue.request(
|
|
namespace="exec",
|
|
params={"sessionKey": "s1", "command": "rm x"},
|
|
)
|
|
queue.resolve(approval_id, True)
|
|
status = queue.status(approval_id)
|
|
assert "elevatedMode" not in status["params"]
|
|
finally:
|
|
queue.close()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_warnlist_shell_uses_sandbox_gate_without_exec_approval(monkeypatch) -> None:
|
|
from opensquilla.gateway.approval_queue import get_approval_queue, reset_approval_queue
|
|
from opensquilla.tools.builtin import shell
|
|
|
|
calls: list[tuple[str, object]] = []
|
|
reset_approval_queue()
|
|
|
|
class _Runtime:
|
|
effective = SimpleNamespace(sandbox_enabled=True)
|
|
|
|
async def _fake_gate_action(**kwargs):
|
|
calls.append(("gate", kwargs))
|
|
policy = SimpleNamespace()
|
|
request = SimpleNamespace(cwd="/tmp", action_kind="shell.exec", policy=policy)
|
|
return object(), policy, request
|
|
|
|
async def _fake_run_under_backend(request, *, runtime=None):
|
|
calls.append(("backend", request))
|
|
return SimpleNamespace(
|
|
returncode=0,
|
|
stdout="sandboxed after approval\n",
|
|
stderr="",
|
|
backend_notes=(),
|
|
)
|
|
|
|
monkeypatch.setattr(shell, "get_runtime", lambda: _Runtime())
|
|
monkeypatch.setattr(shell, "gate_action", _fake_gate_action)
|
|
monkeypatch.setattr(shell, "run_under_backend", _fake_run_under_backend)
|
|
monkeypatch.setattr(
|
|
shell,
|
|
"check_safe_bin",
|
|
lambda command: SimpleNamespace(
|
|
allowed=True,
|
|
needs_approval=True,
|
|
reason="command requires approval",
|
|
),
|
|
)
|
|
|
|
token = current_tool_context.set(
|
|
ToolContext(is_owner=True, caller_kind=CallerKind.CLI, session_key="s1")
|
|
)
|
|
try:
|
|
result = await shell.exec_command("rm x")
|
|
finally:
|
|
current_tool_context.reset(token)
|
|
reset_approval_queue()
|
|
|
|
assert "sandboxed after approval" in result
|
|
assert get_approval_queue().list_pending("exec") == []
|
|
assert [name for name, _ in calls] == ["gate", "backend"]
|
|
hints = calls[0][1]["hints"] # type: ignore[index]
|
|
assert hints.high_impact is True
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_trusted_workspace_shell_cleanup_stays_out_of_locked_approval(
|
|
monkeypatch,
|
|
tmp_path,
|
|
) -> None:
|
|
from opensquilla.gateway.approval_queue import get_approval_queue, reset_approval_queue
|
|
from opensquilla.tools.builtin import shell
|
|
|
|
calls: list[tuple[str, object]] = []
|
|
reset_approval_queue()
|
|
|
|
class _Runtime:
|
|
effective = SimpleNamespace(sandbox_enabled=True)
|
|
|
|
async def _fake_gate_action(**kwargs):
|
|
calls.append(("gate", kwargs))
|
|
policy = SimpleNamespace()
|
|
request = SimpleNamespace(cwd=tmp_path, action_kind="shell.exec", policy=policy)
|
|
return object(), policy, request
|
|
|
|
async def _fake_run_under_backend(request, *, runtime=None):
|
|
calls.append(("backend", request))
|
|
return SimpleNamespace(
|
|
returncode=0,
|
|
stdout="shell-workspace-ok\n",
|
|
stderr="",
|
|
backend_notes=(),
|
|
)
|
|
|
|
monkeypatch.setattr(shell, "get_runtime", lambda: _Runtime())
|
|
monkeypatch.setattr(shell, "gate_action", _fake_gate_action)
|
|
monkeypatch.setattr(shell, "run_under_backend", _fake_run_under_backend)
|
|
monkeypatch.setattr(
|
|
shell,
|
|
"check_safe_bin",
|
|
lambda command: SimpleNamespace(
|
|
allowed=True,
|
|
needs_approval=True,
|
|
reason="command requires approval",
|
|
),
|
|
)
|
|
|
|
token = current_tool_context.set(
|
|
ToolContext(
|
|
is_owner=True,
|
|
caller_kind=CallerKind.WEB,
|
|
session_key="s1",
|
|
run_mode="trusted",
|
|
workspace_dir=str(tmp_path),
|
|
)
|
|
)
|
|
try:
|
|
result = await shell.exec_command(
|
|
'printf "%s\\n" shell-workspace-ok > sandbox_probe_shell.txt '
|
|
"&& cat sandbox_probe_shell.txt && rm sandbox_probe_shell.txt",
|
|
workdir=str(tmp_path),
|
|
)
|
|
finally:
|
|
current_tool_context.reset(token)
|
|
reset_approval_queue()
|
|
|
|
assert "shell-workspace-ok" in result
|
|
assert get_approval_queue().list_pending("exec") == []
|
|
assert [name for name, _ in calls] == ["gate", "backend"]
|
|
hints = calls[0][1]["hints"] # type: ignore[index]
|
|
assert hints.high_impact is False
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_backend_denial_does_not_fall_back_to_host(monkeypatch) -> None:
|
|
from opensquilla.tools.builtin import shell
|
|
|
|
calls: list[str] = []
|
|
|
|
class _Runtime:
|
|
effective = SimpleNamespace(sandbox_enabled=True)
|
|
|
|
class _Proc:
|
|
pid = 999999
|
|
returncode = 0
|
|
|
|
async def wait(self):
|
|
return 0
|
|
|
|
def terminate(self) -> None:
|
|
return None
|
|
|
|
def kill(self) -> None:
|
|
return None
|
|
|
|
async def _fake_gate_action(**kwargs):
|
|
calls.append("gate")
|
|
policy = SimpleNamespace()
|
|
request = SimpleNamespace(
|
|
cwd="/tmp",
|
|
action_kind="shell.exec",
|
|
policy=policy,
|
|
reason="",
|
|
)
|
|
return object(), policy, request
|
|
|
|
backend_results = [
|
|
SimpleNamespace(
|
|
returncode=1,
|
|
stdout="",
|
|
stderr="denied",
|
|
backend_notes=("exec denied",),
|
|
),
|
|
SimpleNamespace(
|
|
returncode=0,
|
|
stdout="sandboxed again\n",
|
|
stderr="",
|
|
backend_notes=(),
|
|
),
|
|
]
|
|
|
|
async def _fake_run_under_backend(request, *, runtime=None):
|
|
calls.append("backend")
|
|
return backend_results.pop(0)
|
|
|
|
async def _fake_escalate_backend_denial(*args, **kwargs):
|
|
calls.append("escalate")
|
|
return object()
|
|
|
|
async def _fake_create_subprocess_shell(*args, **kwargs):
|
|
calls.append("host")
|
|
assert kwargs["stdout"] != shell.asyncio.subprocess.PIPE
|
|
assert kwargs["stderr"] == shell.asyncio.subprocess.STDOUT
|
|
if shell.os.name == "posix":
|
|
assert kwargs["start_new_session"] is True
|
|
return _Proc()
|
|
|
|
class _FakeTemporaryFile:
|
|
def __enter__(self):
|
|
return self
|
|
|
|
def __exit__(self, exc_type, exc, tb) -> None:
|
|
return None
|
|
|
|
def flush(self) -> None:
|
|
return None
|
|
|
|
def seek(self, offset: int) -> None:
|
|
return None
|
|
|
|
def read(self) -> bytes:
|
|
return b"host fallback should not run\n"
|
|
|
|
monkeypatch.setattr(shell, "get_runtime", lambda: _Runtime())
|
|
monkeypatch.setattr(shell, "gate_action", _fake_gate_action)
|
|
monkeypatch.setattr(shell, "run_under_backend", _fake_run_under_backend)
|
|
monkeypatch.setattr(shell, "escalate_backend_denial", _fake_escalate_backend_denial)
|
|
monkeypatch.setattr(shell.asyncio, "create_subprocess_shell", _fake_create_subprocess_shell)
|
|
monkeypatch.setattr(shell.tempfile, "TemporaryFile", lambda: _FakeTemporaryFile())
|
|
monkeypatch.setattr(
|
|
shell,
|
|
"check_safe_bin",
|
|
lambda command: SimpleNamespace(allowed=True, needs_approval=False, reason=""),
|
|
)
|
|
|
|
token = current_tool_context.set(
|
|
ToolContext(is_owner=True, caller_kind=CallerKind.CLI, session_key="s1")
|
|
)
|
|
try:
|
|
with pytest.raises(ToolError, match="host fallback disabled"):
|
|
await shell.exec_command("echo hi")
|
|
finally:
|
|
current_tool_context.reset(token)
|
|
|
|
assert calls == ["gate", "backend", "escalate"]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_full_host_access_code_exec_resolves_host_python(monkeypatch, tmp_path) -> None:
|
|
from opensquilla.tools.builtin import code_exec
|
|
|
|
resolve_calls: list[bool] = []
|
|
child_env: dict[str, str] = {}
|
|
|
|
class _Runtime:
|
|
effective = SimpleNamespace(sandbox_enabled=True)
|
|
workspace = tmp_path
|
|
|
|
class _Proc:
|
|
returncode = 0
|
|
|
|
async def communicate(self):
|
|
return b"host python\n", b""
|
|
|
|
def _fake_resolve_python_bin(*, sandbox_enabled: bool) -> str:
|
|
resolve_calls.append(sandbox_enabled)
|
|
return "/host/python"
|
|
|
|
async def _fake_create_subprocess_exec(*args, **kwargs):
|
|
assert args[:2] == ("/host/python", "-c")
|
|
child_env.update(kwargs["env"])
|
|
return _Proc()
|
|
|
|
monkeypatch.setenv("SystemRoot", r"C:\Windows")
|
|
monkeypatch.setenv("WINDIR", r"C:\Windows")
|
|
monkeypatch.setenv("ComSpec", r"C:\Windows\System32\cmd.exe")
|
|
monkeypatch.setenv("TEMP", str(tmp_path / "temp"))
|
|
monkeypatch.setenv("TMP", str(tmp_path / "temp"))
|
|
monkeypatch.setattr(code_exec, "get_runtime", lambda: _Runtime())
|
|
monkeypatch.setattr(code_exec, "_resolve_python_bin", _fake_resolve_python_bin)
|
|
monkeypatch.setattr(code_exec.asyncio, "create_subprocess_exec", _fake_create_subprocess_exec)
|
|
|
|
token = current_tool_context.set(
|
|
ToolContext(
|
|
is_owner=True,
|
|
caller_kind=CallerKind.CLI,
|
|
session_key="s1",
|
|
run_mode="full",
|
|
workspace_dir=str(tmp_path),
|
|
)
|
|
)
|
|
try:
|
|
result = await code_exec.execute_code("print('hi')")
|
|
finally:
|
|
current_tool_context.reset(token)
|
|
|
|
payload = json.loads(result)
|
|
assert payload["exit_code"] == 0
|
|
assert payload["stdout"] == "host python\n"
|
|
assert resolve_calls == [False]
|
|
folded_env = {key.upper(): value for key, value in child_env.items()}
|
|
assert folded_env["SYSTEMROOT"] == r"C:\Windows"
|
|
assert folded_env["WINDIR"] == r"C:\Windows"
|
|
assert folded_env["COMSPEC"] == r"C:\Windows\System32\cmd.exe"
|
|
assert folded_env["TEMP"] == str(tmp_path / "temp")
|
|
assert folded_env["TMP"] == str(tmp_path / "temp")
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_full_host_access_shell_uses_host_and_skips_sandbox_gates(
|
|
monkeypatch,
|
|
tmp_path,
|
|
) -> None:
|
|
from opensquilla.tools.builtin import shell
|
|
|
|
calls: list[str] = []
|
|
|
|
class _Runtime:
|
|
effective = SimpleNamespace(sandbox_enabled=True)
|
|
|
|
async def _fail_gate_action(**kwargs):
|
|
pytest.fail("Full Host Access shell should not enter sandbox gate_action")
|
|
|
|
async def _fake_host_shell_command(*args, **kwargs):
|
|
calls.append("host")
|
|
return "exit_code=1\nhead: cannot open '/etc/shadow' for reading: Permission denied\n"
|
|
|
|
monkeypatch.setattr(shell, "get_runtime", lambda: _Runtime())
|
|
monkeypatch.setattr(shell, "gate_action", _fail_gate_action)
|
|
monkeypatch.setattr(shell, "_run_host_shell_command", _fake_host_shell_command)
|
|
monkeypatch.setattr(
|
|
shell,
|
|
"check_safe_bin",
|
|
lambda command: SimpleNamespace(allowed=True, needs_approval=False, reason=""),
|
|
)
|
|
|
|
token = current_tool_context.set(
|
|
ToolContext(
|
|
is_owner=True,
|
|
caller_kind=CallerKind.CLI,
|
|
session_key="s1",
|
|
run_mode="full",
|
|
workspace_dir=str(tmp_path),
|
|
)
|
|
)
|
|
try:
|
|
result = await shell.exec_command("head -n 1 /etc/shadow 2>&1; echo exit=$?")
|
|
finally:
|
|
current_tool_context.reset(token)
|
|
|
|
assert calls == ["host"]
|
|
assert "Permission denied" in result
|
|
assert "sensitive_path" not in result
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_full_host_access_shell_strips_managed_proxy_environment(
|
|
monkeypatch,
|
|
tmp_path,
|
|
) -> None:
|
|
from opensquilla.tools.builtin import shell
|
|
|
|
seen_env: dict[str, str] = {}
|
|
|
|
class _Runtime:
|
|
effective = SimpleNamespace(sandbox_enabled=True)
|
|
|
|
async def _fake_host_shell_command(*args, **kwargs):
|
|
seen_env.update(kwargs["env"])
|
|
return "exit_code=0\n"
|
|
|
|
monkeypatch.setenv("HTTP_PROXY", "http://127.0.0.1:48123")
|
|
monkeypatch.setenv("HTTPS_PROXY", "http://127.0.0.1:48123")
|
|
monkeypatch.setenv("OPENSQUILLA_SANDBOX_NETWORK", "proxy_allowlist")
|
|
monkeypatch.setattr(shell, "get_runtime", lambda: _Runtime())
|
|
monkeypatch.setattr(shell, "_run_host_shell_command", _fake_host_shell_command)
|
|
monkeypatch.setattr(
|
|
shell,
|
|
"check_safe_bin",
|
|
lambda command: SimpleNamespace(allowed=True, needs_approval=False, reason=""),
|
|
)
|
|
|
|
token = current_tool_context.set(
|
|
ToolContext(
|
|
is_owner=True,
|
|
caller_kind=CallerKind.CLI,
|
|
session_key="s1",
|
|
run_mode="full",
|
|
workspace_dir=str(tmp_path),
|
|
)
|
|
)
|
|
try:
|
|
await shell.exec_command("env")
|
|
finally:
|
|
current_tool_context.reset(token)
|
|
|
|
assert "OPENSQUILLA_SANDBOX_NETWORK" not in seen_env
|
|
assert "HTTP_PROXY" not in seen_env
|
|
assert "HTTPS_PROXY" not in seen_env
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_full_host_access_background_strips_managed_proxy_environment(
|
|
monkeypatch,
|
|
tmp_path,
|
|
) -> None:
|
|
from opensquilla.tools.builtin import shell
|
|
|
|
seen_env: dict[str, str] = {}
|
|
|
|
class _Runtime:
|
|
effective = SimpleNamespace(sandbox_enabled=True)
|
|
|
|
class _FakeProcess:
|
|
stdout = None
|
|
stdin = None
|
|
returncode = 0
|
|
|
|
async def wait(self) -> int:
|
|
return 0
|
|
|
|
async def _fake_create_subprocess_shell(*args, **kwargs):
|
|
seen_env.update(kwargs["env"])
|
|
return _FakeProcess()
|
|
|
|
monkeypatch.setenv("HTTP_PROXY", "http://127.0.0.1:48123")
|
|
monkeypatch.setenv("HTTPS_PROXY", "http://127.0.0.1:48123")
|
|
monkeypatch.setenv("OPENSQUILLA_SANDBOX_NETWORK", "proxy_allowlist")
|
|
monkeypatch.setattr(shell, "get_runtime", lambda: _Runtime())
|
|
monkeypatch.setattr(shell.asyncio, "create_subprocess_shell", _fake_create_subprocess_shell)
|
|
monkeypatch.setattr(
|
|
shell,
|
|
"check_safe_bin",
|
|
lambda command: SimpleNamespace(allowed=True, needs_approval=False, reason=""),
|
|
)
|
|
|
|
token = current_tool_context.set(
|
|
ToolContext(
|
|
is_owner=True,
|
|
caller_kind=CallerKind.CLI,
|
|
session_key="s1",
|
|
run_mode="full",
|
|
workspace_dir=str(tmp_path),
|
|
)
|
|
)
|
|
try:
|
|
result = await shell.background_process("env")
|
|
session_id = result.splitlines()[0].split("=", 1)[1]
|
|
session = shell._bg_sessions[session_id]
|
|
assert session.collector_task is not None
|
|
await session.collector_task
|
|
finally:
|
|
current_tool_context.reset(token)
|
|
|
|
assert "OPENSQUILLA_SANDBOX_NETWORK" not in seen_env
|
|
assert "HTTP_PROXY" not in seen_env
|
|
assert "HTTPS_PROXY" not in seen_env
|