mm-agenttoolkit 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.
@@ -0,0 +1,164 @@
1
+ import os
2
+ import shutil
3
+ from collections.abc import Iterable, Mapping, Sequence
4
+ from pathlib import Path
5
+
6
+ from agenttoolkit.builtins.shell.policy import SandboxPolicy
7
+ from agenttoolkit.builtins.shell.sandbox import (
8
+ SandboxResult,
9
+ SandboxUnavailableError,
10
+ run_process,
11
+ )
12
+
13
+
14
+ class BubblewrapSandbox:
15
+ def __init__(
16
+ self,
17
+ policy: SandboxPolicy | None = None,
18
+ *,
19
+ executable: str = "bwrap",
20
+ shell: str = "/bin/sh",
21
+ shell_arguments: Sequence[str] = ("-lc",),
22
+ ) -> None:
23
+ self._policy = policy or SandboxPolicy()
24
+ self._executable = executable
25
+ self._shell = shell
26
+ self._shell_arguments = tuple(shell_arguments)
27
+
28
+ @property
29
+ def policy(self) -> SandboxPolicy:
30
+ return self._policy
31
+
32
+ @property
33
+ def available(self) -> bool:
34
+ return os.name == "posix" and shutil.which(self._executable) is not None
35
+
36
+ async def execute(
37
+ self,
38
+ command: str,
39
+ *,
40
+ cwd: str | os.PathLike[str] | None = None,
41
+ env: Mapping[str, str] | None = None,
42
+ stdin: str | bytes | None = None,
43
+ timeout: float | None = None,
44
+ ) -> SandboxResult:
45
+ if not self.available:
46
+ raise SandboxUnavailableError(
47
+ "bubblewrap is only available on Linux with bwrap installed"
48
+ )
49
+ argv = self.build_argv(command, cwd=cwd, env=env)
50
+ return await run_process(
51
+ argv,
52
+ command=command,
53
+ cwd=None,
54
+ env=None,
55
+ stdin=stdin,
56
+ timeout=(
57
+ self._policy.limits.timeout_seconds if timeout is None else timeout
58
+ ),
59
+ max_output_bytes=self._policy.limits.max_output_bytes,
60
+ )
61
+
62
+ def build_argv(
63
+ self,
64
+ command: str,
65
+ *,
66
+ cwd: str | os.PathLike[str] | None = None,
67
+ env: Mapping[str, str] | None = None,
68
+ ) -> tuple[str, ...]:
69
+ if not command:
70
+ raise ValueError("command must not be empty")
71
+ limits = self._policy.limits
72
+ unsupported = [
73
+ name
74
+ for name, value in (
75
+ ("memory_bytes", limits.memory_bytes),
76
+ ("pids", limits.pids),
77
+ ("cpus", limits.cpus),
78
+ )
79
+ if value is not None
80
+ ]
81
+ if unsupported:
82
+ names = ", ".join(unsupported)
83
+ raise ValueError(f"bubblewrap cannot enforce these limits: {names}")
84
+
85
+ selected_cwd = self._policy.validate_working_directory(cwd)
86
+ mounts = self._mounts(selected_cwd)
87
+ argv = [
88
+ self._executable,
89
+ "--die-with-parent",
90
+ "--new-session",
91
+ "--unshare-all",
92
+ "--tmpfs",
93
+ "/",
94
+ "--proc",
95
+ "/proc",
96
+ "--dev",
97
+ "/dev",
98
+ "--dir",
99
+ "/tmp",
100
+ ]
101
+ if self._policy.enable_network_access:
102
+ argv.append("--share-net")
103
+
104
+ for system_path in ("/usr", "/bin", "/sbin", "/lib", "/lib64"):
105
+ if Path(system_path).exists():
106
+ argv.extend(("--ro-bind", system_path, system_path))
107
+
108
+ destinations = _destination_directories(source for source, _ in mounts)
109
+ for destination in destinations:
110
+ argv.extend(("--dir", str(destination)))
111
+ for source, writable in mounts:
112
+ option = "--bind" if writable else "--ro-bind"
113
+ argv.extend((option, str(source), str(source)))
114
+
115
+ selected_env = {
116
+ "HOME": "/tmp",
117
+ "PATH": os.environ.get("PATH", "/usr/bin:/bin"),
118
+ **self._policy.environment,
119
+ }
120
+ if env:
121
+ selected_env.update(env)
122
+ argv.append("--clearenv")
123
+ for key, value in selected_env.items():
124
+ argv.extend(("--setenv", key, value))
125
+
126
+ argv.extend(
127
+ (
128
+ "--chdir",
129
+ str(selected_cwd),
130
+ self._shell,
131
+ *self._shell_arguments,
132
+ command,
133
+ )
134
+ )
135
+ return tuple(argv)
136
+
137
+ def _mounts(self, working_directory: Path) -> tuple[tuple[Path, bool], ...]:
138
+ roots: dict[Path, bool] = {}
139
+ workspace = self._policy.working_directory or working_directory
140
+ roots[workspace] = self._policy.allows_write(workspace)
141
+ for path in self._policy.readable_paths:
142
+ roots.setdefault(path, False)
143
+ for path in self._policy.writable_paths:
144
+ roots[path] = True
145
+ for source in roots:
146
+ if not source.exists():
147
+ raise FileNotFoundError(source)
148
+ return tuple(roots.items())
149
+
150
+
151
+ def _destination_directories(paths: Iterable[Path]) -> list[Path]:
152
+ directories: set[Path] = set()
153
+ for source in paths:
154
+ path = source if source.is_dir() else source.parent
155
+ while path != path.parent and str(path) not in {
156
+ "/usr",
157
+ "/bin",
158
+ "/sbin",
159
+ "/lib",
160
+ "/lib64",
161
+ }:
162
+ directories.add(path)
163
+ path = path.parent
164
+ return sorted(directories, key=lambda item: len(item.parts))
@@ -0,0 +1,355 @@
1
+ import asyncio
2
+ import os
3
+ import uuid
4
+ from collections.abc import Mapping, Sequence
5
+ from dataclasses import dataclass
6
+ from enum import StrEnum
7
+ from pathlib import Path, PurePosixPath
8
+ from typing import Self
9
+
10
+ from agenttoolkit.builtins.shell.policy import SandboxPolicy
11
+ from agenttoolkit.builtins.shell.sandbox import (
12
+ SandboxError,
13
+ SandboxResult,
14
+ SandboxUnavailableError,
15
+ run_process,
16
+ )
17
+
18
+
19
+ class DockerNetworkMode(StrEnum):
20
+ BRIDGE = "bridge"
21
+ HOST = "host"
22
+
23
+
24
+ @dataclass(frozen=True, slots=True, init=False)
25
+ class BindMount:
26
+ source: Path
27
+ target: PurePosixPath
28
+ writable: bool
29
+
30
+ def __init__(
31
+ self,
32
+ source: str | os.PathLike[str],
33
+ target: str | PurePosixPath,
34
+ *,
35
+ writable: bool = False,
36
+ ) -> None:
37
+ normalized_source = Path(source).expanduser().resolve(strict=False)
38
+ normalized_target = PurePosixPath(target)
39
+ if (
40
+ not normalized_target.is_absolute()
41
+ or normalized_target == PurePosixPath("/")
42
+ or ".." in normalized_target.parts
43
+ ):
44
+ raise ValueError(
45
+ f"mount target must be an absolute container path below '/': {target!r}"
46
+ )
47
+ object.__setattr__(self, "source", normalized_source)
48
+ object.__setattr__(self, "target", normalized_target)
49
+ object.__setattr__(self, "writable", writable)
50
+
51
+ @classmethod
52
+ def read_only(
53
+ cls,
54
+ source: str | os.PathLike[str],
55
+ target: str | PurePosixPath,
56
+ ) -> Self:
57
+ return cls(source, target)
58
+
59
+ @classmethod
60
+ def read_write(
61
+ cls,
62
+ source: str | os.PathLike[str],
63
+ target: str | PurePosixPath,
64
+ ) -> Self:
65
+ return cls(source, target, writable=True)
66
+
67
+
68
+ class DockerSandbox:
69
+ def __init__(
70
+ self,
71
+ image: str,
72
+ policy: SandboxPolicy | None = None,
73
+ *,
74
+ mounts: Sequence[BindMount] = (),
75
+ inherit_environment: Sequence[str] = (),
76
+ user: str | None = None,
77
+ network_mode: DockerNetworkMode | None = None,
78
+ executable: str = "docker",
79
+ shell: str = "/bin/sh",
80
+ shell_arguments: Sequence[str] = ("-lc",),
81
+ ) -> None:
82
+ if not image.strip():
83
+ raise ValueError("image must not be empty")
84
+ self._image = image
85
+ self._policy = policy or SandboxPolicy()
86
+ self._mount_definitions = _validate_mounts(mounts)
87
+ self._inherit_environment = _validate_environment_names(inherit_environment)
88
+ if user is not None and not user.strip():
89
+ raise ValueError("user must not be empty")
90
+ self._user = user
91
+ if network_mode is not None:
92
+ if not isinstance(network_mode, DockerNetworkMode):
93
+ raise TypeError("network mode must be a DockerNetworkMode")
94
+ if not self._policy.enable_network_access:
95
+ raise ValueError(
96
+ "network mode requires network access to be enabled by policy"
97
+ )
98
+ self._network_mode = network_mode
99
+ self._executable = executable
100
+ self._shell = shell
101
+ self._shell_arguments = tuple(shell_arguments)
102
+
103
+ @property
104
+ def policy(self) -> SandboxPolicy:
105
+ return self._policy
106
+
107
+ @property
108
+ def mounts(self) -> tuple[BindMount, ...]:
109
+ return self._mount_definitions
110
+
111
+ @property
112
+ def inherit_environment(self) -> tuple[str, ...]:
113
+ return self._inherit_environment
114
+
115
+ @property
116
+ def user(self) -> str | None:
117
+ return self._user
118
+
119
+ @property
120
+ def network_mode(self) -> DockerNetworkMode | None:
121
+ return self._network_mode
122
+
123
+ async def execute(
124
+ self,
125
+ command: str,
126
+ *,
127
+ cwd: str | os.PathLike[str] | None = None,
128
+ env: Mapping[str, str] | None = None,
129
+ stdin: str | bytes | None = None,
130
+ timeout: float | None = None,
131
+ ) -> SandboxResult:
132
+ container_name = f"agenttoolkit-{uuid.uuid4().hex}"
133
+ argv = self.build_argv(
134
+ command,
135
+ cwd=cwd,
136
+ env=env,
137
+ interactive=stdin is not None,
138
+ container_name=container_name,
139
+ )
140
+ try:
141
+ result = await run_process(
142
+ argv,
143
+ command=command,
144
+ cwd=None,
145
+ env=None,
146
+ stdin=stdin,
147
+ timeout=(
148
+ self._policy.limits.timeout_seconds if timeout is None else timeout
149
+ ),
150
+ max_output_bytes=self._policy.limits.max_output_bytes,
151
+ )
152
+ except SandboxUnavailableError:
153
+ raise
154
+ except asyncio.CancelledError:
155
+ await asyncio.shield(self._remove_container(container_name))
156
+ raise
157
+ except BaseException:
158
+ await self._remove_container(container_name)
159
+ raise
160
+ if result.timed_out:
161
+ await self._remove_container(container_name)
162
+ return result
163
+
164
+ def build_argv(
165
+ self,
166
+ command: str,
167
+ *,
168
+ cwd: str | os.PathLike[str] | None = None,
169
+ env: Mapping[str, str] | None = None,
170
+ interactive: bool = False,
171
+ container_name: str | None = None,
172
+ ) -> tuple[str, ...]:
173
+ if not command:
174
+ raise ValueError("command must not be empty")
175
+ selected_cwd = self._policy.validate_working_directory(cwd)
176
+ mounts = self._mounts(selected_cwd)
177
+ container_cwd = _container_path(selected_cwd, mounts)
178
+
179
+ argv = [
180
+ self._executable,
181
+ "run",
182
+ "--rm",
183
+ "--read-only",
184
+ "--cap-drop",
185
+ "ALL",
186
+ "--security-opt",
187
+ "no-new-privileges",
188
+ "--tmpfs",
189
+ "/tmp:rw,nosuid,nodev",
190
+ "--workdir",
191
+ str(container_cwd),
192
+ ]
193
+ if interactive:
194
+ argv.append("-i")
195
+ if container_name is not None:
196
+ argv.extend(("--name", container_name))
197
+ if self._network_mode is not None:
198
+ argv.extend(("--network", self._network_mode.value))
199
+ elif not self._policy.enable_network_access:
200
+ argv.extend(("--network", "none"))
201
+ if selected_user := _resolve_user(self._user):
202
+ argv.extend(("--user", selected_user))
203
+
204
+ limits = self._policy.limits
205
+ if limits.memory_bytes is not None:
206
+ argv.extend(("--memory", str(limits.memory_bytes)))
207
+ if limits.pids is not None:
208
+ argv.extend(("--pids-limit", str(limits.pids)))
209
+ if limits.cpus is not None:
210
+ argv.extend(("--cpus", str(limits.cpus)))
211
+
212
+ for source, target, writable in mounts:
213
+ specification = f"type=bind,src={source},dst={target}"
214
+ if not writable:
215
+ specification += ",readonly"
216
+ argv.extend(("--mount", specification))
217
+
218
+ selected_env = dict(self._policy.environment)
219
+ if env:
220
+ selected_env.update(env)
221
+ for key in self._inherit_environment:
222
+ if key in selected_env:
223
+ continue
224
+ if key not in os.environ:
225
+ raise ValueError(f"host environment variable is not set: {key}")
226
+ argv.extend(("--env", key))
227
+ for key, value in selected_env.items():
228
+ argv.extend(("--env", f"{key}={value}"))
229
+
230
+ argv.extend((self._image, self._shell, *self._shell_arguments, command))
231
+ return tuple(argv)
232
+
233
+ def container_path(
234
+ self,
235
+ path: str | os.PathLike[str],
236
+ ) -> PurePosixPath:
237
+ working_directory = self._policy.validate_working_directory(None)
238
+ requested = Path(path).expanduser()
239
+ if not requested.is_absolute():
240
+ requested = working_directory / requested
241
+ requested = requested.resolve(strict=False)
242
+ return _container_path(requested, self._mounts(working_directory))
243
+
244
+ async def _remove_container(self, name: str) -> None:
245
+ try:
246
+ await run_process(
247
+ (self._executable, "rm", "--force", name),
248
+ command=f"remove container {name}",
249
+ cwd=None,
250
+ env=None,
251
+ stdin=None,
252
+ timeout=10,
253
+ max_output_bytes=64 * 1024,
254
+ )
255
+ except SandboxError:
256
+ pass
257
+
258
+ def _mounts(
259
+ self,
260
+ working_directory: Path,
261
+ ) -> tuple[tuple[Path, PurePosixPath, bool], ...]:
262
+ roots: list[tuple[Path, bool]] = []
263
+ workspace = self._policy.working_directory or working_directory
264
+ roots.append((workspace, self._policy.allows_write(workspace)))
265
+ roots.extend((path, False) for path in self._policy.readable_paths)
266
+ roots.extend((path, True) for path in self._policy.writable_paths)
267
+
268
+ merged: dict[Path, bool] = {}
269
+ for source, writable in roots:
270
+ if not source.exists():
271
+ raise FileNotFoundError(source)
272
+ merged[source] = merged.get(source, False) or writable
273
+
274
+ explicit_sources = {mount.source for mount in self._mount_definitions}
275
+ mounts: list[tuple[Path, PurePosixPath, bool]] = []
276
+ extra_index = 0
277
+ for source, writable in merged.items():
278
+ if source in explicit_sources:
279
+ continue
280
+ if source == workspace:
281
+ target = PurePosixPath("/workspace")
282
+ else:
283
+ target = PurePosixPath("/mnt") / f"path-{extra_index}"
284
+ extra_index += 1
285
+ mounts.append((source, target, writable))
286
+ mounts.extend(
287
+ (mount.source, mount.target, mount.writable)
288
+ for mount in self._mount_definitions
289
+ )
290
+
291
+ targets: dict[PurePosixPath, Path] = {}
292
+ for source, target, _ in mounts:
293
+ if not source.exists():
294
+ raise FileNotFoundError(source)
295
+ if existing := targets.get(target):
296
+ raise ValueError(
297
+ f"mount target {target} is used by both {existing} and {source}"
298
+ )
299
+ targets[target] = source
300
+ mounts.sort(key=lambda mount: len(mount[1].parts))
301
+ return tuple(mounts)
302
+
303
+
304
+ def _validate_mounts(mounts: Sequence[BindMount]) -> tuple[BindMount, ...]:
305
+ normalized = tuple(mounts)
306
+ sources: set[Path] = set()
307
+ targets: set[PurePosixPath] = set()
308
+ for mount in normalized:
309
+ if mount.source in sources:
310
+ raise ValueError(
311
+ f"mount source is configured more than once: {mount.source}"
312
+ )
313
+ if mount.target in targets:
314
+ raise ValueError(
315
+ f"mount target is configured more than once: {mount.target}"
316
+ )
317
+ sources.add(mount.source)
318
+ targets.add(mount.target)
319
+ return normalized
320
+
321
+
322
+ def _validate_environment_names(names: Sequence[str]) -> tuple[str, ...]:
323
+ normalized: list[str] = []
324
+ for name in names:
325
+ if not name or "=" in name or "\x00" in name:
326
+ raise ValueError(f"invalid environment variable name: {name!r}")
327
+ if name not in normalized:
328
+ normalized.append(name)
329
+ return tuple(normalized)
330
+
331
+
332
+ def _resolve_user(user: str | None) -> str | None:
333
+ if user != "host":
334
+ return user
335
+ getuid = getattr(os, "getuid", None)
336
+ getgid = getattr(os, "getgid", None)
337
+ if getuid is None or getgid is None:
338
+ return None
339
+ return f"{getuid()}:{getgid()}"
340
+
341
+
342
+ def _container_path(
343
+ path: Path,
344
+ mounts: tuple[tuple[Path, PurePosixPath, bool], ...],
345
+ ) -> PurePosixPath:
346
+ candidates: list[tuple[int, PurePosixPath]] = []
347
+ for source, target, _ in mounts:
348
+ try:
349
+ relative = path.relative_to(source)
350
+ except ValueError:
351
+ continue
352
+ candidates.append((len(source.parts), target.joinpath(*relative.parts)))
353
+ if not candidates:
354
+ raise PermissionError(f"path is not mounted in container: {path}")
355
+ return max(candidates, key=lambda candidate: candidate[0])[1]
@@ -0,0 +1,55 @@
1
+ import os
2
+ from collections.abc import Mapping, Sequence
3
+ from pathlib import Path
4
+
5
+ from agenttoolkit.builtins.shell.policy import SandboxPolicy
6
+ from agenttoolkit.builtins.shell.sandbox import SandboxResult, run_process
7
+
8
+
9
+ class UnsafeLocalSandbox:
10
+ """Runs locally; path and network policy fields are intentionally not enforced."""
11
+
12
+ def __init__(
13
+ self,
14
+ policy: SandboxPolicy | None = None,
15
+ *,
16
+ shell: str = "bash",
17
+ shell_arguments: Sequence[str] = ("-lc",),
18
+ ) -> None:
19
+ self._policy = policy or SandboxPolicy(enable_network_access=True)
20
+ self._shell = shell
21
+ self._shell_arguments = tuple(shell_arguments)
22
+
23
+ @property
24
+ def policy(self) -> SandboxPolicy:
25
+ return self._policy
26
+
27
+ async def execute(
28
+ self,
29
+ command: str,
30
+ *,
31
+ cwd: str | os.PathLike[str] | None = None,
32
+ env: Mapping[str, str] | None = None,
33
+ stdin: str | bytes | None = None,
34
+ timeout: float | None = None,
35
+ ) -> SandboxResult:
36
+ if not command:
37
+ raise ValueError("command must not be empty")
38
+
39
+ selected_cwd = self._policy.validate_working_directory(cwd)
40
+ selected_env = dict(os.environ)
41
+ selected_env.update(self._policy.environment)
42
+ if env:
43
+ selected_env.update(env)
44
+
45
+ return await run_process(
46
+ (self._shell, *self._shell_arguments, command),
47
+ command=command,
48
+ cwd=Path(selected_cwd),
49
+ env=selected_env,
50
+ stdin=stdin,
51
+ timeout=(
52
+ self._policy.limits.timeout_seconds if timeout is None else timeout
53
+ ),
54
+ max_output_bytes=self._policy.limits.max_output_bytes,
55
+ )