227 lines
6.9 KiB
Python
227 lines
6.9 KiB
Python
from __future__ import annotations
|
|
|
|
import asyncio
|
|
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 import integration as integration_mod
|
|
from opensquilla.sandbox.network_proxy import SandboxProxyServer
|
|
from opensquilla.sandbox.network_runtime import (
|
|
NetworkApprovalService,
|
|
NetworkPolicyRequest,
|
|
NetworkProtocol,
|
|
)
|
|
from opensquilla.sandbox.run_context import RunContext
|
|
from opensquilla.sandbox.run_mode import RunMode
|
|
from opensquilla.sandbox.types import (
|
|
NetworkMode,
|
|
ResourceLimits,
|
|
SandboxPolicy,
|
|
SandboxRequest,
|
|
SecurityLevel,
|
|
)
|
|
from opensquilla.tools.types import CallerKind, ToolContext, current_tool_context
|
|
|
|
|
|
def _proxy_policy() -> SandboxPolicy:
|
|
return SandboxPolicy(
|
|
level=SecurityLevel.STANDARD,
|
|
network=NetworkMode.PROXY_ALLOWLIST,
|
|
mounts=(),
|
|
workspace_rw=True,
|
|
tmp_writable=True,
|
|
limits=ResourceLimits(),
|
|
env_allowlist=("PATH",),
|
|
require_approval=False,
|
|
)
|
|
|
|
|
|
async def _send_proxy_request(server: SandboxProxyServer, request: bytes) -> bytes:
|
|
reader, writer = await asyncio.open_connection(server.host, server.port)
|
|
try:
|
|
writer.write(request)
|
|
await writer.drain()
|
|
return await asyncio.wait_for(reader.read(4096), timeout=2.0)
|
|
finally:
|
|
writer.close()
|
|
await writer.wait_closed()
|
|
|
|
|
|
async def _wait_for_pending_network_approval() -> dict:
|
|
for _ in range(100):
|
|
pending = get_approval_queue().list_pending("exec")
|
|
if pending:
|
|
assert len(pending) == 1
|
|
return pending[0]
|
|
await asyncio.sleep(0.01)
|
|
raise AssertionError("network approval was not queued")
|
|
|
|
|
|
async def test_proxy_runtime_approval_waits_and_forwards_after_allow(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
tmp_path: Path,
|
|
) -> None:
|
|
reset_approval_queue()
|
|
upstream_requests: list[bytes] = []
|
|
|
|
async def handle_upstream(
|
|
reader: asyncio.StreamReader,
|
|
writer: asyncio.StreamWriter,
|
|
) -> None:
|
|
upstream_requests.append(await reader.readuntil(b"\r\n\r\n"))
|
|
writer.write(
|
|
b"HTTP/1.1 200 OK\r\n"
|
|
b"Content-Length: 2\r\n"
|
|
b"Connection: close\r\n"
|
|
b"\r\n"
|
|
b"ok"
|
|
)
|
|
await writer.drain()
|
|
writer.close()
|
|
await writer.wait_closed()
|
|
|
|
upstream = await asyncio.start_server(handle_upstream, "127.0.0.1", 0)
|
|
upstream_socket = next(iter(upstream.sockets or ()), None)
|
|
assert upstream_socket is not None
|
|
upstream_host, upstream_port = upstream_socket.getsockname()[:2]
|
|
|
|
real_open_connection = asyncio.open_connection
|
|
|
|
async def fake_open_connection(host: str, port: int, *args: object, **kwargs: object):
|
|
if host == "93.184.216.34" and port == upstream_port:
|
|
return await real_open_connection(str(upstream_host), upstream_port, *args, **kwargs)
|
|
return await real_open_connection(host, port, *args, **kwargs)
|
|
|
|
monkeypatch.setattr(asyncio, "open_connection", fake_open_connection)
|
|
|
|
request = SandboxRequest(
|
|
argv=("exec_command", "curl", "http://unknown.test/path"),
|
|
cwd=tmp_path,
|
|
action_kind="shell.exec",
|
|
policy=_proxy_policy(),
|
|
session_id="s1",
|
|
run_mode="standard",
|
|
)
|
|
runtime = SimpleNamespace(workspace=tmp_path)
|
|
service = NetworkApprovalService(
|
|
context=RunContext(run_mode=RunMode.STANDARD),
|
|
request=request,
|
|
runtime=runtime,
|
|
approval_timeout_seconds=2.0,
|
|
)
|
|
server = SandboxProxyServer(
|
|
policy_decider=service,
|
|
resolver=lambda host, port: ("93.184.216.34", upstream_port),
|
|
)
|
|
await server.start()
|
|
try:
|
|
response_task = asyncio.create_task(
|
|
_send_proxy_request(
|
|
server,
|
|
b"GET http://unknown.test/path HTTP/1.1\r\n"
|
|
b"Host: unknown.test\r\n"
|
|
b"\r\n",
|
|
)
|
|
)
|
|
pending = await _wait_for_pending_network_approval()
|
|
params = pending["params"]
|
|
assert params["approvalKind"] == "sandbox_network"
|
|
assert params["host"] == "unknown.test"
|
|
assert params["sessionKey"] == "s1"
|
|
assert params["fingerprint"]
|
|
|
|
get_approval_queue().resolve(str(pending["id"]), True)
|
|
response = await response_task
|
|
finally:
|
|
await server.stop()
|
|
upstream.close()
|
|
await upstream.wait_closed()
|
|
|
|
assert response.startswith(b"HTTP/1.1 200 OK")
|
|
assert b"ok" in response
|
|
assert upstream_requests == [
|
|
b"GET /path HTTP/1.1\r\n"
|
|
b"Host: unknown.test\r\n"
|
|
b"Connection: close\r\n"
|
|
b"\r\n"
|
|
]
|
|
|
|
|
|
async def test_trusted_runtime_network_decider_allows_without_approval(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
reset_approval_queue()
|
|
request = SandboxRequest(
|
|
argv=("exec_command", "curl", "https://new-public.example"),
|
|
cwd=tmp_path,
|
|
action_kind="shell.exec",
|
|
policy=_proxy_policy(),
|
|
session_id="s1",
|
|
run_mode="trusted",
|
|
)
|
|
service = NetworkApprovalService(
|
|
context=RunContext(run_mode=RunMode.TRUSTED),
|
|
request=request,
|
|
runtime=SimpleNamespace(workspace=tmp_path),
|
|
approval_timeout_seconds=0.01,
|
|
)
|
|
|
|
decision = await service.decide(
|
|
NetworkPolicyRequest(
|
|
protocol=NetworkProtocol.HTTPS_CONNECT,
|
|
host="new-public.example",
|
|
port=443,
|
|
method="CONNECT",
|
|
tool_name="exec_command",
|
|
command="curl https://new-public.example",
|
|
)
|
|
)
|
|
|
|
assert decision.status == "allow"
|
|
assert decision.reason == "auto_trusted"
|
|
assert get_approval_queue().list_pending("exec") == []
|
|
|
|
|
|
async def test_subprocess_preflight_leaves_explicit_url_approval_to_proxy_runtime(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
reset_approval_queue()
|
|
request = SandboxRequest(
|
|
argv=("exec_command", "curl", "https://unknown.test/path"),
|
|
cwd=tmp_path,
|
|
action_kind="shell.exec",
|
|
policy=_proxy_policy(),
|
|
session_id="s1",
|
|
run_mode="standard",
|
|
)
|
|
runtime = SimpleNamespace(
|
|
backend=SimpleNamespace(name="noop"),
|
|
workspace=tmp_path,
|
|
)
|
|
token = current_tool_context.set(
|
|
ToolContext(
|
|
is_owner=True,
|
|
caller_kind=CallerKind.CLI,
|
|
workspace_dir=str(tmp_path),
|
|
session_key="s1",
|
|
run_mode="standard",
|
|
sandbox_run_context=RunContext(
|
|
run_mode=RunMode.STANDARD,
|
|
workspace=str(tmp_path),
|
|
),
|
|
)
|
|
)
|
|
try:
|
|
result = await integration_mod.preflight_subprocess_managed_network(
|
|
request,
|
|
runtime,
|
|
)
|
|
finally:
|
|
current_tool_context.reset(token)
|
|
|
|
assert result is None
|
|
assert get_approval_queue().list_pending("exec") == []
|