Skip to main content

dekopon_shell/
limits.rs

1//! Hand-built sandbox bounds for the tree-walking evaluator.
2//!
3//! This interpreter is native Rust, not Wasm: there is no fuel meter, no linear-memory ceiling, and
4//! no engine-level deadline to fall back on. Every bound a script can exhaust is owned here and
5//! enforced from the evaluator.
6
7use std::{
8    collections::VecDeque,
9    time::{Duration, Instant},
10};
11
12/// Default statement/loop/call budget for one script.
13pub const DEFAULT_MAX_STEPS: u64 = 100_000;
14/// Default shell-function call-stack depth.
15pub const DEFAULT_MAX_RECURSION_DEPTH: u32 = 64;
16/// Default accumulated output ceiling in bytes.
17pub const DEFAULT_MAX_OUTPUT_BYTES: usize = 256 * 1024;
18/// Default accumulated output ceiling in lines.
19pub const DEFAULT_MAX_OUTPUT_LINES: usize = 2_000;
20/// Default wall-clock deadline for one script.
21pub const DEFAULT_TIMEOUT: Duration = Duration::from_secs(30);
22/// Default number of capability invocations one script may drive.
23pub const DEFAULT_MAX_CAPABILITY_CALLS: u32 = 32;
24/// Default ceiling on the value bytes one script may materialize.
25pub const DEFAULT_MAX_VALUE_BYTES: u64 = 32 * 1024 * 1024;
26/// Whether a script may read the host wall clock by default. It may not.
27pub const DEFAULT_ALLOW_CLOCK: bool = false;
28
29/// Configurable execution bounds.
30#[derive(Clone, Copy, Debug, Eq, PartialEq)]
31pub struct Limits {
32    /// Statements, loop iterations, and function calls one script may execute.
33    pub max_steps: u64,
34    /// Maximum nested shell-function calls.
35    pub max_recursion_depth: u32,
36    /// Maximum accumulated output bytes.
37    pub max_output_bytes: usize,
38    /// Maximum accumulated output lines.
39    pub max_output_lines: usize,
40    /// Wall-clock deadline for the whole script.
41    pub timeout: Duration,
42    /// Maximum capability invocations one script may drive.
43    pub max_capability_calls: u32,
44    /// Maximum value bytes one script may materialize; see [`Budget::charge_value_bytes`].
45    pub max_value_bytes: u64,
46    /// Whether the `date` builtin may read the host wall clock.
47    ///
48    /// This is the odd one out in this struct: it is a permission rather than a ceiling. It lives
49    /// here anyway because it travels the same path every other bound does, from one CLI flag
50    /// through one construction site, and a second parallel channel for a single boolean would be
51    /// one more place for the two to disagree. Off by default: reading the clock is ambient
52    /// authority, and with it off `date` is simply not a command this session has.
53    pub allow_clock: bool,
54}
55
56impl Default for Limits {
57    fn default() -> Self {
58        Self {
59            max_steps: DEFAULT_MAX_STEPS,
60            max_recursion_depth: DEFAULT_MAX_RECURSION_DEPTH,
61            max_output_bytes: DEFAULT_MAX_OUTPUT_BYTES,
62            max_output_lines: DEFAULT_MAX_OUTPUT_LINES,
63            timeout: DEFAULT_TIMEOUT,
64            max_capability_calls: DEFAULT_MAX_CAPABILITY_CALLS,
65            max_value_bytes: DEFAULT_MAX_VALUE_BYTES,
66            allow_clock: DEFAULT_ALLOW_CLOCK,
67        }
68    }
69}
70
71/// A limit a running script exhausted.
72#[derive(Clone, Copy, Debug, Eq, PartialEq)]
73pub enum LimitExceeded {
74    /// The statement/loop/call budget ran out.
75    Steps {
76        /// Configured budget.
77        maximum: u64,
78    },
79    /// Shell functions nested too deeply.
80    RecursionDepth {
81        /// Configured depth cap.
82        maximum: u32,
83    },
84    /// The script exceeded its wall-clock deadline.
85    Deadline {
86        /// Configured deadline in milliseconds.
87        timeout_ms: u128,
88    },
89    /// The script drove too many capability invocations.
90    CapabilityCalls {
91        /// Configured call cap.
92        maximum: u32,
93    },
94    /// The script materialized more value bytes than it is allowed to.
95    ValueBytes {
96        /// Configured value-byte ceiling.
97        maximum: u64,
98    },
99}
100
101/// Mutable per-execution counters for one script run.
102#[derive(Debug)]
103pub struct Budget {
104    limits: Limits,
105    started: Instant,
106    steps: u64,
107    depth: u32,
108    capability_calls: u32,
109    value_bytes: u64,
110}
111
112impl Budget {
113    /// Starts a fresh budget and its wall clock.
114    #[must_use]
115    pub fn start(limits: Limits) -> Self {
116        Self {
117            limits,
118            started: Instant::now(),
119            steps: 0,
120            depth: 0,
121            capability_calls: 0,
122            value_bytes: 0,
123        }
124    }
125
126    /// Charges one evaluation step and re-checks the deadline.
127    ///
128    /// This is the only backstop against `while true; do :; done`. The deadline is re-read on
129    /// *every* step rather than every Nth: a script can spend minutes in very few steps (a handful
130    /// of slow capability calls, one enormous string concatenation), so a sampled clock leaves the
131    /// exact workloads that most need bounding unbounded. Reading a monotonic clock costs tens of
132    /// nanoseconds against a tree-walking step that costs far more.
133    pub fn charge_step(&mut self) -> Result<(), LimitExceeded> {
134        self.steps = self.steps.saturating_add(1);
135        if self.steps > self.limits.max_steps {
136            return Err(LimitExceeded::Steps {
137                maximum: self.limits.max_steps,
138            });
139        }
140        self.check_deadline()
141    }
142
143    /// Charges value bytes a script materialized into a variable, buffer, or capture.
144    ///
145    /// This counter is deliberately **cumulative rather than retained**: it bounds how many bytes a
146    /// script may bring into existence over its whole run, not how many it holds at one instant.
147    /// Retained memory is always at most the cumulative total, so a cheap bound on the total is a
148    /// sound bound on the peak, and it needs no release path that a missed call could silently
149    /// corrupt. Without it, `x="$x$x"` repeated twenty-six times reaches gigabytes in a few hundred
150    /// steps — every other ceiling here counts operations, and none of them counts bytes.
151    pub fn charge_value_bytes(&mut self, bytes: u64) -> Result<(), LimitExceeded> {
152        self.value_bytes = self.value_bytes.saturating_add(bytes);
153        if self.value_bytes > self.limits.max_value_bytes {
154            return Err(LimitExceeded::ValueBytes {
155                maximum: self.limits.max_value_bytes,
156            });
157        }
158        Ok(())
159    }
160
161    /// Re-reads the wall clock immediately.
162    pub fn check_deadline(&self) -> Result<(), LimitExceeded> {
163        if self.started.elapsed() > self.limits.timeout {
164            return Err(LimitExceeded::Deadline {
165                timeout_ms: self.limits.timeout.as_millis(),
166            });
167        }
168        Ok(())
169    }
170
171    /// Returns the time left before the deadline trips.
172    #[must_use]
173    pub fn remaining(&self) -> Duration {
174        self.limits.timeout.saturating_sub(self.started.elapsed())
175    }
176
177    /// Enters one shell-function frame.
178    pub fn enter_call(&mut self) -> Result<(), LimitExceeded> {
179        if self.depth >= self.limits.max_recursion_depth {
180            return Err(LimitExceeded::RecursionDepth {
181                maximum: self.limits.max_recursion_depth,
182            });
183        }
184        self.depth = self.depth.saturating_add(1);
185        Ok(())
186    }
187
188    /// Leaves one shell-function frame.
189    pub fn leave_call(&mut self) {
190        self.depth = self.depth.saturating_sub(1);
191    }
192
193    /// Charges one capability invocation.
194    ///
195    /// This counter is deliberately independent of the step budget: a single script can loop and
196    /// drive many capability calls where one model tool call drives exactly one today, so the
197    /// amplification vector needs its own ceiling.
198    pub fn charge_capability_call(&mut self) -> Result<(), LimitExceeded> {
199        if self.capability_calls >= self.limits.max_capability_calls {
200            return Err(LimitExceeded::CapabilityCalls {
201                maximum: self.limits.max_capability_calls,
202            });
203        }
204        self.capability_calls = self.capability_calls.saturating_add(1);
205        Ok(())
206    }
207
208    /// Returns the number of capability invocations charged so far.
209    #[must_use]
210    pub fn capability_calls(&self) -> u32 {
211        self.capability_calls
212    }
213
214    /// Returns the number of steps charged so far.
215    #[must_use]
216    pub fn steps(&self) -> u64 {
217        self.steps
218    }
219
220    /// Returns the value bytes charged so far.
221    #[must_use]
222    pub fn value_bytes(&self) -> u64 {
223        self.value_bytes
224    }
225}
226
227/// Bounded combined stdout/stderr accumulator.
228///
229/// The byte and line ceilings are independent so a single oversized line cannot slip past a
230/// line-count-only limit. When either trips, the head and the tail are both retained with a marker
231/// in between; head-only truncation would hide a script's final result, which is usually the part
232/// worth reading.
233#[derive(Debug)]
234pub struct OutputBuffer {
235    max_bytes: usize,
236    max_lines: usize,
237    head: Vec<String>,
238    head_bytes: usize,
239    tail: VecDeque<String>,
240    tail_bytes: usize,
241    total_lines: usize,
242    truncated: bool,
243    pending: String,
244}
245
246impl OutputBuffer {
247    /// Creates an empty buffer under the configured ceilings.
248    #[must_use]
249    pub fn new(limits: &Limits) -> Self {
250        Self {
251            max_bytes: limits.max_output_bytes.max(1),
252            max_lines: limits.max_output_lines.max(1),
253            head: Vec::new(),
254            head_bytes: 0,
255            tail: VecDeque::new(),
256            tail_bytes: 0,
257            total_lines: 0,
258            truncated: false,
259            pending: String::new(),
260        }
261    }
262
263    fn tail_line_budget(&self) -> usize {
264        (self.max_lines / 2).max(1)
265    }
266
267    fn tail_byte_budget(&self) -> usize {
268        (self.max_bytes / 2).max(1)
269    }
270
271    /// Appends one already-rendered line.
272    pub fn push_line(&mut self, line: &str) {
273        let line = clamp_line(line, self.max_bytes);
274        let cost = line.len().saturating_add(1);
275        self.total_lines = self.total_lines.saturating_add(1);
276
277        if !self.truncated {
278            let fits = self.head.len() < self.max_lines
279                && self.head_bytes.saturating_add(cost) <= self.max_bytes;
280            if fits {
281                self.head_bytes = self.head_bytes.saturating_add(cost);
282                self.head.push(line);
283                return;
284            }
285            self.begin_truncation();
286        }
287
288        self.tail_bytes = self.tail_bytes.saturating_add(cost);
289        self.tail.push_back(line);
290        self.evict_tail();
291    }
292
293    /// Appends text without terminating the current line.
294    ///
295    /// This is what `echo -n` and `printf` produce; the fragment joins whatever the next write
296    /// appends, exactly as it would on a real terminal.
297    pub fn push_fragment(&mut self, fragment: &str) {
298        self.pending.push_str(fragment);
299        self.drain_complete_lines();
300    }
301
302    /// Appends a possibly multi-line block and terminates the line.
303    pub fn push_block(&mut self, block: &str) {
304        self.pending.push_str(block);
305        self.pending.push('\n');
306        self.drain_complete_lines();
307    }
308
309    fn drain_complete_lines(&mut self) {
310        while let Some(offset) = self.pending.find('\n') {
311            let line = self.pending[..offset].to_owned();
312            self.pending.drain(..=offset);
313            self.push_line(&line);
314        }
315    }
316
317    /// Flushes any unterminated trailing fragment. Call once before rendering.
318    pub fn finish(&mut self) {
319        if !self.pending.is_empty() {
320            let line = std::mem::take(&mut self.pending);
321            self.push_line(&line);
322        }
323    }
324
325    fn begin_truncation(&mut self) {
326        self.truncated = true;
327        let head_lines = self.max_lines.saturating_sub(self.tail_line_budget());
328        let head_bytes = self.max_bytes.saturating_sub(self.tail_byte_budget());
329        while self.head.len() > head_lines || self.head_bytes > head_bytes {
330            let Some(dropped) = self.head.pop() else {
331                break;
332            };
333            self.head_bytes = self
334                .head_bytes
335                .saturating_sub(dropped.len().saturating_add(1));
336        }
337    }
338
339    fn evict_tail(&mut self) {
340        while self.tail.len() > self.tail_line_budget() || self.tail_bytes > self.tail_byte_budget()
341        {
342            let Some(dropped) = self.tail.pop_front() else {
343                break;
344            };
345            self.tail_bytes = self
346                .tail_bytes
347                .saturating_sub(dropped.len().saturating_add(1));
348            if self.tail.is_empty() {
349                break;
350            }
351        }
352    }
353
354    /// Reports whether any line was dropped or clamped.
355    #[must_use]
356    pub fn is_truncated(&self) -> bool {
357        self.truncated
358    }
359
360    /// Renders the retained output, including the truncation marker when one applies.
361    #[must_use]
362    pub fn render(&self) -> String {
363        let mut lines = Vec::with_capacity(self.head.len() + self.tail.len() + 1);
364        lines.extend(self.head.iter().cloned());
365        if self.truncated {
366            lines.push(format!(
367                "... Output truncated ({} total lines) ...",
368                self.total_lines
369            ));
370            lines.extend(self.tail.iter().cloned());
371        }
372        lines.join("\n")
373    }
374}
375
376/// Clamps one line so a single enormous line cannot defeat the byte ceiling.
377fn clamp_line(line: &str, maximum: usize) -> String {
378    if line.len() <= maximum {
379        return line.to_owned();
380    }
381    let mut end = maximum;
382    while end > 0 && !line.is_char_boundary(end) {
383        end -= 1;
384    }
385    format!("{}...", &line[..end])
386}
387
388#[cfg(test)]
389mod tests {
390    use std::time::Duration;
391
392    use super::{Budget, LimitExceeded, Limits, OutputBuffer};
393
394    #[test]
395    fn step_budget_trips_at_the_configured_ceiling() {
396        let mut budget = Budget::start(Limits {
397            max_steps: 3,
398            ..Limits::default()
399        });
400        assert!(budget.charge_step().is_ok());
401        assert!(budget.charge_step().is_ok());
402        assert!(budget.charge_step().is_ok());
403        assert_eq!(
404            budget.charge_step(),
405            Err(LimitExceeded::Steps { maximum: 3 })
406        );
407    }
408
409    #[test]
410    fn recursion_depth_is_capped_and_released() {
411        let mut budget = Budget::start(Limits {
412            max_recursion_depth: 2,
413            ..Limits::default()
414        });
415        assert!(budget.enter_call().is_ok());
416        assert!(budget.enter_call().is_ok());
417        assert_eq!(
418            budget.enter_call(),
419            Err(LimitExceeded::RecursionDepth { maximum: 2 })
420        );
421        budget.leave_call();
422        assert!(budget.enter_call().is_ok());
423    }
424
425    #[test]
426    fn capability_calls_are_counted_separately_from_steps() {
427        let mut budget = Budget::start(Limits {
428            max_capability_calls: 1,
429            ..Limits::default()
430        });
431        assert!(budget.charge_capability_call().is_ok());
432        assert_eq!(
433            budget.charge_capability_call(),
434            Err(LimitExceeded::CapabilityCalls { maximum: 1 })
435        );
436        assert_eq!(budget.steps(), 0);
437        assert_eq!(budget.capability_calls(), 1);
438    }
439
440    #[test]
441    fn value_bytes_accumulate_across_the_whole_run() {
442        let mut budget = Budget::start(Limits {
443            max_value_bytes: 10,
444            ..Limits::default()
445        });
446        assert!(budget.charge_value_bytes(6).is_ok());
447        // Cumulative, not retained: two values that each fit still trip the ceiling together.
448        assert_eq!(
449            budget.charge_value_bytes(6),
450            Err(LimitExceeded::ValueBytes { maximum: 10 })
451        );
452        assert_eq!(budget.value_bytes(), 12);
453    }
454
455    #[test]
456    fn an_expired_deadline_is_reported_immediately() {
457        let budget = Budget::start(Limits {
458            timeout: Duration::ZERO,
459            ..Limits::default()
460        });
461        std::thread::sleep(Duration::from_millis(2));
462        assert!(matches!(
463            budget.check_deadline(),
464            Err(LimitExceeded::Deadline { .. })
465        ));
466        assert_eq!(budget.remaining(), Duration::ZERO);
467    }
468
469    #[test]
470    fn charging_a_step_re_reads_the_deadline() {
471        // The step counter is not the backstop here: a script that is slow rather than long must
472        // still be stopped, so every step re-reads the clock.
473        let mut budget = Budget::start(Limits {
474            max_steps: u64::MAX,
475            timeout: Duration::from_millis(5),
476            ..Limits::default()
477        });
478        assert!(budget.charge_step().is_ok());
479        std::thread::sleep(Duration::from_millis(10));
480        assert!(matches!(
481            budget.charge_step(),
482            Err(LimitExceeded::Deadline { .. })
483        ));
484        assert_eq!(budget.steps(), 2);
485    }
486
487    #[test]
488    fn output_under_both_ceilings_is_preserved_exactly() {
489        let mut buffer = OutputBuffer::new(&Limits::default());
490        buffer.push_line("first");
491        buffer.push_block("second\nthird");
492        assert!(!buffer.is_truncated());
493        assert_eq!(buffer.render(), "first\nsecond\nthird");
494    }
495
496    #[test]
497    fn the_line_ceiling_keeps_head_and_tail() {
498        let mut buffer = OutputBuffer::new(&Limits {
499            max_output_lines: 4,
500            ..Limits::default()
501        });
502        for index in 0..20 {
503            buffer.push_line(&format!("line-{index}"));
504        }
505        let rendered = buffer.render();
506        assert!(buffer.is_truncated());
507        assert!(rendered.starts_with("line-0\nline-1\n"), "{rendered}");
508        assert!(rendered.ends_with("line-18\nline-19"), "{rendered}");
509        assert!(
510            rendered.contains("... Output truncated (20 total lines) ..."),
511            "{rendered}"
512        );
513    }
514
515    #[test]
516    fn one_oversized_line_cannot_bypass_the_byte_ceiling() {
517        let mut buffer = OutputBuffer::new(&Limits {
518            max_output_bytes: 32,
519            max_output_lines: 10_000,
520            ..Limits::default()
521        });
522        buffer.push_line(&"x".repeat(4096));
523        buffer.push_line("tail");
524        assert!(buffer.is_truncated());
525        assert!(buffer.render().len() < 200, "{}", buffer.render());
526        assert!(buffer.render().ends_with("tail"));
527    }
528}