Files
2026-07-13 13:12:33 +08:00

668 lines
20 KiB
Python

from __future__ import annotations
from types import SimpleNamespace
import pytest
from opensquilla.sandbox.default_allowlist import default_allowlist_payload
from opensquilla.sandbox.domain_validation import domain_matches
from opensquilla.sandbox.network_guard import NetworkDecision, decide_network_access
from opensquilla.sandbox.package_bundles import expand_package_bundle
from opensquilla.sandbox.run_context import (
DomainGrant,
PackageBundleGrant,
PublicNetworkGrant,
RunContext,
)
from opensquilla.sandbox.run_mode import RunMode
class _SessionManager:
def __init__(self) -> None:
self.node = SimpleNamespace(
session_key="agent:main:webchat:abc",
agent_id="main",
origin=None,
)
self.sessions = {self.node.session_key: self.node}
async def get_session(self, session_key: str):
return self.sessions.get(session_key)
async def update(self, session_key: str, **fields):
node = self.sessions[session_key]
for key, value in fields.items():
setattr(node, key, value)
return node
def _context(
*,
run_mode: RunMode = RunMode.STANDARD,
domains: tuple[DomainGrant, ...] = (),
bundles: tuple[PackageBundleGrant, ...] = (),
public_network: tuple[PublicNetworkGrant, ...] = (),
) -> RunContext:
return RunContext(
run_mode=run_mode,
domains=domains,
bundles=bundles,
public_network=public_network,
)
def test_decide_network_access_allows_explicit_domain_grant() -> None:
context = _context(domains=(DomainGrant("pypi.org", source="user"),))
decision = decide_network_access("HTTPS://PyPI.org/simple", context)
assert decision == NetworkDecision(
status="allow",
normalized_host="pypi.org",
reason="domain_grant",
source="domain:pypi.org",
)
def test_decide_network_access_allows_wildcard_domain_grant() -> None:
context = _context(domains=(DomainGrant("*.pythonhosted.org"),))
decision = decide_network_access("files.pythonhosted.org", context)
assert decision == NetworkDecision(
status="allow",
normalized_host="files.pythonhosted.org",
reason="domain_grant",
source="domain:*.pythonhosted.org",
)
def test_decide_network_access_asks_for_unknown_valid_domain() -> None:
decision = decide_network_access("example.com", _context())
assert decision == NetworkDecision(
status="ask",
normalized_host="example.com",
reason="unknown_domain",
source=None,
)
def test_standard_sandbox_unknown_public_host_still_asks() -> None:
decision = decide_network_access(
"https://new-public-example.invalid",
_context(run_mode=RunMode.STANDARD),
)
assert decision == NetworkDecision(
status="ask",
normalized_host="new-public-example.invalid",
reason="unknown_domain",
source=None,
)
def test_trusted_sandbox_auto_trusts_unknown_public_host_for_this_chat() -> None:
decision = decide_network_access("docs.example.com", _context(run_mode=RunMode.TRUSTED))
assert decision == NetworkDecision(
status="allow",
normalized_host="docs.example.com",
reason="auto_trusted",
source="auto_trusted:chat",
)
def test_trusted_sandbox_unknown_public_host_auto_trusts_for_chat() -> None:
decision = decide_network_access(
"https://new-public-example.invalid",
_context(run_mode=RunMode.TRUSTED),
)
assert decision == NetworkDecision(
status="allow",
normalized_host="new-public-example.invalid",
reason="auto_trusted",
source="auto_trusted:chat",
)
def test_standard_public_network_grant_allows_unknown_public_domain() -> None:
context = _context(public_network=(PublicNetworkGrant(scope="chat"),))
decision = decide_network_access("docs.example.com", context)
assert decision == NetworkDecision(
status="allow",
normalized_host="docs.example.com",
reason="public_network",
source="public_network:chat",
)
def test_workspace_public_network_grant_reports_user_source() -> None:
context = _context(public_network=(PublicNetworkGrant(scope="workspace"),))
decision = decide_network_access("docs.example.com", context)
assert decision == NetworkDecision(
status="allow",
normalized_host="docs.example.com",
reason="public_network",
source="public_network:user",
)
def test_standard_public_network_grant_does_not_override_validation_block() -> None:
context = _context(public_network=(PublicNetworkGrant(scope="chat"),))
for host, expected in (
(
"127.0.0.1",
NetworkDecision(
status="block",
normalized_host="127.0.0.1",
reason="ip_literal",
source="validation",
),
),
(
"localhost",
NetworkDecision(
status="block",
normalized_host="localhost",
reason="not_fqdn",
source="validation",
),
),
(
"*.com",
NetworkDecision(
status="block",
normalized_host="*.com",
reason="broad_wildcard",
source="validation",
),
),
):
decision = decide_network_access(host, context)
assert decision == expected
def test_unknown_domain_builds_structured_network_escalation_choices() -> None:
from opensquilla.sandbox.escalation import build_network_approval_params
decision = decide_network_access("example.com", _context())
proposal = build_network_approval_params(
decision,
session_key="agent:main:webchat:abc",
workspace="/tmp/ws",
fingerprint="fp123",
)
assert proposal is not None
assert proposal["approvalKind"] == "sandbox_network"
assert proposal["host"] == "example.com"
assert proposal["sessionKey"] == "agent:main:webchat:abc"
assert proposal["workspace"] == "/tmp/ws"
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",
]
def test_blocked_network_decision_has_no_escalation_choices() -> None:
from opensquilla.sandbox.escalation import build_network_approval_params
decision = decide_network_access("127.0.0.1", _context())
assert decision.status == "block"
assert build_network_approval_params(
decision,
session_key="agent:main:webchat:abc",
workspace="/tmp/ws",
fingerprint="fp123",
) is None
def test_standard_sandbox_allows_builtin_github_default_hosts() -> None:
expected_hosts = {
"github.com",
"api.github.com",
"raw.githubusercontent.com",
"codeload.github.com",
"objects.githubusercontent.com",
"github.githubassets.com",
"avatars.githubusercontent.com",
"uploads.github.com",
"release-assets.githubusercontent.com",
"ghcr.io",
"pkg-containers.githubusercontent.com",
}
for host in expected_hosts:
decision = decide_network_access(host, _context())
assert decision.status == "allow", (host, decision)
assert decision.reason == "default_allowlist"
assert decision.source == "default:github"
def test_standard_sandbox_allows_builtin_search_provider_hosts_without_custom_domains() -> None:
for host in (
"api.search.brave.com",
"html.duckduckgo.com",
"duckduckgo.com",
"www.google.com",
"www.bing.com",
):
decision = decide_network_access(host, _context())
assert decision == NetworkDecision(
status="allow",
normalized_host=host,
reason="default_allowlist",
source="default:search",
)
def test_standard_sandbox_allows_developer_doc_hosts_without_custom_domains() -> None:
for host in (
"developer.mozilla.org",
"docs.python.org",
"docs.npmjs.com",
"doc.rust-lang.org",
"go.dev",
):
decision = decide_network_access(host, _context())
assert decision == NetworkDecision(
status="allow",
normalized_host=host,
reason="default_allowlist",
source="default:developer-docs",
)
def test_standard_sandbox_allows_common_package_and_doc_hosts_by_default() -> None:
for host in (
"pypi.org",
"files.pythonhosted.org",
"registry.npmjs.org",
"crates.io",
"proxy.golang.org",
"developer.mozilla.org",
"docs.python.org",
):
decision = decide_network_access(host, _context())
assert decision.status == "allow", (host, decision)
def test_default_allowlist_payload_exposes_read_only_builtin_groups() -> None:
payload = {entry["group"]: entry for entry in default_allowlist_payload()}
assert {"github", "search", "developer-docs"}.issubset(payload)
for group in ("github", "search", "developer-docs"):
assert payload[group]["read_only"] is True
assert "pkg-containers.githubusercontent.com" in payload["github"]["domains"]
assert "www.google.com" in payload["search"]["domains"]
assert "developer.mozilla.org" in payload["developer-docs"]["domains"]
def test_github_exact_domain_grant_does_not_cover_github_workflow_hosts() -> None:
for host in (
"api.github.com",
"raw.githubusercontent.com",
"codeload.github.com",
"objects.githubusercontent.com",
):
assert not domain_matches("github.com", host), host
def test_full_host_access_bypasses_sandbox_domain_controls() -> None:
decision = decide_network_access(
"unknown-public.example",
_context(run_mode=RunMode.FULL),
)
assert decision == NetworkDecision(
status="allow",
normalized_host="unknown-public.example",
reason="full_host_access",
source="run_mode:full",
)
def test_trusted_sandbox_auto_trusts_low_risk_recognized_hosts_for_this_chat() -> None:
context = _context(run_mode=RunMode.TRUSTED)
for host in ("api.github.com", "registry.npmjs.org"):
decision = decide_network_access(host, context)
assert decision == NetworkDecision(
status="allow",
normalized_host=host,
reason="auto_trusted",
source="auto_trusted:chat",
)
@pytest.mark.asyncio
async def test_trusted_sandbox_auto_trust_persists_chat_scoped_grant() -> None:
from opensquilla.sandbox.run_context import get_run_context
from opensquilla.sandbox.run_context_service import auto_add_trusted_domain_grant
manager = _SessionManager()
config = SimpleNamespace(
sandbox=SimpleNamespace(run_mode="trusted"),
permissions=SimpleNamespace(default_mode="off"),
)
context = await auto_add_trusted_domain_grant(
manager,
manager.node.session_key,
domain="api.github.com",
config=config,
workspace="/tmp/ws",
)
saved = await get_run_context(
manager,
manager.node.session_key,
config=config,
workspace="/tmp/ws",
)
expected_grant = DomainGrant(
domain="api.github.com",
scope="chat",
source="auto_trusted",
)
assert expected_grant in context.domains
assert expected_grant in saved.domains
assert manager.node.origin is not None
@pytest.mark.asyncio
async def test_trusted_sandbox_auto_trust_idempotent_second_call_does_not_mutate() -> None:
from opensquilla.sandbox.run_context import get_run_context
from opensquilla.sandbox.run_context_service import auto_add_trusted_domain_grant
manager = _SessionManager()
config = SimpleNamespace(
sandbox=SimpleNamespace(run_mode="trusted"),
permissions=SimpleNamespace(default_mode="off"),
)
manager.node.origin = {
"sandbox_run_context": {
"run_mode": "trusted",
"workspace": "/tmp/ws",
"domains": [{"domain": "registry.npmjs.org", "source": "manual"}],
}
}
first = await auto_add_trusted_domain_grant(
manager,
manager.node.session_key,
domain="HTTPS://Api.GitHub.com",
config=config,
workspace="/tmp/ws",
)
saved_after_first = await get_run_context(
manager,
manager.node.session_key,
config=config,
workspace="/tmp/ws",
)
origin_after_first = manager.node.origin
second = await auto_add_trusted_domain_grant(
manager,
manager.node.session_key,
domain="api.github.com",
config=config,
workspace="/tmp/ws",
)
saved_after_second = await get_run_context(
manager,
manager.node.session_key,
config=config,
workspace="/tmp/ws",
)
assert (
first.domains
== saved_after_first.domains
== saved_after_second.domains
== second.domains
)
assert [grant.domain for grant in saved_after_second.domains] == [
"registry.npmjs.org",
"api.github.com",
]
assert manager.node.origin is origin_after_first
@pytest.mark.asyncio
async def test_trusted_sandbox_auto_trust_does_not_persist_unsafe_hosts() -> None:
from opensquilla.sandbox.run_context import get_run_context
from opensquilla.sandbox.run_context_service import auto_add_trusted_domain_grant
config = SimpleNamespace(
sandbox=SimpleNamespace(run_mode="trusted"),
permissions=SimpleNamespace(default_mode="off"),
)
for host in (
"10.0.0.1",
"127.0.0.1",
"169.254.169.254",
"*.com",
):
manager = _SessionManager()
try:
result = await auto_add_trusted_domain_grant(
manager,
manager.node.session_key,
domain=host,
config=config,
workspace="/tmp/ws",
)
except ValueError:
result = None
saved = await get_run_context(
manager,
manager.node.session_key,
config=config,
workspace="/tmp/ws",
)
assert manager.node.origin is None, host
assert all(grant.source != "auto_trusted" for grant in saved.domains), host
if result is not None:
assert getattr(result, "status", "block") in {"ask", "block"}, (host, result)
def test_trusted_sandbox_does_not_auto_trust_unsafe_hosts() -> None:
context = _context(run_mode=RunMode.TRUSTED)
for host in (
"127.0.0.1",
"10.0.0.1",
"192.0.2.1",
"localhost",
"169.254.169.254",
"*.example.com",
"*.com",
):
decision = decide_network_access(host, context)
assert decision.status != "allow", (host, decision)
assert decision.reason != "auto_trusted", (host, decision)
def test_decide_network_access_blocks_unsafe_ip_literal() -> None:
decision = decide_network_access("169.254.169.254", _context())
assert decision == NetworkDecision(
status="block",
normalized_host="169.254.169.254",
reason="ip_literal",
source="validation",
)
def test_decide_network_access_blocks_malformed_host_with_validation_reason() -> None:
decision = decide_network_access("example.com:99999", _context())
assert decision == NetworkDecision(
status="block",
normalized_host="example.com",
reason="invalid_port",
source="validation",
)
def test_decide_network_access_allows_package_bundle_domain() -> None:
context = _context(bundles=(PackageBundleGrant("node-package-install"),))
decision = decide_network_access("registry.npmjs.org", context)
assert decision == NetworkDecision(
status="allow",
normalized_host="registry.npmjs.org",
reason="package_bundle",
source="bundle:node-package-install",
)
def test_decide_network_access_allows_default_bundle_domain_without_explicit_grant() -> None:
decision = decide_network_access("registry.npmjs.org", _context())
assert decision == NetworkDecision(
status="allow",
normalized_host="registry.npmjs.org",
reason="package_bundle",
source="bundle:node-package-install",
)
def test_decide_network_access_honors_disabled_default_bundle_override() -> None:
context = _context(
bundles=(
PackageBundleGrant(
bundle_id="node-package-install",
source="disabled",
),
),
)
decision = decide_network_access("registry.npmjs.org", context)
assert decision == NetworkDecision(
status="ask",
normalized_host="registry.npmjs.org",
reason="unknown_domain",
source=None,
)
def test_package_install_bundles_cover_common_registry_and_artifact_hosts() -> None:
expectations = {
"python-package-install": {
"pypi.org",
"files.pythonhosted.org",
"pypi.python.org",
"bootstrap.pypa.io",
},
"node-package-install": {
"registry.npmjs.org",
"registry.yarnpkg.com",
"yarnpkg.com",
"nodejs.org",
},
"rust-package-install": {
"crates.io",
"static.crates.io",
"index.crates.io",
"github.com",
"objects.githubusercontent.com",
},
"go-package-install": {
"proxy.golang.org",
"sum.golang.org",
"go.dev",
"golang.org",
"storage.googleapis.com",
},
}
for bundle_id, hosts in expectations.items():
context = _context(bundles=(PackageBundleGrant(bundle_id),))
for host in hosts:
decision = decide_network_access(host, context)
assert decision.status == "allow", (bundle_id, host, decision)
assert decision.reason == "package_bundle"
def test_default_github_bundle_covers_full_git_and_release_workflow_hosts() -> None:
expected_hosts = {
"github.com",
"api.github.com",
"raw.githubusercontent.com",
"codeload.github.com",
"objects.githubusercontent.com",
}
assert expected_hosts.issubset(set(expand_package_bundle("github-default")))
def test_default_github_bundle_covers_developer_workflow_hosts() -> None:
expected_hosts = {
"actions.githubusercontent.com",
"pipelines.actions.githubusercontent.com",
"results-receiver.actions.githubusercontent.com",
"uploads.github.com",
"release-assets.githubusercontent.com",
"ghcr.io",
"pkg-containers.githubusercontent.com",
}
assert expected_hosts.issubset(set(expand_package_bundle("github-default")))
def test_default_github_bundle_allows_workflow_host() -> None:
decision = decide_network_access("actions.githubusercontent.com", _context())
assert decision == NetworkDecision(
status="allow",
normalized_host="actions.githubusercontent.com",
reason="package_bundle",
source="bundle:github-default",
)
def test_disabled_default_github_bundle_blocks_workflow_host() -> None:
context = _context(
bundles=(PackageBundleGrant(bundle_id="github-default", source="disabled"),),
)
decision = decide_network_access("actions.githubusercontent.com", context)
assert decision == NetworkDecision(
status="ask",
normalized_host="actions.githubusercontent.com",
reason="unknown_domain",
source=None,
)