strands-shell 0.3.0__cp313-cp313-win_amd64.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.
- strands_shell/__init__.py +416 -0
- strands_shell/_native.cp313-win_amd64.pyd +0 -0
- strands_shell-0.3.0.dist-info/METADATA +297 -0
- strands_shell-0.3.0.dist-info/RECORD +9 -0
- strands_shell-0.3.0.dist-info/WHEEL +4 -0
- strands_shell-0.3.0.dist-info/entry_points.txt +2 -0
- strands_shell-0.3.0.dist-info/licenses/LICENSE +175 -0
- strands_shell-0.3.0.dist-info/licenses/NOTICE +1 -0
- strands_shell-0.3.0.dist-info/sboms/strands-shell.cyclonedx.json +6810 -0
|
@@ -0,0 +1,416 @@
|
|
|
1
|
+
"""Strands Shell — a virtual shell sandbox for AI agents.
|
|
2
|
+
|
|
3
|
+
This package is the customer-facing Python API. It wraps the native extension
|
|
4
|
+
(``strands_shell._native``, built from Rust via maturin) with:
|
|
5
|
+
|
|
6
|
+
* a config-driven :class:`Shell` constructor (flat keyword args plus the
|
|
7
|
+
:class:`Bind` / :class:`Cred` / :class:`Limits` option dataclasses), mirroring
|
|
8
|
+
the ``Agent`` / ``Swarm`` constructor shape in the Strands SDK, and
|
|
9
|
+
* a typed :class:`ShellError` exception hierarchy whose subclasses also inherit
|
|
10
|
+
the matching stdlib exceptions, so adapter code can ``except FileNotFoundError``
|
|
11
|
+
directly.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import builtins
|
|
17
|
+
import math
|
|
18
|
+
from dataclasses import dataclass
|
|
19
|
+
from typing import Literal
|
|
20
|
+
|
|
21
|
+
from strands_shell import _native
|
|
22
|
+
|
|
23
|
+
__all__ = [
|
|
24
|
+
"Shell",
|
|
25
|
+
"Bind",
|
|
26
|
+
"Cred",
|
|
27
|
+
"Limits",
|
|
28
|
+
"Output",
|
|
29
|
+
"FileInfo",
|
|
30
|
+
"ShellConfig",
|
|
31
|
+
"ConfigBind",
|
|
32
|
+
"ConfigCred",
|
|
33
|
+
"ConfigLimits",
|
|
34
|
+
"ShellError",
|
|
35
|
+
"FileNotFoundError",
|
|
36
|
+
"PermissionDeniedError",
|
|
37
|
+
"FileTooLargeError",
|
|
38
|
+
]
|
|
39
|
+
|
|
40
|
+
# Value types are re-exported straight from the native module — they are plain
|
|
41
|
+
# data carriers with the right attribute names already.
|
|
42
|
+
Output = _native.Output
|
|
43
|
+
FileInfo = _native.FileInfo
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
# --------------------------------------------------------------------------- #
|
|
47
|
+
# Option dataclasses
|
|
48
|
+
# --------------------------------------------------------------------------- #
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@dataclass(frozen=True)
|
|
52
|
+
class Bind:
|
|
53
|
+
"""A bind-mount entry mapping a host path into the VFS.
|
|
54
|
+
|
|
55
|
+
``mode="direct"`` is host passthrough (host-side writes after construction
|
|
56
|
+
are visible, and VFS writes hit the host); ``mode="copy"`` snapshots the
|
|
57
|
+
host directory into the VFS at construction time. ``readonly=True`` rejects
|
|
58
|
+
writes through the mount.
|
|
59
|
+
"""
|
|
60
|
+
|
|
61
|
+
source: str
|
|
62
|
+
destination: str
|
|
63
|
+
mode: Literal["direct", "copy"] = "direct"
|
|
64
|
+
readonly: bool = False
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@dataclass(frozen=True)
|
|
68
|
+
class Cred:
|
|
69
|
+
"""A credential injection rule.
|
|
70
|
+
|
|
71
|
+
Exactly one of ``token`` / ``env_var`` must be set. ``env_var`` is resolved
|
|
72
|
+
against the process environment when the :class:`Shell` is constructed.
|
|
73
|
+
"""
|
|
74
|
+
|
|
75
|
+
url: str
|
|
76
|
+
token: str | None = None
|
|
77
|
+
env_var: str | None = None
|
|
78
|
+
|
|
79
|
+
def __post_init__(self) -> None:
|
|
80
|
+
if (self.token is None) == (self.env_var is None):
|
|
81
|
+
raise ValueError(
|
|
82
|
+
"Cred requires exactly one of `token` or `env_var` to be set"
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
@dataclass(frozen=True)
|
|
87
|
+
class Limits:
|
|
88
|
+
"""Resource caps for a :class:`Shell`.
|
|
89
|
+
|
|
90
|
+
Bundled together (rather than as flat constructor kwargs) so the behavioral
|
|
91
|
+
settings on the constructor stay visually separate from the protective
|
|
92
|
+
caps. Mirrors the MCP server's ``[limits]`` TOML table. Override only the
|
|
93
|
+
caps you care about; the rest keep their defaults.
|
|
94
|
+
"""
|
|
95
|
+
|
|
96
|
+
max_output: int = 1 << 20 # 1 MiB
|
|
97
|
+
max_file_size: int = 10 << 20 # 10 MiB
|
|
98
|
+
max_fds: int = 128
|
|
99
|
+
max_bg_jobs: int = 8
|
|
100
|
+
max_pipeline: int = 16
|
|
101
|
+
max_input: int = 1 << 20 # 1 MiB
|
|
102
|
+
max_inodes: int = 10_000
|
|
103
|
+
max_depth: int = 64
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
# --------------------------------------------------------------------------- #
|
|
107
|
+
# Read-only config snapshot (returned by ``Shell.config``)
|
|
108
|
+
# --------------------------------------------------------------------------- #
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
@dataclass(frozen=True)
|
|
112
|
+
class ConfigBind:
|
|
113
|
+
"""A bind mount as reported by :attr:`Shell.config`.
|
|
114
|
+
|
|
115
|
+
Read-only view; mirrors the :class:`Bind` you pass in, with ``mode``
|
|
116
|
+
normalized to ``"copy"`` / ``"direct"``.
|
|
117
|
+
"""
|
|
118
|
+
|
|
119
|
+
source: str
|
|
120
|
+
destination: str
|
|
121
|
+
mode: Literal["direct", "copy"]
|
|
122
|
+
readonly: bool
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
@dataclass(frozen=True)
|
|
126
|
+
class ConfigCred:
|
|
127
|
+
"""A credential rule as reported by :attr:`Shell.config`.
|
|
128
|
+
|
|
129
|
+
The secret value is **never** exposed. ``env_var`` holds the environment
|
|
130
|
+
variable name when the credential was configured from the environment;
|
|
131
|
+
``from_literal`` is ``True`` when a literal token was supplied directly
|
|
132
|
+
(its value is still withheld).
|
|
133
|
+
"""
|
|
134
|
+
|
|
135
|
+
url: str
|
|
136
|
+
kind: str
|
|
137
|
+
methods: tuple[str, ...]
|
|
138
|
+
param: str | None
|
|
139
|
+
env_var: str | None
|
|
140
|
+
from_literal: bool
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
@dataclass(frozen=True)
|
|
144
|
+
class ConfigLimits:
|
|
145
|
+
"""Resource caps as reported by :attr:`Shell.config`.
|
|
146
|
+
|
|
147
|
+
Unlike :class:`Limits` (the input bundle), this view always carries every
|
|
148
|
+
cap with its concrete active value.
|
|
149
|
+
"""
|
|
150
|
+
|
|
151
|
+
max_depth: int
|
|
152
|
+
max_output: int
|
|
153
|
+
max_fds: int
|
|
154
|
+
max_bg_jobs: int
|
|
155
|
+
max_pipeline: int
|
|
156
|
+
max_input: int
|
|
157
|
+
max_file_size: int
|
|
158
|
+
max_inodes: int
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
@dataclass(frozen=True)
|
|
162
|
+
class ShellConfig:
|
|
163
|
+
"""A read-only snapshot of how a :class:`Shell` was configured.
|
|
164
|
+
|
|
165
|
+
Returned by :attr:`Shell.config`. Lets an embedder introspect a constructed
|
|
166
|
+
shell after the fact (to build tool descriptions, surface the network
|
|
167
|
+
allowlist, or report active limits) without having held onto the
|
|
168
|
+
construction arguments. Secret values are never included.
|
|
169
|
+
"""
|
|
170
|
+
|
|
171
|
+
binds: tuple[ConfigBind, ...]
|
|
172
|
+
credentials: tuple[ConfigCred, ...]
|
|
173
|
+
allowed_urls: tuple[str, ...]
|
|
174
|
+
env: dict[str, str]
|
|
175
|
+
umask: int
|
|
176
|
+
timeout: float | None
|
|
177
|
+
limits: ConfigLimits
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def _snapshot_from_native(native_config: object) -> ShellConfig:
|
|
181
|
+
"""Convert a ``_native.ShellConfig`` into the frozen public dataclass."""
|
|
182
|
+
return ShellConfig(
|
|
183
|
+
binds=tuple(
|
|
184
|
+
ConfigBind(
|
|
185
|
+
source=b.source,
|
|
186
|
+
destination=b.destination,
|
|
187
|
+
mode=b.mode, # type: ignore[arg-type]
|
|
188
|
+
readonly=b.readonly,
|
|
189
|
+
)
|
|
190
|
+
for b in native_config.binds
|
|
191
|
+
),
|
|
192
|
+
credentials=tuple(
|
|
193
|
+
ConfigCred(
|
|
194
|
+
url=c.url,
|
|
195
|
+
kind=c.kind,
|
|
196
|
+
methods=tuple(c.methods),
|
|
197
|
+
param=c.param,
|
|
198
|
+
env_var=c.env_var,
|
|
199
|
+
from_literal=c.from_literal,
|
|
200
|
+
)
|
|
201
|
+
for c in native_config.credentials
|
|
202
|
+
),
|
|
203
|
+
allowed_urls=tuple(native_config.allowed_urls),
|
|
204
|
+
env=dict(native_config.env),
|
|
205
|
+
umask=native_config.umask,
|
|
206
|
+
timeout=native_config.timeout,
|
|
207
|
+
limits=ConfigLimits(
|
|
208
|
+
max_depth=native_config.limits.max_depth,
|
|
209
|
+
max_output=native_config.limits.max_output,
|
|
210
|
+
max_fds=native_config.limits.max_fds,
|
|
211
|
+
max_bg_jobs=native_config.limits.max_bg_jobs,
|
|
212
|
+
max_pipeline=native_config.limits.max_pipeline,
|
|
213
|
+
max_input=native_config.limits.max_input,
|
|
214
|
+
max_file_size=native_config.limits.max_file_size,
|
|
215
|
+
max_inodes=native_config.limits.max_inodes,
|
|
216
|
+
),
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
# --------------------------------------------------------------------------- #
|
|
221
|
+
# Exception hierarchy
|
|
222
|
+
# --------------------------------------------------------------------------- #
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
class ShellError(Exception):
|
|
226
|
+
"""Base for all Strands Shell file-op failures.
|
|
227
|
+
|
|
228
|
+
Carries the offending ``path`` and the kernel ``message``. Subclasses
|
|
229
|
+
differentiate the common failure types and additionally inherit the
|
|
230
|
+
matching stdlib exception, so ``except FileNotFoundError`` catches
|
|
231
|
+
:class:`FileNotFoundError` below without any translation shim.
|
|
232
|
+
"""
|
|
233
|
+
|
|
234
|
+
def __init__(self, message: str, *, path: str = "") -> None:
|
|
235
|
+
super().__init__(message)
|
|
236
|
+
self.path = path
|
|
237
|
+
self.message = message
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
class FileNotFoundError(ShellError, builtins.FileNotFoundError):
|
|
241
|
+
"""A path did not exist (read / remove / list of a missing path)."""
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
class PermissionDeniedError(ShellError, builtins.PermissionError):
|
|
245
|
+
"""A write or remove was blocked — read-only mount or mount policy."""
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
class FileTooLargeError(ShellError, OSError):
|
|
249
|
+
"""``max_file_size`` or ``max_inodes`` was exceeded on write."""
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
# Map the native error's `kind` discriminator onto the typed subclasses.
|
|
253
|
+
_ERROR_BY_KIND = {
|
|
254
|
+
"not_found": FileNotFoundError,
|
|
255
|
+
"permission_denied": PermissionDeniedError,
|
|
256
|
+
"too_large": FileTooLargeError,
|
|
257
|
+
"other": ShellError,
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def _raise_typed(exc: BaseException) -> "ShellError":
|
|
262
|
+
"""Translate a ``_native.NativeShellError`` into the typed hierarchy."""
|
|
263
|
+
kind = getattr(exc, "kind", "other")
|
|
264
|
+
path = getattr(exc, "path", "")
|
|
265
|
+
message = getattr(exc, "message", str(exc))
|
|
266
|
+
cls = _ERROR_BY_KIND.get(kind, ShellError)
|
|
267
|
+
return cls(message, path=path)
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
# --------------------------------------------------------------------------- #
|
|
271
|
+
# Shell
|
|
272
|
+
# --------------------------------------------------------------------------- #
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
class Shell:
|
|
276
|
+
"""A sandboxed shell environment.
|
|
277
|
+
|
|
278
|
+
Constructed directly with config — no builder, no factory. Mounts and
|
|
279
|
+
credentials go in as lists of :class:`Bind` / :class:`Cred`; resource caps
|
|
280
|
+
go in a single :class:`Limits` bundle; behavioral settings (``env``,
|
|
281
|
+
``umask``, ``timeout``) are top-level keyword args.
|
|
282
|
+
|
|
283
|
+
State (cwd, env, functions, open fds) persists across :meth:`run` calls.
|
|
284
|
+
There is no ``close()`` — the embedded interpreter and in-process VFS are
|
|
285
|
+
released by refcounting when the last reference drops.
|
|
286
|
+
"""
|
|
287
|
+
|
|
288
|
+
def __init__(
|
|
289
|
+
self,
|
|
290
|
+
*,
|
|
291
|
+
binds: list[Bind] | None = None,
|
|
292
|
+
credentials: list[Cred] | None = None,
|
|
293
|
+
allowed_urls: list[str] | None = None,
|
|
294
|
+
env: dict[str, str] | None = None,
|
|
295
|
+
umask: int | None = None,
|
|
296
|
+
timeout: float | None = None,
|
|
297
|
+
limits: Limits | None = None,
|
|
298
|
+
config_file: str | None = None,
|
|
299
|
+
) -> None:
|
|
300
|
+
builder = _native.Shell.builder()
|
|
301
|
+
|
|
302
|
+
# config_file merges in first; explicit args below win over it. Each
|
|
303
|
+
# behavioral/limit setting is applied only when the caller actually
|
|
304
|
+
# passed it (``None`` means "unset"), so defaulting an argument never
|
|
305
|
+
# silently clobbers a value the TOML set. The Rust core supplies the
|
|
306
|
+
# real defaults when nothing is configured here or in the file.
|
|
307
|
+
if config_file is not None:
|
|
308
|
+
builder.config_file(config_file)
|
|
309
|
+
|
|
310
|
+
for b in binds or []:
|
|
311
|
+
if b.mode == "direct" and b.readonly:
|
|
312
|
+
builder.bind_direct_readonly(b.source, b.destination)
|
|
313
|
+
elif b.mode == "direct":
|
|
314
|
+
builder.bind_direct(b.source, b.destination)
|
|
315
|
+
elif b.readonly:
|
|
316
|
+
builder.bind_readonly(b.source, b.destination)
|
|
317
|
+
else:
|
|
318
|
+
builder.bind(b.source, b.destination)
|
|
319
|
+
|
|
320
|
+
for c in credentials or []:
|
|
321
|
+
if c.token is not None:
|
|
322
|
+
builder.credential(c.url, c.token)
|
|
323
|
+
else:
|
|
324
|
+
builder.credential_from_env(c.url, c.env_var)
|
|
325
|
+
|
|
326
|
+
for prefix in allowed_urls or []:
|
|
327
|
+
builder.allow_url(prefix)
|
|
328
|
+
|
|
329
|
+
for key, value in (env or {}).items():
|
|
330
|
+
builder.env(key, value)
|
|
331
|
+
|
|
332
|
+
if umask is not None:
|
|
333
|
+
builder.umask(umask)
|
|
334
|
+
if timeout is not None:
|
|
335
|
+
# Reject non-positive / non-finite up front: zero would expire every
|
|
336
|
+
# command immediately (there is no "unlimited" sentinel — omit
|
|
337
|
+
# timeout instead), and a negative/NaN/inf value would panic the
|
|
338
|
+
# native Duration::from_secs_f64 across the FFI boundary.
|
|
339
|
+
if not math.isfinite(timeout) or timeout <= 0:
|
|
340
|
+
raise ValueError(
|
|
341
|
+
"timeout must be a positive, finite number of seconds "
|
|
342
|
+
"(omit it for no timeout)"
|
|
343
|
+
)
|
|
344
|
+
builder.timeout(timeout)
|
|
345
|
+
if limits is not None:
|
|
346
|
+
builder.max_output(limits.max_output)
|
|
347
|
+
builder.max_file_size(limits.max_file_size)
|
|
348
|
+
builder.max_fds(limits.max_fds)
|
|
349
|
+
builder.max_bg_jobs(limits.max_bg_jobs)
|
|
350
|
+
builder.max_pipeline(limits.max_pipeline)
|
|
351
|
+
builder.max_input(limits.max_input)
|
|
352
|
+
builder.max_inodes(limits.max_inodes)
|
|
353
|
+
builder.max_depth(limits.max_depth)
|
|
354
|
+
|
|
355
|
+
self._shell = builder.build()
|
|
356
|
+
|
|
357
|
+
# ---- Command execution ----
|
|
358
|
+
|
|
359
|
+
def run(self, command: str) -> Output:
|
|
360
|
+
"""Run a command and capture its output. Never raises for command-level
|
|
361
|
+
failures — check :attr:`Output.status`."""
|
|
362
|
+
return self._shell.run(command)
|
|
363
|
+
|
|
364
|
+
# ---- Environment ----
|
|
365
|
+
|
|
366
|
+
def set_env(self, key: str, value: str) -> None:
|
|
367
|
+
self._shell.set_env(key, value)
|
|
368
|
+
|
|
369
|
+
def get_env(self, key: str) -> str | None:
|
|
370
|
+
return self._shell.get_env(key)
|
|
371
|
+
|
|
372
|
+
# ---- Configuration introspection ----
|
|
373
|
+
|
|
374
|
+
@property
|
|
375
|
+
def config(self) -> ShellConfig:
|
|
376
|
+
"""A read-only snapshot of how this shell was configured.
|
|
377
|
+
|
|
378
|
+
Reports bind mounts, credential rules, the network allowlist, seeded
|
|
379
|
+
environment variables, umask, timeout, and resource caps. Useful for
|
|
380
|
+
introspecting a constructed shell — e.g. to build tool descriptions or
|
|
381
|
+
surface the allowlist — without having held onto the constructor args.
|
|
382
|
+
|
|
383
|
+
Secret values are never included: each :class:`ConfigCred` reports its
|
|
384
|
+
URL pattern, kind, and source (literal vs environment variable name),
|
|
385
|
+
but never the token itself.
|
|
386
|
+
"""
|
|
387
|
+
return _snapshot_from_native(self._shell.config())
|
|
388
|
+
|
|
389
|
+
# ---- VFS file operations ----
|
|
390
|
+
# Each accepts **kwargs and ignores unknown keys, matching the
|
|
391
|
+
# kwargs-tolerant strands.sandbox.Sandbox contract the adapter passes
|
|
392
|
+
# through to.
|
|
393
|
+
|
|
394
|
+
def read_file(self, path: str, **kwargs: object) -> bytes:
|
|
395
|
+
try:
|
|
396
|
+
return self._shell.read_file(path)
|
|
397
|
+
except _native.NativeShellError as exc:
|
|
398
|
+
raise _raise_typed(exc) from None
|
|
399
|
+
|
|
400
|
+
def write_file(self, path: str, content: bytes, **kwargs: object) -> None:
|
|
401
|
+
try:
|
|
402
|
+
self._shell.write_file(path, content)
|
|
403
|
+
except _native.NativeShellError as exc:
|
|
404
|
+
raise _raise_typed(exc) from None
|
|
405
|
+
|
|
406
|
+
def remove_file(self, path: str, **kwargs: object) -> None:
|
|
407
|
+
try:
|
|
408
|
+
self._shell.remove_file(path)
|
|
409
|
+
except _native.NativeShellError as exc:
|
|
410
|
+
raise _raise_typed(exc) from None
|
|
411
|
+
|
|
412
|
+
def list_files(self, path: str, **kwargs: object) -> list[FileInfo]:
|
|
413
|
+
try:
|
|
414
|
+
return self._shell.list_files(path)
|
|
415
|
+
except _native.NativeShellError as exc:
|
|
416
|
+
raise _raise_typed(exc) from None
|
|
Binary file
|