Skip to content

Commit 48c02c6

Browse files
committed
Harden sandbox transport and capability tokens
1 parent 5eb44ca commit 48c02c6

11 files changed

Lines changed: 178 additions & 58 deletions

core/agent_runtime/builder/controller.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -356,7 +356,7 @@ def workspace_build_verification(
356356
return {"status": "skipped", "ok": False, "response_text": "Verification skipped for non-Python scaffold."}
357357
execution = execute_runtime_tool_fn(
358358
"sandbox.run_command",
359-
{"command": f"python3 -m compileall -q {root_dir}/src"},
359+
{"command": f"python3 -m compileall -q {root_dir}/src", "_trusted_local_only": True},
360360
source_context=source_context,
361361
)
362362
if execution is None:

core/agent_runtime/builder/support.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -179,7 +179,10 @@ def workspace_build_verification_payload(*, target: dict[str, str]) -> dict[str,
179179
return None
180180
return {
181181
"intent": "sandbox.run_command",
182-
"arguments": {"command": f"python3 -m compileall -q {root_dir}/src"},
182+
"arguments": {
183+
"command": f"python3 -m compileall -q {root_dir}/src",
184+
"_trusted_local_only": True,
185+
},
183186
}
184187

185188

core/capability_tokens.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -218,7 +218,7 @@ def verify_assignment_capability(
218218
remembered = load_capability_token(token_id)
219219
if remembered is not None:
220220
status = str(remembered.get("status") or "active")
221-
if status not in {"active", "used"}:
221+
if status != "active":
222222
return CapabilityDecision(False, f"Capability token is {status}.")
223223
local_signature = str(remembered.get("signature") or "")
224224
if local_signature and local_signature != signature:

core/runtime_execution_tools.py

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
from core.execution_gate import ExecutionGate
1212
from core.runtime_paths import resolve_workspace_root
1313
from core.runtime_tool_contracts import runtime_tool_contract_map, runtime_tool_contracts
14+
from sandbox.network_guard import parse_command
1415
from sandbox.sandbox_runner import SandboxRunner
1516

1617
_EXECUTION_REQUEST_MARKERS = (
@@ -887,7 +888,11 @@ def _run_command(arguments: dict[str, Any], *, workspace_root: Path) -> RuntimeE
887888
cwd = _resolve_workspace_path(raw_cwd, workspace_root=workspace_root) if raw_cwd else workspace_root
888889
if cwd.is_file():
889890
cwd = cwd.parent
890-
runner = SandboxRunner(ExecutionGate(), str(workspace_root))
891+
runner = SandboxRunner(
892+
ExecutionGate(),
893+
str(workspace_root),
894+
network_isolation_mode=_trusted_local_network_mode(command, arguments=arguments),
895+
)
891896
result = runner.run_command(command, cwd=str(cwd))
892897
relative_cwd = _relative_path(cwd, workspace_root=workspace_root)
893898
status = str(result.get("status") or "")
@@ -1012,3 +1017,16 @@ def _run_command(arguments: dict[str, Any], *, workspace_root: Path) -> RuntimeE
10121017
),
10131018
},
10141019
)
1020+
1021+
1022+
def _trusted_local_network_mode(command: str, *, arguments: dict[str, Any]) -> str | None:
1023+
if not bool(arguments.get("_trusted_local_only", False)):
1024+
return None
1025+
argv = parse_command(command)
1026+
if len(argv) < 4:
1027+
return None
1028+
if argv[0] not in {"python", "python3"}:
1029+
return None
1030+
if argv[1:4] != ["-m", "compileall", "-q"]:
1031+
return None
1032+
return "heuristic_only"

network/transport.py

Lines changed: 17 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import base64
44
import contextlib
5+
import errno
56
import os
67
import secrets
78
import socket
@@ -182,42 +183,18 @@ def _parse_frag_header(packet: bytes) -> tuple[str, int, int] | None:
182183
return transfer_id, index, total
183184

184185

185-
def _kill_stale_udp_holder(port: int) -> bool:
186-
"""Find and kill a stale process holding a UDP port (Windows + POSIX)."""
187-
import subprocess
188-
my_pid = os.getpid()
189-
try:
190-
result = subprocess.run(
191-
["netstat", "-ano", "-p", "UDP"],
192-
capture_output=True, text=True, timeout=5,
193-
)
194-
for line in (result.stdout or "").splitlines():
195-
if "UDP" not in line or f":{port}" not in line:
196-
continue
197-
parts = line.split()
198-
if len(parts) < 3:
199-
continue
200-
try:
201-
holder_pid = int(parts[-1])
202-
except ValueError:
203-
continue
204-
if holder_pid == my_pid or holder_pid == 0:
205-
continue
206-
audit_logger.log(
207-
"killing_stale_port_holder",
208-
target_id=f"pid={holder_pid}",
209-
target_type="transport",
210-
details={"port": port},
211-
)
212-
with contextlib.suppress(Exception):
213-
subprocess.run(
214-
["taskkill", "/F", "/PID", str(holder_pid)],
215-
capture_output=True, timeout=5,
216-
)
217-
return True
218-
except Exception:
219-
pass
220-
return False
186+
def _is_address_in_use_error(exc: OSError) -> bool:
187+
err_no = getattr(exc, "errno", None)
188+
winerror = getattr(exc, "winerror", None)
189+
return err_no == errno.EADDRINUSE or err_no == 10048 or winerror == 10048
190+
191+
192+
def _raise_udp_bind_conflict(host: str, port: int, exc: OSError) -> None:
193+
raise OSError(
194+
getattr(exc, "errno", None) or getattr(exc, "winerror", None) or errno.EADDRINUSE,
195+
f"UDP transport cannot bind {host}:{port}: port already in use. "
196+
"Stop the conflicting process or choose a different port.",
197+
) from exc
221198

222199

223200
def _configure_udp_socket_buffers(sock: socket.socket) -> None:
@@ -285,20 +262,10 @@ def start(self) -> TransportRuntime:
285262
try:
286263
sock.bind((self.host, self.port))
287264
except OSError as bind_err:
288-
if getattr(bind_err, "winerror", None) == 10048 or bind_err.errno == 10048:
289-
killed = _kill_stale_udp_holder(self.port)
290-
if killed:
291-
time.sleep(0.5)
292-
try:
293-
sock.bind((self.host, self.port))
294-
except OSError:
295-
sock.close()
296-
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
297-
sock.bind((self.host, self.port))
298-
else:
299-
raise
300-
else:
301-
raise
265+
sock.close()
266+
if _is_address_in_use_error(bind_err):
267+
_raise_udp_bind_conflict(self.host, self.port, bind_err)
268+
raise
302269

303270
bound_port = int(sock.getsockname()[1])
304271

sandbox/job_runner.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -65,9 +65,11 @@ def _with_network_isolation(self, argv: list[str]) -> list[str]:
6565
isolated = self._kernel_network_isolation_prefix(argv)
6666
if isolated is not None:
6767
return isolated
68-
if mode == "os_enforced":
68+
if mode in {"auto", "os_enforced"}:
6969
raise ValueError(
70-
"OS-level network isolation is required but unavailable (expected one of: bwrap, unshare, firejail)."
70+
"OS-level network isolation is required but unavailable "
71+
"(expected one of: bwrap, unshare, firejail). "
72+
"Set network_isolation_mode='heuristic_only' only for an explicit unsafe local override."
7173
)
7274
return argv
7375

sandbox/sandbox_runner.py

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
from __future__ import annotations
22

3+
import os
34
import subprocess
45
from pathlib import Path
56

@@ -10,6 +11,16 @@
1011
from sandbox.resource_limits import ExecutionPolicy
1112

1213

14+
def _network_isolation_mode_from_env() -> str:
15+
raw = str(os.environ.get("NULLA_SANDBOX_NETWORK_MODE") or "").strip().lower()
16+
if raw in {"os_enforced", "heuristic_only"}:
17+
return raw
18+
unsafe = str(os.environ.get("NULLA_SANDBOX_UNSAFE_EXPLICIT") or "").strip().lower()
19+
if unsafe in {"1", "true", "yes", "on"}:
20+
return "heuristic_only"
21+
return "auto"
22+
23+
1324
class SandboxRunner:
1425
"""
1526
Acts as the single point of OS-level command execution.
@@ -29,7 +40,7 @@ class SandboxRunner:
2940

3041
_PACKAGE_INSTALL_COMMANDS = {"pip", "pip3", "npm", "npx", "yarn", "pnpm", "cargo"}
3142

32-
def __init__(self, gate: ExecutionGate, workspace_path: str):
43+
def __init__(self, gate: ExecutionGate, workspace_path: str, *, network_isolation_mode: str | None = None):
3344
self.gate = gate
3445
self.workspace = workspace_path
3546
self._workspace_path = Path(workspace_path).resolve()
@@ -40,6 +51,7 @@ def __init__(self, gate: ExecutionGate, workspace_path: str):
4051
max_seconds=120,
4152
max_output_kb=256,
4253
allow_network_egress=False,
54+
network_isolation_mode=network_isolation_mode or _network_isolation_mode_from_env(),
4355
)
4456
)
4557

tests/test_capability_tokens.py

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
issue_assignment_capability,
1212
load_capability_token,
1313
mark_capability_token_used,
14+
remember_capability_token,
1415
verify_assignment_capability,
1516
)
1617
from core.task_capsule import build_task_capsule
@@ -131,6 +132,42 @@ def test_expire_stale_capability_tokens_marks_expired(self) -> None:
131132
remembered = load_capability_token(str(token["token_id"]))
132133
self.assertEqual(str((remembered or {}).get("status")), "expired")
133134

135+
def test_assignment_capability_rejects_previously_used_token(self) -> None:
136+
task_id = f"task-{uuid.uuid4()}"
137+
capsule = _capsule(task_id)
138+
helper_peer_id = "helper-peer-used"
139+
140+
token = {
141+
"token_id": str(uuid.uuid4()),
142+
"capability_name": "EXECUTE_TASK_CAPSULE",
143+
"task_id": task_id,
144+
"granted_by": get_local_peer_id(),
145+
"granted_to": helper_peer_id,
146+
"scope": {
147+
"task_id": task_id,
148+
"capsule_hash": capsule.capsule_hash,
149+
"allowed_operations": list(capsule.allowed_operations),
150+
"max_response_bytes": int(capsule.max_response_bytes),
151+
"assignment_mode": "single",
152+
},
153+
"created_at": datetime.now(timezone.utc).isoformat(),
154+
"expires_at": (datetime.now(timezone.utc) + timedelta(minutes=10)).isoformat(),
155+
"signature": "sig-test",
156+
}
157+
remember_capability_token(token, status="used")
158+
159+
with unittest.mock.patch("core.capability_tokens.signer.verify", return_value=True):
160+
decision = verify_assignment_capability(
161+
token,
162+
task_id=task_id,
163+
parent_peer_id=get_local_peer_id(),
164+
helper_peer_id=helper_peer_id,
165+
capsule=capsule,
166+
)
167+
168+
self.assertFalse(decision.ok)
169+
self.assertIn("used", decision.reason)
170+
134171

135172
if __name__ == "__main__":
136173
unittest.main()

tests/test_job_runner.py

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,35 @@ def test_os_enforced_network_isolation_requires_backend(self) -> None:
3939
with self.assertRaises(ValueError):
4040
runner.run(["python3", "-c", "print('safe')"])
4141

42+
@unittest.skipIf(sys.platform == "win32", "PosixPath cannot be instantiated on Windows")
43+
def test_auto_network_isolation_now_fails_closed_without_backend(self) -> None:
44+
with tempfile.TemporaryDirectory() as tmpdir:
45+
runner = JobRunner(
46+
ExecutionPolicy(
47+
workspace_root=Path(tmpdir),
48+
network_isolation_mode="auto",
49+
)
50+
)
51+
with patch("sandbox.job_runner.os.name", "posix"), patch("sandbox.job_runner.sys.platform", "darwin"), patch(
52+
"sandbox.job_runner.shutil.which", return_value=None
53+
):
54+
with self.assertRaises(ValueError):
55+
runner.run(["python3", "-c", "print('safe')"])
56+
57+
def test_heuristic_only_mode_remains_explicit_opt_in(self) -> None:
58+
with tempfile.TemporaryDirectory() as tmpdir:
59+
runner = JobRunner(
60+
ExecutionPolicy(
61+
workspace_root=Path(tmpdir),
62+
network_isolation_mode="heuristic_only",
63+
)
64+
)
65+
with patch("sandbox.job_runner.os.name", "posix"), patch("sandbox.job_runner.sys.platform", "darwin"), patch(
66+
"sandbox.job_runner.shutil.which", return_value=None
67+
):
68+
argv = runner._with_network_isolation(["python3", "-c", "print('x')"])
69+
self.assertEqual(argv, ["python3", "-c", "print('x')"])
70+
4271
def test_linux_unshare_prefix_applied_when_available(self) -> None:
4372
with tempfile.TemporaryDirectory() as tmpdir:
4473
runner = JobRunner(

tests/test_runtime_execution_tools.py

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,11 @@
99

1010
import pytest
1111

12-
from core.runtime_execution_tools import execute_runtime_tool, extract_observation_followup_hints
12+
from core.runtime_execution_tools import (
13+
_trusted_local_network_mode,
14+
execute_runtime_tool,
15+
extract_observation_followup_hints,
16+
)
1317

1418
_UNSHARE_AVAILABLE = os.system("unshare -r true >/dev/null 2>&1") == 0
1519

@@ -232,6 +236,27 @@ def test_sandbox_run_command_blocks_network_commands(self) -> None:
232236
self.assertEqual(result.details["observation"]["tool_surface"], "sandbox")
233237
self.assertEqual(result.details["observation"]["status"], "blocked_by_policy")
234238

239+
def test_trusted_local_network_mode_only_applies_to_internal_compileall_verification(self) -> None:
240+
self.assertEqual(
241+
_trusted_local_network_mode(
242+
"python3 -m compileall -q generated/telegram-bot/src",
243+
arguments={"_trusted_local_only": True},
244+
),
245+
"heuristic_only",
246+
)
247+
self.assertIsNone(
248+
_trusted_local_network_mode(
249+
"python3 app.py",
250+
arguments={"_trusted_local_only": True},
251+
)
252+
)
253+
self.assertIsNone(
254+
_trusted_local_network_mode(
255+
"python3 -m compileall -q generated/telegram-bot/src",
256+
arguments={},
257+
)
258+
)
259+
235260

236261
if __name__ == "__main__":
237262
unittest.main()

0 commit comments

Comments
 (0)