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
agenttoolkit/__init__.py
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
from .skills import (
|
|
2
|
+
LoadedSkill,
|
|
3
|
+
Skill,
|
|
4
|
+
SkillChanges,
|
|
5
|
+
Skills,
|
|
6
|
+
parse_skill,
|
|
7
|
+
)
|
|
8
|
+
from .tools import (
|
|
9
|
+
ActionResult,
|
|
10
|
+
Inject,
|
|
11
|
+
SkillRefreshMiddleware,
|
|
12
|
+
Tool,
|
|
13
|
+
ToolAvailability,
|
|
14
|
+
ToolCall,
|
|
15
|
+
ToolContext,
|
|
16
|
+
ToolDescription,
|
|
17
|
+
ToolEffect,
|
|
18
|
+
ToolMetadata,
|
|
19
|
+
ToolMiddleware,
|
|
20
|
+
ToolPredicate,
|
|
21
|
+
Tools,
|
|
22
|
+
ToolSchema,
|
|
23
|
+
ToolSchemaFormat,
|
|
24
|
+
build_schema,
|
|
25
|
+
description_from_context,
|
|
26
|
+
provided,
|
|
27
|
+
requires,
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
__all__ = [
|
|
31
|
+
"ActionResult",
|
|
32
|
+
"Inject",
|
|
33
|
+
"LoadedSkill",
|
|
34
|
+
"Skill",
|
|
35
|
+
"SkillChanges",
|
|
36
|
+
"SkillRefreshMiddleware",
|
|
37
|
+
"Skills",
|
|
38
|
+
"Tool",
|
|
39
|
+
"ToolAvailability",
|
|
40
|
+
"ToolCall",
|
|
41
|
+
"ToolContext",
|
|
42
|
+
"ToolDescription",
|
|
43
|
+
"ToolEffect",
|
|
44
|
+
"ToolMetadata",
|
|
45
|
+
"ToolMiddleware",
|
|
46
|
+
"ToolPredicate",
|
|
47
|
+
"ToolSchema",
|
|
48
|
+
"ToolSchemaFormat",
|
|
49
|
+
"Tools",
|
|
50
|
+
"build_schema",
|
|
51
|
+
"description_from_context",
|
|
52
|
+
"parse_skill",
|
|
53
|
+
"provided",
|
|
54
|
+
"requires",
|
|
55
|
+
]
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
from .fs import (
|
|
2
|
+
EditError,
|
|
3
|
+
Entry,
|
|
4
|
+
FileTooLargeError,
|
|
5
|
+
LocalWorkspace,
|
|
6
|
+
PathOutsideWorkspaceError,
|
|
7
|
+
Workspace,
|
|
8
|
+
WorkspaceError,
|
|
9
|
+
)
|
|
10
|
+
from .shell import (
|
|
11
|
+
BindMount,
|
|
12
|
+
BubblewrapSandbox,
|
|
13
|
+
DockerSandbox,
|
|
14
|
+
Sandbox,
|
|
15
|
+
SandboxError,
|
|
16
|
+
SandboxExecutionError,
|
|
17
|
+
SandboxLimits,
|
|
18
|
+
SandboxPolicy,
|
|
19
|
+
SandboxResult,
|
|
20
|
+
SandboxUnavailableError,
|
|
21
|
+
UnsafeLocalSandbox,
|
|
22
|
+
)
|
|
23
|
+
from .todo import (
|
|
24
|
+
InMemoryTodoList,
|
|
25
|
+
Todo,
|
|
26
|
+
TodoError,
|
|
27
|
+
TodoList,
|
|
28
|
+
TodoNotFoundError,
|
|
29
|
+
TodoStateError,
|
|
30
|
+
TodoStatus,
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
__all__ = [
|
|
34
|
+
"BindMount",
|
|
35
|
+
"BubblewrapSandbox",
|
|
36
|
+
"DockerSandbox",
|
|
37
|
+
"EditError",
|
|
38
|
+
"Entry",
|
|
39
|
+
"FileTooLargeError",
|
|
40
|
+
"InMemoryTodoList",
|
|
41
|
+
"LocalWorkspace",
|
|
42
|
+
"PathOutsideWorkspaceError",
|
|
43
|
+
"Sandbox",
|
|
44
|
+
"SandboxError",
|
|
45
|
+
"SandboxExecutionError",
|
|
46
|
+
"SandboxLimits",
|
|
47
|
+
"SandboxPolicy",
|
|
48
|
+
"SandboxResult",
|
|
49
|
+
"SandboxUnavailableError",
|
|
50
|
+
"Todo",
|
|
51
|
+
"TodoError",
|
|
52
|
+
"TodoList",
|
|
53
|
+
"TodoNotFoundError",
|
|
54
|
+
"TodoStateError",
|
|
55
|
+
"TodoStatus",
|
|
56
|
+
"UnsafeLocalSandbox",
|
|
57
|
+
"Workspace",
|
|
58
|
+
"WorkspaceError",
|
|
59
|
+
]
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
from .workspace import (
|
|
2
|
+
EditError,
|
|
3
|
+
Entry,
|
|
4
|
+
FileTooLargeError,
|
|
5
|
+
GrepMatch,
|
|
6
|
+
LocalWorkspace,
|
|
7
|
+
PathOutsideWorkspaceError,
|
|
8
|
+
Workspace,
|
|
9
|
+
WorkspaceError,
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
__all__ = [
|
|
13
|
+
"EditError",
|
|
14
|
+
"Entry",
|
|
15
|
+
"FileTooLargeError",
|
|
16
|
+
"GrepMatch",
|
|
17
|
+
"LocalWorkspace",
|
|
18
|
+
"PathOutsideWorkspaceError",
|
|
19
|
+
"Workspace",
|
|
20
|
+
"WorkspaceError",
|
|
21
|
+
]
|
|
@@ -0,0 +1,482 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import os
|
|
3
|
+
import re
|
|
4
|
+
import stat
|
|
5
|
+
import tempfile
|
|
6
|
+
from collections.abc import Mapping, Sequence
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from pathlib import Path, PurePath, PurePosixPath
|
|
9
|
+
from typing import Protocol, runtime_checkable
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class WorkspaceError(Exception):
|
|
13
|
+
pass
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class PathOutsideWorkspaceError(WorkspaceError, ValueError):
|
|
17
|
+
pass
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class FileTooLargeError(WorkspaceError):
|
|
21
|
+
pass
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class EditError(WorkspaceError, ValueError):
|
|
25
|
+
pass
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass(frozen=True, slots=True)
|
|
29
|
+
class Entry:
|
|
30
|
+
path: str
|
|
31
|
+
is_dir: bool
|
|
32
|
+
is_symlink: bool
|
|
33
|
+
size: int | None = None
|
|
34
|
+
modified: float | None = None
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass(frozen=True, slots=True)
|
|
38
|
+
class GrepMatch:
|
|
39
|
+
path: str
|
|
40
|
+
line_number: int
|
|
41
|
+
line: str
|
|
42
|
+
context_before: tuple[str, ...] = ()
|
|
43
|
+
context_after: tuple[str, ...] = ()
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@runtime_checkable
|
|
47
|
+
class Workspace(Protocol):
|
|
48
|
+
@property
|
|
49
|
+
def root(self) -> Path: ...
|
|
50
|
+
|
|
51
|
+
async def read_file(
|
|
52
|
+
self,
|
|
53
|
+
path: str | os.PathLike[str],
|
|
54
|
+
*,
|
|
55
|
+
encoding: str | None = None,
|
|
56
|
+
) -> str: ...
|
|
57
|
+
|
|
58
|
+
async def write_file(
|
|
59
|
+
self,
|
|
60
|
+
path: str | os.PathLike[str],
|
|
61
|
+
content: str,
|
|
62
|
+
*,
|
|
63
|
+
encoding: str | None = None,
|
|
64
|
+
create_parents: bool = True,
|
|
65
|
+
) -> None: ...
|
|
66
|
+
|
|
67
|
+
async def edit_file(
|
|
68
|
+
self,
|
|
69
|
+
path: str | os.PathLike[str],
|
|
70
|
+
old: str,
|
|
71
|
+
new: str,
|
|
72
|
+
*,
|
|
73
|
+
replace_all: bool = False,
|
|
74
|
+
encoding: str | None = None,
|
|
75
|
+
) -> int: ...
|
|
76
|
+
|
|
77
|
+
async def glob(self, pattern: str) -> Sequence[Entry]: ...
|
|
78
|
+
|
|
79
|
+
async def grep(
|
|
80
|
+
self,
|
|
81
|
+
pattern: str,
|
|
82
|
+
*,
|
|
83
|
+
glob: str | None = None,
|
|
84
|
+
case_sensitive: bool = True,
|
|
85
|
+
context_lines: int = 0,
|
|
86
|
+
max_matches: int | None = None,
|
|
87
|
+
) -> Sequence[GrepMatch]: ...
|
|
88
|
+
|
|
89
|
+
async def list_dir(
|
|
90
|
+
self,
|
|
91
|
+
path: str | os.PathLike[str] = ".",
|
|
92
|
+
*,
|
|
93
|
+
recursive: bool = False,
|
|
94
|
+
limit: int | None = None,
|
|
95
|
+
) -> Sequence[Entry]: ...
|
|
96
|
+
|
|
97
|
+
async def stat(
|
|
98
|
+
self,
|
|
99
|
+
path: str | os.PathLike[str],
|
|
100
|
+
) -> Entry | None: ...
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
class LocalWorkspace:
|
|
104
|
+
def __init__(
|
|
105
|
+
self,
|
|
106
|
+
root: str | os.PathLike[str],
|
|
107
|
+
*,
|
|
108
|
+
mounts: Mapping[str | PurePosixPath, str | os.PathLike[str]] | None = None,
|
|
109
|
+
encoding: str = "utf-8",
|
|
110
|
+
max_file_bytes: int | None = 10 * 1024 * 1024,
|
|
111
|
+
) -> None:
|
|
112
|
+
if max_file_bytes is not None and max_file_bytes < 1:
|
|
113
|
+
raise ValueError("max_file_bytes must be positive or None")
|
|
114
|
+
|
|
115
|
+
root_path = Path(root).expanduser()
|
|
116
|
+
if root_path.exists() and not root_path.is_dir():
|
|
117
|
+
raise NotADirectoryError(root_path)
|
|
118
|
+
root_path.mkdir(parents=True, exist_ok=True)
|
|
119
|
+
|
|
120
|
+
self._root = root_path.resolve()
|
|
121
|
+
self._mounts = _normalize_mounts(mounts)
|
|
122
|
+
self._encoding = encoding
|
|
123
|
+
self._max_file_bytes = max_file_bytes
|
|
124
|
+
|
|
125
|
+
@property
|
|
126
|
+
def root(self) -> Path:
|
|
127
|
+
return self._root
|
|
128
|
+
|
|
129
|
+
async def read_file(
|
|
130
|
+
self,
|
|
131
|
+
path: str | os.PathLike[str],
|
|
132
|
+
*,
|
|
133
|
+
encoding: str | None = None,
|
|
134
|
+
) -> str:
|
|
135
|
+
return await asyncio.to_thread(self._read_file, path, encoding)
|
|
136
|
+
|
|
137
|
+
def _read_file(
|
|
138
|
+
self,
|
|
139
|
+
path: str | os.PathLike[str],
|
|
140
|
+
encoding: str | None,
|
|
141
|
+
) -> str:
|
|
142
|
+
target = self._resolve(path)
|
|
143
|
+
if not target.is_file():
|
|
144
|
+
raise FileNotFoundError(target)
|
|
145
|
+
self._check_size(target.stat().st_size)
|
|
146
|
+
return target.read_text(encoding=encoding or self._encoding)
|
|
147
|
+
|
|
148
|
+
async def write_file(
|
|
149
|
+
self,
|
|
150
|
+
path: str | os.PathLike[str],
|
|
151
|
+
content: str,
|
|
152
|
+
*,
|
|
153
|
+
encoding: str | None = None,
|
|
154
|
+
create_parents: bool = True,
|
|
155
|
+
) -> None:
|
|
156
|
+
await asyncio.to_thread(
|
|
157
|
+
self._write_file,
|
|
158
|
+
path,
|
|
159
|
+
content,
|
|
160
|
+
encoding,
|
|
161
|
+
create_parents,
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
def _write_file(
|
|
165
|
+
self,
|
|
166
|
+
path: str | os.PathLike[str],
|
|
167
|
+
content: str,
|
|
168
|
+
encoding: str | None,
|
|
169
|
+
create_parents: bool,
|
|
170
|
+
) -> None:
|
|
171
|
+
target = self._resolve(path)
|
|
172
|
+
if target.exists() and not target.is_file():
|
|
173
|
+
raise IsADirectoryError(target)
|
|
174
|
+
|
|
175
|
+
data = content.encode(encoding or self._encoding)
|
|
176
|
+
self._check_size(len(data))
|
|
177
|
+
if create_parents:
|
|
178
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
179
|
+
elif not target.parent.is_dir():
|
|
180
|
+
raise FileNotFoundError(target.parent)
|
|
181
|
+
|
|
182
|
+
self._assert_inside(target.parent.resolve(), self._base_of(target))
|
|
183
|
+
_atomic_write(target, data)
|
|
184
|
+
|
|
185
|
+
async def edit_file(
|
|
186
|
+
self,
|
|
187
|
+
path: str | os.PathLike[str],
|
|
188
|
+
old: str,
|
|
189
|
+
new: str,
|
|
190
|
+
*,
|
|
191
|
+
replace_all: bool = False,
|
|
192
|
+
encoding: str | None = None,
|
|
193
|
+
) -> int:
|
|
194
|
+
return await asyncio.to_thread(
|
|
195
|
+
self._edit_file,
|
|
196
|
+
path,
|
|
197
|
+
old,
|
|
198
|
+
new,
|
|
199
|
+
replace_all,
|
|
200
|
+
encoding,
|
|
201
|
+
)
|
|
202
|
+
|
|
203
|
+
def _edit_file(
|
|
204
|
+
self,
|
|
205
|
+
path: str | os.PathLike[str],
|
|
206
|
+
old: str,
|
|
207
|
+
new: str,
|
|
208
|
+
replace_all: bool,
|
|
209
|
+
encoding: str | None,
|
|
210
|
+
) -> int:
|
|
211
|
+
if not old:
|
|
212
|
+
raise EditError("old text must not be empty")
|
|
213
|
+
|
|
214
|
+
selected_encoding = encoding or self._encoding
|
|
215
|
+
content = self._read_file(path, selected_encoding)
|
|
216
|
+
occurrences = content.count(old)
|
|
217
|
+
if occurrences == 0:
|
|
218
|
+
raise EditError("old text was not found")
|
|
219
|
+
if occurrences > 1 and not replace_all:
|
|
220
|
+
raise EditError(
|
|
221
|
+
f"old text occurs {occurrences} times; use replace_all=True"
|
|
222
|
+
)
|
|
223
|
+
|
|
224
|
+
count = occurrences if replace_all else 1
|
|
225
|
+
updated = content.replace(old, new, -1 if replace_all else 1)
|
|
226
|
+
self._write_file(path, updated, selected_encoding, True)
|
|
227
|
+
return count
|
|
228
|
+
|
|
229
|
+
async def glob(self, pattern: str) -> Sequence[Entry]:
|
|
230
|
+
return await asyncio.to_thread(self._glob, pattern)
|
|
231
|
+
|
|
232
|
+
def _glob(self, pattern: str) -> list[Entry]:
|
|
233
|
+
_validate_pattern(pattern)
|
|
234
|
+
matches: list[Entry] = []
|
|
235
|
+
for candidate in self._root.glob(pattern):
|
|
236
|
+
try:
|
|
237
|
+
matches.append(self._entry(candidate))
|
|
238
|
+
except OSError:
|
|
239
|
+
continue
|
|
240
|
+
return sorted(matches, key=lambda entry: entry.path)
|
|
241
|
+
|
|
242
|
+
async def grep(
|
|
243
|
+
self,
|
|
244
|
+
pattern: str,
|
|
245
|
+
*,
|
|
246
|
+
glob: str | None = None,
|
|
247
|
+
case_sensitive: bool = True,
|
|
248
|
+
context_lines: int = 0,
|
|
249
|
+
max_matches: int | None = None,
|
|
250
|
+
) -> Sequence[GrepMatch]:
|
|
251
|
+
return await asyncio.to_thread(
|
|
252
|
+
self._grep,
|
|
253
|
+
pattern,
|
|
254
|
+
glob,
|
|
255
|
+
case_sensitive,
|
|
256
|
+
context_lines,
|
|
257
|
+
max_matches,
|
|
258
|
+
)
|
|
259
|
+
|
|
260
|
+
def _grep(
|
|
261
|
+
self,
|
|
262
|
+
pattern: str,
|
|
263
|
+
glob: str | None,
|
|
264
|
+
case_sensitive: bool,
|
|
265
|
+
context_lines: int,
|
|
266
|
+
max_matches: int | None,
|
|
267
|
+
) -> list[GrepMatch]:
|
|
268
|
+
if context_lines < 0:
|
|
269
|
+
raise ValueError("context_lines must be non-negative")
|
|
270
|
+
if max_matches is not None and max_matches < 0:
|
|
271
|
+
raise ValueError("max_matches must be non-negative or None")
|
|
272
|
+
try:
|
|
273
|
+
regex = re.compile(pattern, 0 if case_sensitive else re.IGNORECASE)
|
|
274
|
+
except re.error as error:
|
|
275
|
+
raise ValueError(f"invalid regex pattern: {pattern}") from error
|
|
276
|
+
|
|
277
|
+
candidates = (
|
|
278
|
+
self._glob(glob) if glob is not None else self._list_dir(".", True, None)
|
|
279
|
+
)
|
|
280
|
+
matches: list[GrepMatch] = []
|
|
281
|
+
for entry in candidates:
|
|
282
|
+
if entry.is_dir:
|
|
283
|
+
continue
|
|
284
|
+
if max_matches is not None and len(matches) >= max_matches:
|
|
285
|
+
break
|
|
286
|
+
try:
|
|
287
|
+
lines = (
|
|
288
|
+
self._resolve(entry.path)
|
|
289
|
+
.read_text(encoding=self._encoding)
|
|
290
|
+
.splitlines()
|
|
291
|
+
)
|
|
292
|
+
except (UnicodeDecodeError, OSError):
|
|
293
|
+
continue
|
|
294
|
+
for index, line in enumerate(lines):
|
|
295
|
+
if not regex.search(line):
|
|
296
|
+
continue
|
|
297
|
+
matches.append(
|
|
298
|
+
GrepMatch(
|
|
299
|
+
path=entry.path,
|
|
300
|
+
line_number=index + 1,
|
|
301
|
+
line=line,
|
|
302
|
+
context_before=tuple(
|
|
303
|
+
lines[max(0, index - context_lines) : index]
|
|
304
|
+
),
|
|
305
|
+
context_after=tuple(
|
|
306
|
+
lines[index + 1 : index + 1 + context_lines]
|
|
307
|
+
),
|
|
308
|
+
)
|
|
309
|
+
)
|
|
310
|
+
if max_matches is not None and len(matches) >= max_matches:
|
|
311
|
+
break
|
|
312
|
+
return matches
|
|
313
|
+
|
|
314
|
+
async def list_dir(
|
|
315
|
+
self,
|
|
316
|
+
path: str | os.PathLike[str] = ".",
|
|
317
|
+
*,
|
|
318
|
+
recursive: bool = False,
|
|
319
|
+
limit: int | None = None,
|
|
320
|
+
) -> Sequence[Entry]:
|
|
321
|
+
return await asyncio.to_thread(self._list_dir, path, recursive, limit)
|
|
322
|
+
|
|
323
|
+
def _list_dir(
|
|
324
|
+
self,
|
|
325
|
+
path: str | os.PathLike[str],
|
|
326
|
+
recursive: bool,
|
|
327
|
+
limit: int | None,
|
|
328
|
+
) -> list[Entry]:
|
|
329
|
+
if limit is not None and limit < 0:
|
|
330
|
+
raise ValueError("limit must be non-negative or None")
|
|
331
|
+
directory = self._resolve(path)
|
|
332
|
+
if not directory.is_dir():
|
|
333
|
+
raise NotADirectoryError(directory)
|
|
334
|
+
|
|
335
|
+
discovered = directory.rglob("*") if recursive else directory.iterdir()
|
|
336
|
+
candidates = sorted(discovered, key=self._display)
|
|
337
|
+
entries: list[Entry] = []
|
|
338
|
+
for candidate in candidates:
|
|
339
|
+
if limit is not None and len(entries) >= limit:
|
|
340
|
+
break
|
|
341
|
+
try:
|
|
342
|
+
entries.append(self._entry(candidate))
|
|
343
|
+
except OSError:
|
|
344
|
+
continue
|
|
345
|
+
return sorted(entries, key=lambda entry: entry.path)
|
|
346
|
+
|
|
347
|
+
async def stat(
|
|
348
|
+
self,
|
|
349
|
+
path: str | os.PathLike[str],
|
|
350
|
+
) -> Entry | None:
|
|
351
|
+
return await asyncio.to_thread(self._stat, path)
|
|
352
|
+
|
|
353
|
+
def _stat(self, path: str | os.PathLike[str]) -> Entry | None:
|
|
354
|
+
target = self._resolve_entry(path)
|
|
355
|
+
try:
|
|
356
|
+
return self._entry(target)
|
|
357
|
+
except FileNotFoundError:
|
|
358
|
+
return None
|
|
359
|
+
|
|
360
|
+
def _entry(self, path: Path) -> Entry:
|
|
361
|
+
metadata = path.lstat()
|
|
362
|
+
is_dir = path.is_dir()
|
|
363
|
+
return Entry(
|
|
364
|
+
path=self._display(path),
|
|
365
|
+
is_dir=is_dir,
|
|
366
|
+
is_symlink=path.is_symlink(),
|
|
367
|
+
size=None if is_dir else metadata.st_size,
|
|
368
|
+
modified=metadata.st_mtime,
|
|
369
|
+
)
|
|
370
|
+
|
|
371
|
+
def _resolve(self, path: str | os.PathLike[str]) -> Path:
|
|
372
|
+
target, base = self._locate(path)
|
|
373
|
+
target = target.resolve(strict=False)
|
|
374
|
+
self._assert_inside(target, base)
|
|
375
|
+
return target
|
|
376
|
+
|
|
377
|
+
def _resolve_entry(self, path: str | os.PathLike[str]) -> Path:
|
|
378
|
+
target, base = self._locate(path)
|
|
379
|
+
target = Path(os.path.abspath(target))
|
|
380
|
+
self._assert_inside(target, base)
|
|
381
|
+
self._assert_inside(target.parent.resolve(strict=False), base)
|
|
382
|
+
return target
|
|
383
|
+
|
|
384
|
+
def _locate(self, path: str | os.PathLike[str]) -> tuple[Path, Path]:
|
|
385
|
+
"""Map a requested path to a host path plus the root it may not escape.
|
|
386
|
+
|
|
387
|
+
Mount prefixes are matched textually because a POSIX-absolute prefix
|
|
388
|
+
such as ``/skills`` is drive-relative on Windows, so ``Path`` would
|
|
389
|
+
silently reinterpret it against the current drive.
|
|
390
|
+
"""
|
|
391
|
+
text = os.fspath(path).replace("\\", "/")
|
|
392
|
+
for prefix, mount in self._mounts:
|
|
393
|
+
if text == prefix:
|
|
394
|
+
return mount, mount
|
|
395
|
+
if text.startswith(f"{prefix}/"):
|
|
396
|
+
return mount / text[len(prefix) + 1 :], mount
|
|
397
|
+
|
|
398
|
+
requested = Path(path)
|
|
399
|
+
target = requested if requested.is_absolute() else self._root / requested
|
|
400
|
+
return target, self._root
|
|
401
|
+
|
|
402
|
+
def _assert_inside(self, path: Path, base: Path | None = None) -> None:
|
|
403
|
+
base = self._root if base is None else base
|
|
404
|
+
try:
|
|
405
|
+
path.relative_to(base)
|
|
406
|
+
except ValueError as error:
|
|
407
|
+
raise PathOutsideWorkspaceError(
|
|
408
|
+
f"path is outside workspace: {path}"
|
|
409
|
+
) from error
|
|
410
|
+
|
|
411
|
+
def _base_of(self, path: Path) -> Path:
|
|
412
|
+
for _, mount in self._mounts:
|
|
413
|
+
if path == mount or mount in path.parents:
|
|
414
|
+
return mount
|
|
415
|
+
return self._root
|
|
416
|
+
|
|
417
|
+
def _display(self, path: Path) -> str:
|
|
418
|
+
base = self._base_of(path)
|
|
419
|
+
relative = path.relative_to(base).as_posix()
|
|
420
|
+
if base == self._root:
|
|
421
|
+
return relative if relative != "." else "."
|
|
422
|
+
prefix = next(prefix for prefix, mount in self._mounts if mount == base)
|
|
423
|
+
return prefix if relative == "." else f"{prefix}/{relative}"
|
|
424
|
+
|
|
425
|
+
def _check_size(self, size: int) -> None:
|
|
426
|
+
if self._max_file_bytes is not None and size > self._max_file_bytes:
|
|
427
|
+
raise FileTooLargeError(
|
|
428
|
+
f"file is {size} bytes; limit is {self._max_file_bytes} bytes"
|
|
429
|
+
)
|
|
430
|
+
|
|
431
|
+
|
|
432
|
+
def _normalize_mounts(
|
|
433
|
+
mounts: Mapping[str | PurePosixPath, str | os.PathLike[str]] | None,
|
|
434
|
+
) -> tuple[tuple[str, Path], ...]:
|
|
435
|
+
if not mounts:
|
|
436
|
+
return ()
|
|
437
|
+
|
|
438
|
+
normalized: list[tuple[str, Path]] = []
|
|
439
|
+
for prefix, directory in mounts.items():
|
|
440
|
+
text = str(prefix).replace("\\", "/").rstrip("/")
|
|
441
|
+
if not text.startswith("/"):
|
|
442
|
+
raise ValueError(f"mount prefix must be absolute: {prefix}")
|
|
443
|
+
|
|
444
|
+
target = Path(directory).expanduser()
|
|
445
|
+
if target.exists() and not target.is_dir():
|
|
446
|
+
raise NotADirectoryError(target)
|
|
447
|
+
target.mkdir(parents=True, exist_ok=True)
|
|
448
|
+
normalized.append((text, target.resolve()))
|
|
449
|
+
|
|
450
|
+
# Longest first so nested mounts win over their parent prefix.
|
|
451
|
+
return tuple(sorted(normalized, key=lambda mount: len(mount[0]), reverse=True))
|
|
452
|
+
|
|
453
|
+
|
|
454
|
+
def _validate_pattern(pattern: str) -> None:
|
|
455
|
+
if not pattern:
|
|
456
|
+
raise ValueError("glob pattern must not be empty")
|
|
457
|
+
parsed = PurePath(pattern)
|
|
458
|
+
if parsed.is_absolute() or ".." in parsed.parts:
|
|
459
|
+
raise PathOutsideWorkspaceError(
|
|
460
|
+
f"glob pattern must stay inside workspace: {pattern}"
|
|
461
|
+
)
|
|
462
|
+
|
|
463
|
+
|
|
464
|
+
def _atomic_write(target: Path, data: bytes) -> None:
|
|
465
|
+
existing_mode = stat.S_IMODE(target.stat().st_mode) if target.exists() else None
|
|
466
|
+
descriptor, temporary_name = tempfile.mkstemp(
|
|
467
|
+
prefix=f".{target.name}.",
|
|
468
|
+
suffix=".tmp",
|
|
469
|
+
dir=target.parent,
|
|
470
|
+
)
|
|
471
|
+
temporary = Path(temporary_name)
|
|
472
|
+
try:
|
|
473
|
+
with os.fdopen(descriptor, "wb") as handle:
|
|
474
|
+
handle.write(data)
|
|
475
|
+
handle.flush()
|
|
476
|
+
os.fsync(handle.fileno())
|
|
477
|
+
if existing_mode is not None:
|
|
478
|
+
temporary.chmod(existing_mode)
|
|
479
|
+
os.replace(temporary, target)
|
|
480
|
+
except BaseException:
|
|
481
|
+
temporary.unlink(missing_ok=True)
|
|
482
|
+
raise
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
from .backends import (
|
|
2
|
+
BindMount,
|
|
3
|
+
BubblewrapSandbox,
|
|
4
|
+
DockerNetworkMode,
|
|
5
|
+
DockerSandbox,
|
|
6
|
+
UnsafeLocalSandbox,
|
|
7
|
+
)
|
|
8
|
+
from .policy import SandboxLimits, SandboxPolicy
|
|
9
|
+
from .sandbox import (
|
|
10
|
+
Sandbox,
|
|
11
|
+
SandboxError,
|
|
12
|
+
SandboxExecutionError,
|
|
13
|
+
SandboxResult,
|
|
14
|
+
SandboxUnavailableError,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
__all__ = [
|
|
18
|
+
"BindMount",
|
|
19
|
+
"BubblewrapSandbox",
|
|
20
|
+
"DockerNetworkMode",
|
|
21
|
+
"DockerSandbox",
|
|
22
|
+
"Sandbox",
|
|
23
|
+
"SandboxError",
|
|
24
|
+
"SandboxExecutionError",
|
|
25
|
+
"SandboxLimits",
|
|
26
|
+
"SandboxPolicy",
|
|
27
|
+
"SandboxResult",
|
|
28
|
+
"SandboxUnavailableError",
|
|
29
|
+
"UnsafeLocalSandbox",
|
|
30
|
+
]
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
from .bubblewrap import BubblewrapSandbox
|
|
2
|
+
from .docker import BindMount, DockerNetworkMode, DockerSandbox
|
|
3
|
+
from .subprocess import UnsafeLocalSandbox
|
|
4
|
+
|
|
5
|
+
__all__ = [
|
|
6
|
+
"BindMount",
|
|
7
|
+
"BubblewrapSandbox",
|
|
8
|
+
"DockerNetworkMode",
|
|
9
|
+
"DockerSandbox",
|
|
10
|
+
"UnsafeLocalSandbox",
|
|
11
|
+
]
|