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.
- agenttoolkit/__init__.py +55 -0
- agenttoolkit/builtins/__init__.py +59 -0
- agenttoolkit/builtins/fs/__init__.py +21 -0
- agenttoolkit/builtins/fs/workspace.py +482 -0
- agenttoolkit/builtins/shell/__init__.py +30 -0
- agenttoolkit/builtins/shell/backends/__init__.py +11 -0
- agenttoolkit/builtins/shell/backends/bubblewrap.py +164 -0
- agenttoolkit/builtins/shell/backends/docker.py +355 -0
- agenttoolkit/builtins/shell/backends/subprocess.py +55 -0
- agenttoolkit/builtins/shell/policy.py +170 -0
- agenttoolkit/builtins/shell/sandbox.py +222 -0
- agenttoolkit/builtins/todo/__init__.py +14 -0
- agenttoolkit/builtins/todo/errors.py +10 -0
- agenttoolkit/builtins/todo/in_memory.py +122 -0
- agenttoolkit/builtins/todo/models.py +15 -0
- agenttoolkit/builtins/todo/protocol.py +34 -0
- agenttoolkit/skills/__init__.py +10 -0
- agenttoolkit/skills/models.py +145 -0
- agenttoolkit/skills/skills.py +188 -0
- agenttoolkit/tools/__init__.py +40 -0
- agenttoolkit/tools/arguments.py +64 -0
- agenttoolkit/tools/binding.py +68 -0
- agenttoolkit/tools/context.py +51 -0
- agenttoolkit/tools/middleware.py +182 -0
- agenttoolkit/tools/models.py +184 -0
- agenttoolkit/tools/results.py +41 -0
- agenttoolkit/tools/schema.py +105 -0
- agenttoolkit/tools/tools.py +216 -0
- mm_agenttoolkit-0.1.0.dist-info/METADATA +556 -0
- mm_agenttoolkit-0.1.0.dist-info/RECORD +32 -0
- mm_agenttoolkit-0.1.0.dist-info/WHEEL +4 -0
- mm_agenttoolkit-0.1.0.dist-info/licenses/LICENSE.md +21 -0
|
@@ -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
|
+
)
|