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}