1811 lines
56 KiB
Python
1811 lines
56 KiB
Python
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import json
|
|
import os
|
|
import socket
|
|
from collections.abc import Iterator
|
|
from pathlib import Path
|
|
from types import SimpleNamespace
|
|
from urllib.parse import urlsplit
|
|
|
|
import pytest
|
|
|
|
from opensquilla.env import trust_env
|
|
from opensquilla.gateway import rpc_tools
|
|
from opensquilla.gateway.approval_queue import get_approval_queue, reset_approval_queue
|
|
from opensquilla.gateway.auth import Principal
|
|
from opensquilla.gateway.rpc import RpcContext
|
|
from opensquilla.sandbox import integration as integration_mod
|
|
from opensquilla.sandbox.config import SandboxSettings
|
|
from opensquilla.sandbox.integration import configure_runtime, reset_runtime, sandboxed
|
|
from opensquilla.sandbox.network_guard import NetworkDecision
|
|
from opensquilla.sandbox.network_proxy import SandboxProxyServer as RealSandboxProxyServer
|
|
from opensquilla.sandbox.run_context import (
|
|
DomainGrant,
|
|
RunContext,
|
|
TemporaryGrant,
|
|
get_run_context,
|
|
)
|
|
from opensquilla.sandbox.run_mode import RunMode
|
|
from opensquilla.sandbox.types import (
|
|
DenialResult,
|
|
NetworkMode,
|
|
ResourceLimits,
|
|
SandboxPolicy,
|
|
SecurityLevel,
|
|
)
|
|
from opensquilla.tools.builtin import web as web_mod
|
|
from opensquilla.tools.builtin import web_fetch as web_fetch_mod
|
|
from opensquilla.tools.types import CallerKind, ToolContext, current_tool_context
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def sandbox_runtime(tmp_path: Path) -> Iterator[None]:
|
|
reset_approval_queue()
|
|
workspace = tmp_path / "workspace"
|
|
workspace.mkdir()
|
|
configure_runtime(
|
|
SandboxSettings(
|
|
run_mode="standard",
|
|
backend="noop",
|
|
allow_legacy_mode=True,
|
|
network_default="proxy_allowlist",
|
|
),
|
|
workspace=workspace,
|
|
)
|
|
try:
|
|
yield
|
|
finally:
|
|
reset_approval_queue()
|
|
reset_runtime()
|
|
|
|
|
|
@pytest.fixture
|
|
def managed_context(tmp_path: Path) -> Iterator[ToolContext]:
|
|
ctx = 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,
|
|
domains=(DomainGrant(domain="allowed.test"),),
|
|
),
|
|
)
|
|
token = current_tool_context.set(ctx)
|
|
try:
|
|
yield ctx
|
|
finally:
|
|
current_tool_context.reset(token)
|
|
|
|
|
|
async def _send_current_managed_proxy_request(request: bytes) -> bytes:
|
|
proxy_url = integration_mod.current_managed_network_proxy_url()
|
|
assert proxy_url is not None
|
|
parsed = urlsplit(proxy_url)
|
|
assert parsed.hostname is not None
|
|
assert parsed.port is not None
|
|
reader, writer = await asyncio.open_connection(parsed.hostname, parsed.port)
|
|
try:
|
|
writer.write(request)
|
|
await writer.drain()
|
|
return await reader.read(4096)
|
|
finally:
|
|
writer.close()
|
|
await writer.wait_closed()
|
|
|
|
|
|
def _install_trusted_session_handles(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
tmp_path: Path,
|
|
):
|
|
from opensquilla.tools.builtin import sessions as sessions_mod
|
|
|
|
workspace = tmp_path / "workspace"
|
|
workspace.mkdir(exist_ok=True)
|
|
manager = SimpleNamespace()
|
|
manager.node = SimpleNamespace(
|
|
session_key="s1",
|
|
agent_id="main",
|
|
origin={
|
|
"sandbox_run_context": RunContext(run_mode=RunMode.TRUSTED).to_origin_payload(),
|
|
},
|
|
)
|
|
|
|
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(
|
|
workspace_dir=str(workspace),
|
|
agents=[],
|
|
sandbox=SimpleNamespace(
|
|
run_mode="trusted",
|
|
sandbox=True,
|
|
security_grading=True,
|
|
backend="noop",
|
|
network_default="proxy_allowlist",
|
|
),
|
|
permissions=SimpleNamespace(default_mode="off"),
|
|
)
|
|
monkeypatch.setattr(sessions_mod, "_session_manager", manager)
|
|
monkeypatch.setattr(sessions_mod, "_gateway_config", config)
|
|
return workspace, manager, config
|
|
|
|
|
|
def test_request_with_managed_network_proxy_env_overrides_user_proxy_env(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
policy = SandboxPolicy(
|
|
level=SecurityLevel.STANDARD,
|
|
network=NetworkMode.PROXY_ALLOWLIST,
|
|
mounts=(),
|
|
workspace_rw=True,
|
|
tmp_writable=True,
|
|
limits=ResourceLimits(),
|
|
env_allowlist=("PATH",),
|
|
require_approval=False,
|
|
network_proxy=integration_mod.NetworkProxySpec(host="127.0.0.1", port=18080),
|
|
)
|
|
request = integration_mod.SandboxRequest(
|
|
argv=("sh", "-lc", "curl https://example.com"),
|
|
cwd=tmp_path,
|
|
action_kind="shell.exec",
|
|
policy=policy,
|
|
env={
|
|
"PATH": "/bin",
|
|
"HTTP_PROXY": "http://attacker.invalid:1",
|
|
"http_proxy": "http://attacker.invalid:3",
|
|
"HTTPS_PROXY": "http://attacker.invalid:2",
|
|
"https_proxy": "http://attacker.invalid:4",
|
|
"npm_config_https_proxy": "http://attacker.invalid:5",
|
|
"PIP_PROXY": "http://attacker.invalid:6",
|
|
"WS_PROXY": "http://attacker.invalid:7",
|
|
"NO_PROXY": "*",
|
|
"no_proxy": "*",
|
|
},
|
|
)
|
|
|
|
updated = integration_mod.request_with_managed_network_proxy_env(request)
|
|
|
|
assert updated.env["PATH"] == "/bin"
|
|
for key in (
|
|
"HTTP_PROXY",
|
|
"HTTPS_PROXY",
|
|
"http_proxy",
|
|
"https_proxy",
|
|
"npm_config_https_proxy",
|
|
"PIP_PROXY",
|
|
"WS_PROXY",
|
|
):
|
|
assert updated.env[key] == "http://127.0.0.1:18080"
|
|
for key in ("NO_PROXY", "no_proxy"):
|
|
assert updated.env[key]
|
|
assert "http://attacker.invalid:1" not in updated.env.values()
|
|
assert "http://attacker.invalid:3" not in updated.env.values()
|
|
assert "http://attacker.invalid:5" not in updated.env.values()
|
|
assert "http://attacker.invalid:6" not in updated.env.values()
|
|
assert "http://attacker.invalid:7" not in updated.env.values()
|
|
for key in ("HTTP_PROXY", "HTTPS_PROXY", "NO_PROXY", "PIP_PROXY"):
|
|
assert key in updated.policy.env_allowlist
|
|
assert request.env["HTTP_PROXY"] == "http://attacker.invalid:1"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_url_shaped_inprocess_network_action_sets_context_proxy_without_env_mutation(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
managed_context: ToolContext,
|
|
) -> None:
|
|
events: list[str] = []
|
|
seen: dict[str, object] = {}
|
|
|
|
class FakeProxy:
|
|
host = "127.0.0.1"
|
|
port = 28080
|
|
|
|
def __init__(self, decide: object, **kwargs: object) -> None:
|
|
self._decide = decide
|
|
events.append("proxy.init")
|
|
|
|
async def start(self) -> None:
|
|
events.append("proxy.start")
|
|
decision = self._decide("allowed.test")
|
|
assert isinstance(decision, NetworkDecision)
|
|
seen["decision"] = decision.status
|
|
|
|
async def stop(self) -> None:
|
|
events.append("proxy.stop")
|
|
|
|
monkeypatch.setattr(integration_mod, "SandboxProxyServer", FakeProxy)
|
|
monkeypatch.setenv("HTTP_PROXY", "http://user.invalid:1")
|
|
monkeypatch.delenv("HTTPS_PROXY", raising=False)
|
|
monkeypatch.setenv("http_proxy", "http://user-lower.invalid:1")
|
|
monkeypatch.delenv("https_proxy", raising=False)
|
|
monkeypatch.setenv("ALL_PROXY", "http://all.invalid:1")
|
|
monkeypatch.delenv("all_proxy", raising=False)
|
|
monkeypatch.setenv("NO_PROXY", "*")
|
|
monkeypatch.delenv("no_proxy", raising=False)
|
|
monkeypatch.setenv("OPENSQUILLA_TRUST_ENV", "0")
|
|
expected_env = {
|
|
key: os.environ.get(key)
|
|
for key in (
|
|
"HTTP_PROXY",
|
|
"HTTPS_PROXY",
|
|
"http_proxy",
|
|
"https_proxy",
|
|
"ALL_PROXY",
|
|
"all_proxy",
|
|
"NO_PROXY",
|
|
"no_proxy",
|
|
"OPENSQUILLA_TRUST_ENV",
|
|
)
|
|
}
|
|
|
|
@sandboxed(
|
|
"network.http",
|
|
argv_factory=lambda a: ("http_request", "GET", str(a["url"])),
|
|
record_payload=False,
|
|
)
|
|
async def dummy_http_request(url: str) -> str:
|
|
seen["url"] = url
|
|
seen["proxy_url"] = integration_mod.current_managed_network_proxy_url()
|
|
seen["httpx_kwargs"] = integration_mod.managed_network_httpx_kwargs()
|
|
seen["trust_env"] = trust_env()
|
|
return "ok"
|
|
|
|
result = await dummy_http_request("http://allowed.test/path")
|
|
|
|
assert result == "ok"
|
|
assert seen["decision"] == "allow"
|
|
assert seen["proxy_url"] == "http://127.0.0.1:28080"
|
|
assert seen["httpx_kwargs"] == {
|
|
"proxy": "http://127.0.0.1:28080",
|
|
"trust_env": False,
|
|
}
|
|
assert seen["trust_env"] is False
|
|
assert events == ["proxy.init", "proxy.start", "proxy.stop"]
|
|
for key, expected in expected_env.items():
|
|
if expected is None:
|
|
assert key not in os.environ
|
|
else:
|
|
assert os.environ[key] == expected
|
|
assert integration_mod.current_managed_network_proxy_url() is None
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_http_request_uses_explicit_context_proxy_kwargs(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
managed_context: ToolContext,
|
|
) -> None:
|
|
seen: dict[str, object] = {}
|
|
|
|
class FakeProxy:
|
|
host = "127.0.0.1"
|
|
port = 28080
|
|
|
|
def __init__(self, decide: object, **kwargs: object) -> None:
|
|
self._decide = decide
|
|
|
|
async def start(self) -> None:
|
|
decision = self._decide("allowed.test")
|
|
assert isinstance(decision, NetworkDecision)
|
|
assert decision.status == "allow"
|
|
|
|
async def stop(self) -> None:
|
|
return None
|
|
|
|
class FakeAsyncClient:
|
|
def __init__(self, **kwargs: object) -> None:
|
|
seen["client_kwargs"] = kwargs
|
|
|
|
async def __aenter__(self) -> FakeAsyncClient:
|
|
return self
|
|
|
|
async def __aexit__(self, *args: object) -> None:
|
|
return None
|
|
|
|
async def request(self, **kwargs: object) -> object:
|
|
seen["request_kwargs"] = kwargs
|
|
return SimpleNamespace(
|
|
status_code=200,
|
|
url=kwargs["url"],
|
|
headers={"content-type": "text/plain"},
|
|
content=b"ok",
|
|
text="ok",
|
|
)
|
|
|
|
monkeypatch.setattr(integration_mod, "SandboxProxyServer", FakeProxy)
|
|
monkeypatch.setattr(web_mod.httpx, "AsyncClient", FakeAsyncClient)
|
|
|
|
payload = json.loads(await web_mod.http_request("http://allowed.test/path"))
|
|
|
|
assert payload["status"] == 200
|
|
assert seen["client_kwargs"] == {
|
|
"timeout": 30.0,
|
|
"proxy": "http://127.0.0.1:28080",
|
|
"trust_env": False,
|
|
}
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_unknown_explicit_target_does_not_preflight_without_network_request(
|
|
managed_context: ToolContext,
|
|
) -> None:
|
|
@sandboxed(
|
|
"network.http",
|
|
argv_factory=lambda a: ("http_request", "GET", str(a["url"])),
|
|
record_payload=False,
|
|
)
|
|
async def dummy_http_request(url: str) -> str:
|
|
return "ok"
|
|
|
|
result = await dummy_http_request("http://unknown.test/path")
|
|
|
|
assert result == "ok"
|
|
assert get_approval_queue().list_pending("exec") == []
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_parallel_unknown_targets_do_not_preflight_without_network_request(
|
|
managed_context: ToolContext,
|
|
) -> None:
|
|
@sandboxed(
|
|
"network.http",
|
|
argv_factory=lambda a: ("http_request", "GET", str(a["url"])),
|
|
record_payload=False,
|
|
)
|
|
async def dummy_http_request(url: str) -> str:
|
|
return "ok"
|
|
|
|
first, second = await asyncio.gather(
|
|
dummy_http_request("http://first-unknown.test/path"),
|
|
dummy_http_request("http://second-unknown.test/path"),
|
|
)
|
|
|
|
assert first == "ok"
|
|
assert second == "ok"
|
|
assert get_approval_queue().list_pending("exec") == []
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_temporary_network_grant_allows_retry_for_explicit_target(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
managed_context: ToolContext,
|
|
) -> None:
|
|
class FakeProxy:
|
|
host = "127.0.0.1"
|
|
port = 28080
|
|
|
|
def __init__(self, decide: object, **kwargs: object) -> None:
|
|
self._decide = decide
|
|
|
|
async def start(self) -> None:
|
|
decision = self._decide("unknown.test")
|
|
assert isinstance(decision, NetworkDecision)
|
|
assert decision.status == "allow"
|
|
|
|
async def stop(self) -> None:
|
|
return None
|
|
|
|
monkeypatch.setattr(integration_mod, "SandboxProxyServer", FakeProxy)
|
|
|
|
request = integration_mod.build_request(
|
|
action_kind="network.http",
|
|
argv=("http_request", "GET", "http://unknown.test/path"),
|
|
cwd=Path(managed_context.workspace_dir or "."),
|
|
policy=SandboxPolicy(
|
|
level=SecurityLevel.STANDARD,
|
|
network=NetworkMode.PROXY_ALLOWLIST,
|
|
mounts=(),
|
|
workspace_rw=True,
|
|
tmp_writable=True,
|
|
limits=ResourceLimits(),
|
|
env_allowlist=("PATH",),
|
|
require_approval=False,
|
|
),
|
|
)
|
|
managed_context.sandbox_run_context = RunContext(
|
|
run_mode=RunMode.STANDARD,
|
|
temporary_grants=(
|
|
TemporaryGrant(
|
|
kind="domain",
|
|
value="unknown.test",
|
|
fingerprint=integration_mod.action_fingerprint(request),
|
|
),
|
|
),
|
|
)
|
|
|
|
@sandboxed(
|
|
"network.http",
|
|
argv_factory=lambda a: ("http_request", "GET", str(a["url"])),
|
|
record_payload=False,
|
|
)
|
|
async def dummy_http_request(url: str) -> str:
|
|
return "ok"
|
|
result = await dummy_http_request("http://unknown.test/path")
|
|
|
|
assert result == "ok"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_windows_unready_boundary_blocks_decorated_network_tools(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
managed_context: ToolContext,
|
|
) -> None:
|
|
runtime = integration_mod.get_runtime()
|
|
assert runtime is not None
|
|
runtime.backend = SimpleNamespace(name="windows_default")
|
|
managed_context.sandbox_run_context = RunContext(run_mode=RunMode.TRUSTED)
|
|
|
|
class _UnexpectedClient:
|
|
def __init__(self, *args: object, **kwargs: object) -> None:
|
|
pytest.fail("network tool bypassed Windows managed-network readiness")
|
|
|
|
monkeypatch.setattr(
|
|
integration_mod,
|
|
"_windows_proxy_allowlist_enforced",
|
|
lambda runtime: False,
|
|
)
|
|
async def _repair_failed(runtime) -> bool:
|
|
return False
|
|
|
|
monkeypatch.setattr(
|
|
integration_mod,
|
|
"_ensure_windows_proxy_allowlist_setup",
|
|
_repair_failed,
|
|
)
|
|
monkeypatch.setattr(web_mod.httpx, "AsyncClient", _UnexpectedClient)
|
|
monkeypatch.setattr(web_fetch_mod.httpx, "AsyncClient", _UnexpectedClient)
|
|
monkeypatch.setattr(web_fetch_mod, "_check_ssrf", lambda url: None)
|
|
|
|
http_payload = json.loads(await web_mod.http_request("https://example.com"))
|
|
fetch_payload = json.loads(await web_fetch_mod.web_fetch("https://example.com"))
|
|
|
|
assert http_payload["status"] == "denied"
|
|
assert fetch_payload["status"] == "denied"
|
|
assert "Windows sandbox managed network is unavailable" in http_payload["message"]
|
|
assert "Windows sandbox managed network is unavailable" in fetch_payload["message"]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_windows_unready_boundary_does_not_trigger_elevation_when_not_admin(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
managed_context: ToolContext,
|
|
) -> None:
|
|
runtime = integration_mod.get_runtime()
|
|
assert runtime is not None
|
|
runtime.backend = SimpleNamespace(name="windows_default")
|
|
managed_context.sandbox_run_context = RunContext(run_mode=RunMode.TRUSTED)
|
|
|
|
monkeypatch.setattr(
|
|
integration_mod,
|
|
"_windows_proxy_allowlist_enforced",
|
|
lambda runtime: False,
|
|
)
|
|
monkeypatch.setattr(integration_mod, "_windows_process_is_admin", lambda: False)
|
|
|
|
async def fail_if_called(settings):
|
|
pytest.fail("Trusted managed-network repair must not trigger elevation")
|
|
|
|
monkeypatch.setattr(integration_mod, "ensure_sandbox_setup", fail_if_called, raising=False)
|
|
|
|
assert await integration_mod._windows_proxy_allowlist_ready_or_repaired(runtime) is False
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_windows_unready_boundary_repairs_when_already_admin(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
managed_context: ToolContext,
|
|
) -> None:
|
|
runtime = integration_mod.get_runtime()
|
|
assert runtime is not None
|
|
runtime.backend = SimpleNamespace(name="windows_default")
|
|
managed_context.sandbox_run_context = RunContext(run_mode=RunMode.TRUSTED)
|
|
calls: list[object] = []
|
|
|
|
monkeypatch.setattr(
|
|
integration_mod,
|
|
"_windows_proxy_allowlist_enforced",
|
|
lambda runtime: False,
|
|
)
|
|
monkeypatch.setattr(integration_mod, "_windows_process_is_admin", lambda: True)
|
|
|
|
async def _repair(runtime):
|
|
calls.append(runtime)
|
|
return True
|
|
|
|
monkeypatch.setattr(integration_mod, "_ensure_windows_proxy_allowlist_setup", _repair)
|
|
|
|
assert await integration_mod._windows_proxy_allowlist_ready_or_repaired(runtime) is True
|
|
assert calls == [runtime]
|
|
|
|
|
|
def test_windows_unavailable_message_reports_outdated_network_marker(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
tmp_path: Path,
|
|
) -> None:
|
|
from opensquilla.sandbox.backend import windows_default_setup as setup_mod
|
|
from opensquilla.sandbox.backend.windows_default_network import (
|
|
WindowsNetworkSetup,
|
|
)
|
|
|
|
marker = tmp_path / "setup_marker.json"
|
|
setup_mod.write_setup_marker(
|
|
marker,
|
|
network=WindowsNetworkSetup(
|
|
offline_user_sid="S-1-5-21-100-200-300-400",
|
|
allowed_proxy_ports=(48123,),
|
|
allow_local_binding=False,
|
|
firewall_rule_version=1,
|
|
wfp_rule_version=1,
|
|
),
|
|
)
|
|
monkeypatch.setattr(setup_mod, "default_setup_marker_path", lambda home=None: marker)
|
|
runtime = SimpleNamespace(backend=SimpleNamespace(name="windows_default"))
|
|
|
|
message = integration_mod._windows_managed_network_unavailable_message(runtime)
|
|
|
|
assert "network marker is out of date" in message
|
|
assert "firewall=1 required=5" in message
|
|
assert "wfp=1 required=2" in message
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_windows_unready_boundary_blocks_rpc_network_action(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
managed_context: ToolContext,
|
|
) -> None:
|
|
runtime = integration_mod.get_runtime()
|
|
assert runtime is not None
|
|
runtime.backend = SimpleNamespace(name="windows_default")
|
|
managed_context.sandbox_run_context = RunContext(run_mode=RunMode.TRUSTED)
|
|
called = False
|
|
|
|
async def _callback() -> str:
|
|
nonlocal called
|
|
called = True
|
|
return "unexpected"
|
|
|
|
monkeypatch.setattr(
|
|
integration_mod,
|
|
"_windows_proxy_allowlist_enforced",
|
|
lambda runtime: False,
|
|
)
|
|
async def _repair_failed(runtime) -> bool:
|
|
return False
|
|
|
|
monkeypatch.setattr(
|
|
integration_mod,
|
|
"_ensure_windows_proxy_allowlist_setup",
|
|
_repair_failed,
|
|
)
|
|
|
|
result = await integration_mod.run_in_process_network_action(
|
|
action_kind="network.http",
|
|
argv=("http_request", "GET", "https://example.com"),
|
|
callback=_callback,
|
|
)
|
|
|
|
assert called is False
|
|
assert isinstance(result, DenialResult)
|
|
assert result.retryable is False
|
|
assert "Windows sandbox managed network is unavailable" in result.message
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_allow_once_resolve_allows_one_retry_then_expires_for_explicit_target(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
managed_context: ToolContext,
|
|
) -> None:
|
|
class FakeProxy:
|
|
host = "127.0.0.1"
|
|
port = 28080
|
|
|
|
def __init__(self, decide: object, **kwargs: object) -> None:
|
|
self._decide = decide
|
|
|
|
async def start(self) -> None:
|
|
return None
|
|
|
|
async def stop(self) -> None:
|
|
return None
|
|
|
|
monkeypatch.setattr(integration_mod, "SandboxProxyServer", FakeProxy)
|
|
|
|
@sandboxed(
|
|
"network.http",
|
|
argv_factory=lambda a: ("http_request", "GET", str(a["url"])),
|
|
record_payload=False,
|
|
)
|
|
async def dummy_http_request(url: str) -> str:
|
|
return "ok"
|
|
|
|
first = await dummy_http_request("http://unknown.test/path")
|
|
second = await dummy_http_request("http://unknown.test/path")
|
|
|
|
assert first == "ok"
|
|
assert second == "ok"
|
|
assert get_approval_queue().list_pending("exec") == []
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_allow_same_type_choice_keeps_later_unknown_public_targets_scoped(
|
|
managed_context: ToolContext,
|
|
) -> None:
|
|
@sandboxed(
|
|
"network.http",
|
|
argv_factory=lambda a: ("http_request", "GET", str(a["url"])),
|
|
record_payload=False,
|
|
)
|
|
async def dummy_http_request(url: str) -> str:
|
|
return "ok"
|
|
|
|
result = await dummy_http_request("http://docs.example.com/path")
|
|
|
|
assert result == "ok"
|
|
assert get_approval_queue().list_pending("exec") == []
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_persisted_temporary_grant_from_saved_origin_does_not_allow_after_restart(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
tmp_path: Path,
|
|
) -> None:
|
|
seen: dict[str, object] = {}
|
|
|
|
class FakeProxy:
|
|
host = "127.0.0.1"
|
|
port = 28080
|
|
|
|
def __init__(self, decide: object, **kwargs: object) -> None:
|
|
self._decide = decide
|
|
|
|
async def start(self) -> None:
|
|
decision = self._decide("unknown.test")
|
|
assert isinstance(decision, NetworkDecision)
|
|
seen["decision"] = decision.status
|
|
|
|
async def stop(self) -> None:
|
|
return None
|
|
|
|
monkeypatch.setattr(integration_mod, "SandboxProxyServer", FakeProxy)
|
|
workspace = tmp_path / "workspace"
|
|
workspace.mkdir(exist_ok=True)
|
|
|
|
@sandboxed(
|
|
"network.http",
|
|
argv_factory=lambda a: ("http_request", "GET", str(a["url"])),
|
|
record_payload=False,
|
|
)
|
|
async def dummy_http_request(url: str) -> str:
|
|
return "ok"
|
|
|
|
persisted = RunContext(
|
|
run_mode=RunMode.STANDARD,
|
|
temporary_grants=(
|
|
TemporaryGrant(
|
|
kind="domain",
|
|
value="unknown.test",
|
|
fingerprint="legacy-fp",
|
|
),
|
|
),
|
|
source="saved",
|
|
)
|
|
token = current_tool_context.set(
|
|
ToolContext(
|
|
is_owner=True,
|
|
caller_kind=CallerKind.CLI,
|
|
workspace_dir=str(workspace),
|
|
session_key="s1",
|
|
run_mode="standard",
|
|
sandbox_run_context=persisted,
|
|
)
|
|
)
|
|
try:
|
|
result = await dummy_http_request("http://unknown.test/path")
|
|
finally:
|
|
current_tool_context.reset(token)
|
|
|
|
assert result == "ok"
|
|
assert seen["decision"] == "ask"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_trusted_explicit_target_does_not_auto_add_before_proxy_upstream(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
tmp_path: Path,
|
|
) -> None:
|
|
class FakeProxy:
|
|
host = "127.0.0.1"
|
|
port = 28080
|
|
|
|
def __init__(self, decide: object, **kwargs: object) -> None:
|
|
self._decide = decide
|
|
|
|
async def start(self) -> None:
|
|
decision = self._decide("api.github.com")
|
|
assert isinstance(decision, NetworkDecision)
|
|
assert decision.status == "allow"
|
|
assert decision.reason == "auto_trusted"
|
|
|
|
async def stop(self) -> None:
|
|
return None
|
|
|
|
monkeypatch.setattr(integration_mod, "SandboxProxyServer", FakeProxy)
|
|
workspace, manager, config = _install_trusted_session_handles(monkeypatch, tmp_path)
|
|
|
|
@sandboxed(
|
|
"network.http",
|
|
argv_factory=lambda a: ("http_request", "GET", str(a["url"])),
|
|
record_payload=False,
|
|
)
|
|
async def dummy_http_request(url: str) -> str:
|
|
return "ok"
|
|
|
|
token = current_tool_context.set(
|
|
ToolContext(
|
|
is_owner=True,
|
|
caller_kind=CallerKind.CLI,
|
|
workspace_dir=str(workspace),
|
|
session_key="s1",
|
|
run_mode="trusted",
|
|
sandbox_run_context=RunContext(run_mode=RunMode.TRUSTED, source="route_metadata"),
|
|
)
|
|
)
|
|
try:
|
|
result = await dummy_http_request("https://api.github.com/repos/openai")
|
|
finally:
|
|
current_tool_context.reset(token)
|
|
|
|
assert result == "ok"
|
|
saved = await get_run_context(manager, "s1", config=config, workspace=str(workspace))
|
|
assert DomainGrant(
|
|
domain="api.github.com",
|
|
scope="chat",
|
|
source="auto_trusted",
|
|
) not in saved.domains
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_trusted_inprocess_auto_trust_does_not_persist_private_resolution(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
tmp_path: Path,
|
|
) -> None:
|
|
workspace, manager, config = _install_trusted_session_handles(monkeypatch, tmp_path)
|
|
real_getaddrinfo = socket.getaddrinfo
|
|
|
|
def fake_getaddrinfo(host: str, port: int, *args: object, **kwargs: object) -> list[tuple]:
|
|
if host == "new-public.example":
|
|
return [
|
|
(
|
|
socket.AF_INET,
|
|
socket.SOCK_STREAM,
|
|
socket.IPPROTO_TCP,
|
|
"",
|
|
("127.0.0.1", port),
|
|
)
|
|
]
|
|
return real_getaddrinfo(host, port, *args, **kwargs)
|
|
|
|
monkeypatch.setattr(socket, "getaddrinfo", fake_getaddrinfo)
|
|
|
|
@sandboxed(
|
|
"network.http",
|
|
argv_factory=lambda a: ("http_request", "GET", str(a["url"])),
|
|
record_payload=False,
|
|
)
|
|
async def dummy_http_request(url: str) -> bytes:
|
|
return await _send_current_managed_proxy_request(
|
|
b"GET http://new-public.example/path HTTP/1.1\r\n\r\n"
|
|
)
|
|
|
|
token = current_tool_context.set(
|
|
ToolContext(
|
|
is_owner=True,
|
|
caller_kind=CallerKind.CLI,
|
|
workspace_dir=str(workspace),
|
|
session_key="s1",
|
|
run_mode="trusted",
|
|
sandbox_run_context=RunContext(run_mode=RunMode.TRUSTED, source="route_metadata"),
|
|
)
|
|
)
|
|
try:
|
|
response = await dummy_http_request("http://new-public.example/path")
|
|
finally:
|
|
current_tool_context.reset(token)
|
|
|
|
assert response.startswith(b"HTTP/1.1 403")
|
|
saved = await get_run_context(manager, "s1", config=config, workspace=str(workspace))
|
|
assert DomainGrant(
|
|
domain="new-public.example",
|
|
scope="chat",
|
|
source="auto_trusted",
|
|
) not in saved.domains
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_trusted_inprocess_auto_trust_persists_after_safe_proxy_upstream(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
tmp_path: Path,
|
|
) -> None:
|
|
async def handle_upstream(
|
|
reader: asyncio.StreamReader,
|
|
writer: asyncio.StreamWriter,
|
|
) -> None:
|
|
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]
|
|
public_upstream_host = "93.184.216.34"
|
|
real_open_connection = asyncio.open_connection
|
|
|
|
async def fake_open_connection(
|
|
host: str,
|
|
port: int,
|
|
*args: object,
|
|
**kwargs: object,
|
|
) -> tuple[asyncio.StreamReader, asyncio.StreamWriter]:
|
|
if str(host) == public_upstream_host and int(port) == int(upstream_port):
|
|
return await real_open_connection(
|
|
str(upstream_host),
|
|
int(upstream_port),
|
|
*args,
|
|
**kwargs,
|
|
)
|
|
return await real_open_connection(host, port, *args, **kwargs)
|
|
|
|
monkeypatch.setattr(asyncio, "open_connection", fake_open_connection)
|
|
|
|
def proxy_factory(decide: object, **kwargs: object) -> RealSandboxProxyServer:
|
|
return RealSandboxProxyServer(
|
|
decide,
|
|
resolver=lambda host, port: (public_upstream_host, int(upstream_port)),
|
|
**kwargs,
|
|
)
|
|
|
|
monkeypatch.setattr(integration_mod, "SandboxProxyServer", proxy_factory)
|
|
workspace, manager, config = _install_trusted_session_handles(monkeypatch, tmp_path)
|
|
|
|
@sandboxed(
|
|
"network.http",
|
|
argv_factory=lambda a: ("http_request", "GET", str(a["url"])),
|
|
record_payload=False,
|
|
)
|
|
async def dummy_http_request(url: str) -> bytes:
|
|
return await _send_current_managed_proxy_request(
|
|
b"GET http://new-public.example/path HTTP/1.1\r\n\r\n"
|
|
)
|
|
|
|
token = current_tool_context.set(
|
|
ToolContext(
|
|
is_owner=True,
|
|
caller_kind=CallerKind.CLI,
|
|
workspace_dir=str(workspace),
|
|
session_key="s1",
|
|
run_mode="trusted",
|
|
sandbox_run_context=RunContext(run_mode=RunMode.TRUSTED, source="route_metadata"),
|
|
)
|
|
)
|
|
try:
|
|
response = await dummy_http_request("http://new-public.example/path")
|
|
finally:
|
|
current_tool_context.reset(token)
|
|
upstream.close()
|
|
await upstream.wait_closed()
|
|
|
|
assert response.startswith(b"HTTP/1.1 200 OK")
|
|
saved = await get_run_context(manager, "s1", config=config, workspace=str(workspace))
|
|
assert DomainGrant(
|
|
domain="new-public.example",
|
|
scope="chat",
|
|
source="auto_trusted",
|
|
) in saved.domains
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_trusted_explicit_target_auto_adds_chat_domain_grant_in_production_path(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
tmp_path: Path,
|
|
) -> None:
|
|
from opensquilla.tools.builtin import sessions as sessions_mod
|
|
|
|
seen: dict[str, object] = {}
|
|
|
|
class FakeProxy:
|
|
host = "127.0.0.1"
|
|
port = 28080
|
|
|
|
def __init__(self, decide: object, **kwargs: object) -> None:
|
|
self._decide = decide
|
|
self._on_upstream_opened = kwargs.get("on_upstream_opened")
|
|
|
|
async def start(self) -> None:
|
|
decision = self._decide("api.github.com")
|
|
assert isinstance(decision, NetworkDecision)
|
|
assert decision.status == "allow"
|
|
assert self._on_upstream_opened is not None
|
|
await self._on_upstream_opened(decision)
|
|
seen["callback_called"] = True
|
|
|
|
async def stop(self) -> None:
|
|
return None
|
|
|
|
monkeypatch.setattr(integration_mod, "SandboxProxyServer", FakeProxy)
|
|
workspace = tmp_path / "workspace"
|
|
workspace.mkdir(exist_ok=True)
|
|
manager = SimpleNamespace()
|
|
manager.node = SimpleNamespace(
|
|
session_key="s1",
|
|
agent_id="main",
|
|
origin={
|
|
"sandbox_run_context": RunContext(run_mode=RunMode.TRUSTED).to_origin_payload(),
|
|
},
|
|
)
|
|
|
|
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(
|
|
workspace_dir=str(workspace),
|
|
agents=[],
|
|
sandbox=SimpleNamespace(
|
|
run_mode="trusted",
|
|
sandbox=True,
|
|
security_grading=True,
|
|
backend="noop",
|
|
network_default="proxy_allowlist",
|
|
),
|
|
permissions=SimpleNamespace(default_mode="off"),
|
|
)
|
|
monkeypatch.setattr(sessions_mod, "_session_manager", manager)
|
|
monkeypatch.setattr(sessions_mod, "_gateway_config", config)
|
|
|
|
@sandboxed(
|
|
"network.http",
|
|
argv_factory=lambda a: ("http_request", "GET", str(a["url"])),
|
|
record_payload=False,
|
|
)
|
|
async def dummy_http_request(url: str) -> str:
|
|
return "ok"
|
|
|
|
token = current_tool_context.set(
|
|
ToolContext(
|
|
is_owner=True,
|
|
caller_kind=CallerKind.CLI,
|
|
workspace_dir=str(workspace),
|
|
session_key="s1",
|
|
run_mode="trusted",
|
|
sandbox_run_context=RunContext(run_mode=RunMode.TRUSTED, source="route_metadata"),
|
|
)
|
|
)
|
|
try:
|
|
result = await dummy_http_request("https://api.github.com/repos/openai")
|
|
finally:
|
|
current_tool_context.reset(token)
|
|
|
|
assert result == "ok"
|
|
assert seen["callback_called"] is True
|
|
saved = await get_run_context(manager, "s1", config=config, workspace=str(workspace))
|
|
assert DomainGrant(
|
|
domain="api.github.com",
|
|
scope="chat",
|
|
source="auto_trusted",
|
|
) in saved.domains
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_standard_explicit_target_does_not_auto_add_recognized_default_host(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
tmp_path: Path,
|
|
) -> None:
|
|
from opensquilla.tools.builtin import sessions as sessions_mod
|
|
|
|
class FakeProxy:
|
|
host = "127.0.0.1"
|
|
port = 28080
|
|
|
|
def __init__(self, decide: object, **kwargs: object) -> None:
|
|
self._decide = decide
|
|
|
|
async def start(self) -> None:
|
|
decision = self._decide("api.github.com")
|
|
assert isinstance(decision, NetworkDecision)
|
|
assert decision.status == "allow"
|
|
assert decision.reason == "default_allowlist"
|
|
|
|
async def stop(self) -> None:
|
|
return None
|
|
|
|
monkeypatch.setattr(integration_mod, "SandboxProxyServer", FakeProxy)
|
|
workspace = tmp_path / "workspace"
|
|
workspace.mkdir(exist_ok=True)
|
|
manager = SimpleNamespace()
|
|
manager.node = SimpleNamespace(
|
|
session_key="s1",
|
|
agent_id="main",
|
|
origin={
|
|
"sandbox_run_context": RunContext(run_mode=RunMode.STANDARD).to_origin_payload(),
|
|
},
|
|
)
|
|
|
|
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(
|
|
workspace_dir=str(workspace),
|
|
agents=[],
|
|
sandbox=SimpleNamespace(
|
|
run_mode="standard",
|
|
sandbox=True,
|
|
security_grading=True,
|
|
backend="noop",
|
|
network_default="proxy_allowlist",
|
|
),
|
|
permissions=SimpleNamespace(default_mode="off"),
|
|
)
|
|
monkeypatch.setattr(sessions_mod, "_session_manager", manager)
|
|
monkeypatch.setattr(sessions_mod, "_gateway_config", config)
|
|
|
|
@sandboxed(
|
|
"network.http",
|
|
argv_factory=lambda a: ("http_request", "GET", str(a["url"])),
|
|
record_payload=False,
|
|
)
|
|
async def dummy_http_request(url: str) -> str:
|
|
return "ok"
|
|
|
|
token = current_tool_context.set(
|
|
ToolContext(
|
|
is_owner=True,
|
|
caller_kind=CallerKind.CLI,
|
|
workspace_dir=str(workspace),
|
|
session_key="s1",
|
|
run_mode="standard",
|
|
sandbox_run_context=RunContext(run_mode=RunMode.STANDARD, source="route_metadata"),
|
|
)
|
|
)
|
|
try:
|
|
result = await dummy_http_request("https://api.github.com/repos/openai")
|
|
finally:
|
|
current_tool_context.reset(token)
|
|
|
|
assert result == "ok"
|
|
saved = await get_run_context(manager, "s1", config=config, workspace=str(workspace))
|
|
assert saved.domains == ()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_run_with_managed_network_proxy_honors_temporary_domain_grant(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
tmp_path: Path,
|
|
) -> None:
|
|
seen: dict[str, object] = {}
|
|
|
|
class FakeProxy:
|
|
host = "127.0.0.1"
|
|
port = 28080
|
|
|
|
def __init__(self, decide: object, **kwargs: object) -> None:
|
|
self._decide = decide
|
|
|
|
async def start(self) -> None:
|
|
decision = self._decide("temp-allowed.test")
|
|
assert isinstance(decision, NetworkDecision)
|
|
seen["decision"] = decision
|
|
|
|
async def stop(self) -> None:
|
|
return None
|
|
|
|
class FakeBackend:
|
|
name = "fake"
|
|
|
|
async def run(self, request):
|
|
seen["policy"] = request.policy
|
|
return SimpleNamespace(
|
|
returncode=0,
|
|
stdout="ok",
|
|
stderr="",
|
|
wall_time_s=0.1,
|
|
backend_used="fake",
|
|
backend_notes=(),
|
|
)
|
|
|
|
monkeypatch.setattr(integration_mod, "SandboxProxyServer", FakeProxy)
|
|
workspace = tmp_path / "workspace"
|
|
workspace.mkdir(exist_ok=True)
|
|
policy = SandboxPolicy(
|
|
level=SecurityLevel.STANDARD,
|
|
network=NetworkMode.PROXY_ALLOWLIST,
|
|
mounts=(),
|
|
workspace_rw=True,
|
|
tmp_writable=True,
|
|
limits=ResourceLimits(),
|
|
env_allowlist=("PATH",),
|
|
require_approval=False,
|
|
)
|
|
|
|
request = integration_mod.build_request(
|
|
action_kind="network.http",
|
|
argv=("http_request", "GET", "http://temp-allowed.test/path"),
|
|
cwd=workspace,
|
|
policy=policy,
|
|
)
|
|
runtime = SimpleNamespace(
|
|
backend=FakeBackend(),
|
|
)
|
|
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,
|
|
temporary_grants=(
|
|
TemporaryGrant(
|
|
kind="domain",
|
|
value="temp-allowed.test",
|
|
fingerprint=integration_mod.action_fingerprint(request),
|
|
),
|
|
),
|
|
),
|
|
)
|
|
)
|
|
try:
|
|
result = await integration_mod._run_with_managed_network_proxy(request, runtime)
|
|
finally:
|
|
current_tool_context.reset(token)
|
|
|
|
assert result.stdout == "ok"
|
|
decision = seen["decision"]
|
|
assert isinstance(decision, NetworkDecision)
|
|
assert decision.status == "allow"
|
|
assert decision.reason == "domain_grant"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_windows_fixed_proxy_port_subprocesses_are_serialized(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
managed_context: ToolContext,
|
|
tmp_path: Path,
|
|
) -> None:
|
|
runtime = integration_mod.get_runtime()
|
|
assert runtime is not None
|
|
runtime.backend = SimpleNamespace(name="windows_default")
|
|
managed_context.sandbox_run_context = RunContext(
|
|
run_mode=RunMode.STANDARD,
|
|
domains=(DomainGrant(domain="example.com"),),
|
|
)
|
|
monkeypatch.setattr(integration_mod, "_windows_allowed_proxy_ports", lambda _rt: (48123,))
|
|
monkeypatch.setattr(
|
|
integration_mod,
|
|
"_windows_proxy_allowlist_enforced",
|
|
lambda _rt, *, proxy_ports=(): True,
|
|
)
|
|
|
|
events: list[str] = []
|
|
active = 0
|
|
|
|
class FakeProxy:
|
|
host = "127.0.0.1"
|
|
|
|
def __init__(self, _service: object, *, port: int = 0, **_kwargs: object) -> None:
|
|
self.port = port
|
|
|
|
async def start(self) -> None:
|
|
nonlocal active
|
|
if active:
|
|
raise AssertionError("fixed Windows proxy port was started concurrently")
|
|
active += 1
|
|
events.append(f"start:{self.port}")
|
|
|
|
async def stop(self) -> None:
|
|
nonlocal active
|
|
active -= 1
|
|
events.append("stop")
|
|
|
|
monkeypatch.setattr(integration_mod, "SandboxProxyServer", FakeProxy)
|
|
first_ready = asyncio.Event()
|
|
release_first = asyncio.Event()
|
|
policy = 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 run_managed(name: str) -> None:
|
|
request = integration_mod.build_request(
|
|
action_kind="shell.exec",
|
|
argv=("curl", f"https://example.com/{name}"),
|
|
cwd=tmp_path,
|
|
policy=policy,
|
|
)
|
|
managed = await integration_mod.prepare_subprocess_managed_network_proxy(
|
|
request,
|
|
runtime=runtime,
|
|
)
|
|
events.append(f"ready:{name}")
|
|
try:
|
|
if name == "first":
|
|
first_ready.set()
|
|
await release_first.wait()
|
|
finally:
|
|
await managed.cleanup()
|
|
|
|
first = asyncio.create_task(run_managed("first"))
|
|
await first_ready.wait()
|
|
second = asyncio.create_task(run_managed("second"))
|
|
await asyncio.sleep(0)
|
|
|
|
assert events == ["start:48123", "ready:first"]
|
|
|
|
release_first.set()
|
|
await asyncio.gather(first, second)
|
|
|
|
assert events == [
|
|
"start:48123",
|
|
"ready:first",
|
|
"stop",
|
|
"start:48123",
|
|
"ready:second",
|
|
"stop",
|
|
]
|
|
assert active == 0
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_run_with_managed_network_proxy_does_not_auto_add_before_proxy_upstream(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
tmp_path: Path,
|
|
) -> None:
|
|
seen: dict[str, object] = {}
|
|
|
|
class FakeProxy:
|
|
host = "127.0.0.1"
|
|
port = 28080
|
|
|
|
def __init__(self, decide: object, **kwargs: object) -> None:
|
|
self._decide = decide
|
|
seen["has_callback"] = kwargs.get("on_upstream_opened") is not None
|
|
|
|
async def start(self) -> None:
|
|
decision = self._decide("api.github.com")
|
|
assert isinstance(decision, NetworkDecision)
|
|
assert decision.status == "allow"
|
|
assert decision.reason == "auto_trusted"
|
|
|
|
async def stop(self) -> None:
|
|
return None
|
|
|
|
class FakeBackend:
|
|
name = "fake"
|
|
|
|
async def run(self, request):
|
|
seen["policy"] = request.policy
|
|
return SimpleNamespace(
|
|
returncode=0,
|
|
stdout="ok",
|
|
stderr="",
|
|
wall_time_s=0.1,
|
|
backend_used="fake",
|
|
backend_notes=(),
|
|
)
|
|
|
|
monkeypatch.setattr(integration_mod, "SandboxProxyServer", FakeProxy)
|
|
workspace, manager, config = _install_trusted_session_handles(monkeypatch, tmp_path)
|
|
policy = SandboxPolicy(
|
|
level=SecurityLevel.STANDARD,
|
|
network=NetworkMode.PROXY_ALLOWLIST,
|
|
mounts=(),
|
|
workspace_rw=True,
|
|
tmp_writable=True,
|
|
limits=ResourceLimits(),
|
|
env_allowlist=("PATH",),
|
|
require_approval=False,
|
|
)
|
|
request = integration_mod.build_request(
|
|
action_kind="network.http",
|
|
argv=("http_request", "GET", "https://api.github.com/repos/openai"),
|
|
cwd=workspace,
|
|
policy=policy,
|
|
)
|
|
runtime = SimpleNamespace(
|
|
backend=FakeBackend(),
|
|
)
|
|
token = current_tool_context.set(
|
|
ToolContext(
|
|
is_owner=True,
|
|
caller_kind=CallerKind.CLI,
|
|
workspace_dir=str(workspace),
|
|
session_key="s1",
|
|
run_mode="trusted",
|
|
sandbox_run_context=RunContext(run_mode=RunMode.TRUSTED, source="route_metadata"),
|
|
)
|
|
)
|
|
try:
|
|
result = await integration_mod._run_with_managed_network_proxy(request, runtime)
|
|
finally:
|
|
current_tool_context.reset(token)
|
|
|
|
assert result.stdout == "ok"
|
|
assert seen["has_callback"] is True
|
|
saved = await get_run_context(manager, "s1", config=config, workspace=str(workspace))
|
|
assert DomainGrant(
|
|
domain="api.github.com",
|
|
scope="chat",
|
|
source="auto_trusted",
|
|
) not in saved.domains
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_web_fetch_cache_hit_requires_current_run_context_grant(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
managed_context: ToolContext,
|
|
) -> None:
|
|
url = "http://blocked.test/page"
|
|
web_fetch_mod._cache.clear()
|
|
web_fetch_mod._cache[(url, "markdown")] = {
|
|
"url": url,
|
|
"final_url": url,
|
|
"status": 200,
|
|
"content_type": "text/html",
|
|
"title": "",
|
|
"extract_mode": "markdown",
|
|
"extractor": "cache",
|
|
"truncated": False,
|
|
"length": 13,
|
|
"text": "cached secret",
|
|
}
|
|
monkeypatch.setattr(web_fetch_mod, "_check_ssrf", lambda value: None)
|
|
monkeypatch.setattr(
|
|
integration_mod,
|
|
"SandboxProxyServer",
|
|
lambda *args, **kwargs: pytest.fail("proxy should not start for denied target"),
|
|
)
|
|
|
|
result = await web_fetch_mod.web_fetch(url)
|
|
|
|
payload = json.loads(result)
|
|
assert payload["status"] == "approval_required"
|
|
assert payload["approval_id"]
|
|
assert payload["approvalKind"] == "sandbox_network"
|
|
assert payload["host"] == "blocked.test"
|
|
assert "cached secret" not in result
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_rpc_search_query_allows_search_provider_endpoint_under_managed_network(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
managed_context: ToolContext,
|
|
) -> None:
|
|
seen: dict[str, object] = {}
|
|
|
|
class FakeProxy:
|
|
host = "127.0.0.1"
|
|
port = 28080
|
|
|
|
def __init__(self, decide: object, **kwargs: object) -> None:
|
|
self._decide = decide
|
|
|
|
async def start(self) -> None:
|
|
provider_decision = self._decide("api.search.brave.com")
|
|
unknown_decision = self._decide("not-approved.example")
|
|
assert isinstance(provider_decision, NetworkDecision)
|
|
assert isinstance(unknown_decision, NetworkDecision)
|
|
seen["provider_decision"] = provider_decision.status
|
|
seen["provider_reason"] = provider_decision.reason
|
|
seen["unknown_decision"] = unknown_decision.status
|
|
|
|
async def stop(self) -> None:
|
|
return None
|
|
|
|
async def fake_search(*args: object, **kwargs: object) -> dict[str, object]:
|
|
seen["search_called"] = True
|
|
seen["provider_kwargs"] = web_mod._search_provider_kwargs("brave")
|
|
return {
|
|
"ok": True,
|
|
"query": "python packages",
|
|
"provider": "brave",
|
|
"results": [{"title": "PyPI", "url": "https://pypi.org", "snippet": ""}],
|
|
}
|
|
|
|
monkeypatch.setattr(rpc_tools, "run_web_search_payload", fake_search)
|
|
monkeypatch.setattr(integration_mod, "SandboxProxyServer", FakeProxy)
|
|
web_mod.configure_search("brave", api_key="test-key")
|
|
ctx = RpcContext(
|
|
conn_id="c",
|
|
principal=Principal(
|
|
role="operator",
|
|
scopes=frozenset(["operator.write", "operator.read"]),
|
|
is_owner=True,
|
|
authenticated=True,
|
|
),
|
|
)
|
|
|
|
result = await rpc_tools._handle_search_query({"query": "python packages"}, ctx)
|
|
|
|
assert result["ok"] is True
|
|
assert result["provider"] == "brave"
|
|
assert seen == {
|
|
"provider_decision": "allow",
|
|
"provider_reason": "system_domain_grant",
|
|
"unknown_decision": "ask",
|
|
"provider_kwargs": {
|
|
"proxy": "http://127.0.0.1:28080",
|
|
"use_env_proxy": False,
|
|
"api_key": "test-key",
|
|
"diagnostics": False,
|
|
},
|
|
"search_called": True,
|
|
}
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_rpc_search_query_without_runtime_uses_provider_path(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
reset_runtime()
|
|
|
|
async def fake_search(*args: object, **kwargs: object) -> dict[str, object]:
|
|
return {
|
|
"ok": True,
|
|
"query": "python packages",
|
|
"provider": "fake",
|
|
"results": [{"title": "PyPI", "url": "https://pypi.org", "snippet": "python"}],
|
|
}
|
|
|
|
monkeypatch.setattr(rpc_tools, "run_web_search_payload", fake_search)
|
|
ctx = RpcContext(
|
|
conn_id="c",
|
|
principal=Principal(
|
|
role="operator",
|
|
scopes=frozenset(["operator.write", "operator.read"]),
|
|
is_owner=True,
|
|
authenticated=True,
|
|
),
|
|
)
|
|
|
|
result = await rpc_tools._handle_search_query({"query": "python packages"}, ctx)
|
|
|
|
assert result["ok"] is True
|
|
assert result["results"] == [
|
|
{"title": "PyPI", "url": "https://pypi.org", "snippet": "python"}
|
|
]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_inprocess_network_action_without_run_context_fails_closed(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
tmp_path: Path,
|
|
) -> None:
|
|
ctx = ToolContext(
|
|
is_owner=True,
|
|
caller_kind=CallerKind.CLI,
|
|
workspace_dir=str(tmp_path),
|
|
session_key="s1",
|
|
run_mode="standard",
|
|
)
|
|
token = current_tool_context.set(ctx)
|
|
monkeypatch.setattr(
|
|
integration_mod,
|
|
"SandboxProxyServer",
|
|
lambda *args, **kwargs: pytest.fail("proxy should not start without context"),
|
|
)
|
|
called = False
|
|
|
|
@sandboxed(
|
|
"network.http",
|
|
argv_factory=lambda a: ("http_request", "GET", str(a["url"])),
|
|
record_payload=False,
|
|
)
|
|
async def dummy_http_request(url: str) -> str:
|
|
nonlocal called
|
|
called = True
|
|
return url
|
|
|
|
try:
|
|
result = await dummy_http_request("http://allowed.test/path")
|
|
finally:
|
|
current_tool_context.reset(token)
|
|
|
|
payload = json.loads(result)
|
|
assert payload["status"] == "denied"
|
|
assert payload["reason"] == "policy_denied"
|
|
assert "Run Context" in payload["message"]
|
|
assert called is False
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_web_search_shaped_inprocess_action_uses_search_provider_endpoint_grant(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
managed_context: ToolContext,
|
|
) -> None:
|
|
seen: dict[str, object] = {}
|
|
|
|
class FakeProxy:
|
|
host = "127.0.0.1"
|
|
port = 28080
|
|
|
|
def __init__(self, decide: object, **kwargs: object) -> None:
|
|
self._decide = decide
|
|
|
|
async def start(self) -> None:
|
|
decision = self._decide("html.duckduckgo.com")
|
|
assert isinstance(decision, NetworkDecision)
|
|
seen["decision"] = decision.status
|
|
seen["reason"] = decision.reason
|
|
|
|
async def stop(self) -> None:
|
|
return None
|
|
|
|
monkeypatch.setattr(integration_mod, "SandboxProxyServer", FakeProxy)
|
|
web_mod.configure_search("duckduckgo")
|
|
|
|
@sandboxed(
|
|
"web.fetch",
|
|
argv_factory=lambda a: (
|
|
"web_search",
|
|
str(a.get("query", "")),
|
|
str(a.get("max_results", "")),
|
|
),
|
|
record_payload=False,
|
|
)
|
|
async def dummy_web_search(query: str, max_results: int | None = None) -> str:
|
|
seen["called"] = True
|
|
return query
|
|
|
|
result = await dummy_web_search("python packages", 5)
|
|
|
|
assert result == "python packages"
|
|
assert seen == {
|
|
"decision": "allow",
|
|
"reason": "system_domain_grant",
|
|
"called": True,
|
|
}
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_inprocess_network_action_with_network_none_defers_to_proxy_runtime(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
tmp_path: Path,
|
|
) -> None:
|
|
reset_runtime()
|
|
workspace = tmp_path / "workspace"
|
|
workspace.mkdir(exist_ok=True)
|
|
configure_runtime(
|
|
SandboxSettings(
|
|
run_mode="standard",
|
|
backend="noop",
|
|
allow_legacy_mode=True,
|
|
network_default="none",
|
|
),
|
|
workspace=workspace,
|
|
)
|
|
ctx = 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),
|
|
)
|
|
token = current_tool_context.set(ctx)
|
|
seen: dict[str, object] = {}
|
|
|
|
class FakeProxy:
|
|
host = "127.0.0.1"
|
|
port = 28080
|
|
|
|
def __init__(self, decide: object, **kwargs: object) -> None:
|
|
self._decide = decide
|
|
|
|
async def start(self) -> None:
|
|
seen["started"] = True
|
|
|
|
async def stop(self) -> None:
|
|
seen["stopped"] = True
|
|
|
|
monkeypatch.setattr(integration_mod, "SandboxProxyServer", FakeProxy)
|
|
called = False
|
|
|
|
@sandboxed(
|
|
"network.http",
|
|
argv_factory=lambda a: ("http_request", "GET", str(a["url"])),
|
|
record_payload=False,
|
|
)
|
|
async def dummy_http_request(url: str) -> str:
|
|
nonlocal called
|
|
called = True
|
|
return url
|
|
|
|
try:
|
|
result = await dummy_http_request("http://example.com")
|
|
finally:
|
|
current_tool_context.reset(token)
|
|
|
|
assert result == "http://example.com"
|
|
assert called is True
|
|
assert seen == {"started": True, "stopped": True}
|
|
assert get_approval_queue().list_pending("exec") == []
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_inprocess_network_action_with_network_none_uses_granted_explicit_target(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
tmp_path: Path,
|
|
) -> None:
|
|
reset_runtime()
|
|
workspace = tmp_path / "workspace"
|
|
workspace.mkdir(exist_ok=True)
|
|
configure_runtime(
|
|
SandboxSettings(
|
|
run_mode="standard",
|
|
backend="noop",
|
|
allow_legacy_mode=True,
|
|
network_default="none",
|
|
),
|
|
workspace=workspace,
|
|
)
|
|
ctx = 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,
|
|
domains=(
|
|
DomainGrant(
|
|
domain="example.com",
|
|
scope="chat",
|
|
source="user",
|
|
),
|
|
),
|
|
),
|
|
)
|
|
token = current_tool_context.set(ctx)
|
|
seen: dict[str, object] = {}
|
|
|
|
class FakeProxy:
|
|
host = "127.0.0.1"
|
|
port = 28080
|
|
|
|
def __init__(self, decide: object, **kwargs: object) -> None:
|
|
self._decide = decide
|
|
|
|
async def start(self) -> None:
|
|
decision = self._decide("example.com")
|
|
assert isinstance(decision, NetworkDecision)
|
|
seen["decision"] = decision.status
|
|
|
|
async def stop(self) -> None:
|
|
seen["stopped"] = True
|
|
|
|
monkeypatch.setattr(integration_mod, "SandboxProxyServer", FakeProxy)
|
|
|
|
@sandboxed(
|
|
"network.http",
|
|
argv_factory=lambda a: ("http_request", "GET", str(a["url"])),
|
|
record_payload=False,
|
|
)
|
|
async def dummy_http_request(url: str) -> str:
|
|
seen["called"] = True
|
|
return "ok"
|
|
|
|
try:
|
|
result = await dummy_http_request("http://example.com/path")
|
|
finally:
|
|
current_tool_context.reset(token)
|
|
|
|
assert result == "ok"
|
|
assert seen == {"decision": "allow", "called": True, "stopped": True}
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_trusted_network_none_auto_allows_unknown_explicit_public_target(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
tmp_path: Path,
|
|
) -> None:
|
|
reset_runtime()
|
|
workspace = tmp_path / "workspace"
|
|
workspace.mkdir(exist_ok=True)
|
|
configure_runtime(
|
|
SandboxSettings(
|
|
run_mode="trusted",
|
|
backend="noop",
|
|
allow_legacy_mode=True,
|
|
network_default="none",
|
|
),
|
|
workspace=workspace,
|
|
)
|
|
ctx = ToolContext(
|
|
is_owner=True,
|
|
caller_kind=CallerKind.CLI,
|
|
workspace_dir=str(tmp_path),
|
|
session_key="s1",
|
|
run_mode="trusted",
|
|
sandbox_run_context=RunContext(run_mode=RunMode.TRUSTED),
|
|
)
|
|
token = current_tool_context.set(ctx)
|
|
seen: dict[str, object] = {}
|
|
|
|
class FakeProxy:
|
|
host = "127.0.0.1"
|
|
port = 28080
|
|
|
|
def __init__(self, decide: object, **kwargs: object) -> None:
|
|
self._decide = decide
|
|
|
|
async def start(self) -> None:
|
|
decision = self._decide("new-public.example")
|
|
assert isinstance(decision, NetworkDecision)
|
|
seen["decision"] = decision.status
|
|
seen["reason"] = decision.reason
|
|
|
|
async def stop(self) -> None:
|
|
seen["stopped"] = True
|
|
|
|
monkeypatch.setattr(integration_mod, "SandboxProxyServer", FakeProxy)
|
|
|
|
@sandboxed(
|
|
"network.http",
|
|
argv_factory=lambda a: ("http_request", "GET", str(a["url"])),
|
|
record_payload=False,
|
|
)
|
|
async def dummy_http_request(url: str) -> str:
|
|
seen["called"] = True
|
|
return "ok"
|
|
|
|
try:
|
|
result = await dummy_http_request("http://new-public.example/path")
|
|
finally:
|
|
current_tool_context.reset(token)
|
|
|
|
assert result == "ok"
|
|
assert seen == {
|
|
"decision": "allow",
|
|
"reason": "auto_trusted",
|
|
"called": True,
|
|
"stopped": True,
|
|
}
|