Skip to content

Commit 4847b13

Browse files
committed
fix(sandbox): correct sandlock integration semantics and fail loud
The SandlockSandbox wrapper had several latent correctness issues that could cause it to silently run with weaker isolation than intended, or drop resource limits on the floor. Fixes: * Network policy intent is now explicit. sandlock's net_allow_hosts uses tri-state semantics (None=unrestricted, []=deny all, [..]= allowlist) and network-enabled=True previously passed None to a Sequence[str] field. Rewritten to pass net_connect=["0-65535"] when network is enabled, or net_allow_hosts=[] to block DNS when disabled. TCP defaults ([] = deny all) handle the rest. * stdout/stderr are now str, not bytes. sandlock returns bytes from Sandbox.run(); PraisonAI's SandboxResult is typed as str. Added a _decode() helper with errors="replace" so downstream consumers never see binary artefacts or crash on .lower() / .split(). * max_cpu is now actually passed to the Policy. Previously limits.cpu_percent was silently ignored. * execute_file() passes the script by path, not via `python3 -c <code>`. Large scripts no longer hit ARG_MAX, and the script's parent directory is added to the Landlock read allowlist via the new extra_readable parameter on _create_policy. * Timeout detection is authoritative: we now inspect result.error for "timed out" rather than heuristically comparing wall-clock duration against limits.timeout_seconds. A process that happens to finish just under the timeout no longer gets mis-classified. * Sandbox handles are now managed via `with ... as sb:` so cleanup runs on exception. * fs_readable is filtered to paths that actually exist. Landlock fails at spawn time if any allowlisted path is missing, so the hardcoded list (which included /usr/local/lib/python3) caused sandlock_spawn failures on most hosts. Now we filter with os.path.isdir before constructing the policy. Breaking change — silent fallback removed: SandlockSandbox used to fall back to SubprocessSandbox whenever landlock_abi_version() < 1, logging only a warning. This violates the caller's explicit choice of kernel-level isolation: a SandlockSandbox that isn't actually using Landlock is a security footgun, and a warning in the logs is not a consent mechanism. __init__ now raises RuntimeError if Landlock support is missing. Callers who want graceful degradation should catch ImportError / RuntimeError and construct SubprocessSandbox explicitly, e.g.: try: sb = SandlockSandbox(cfg) except (ImportError, RuntimeError): sb = SubprocessSandbox(cfg) The equivalent fallback branches in execute(), run_command(), and execute_file() are removed. Tests updated: - test_raises_when_landlock_unavailable replaces the two fallback tests and asserts RuntimeError is raised at construction time. - test_sandlock_execution_timeout now mocks result.error instead of patching time.time. - test_sandlock_execution_failure sets result.error=None explicitly. - test_policy_creation_with_minimal_limits strengthened to check max_cpu, the new net_allow_hosts=[] deny-all semantics, and that net_connect is left unset (defaults to deny-all). All 10 unit tests pass, including the real-sandlock integration test (which was failing on baseline because of the /usr/local/lib/python3 hardcoded path).
1 parent 6693a75 commit 4847b13

2 files changed

Lines changed: 191 additions & 162 deletions

File tree

src/praisonai/praisonai/sandbox/sandlock.py

Lines changed: 157 additions & 113 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,21 @@ def __init__(
7474
"sandlock package required for SandlockSandbox. "
7575
"Install with: pip install 'praisonai[sandbox]' or pip install sandlock"
7676
)
77+
78+
# Fail loud if Landlock isn't supported on this kernel.
79+
try:
80+
abi = self._sandlock.landlock_abi_version()
81+
except Exception as e:
82+
raise RuntimeError(
83+
f"failed to query Landlock ABI version: {e}"
84+
) from e
85+
if abi < 1:
86+
raise RuntimeError(
87+
"SandlockSandbox requires Landlock support (Linux kernel "
88+
">= 6.12 with CONFIG_SECURITY_LANDLOCK=y). This kernel "
89+
f"reports Landlock ABI version {abi}. Use SubprocessSandbox "
90+
"explicitly if weaker isolation is acceptable."
91+
)
7792

