diff --git a/README.md b/README.md index 24929b8..39ef3cc 100644 --- a/README.md +++ b/README.md @@ -141,6 +141,47 @@ shell = strands_shell.Shell( > ⚠️ **`mode: "direct"` mounts are live.** The agent can read and modify host files in real time. Use only for designated output directories. Never direct-bind directories containing secrets, credentials, or configuration you don't want the agent to modify. +### Inspecting configuration + +A constructed shell exposes a read-only snapshot of how it was configured. This +is useful when you embed Strands Shell as a sandbox in a larger framework and +need to build tool descriptions, surface the network allowlist, or report the +active resource caps from a shell object you were handed. + +```python +shell = strands_shell.Shell( + allowed_urls=["https://api.example.com/"], + credentials=[strands_shell.Cred("https://api.example.com/", env_var="API_TOKEN")], + timeout=30.0, +) + +cfg = shell.config # a frozen ShellConfig snapshot +cfg.allowed_urls # ('https://api.example.com/',) +cfg.timeout # 30.0 +cfg.credentials[0].url # 'https://api.example.com/' +cfg.credentials[0].env_var # 'API_TOKEN' (the secret value is never exposed) +``` + +```javascript +const shell = await Shell.create({ + allowedUrls: ['https://api.example.com/'], + credentials: [{ url: 'https://api.example.com/', envVar: 'API_TOKEN' }], + timeout: 30, +}) + +const cfg = await shell.config() // a deep-frozen snapshot object +cfg.allowedUrls // ['https://api.example.com/'] +cfg.timeout // 30 +cfg.credentials[0].envVar // 'API_TOKEN' (the secret value is never exposed) +``` + +The snapshot reports binds, credentials, the network allowlist, environment +variables, umask, timeout, and resource limits. Credential **secrets are never +included** — each entry reports its URL pattern, kind, and source (a literal +token was supplied, or the name of the environment variable it is read from), +so you can reason about credentials without the agent or your tooling ever +seeing the secret itself. + ### TOML You can load all of this from a config file instead: diff --git a/index.d.ts b/index.d.ts index b26d190..2b54dae 100644 --- a/index.d.ts +++ b/index.d.ts @@ -63,6 +63,61 @@ export interface ShellConfig { configFile?: string } +/** A bind mount in a {@link ShellConfigSnapshot}. */ +export interface BindInfo { + readonly source: string + readonly destination: string + /** `'copy'` (build-time snapshot) or `'direct'` (host passthrough). */ + readonly mode: 'copy' | 'direct' + readonly readonly: boolean +} + +/** + * A credential rule in a {@link ShellConfigSnapshot}. + * + * The secret value is never exposed. `envVar` holds the environment-variable + * name when the credential was configured from the environment; `fromLiteral` + * is `true` when a literal token was supplied directly (its value is withheld). + */ +export interface CredInfo { + readonly url: string + readonly kind: 'bearer' | 'query' + readonly methods: readonly string[] + readonly param: string | null + readonly envVar: string | null + readonly fromLiteral: boolean +} + +/** Resource caps in a {@link ShellConfigSnapshot}. Every cap is present. */ +export interface LimitsInfo { + readonly maxDepth: number + readonly maxOutput: number + readonly maxFds: number + readonly maxBgJobs: number + readonly maxPipeline: number + readonly maxInput: number + readonly maxFileSize: number + readonly maxInodes: number +} + +/** + * A read-only snapshot of how a {@link Shell} was configured, returned by + * {@link Shell.config}. Lets an embedder introspect a constructed shell — to + * build tool descriptions, surface the network allowlist, or report active + * limits — without having held onto the {@link ShellConfig} it was built from. + * Secret values are never included. + */ +export interface ShellConfigSnapshot { + readonly binds: readonly BindInfo[] + readonly credentials: readonly CredInfo[] + readonly allowedUrls: readonly string[] + readonly env: Readonly> + readonly umask: number + /** Per-command timeout in seconds, or `null` for no timeout. */ + readonly timeout: number | null + readonly limits: LimitsInfo +} + /** errno-style discriminator carried on every {@link ShellError}. */ export type ShellErrorCode = 'ENOENT' | 'EACCES' | 'EFBIG' | 'EOTHER' @@ -89,6 +144,11 @@ export declare class Shell { setEnv(key: string, value: string): Promise /** Get an environment variable. */ getEnv(key: string): Promise + /** + * A read-only snapshot of how this shell was configured. Secret values are + * never included — see {@link CredInfo}. The returned object is deep-frozen. + */ + config(): Promise /** Read a file as raw bytes. Rejects with {@link NotFoundError} if missing. */ readFile(path: string): Promise /** Write raw bytes; creates parent dirs (mkdir -p) and truncates. */ diff --git a/index.js b/index.js index dcff3c6..dd3590d 100644 --- a/index.js +++ b/index.js @@ -190,6 +190,52 @@ class Shell { return this._inner.getEnv(key) } + // ---- Configuration introspection ---- + + /** + * A read-only snapshot of how this shell was configured. Resolves to an + * object reporting binds, credentials, allowedUrls, env, umask, timeout, + * and limits. + * + * Secret values are never included: each credential reports its `url` + * pattern, `kind`, and source (`envVar` name, or `fromLiteral: true`), but + * never the token itself. The returned object and its nested objects/arrays + * are deep-frozen so the snapshot cannot be mutated. + */ + async config() { + const c = await this._inner.config() + const snapshot = { + binds: c.binds.map((b) => + Object.freeze({ + source: b.source, + destination: b.destination, + mode: b.mode, + readonly: b.readonly, + }), + ), + credentials: c.credentials.map((cr) => + Object.freeze({ + url: cr.url, + kind: cr.kind, + methods: Object.freeze([...cr.methods]), + param: cr.param ?? null, + envVar: cr.envVar ?? null, + fromLiteral: cr.fromLiteral, + }), + ), + allowedUrls: c.allowedUrls, + env: c.env, + umask: c.umask, + timeout: c.timeout ?? null, + limits: Object.freeze({ ...c.limits }), + } + Object.freeze(snapshot.binds) + Object.freeze(snapshot.credentials) + Object.freeze(snapshot.allowedUrls) + Object.freeze(snapshot.env) + return Object.freeze(snapshot) + } + // ---- VFS file operations ---- readFile(path) { diff --git a/python/strands_shell/__init__.py b/python/strands_shell/__init__.py index 1eefa83..82d428b 100644 --- a/python/strands_shell/__init__.py +++ b/python/strands_shell/__init__.py @@ -27,6 +27,10 @@ "Limits", "Output", "FileInfo", + "ShellConfig", + "ConfigBind", + "ConfigCred", + "ConfigLimits", "ShellError", "FileNotFoundError", "PermissionDeniedError", @@ -99,6 +103,120 @@ class Limits: max_depth: int = 64 +# --------------------------------------------------------------------------- # +# Read-only config snapshot (returned by ``Shell.config``) +# --------------------------------------------------------------------------- # + + +@dataclass(frozen=True) +class ConfigBind: + """A bind mount as reported by :attr:`Shell.config`. + + Read-only view; mirrors the :class:`Bind` you pass in, with ``mode`` + normalized to ``"copy"`` / ``"direct"``. + """ + + source: str + destination: str + mode: Literal["direct", "copy"] + readonly: bool + + +@dataclass(frozen=True) +class ConfigCred: + """A credential rule as reported by :attr:`Shell.config`. + + The secret value is **never** exposed. ``env_var`` holds the environment + variable name when the credential was configured from the environment; + ``from_literal`` is ``True`` when a literal token was supplied directly + (its value is still withheld). + """ + + url: str + kind: str + methods: tuple[str, ...] + param: str | None + env_var: str | None + from_literal: bool + + +@dataclass(frozen=True) +class ConfigLimits: + """Resource caps as reported by :attr:`Shell.config`. + + Unlike :class:`Limits` (the input bundle), this view always carries every + cap with its concrete active value. + """ + + max_depth: int + max_output: int + max_fds: int + max_bg_jobs: int + max_pipeline: int + max_input: int + max_file_size: int + max_inodes: int + + +@dataclass(frozen=True) +class ShellConfig: + """A read-only snapshot of how a :class:`Shell` was configured. + + Returned by :attr:`Shell.config`. Lets an embedder introspect a constructed + shell after the fact (to build tool descriptions, surface the network + allowlist, or report active limits) without having held onto the + construction arguments. Secret values are never included. + """ + + binds: tuple[ConfigBind, ...] + credentials: tuple[ConfigCred, ...] + allowed_urls: tuple[str, ...] + env: dict[str, str] + umask: int + timeout: float | None + limits: ConfigLimits + + +def _snapshot_from_native(native_config: object) -> ShellConfig: + """Convert a ``_native.ShellConfig`` into the frozen public dataclass.""" + return ShellConfig( + binds=tuple( + ConfigBind( + source=b.source, + destination=b.destination, + mode=b.mode, # type: ignore[arg-type] + readonly=b.readonly, + ) + for b in native_config.binds + ), + credentials=tuple( + ConfigCred( + url=c.url, + kind=c.kind, + methods=tuple(c.methods), + param=c.param, + env_var=c.env_var, + from_literal=c.from_literal, + ) + for c in native_config.credentials + ), + allowed_urls=tuple(native_config.allowed_urls), + env=dict(native_config.env), + umask=native_config.umask, + timeout=native_config.timeout, + limits=ConfigLimits( + max_depth=native_config.limits.max_depth, + max_output=native_config.limits.max_output, + max_fds=native_config.limits.max_fds, + max_bg_jobs=native_config.limits.max_bg_jobs, + max_pipeline=native_config.limits.max_pipeline, + max_input=native_config.limits.max_input, + max_file_size=native_config.limits.max_file_size, + max_inodes=native_config.limits.max_inodes, + ), + ) + + # --------------------------------------------------------------------------- # # Exception hierarchy # --------------------------------------------------------------------------- # @@ -251,6 +369,23 @@ def set_env(self, key: str, value: str) -> None: def get_env(self, key: str) -> str | None: return self._shell.get_env(key) + # ---- Configuration introspection ---- + + @property + def config(self) -> ShellConfig: + """A read-only snapshot of how this shell was configured. + + Reports bind mounts, credential rules, the network allowlist, seeded + environment variables, umask, timeout, and resource caps. Useful for + introspecting a constructed shell — e.g. to build tool descriptions or + surface the allowlist — without having held onto the constructor args. + + Secret values are never included: each :class:`ConfigCred` reports its + URL pattern, kind, and source (literal vs environment variable name), + but never the token itself. + """ + return _snapshot_from_native(self._shell.config()) + # ---- VFS file operations ---- # Each accepts **kwargs and ignores unknown keys, matching the # kwargs-tolerant strands.sandbox.Sandbox contract the adapter passes diff --git a/src/js.rs b/src/js.rs index 2197839..2fa2229 100644 --- a/src/js.rs +++ b/src/js.rs @@ -166,6 +166,66 @@ pub struct FileInfo { pub size: Option, } +// --------------------------------------------------------------------------- +// Read-only config snapshot — plain object shapes mirrored from the core +// `crate::shell::{ShellConfig, BindInfo, CredInfo, LimitsInfo}` view types. +// Returned by `Shell.config()`. Secret values are never carried — see +// `CredInfo`. +// --------------------------------------------------------------------------- + +/// A single bind mount in a config snapshot. +#[napi(object)] +pub struct BindInfo { + pub source: String, + pub destination: String, + /// `"copy"` or `"direct"`. + pub mode: String, + pub readonly: bool, +} + +/// A single credential rule in a config snapshot. Never carries the secret. +#[napi(object)] +pub struct CredInfo { + pub url: String, + /// `"bearer"` or `"query"`. + pub kind: String, + pub methods: Vec, + pub param: Option, + /// Name of the env var the secret is read from, or `null` for a literal. + pub env_var: Option, + /// True when a literal token was supplied (value itself never exposed). + pub from_literal: bool, +} + +/// Resource caps in a config snapshot. +#[napi(object)] +pub struct LimitsInfo { + // f64 because JS numbers are doubles; values fit comfortably (max_inodes + // default 10_000, max_file_size default 10 MiB — well within 2^53). + pub max_depth: f64, + pub max_output: f64, + pub max_fds: f64, + pub max_bg_jobs: f64, + pub max_pipeline: f64, + pub max_input: f64, + pub max_file_size: f64, + pub max_inodes: f64, +} + +/// A read-only snapshot of how a `Shell` was configured. +#[napi(object)] +pub struct ShellConfig { + pub binds: Vec, + pub credentials: Vec, + pub allowed_urls: Vec, + /// Seeded environment variables as a plain object. + pub env: std::collections::HashMap, + pub umask: f64, + /// Per-command timeout in seconds, or `null` for no timeout. + pub timeout: Option, + pub limits: LimitsInfo, +} + // --------------------------------------------------------------------------- // ShellBuilder // --------------------------------------------------------------------------- @@ -400,6 +460,57 @@ impl Shell { .await } + /// Read-only snapshot of the configuration this shell was built with. + /// + /// Mirrors `Shell::config()` in the core. Never carries secret values — + /// each credential reports its source (literal vs env-var name) only. + #[napi] + pub async fn config(&self) -> Result { + self.worker + .run(move |shell, _rt| { + let c = shell.config(); + ShellConfig { + binds: c + .binds + .iter() + .map(|b| BindInfo { + source: b.source.clone(), + destination: b.destination.clone(), + mode: b.mode.to_string(), + readonly: b.readonly, + }) + .collect(), + credentials: c + .credentials + .iter() + .map(|cr| CredInfo { + url: cr.url.clone(), + kind: cr.kind.to_string(), + methods: cr.methods.clone(), + param: cr.param.clone(), + env_var: cr.env_var.clone(), + from_literal: cr.from_literal, + }) + .collect(), + allowed_urls: c.allowed_urls.clone(), + env: c.env.iter().cloned().collect(), + umask: c.umask as f64, + timeout: c.timeout_secs, + limits: LimitsInfo { + max_depth: c.limits.max_depth as f64, + max_output: c.limits.max_output as f64, + max_fds: c.limits.max_fds as f64, + max_bg_jobs: c.limits.max_bg_jobs as f64, + max_pipeline: c.limits.max_pipeline as f64, + max_input: c.limits.max_input as f64, + max_file_size: c.limits.max_file_size as f64, + max_inodes: c.limits.max_inodes as f64, + }, + } + }) + .await + } + /// Read a file from the virtual filesystem as raw bytes. #[napi] pub async fn read_file(&self, path: String) -> Result { diff --git a/src/lib.rs b/src/lib.rs index 7a72667..5517330 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -164,5 +164,8 @@ pub mod python; pub mod js; // Primary public API -pub use shell::{FileInfo, FileOpErrorKind, Output, Shell, ShellBuilder}; +pub use shell::{ + BindInfo, CredInfo, FileInfo, FileOpErrorKind, LimitsInfo, Output, Shell, ShellBuilder, + ShellConfig, +}; pub use vfs_config::CredKind; diff --git a/src/python.rs b/src/python.rs index 82548cd..7a1cd27 100644 --- a/src/python.rs +++ b/src/python.rs @@ -101,6 +101,155 @@ impl FileInfo { } } +// --------------------------------------------------------------------------- # +// Read-only config snapshot carriers +// +// These mirror the Rust `crate::shell::{ShellConfig, BindInfo, CredInfo, +// LimitsInfo}` view types. They are the low-level surface; the pure-Python +// wrapper (`strands_shell/__init__.py`) re-shapes them into frozen public +// dataclasses. Secret values are never carried — see `CredInfo`. +// --------------------------------------------------------------------------- # + +/// A single bind mount in a config snapshot. +#[pyclass(skip_from_py_object)] +#[derive(Clone)] +pub struct BindInfo { + #[pyo3(get)] + pub source: String, + #[pyo3(get)] + pub destination: String, + /// `"copy"` or `"direct"`. + #[pyo3(get)] + pub mode: String, + #[pyo3(get)] + pub readonly: bool, +} + +#[pymethods] +impl BindInfo { + fn __repr__(&self) -> String { + format!( + "BindInfo(source={:?}, destination={:?}, mode={:?}, readonly={})", + self.source, + self.destination, + self.mode, + if self.readonly { "True" } else { "False" } + ) + } +} + +/// A single credential rule in a config snapshot. Never carries the secret. +#[pyclass(skip_from_py_object)] +#[derive(Clone)] +pub struct CredInfo { + #[pyo3(get)] + pub url: String, + /// `"bearer"` or `"query"`. + #[pyo3(get)] + pub kind: String, + #[pyo3(get)] + pub methods: Vec, + #[pyo3(get)] + pub param: Option, + /// Name of the env var the secret is read from, or `None` for a literal. + #[pyo3(get)] + pub env_var: Option, + /// True when a literal token was supplied (value itself never exposed). + #[pyo3(get)] + pub from_literal: bool, +} + +#[pymethods] +impl CredInfo { + fn __repr__(&self) -> String { + format!( + "CredInfo(url={:?}, kind={:?}, methods={:?}, param={:?}, env_var={:?}, from_literal={})", + self.url, + self.kind, + self.methods, + self.param, + self.env_var, + if self.from_literal { "True" } else { "False" } + ) + } +} + +/// Resource caps in a config snapshot. +#[pyclass(skip_from_py_object)] +#[derive(Clone)] +pub struct LimitsInfo { + #[pyo3(get)] + pub max_depth: u32, + #[pyo3(get)] + pub max_output: usize, + #[pyo3(get)] + pub max_fds: usize, + #[pyo3(get)] + pub max_bg_jobs: usize, + #[pyo3(get)] + pub max_pipeline: usize, + #[pyo3(get)] + pub max_input: usize, + #[pyo3(get)] + pub max_file_size: usize, + #[pyo3(get)] + pub max_inodes: usize, +} + +#[pymethods] +impl LimitsInfo { + fn __repr__(&self) -> String { + format!( + "LimitsInfo(max_depth={}, max_output={}, max_fds={}, max_bg_jobs={}, max_pipeline={}, max_input={}, max_file_size={}, max_inodes={})", + self.max_depth, + self.max_output, + self.max_fds, + self.max_bg_jobs, + self.max_pipeline, + self.max_input, + self.max_file_size, + self.max_inodes + ) + } +} + +/// A read-only snapshot of how a `Shell` was configured. +#[pyclass(skip_from_py_object)] +#[derive(Clone)] +pub struct ShellConfig { + #[pyo3(get)] + pub binds: Vec, + #[pyo3(get)] + pub credentials: Vec, + #[pyo3(get)] + pub allowed_urls: Vec, + /// List of `(key, value)` pairs, in declaration order. + #[pyo3(get)] + pub env: Vec<(String, String)>, + #[pyo3(get)] + pub umask: u32, + /// Per-command timeout in seconds, or `None` for no timeout. + #[pyo3(get)] + pub timeout: Option, + #[pyo3(get)] + pub limits: LimitsInfo, +} + +#[pymethods] +impl ShellConfig { + fn __repr__(&self) -> String { + format!( + "ShellConfig(binds={} entries, credentials={} entries, allowed_urls={:?}, env={} vars, umask={:#o}, timeout={:?})", + self.binds.len(), + self.credentials.len(), + self.allowed_urls, + self.env.len(), + self.umask, + self.timeout + ) + } +} + /// Builder for configuring a Shell. #[pyclass] pub struct ShellBuilder { @@ -336,6 +485,56 @@ impl Shell { Ok(shell.get_env(key).map(|s| s.to_string())) } + /// Read-only snapshot of the configuration this shell was built with. + /// + /// Mirrors `Shell::config()` in the core. Never carries secret values — + /// each credential reports its source (literal vs env-var name) only. + fn config(&self) -> PyResult { + let shell = self + .inner + .as_ref() + .ok_or_else(|| PyRuntimeError::new_err("shell consumed"))?; + let c = shell.config(); + Ok(ShellConfig { + binds: c + .binds + .iter() + .map(|b| BindInfo { + source: b.source.clone(), + destination: b.destination.clone(), + mode: b.mode.to_string(), + readonly: b.readonly, + }) + .collect(), + credentials: c + .credentials + .iter() + .map(|cr| CredInfo { + url: cr.url.clone(), + kind: cr.kind.to_string(), + methods: cr.methods.clone(), + param: cr.param.clone(), + env_var: cr.env_var.clone(), + from_literal: cr.from_literal, + }) + .collect(), + allowed_urls: c.allowed_urls.clone(), + env: c.env.clone(), + umask: c.umask, + timeout: c.timeout_secs, + limits: LimitsInfo { + max_depth: c.limits.max_depth, + max_output: c.limits.max_output, + max_fds: c.limits.max_fds, + max_bg_jobs: c.limits.max_bg_jobs, + max_pipeline: c.limits.max_pipeline, + max_input: c.limits.max_input, + max_file_size: c.limits.max_file_size, + max_inodes: c.limits.max_inodes, + }, + }) + } + /// Read a file from the virtual filesystem as raw bytes. /// /// Mirrors `Sandbox.read_file` from the Strands SDK. @@ -435,6 +634,10 @@ fn _native(m: &Bound<'_, PyModule>) -> PyResult<()> { m.add_class::()?; m.add_class::()?; m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; m.add("NativeShellError", m.py().get_type::())?; m.add_function(wrap_pyfunction!(cli_main, m)?)?; Ok(()) diff --git a/src/shell.rs b/src/shell.rs index cdd72dc..96096ba 100644 --- a/src/shell.rs +++ b/src/shell.rs @@ -70,6 +70,144 @@ pub struct FileInfo { pub size: Option, } +/// A read-only snapshot of how a [`Shell`] was configured. +/// +/// Captured at [`build()`](ShellBuilder::build) time and returned by +/// [`Shell::config()`]. This exists so an embedder (for example a sandbox +/// adapter in another SDK) can introspect a constructed `Shell` after the +/// fact — to build tool descriptions, surface the network allowlist, or +/// report the active resource caps — without having held onto the builder. +/// +/// The snapshot intentionally **never carries resolved secret values**. +/// strands-shell's security model is that the agent never sees credentials, +/// so [`CredInfo`] reports only the URL pattern, the credential *kind*, and +/// the *source* of the secret (a literal was provided, or the name of the +/// environment variable it is read from) — never the token itself. +/// +/// `#[non_exhaustive]` so future fields can be added without breaking callers +/// who construct or pattern-match exhaustively. +#[derive(Debug, Clone)] +#[non_exhaustive] +pub struct ShellConfig { + /// Bind mounts mapping host paths into the VFS, in declaration order. + pub binds: Vec, + /// Credential injection rules, in declaration order. Secret values are + /// never included — see [`CredInfo`]. + pub credentials: Vec, + /// SSRF allowlist: URL prefixes `curl` may reach, in declaration order. + pub allowed_urls: Vec, + /// Environment variables seeded into the shell, in declaration order. + pub env: Vec<(String, String)>, + /// File-creation umask. + pub umask: u32, + /// Per-command wall-clock timeout in seconds, or `None` for no timeout. + pub timeout_secs: Option, + /// Active resource caps. + pub limits: LimitsInfo, +} + +/// A single bind mount in a [`ShellConfig`] snapshot. +#[derive(Debug, Clone)] +#[non_exhaustive] +pub struct BindInfo { + /// Host path that was mounted. + pub source: String, + /// Destination path inside the VFS. + pub destination: String, + /// `"copy"` (build-time snapshot) or `"direct"` (host passthrough). + pub mode: &'static str, + /// Whether writes through this mount are rejected. + pub readonly: bool, +} + +/// A single credential rule in a [`ShellConfig`] snapshot. +/// +/// Carries everything an embedder needs to reason about a credential — +/// *except the secret itself*. When the credential was configured from an +/// environment variable, [`env_var`](Self::env_var) holds that variable's +/// name; the value is never read into the snapshot. When a literal token was +/// supplied, [`from_literal`](Self::from_literal) is `true` and `env_var` is +/// `None`. +#[derive(Debug, Clone)] +#[non_exhaustive] +pub struct CredInfo { + /// URL pattern the credential applies to (supports glob patterns). + pub url: String, + /// `"bearer"` or `"query"`. + pub kind: &'static str, + /// HTTP methods this credential is scoped to (empty means all methods). + pub methods: Vec, + /// Query-parameter name, set only for `kind == "query"` credentials. + pub param: Option, + /// Name of the environment variable the secret is read from, or `None` + /// when a literal token was supplied. + pub env_var: Option, + /// `true` when a literal token value was supplied directly (rather than + /// via an environment variable). The token value itself is never exposed. + pub from_literal: bool, +} + +/// The resource caps active on a [`Shell`], as reported in a +/// [`ShellConfig`] snapshot. +/// +/// Unlike [`crate::os::ProcessLimits`] (process-only), this view also carries +/// the two VFS-level caps (`max_file_size`, `max_inodes`) so the snapshot +/// reflects every limit the builder applied in one place. +#[derive(Debug, Clone, Copy)] +#[non_exhaustive] +pub struct LimitsInfo { + /// Max recursion depth for functions/subshells. + pub max_depth: u32, + /// Max size in bytes for any single output accumulation. + pub max_output: usize, + /// Max open file descriptors per process. + pub max_fds: usize, + /// Max concurrent background jobs. + pub max_bg_jobs: usize, + /// Max stages in a single pipeline. + pub max_pipeline: usize, + /// Max input size in bytes the parser will accept. + pub max_input: usize, + /// Max size in bytes for any single file in the VFS. + pub max_file_size: usize, + /// Max inodes (files + directories) in the VFS. + pub max_inodes: usize, +} + +impl Default for LimitsInfo { + /// Matches [`ShellBuilder::default`]'s caps, so a [`Shell`] built without + /// touching the limit setters reports these values. + fn default() -> Self { + Self { + max_depth: 64, + max_output: 1024 * 1024, + max_fds: 128, + max_bg_jobs: 8, + max_pipeline: 16, + max_input: 1024 * 1024, + max_file_size: 10 * 1024 * 1024, + max_inodes: 10_000, + } + } +} + +impl Default for ShellConfig { + /// An empty snapshot with default umask, no timeout, and default caps. + /// Used for shells created via [`Shell::with_kernel`], which bypass the + /// builder and therefore have no captured configuration. + fn default() -> Self { + Self { + binds: Vec::new(), + credentials: Vec::new(), + allowed_urls: Vec::new(), + env: Vec::new(), + umask: 0o022, + timeout_secs: None, + limits: LimitsInfo::default(), + } + } +} + /// Classification of a file-op `io::Error` into the categories the language /// bindings surface as typed errors. /// @@ -181,6 +319,10 @@ pub struct Shell { /// never pull more into memory than a write is allowed to commit. /// `0` means no cap. Mirrors the kernel's write-side `max_file_size`. max_file_size: usize, + /// Read-only snapshot of the configuration this shell was built with. + /// Captured at build time so embedders can introspect a constructed + /// shell (see [`Shell::config`]). Never carries secret values. + config: ShellConfig, #[cfg(not(target_arch = "wasm32"))] mcp_clients: Rc>, #[cfg(not(target_arch = "wasm32"))] @@ -229,6 +371,7 @@ impl Shell { proc, timeout: None, max_file_size: 0, + config: ShellConfig::default(), #[cfg(not(target_arch = "wasm32"))] mcp_clients: Rc::new(Vec::new()), #[cfg(not(target_arch = "wasm32"))] @@ -341,6 +484,26 @@ impl Shell { self.proc.limits() } + /// Get a read-only snapshot of the configuration this shell was built + /// with. + /// + /// The snapshot is captured at [`build()`](ShellBuilder::build) time and + /// reports bind mounts, credential rules, the network allowlist, seeded + /// environment variables, umask, timeout, and resource caps. It exists so + /// an embedder (e.g. a sandbox adapter in another SDK) can introspect a + /// constructed `Shell` without having held onto the builder — to build + /// tool descriptions, surface the allowlist, or report active limits. + /// + /// Secret values are never included: [`CredInfo`] reports the credential's + /// URL pattern, kind, and source (literal vs environment variable name), + /// but never the token itself. + /// + /// Shells created via [`with_kernel`](Shell::with_kernel) (which bypass the + /// builder) report a [`ShellConfig::default`] snapshot. + pub fn config(&self) -> &ShellConfig { + &self.config + } + /// Read a file from the virtual filesystem as raw bytes. /// /// Subject to the per-`Shell` `max_file_size` limit set on the builder, @@ -961,6 +1124,51 @@ impl ShellBuilder { "timeout must be greater than zero (omit it for no timeout)", )); } + + // Capture a read-only config snapshot before the builder's fields are + // moved into the kernel/process below. This is what `Shell::config()` + // returns. Secret values are deliberately omitted: a credential + // reports its source (literal vs env-var name) but never the token. + let config_snapshot = ShellConfig { + binds: self + .config + .bind + .iter() + .map(|b| BindInfo { + source: b.source.clone(), + destination: b.destination.clone(), + mode: b.mode.as_str(), + readonly: b.readonly, + }) + .collect(), + credentials: self + .creds + .iter() + .map(|c| CredInfo { + url: c.url.clone(), + kind: c.kind.as_str(), + methods: c.methods.clone(), + param: c.param.clone(), + env_var: c.api_key_env.clone(), + from_literal: c.api_key.is_some(), + }) + .collect(), + allowed_urls: self.allowed_url_prefixes.clone(), + env: self.env.clone(), + umask: self.config.umask, + timeout_secs: self.timeout.map(|d| d.as_secs_f64()), + limits: LimitsInfo { + max_depth: self.max_depth, + max_output: self.max_output, + max_fds: self.max_fds, + max_bg_jobs: self.max_bg_jobs, + max_pipeline: self.max_pipeline, + max_input: self.max_input, + max_file_size: self.max_file_size, + max_inodes: self.max_inodes, + }, + }; + let resolved_creds = resolve_creds(&self.creds)?; let mut vfs = build_vfs(&self.config)?; vfs.max_file_size = self.max_file_size; @@ -999,6 +1207,7 @@ impl ShellBuilder { proc, timeout: self.timeout, max_file_size: self.max_file_size, + config: config_snapshot, #[cfg(not(target_arch = "wasm32"))] mcp_clients: Rc::new(Vec::new()), #[cfg(not(target_arch = "wasm32"))] diff --git a/src/vfs_config.rs b/src/vfs_config.rs index 5d8156b..b0f8971 100644 --- a/src/vfs_config.rs +++ b/src/vfs_config.rs @@ -128,13 +128,24 @@ fn default_mode() -> BindMode { BindMode::Copy } -#[derive(Deserialize)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)] #[serde(rename_all = "lowercase")] pub enum BindMode { Copy, Direct, } +impl BindMode { + /// The lowercase string the TOML / bindings use for this mode + /// (`"copy"` or `"direct"`). + pub fn as_str(self) -> &'static str { + match self { + BindMode::Copy => "copy", + BindMode::Direct => "direct", + } + } +} + #[derive(Deserialize)] #[serde(deny_unknown_fields)] pub struct CredEntry { @@ -148,13 +159,24 @@ pub struct CredEntry { pub param: Option, } -#[derive(Clone, Deserialize)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)] #[serde(rename_all = "lowercase")] pub enum CredKind { Bearer, Query, } +impl CredKind { + /// The lowercase string the TOML / bindings use for this kind + /// (`"bearer"` or `"query"`). + pub fn as_str(self) -> &'static str { + match self { + CredKind::Bearer => "bearer", + CredKind::Query => "query", + } + } +} + /// Parse a VFS config from a TOML string. pub fn parse_config(toml_str: &str) -> io::Result { toml::from_str(toml_str).map_err(|e| io::Error::new(io::ErrorKind::InvalidInput, e.to_string())) @@ -201,7 +223,7 @@ pub fn resolve_creds(creds: &[CredEntry]) -> io::Result> { Ok(ResolvedCred { url: c.url.clone(), methods: c.methods.iter().map(|m| m.to_uppercase()).collect(), - kind: c.kind.clone(), + kind: c.kind, api_key, param: c.param.clone(), }) diff --git a/tests/config.rs b/tests/config.rs new file mode 100644 index 0000000..42c8191 --- /dev/null +++ b/tests/config.rs @@ -0,0 +1,168 @@ +//! Integration tests for the read-only config snapshot returned by +//! [`Shell::config`]. +//! +//! The snapshot lets an embedder introspect a constructed shell — binds, the +//! network allowlist, credential rules, env, umask, timeout, limits — without +//! having held onto the builder. Its most important guarantee is that it never +//! exposes resolved secret values: a credential reports only its source (a +//! literal was supplied, or the name of the env var it reads from). + +use std::time::Duration; + +use strands_shell::{CredKind, Shell}; + +#[test] +fn default_shell_reports_default_config() { + let shell = Shell::builder().build().unwrap(); + let cfg = shell.config(); + assert!(cfg.binds.is_empty()); + assert!(cfg.credentials.is_empty()); + assert!(cfg.allowed_urls.is_empty()); + assert!(cfg.env.is_empty()); + assert_eq!(cfg.umask, 0o022); + // Builder default is a 30s per-command timeout; the snapshot reports the + // real effective value. + assert_eq!(cfg.timeout_secs, Some(30.0)); + assert_eq!(cfg.limits.max_depth, 64); + assert_eq!(cfg.limits.max_output, 1024 * 1024); + assert_eq!(cfg.limits.max_fds, 128); + assert_eq!(cfg.limits.max_bg_jobs, 8); + assert_eq!(cfg.limits.max_pipeline, 16); + assert_eq!(cfg.limits.max_input, 1024 * 1024); + assert_eq!(cfg.limits.max_file_size, 10 * 1024 * 1024); + assert_eq!(cfg.limits.max_inodes, 10_000); +} + +#[test] +fn config_reports_binds() { + let dir = tempdir(); + let shell = Shell::builder() + .bind_direct_readonly(&dir, "/work") + .bind(&dir, "/copy") + .build() + .unwrap(); + let cfg = shell.config(); + assert_eq!(cfg.binds.len(), 2); + assert_eq!(cfg.binds[0].destination, "/work"); + assert_eq!(cfg.binds[0].mode, "direct"); + assert!(cfg.binds[0].readonly); + assert_eq!(cfg.binds[1].destination, "/copy"); + assert_eq!(cfg.binds[1].mode, "copy"); + assert!(!cfg.binds[1].readonly); +} + +#[test] +fn config_reports_allowed_urls_env_umask_timeout() { + let shell = Shell::builder() + .allow_url("https://api.example.com/") + .allow_url("https://api.openai.com/") + .env("PROJECT", "demo") + .umask(0o027) + .timeout(Duration::from_secs_f64(12.5)) + .build() + .unwrap(); + let cfg = shell.config(); + assert_eq!( + cfg.allowed_urls, + vec![ + "https://api.example.com/".to_string(), + "https://api.openai.com/".to_string() + ] + ); + assert_eq!(cfg.env, vec![("PROJECT".to_string(), "demo".to_string())]); + assert_eq!(cfg.umask, 0o027); + assert_eq!(cfg.timeout_secs, Some(12.5)); +} + +#[test] +fn config_reports_overridden_limits() { + let shell = Shell::builder() + .max_output(2048) + .max_inodes(500) + .build() + .unwrap(); + let cfg = shell.config(); + assert_eq!(cfg.limits.max_output, 2048); + assert_eq!(cfg.limits.max_inodes, 500); +} + +#[test] +fn config_credentials_never_leak_literal_token() { + let shell = Shell::builder() + .credential( + "https://api.example.com/*", + CredKind::Bearer, + "sk-super-secret", + ) + .build() + .unwrap(); + let cfg = shell.config(); + let cred = &cfg.credentials[0]; + assert_eq!(cred.url, "https://api.example.com/*"); + assert_eq!(cred.kind, "bearer"); + assert!(cred.from_literal); + assert_eq!(cred.env_var, None); + // The secret itself must never appear in the snapshot's debug form. + let dump = format!("{cfg:?}"); + assert!( + !dump.contains("sk-super-secret"), + "literal token leaked into config snapshot: {dump}" + ); +} + +#[test] +fn config_credentials_report_env_var_name_not_value() { + // SAFETY: single-threaded test; we set and read back our own scoped var. + unsafe { + std::env::set_var("STRANDS_SHELL_RS_TEST_TOKEN", "value-must-not-leak"); + } + let shell = Shell::builder() + .credential_from_env( + "https://api.openai.com/*", + CredKind::Bearer, + "STRANDS_SHELL_RS_TEST_TOKEN", + ) + .build() + .unwrap(); + let cfg = shell.config(); + let cred = &cfg.credentials[0]; + assert_eq!(cred.env_var.as_deref(), Some("STRANDS_SHELL_RS_TEST_TOKEN")); + assert!(!cred.from_literal); + let dump = format!("{cfg:?}"); + assert!( + !dump.contains("value-must-not-leak"), + "env-var secret leaked into config snapshot: {dump}" + ); + unsafe { + std::env::remove_var("STRANDS_SHELL_RS_TEST_TOKEN"); + } +} + +#[test] +fn with_kernel_reports_default_config_snapshot() { + // Shells built via with_kernel bypass the builder; they report the default + // snapshot rather than panicking or carrying stale data. + let kernel = Shell::builder().build().unwrap().kernel().clone(); + let shell = Shell::with_kernel(kernel); + let cfg = shell.config(); + assert!(cfg.binds.is_empty()); + assert_eq!(cfg.umask, 0o022); + assert_eq!(cfg.timeout_secs, None); +} + +/// Create a throwaway host directory usable as a bind source (bind sources +/// must exist at build time). +fn tempdir() -> String { + let mut path = std::env::temp_dir(); + let unique = format!( + "strands-shell-config-rs-{}-{:?}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + ); + path.push(unique); + std::fs::create_dir_all(&path).unwrap(); + path.to_string_lossy().into_owned() +} diff --git a/tests/js/test_config.mjs b/tests/js/test_config.mjs new file mode 100644 index 0000000..580464c --- /dev/null +++ b/tests/js/test_config.mjs @@ -0,0 +1,135 @@ +// Tests for the read-only config snapshot exposed via `shell.config()`. +// +// Mirrors tests/python/test_config.py. Run with `npm test`. +// +// The snapshot lets an embedder introspect a constructed shell without having +// held onto the config object. Its key guarantee: it never leaks secret +// values — only the source of a credential (literal vs env-var name). + +import { test } from 'node:test' +import assert from 'node:assert/strict' +import fs from 'node:fs/promises' +import os from 'node:os' +import path from 'node:path' + +import { Shell } from '../../index.js' + +async function makeHostDir() { + return fs.mkdtemp(path.join(os.tmpdir(), 'strands-shell-config-test-')) +} + +test('default shell reports default config', async () => { + const cfg = await (await Shell.create()).config() + assert.deepEqual(cfg.binds, []) + assert.deepEqual(cfg.credentials, []) + assert.deepEqual(cfg.allowedUrls, []) + assert.deepEqual(cfg.env, {}) + assert.equal(cfg.umask, 0o022) + // The builder defaults to a 30s per-command timeout; the snapshot reports + // the real effective value. + assert.equal(cfg.timeout, 30) + assert.equal(cfg.limits.maxDepth, 64) + assert.equal(cfg.limits.maxOutput, 1 << 20) + assert.equal(cfg.limits.maxFds, 128) + assert.equal(cfg.limits.maxBgJobs, 8) + assert.equal(cfg.limits.maxPipeline, 16) + assert.equal(cfg.limits.maxInput, 1 << 20) + assert.equal(cfg.limits.maxFileSize, 10 << 20) + assert.equal(cfg.limits.maxInodes, 10000) +}) + +test('config reports binds', async () => { + const hostDir = await makeHostDir() + try { + const shell = await Shell.create({ + binds: [ + { source: hostDir, destination: '/work', mode: 'direct', readonly: true }, + { source: hostDir, destination: '/copy', mode: 'copy' }, + ], + }) + const cfg = await shell.config() + assert.equal(cfg.binds.length, 2) + assert.equal(cfg.binds[0].source, hostDir) + assert.equal(cfg.binds[0].destination, '/work') + assert.equal(cfg.binds[0].mode, 'direct') + assert.equal(cfg.binds[0].readonly, true) + assert.equal(cfg.binds[1].mode, 'copy') + assert.equal(cfg.binds[1].readonly, false) + } finally { + await fs.rm(hostDir, { recursive: true, force: true }) + } +}) + +test('config reports allowedUrls, env, umask, timeout', async () => { + const shell = await Shell.create({ + allowedUrls: ['https://api.example.com/', 'https://api.openai.com/'], + env: { PROJECT: 'demo', STAGE: 'prod' }, + umask: 0o027, + timeout: 12.5, + }) + const cfg = await shell.config() + assert.deepEqual(cfg.allowedUrls, ['https://api.example.com/', 'https://api.openai.com/']) + assert.deepEqual(cfg.env, { PROJECT: 'demo', STAGE: 'prod' }) + assert.equal(cfg.umask, 0o027) + assert.equal(cfg.timeout, 12.5) +}) + +test('config reports overridden limits', async () => { + const shell = await Shell.create({ limits: { maxOutput: 2048, maxInodes: 500 } }) + const cfg = await shell.config() + assert.equal(cfg.limits.maxOutput, 2048) + assert.equal(cfg.limits.maxInodes, 500) +}) + +test('config credentials never leak literal token', async () => { + const shell = await Shell.create({ + credentials: [{ url: 'https://api.example.com/*', token: 'sk-super-secret' }], + }) + const cfg = await shell.config() + const cred = cfg.credentials[0] + assert.equal(cred.url, 'https://api.example.com/*') + assert.equal(cred.kind, 'bearer') + assert.equal(cred.fromLiteral, true) + assert.equal(cred.envVar, null) + assert.ok(!JSON.stringify(cfg).includes('sk-super-secret')) +}) + +test('config credentials report env var name not value', async () => { + process.env.STRANDS_SHELL_TEST_TOKEN = 'value-must-not-leak' + try { + const shell = await Shell.create({ + credentials: [{ url: 'https://api.openai.com/*', envVar: 'STRANDS_SHELL_TEST_TOKEN' }], + }) + const cfg = await shell.config() + const cred = cfg.credentials[0] + assert.equal(cred.envVar, 'STRANDS_SHELL_TEST_TOKEN') + assert.equal(cred.fromLiteral, false) + assert.ok(!JSON.stringify(cfg).includes('value-must-not-leak')) + } finally { + delete process.env.STRANDS_SHELL_TEST_TOKEN + } +}) + +test('config snapshot is deep-frozen', async () => { + const shell = await Shell.create({ + binds: [{ source: os.tmpdir(), destination: '/work', mode: 'direct' }], + }) + const cfg = await shell.config() + assert.ok(Object.isFrozen(cfg)) + assert.ok(Object.isFrozen(cfg.binds)) + assert.ok(Object.isFrozen(cfg.binds[0])) + assert.ok(Object.isFrozen(cfg.limits)) + assert.throws(() => { + 'use strict' + cfg.umask = 0 + }) +}) + +test('config is a snapshot, not a live view', async () => { + // A fresh snapshot is taken on each call; mutating the shell after the first + // call must not retroactively change the already-returned object. + const shell = await Shell.create({ env: { A: '1' } }) + const cfg = await shell.config() + await shell.setEnv('A', '2') + assert.equal(cfg.env.A, '1') +}) diff --git a/tests/python/test_config.py b/tests/python/test_config.py new file mode 100644 index 0000000..786fca0 --- /dev/null +++ b/tests/python/test_config.py @@ -0,0 +1,134 @@ +"""Tests for the read-only config snapshot exposed via ``Shell.config``. + +The snapshot lets an embedder introspect a constructed shell (binds, the +network allowlist, credential rules, env, umask, timeout, limits) without +having held onto the constructor arguments. Its single most important +guarantee is that it **never leaks secret values** — only the source of a +credential (a literal was supplied, or the name of the env var it reads from). +""" + +import os +import shutil +import tempfile + +import pytest + +import strands_shell +from strands_shell import Bind, Cred, Limits, Shell + + +@pytest.fixture +def host_dir(): + path = tempfile.mkdtemp(prefix="strands-shell-config-test-") + try: + yield path + finally: + shutil.rmtree(path, ignore_errors=True) + + +def test_default_shell_reports_default_config(): + cfg = Shell().config + assert cfg.binds == () + assert cfg.credentials == () + assert cfg.allowed_urls == () + assert cfg.env == {} + assert cfg.umask == 0o022 + # The builder defaults to a 30s per-command timeout, and the snapshot + # reports the real effective value rather than "unset". + assert cfg.timeout == 30.0 + # Limits report the documented builder defaults. + assert cfg.limits.max_depth == 64 + assert cfg.limits.max_output == 1 << 20 + assert cfg.limits.max_fds == 128 + assert cfg.limits.max_bg_jobs == 8 + assert cfg.limits.max_pipeline == 16 + assert cfg.limits.max_input == 1 << 20 + assert cfg.limits.max_file_size == 10 << 20 + assert cfg.limits.max_inodes == 10_000 + + +def test_config_reports_binds(host_dir): + cfg = Shell( + binds=[ + Bind(host_dir, "/work", mode="direct", readonly=True), + Bind(host_dir, "/copy", mode="copy"), + ] + ).config + assert len(cfg.binds) == 2 + assert cfg.binds[0].source == host_dir + assert cfg.binds[0].destination == "/work" + assert cfg.binds[0].mode == "direct" + assert cfg.binds[0].readonly is True + assert cfg.binds[1].mode == "copy" + assert cfg.binds[1].readonly is False + + +def test_config_reports_allowed_urls_env_umask_timeout(): + cfg = Shell( + allowed_urls=["https://api.example.com/", "https://api.openai.com/"], + env={"PROJECT": "demo", "STAGE": "prod"}, + umask=0o027, + timeout=12.5, + ).config + assert cfg.allowed_urls == ("https://api.example.com/", "https://api.openai.com/") + assert cfg.env == {"PROJECT": "demo", "STAGE": "prod"} + assert cfg.umask == 0o027 + assert cfg.timeout == 12.5 + + +def test_config_reports_overridden_limits(): + cfg = Shell(limits=Limits(max_output=2048, max_inodes=500)).config + assert cfg.limits.max_output == 2048 + assert cfg.limits.max_inodes == 500 + + +def test_config_credentials_never_leak_literal_token(): + cfg = Shell( + credentials=[Cred("https://api.example.com/*", token="sk-super-secret")] + ).config + cred = cfg.credentials[0] + assert cred.url == "https://api.example.com/*" + assert cred.kind == "bearer" + assert cred.from_literal is True + assert cred.env_var is None + # The secret itself must appear nowhere in the snapshot. + assert "sk-super-secret" not in repr(cfg) + + +def test_config_credentials_report_env_var_name_not_value(): + os.environ["STRANDS_SHELL_TEST_TOKEN"] = "value-must-not-leak" + try: + cfg = Shell( + credentials=[ + Cred("https://api.openai.com/*", env_var="STRANDS_SHELL_TEST_TOKEN") + ] + ).config + finally: + del os.environ["STRANDS_SHELL_TEST_TOKEN"] + cred = cfg.credentials[0] + assert cred.env_var == "STRANDS_SHELL_TEST_TOKEN" + assert cred.from_literal is False + assert "value-must-not-leak" not in repr(cfg) + + +def test_config_snapshot_is_frozen(): + cfg = Shell().config + with pytest.raises(Exception): + cfg.umask = 0 # type: ignore[misc] + + +def test_config_is_a_snapshot_not_a_live_view(): + # Mutating the shell's env after construction must not change the snapshot + # that was already taken (it reflects build-time configuration). + shell = Shell(env={"A": "1"}) + cfg = shell.config + shell.set_env("A", "2") + assert cfg.env == {"A": "1"} + + +def test_config_reexported_types_are_public(): + # The public dataclasses are importable from the package root. + assert hasattr(strands_shell, "ShellConfig") + assert hasattr(strands_shell, "ConfigBind") + assert hasattr(strands_shell, "ConfigCred") + assert hasattr(strands_shell, "ConfigLimits") diff --git a/tests/ts/api.types.ts b/tests/ts/api.types.ts index 8955338..f66d663 100644 --- a/tests/ts/api.types.ts +++ b/tests/ts/api.types.ts @@ -18,6 +18,10 @@ import { type CredConfig, type ShellLimits, type ShellErrorCode, + type ShellConfigSnapshot, + type BindInfo, + type CredInfo, + type LimitsInfo, } from '../../index.js' async function usage(): Promise { @@ -53,6 +57,20 @@ async function usage(): Promise { const entries: FileInfo[] = await shell.listFiles('/work') const _name: string = entries[0].name + // Read-only config snapshot: every field is typed and readonly. + const snapshot: ShellConfigSnapshot = await shell.config() + const _binds: readonly BindInfo[] = snapshot.binds + const _mode: 'copy' | 'direct' = snapshot.binds[0].mode + const _creds: readonly CredInfo[] = snapshot.credentials + const _credKind: 'bearer' | 'query' = snapshot.credentials[0].kind + const _envVar: string | null = snapshot.credentials[0].envVar + const _fromLiteral: boolean = snapshot.credentials[0].fromLiteral + const _allowed: readonly string[] = snapshot.allowedUrls + const _umask: number = snapshot.umask + const _timeout: number | null = snapshot.timeout + const _limits: LimitsInfo = snapshot.limits + const _maxOutput: number = snapshot.limits.maxOutput + // Typed error hierarchy: subclasses are ShellErrors carrying path + code. try { await shell.readFile('/work/missing')