nsjail-python 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
nsjail/enums.py ADDED
@@ -0,0 +1,27 @@
1
+ # GENERATED from nsjail config.proto — DO NOT EDIT
2
+ # Re-run: python -m _codegen.generate
3
+
4
+ from enum import IntEnum
5
+
6
+
7
+ class Mode(IntEnum):
8
+ LISTEN = 0
9
+ ONCE = 1
10
+ RERUN = 2
11
+ EXECVE = 3
12
+
13
+
14
+ class LogLevel(IntEnum):
15
+ DEBUG = 0
16
+ INFO = 1
17
+ WARNING = 2
18
+ ERROR = 3
19
+ FATAL = 4
20
+
21
+
22
+ class RLimitType(IntEnum):
23
+ VALUE = 0
24
+ SOFT = 1
25
+ HARD = 2
26
+ INF = 3
27
+
nsjail/exceptions.py ADDED
@@ -0,0 +1,29 @@
1
+ """Exception types for nsjail-python."""
2
+
3
+
4
+ class NsjailError(Exception):
5
+ """Base exception for all nsjail-python errors."""
6
+
7
+
8
+ class UnsupportedCLIField(NsjailError):
9
+ """Raised when a config field has no CLI flag equivalent."""
10
+
11
+ def __init__(self, field_name: str) -> None:
12
+ self.field_name = field_name
13
+ super().__init__(
14
+ f"Config field {field_name!r} has no CLI flag equivalent. "
15
+ f"Use textproto rendering instead, or pass on_unsupported='skip'."
16
+ )
17
+
18
+
19
+ class NsjailNotFound(NsjailError):
20
+ """Raised when the nsjail binary cannot be found."""
21
+
22
+ def __init__(self) -> None:
23
+ super().__init__(
24
+ "nsjail binary not found. Install it via:\n"
25
+ " pip install nsjail-python # includes pre-built binary\n"
26
+ " pip install nsjail-python[build] # build from source\n"
27
+ " apt-get install nsjail # system package\n"
28
+ "Or specify the path: Runner(nsjail_path='/path/to/nsjail')"
29
+ )
nsjail/presets.py ADDED
@@ -0,0 +1,74 @@
1
+ """Opinionated preset configurations and composable modifiers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from nsjail.config import Exe, MountPt, NsJailConfig
6
+ from nsjail.enums import Mode
7
+
8
+
9
+ def apply_readonly_root(
10
+ cfg: NsJailConfig,
11
+ *,
12
+ writable: list[str] | None = None,
13
+ ) -> None:
14
+ cfg.mount.append(MountPt(src="/", dst="/", is_bind=True, rw=False))
15
+
16
+ for path in writable or []:
17
+ if path == "/tmp":
18
+ cfg.mount.append(
19
+ MountPt(dst="/tmp", fstype="tmpfs", rw=True, is_dir=True)
20
+ )
21
+ else:
22
+ cfg.mount.append(
23
+ MountPt(src=path, dst=path, is_bind=True, rw=True)
24
+ )
25
+
26
+
27
+ def apply_cgroup_limits(
28
+ cfg: NsJailConfig,
29
+ *,
30
+ memory_mb: int | None = None,
31
+ cpu_ms_per_sec: int | None = None,
32
+ pids_max: int | None = None,
33
+ ) -> None:
34
+ if memory_mb is not None:
35
+ cfg.cgroup_mem_max = memory_mb * 1024 * 1024
36
+ if cpu_ms_per_sec is not None:
37
+ cfg.cgroup_cpu_ms_per_sec = cpu_ms_per_sec
38
+ if pids_max is not None:
39
+ cfg.cgroup_pids_max = pids_max
40
+
41
+
42
+ def apply_seccomp_log(cfg: NsJailConfig) -> None:
43
+ cfg.seccomp_log = True
44
+
45
+
46
+ def sandbox(
47
+ *,
48
+ command: list[str],
49
+ cwd: str = "/",
50
+ timeout_sec: int = 600,
51
+ memory_mb: int | None = None,
52
+ cpu_ms_per_sec: int | None = None,
53
+ pids_max: int | None = None,
54
+ network: bool = False,
55
+ writable_dirs: list[str] | None = None,
56
+ ) -> NsJailConfig:
57
+ cfg = NsJailConfig(
58
+ mode=Mode.ONCE,
59
+ cwd=cwd,
60
+ time_limit=timeout_sec,
61
+ clone_newnet=not network,
62
+ )
63
+
64
+ cfg.exec_bin = Exe(path=command[0], arg=command[1:])
65
+
66
+ apply_readonly_root(cfg, writable=writable_dirs)
67
+ apply_cgroup_limits(
68
+ cfg,
69
+ memory_mb=memory_mb,
70
+ cpu_ms_per_sec=cpu_ms_per_sec,
71
+ pids_max=pids_max,
72
+ )
73
+
74
+ return cfg
nsjail/runner.py ADDED
@@ -0,0 +1,274 @@
1
+ """Runner for executing nsjail sandboxes."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import copy
7
+ import shutil
8
+ import subprocess
9
+ import tempfile
10
+ from dataclasses import dataclass, fields as dc_fields
11
+ from pathlib import Path
12
+ from typing import Any
13
+
14
+ from nsjail.config import NsJailConfig
15
+ from nsjail.exceptions import NsjailNotFound
16
+ from nsjail.serializers import to_file
17
+
18
+
19
+ def _try_companion_binary() -> Path | None:
20
+ """Try to find the nsjail binary from companion packages."""
21
+ for module_name in ("nsjail_bin", "nsjail_bin_build"):
22
+ try:
23
+ mod = __import__(module_name)
24
+ return mod.binary_path()
25
+ except (ImportError, AttributeError, FileNotFoundError):
26
+ continue
27
+ return None
28
+
29
+
30
+ def resolve_nsjail_path(explicit_path: str | None) -> Path:
31
+ """Resolve the nsjail binary path.
32
+
33
+ Precedence: explicit path > system PATH > companion package > error.
34
+ """
35
+ if explicit_path is not None:
36
+ return Path(explicit_path)
37
+
38
+ system = shutil.which("nsjail")
39
+ if system is not None:
40
+ return Path(system)
41
+
42
+ companion = _try_companion_binary()
43
+ if companion is not None:
44
+ return companion
45
+
46
+ raise NsjailNotFound()
47
+
48
+
49
+ def merge_configs(
50
+ base: NsJailConfig,
51
+ overrides: NsJailConfig,
52
+ *,
53
+ override_fields: set[str],
54
+ extra_args: list[str] | None = None,
55
+ ) -> NsJailConfig:
56
+ """Merge an override config into a base config.
57
+
58
+ Scalars in override_fields replace the base value.
59
+ Lists in override_fields are appended.
60
+ extra_args are appended to exec_bin.arg.
61
+ """
62
+ merged = copy.deepcopy(base)
63
+
64
+ for f in dc_fields(NsJailConfig):
65
+ if f.name not in override_fields:
66
+ continue
67
+
68
+ override_val = getattr(overrides, f.name)
69
+ base_val = getattr(merged, f.name)
70
+
71
+ if isinstance(base_val, list):
72
+ base_val.extend(override_val)
73
+ else:
74
+ setattr(merged, f.name, override_val)
75
+
76
+ if extra_args and merged.exec_bin is not None:
77
+ merged.exec_bin.arg.extend(extra_args)
78
+
79
+ return merged
80
+
81
+
82
+ @dataclass
83
+ class NsJailResult:
84
+ """Result of running nsjail."""
85
+
86
+ returncode: int
87
+ stdout: bytes
88
+ stderr: bytes
89
+ config_path: Path | None
90
+ nsjail_args: list[str]
91
+ timed_out: bool
92
+ oom_killed: bool
93
+ signaled: bool
94
+ inner_returncode: int | None
95
+
96
+
97
+ class Runner:
98
+ """Configurable nsjail executor with optional baked-in config."""
99
+
100
+ def __init__(
101
+ self,
102
+ *,
103
+ nsjail_path: str | None = None,
104
+ base_config: NsJailConfig | None = None,
105
+ render_mode: str = "textproto",
106
+ capture_output: bool = True,
107
+ keep_config: bool = False,
108
+ ) -> None:
109
+ self._nsjail_path = nsjail_path
110
+ self._base_config = copy.deepcopy(base_config) if base_config else NsJailConfig()
111
+ self._render_mode = render_mode
112
+ self._capture_output = capture_output
113
+ self._keep_config = keep_config
114
+
115
+ def _prepare_run(
116
+ self,
117
+ overrides: NsJailConfig | None,
118
+ override_fields: set[str] | None,
119
+ extra_args: list[str] | None,
120
+ ) -> tuple[list[str], Path | None, NsJailConfig]:
121
+ """Resolve binary, merge configs, and render to args.
122
+
123
+ Returns (nsjail_args, config_path, merged_cfg).
124
+ config_path is None when render_mode is 'cli'.
125
+ """
126
+ nsjail_bin = resolve_nsjail_path(self._nsjail_path)
127
+
128
+ if overrides is not None and override_fields:
129
+ cfg = merge_configs(
130
+ self._base_config, overrides,
131
+ override_fields=override_fields, extra_args=extra_args,
132
+ )
133
+ elif extra_args:
134
+ cfg = merge_configs(
135
+ self._base_config, NsJailConfig(),
136
+ override_fields=set(), extra_args=extra_args,
137
+ )
138
+ else:
139
+ cfg = copy.deepcopy(self._base_config)
140
+
141
+ config_path: Path | None = None
142
+ if self._render_mode == "textproto":
143
+ tmp = tempfile.NamedTemporaryFile(
144
+ mode="w", suffix=".cfg", delete=False, prefix="nsjail_"
145
+ )
146
+ config_path = Path(tmp.name)
147
+ to_file(cfg, config_path)
148
+ tmp.close()
149
+ nsjail_args = [str(nsjail_bin), "--config", str(config_path)]
150
+ else:
151
+ from nsjail.serializers.cli import to_cli_args
152
+ cli_args = to_cli_args(cfg, on_unsupported="skip")
153
+ nsjail_args = [str(nsjail_bin)] + cli_args
154
+
155
+ if cfg.exec_bin and self._render_mode == "cli":
156
+ nsjail_args.append("--")
157
+ nsjail_args.append(cfg.exec_bin.path)
158
+ nsjail_args.extend(cfg.exec_bin.arg)
159
+
160
+ return nsjail_args, config_path, cfg
161
+
162
+ def _make_result(
163
+ self,
164
+ returncode: int,
165
+ stdout: bytes,
166
+ stderr: bytes,
167
+ config_path: Path | None,
168
+ nsjail_args: list[str],
169
+ ) -> NsJailResult:
170
+ """Build an NsJailResult from raw subprocess output."""
171
+ timed_out = returncode == 109
172
+ signaled = returncode > 100 and not timed_out
173
+ oom_killed = returncode == 137
174
+
175
+ return NsJailResult(
176
+ returncode=returncode,
177
+ stdout=stdout,
178
+ stderr=stderr,
179
+ config_path=config_path,
180
+ nsjail_args=nsjail_args,
181
+ timed_out=timed_out,
182
+ oom_killed=oom_killed,
183
+ signaled=signaled,
184
+ inner_returncode=returncode if returncode < 100 else None,
185
+ )
186
+
187
+ def run(
188
+ self,
189
+ overrides: NsJailConfig | None = None,
190
+ *,
191
+ override_fields: set[str] | None = None,
192
+ extra_args: list[str] | None = None,
193
+ timeout: float | None = None,
194
+ ) -> NsJailResult:
195
+ nsjail_args, config_path, _cfg = self._prepare_run(overrides, override_fields, extra_args)
196
+
197
+ try:
198
+ result = subprocess.run(
199
+ nsjail_args,
200
+ capture_output=self._capture_output,
201
+ timeout=timeout,
202
+ )
203
+ finally:
204
+ if config_path and not self._keep_config:
205
+ config_path.unlink(missing_ok=True)
206
+ config_path = None
207
+
208
+ return self._make_result(
209
+ returncode=result.returncode,
210
+ stdout=result.stdout if self._capture_output else b"",
211
+ stderr=result.stderr if self._capture_output else b"",
212
+ config_path=config_path,
213
+ nsjail_args=nsjail_args,
214
+ )
215
+
216
+ async def async_run(
217
+ self,
218
+ overrides: NsJailConfig | None = None,
219
+ *,
220
+ override_fields: set[str] | None = None,
221
+ extra_args: list[str] | None = None,
222
+ timeout: float | None = None,
223
+ ) -> NsJailResult:
224
+ """Run nsjail asynchronously."""
225
+ nsjail_args, config_path, cfg = self._prepare_run(
226
+ overrides, override_fields, extra_args
227
+ )
228
+
229
+ try:
230
+ proc = await asyncio.create_subprocess_exec(
231
+ *nsjail_args,
232
+ stdout=asyncio.subprocess.PIPE if self._capture_output else None,
233
+ stderr=asyncio.subprocess.PIPE if self._capture_output else None,
234
+ )
235
+ if timeout is not None:
236
+ stdout, stderr = await asyncio.wait_for(
237
+ proc.communicate(), timeout=timeout
238
+ )
239
+ else:
240
+ stdout, stderr = await proc.communicate()
241
+ finally:
242
+ if config_path and not self._keep_config:
243
+ config_path.unlink(missing_ok=True)
244
+ config_path = None
245
+
246
+ return self._make_result(
247
+ proc.returncode,
248
+ stdout if self._capture_output else b"",
249
+ stderr if self._capture_output else b"",
250
+ config_path,
251
+ nsjail_args,
252
+ )
253
+
254
+ def fork(
255
+ self,
256
+ *,
257
+ overrides: NsJailConfig | None = None,
258
+ override_fields: set[str] | None = None,
259
+ nsjail_path: str | None = None,
260
+ ) -> Runner:
261
+ if overrides and override_fields:
262
+ new_base = merge_configs(
263
+ self._base_config, overrides, override_fields=override_fields
264
+ )
265
+ else:
266
+ new_base = copy.deepcopy(self._base_config)
267
+
268
+ return Runner(
269
+ nsjail_path=nsjail_path or self._nsjail_path,
270
+ base_config=new_base,
271
+ render_mode=self._render_mode,
272
+ capture_output=self._capture_output,
273
+ keep_config=self._keep_config,
274
+ )
@@ -0,0 +1,26 @@
1
+ """Serializers for NsJailConfig: textproto, CLI args, protobuf."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+ from typing import Any
7
+
8
+ from nsjail.serializers.textproto import to_textproto
9
+ from nsjail.serializers.cli import to_cli_args
10
+
11
+
12
+ def to_file(cfg: Any, path: str | Path, *, validate: bool = False) -> None:
13
+ if validate:
14
+ try:
15
+ from nsjail.serializers.protobuf import to_protobuf
16
+ except ImportError:
17
+ raise ImportError(
18
+ "Validation requires the protobuf extra: pip install nsjail-python[proto]"
19
+ ) from None
20
+ to_protobuf(cfg)
21
+
22
+ text = to_textproto(cfg)
23
+ Path(path).write_text(text + "\n")
24
+
25
+
26
+ __all__ = ["to_textproto", "to_cli_args", "to_file"]
@@ -0,0 +1,69 @@
1
+ """Serialize NsJailConfig to nsjail CLI arguments."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ from dataclasses import fields as dc_fields
7
+ from enum import IntEnum
8
+ from typing import Any, Literal
9
+
10
+ from nsjail._field_meta import FIELD_REGISTRY
11
+ from nsjail.exceptions import UnsupportedCLIField
12
+
13
+ logger = logging.getLogger(__name__)
14
+
15
+
16
+ def to_cli_args(
17
+ cfg: Any,
18
+ *,
19
+ on_unsupported: Literal["raise", "warn", "skip"] = "raise",
20
+ ) -> list[str]:
21
+ args: list[str] = []
22
+ cls_name = type(cfg).__name__
23
+
24
+ for f in dc_fields(cfg):
25
+ key = (cls_name, f.name)
26
+ meta = FIELD_REGISTRY.get(key)
27
+ if meta is None:
28
+ continue
29
+
30
+ value = getattr(cfg, f.name)
31
+
32
+ # Skip defaults and None
33
+ if meta.is_repeated:
34
+ if not value:
35
+ continue
36
+ elif meta.is_message:
37
+ if value is None:
38
+ continue
39
+ else:
40
+ from nsjail.serializers.textproto import _is_default
41
+ if _is_default(value, meta):
42
+ continue
43
+
44
+ # Check CLI support
45
+ if not meta.cli_supported or meta.cli_flag is None:
46
+ if on_unsupported == "raise":
47
+ raise UnsupportedCLIField(f.name)
48
+ elif on_unsupported == "warn":
49
+ logger.warning(
50
+ "Config field %r has no CLI equivalent, skipping", f.name
51
+ )
52
+ continue
53
+
54
+ # Render the field
55
+ if meta.is_repeated:
56
+ for item in value:
57
+ args.append(meta.cli_flag)
58
+ args.append(str(item))
59
+ elif isinstance(value, bool):
60
+ if value:
61
+ args.append(meta.cli_flag)
62
+ elif isinstance(value, IntEnum):
63
+ args.append(meta.cli_flag)
64
+ args.append(str(int(value)))
65
+ else:
66
+ args.append(meta.cli_flag)
67
+ args.append(str(value))
68
+
69
+ return args
@@ -0,0 +1,22 @@
1
+ """Convert NsJailConfig dataclass to compiled protobuf message.
2
+
3
+ Requires the [proto] extra: pip install nsjail-python[proto]
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ from typing import Any
9
+
10
+ from google.protobuf import text_format
11
+
12
+ from nsjail.serializers.textproto import to_textproto
13
+
14
+
15
+ def to_protobuf(cfg: Any) -> Any:
16
+ """Convert a NsJailConfig to a compiled protobuf message."""
17
+ from nsjail._proto import config_pb2
18
+
19
+ text = to_textproto(cfg)
20
+ msg = config_pb2.NsJailConfig()
21
+ text_format.Parse(text, msg)
22
+ return msg
@@ -0,0 +1,91 @@
1
+ """Pure Python serializer for protobuf text format."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import fields as dc_fields
6
+ from enum import IntEnum
7
+ from typing import Any
8
+
9
+ from nsjail._field_meta import FIELD_REGISTRY, FieldMeta
10
+
11
+
12
+ def to_textproto(obj: Any, indent: int = 0) -> str:
13
+ lines: list[str] = []
14
+ prefix = " " * indent
15
+ cls_name = type(obj).__name__
16
+
17
+ for f in dc_fields(obj):
18
+ key = (cls_name, f.name)
19
+ meta = FIELD_REGISTRY.get(key)
20
+ if meta is None:
21
+ continue
22
+
23
+ value = getattr(obj, f.name)
24
+
25
+ if meta.is_repeated:
26
+ if not value:
27
+ continue
28
+ if meta.is_message:
29
+ for item in value:
30
+ lines.append(f"{prefix}{f.name} {{")
31
+ inner = to_textproto(item, indent + 1)
32
+ if inner.strip():
33
+ lines.append(inner)
34
+ lines.append(f"{prefix}}}")
35
+ else:
36
+ for item in value:
37
+ lines.append(f"{prefix}{f.name}: {_format_scalar(item, meta)}")
38
+ elif meta.is_message:
39
+ if value is None:
40
+ continue
41
+ lines.append(f"{prefix}{f.name} {{")
42
+ inner = to_textproto(value, indent + 1)
43
+ if inner.strip():
44
+ lines.append(inner)
45
+ lines.append(f"{prefix}}}")
46
+ else:
47
+ if _is_default(value, meta):
48
+ continue
49
+ lines.append(f"{prefix}{f.name}: {_format_scalar(value, meta)}")
50
+
51
+ return "\n".join(lines)
52
+
53
+
54
+ def _is_default(value: Any, meta: FieldMeta) -> bool:
55
+ if value is None and meta.default is None:
56
+ return True
57
+ if value is None:
58
+ return False
59
+ if meta.default is None:
60
+ return False
61
+ if isinstance(value, IntEnum):
62
+ return int(value) == meta.default
63
+ return value == meta.default
64
+
65
+
66
+ def _format_scalar(value: Any, meta: FieldMeta) -> str:
67
+ if isinstance(value, bool):
68
+ return "true" if value else "false"
69
+ if isinstance(value, IntEnum):
70
+ return value.name
71
+ if isinstance(value, int):
72
+ return str(value)
73
+ if isinstance(value, str):
74
+ return f'"{_escape_string(value)}"'
75
+ if isinstance(value, bytes):
76
+ return f'"{_escape_bytes(value)}"'
77
+ return str(value)
78
+
79
+
80
+ def _escape_string(s: str) -> str:
81
+ return s.replace("\\", "\\\\").replace('"', '\\"').replace("\n", "\\n")
82
+
83
+
84
+ def _escape_bytes(b: bytes) -> str:
85
+ parts: list[str] = []
86
+ for byte in b:
87
+ if 32 <= byte < 127 and byte != ord("\\") and byte != ord('"'):
88
+ parts.append(chr(byte))
89
+ else:
90
+ parts.append(f"\\x{byte:02x}")
91
+ return "".join(parts)