Skip to main content

talon/
output_limits.rs

1//! Caps on agent output before it is redacted, stored or delivered to a
2//! chat channel (MYTH-07).
3//!
4//! A compromised container controls every frame it emits. Without caps a
5//! run could stream arbitrarily large or arbitrarily many results into host
6//! memory, the redactor and the chat channels. The orchestrator therefore
7//! passes every frame through an [`OutputGuard`]:
8//!
9//! - **per frame / per turn**: result text is truncated to
10//!   [`OutputLimits::max_result_bytes`] (64 KiB) with a note saying so. The
11//!   agent emits exactly one result frame per turn, so this is also the
12//!   per-turn cap of a well-behaved agent. Long results still reach
13//!   CoPilot in full through the agent's own MCP write-back; only the copy
14//!   delivered to the chat channel is shortened. Error texts are capped
15//!   too, and session ids must pass [`is_valid_session_id`] (at most
16//!   [`MAX_SESSION_ID_BYTES`] of `[A-Za-z0-9_-]`; N-5) or are dropped.
17//! - **per run** (one container, including piped follow-ups): at most
18//!   [`OutputLimits::max_run_frames`] frames and
19//!   [`OutputLimits::max_run_result_bytes`] of (already truncated) result
20//!   text. Exceeding either aborts the run: the container is stopped and
21//!   the remaining frames are dropped.
22//!
23//! Frames reach the orchestrator through a bounded channel
24//! ([`OUTPUT_CHANNEL_CAPACITY`]), so a slow consumer applies backpressure
25//! to the container's stdout reader instead of buffering without bound.
26
27use talon_core::protocol::ContainerOutput;
28
29/// Capacity of the launcher -> orchestrator frame channel.
30pub const OUTPUT_CHANNEL_CAPACITY: usize = 64;
31/// Default per-frame result cap (bytes).
32pub const DEFAULT_MAX_RESULT_BYTES: usize = 64 * 1024;
33/// Default per-run cap on delivered result text (bytes).
34pub const DEFAULT_MAX_RUN_RESULT_BYTES: usize = 4 * 1024 * 1024;
35/// Default per-run frame cap.
36pub const DEFAULT_MAX_RUN_FRAMES: usize = 1024;
37/// Default per-frame error text cap (bytes).
38pub const DEFAULT_MAX_ERROR_BYTES: usize = 8 * 1024;
39/// Session ids longer than this are ignored (they are persisted in the
40/// database, kept in memory, logged and sent back to the container on the
41/// next run). The agent's own ids are 36-byte UUIDs.
42pub const MAX_SESSION_ID_BYTES: usize = 128;
43/// How far back from the cut point a whitespace boundary is searched, so
44/// a truncation does not split a token (a partial secret would no longer
45/// match the redactor).
46const BOUNDARY_SEARCH_BYTES: usize = 1024;
47
48/// Output caps (see the module docs).
49#[derive(Debug, Clone, Copy, PartialEq, Eq)]
50pub struct OutputLimits {
51    /// Longest result text delivered per frame (bytes, including the
52    /// truncation note).
53    pub max_result_bytes: usize,
54    /// Most result text a run may deliver in total (bytes).
55    pub max_run_result_bytes: usize,
56    /// Most frames a run may emit.
57    pub max_run_frames: usize,
58    /// Longest error text kept per frame (bytes).
59    pub max_error_bytes: usize,
60}
61
62impl Default for OutputLimits {
63    fn default() -> Self {
64        Self {
65            max_result_bytes: DEFAULT_MAX_RESULT_BYTES,
66            max_run_result_bytes: DEFAULT_MAX_RUN_RESULT_BYTES,
67            max_run_frames: DEFAULT_MAX_RUN_FRAMES,
68            max_error_bytes: DEFAULT_MAX_ERROR_BYTES,
69        }
70    }
71}
72
73/// Whether `id` is acceptable as a session id reported by an agent:
74/// 1..=[`MAX_SESSION_ID_BYTES`] bytes of `[A-Za-z0-9_-]` (N-5). Anything
75/// else — oversized, path-like, control characters — is never persisted.
76pub fn is_valid_session_id(id: &str) -> bool {
77    !id.is_empty()
78        && id.len() <= MAX_SESSION_ID_BYTES
79        && id
80            .bytes()
81            .all(|b| b.is_ascii_alphanumeric() || b == b'_' || b == b'-')
82}
83
84/// A session id reported by the agent, or `None` when it is absent, empty
85/// or invalid (see [`is_valid_session_id`]; invalid ids are logged —
86/// length only, never the id itself — and ignored).
87pub fn sanitize_session_id(id: Option<String>) -> Option<String> {
88    let id = id.filter(|id| !id.is_empty())?;
89    if is_valid_session_id(&id) {
90        Some(id)
91    } else {
92        tracing::warn!(
93            bytes = id.len(),
94            limit = MAX_SESSION_ID_BYTES,
95            "agent reported an invalid session id (oversized or not [A-Za-z0-9_-]); ignored"
96        );
97        None
98    }
99}
100
101/// Largest index `<= max` that is a char boundary of `text`, moved back to
102/// just after a whitespace character when one is within reach.
103fn cut_point(text: &str, max: usize) -> usize {
104    let mut cut = max.min(text.len());
105    while cut > 0 && !text.is_char_boundary(cut) {
106        cut -= 1;
107    }
108    let floor = cut.saturating_sub(BOUNDARY_SEARCH_BYTES);
109    match text[floor..cut].rfind(char::is_whitespace) {
110        Some(rel) => floor + rel,
111        None => cut,
112    }
113}
114
115/// `text` limited to `max` bytes. Longer text is cut (at a whitespace
116/// boundary when possible) and ends with a note saying how much was
117/// omitted; the result, note included, is at most `max` bytes.
118pub fn cap_text(text: &str, max: usize) -> String {
119    if text.len() <= max {
120        return text.to_string();
121    }
122    let note = format!(
123        "\n\n[Talahon: output truncated — the result was {} bytes; only the first part \
124         (limit {max} bytes) is shown here]",
125        text.len()
126    );
127    let budget = max.saturating_sub(note.len());
128    if budget == 0 {
129        let cut = cut_point(text, max);
130        return text[..cut].to_string();
131    }
132    let cut = cut_point(text, budget);
133    let mut out = String::with_capacity(cut + note.len());
134    out.push_str(text[..cut].trim_end());
135    out.push_str(&note);
136    out
137}
138
139/// What to do with one frame.
140#[derive(Debug, Clone, PartialEq)]
141pub enum Admission {
142    /// Deliver this (possibly capped) frame.
143    Deliver(ContainerOutput),
144    /// The run exceeded its caps: stop the container, drop the rest.
145    Abort(String),
146}
147
148/// Per-run accounting of agent output (see the module docs).
149#[derive(Debug, Clone)]
150pub struct OutputGuard {
151    limits: OutputLimits,
152    frames: usize,
153    result_bytes: usize,
154    truncated: usize,
155}
156
157impl OutputGuard {
158    /// A guard for one run.
159    pub fn new(limits: OutputLimits) -> Self {
160        Self {
161            limits,
162            frames: 0,
163            result_bytes: 0,
164            truncated: 0,
165        }
166    }
167
168    /// Frames admitted so far.
169    pub fn frames(&self) -> usize {
170        self.frames
171    }
172
173    /// Result bytes admitted so far.
174    pub fn result_bytes(&self) -> usize {
175        self.result_bytes
176    }
177
178    /// Frames whose result was truncated.
179    pub fn truncated(&self) -> usize {
180        self.truncated
181    }
182
183    /// Cap one frame, or decide that the run must be aborted.
184    pub fn admit(&mut self, mut output: ContainerOutput) -> Admission {
185        self.frames += 1;
186        if self.frames > self.limits.max_run_frames {
187            return Admission::Abort(format!(
188                "agent output exceeded the per-run limit of {} frames; run stopped",
189                self.limits.max_run_frames
190            ));
191        }
192        if let Some(result) = output.result.take() {
193            let capped = if result.len() > self.limits.max_result_bytes {
194                self.truncated += 1;
195                tracing::warn!(
196                    bytes = result.len(),
197                    limit = self.limits.max_result_bytes,
198                    "agent result exceeds the per-message limit; truncated"
199                );
200                cap_text(&result, self.limits.max_result_bytes)
201            } else {
202                result
203            };
204            self.result_bytes = self.result_bytes.saturating_add(capped.len());
205            if self.result_bytes > self.limits.max_run_result_bytes {
206                return Admission::Abort(format!(
207                    "agent output exceeded the per-run limit of {} bytes; run stopped",
208                    self.limits.max_run_result_bytes
209                ));
210            }
211            output.result = Some(capped);
212        }
213        if let Some(error) = output.error.take() {
214            output.error = Some(cap_text(&error, self.limits.max_error_bytes));
215        }
216        output.new_session_id = sanitize_session_id(output.new_session_id.take());
217        Admission::Deliver(output)
218    }
219}
220
221#[cfg(test)]
222mod tests {
223    use super::*;
224
225    fn limits(result: usize, run: usize, frames: usize) -> OutputLimits {
226        OutputLimits {
227            max_result_bytes: result,
228            max_run_result_bytes: run,
229            max_run_frames: frames,
230            max_error_bytes: 200,
231        }
232    }
233
234    #[test]
235    fn short_text_is_untouched() {
236        assert_eq!(cap_text("hello", 64), "hello");
237        assert_eq!(cap_text("", 0), "");
238    }
239
240    #[test]
241    fn long_text_is_truncated_with_a_note_within_the_limit() {
242        let text = "word ".repeat(100_000);
243        let capped = cap_text(&text, DEFAULT_MAX_RESULT_BYTES);
244        assert!(capped.len() <= DEFAULT_MAX_RESULT_BYTES, "{}", capped.len());
245        assert!(capped.contains("output truncated"));
246        assert!(capped.contains(&text.len().to_string()));
247        assert!(capped.starts_with("word word"));
248    }
249
250    #[test]
251    fn truncation_respects_char_boundaries_and_token_boundaries() {
252        let text = "é".repeat(50_000);
253        let capped = cap_text(&text, 1001);
254        assert!(capped.len() <= 1001);
255        // Must not split a token when whitespace is near the cut point.
256        let secret = "sk-SECRETSECRETSECRET";
257        let text = format!("{} {secret} {}", "a ".repeat(200), "b".repeat(10_000));
258        let capped = cap_text(&text, 600);
259        assert!(capped.contains(secret), "{capped}");
260        // A cut inside the token moves back to the preceding space.
261        let start = text.find(secret).unwrap();
262        let cut = cut_point(&text, start + 5);
263        assert_eq!(cut, start - 1);
264        assert!(!text[..cut].contains("sk-"));
265        // Without whitespace in reach, the cut is at the char boundary.
266        let solid = "z".repeat(5_000);
267        assert_eq!(cut_point(&solid, 4_000), 4_000);
268        // A tiny limit still yields at most `max` bytes.
269        assert!(cap_text(&"x".repeat(100), 5).len() <= 5);
270    }
271
272    #[test]
273    fn guard_truncates_frames_and_caps_errors_and_session_ids() {
274        let mut guard = OutputGuard::new(limits(1000, 1_000_000, 10));
275        let big = ContainerOutput::success(Some("x ".repeat(5_000)), Some("s".repeat(300)));
276        let Admission::Deliver(out) = guard.admit(big) else {
277            panic!("must deliver");
278        };
279        assert!(out.result.as_ref().unwrap().len() <= 1000);
280        assert_eq!(out.new_session_id, None);
281        assert_eq!(guard.truncated(), 1);
282
283        let Admission::Deliver(err) = guard.admit(ContainerOutput::error("e".repeat(10_000)))
284        else {
285            panic!("must deliver");
286        };
287        assert!(err.error.unwrap().len() <= 200);
288
289        let Admission::Deliver(ok) = guard.admit(ContainerOutput::success(
290            Some("hi".into()),
291            Some("sess-1".into()),
292        )) else {
293            panic!("must deliver");
294        };
295        assert_eq!(ok.result.as_deref(), Some("hi"));
296        assert_eq!(ok.new_session_id.as_deref(), Some("sess-1"));
297        assert_eq!(guard.frames(), 3);
298    }
299
300    #[test]
301    fn session_ids_are_validated() {
302        assert!(is_valid_session_id("3f2b8c1e-9a4d-4e6f-8b7a-0c1d2e3f4a5b"));
303        assert!(is_valid_session_id("sess_1-A"));
304        assert!(is_valid_session_id(&"a".repeat(MAX_SESSION_ID_BYTES)));
305        assert!(!is_valid_session_id(&"a".repeat(MAX_SESSION_ID_BYTES + 1)));
306        assert!(!is_valid_session_id(""));
307        for bad in [
308            "../../etc/passwd",
309            "a/b",
310            "a.b",
311            "a b",
312            "line\nbreak",
313            "tab\t",
314            "ümlaut",
315            "id;rm",
316            "\u{1b}[31m",
317        ] {
318            assert!(!is_valid_session_id(bad), "{bad:?} must be rejected");
319        }
320        assert_eq!(sanitize_session_id(None), None);
321        assert_eq!(sanitize_session_id(Some(String::new())), None);
322        assert_eq!(sanitize_session_id(Some("x/y".into())), None);
323        assert_eq!(
324            sanitize_session_id(Some("x".repeat(MAX_SESSION_ID_BYTES + 1))),
325            None
326        );
327        assert_eq!(
328            sanitize_session_id(Some("sess-1".into())).as_deref(),
329            Some("sess-1")
330        );
331    }
332
333    #[test]
334    fn guard_drops_invalid_session_ids() {
335        let mut guard = OutputGuard::new(limits(1000, 1_000_000, 10));
336        for bad in [
337            "s".repeat(MAX_SESSION_ID_BYTES + 1),
338            "../sessions/x".to_string(),
339            "id with spaces".to_string(),
340            "evil\nlog line".to_string(),
341        ] {
342            let Admission::Deliver(out) =
343                guard.admit(ContainerOutput::success(Some("ok".into()), Some(bad)))
344            else {
345                panic!("must deliver");
346            };
347            assert_eq!(out.new_session_id, None);
348            // The rest of the frame is still delivered.
349            assert_eq!(out.result.as_deref(), Some("ok"));
350        }
351        let max = "s".repeat(MAX_SESSION_ID_BYTES);
352        let Admission::Deliver(out) =
353            guard.admit(ContainerOutput::success(None, Some(max.clone())))
354        else {
355            panic!("must deliver");
356        };
357        assert_eq!(out.new_session_id, Some(max));
358    }
359
360    #[test]
361    fn guard_aborts_when_the_run_exceeds_its_caps() {
362        let mut guard = OutputGuard::new(limits(1000, 2500, 100));
363        for _ in 0..2 {
364            assert!(matches!(
365                guard.admit(ContainerOutput::success(Some("y".repeat(1000)), None)),
366                Admission::Deliver(_)
367            ));
368        }
369        assert!(matches!(
370            guard.admit(ContainerOutput::success(Some("y".repeat(1000)), None)),
371            Admission::Abort(reason) if reason.contains("2500 bytes")
372        ));
373
374        let mut guard = OutputGuard::new(limits(1000, 1_000_000, 3));
375        for _ in 0..3 {
376            assert!(matches!(
377                guard.admit(ContainerOutput::success(None, None)),
378                Admission::Deliver(_)
379            ));
380        }
381        assert!(matches!(
382            guard.admit(ContainerOutput::success(None, None)),
383            Admission::Abort(reason) if reason.contains("3 frames")
384        ));
385    }
386}