Skip to content

Commit c2e058c

Browse files
authored
Expose filesystem limits in Python SDK (#214)
Signed-off-by: James Sturtevant <jsturtevant@gmail.com>
1 parent 3f04b81 commit c2e058c

5 files changed

Lines changed: 295 additions & 17 deletions

File tree

‎src/sdk/python/core/hyperlight_sandbox/__init__.py‎

Lines changed: 53 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
from collections.abc import Callable
1111
from dataclasses import dataclass, field
1212
from importlib import metadata
13-
from typing import Any
13+
from typing import Any, Literal
1414

1515
from ._module_resolver import DEFAULT_MODULE_REF, resolve_module_path
1616

@@ -83,7 +83,12 @@ def success(self) -> bool:
8383

8484
@dataclass
8585
class SandboxEnvironment:
86-
"""Configuration for creating a sandbox."""
86+
"""Configuration for creating a sandbox.
87+
88+
Filesystem quotas use human-readable binary size strings such as ``"64Mi"``.
89+
Leave all quota fields unset to use balanced backend defaults, or set
90+
``filesystem_limits="unlimited"`` to explicitly disable filesystem quotas.
91+
"""
8792

8893
input_dir: str | None = None
8994
output_dir: str | None = None
@@ -93,10 +98,20 @@ class SandboxEnvironment:
9398
module_path: str | None = None
9499
heap_size: str = field(default_factory=lambda: _DEFAULT_HEAP_SIZE)
95100
stack_size: str = field(default_factory=lambda: _DEFAULT_STACK_SIZE)
101+
filesystem_limits: Literal["unlimited"] | None = None
102+
max_file_size: str | None = None
103+
max_total_size: str | None = None
104+
max_file_count: int | None = None
96105

97106

98107
class Sandbox:
99-
"""Stable Python API over swappable Hyperlight backends."""
108+
"""Stable Python API over swappable Hyperlight backends.
109+
110+
``max_file_size`` and ``max_total_size`` accept binary size strings such as
111+
``"64Mi"``. Partial numeric overrides retain balanced defaults for omitted
112+
values. Use ``filesystem_limits="unlimited"`` to explicitly disable all
113+
filesystem quotas; it cannot be combined with numeric overrides.
114+
"""
100115

101116
def __init__(
102117
self,
@@ -109,7 +124,30 @@ def __init__(
109124
module_path: str | None = None,
110125
heap_size: str | None = None,
111126
stack_size: str | None = None,
127+
filesystem_limits: Literal["unlimited"] | None = None,
128+
max_file_size: str | None = None,
129+
max_total_size: str | None = None,
130+
max_file_count: int | None = None,
112131
) -> None:
132+
numeric_limits = (max_file_size, max_total_size, max_file_count)
133+
if filesystem_limits is not None and filesystem_limits != "unlimited":
134+
raise ValueError("filesystem_limits must be 'unlimited' when provided")
135+
if filesystem_limits == "unlimited" and any(value is not None for value in numeric_limits):
136+
raise ValueError(
137+
"filesystem_limits='unlimited' cannot be combined with max_file_size, max_total_size, or max_file_count"
138+
)
139+
for name, value in (
140+
("max_file_size", max_file_size),
141+
("max_total_size", max_total_size),
142+
):
143+
if value is not None and not isinstance(value, str):
144+
raise TypeError(f"{name} must be a size string")
145+
if max_file_count is not None:
146+
if isinstance(max_file_count, bool) or not isinstance(max_file_count, int):
147+
raise TypeError("max_file_count must be an integer")
148+
if max_file_count < 0:
149+
raise ValueError("max_file_count must be non-negative")
150+
113151
if heap_size is None:
114152
heap_size = _DEFAULT_HEAP_SIZE
115153
if stack_size is None:
@@ -129,6 +167,14 @@ def __init__(
129167
kwargs["output_dir"] = output_dir
130168
if temp_output:
131169
kwargs["temp_output"] = True
170+
if filesystem_limits is not None:
171+
kwargs["filesystem_limits"] = filesystem_limits
172+
if max_file_size is not None:
173+
kwargs["max_file_size"] = max_file_size
174+
if max_total_size is not None:
175+
kwargs["max_total_size"] = max_total_size
176+
if max_file_count is not None:
177+
kwargs["max_file_count"] = max_file_count
132178

133179
if normalized_backend == "wasm":
134180
resolved_module_path = resolve_module_path(module=effective_module, module_path=module_path)
@@ -218,6 +264,10 @@ def _get_sandbox(self) -> Sandbox:
218264
module_path=self.environment.module_path,
219265
heap_size=self.environment.heap_size,
220266
stack_size=self.environment.stack_size,
267+
filesystem_limits=self.environment.filesystem_limits,
268+
max_file_size=self.environment.max_file_size,
269+
max_total_size=self.environment.max_total_size,
270+
max_file_count=self.environment.max_file_count,
221271
)
222272
for tool_fn in self.tools:
223273
self._sandbox.register_tool(tool_fn)
Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
"""Tests for stable filesystem quota validation and forwarding."""
2+
3+
import unittest
4+
from unittest.mock import patch
5+
6+
from hyperlight_sandbox import CodeExecutionTool, Sandbox, SandboxEnvironment
7+
8+
9+
class _FakeNativeSandbox:
10+
calls: list[dict] = []
11+
12+
def __init__(self, **kwargs):
13+
self.calls.append(kwargs)
14+
15+
def register_tool(self, *args):
16+
pass
17+
18+
19+
class TestFilesystemLimitForwarding(unittest.TestCase):
20+
def setUp(self):
21+
_FakeNativeSandbox.calls.clear()
22+
23+
def test_omitted_limits_leave_backend_defaults_unchanged(self):
24+
with patch(
25+
"hyperlight_sandbox._load_backend",
26+
return_value=("hyperlight-js", _FakeNativeSandbox),
27+
):
28+
Sandbox(backend="hyperlight-js")
29+
30+
kwargs = _FakeNativeSandbox.calls[-1]
31+
self.assertNotIn("filesystem_limits", kwargs)
32+
self.assertNotIn("max_file_size", kwargs)
33+
self.assertNotIn("max_total_size", kwargs)
34+
self.assertNotIn("max_file_count", kwargs)
35+
36+
def test_custom_limits_and_zero_are_forwarded(self):
37+
with patch(
38+
"hyperlight_sandbox._load_backend",
39+
return_value=("hyperlight-js", _FakeNativeSandbox),
40+
):
41+
Sandbox(
42+
backend="hyperlight-js",
43+
max_file_size="8Mi",
44+
max_total_size="20Mi",
45+
max_file_count=0,
46+
)
47+
48+
self.assertEqual(_FakeNativeSandbox.calls[-1]["max_file_size"], "8Mi")
49+
self.assertEqual(_FakeNativeSandbox.calls[-1]["max_total_size"], "20Mi")
50+
self.assertEqual(_FakeNativeSandbox.calls[-1]["max_file_count"], 0)
51+
52+
def test_unlimited_is_forwarded(self):
53+
with patch(
54+
"hyperlight_sandbox._load_backend",
55+
return_value=("hyperlight-js", _FakeNativeSandbox),
56+
):
57+
Sandbox(backend="hyperlight-js", filesystem_limits="unlimited")
58+
59+
self.assertEqual(_FakeNativeSandbox.calls[-1]["filesystem_limits"], "unlimited")
60+
61+
def test_wasm_backend_forwards_limits(self):
62+
with (
63+
patch(
64+
"hyperlight_sandbox._load_backend",
65+
return_value=("wasm", _FakeNativeSandbox),
66+
),
67+
patch("hyperlight_sandbox.resolve_module_path", return_value="/guest.wasm"),
68+
):
69+
Sandbox(backend="wasm", max_total_size="4Mi")
70+
71+
kwargs = _FakeNativeSandbox.calls[-1]
72+
self.assertEqual(kwargs["module_path"], "/guest.wasm")
73+
self.assertEqual(kwargs["max_total_size"], "4Mi")
74+
75+
def test_environment_forwards_limits(self):
76+
environment = SandboxEnvironment(max_file_size="1Ki", max_file_count=2)
77+
tool = CodeExecutionTool(environment=environment)
78+
with patch(
79+
"hyperlight_sandbox._load_backend",
80+
return_value=("hyperlight-js", _FakeNativeSandbox),
81+
):
82+
tool._get_sandbox()
83+
84+
kwargs = _FakeNativeSandbox.calls[-1]
85+
self.assertEqual(kwargs["max_file_size"], "1Ki")
86+
self.assertEqual(kwargs["max_file_count"], 2)
87+
88+
89+
class TestFilesystemLimitValidation(unittest.TestCase):
90+
def test_unlimited_rejects_numeric_overrides(self):
91+
with self.assertRaisesRegex(ValueError, "cannot be combined"):
92+
Sandbox(filesystem_limits="unlimited", max_file_count=0)
93+
94+
def test_unknown_mode_is_rejected(self):
95+
with self.assertRaisesRegex(ValueError, "must be 'unlimited'"):
96+
Sandbox(filesystem_limits="default")
97+
98+
def test_negative_file_count_is_rejected(self):
99+
with self.assertRaisesRegex(ValueError, "non-negative"):
100+
Sandbox(max_file_count=-1)
101+
102+
def test_file_count_rejects_bool(self):
103+
with self.assertRaisesRegex(TypeError, "must be an integer"):
104+
Sandbox(max_file_count=True)
105+
106+
def test_size_override_must_be_a_string(self):
107+
with self.assertRaisesRegex(TypeError, "must be a size string"):
108+
Sandbox(max_file_size=1024) # type: ignore[arg-type]
109+
110+
def test_new_kwargs_are_not_silently_dropped_for_old_backends(self):
111+
class _OldBackend:
112+
def __init__(self, *, heap_size, stack_size):
113+
pass
114+
115+
with (
116+
patch(
117+
"hyperlight_sandbox._load_backend",
118+
return_value=("hyperlight-js", _OldBackend),
119+
),
120+
self.assertRaisesRegex(TypeError, "max_file_count"),
121+
):
122+
Sandbox(backend="hyperlight-js", max_file_count=1)
123+
124+
125+
if __name__ == "__main__":
126+
unittest.main()

‎src/sdk/python/hyperlight_js_backend/src/lib.rs‎

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,12 @@ use std::collections::HashMap;
22

33
use hyperlight_javascript_sandbox::HyperlightJs;
44
use hyperlight_sandbox::{
5-
DEFAULT_HEAP_SIZE, DEFAULT_STACK_SIZE, DirPerms, FilePerms, HttpMethod, Sandbox,
6-
SandboxBuilder, SandboxConfig,
5+
DEFAULT_HEAP_SIZE, DEFAULT_STACK_SIZE, DirPerms, FilePerms, FilesystemLimits, HttpMethod,
6+
Sandbox, SandboxBuilder, SandboxConfig,
77
};
88
use hyperlight_sandbox_pyo3_common::{
9-
PyExecutionResult, build_tool_registry, parse_size, parse_tool_registration,
9+
PyExecutionResult, build_tool_registry, parse_filesystem_limits, parse_size,
10+
parse_tool_registration,
1011
};
1112
use pyo3::exceptions::PyRuntimeError;
1213
use pyo3::prelude::*;
@@ -30,19 +31,24 @@ pub struct JSSandbox {
3031
input_dir: Option<String>,
3132
output_dir: Option<String>,
3233
temp_output: bool,
34+
filesystem_limits: Option<FilesystemLimits>,
3335
}
3436

3537
#[pymethods]
3638
impl JSSandbox {
3739
#[new]
38-
#[pyo3(signature = (input_dir=None, output_dir=None, temp_output=false, module_path="", heap_size=None, stack_size=None))]
40+
#[pyo3(signature = (input_dir=None, output_dir=None, temp_output=false, module_path="", heap_size=None, stack_size=None, filesystem_limits=None, max_file_size=None, max_total_size=None, max_file_count=None))]
3941
fn new(
4042
input_dir: Option<&str>,
4143
output_dir: Option<&str>,
4244
temp_output: bool,
4345
module_path: &str,
4446
heap_size: Option<&str>,
4547
stack_size: Option<&str>,
48+
filesystem_limits: Option<&str>,
49+
max_file_size: Option<&str>,
50+
max_total_size: Option<&str>,
51+
max_file_count: Option<usize>,
4652
) -> PyResult<Self> {
4753
if !module_path.is_empty() {
4854
return Err(PyRuntimeError::new_err(
@@ -68,6 +74,12 @@ impl JSSandbox {
6874
input_dir: input_dir.map(|s| s.to_string()),
6975
output_dir: output_dir.map(|s| s.to_string()),
7076
temp_output,
77+
filesystem_limits: parse_filesystem_limits(
78+
filesystem_limits,
79+
max_file_size,
80+
max_total_size,
81+
max_file_count,
82+
)?,
7183
})
7284
}
7385

@@ -99,6 +111,9 @@ impl JSSandbox {
99111
.stack_size(self.config.stack_size)
100112
.with_tools(registry)
101113
.guest(HyperlightJs);
114+
if let Some(limits) = self.filesystem_limits {
115+
builder = builder.filesystem_limits(limits);
116+
}
102117
if let Some(ref dir) = self.input_dir {
103118
builder = builder.input_dir(dir);
104119
}

‎src/sdk/python/pyo3_common/src/lib.rs‎

Lines changed: 78 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,14 @@
1-
use hyperlight_sandbox::{ArgType, ToolRegistry, ToolSchema};
2-
use pyo3::exceptions::{PyRuntimeError, PyTypeError};
1+
use hyperlight_sandbox::{ArgType, FilesystemLimits, ToolRegistry, ToolSchema};
2+
use pyo3::exceptions::{PyRuntimeError, PyTypeError, PyValueError};
33
use pyo3::prelude::*;
44
use pyo3::types::{PyDict, PyModule};
55

66
/// Convert a human-readable size string (e.g. `"200Mi"`) to bytes.
77
pub fn parse_size(size: &str) -> PyResult<u64> {
8+
parse_size_value(size).map_err(PyRuntimeError::new_err)
9+
}
10+
11+
fn parse_size_value(size: &str) -> Result<u64, String> {
812
let size = size.trim();
913
let (value, multiplier) = if let Some(value) = size.strip_suffix("Gi") {
1014
(value, 1024u64.pow(3))
@@ -15,12 +19,65 @@ pub fn parse_size(size: &str) -> PyResult<u64> {
1519
} else {
1620
(size, 1)
1721
};
18-
let parsed: u64 = value
19-
.parse()
20-
.map_err(|e| PyRuntimeError::new_err(format!("invalid size: {e}")))?;
22+
let parsed: u64 = value.parse().map_err(|e| format!("invalid size: {e}"))?;
2123
parsed
2224
.checked_mul(multiplier)
23-
.ok_or_else(|| PyRuntimeError::new_err("invalid size: value is too large"))
25+
.ok_or_else(|| "invalid size: value is too large".to_string())
26+
}
27+
28+
/// Parse the Python filesystem quota arguments into an optional builder override.
29+
///
30+
/// Returning `None` preserves the builder's balanced default limits.
31+
pub fn parse_filesystem_limits(
32+
filesystem_limits: Option<&str>,
33+
max_file_size: Option<&str>,
34+
max_total_size: Option<&str>,
35+
max_file_count: Option<usize>,
36+
) -> PyResult<Option<FilesystemLimits>> {
37+
let has_numeric_override =
38+
max_file_size.is_some() || max_total_size.is_some() || max_file_count.is_some();
39+
40+
if let Some(mode) = filesystem_limits {
41+
if mode != "unlimited" {
42+
return Err(PyValueError::new_err(
43+
"filesystem_limits must be 'unlimited' when provided",
44+
));
45+
}
46+
if has_numeric_override {
47+
return Err(PyValueError::new_err(
48+
"filesystem_limits='unlimited' cannot be combined with max_file_size, \
49+
max_total_size, or max_file_count",
50+
));
51+
}
52+
return Ok(Some(FilesystemLimits::unlimited()));
53+
}
54+
55+
if !has_numeric_override {
56+
return Ok(None);
57+
}
58+
59+
let defaults = FilesystemLimits::default();
60+
let max_file_size = match max_file_size {
61+
Some(size) => parse_size_value(size).map_err(PyValueError::new_err)?,
62+
None => defaults
63+
.max_file_size()
64+
.expect("default file size is limited"),
65+
};
66+
let max_total_size = match max_total_size {
67+
Some(size) => parse_size_value(size).map_err(PyValueError::new_err)?,
68+
None => defaults
69+
.max_total_size()
70+
.expect("default total size is limited"),
71+
};
72+
let max_file_count = max_file_count.unwrap_or_else(|| {
73+
defaults
74+
.max_file_count()
75+
.expect("default file count is limited")
76+
});
77+
78+
FilesystemLimits::new(max_file_size, max_total_size, max_file_count)
79+
.map(Some)
80+
.map_err(|e| PyValueError::new_err(format!("invalid filesystem limits: {e}")))
2481
}
2582

2683
/// Wrap an SDK-style tool object (with `.handler` and `.name`) into a plain `**kwargs` callable.
@@ -456,3 +513,18 @@ pub fn parse_tool_registration(
456513
Ok((name, cb))
457514
}
458515
}
516+
517+
#[cfg(test)]
518+
mod filesystem_limit_tests {
519+
use super::*;
520+
521+
#[test]
522+
fn malformed_size_is_rejected() {
523+
assert!(parse_size_value("invalid").is_err());
524+
}
525+
526+
#[test]
527+
fn overflowing_size_is_rejected() {
528+
assert!(parse_size_value("18446744073709551615Gi").is_err());
529+
}
530+
}

0 commit comments

Comments
 (0)