7893
@property
7994
def is_available(self) -> bool:
@@ -112,58 +127,102 @@ def _create_policy(
112127
self,
113128
limits: ResourceLimits,
114129
working_dir: Optional[str] = None,
130+
extra_readable: Optional[List[str]] = None,
115131
) -> Any:
116132
"""Create sandlock policy from resource limits.
117-
133+
118134
Args:
119135
limits: Resource limits configuration
120-
working_dir: Working directory for execution
121-
122-
Returns:
123-
Sandlock Policy object
136+
working_dir: Working directory for execution (added to the
137+
writable allowlist).
138+
extra_readable: Additional directories to add to the Landlock
139+
read allowlist (e.g. the parent of a script passed to
140+
``execute_file``).
124141
"""
125142
Policy = self._sandlock.Policy
126-
127-
# Determine allowed paths
128-
allowed_read_paths = [
143+
144+
# Landlock requires every path in the allowlist to exist at rule-
145+
# attach time; passing a missing directory makes sandlock_spawn
146+
# fail outright. Filter to paths that actually exist on this host.
147+
_candidate_read_paths = [
129148
"/usr/lib/python3",
130149
"/usr/local/lib/python3",
131150
"/lib",
132151
"/usr/lib",
133152
"/bin",
134153
"/usr/bin",
135154
]
136-
155+
allowed_read_paths = [p for p in _candidate_read_paths if os.path.isdir(p)]
156+
if extra_readable:
157+
allowed_read_paths.extend(
158+
p for p in extra_readable if os.path.isdir(p)
159+
)
160+
137161
allowed_write_paths = []
138162
if working_dir:
139163
allowed_write_paths.append(working_dir)
140164
if self._temp_dir:
141165
allowed_write_paths.append(self._temp_dir)
142-
166+
143167
# Add any configured allowed paths from security policy
144168
if hasattr(self.config, 'security_policy') and self.config.security_policy:
145169
allowed_write_paths.extend(self.config.security_policy.allowed_paths)
146170

147-
# Create policy with kernel-level restrictions
171+
# Network policy.
172+
#
173+
# sandlock uses tri-state semantics for net_allow_hosts:
174+
# None -> unrestricted (real /etc/hosts visible)
175+
# [] -> deny all hosts (empty virtual /etc/hosts)
176+
# ["host", ...] -> allowlist
177+
#
178+
# TCP connectivity is governed separately by net_connect/net_bind,
179+
# both of which default to [] = deny all. To enable network we must
180+
# explicitly open TCP ports; to block network we can rely on the
181+
# defaults AND additionally block DNS via net_allow_hosts=[].
182+
if limits.network_enabled:
183+
net_kwargs: Dict[str, Any] = {
184+
# Allow outbound TCP to any port; leave net_allow_hosts at
185+
# its default (None = /etc/hosts unrestricted).
186+
"net_connect": ["0-65535"],
187+
}
188+
else:
189+
net_kwargs = {
190+
# Empty allowlist -> no host resolvable via virtual /etc/hosts.
191+
# net_bind/net_connect default to [] = deny all TCP.
192+
"net_allow_hosts": [],
193+
}
194+
148195
policy = Policy(
149196
# Filesystem restrictions (Landlock)
150197
fs_readable=allowed_read_paths,
151198
fs_writable=allowed_write_paths,
152-
153-
# Network restrictions
154-
net_allow_hosts=[] if not limits.network_enabled else None,
155-
199+
156200
# Resource limits
157201
max_memory=f"{limits.memory_mb}M",
158202
max_processes=limits.max_processes,
159203
max_open_files=limits.max_open_files,
160-
161-
# Note: CPU throttle percentage, not time limit
162-
# Execution timeout is handled via Sandbox.run(timeout=...)
204+
# max_cpu is a throttle percentage of one core, not a time budget.
205+
# Execution timeout is handled via Sandbox.run(timeout=...).
206+
max_cpu=limits.cpu_percent,
207+
208+
**net_kwargs,
163209
)
164-
210+
165211
return policy
166212

213+
@staticmethod
214+
def _decode(buf: Any) -> str:
215+
"""Decode a sandlock Result stdout/stderr buffer to str.
216+
217+
sandlock returns ``bytes`` from ``Sandbox.run()``; PraisonAI's
218+
``SandboxResult`` uses ``str`` throughout. Invalid UTF-8 is
219+
replaced rather than raised so downstream consumers never see
220+
binary artefacts.
221+
"""
222+
if isinstance(buf, bytes):
223+
return buf.decode("utf-8", errors="replace")
224+
return buf or ""
225+
167226
def _safe_sandbox_path(self, path: str) -> Optional[str]:
168227
"""Resolve a caller-supplied path to an absolute path inside _temp_dir.
169228
@@ -190,6 +249,7 @@ async def _run_sandlocked(
190249
limits: ResourceLimits,
191250
env: Optional[Dict[str, str]],
192251
working_dir: Optional[str],
252+
extra_readable: Optional[List[str]] = None,
193253
) -> SandboxResult:
194254
"""Execute *cmd* inside a sandlock Sandbox and return a SandboxResult.
195255
@@ -202,87 +262,67 @@ async def _run_sandlocked(
202262
prior to sandbox creation. ``working_dir`` is applied via the policy
203263
(added to the writable-path allow-list).
204264
"""
205-
policy = self._create_policy(limits, working_dir)
265+
policy = self._create_policy(limits, working_dir, extra_readable)
206266

207267
started_at = time.time()
208268

209-
try:
210-
sandbox = self._sandlock.Sandbox(policy)
211-
212-
result = await asyncio.get_running_loop().run_in_executor(
213-
None,
214-
lambda: sandbox.run(cmd, timeout=limits.timeout_seconds)
215-
)
216-
217-
completed_at = time.time()
218-
duration = completed_at - started_at
219-
220-
# Check result.success and exit_code to determine actual status
221-
if not result.success:
222-
# Check if this was a timeout based on duration
223-
if duration >= limits.timeout_seconds:
224-
return SandboxResult(
225-
execution_id=execution_id,
226-
status=SandboxStatus.TIMEOUT,
227-
error=f"Execution timed out after {limits.timeout_seconds}s",
228-
exit_code=result.exit_code,
229-
stdout=result.stdout,
230-
stderr=result.stderr,
231-
duration_seconds=duration,
232-
started_at=started_at,
233-
completed_at=completed_at,
234-
)
235-
else:
236-
# Non-zero exit or other failure
237-
return SandboxResult(
238-
execution_id=execution_id,
239-
status=SandboxStatus.FAILED,
240-
error=f"Execution failed with exit code {result.exit_code}: {result.stderr}",
241-
exit_code=result.exit_code,
242-
stdout=result.stdout,
243-
stderr=result.stderr,
244-
duration_seconds=duration,
245-
started_at=started_at,
246-
completed_at=completed_at,
247-
)
248-
249-
return SandboxResult(
250-
execution_id=execution_id,
251-
status=SandboxStatus.COMPLETED,
252-
exit_code=result.exit_code,
253-
stdout=result.stdout,
254-
stderr=result.stderr,
255-
duration_seconds=duration,
256-
started_at=started_at,
257-
completed_at=completed_at,
258-
metadata={
259-
"sandbox_type": "sandlock",
260-
"landlock_enabled": True,
261-
"seccomp_enabled": True,
262-
},
263-
)
269+
def _run() -> Any:
270+
# Context manager ensures the sandbox handle is released even
271+
# if .run() raises partway through.
272+
with self._sandlock.Sandbox(policy) as sb:
273+
return sb.run(cmd, timeout=limits.timeout_seconds)
264274

275+
try:
276+
result = await asyncio.get_running_loop().run_in_executor(None, _run)
265277
except Exception as e:
266278
completed_at = time.time()
267-
duration = completed_at - started_at
268-
if duration >= limits.timeout_seconds:
269-
return SandboxResult(
270-
execution_id=execution_id,
271-
status=SandboxStatus.TIMEOUT,
272-
error=f"Execution timed out after {limits.timeout_seconds}s",
273-
duration_seconds=duration,
274-
started_at=started_at,
275-
completed_at=completed_at,
276-
)
277279
return SandboxResult(
278280
execution_id=execution_id,
279281
status=SandboxStatus.FAILED,
280282
error=f"Execution failed: {e}",
281-
duration_seconds=duration,
283+
duration_seconds=completed_at - started_at,
282284
started_at=started_at,
283285
completed_at=completed_at,
284286
)
285287

288+
completed_at = time.time()
289+
duration = completed_at - started_at
290+
stdout = self._decode(result.stdout)
291+
stderr = self._decode(result.stderr)
292+
293+
# sandlock surfaces timeouts via result.error containing "timed out".
294+
# This is authoritative — wall-clock guesses are unreliable because a
295+
# process can legitimately finish just under the limit.
296+
err_text = (result.error or "") if not result.success else ""
297+
is_timeout = "timed out" in err_text.lower() or "timeout" in err_text.lower()
298+
299+
if result.success:
300+
status = SandboxStatus.COMPLETED
301+
error = None
302+
elif is_timeout:
303+
status = SandboxStatus.TIMEOUT
304+
error = f"Execution timed out after {limits.timeout_seconds}s"
305+
else:
306+
status = SandboxStatus.FAILED
307+
error = f"Execution failed with exit code {result.exit_code}: {stderr or err_text}"
308+
309+
return SandboxResult(
310+
execution_id=execution_id,
311+
status=status,
312+
exit_code=result.exit_code,
313+
stdout=stdout,
314+
stderr=stderr,
315+
duration_seconds=duration,
316+
started_at=started_at,
317+
completed_at=completed_at,
318+
error=error,
319+
metadata={
320+
"sandbox_type": "sandlock",
321+
"landlock_enabled": True,
322+
"seccomp_enabled": True,
323+
},
324+
)
325+
286326
async def execute(
287327
self,
288328
code: str,
@@ -294,14 +334,7 @@ async def execute(
294334
"""Execute code in the sandlock-isolated sandbox."""
295335
if not self._is_running:
296336
await self.start()
297-
298-
if not self.is_available:
299-
# Fallback to subprocess sandbox if sandlock not available
300-
logger.warning("Sandlock not available, falling back to subprocess")
301-
from .subprocess import SubprocessSandbox
302-
fallback = SubprocessSandbox(self.config)
303-
return await fallback.execute(code, language, limits, env, working_dir)
304-
337+
305338
limits = limits or self.config.resource_limits
306339
execution_id = str(uuid.uuid4())
307340

@@ -325,22 +358,39 @@ async def execute_file(
325358
limits: Optional[ResourceLimits] = None,
326359
env: Optional[Dict[str, str]] = None,
327360
) -> SandboxResult:
328-
"""Execute a file in the sandbox."""
361+
"""Execute a file in the sandbox.
362+
363+
The script is passed to the interpreter by path rather than slurped
364+
through ``-c``, so large scripts don't hit ARG_MAX. The file's
365+
parent directory is added to the Landlock read allowlist for this
366+
run so the sandboxed process can actually open it.
367+
"""
368+
if not self._is_running:
369+
await self.start()
370+
329371
if not os.path.exists(file_path):
330372
return SandboxResult(
331373
status=SandboxStatus.FAILED,
332374
error=f"File not found: {file_path}",
333375
)
334-
335-
with open(file_path, "r") as f:
336-
code = f.read()
337-
338-
# Determine language from file extension
339-
language = "python"
340-
if file_path.endswith(".sh") or file_path.endswith(".bash"):
341-
language = "bash"
342-
343-
return await self.execute(code, language=language, limits=limits, env=env)
376+
377+
limits = limits or self.config.resource_limits
378+
execution_id = str(uuid.uuid4())
379+
380+
abs_path = os.path.realpath(file_path)
381+
interp = "bash" if file_path.endswith((".sh", ".bash")) else "python3"
382+
cmd: List[str] = [interp, abs_path]
383+
if args:
384+
cmd.extend(args)
385+
386+
return await self._run_sandlocked(
387+
cmd,
388+
execution_id=execution_id,
389+
limits=limits,
390+
env=env,
391+
working_dir=self._temp_dir,
392+
extra_readable=[os.path.dirname(abs_path)],
393+
)
344394

345395
async def run_command(
346396
self,
@@ -352,13 +402,7 @@ async def run_command(
352402
"""Run a shell command in the sandbox."""
353403
if not self._is_running:
354404
await self.start()
355-
356-
if not self.is_available:
357-
logger.warning("Sandlock not available, falling back to subprocess")
358-
from .subprocess import SubprocessSandbox
359-
fallback = SubprocessSandbox(self.config)
360-
return await fallback.run_command(command, limits, env, working_dir)
361-
405+
362406
limits = limits or self.config.resource_limits
363407
execution_id = str(uuid.uuid4())
364408

@@ -462,4 +506,4 @@ async def cleanup(self) -> None:
462506
async def reset(self) -> None:
463507
"""Reset sandbox to initial state."""
464508
await self.stop()
465-
await self.start()
509+
await self.start()

0 commit comments

Comments
 (0)