Skip to main content

dekopon_shell/
lib.rs

1//! A sandboxed, bash-flavored scripting language whose commands dispatch to Dekopon capabilities.
2//!
3//! This crate is a pure interpreter library. It has no notion of Wasmtime, provider components, the
4//! broker, HTTP, the filesystem, or the process environment. Everything a script can reach outside
5//! its own value space goes through one seam, [`CapabilityInvoker`], which the embedding binary
6//! implements.
7//!
8//! # What this is for
9//!
10//! Exposing one model-facing tool schema per provider capability bloats a system prompt and forces
11//! a model into many small round trips. A single scripting tool lets a model express a multi-step
12//! plan — loops, conditionals, functions, JSON handling — in one tool call. The "commands" in that
13//! script are capability invocations, not operating-system processes.
14//!
15//! # Safety model
16//!
17//! There is no operating-system sandbox here. This is a native tree-walking evaluator, so every
18//! bound is hand-built in [`limits`]:
19//!
20//! - a step budget covering statements, loop iterations, function calls, arithmetic nodes, and
21//!   values pulled from a `jq` filter,
22//! - a shell-function recursion depth cap,
23//! - independent output byte and line ceilings with head-and-tail truncation,
24//! - a wall-clock deadline, re-read on every step and around every capability call,
25//! - a capability-invocation ceiling that is deliberately separate from the step budget,
26//! - a cumulative ceiling on the value bytes a script may materialize, which is what bounds memory
27//!   for a script that is cheap in steps and expensive in bytes.
28//!
29//! One bound is *not* in [`limits`], because it applies before any budget exists: [`parser`] caps
30//! grammar nesting depth at a fixed ceiling. Parsing is recursive and runs on the native stack, so
31//! without it a few kilobytes of nested `$( $( ... ) )` aborts the host process instead of
32//! returning a [`ScriptOutcome`].
33//!
34//! The variable namespace is seeded only from the script's own assignments. This interpreter never
35//! reads the host process environment — including through `jq`, whose standard library exports an
36//! `env` filter that is deliberately not linked.
37//!
38//! # Observability
39//!
40//! Every command word a script runs emits a `shell.command` span with a `shell.command.started` /
41//! `shell.command.completed` event pair, so a trace reads as the ordered list of commands a script
42//! actually executed rather than as one opaque "a script ran" entry.
43//!
44//! This crate depends on `tracing` and nothing else for that. It knows no exporter, no collector,
45//! and no telemetry protocol; the embedding binary's own subscriber decides where these go, the
46//! same way `curl` here links no HTTP client and only assembles a request for one capability. The
47//! dependency does not compromise the synchronous design constraint below — `tracing` imposes no
48//! async runtime and is routinely used from fully synchronous code — but it does mean spans may
49//! leave the process, so `interp::telemetry` documents exactly which fields a command may carry:
50//! never an argument value, and never a model-authored command word.
51//!
52//! # Example
53//!
54//! ```
55//! use dekopon_shell::{CapabilityCallResult, CapabilityInvoker, Interpreter, Limits};
56//! use serde_json::{Value, json};
57//!
58//! struct Fixture;
59//!
60//! impl CapabilityInvoker for Fixture {
61//!     fn granted(&self) -> Vec<String> {
62//!         vec!["echo.echo".to_owned()]
63//!     }
64//!
65//!     fn invoke(&self, capability: &str, input: Value) -> CapabilityCallResult {
66//!         assert_eq!(capability, "echo.echo");
67//!         CapabilityCallResult::Succeeded(input)
68//!     }
69//! }
70//!
71//! let outcome = Interpreter::new(Limits::default())
72//!     .run("echo.echo --message hi | jq -r .message", &Fixture);
73//! assert_eq!(outcome.exit_code.get(), 0);
74//! assert_eq!(outcome.output, "hi");
75//! ```
76
77#![forbid(unsafe_code)]
78
79use serde_json::Value;
80
81pub mod ast;
82mod builtins;
83mod dispatch;
84mod interp;
85pub mod lexer;
86pub mod limits;
87pub mod parser;
88pub mod value;
89
90pub use limits::{
91    DEFAULT_ALLOW_CLOCK, DEFAULT_MAX_CAPABILITY_CALLS, DEFAULT_MAX_OUTPUT_BYTES,
92    DEFAULT_MAX_OUTPUT_LINES, DEFAULT_MAX_RECURSION_DEPTH, DEFAULT_MAX_STEPS,
93    DEFAULT_MAX_VALUE_BYTES, DEFAULT_TIMEOUT, Limits,
94};
95pub use parser::ParseError;
96
97/// Model-facing metadata for one capability, used by `cap --describe`.
98#[derive(Clone, Debug, Eq, PartialEq)]
99pub struct CapabilityDescription {
100    /// Canonical capability identifier.
101    pub capability: String,
102    /// Human-readable operation description.
103    pub description: String,
104    /// Object-shaped JSON Schema for the capability's input.
105    pub input_schema: Value,
106}
107
108/// The outcome of one capability invocation.
109///
110/// The variants mirror the exit-code mapping in [`ExitCode`]: a capability that ran and failed is
111/// materially different from one that policy refused, which is different again from one that does
112/// not exist. Collapsing them would hide an authorization refusal behind a generic failure.
113#[derive(Clone, Debug, PartialEq)]
114pub enum CapabilityCallResult {
115    /// The capability ran and produced output.
116    Succeeded(Value),
117    /// Authorization refused the invocation. The capability was found but not permitted.
118    Denied {
119        /// Why the invocation was refused.
120        reason: String,
121    },
122    /// The capability ran and failed.
123    Failed {
124        /// Failure detail.
125        error: String,
126    },
127    /// No such capability is reachable from this session.
128    NotFound,
129}
130
131/// The boundary between this interpreter and the real world.
132///
133/// Implementations decide what a "capability" is: a direct Wasm component call, a broker proposal,
134/// or a test fixture. This crate never learns which.
135pub trait CapabilityInvoker {
136    /// Returns every capability identifier currently available to invoke.
137    fn granted(&self) -> Vec<String>;
138
139    /// Reports whether one capability identifier is available, for dispatch-time lookup.
140    ///
141    /// The default scans [`CapabilityInvoker::granted`]; override it when a cheaper lookup exists.
142    fn is_granted(&self, capability: &str) -> bool {
143        self.granted().iter().any(|granted| granted == capability)
144    }
145
146    /// Returns model-facing metadata for one capability, when the implementation has any.
147    fn describe(&self, capability: &str) -> Option<CapabilityDescription> {
148        let _ = capability;
149        None
150    }
151
152    /// Invokes one capability synchronously.
153    ///
154    /// This is deliberately synchronous: this crate carries no async runtime dependency, and the
155    /// calling binary's model tool loop is untouched. An implementation that is asynchronous
156    /// underneath bridges here itself, which is what `dekopon-run` does from its blocking task.
157    fn invoke(&self, capability: &str, input: Value) -> CapabilityCallResult;
158}
159
160/// A script exit code.
161///
162/// The mapping is fixed and mirrors the conventions a model already knows from bash and coreutils.
163#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)]
164pub struct ExitCode(u8);
165
166impl ExitCode {
167    /// A capability call, builtin, or script completed successfully.
168    pub const SUCCESS: Self = Self(0);
169    /// A capability call ran and errored, or a builtin reported a runtime failure.
170    pub const FAILURE: Self = Self(1);
171    /// A shell parse error or an exhausted resource limit.
172    pub const SYNTAX: Self = Self(2);
173    /// The script exceeded its wall-clock deadline, matching coreutils `timeout(1)`.
174    pub const TIMEOUT: Self = Self(124);
175    /// A capability was found but authorization refused it, matching bash's "cannot execute".
176    pub const DENIED: Self = Self(126);
177    /// An unknown builtin, or a capability not granted to this session.
178    pub const NOT_FOUND: Self = Self(127);
179
180    /// Wraps a raw status, mirroring bash's `N mod 256` wraparound for `exit N`.
181    #[must_use]
182    pub fn from_script_exit(status: i64) -> Self {
183        Self(u8::try_from(status.rem_euclid(256)).unwrap_or(0))
184    }
185
186    /// Returns the numeric exit code.
187    #[must_use]
188    pub const fn get(self) -> u8 {
189        self.0
190    }
191
192    /// Maps one capability call outcome onto its exit code.
193    #[must_use]
194    pub const fn from_capability_result(result: &CapabilityCallResult) -> Self {
195        match result {
196            CapabilityCallResult::Succeeded(_) => Self::SUCCESS,
197            CapabilityCallResult::Failed { .. } => Self::FAILURE,
198            CapabilityCallResult::Denied { .. } => Self::DENIED,
199            CapabilityCallResult::NotFound => Self::NOT_FOUND,
200        }
201    }
202}
203
204impl From<ExitCode> for u8 {
205    fn from(code: ExitCode) -> Self {
206        code.0
207    }
208}
209
210impl From<u8> for ExitCode {
211    fn from(code: u8) -> Self {
212        Self(code)
213    }
214}
215
216impl std::fmt::Display for ExitCode {
217    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
218        write!(formatter, "{}", self.0)
219    }
220}
221
222/// Everything one script execution produced.
223#[derive(Clone, Debug, Eq, PartialEq)]
224pub struct ScriptOutcome {
225    /// Combined stdout and stderr, already truncated to the configured ceilings.
226    pub output: String,
227    /// The script's exit code.
228    pub exit_code: ExitCode,
229    /// Whether output was dropped to stay under the ceilings.
230    pub truncated: bool,
231    /// Capability invocations this script drove.
232    pub capability_calls: u32,
233    /// Evaluation steps this script charged.
234    pub steps: u64,
235}
236
237/// A configured script interpreter.
238#[derive(Clone, Debug, Default)]
239pub struct Interpreter {
240    limits: Limits,
241    curl_capability: Option<String>,
242}
243
244impl Interpreter {
245    /// Creates an interpreter under the given bounds.
246    #[must_use]
247    pub fn new(limits: Limits) -> Self {
248        Self {
249            limits,
250            curl_capability: None,
251        }
252    }
253
254    /// Selects the capability the `curl` builtin assembles requests for.
255    ///
256    /// `curl` speaks no HTTP itself. It is a flag parser that produces the
257    /// `{uri, method, headers, body}` shape and hands it to this one capability through the same
258    /// [`CapabilityInvoker::invoke`] path every other command uses. When no capability is
259    /// configured, `curl` reports "command not found" like any ungranted capability.
260    #[must_use]
261    pub fn with_curl_capability(mut self, capability: Option<String>) -> Self {
262        self.curl_capability = capability;
263        self
264    }
265
266    /// Returns the configured bounds.
267    #[must_use]
268    pub fn limits(&self) -> Limits {
269        self.limits
270    }
271
272    /// Parses and evaluates one script.
273    ///
274    /// This never returns an error: a script failure is a script outcome. Parse errors and limit
275    /// trips are reported through [`ScriptOutcome::output`] and [`ScriptOutcome::exit_code`].
276    pub fn run(&self, script: &str, invoker: &dyn CapabilityInvoker) -> ScriptOutcome {
277        interp::run(
278            script,
279            invoker,
280            self.limits,
281            self.curl_capability.as_deref(),
282        )
283    }
284}
285
286/// Parses and evaluates one script under default bounds.
287pub fn run(script: &str, invoker: &dyn CapabilityInvoker) -> ScriptOutcome {
288    Interpreter::new(Limits::default()).run(script, invoker)
289}
290
291#[cfg(test)]
292mod tests {
293    use super::{CapabilityCallResult, ExitCode};
294
295    #[test]
296    fn exit_codes_follow_the_documented_mapping() {
297        assert_eq!(ExitCode::SUCCESS.get(), 0);
298        assert_eq!(ExitCode::FAILURE.get(), 1);
299        assert_eq!(ExitCode::SYNTAX.get(), 2);
300        assert_eq!(ExitCode::TIMEOUT.get(), 124);
301        assert_eq!(ExitCode::DENIED.get(), 126);
302        assert_eq!(ExitCode::NOT_FOUND.get(), 127);
303    }
304
305    #[test]
306    fn capability_results_map_onto_distinct_codes() {
307        assert_eq!(
308            ExitCode::from_capability_result(&CapabilityCallResult::Succeeded(
309                serde_json::Value::Null
310            )),
311            ExitCode::SUCCESS
312        );
313        assert_eq!(
314            ExitCode::from_capability_result(&CapabilityCallResult::Failed {
315                error: "boom".to_owned()
316            }),
317            ExitCode::FAILURE
318        );
319        assert_eq!(
320            ExitCode::from_capability_result(&CapabilityCallResult::Denied {
321                reason: "policy".to_owned()
322            }),
323            ExitCode::DENIED
324        );
325        assert_eq!(
326            ExitCode::from_capability_result(&CapabilityCallResult::NotFound),
327            ExitCode::NOT_FOUND
328        );
329    }
330
331    #[test]
332    fn script_exit_wraps_like_bash() {
333        assert_eq!(ExitCode::from_script_exit(0).get(), 0);
334        assert_eq!(ExitCode::from_script_exit(7).get(), 7);
335        assert_eq!(ExitCode::from_script_exit(256).get(), 0);
336        assert_eq!(ExitCode::from_script_exit(257).get(), 1);
337        assert_eq!(ExitCode::from_script_exit(-1).get(), 255);
338    }
339}