diff --git a/PARITY_CHECKLIST.md b/PARITY_CHECKLIST.md index 8ff0717..9294501 100644 --- a/PARITY_CHECKLIST.md +++ b/PARITY_CHECKLIST.md @@ -182,7 +182,7 @@ Missing: - [ ] Full `entrypoints/cli.tsx` parity (version flag, feature flags, env setup, dynamic imports) - [ ] Full `entrypoints/init.ts` parity (settings validation, OAuth, policy limits, telemetry, cleanup handlers) - [ ] SDK entrypoint (`entrypoints/sdk/` — controlTypes, coreTypes, runtimeTypes, settingsTypes, toolTypes) -- [ ] Sandbox types/network config schema (`entrypoints/sandboxTypes.ts`) +- [x] Sandbox types/network config schema (`entrypoints/sandboxTypes.ts`) ## 3. Prompt Assembly diff --git a/src/sandbox_types.py b/src/sandbox_types.py new file mode 100644 index 0000000..f428ec4 --- /dev/null +++ b/src/sandbox_types.py @@ -0,0 +1,230 @@ +"""Sandbox configuration types — Python port of entrypoints/sandboxTypes.ts. + +The npm version uses Zod schemas with `.passthrough()` to forward unknown +fields. Here we mirror the same fields as dataclasses, with `from_dict` +classmethods that accept and round-trip arbitrary extra keys. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any + + +def _str_list(value: Any) -> list[str] | None: + if value is None: + return None + if not isinstance(value, list): + raise ValueError(f'expected list[str], got {type(value).__name__}') + return [str(item) for item in value] + + +def _opt_bool(value: Any) -> bool | None: + if value is None: + return None + if not isinstance(value, bool): + raise ValueError(f'expected bool, got {type(value).__name__}') + return value + + +def _opt_int(value: Any) -> int | None: + if value is None: + return None + if isinstance(value, bool): + raise ValueError('expected number, got bool') + if not isinstance(value, (int, float)): + raise ValueError(f'expected number, got {type(value).__name__}') + return int(value) + + +def _drop_none(d: dict[str, Any]) -> dict[str, Any]: + return {k: v for k, v in d.items() if v is not None} + + +@dataclass(frozen=True) +class SandboxNetworkConfig: + allowed_domains: list[str] | None = None + allow_managed_domains_only: bool | None = None + allow_unix_sockets: list[str] | None = None + allow_all_unix_sockets: bool | None = None + allow_local_binding: bool | None = None + http_proxy_port: int | None = None + socks_proxy_port: int | None = None + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> 'SandboxNetworkConfig': + return cls( + allowed_domains=_str_list(data.get('allowedDomains')), + allow_managed_domains_only=_opt_bool(data.get('allowManagedDomainsOnly')), + allow_unix_sockets=_str_list(data.get('allowUnixSockets')), + allow_all_unix_sockets=_opt_bool(data.get('allowAllUnixSockets')), + allow_local_binding=_opt_bool(data.get('allowLocalBinding')), + http_proxy_port=_opt_int(data.get('httpProxyPort')), + socks_proxy_port=_opt_int(data.get('socksProxyPort')), + ) + + def to_dict(self) -> dict[str, Any]: + return _drop_none({ + 'allowedDomains': self.allowed_domains, + 'allowManagedDomainsOnly': self.allow_managed_domains_only, + 'allowUnixSockets': self.allow_unix_sockets, + 'allowAllUnixSockets': self.allow_all_unix_sockets, + 'allowLocalBinding': self.allow_local_binding, + 'httpProxyPort': self.http_proxy_port, + 'socksProxyPort': self.socks_proxy_port, + }) + + +@dataclass(frozen=True) +class SandboxFilesystemConfig: + allow_write: list[str] | None = None + deny_write: list[str] | None = None + deny_read: list[str] | None = None + allow_read: list[str] | None = None + allow_managed_read_paths_only: bool | None = None + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> 'SandboxFilesystemConfig': + return cls( + allow_write=_str_list(data.get('allowWrite')), + deny_write=_str_list(data.get('denyWrite')), + deny_read=_str_list(data.get('denyRead')), + allow_read=_str_list(data.get('allowRead')), + allow_managed_read_paths_only=_opt_bool( + data.get('allowManagedReadPathsOnly') + ), + ) + + def to_dict(self) -> dict[str, Any]: + return _drop_none({ + 'allowWrite': self.allow_write, + 'denyWrite': self.deny_write, + 'denyRead': self.deny_read, + 'allowRead': self.allow_read, + 'allowManagedReadPathsOnly': self.allow_managed_read_paths_only, + }) + + +@dataclass(frozen=True) +class SandboxRipgrepConfig: + command: str + args: list[str] | None = None + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> 'SandboxRipgrepConfig': + command = data.get('command') + if not isinstance(command, str) or not command: + raise ValueError('ripgrep.command must be a non-empty string') + return cls(command=command, args=_str_list(data.get('args'))) + + def to_dict(self) -> dict[str, Any]: + return _drop_none({'command': self.command, 'args': self.args}) + + +@dataclass(frozen=True) +class SandboxSettings: + enabled: bool | None = None + fail_if_unavailable: bool | None = None + auto_allow_bash_if_sandboxed: bool | None = None + allow_unsandboxed_commands: bool | None = None + network: SandboxNetworkConfig | None = None + filesystem: SandboxFilesystemConfig | None = None + ignore_violations: dict[str, list[str]] | None = None + enable_weaker_nested_sandbox: bool | None = None + enable_weaker_network_isolation: bool | None = None + excluded_commands: list[str] | None = None + ripgrep: SandboxRipgrepConfig | None = None + extra: dict[str, Any] = field(default_factory=dict) + + _KNOWN_KEYS = frozenset({ + 'enabled', 'failIfUnavailable', 'autoAllowBashIfSandboxed', + 'allowUnsandboxedCommands', 'network', 'filesystem', + 'ignoreViolations', 'enableWeakerNestedSandbox', + 'enableWeakerNetworkIsolation', 'excludedCommands', 'ripgrep', + }) + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> 'SandboxSettings': + if not isinstance(data, dict): + raise ValueError(f'expected mapping, got {type(data).__name__}') + network_raw = data.get('network') + filesystem_raw = data.get('filesystem') + ripgrep_raw = data.get('ripgrep') + ignore_raw = data.get('ignoreViolations') + ignore: dict[str, list[str]] | None = None + if ignore_raw is not None: + if not isinstance(ignore_raw, dict): + raise ValueError('ignoreViolations must be a mapping') + ignore = {} + for key, value in ignore_raw.items(): + items = _str_list(value) + ignore[str(key)] = items if items is not None else [] + extra = {k: v for k, v in data.items() if k not in cls._KNOWN_KEYS} + return cls( + enabled=_opt_bool(data.get('enabled')), + fail_if_unavailable=_opt_bool(data.get('failIfUnavailable')), + auto_allow_bash_if_sandboxed=_opt_bool( + data.get('autoAllowBashIfSandboxed') + ), + allow_unsandboxed_commands=_opt_bool( + data.get('allowUnsandboxedCommands') + ), + network=( + SandboxNetworkConfig.from_dict(network_raw) + if isinstance(network_raw, dict) else None + ), + filesystem=( + SandboxFilesystemConfig.from_dict(filesystem_raw) + if isinstance(filesystem_raw, dict) else None + ), + ignore_violations=ignore, + enable_weaker_nested_sandbox=_opt_bool( + data.get('enableWeakerNestedSandbox') + ), + enable_weaker_network_isolation=_opt_bool( + data.get('enableWeakerNetworkIsolation') + ), + excluded_commands=_str_list(data.get('excludedCommands')), + ripgrep=( + SandboxRipgrepConfig.from_dict(ripgrep_raw) + if isinstance(ripgrep_raw, dict) else None + ), + extra=extra, + ) + + def to_dict(self) -> dict[str, Any]: + out: dict[str, Any] = _drop_none({ + 'enabled': self.enabled, + 'failIfUnavailable': self.fail_if_unavailable, + 'autoAllowBashIfSandboxed': self.auto_allow_bash_if_sandboxed, + 'allowUnsandboxedCommands': self.allow_unsandboxed_commands, + 'enableWeakerNestedSandbox': self.enable_weaker_nested_sandbox, + 'enableWeakerNetworkIsolation': self.enable_weaker_network_isolation, + 'excludedCommands': self.excluded_commands, + }) + if self.network is not None: + out['network'] = self.network.to_dict() + if self.filesystem is not None: + out['filesystem'] = self.filesystem.to_dict() + if self.ignore_violations is not None: + out['ignoreViolations'] = { + key: list(value) for key, value in self.ignore_violations.items() + } + if self.ripgrep is not None: + out['ripgrep'] = self.ripgrep.to_dict() + # passthrough — preserve unknown keys after serialization + for key, value in self.extra.items(): + out.setdefault(key, value) + return out + + +SandboxIgnoreViolations = dict[str, list[str]] + + +__all__ = [ + 'SandboxNetworkConfig', + 'SandboxFilesystemConfig', + 'SandboxRipgrepConfig', + 'SandboxSettings', + 'SandboxIgnoreViolations', +] diff --git a/tests/test_sandbox_types.py b/tests/test_sandbox_types.py new file mode 100644 index 0000000..e6934a9 --- /dev/null +++ b/tests/test_sandbox_types.py @@ -0,0 +1,110 @@ +"""Tests for sandbox configuration types ported from sandboxTypes.ts.""" + +from __future__ import annotations + +import unittest + +from src.sandbox_types import ( + SandboxFilesystemConfig, + SandboxNetworkConfig, + SandboxRipgrepConfig, + SandboxSettings, +) + + +class SandboxNetworkConfigTest(unittest.TestCase): + def test_round_trip(self) -> None: + raw = { + 'allowedDomains': ['example.com'], + 'allowManagedDomainsOnly': True, + 'allowUnixSockets': ['/tmp/sock'], + 'allowAllUnixSockets': False, + 'allowLocalBinding': True, + 'httpProxyPort': 8080, + 'socksProxyPort': 1080, + } + parsed = SandboxNetworkConfig.from_dict(raw) + self.assertEqual(parsed.allowed_domains, ['example.com']) + self.assertEqual(parsed.http_proxy_port, 8080) + self.assertEqual(parsed.to_dict(), raw) + + def test_strips_none(self) -> None: + parsed = SandboxNetworkConfig.from_dict({'allowedDomains': ['a']}) + self.assertEqual(parsed.to_dict(), {'allowedDomains': ['a']}) + + def test_rejects_wrong_type(self) -> None: + with self.assertRaises(ValueError): + SandboxNetworkConfig.from_dict({'allowedDomains': 'nope'}) + + +class SandboxFilesystemConfigTest(unittest.TestCase): + def test_round_trip(self) -> None: + raw = { + 'allowWrite': ['/tmp'], + 'denyWrite': ['/etc'], + 'denyRead': ['/etc/secrets'], + 'allowRead': ['/etc/secrets/public'], + 'allowManagedReadPathsOnly': False, + } + parsed = SandboxFilesystemConfig.from_dict(raw) + self.assertEqual(parsed.allow_write, ['/tmp']) + self.assertEqual(parsed.to_dict(), raw) + + +class SandboxRipgrepConfigTest(unittest.TestCase): + def test_requires_command(self) -> None: + with self.assertRaises(ValueError): + SandboxRipgrepConfig.from_dict({'args': ['-i']}) + + def test_round_trip(self) -> None: + parsed = SandboxRipgrepConfig.from_dict({'command': 'rg', 'args': ['--no-ignore']}) + self.assertEqual(parsed.command, 'rg') + self.assertEqual(parsed.to_dict(), {'command': 'rg', 'args': ['--no-ignore']}) + + +class SandboxSettingsTest(unittest.TestCase): + def test_full_round_trip_preserves_passthrough(self) -> None: + raw = { + 'enabled': True, + 'failIfUnavailable': False, + 'autoAllowBashIfSandboxed': True, + 'allowUnsandboxedCommands': True, + 'network': {'allowedDomains': ['x.com']}, + 'filesystem': {'allowWrite': ['/tmp']}, + 'ignoreViolations': {'NetworkViolation': ['y.com']}, + 'enableWeakerNestedSandbox': False, + 'enableWeakerNetworkIsolation': False, + 'excludedCommands': ['rm -rf /'], + 'ripgrep': {'command': 'rg'}, + 'enabledPlatforms': ['macos'], + 'somethingFuture': 42, + } + parsed = SandboxSettings.from_dict(raw) + self.assertTrue(parsed.enabled) + self.assertEqual(parsed.network.allowed_domains, ['x.com']) + self.assertEqual(parsed.ignore_violations, {'NetworkViolation': ['y.com']}) + self.assertEqual(parsed.extra['enabledPlatforms'], ['macos']) + self.assertEqual(parsed.extra['somethingFuture'], 42) + + back = parsed.to_dict() + self.assertEqual(back['enabled'], True) + self.assertEqual(back['enabledPlatforms'], ['macos']) + self.assertEqual(back['somethingFuture'], 42) + + def test_empty_returns_defaults(self) -> None: + parsed = SandboxSettings.from_dict({}) + self.assertIsNone(parsed.enabled) + self.assertIsNone(parsed.network) + self.assertEqual(parsed.to_dict(), {}) + + def test_rejects_non_mapping(self) -> None: + with self.assertRaises(ValueError): + SandboxSettings.from_dict('nope') # type: ignore[arg-type] + + def test_ignore_violations_must_be_mapping(self) -> None: + with self.assertRaises(ValueError): + SandboxSettings.from_dict({'ignoreViolations': ['nope']}) + + +if __name__ == '__main__': + unittest.main()