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.
@@ -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