diff --git a/crates/bashkit/docs/threat-model.md b/crates/bashkit/docs/threat-model.md index 9487a1132..af5e48e70 100644 --- a/crates/bashkit/docs/threat-model.md +++ b/crates/bashkit/docs/threat-model.md @@ -110,7 +110,7 @@ through configurable limits. | split file count (TM-DOS-055) | `split -l 1 bigfile` | FS `max_file_count` limit | MITIGATED | | source self-recursion (TM-DOS-056) | Script that sources itself | Track source depth | **MITIGATED** | | sleep bypasses timeout (TM-DOS-057) | `sleep N` ignores `ExecutionLimits::timeout` | Host-backed timeout; non-JS wasm blocking sleep is clamped to the execution deadline | **MITIGATED** | -| Interactive terminal flooding (TM-DOS-119) | Unbounded type-ahead, undrained output, or a `vi` buffer grown by pastes | Caps on unread input (1 MiB), retained output (4 MiB), line length, `vi` buffer (8 MiB) and undo history; `:w` goes through VFS limits | MITIGATED | +| Interactive terminal flooding (TM-DOS-119) | Unbounded type-ahead, undrained output, or a `vi` buffer grown by pastes | Caps on unread input (1 MiB), retained output (4 MiB), scrollback (1000 rows), transcript (1 MiB), line length, `vi` buffer (8 MiB) and undo history; `:w` goes through VFS limits | MITIGATED | | Input wait vs. timeout (TM-DOS-120) | Abusing the keyboard-wait exclusion to run work past the deadline | Only time blocked reading the terminal is excluded; CPU work and `sleep` still count | MITIGATED | | Unbounded builtin output (TM-DOS-058) | `seq 1 1000000` produces 1M lines | Add `max_stdout_bytes` limit | **MITIGATED** | | Silent truncation at builtin caps (TM-DOS-109) | `seq 200000`, an awk loop past its cap, or an oversized `sprintf` expression returns incomplete output with exit 0 | Caps report `: limit () exceeded` on stderr and exit non-zero; awk caps and formatting errors are fatal | **MITIGATED** | diff --git a/crates/bashkit/src/terminal/mod.rs b/crates/bashkit/src/terminal/mod.rs index 5b8487e0e..bda585e72 100644 --- a/crates/bashkit/src/terminal/mod.rs +++ b/crates/bashkit/src/terminal/mod.rs @@ -13,7 +13,7 @@ //! //! ```rust //! use bashkit::Bash; -//! use bashkit::terminal::{Terminal, TerminalStatus}; +//! use bashkit::terminal::{Terminal, TerminalActivity, TerminalStatus}; //! //! # #[tokio::main] //! # async fn main() { @@ -26,6 +26,14 @@ //! let saved = term.fs().read_file("/tmp/note.txt".as_ref()).await.unwrap(); //! assert_eq!(saved, b"hello from vi\n"); //! +//! // Exact per-command results, without scraping the screen. +//! term.send("ls /nope\r"); +//! term.run_until_idle().await; +//! let record = term.take_transcript().pop().unwrap(); +//! assert_eq!(record.command, "ls /nope"); +//! assert_ne!(record.exit_code, 0); +//! assert_eq!(term.activity(), TerminalActivity::Prompt); +//! //! term.send("exit 3\r"); //! assert_eq!(term.run_until_idle().await, TerminalStatus::Exited(3)); //! # } @@ -43,6 +51,12 @@ //! Zig/C toolchain, builds for every bashkit target. //! - Time blocked on terminal input is excluded from the execution timeout //! (TM-DOS-057): typing speed is not sandbox work. All other limits apply. +//! - Agents get three views of the session: the screen ([`Terminal::screen_text`]), +//! the screen plus scrollback ([`Terminal::history_text`]), and a structured +//! per-command log ([`Terminal::take_transcript`]) with exact output and exit +//! codes, so they do not have to scrape prompts out of screen text. +//! [`Terminal::activity`] says whether the shell is at a prompt or which +//! command line is running. //! - Cooked mode is a small built-in line discipline (echo, backspace, ^U, ^W, //! ^C, ^D). Command stdin is still a value fixed before the command starts //! (L-CLI-002), so `read` does not block on the terminal; programs that need @@ -51,10 +65,11 @@ mod keys; mod tty; +use std::collections::VecDeque; use std::future::Future; use std::pin::Pin; -use std::sync::Arc; use std::sync::atomic::{AtomicBool, AtomicI32, Ordering}; +use std::sync::{Arc, Mutex, PoisonError}; pub(crate) use keys::{Key, ScreenGuard, read_key}; pub(crate) use tty::{Tty, TtyEvent}; @@ -68,6 +83,12 @@ const DEFAULT_PS1: &str = "$ "; const DEFAULT_PS2: &str = "> "; /// Longest line the cooked-mode editor accepts; further input is dropped. const MAX_LINE_BYTES: usize = 64 * 1024; +// THREAT[TM-DOS-119]: the transcript is bounded like every other buffer. +/// Output kept per transcript record; the rest is dropped and flagged. +const MAX_RECORD_OUTPUT: usize = 64 * 1024; +/// Total bytes (commands + outputs) kept across untaken transcript records. +/// Oldest records drop first. +const MAX_TRANSCRIPT_BYTES: usize = 1024 * 1024; /// Terminal dimensions in character cells. #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -103,6 +124,95 @@ pub enum TerminalStatus { Exited(i32), } +/// What the session is doing, from [`Terminal::activity`]. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum TerminalActivity { + /// Session not started yet (before the first `run_until_idle`). + Starting, + /// The shell prompt (`PS1`) is waiting for a command line. + Prompt, + /// A command is incomplete and the `PS2` prompt waits for more lines. + ContinuationPrompt, + /// A command line is running, for example `vi notes.txt` waiting for keys. + Running { + /// The full command line, including continuation lines. + command: String, + }, + /// The shell exited with this code. + Exited(i32), +} + +/// One finished command line, from [`Terminal::take_transcript`]. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CommandRecord { + /// The command line as entered (multi-line input joined with `\n`). + pub command: String, + /// Combined stdout and stderr, in the order it was written, with plain + /// `\n` line endings. Full-screen programs (`vi`, `less`) draw directly to + /// the terminal and are not captured here. + pub output: String, + /// Output past 64 KiB was dropped from `output`. + pub output_truncated: bool, + /// Exit status (`130` when interrupted with Ctrl-C, `2` for a syntax error). + pub exit_code: i32, +} + +/// Session facts shared between the host handle and the shell loop. +#[derive(Default)] +struct SessionLog { + activity: Option, + records: VecDeque, + record_bytes: usize, +} + +#[derive(Clone, Default)] +struct SharedLog(Arc>); + +impl SharedLog { + fn lock(&self) -> std::sync::MutexGuard<'_, SessionLog> { + self.0.lock().unwrap_or_else(PoisonError::into_inner) + } + + fn set_activity(&self, activity: TerminalActivity) { + self.lock().activity = Some(activity); + } + + fn push(&self, record: CommandRecord) { + let mut log = self.lock(); + log.record_bytes += record.command.len() + record.output.len(); + log.records.push_back(record); + while log.record_bytes > MAX_TRANSCRIPT_BYTES { + let Some(old) = log.records.pop_front() else { + break; + }; + log.record_bytes -= old.command.len() + old.output.len(); + } + } +} + +/// Output being collected for the running command's transcript record. +#[derive(Default)] +struct OutputCapture { + text: String, + truncated: bool, +} + +impl OutputCapture { + fn append(&mut self, chunk: &str) { + let room = MAX_RECORD_OUTPUT.saturating_sub(self.text.len()); + if chunk.len() <= room { + self.text.push_str(chunk); + return; + } + let mut end = room; + while !chunk.is_char_boundary(end) { + end -= 1; + } + self.text.push_str(&chunk[..end]); + self.truncated = true; + } +} + type Session = Pin + Send>>; /// An interactive bash session attached to an in-memory terminal. @@ -113,6 +223,7 @@ pub struct Terminal { fs: Arc, session: Option, exit_code: Option, + log: SharedLog, } impl Terminal { @@ -144,12 +255,14 @@ impl Terminal { bash.input_wait_clock = Some(clock.clone()); let tty = Tty::new(size, clock, bash.cancellation_token()); let fs = bash.fs(); - let session: Session = Box::pin(shell_loop(bash, tty.clone(), exit)); + let log = SharedLog::default(); + let session: Session = Box::pin(shell_loop(bash, tty.clone(), exit, log.clone())); Self { tty, fs, session: Some(session), exit_code: None, + log, } } @@ -200,6 +313,43 @@ impl Terminal { text } + /// The screen plus up to 1000 lines of scrollback above it, as plain text: + /// one line per row, oldest first, trailing blanks and trailing empty rows + /// trimmed. While a full-screen program is open this covers its alternate + /// screen, which has no scrollback. + pub fn history_text(&self) -> String { + let rows = self.tty.history_rows(); + let mut text = rows + .iter() + .map(|r| r.trim_end()) + .collect::>() + .join("\n"); + text.truncate(text.trim_end().len()); + text + } + + /// Drain the commands that finished since the last call, oldest first, + /// with their exact output and exit codes. Keeps at most 1 MiB of + /// untaken records (oldest drop first). + pub fn take_transcript(&self) -> Vec { + let mut log = self.log.lock(); + log.record_bytes = 0; + log.records.drain(..).collect() + } + + /// What the session is doing right now: at a prompt, running a command + /// (such as an open `vi`), or exited. + pub fn activity(&self) -> TerminalActivity { + if let Some(code) = self.exit_code { + return TerminalActivity::Exited(code); + } + self.log + .lock() + .activity + .clone() + .unwrap_or(TerminalActivity::Starting) + } + /// Cursor position as `(row, col)`, zero-based. pub fn cursor(&self) -> (u16, u16) { self.tty.with_screen(|s| s.cursor_position()) @@ -252,13 +402,14 @@ struct ExitState { code: AtomicI32, } -async fn shell_loop(mut bash: crate::Bash, tty: Tty, exit: Arc) -> i32 { +async fn shell_loop(mut bash: crate::Bash, tty: Tty, exit: Arc, log: SharedLog) -> i32 { let cancel = bash.cancellation_token(); let mut last_exit = 0; if !bash.shell_state_view().env.contains_key("TERM") { bash.set_env("TERM", "xterm-256color"); } loop { + log.set_activity(TerminalActivity::Prompt); tty.write_cooked(prompt(&bash, "PS1", DEFAULT_PS1).as_bytes()); let mut input = match read_line(&tty).await { LineRead::Line(line) => line, @@ -272,17 +423,25 @@ async fn shell_loop(mut bash: crate::Bash, tty: Tty, exit: Arc) -> i3 continue; } + let capture = Arc::new(Mutex::new(OutputCapture::default())); last_exit = loop { + log.set_activity(TerminalActivity::Running { + command: input.clone(), + }); let size = tty.size(); bash.set_env("COLUMNS", &size.cols.to_string()); bash.set_env("LINES", &size.rows.to_string()); cancel.store(false, Ordering::Relaxed); tty.set_foreground(true); let out_tty = tty.clone(); + let out_capture = Arc::clone(&capture); let options = ExecOptions::new() .streaming(Box::new(move |stdout, stderr| { out_tty.write_cooked(stdout.as_bytes()); out_tty.write_cooked(stderr.as_bytes()); + let mut cap = out_capture.lock().unwrap_or_else(PoisonError::into_inner); + cap.append(stdout); + cap.append(stderr); })) .extensions(ExecutionExtensions::new().with(tty.clone())); let result = bash.exec_with_options(&input, options).await; @@ -298,9 +457,15 @@ async fn shell_loop(mut bash: crate::Bash, tty: Tty, exit: Arc) -> i3 Err(e) => { let msg = e.to_string(); if !is_incomplete_input(&msg) { - tty.write_cooked(format!("bash: {msg}\n").as_bytes()); + let line = format!("bash: {msg}\n"); + tty.write_cooked(line.as_bytes()); + capture + .lock() + .unwrap_or_else(PoisonError::into_inner) + .append(&line); break 2; } + log.set_activity(TerminalActivity::ContinuationPrompt); tty.write_cooked(prompt(&bash, "PS2", DEFAULT_PS2).as_bytes()); match read_line(&tty).await { LineRead::Line(next) => { @@ -314,7 +479,19 @@ async fn shell_loop(mut bash: crate::Bash, tty: Tty, exit: Arc) -> i3 } }; - if exit.requested.load(Ordering::SeqCst) { + let exited = exit.requested.load(Ordering::SeqCst); + let cap = std::mem::take(&mut *capture.lock().unwrap_or_else(PoisonError::into_inner)); + log.push(CommandRecord { + command: input, + output: cap.text, + output_truncated: cap.truncated, + exit_code: if exited { + exit.code.load(Ordering::SeqCst) + } else { + last_exit + }, + }); + if exited { return exit.code.load(Ordering::SeqCst); } } @@ -637,6 +814,119 @@ mod tests { assert!(text.ends_with("^C\n$"), "{text}"); } + #[tokio::test] + async fn transcript_records_commands_output_and_exit_codes() { + let mut term = Terminal::new(Bash::builder()); + run(&mut term, "echo hi; echo err >&2\r").await; + run(&mut term, "false\r").await; + run(&mut term, "for i in 1 2; do\r").await; + run(&mut term, "echo n$i; done\r").await; + run(&mut term, "echo )\r").await; + let records = term.take_transcript(); + let summary: Vec<_> = records + .iter() + .map(|r| (r.command.as_str(), r.output.as_str(), r.exit_code)) + .collect(); + assert_eq!(summary[0], ("echo hi; echo err >&2", "hi\nerr\n", 0)); + assert_eq!(summary[1], ("false", "", 1)); + assert_eq!( + summary[2], + ("for i in 1 2; do\necho n$i; done", "n1\nn2\n", 0) + ); + assert_eq!(summary[3].2, 2); + assert!(summary[3].1.starts_with("bash: "), "{:?}", summary[3]); + assert_eq!(records.len(), 4); + assert!(term.take_transcript().is_empty(), "drained"); + } + + #[tokio::test] + async fn transcript_marks_interrupt_and_exit() { + let mut term = Terminal::new(Bash::builder()); + term.send("sleep 0.3; echo after\r"); + let _ = + tokio::time::timeout(std::time::Duration::from_millis(50), term.run_until_idle()).await; + run(&mut term, "\x03").await; + run(&mut term, "exit 4\r").await; + let records = term.take_transcript(); + assert_eq!(records[0].exit_code, 130); + assert_eq!(records[1].command, "exit 4"); + assert_eq!(records[1].exit_code, 4); + } + + #[tokio::test] + async fn transcript_output_is_capped_per_record() { + let mut term = Terminal::new(Bash::builder()); + run(&mut term, "seq 1 30000\r").await; + let record = term.take_transcript().remove(0); + assert!(record.output_truncated); + assert_eq!(record.output.len(), MAX_RECORD_OUTPUT); + assert!(record.output.starts_with("1\n2\n")); + } + + #[test] + fn transcript_total_size_is_bounded() { + let log = SharedLog::default(); + for i in 0..40 { + log.push(CommandRecord { + command: format!("cmd{i}"), + output: "x".repeat(MAX_RECORD_OUTPUT), + output_truncated: false, + exit_code: 0, + }); + } + let log = log.lock(); + assert!(log.record_bytes <= MAX_TRANSCRIPT_BYTES); + assert_eq!(log.records.back().unwrap().command, "cmd39"); + assert!(log.records.front().unwrap().command != "cmd0"); + } + + #[tokio::test] + async fn activity_tracks_prompt_program_and_exit() { + let mut term = Terminal::new(Bash::builder()); + assert_eq!(term.activity(), TerminalActivity::Starting); + term.run_until_idle().await; + assert_eq!(term.activity(), TerminalActivity::Prompt); + run(&mut term, "vi /tmp/a.txt\r").await; + assert_eq!( + term.activity(), + TerminalActivity::Running { + command: "vi /tmp/a.txt".into() + } + ); + run(&mut term, ":q\r").await; + assert_eq!(term.activity(), TerminalActivity::Prompt); + run(&mut term, "if true; then\r").await; + assert_eq!(term.activity(), TerminalActivity::ContinuationPrompt); + run(&mut term, "echo in; fi\r").await; + run(&mut term, "exit 3\r").await; + assert_eq!(term.activity(), TerminalActivity::Exited(3)); + } + + #[tokio::test] + async fn history_text_keeps_lines_scrolled_off_screen() { + let mut term = Terminal::with_size(Bash::builder(), TerminalSize::new(5, 40)); + run(&mut term, "seq 1 50\r").await; + let screen = term.screen_text(); + assert!(!screen.contains("\n10\n"), "{screen}"); + let history = term.history_text(); + assert!(history.starts_with("$ seq 1 50\n1\n2\n"), "{history}"); + assert!(history.ends_with("49\n50\n$"), "{history}"); + assert_eq!(history.lines().count(), 52); + // Reading history leaves the live view untouched. + assert_eq!(term.screen_text(), screen); + run(&mut term, "echo next\r").await; + assert!(term.screen_text().ends_with("next\n$")); + } + + #[tokio::test] + async fn history_is_bounded() { + let mut term = Terminal::with_size(Bash::builder(), TerminalSize::new(5, 20)); + run(&mut term, "seq 1 3000\r").await; + let history = term.history_text(); + assert_eq!(history.lines().count(), tty::SCROLLBACK_LINES + 5); + assert!(history.ends_with("3000\n$"), "{history}"); + } + #[test] fn prompt_escape_parsing_falls_back_on_unknown() { let bash = Bash::builder().env("PS1", "a\\qb\\").build(); diff --git a/crates/bashkit/src/terminal/tty.rs b/crates/bashkit/src/terminal/tty.rs index de8ce6986..a15025db6 100644 --- a/crates/bashkit/src/terminal/tty.rs +++ b/crates/bashkit/src/terminal/tty.rs @@ -20,6 +20,8 @@ pub(crate) const MAX_PENDING_INPUT: usize = 1024 * 1024; /// Cap on raw output retained for `take_output`. Oldest bytes drop first; the /// screen model still sees every byte. pub(crate) const MAX_PENDING_OUTPUT: usize = 4 * 1024 * 1024; +/// Lines of primary-screen history kept for `history_text`. +pub(crate) const SCROLLBACK_LINES: usize = 1000; /// What a blocking terminal read returned. #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -67,7 +69,7 @@ impl Tty { foreground: false, resized: false, closed: false, - screen: vt100::Parser::new(size.rows, size.cols, 0), + screen: vt100::Parser::new(size.rows, size.cols, SCROLLBACK_LINES), output: Vec::new(), }), input_ready: Notify::new(), @@ -148,6 +150,30 @@ impl Tty { f(self.lock().screen.screen()) } + /// Every retained row of the current screen buffer, oldest first: + /// scrollback history, then the visible rows. One string per row. + pub(crate) fn history_rows(&self) -> Vec { + let mut st = self.lock(); + let screen = st.screen.screen_mut(); + let (rows, cols) = screen.size(); + let rows = usize::from(rows); + screen.set_scrollback(usize::MAX); + let total = screen.scrollback(); + let mut out = Vec::with_capacity(total + rows); + // At offset `off` the view starts `off` rows into history; take the + // history part of each page until the live screen is reached. + let mut off = total; + while off > 0 { + screen.set_scrollback(off); + let take = off.min(rows); + out.extend(screen.rows(0, cols).take(take)); + off -= take; + } + screen.set_scrollback(0); + out.extend(screen.rows(0, cols)); + out + } + // ---- device side ----------------------------------------------------- pub(crate) fn size(&self) -> TerminalSize { diff --git a/docs/terminal.md b/docs/terminal.md index 3a3cf4b5f..344783f50 100644 --- a/docs/terminal.md +++ b/docs/terminal.md @@ -94,6 +94,9 @@ it again. | Method | Returns | |--------|---------| | `screen_text()` | The visible screen as plain text, one line per row, trailing blanks trimmed | +| `history_text()` | The screen plus up to 1000 lines of scrollback above it, same format | +| `take_transcript()` | Commands finished since the last call: command line, exact output, exit code | +| `activity()` | `Prompt`, `ContinuationPrompt`, `Running { command }` (for example an open `vi`) or `Exited(code)` | | `cursor()` | Cursor `(row, col)`, zero-based | | `is_alternate_screen()` | `true` while a full-screen program such as `vi` is open | | `take_output()` | Raw bytes (with escape sequences) produced since the last call, for a renderer like xterm.js | @@ -103,6 +106,28 @@ it again. For most agent use, `screen_text()` plus `fs()` is all you need: the screen shows what happened, and the filesystem holds what was saved. +When an agent needs exact results instead of screen text, use the transcript. +Each `CommandRecord` holds the command line, its combined stdout and stderr +with plain `\n` line endings, and its exit code (`130` after Ctrl-C, `2` for a +syntax error). Output that scrolled off the screen is still there, and no +prompt scraping is needed: + +```rust +let mut term = Terminal::new(Bash::builder()); +term.send("ls /nope\r"); +term.run_until_idle().await; + +let record = &term.take_transcript()[0]; +assert_eq!(record.command, "ls /nope"); +assert_ne!(record.exit_code, 0); +assert_eq!(term.activity(), TerminalActivity::Prompt); +``` + +Full-screen programs (`vi`, `less`) draw straight to the terminal, so their +screens are not in the transcript; read them with `screen_text()`. Each record +keeps up to 64 KiB of output (`output_truncated` says when more was dropped), +and untaken records are capped at 1 MiB in total, oldest first. + ### Sending keys | Key | Bytes | diff --git a/knowledge/integrations/in-process-terminal.md b/knowledge/integrations/in-process-terminal.md index 1a8830b7e..b74ed8a2f 100644 --- a/knowledge/integrations/in-process-terminal.md +++ b/knowledge/integrations/in-process-terminal.md @@ -70,6 +70,19 @@ normal command output is streamed per command. `Tty` is `pub(crate)`: only internal builtins can be full-screen programs for now. Exposing it to custom builtins is a deliberate later step. +## Decision: three read paths for agents + +`screen_text()` (what a person sees), `history_text()` (screen plus 1000 rows +of vt100 scrollback, read by paging the scrollback offset and restoring it), +and `take_transcript()` (one `CommandRecord` per finished command line: +command, exact stdout+stderr from the streaming callback, exit code). +`activity()` reports `Prompt` / `ContinuationPrompt` / `Running { command }` / +`Exited`, set by the shell loop. Why: agents should not parse prompts out of +screen text to learn exit codes or recover scrolled-off output. Full-screen +programs write to the device, not the streaming callback, so they never land +in the transcript. Transcript is drained (like `take_output`) and bounded +(64 KiB output per record, 1 MiB total, oldest dropped). + ## Decision: line discipline split - Cooked mode (prompt): the shell loop's own small line editor (echo, Backspace, @@ -127,7 +140,7 @@ redirected (L-TERM-004). ## Tests - Unit: `terminal::tests` (line discipline, prompts, resize, exit, timeout - exclusion, Ctrl-C) `builtins::vi::tests` (editing commands end-to-end + exclusion, Ctrl-C, transcript, activity, scrollback bounds) `builtins::vi::tests` (editing commands end-to-end through a `Terminal`), and `builtins::pager::tests` (paging, search, and `pagers_are_cat_like_without_terminal`). - Integration: `tests/integration/terminal_tests.rs` (agent-style config edit, diff --git a/knowledge/security/threat-model.md b/knowledge/security/threat-model.md index cfce40849..8f9fcfd83 100644 --- a/knowledge/security/threat-model.md +++ b/knowledge/security/threat-model.md @@ -285,7 +285,7 @@ runaway scripts without permanently breaking the session. | TM-DOS-116 | AWK `close()` recycles getline read quotas | Repeated `getline x < file; close(file)` reloads and rematerializes the same large VFS file while retained-cache accounting returns to zero | Every cache miss monotonically charges the shared aggregate-input budget and byte-proportional work before decoding/materializing lines; `close()` still releases retained cache bytes but cannot refund either charge. Regression: `execution_budget_tests::awk_getline_close_cannot_recycle_input_budget` | **MITIGATED** | | TM-DOS-117 | Newline-dense `head`/`tail` line selection | A small line request over a newline-dense file (`head -n 1` on 1 MB of newlines) built a `Vec` offset for every input line, so a bounded request allocated memory proportional to the whole input | `select_lines` scans for only the requested boundary, forward for `First`/`From` and backward for `Last`/`AllButLast`, and stores no offset collection; work is bounded by the distance to that boundary rather than by input size. Regression tests: `headtail_allocation_tests` counts allocations (13.6 MB peak before, 1.0 MB after, for `head -n 1` over 1 MB of newlines); `headtail::tests::tm_dos_117_newline_dense_selection_finds_boundaries` and `headtail_resource_tests` cover boundary correctness | **MITIGATED** | | TM-DOS-118 | Python read-only self-mount recursion | `bash.mount("/", bash.fs(), read_only=True)` wraps the live filesystem before the core pointer-identity guard, then filesystem reads recurse until stack exhaustion | `Bash.mount()` and `BashTool.mount()` compare the resolved source with the live filesystem before constructing `ReadOnlyFs`; regression tests cover both Python surfaces | **MITIGATED** | -| TM-DOS-119 | Interactive terminal resource exhaustion | A host or script floods the in-process `Terminal` (`terminal` feature): unbounded typed-ahead input, output nobody drains, or a `vi` buffer grown by pastes, `p` and `:s` until memory runs out | Unread input is capped at 1 MiB (`send` returns the accepted count), retained raw output at the newest 4 MiB, the cooked-mode line at 64 KiB, the `vi` buffer at 8 MiB (load, insert, put and substitute check it), and `vi` undo/redo history at 16 MiB total; `vi` patterns use `regex` with a 1 MiB compiled-size limit. `:w` goes through the session VFS, so VFS limits and read-only mounts apply. Regressions: `terminal::tests::input_backlog_is_capped`, `builtins::vi::tests::buffer_cap_stops_inserts`, `builtins::vi::tests::readonly_vfs_write_error_keeps_editor_open` | **MITIGATED** | +| TM-DOS-119 | Interactive terminal resource exhaustion | A host or script floods the in-process `Terminal` (`terminal` feature): unbounded typed-ahead input, output nobody drains, or a `vi` buffer grown by pastes, `p` and `:s` until memory runs out | Unread input is capped at 1 MiB (`send` returns the accepted count), retained raw output at the newest 4 MiB, scrollback at 1000 rows, untaken transcript records at 1 MiB total (64 KiB of output per record, oldest records drop first), the cooked-mode line at 64 KiB, the `vi` buffer at 8 MiB (load, insert, put and substitute check it), and `vi` undo/redo history at 16 MiB total; `vi` patterns use `regex` with a 1 MiB compiled-size limit. `:w` goes through the session VFS, so VFS limits and read-only mounts apply. Regressions: `terminal::tests::input_backlog_is_capped`, `terminal::tests::transcript_output_is_capped_per_record`, `terminal::tests::transcript_total_size_is_bounded`, `terminal::tests::history_is_bounded`, `builtins::vi::tests::buffer_cap_stops_inserts`, `builtins::vi::tests::readonly_vfs_write_error_keeps_editor_open` | **MITIGATED** | | TM-DOS-120 | Interactive input wait vs. execution timeout | Excluding keyboard waits from `ExecutionLimits::timeout` (so a `vi` session is not killed while a person or agent thinks) must not let busy work escape the deadline | Only time blocked in the terminal device read is excluded (`InputWaitClock`); the timer re-arms with the remaining *active* budget, so CPU work, `sleep`, and pending callbacks still time out. Only a `Terminal` installs the clock; plain `exec()` is unchanged. Regressions: `terminal::tests::waiting_for_input_does_not_count_toward_timeout`, `terminal::tests::busy_work_still_times_out` | **MITIGATED** | | TM-DOS-101 | yq structured-data amplification | YAML aliases can expand exponentially during deserialization; deep YAML/JSON, document floods, jaq generators, and YAML re-serialization can consume stack, CPU, or memory beyond the source size | Reject YAML alias tokens with a bounded lexical pass before deserialization; VFS/stdin and aggregate input budgets; serde_yaml_ng recursion cap 128 plus Bashkit depth 100; 4096-document cap; shared jaq work/deadline/output limits; post-serialization stdout cap; YAML+JSON depth/document/input/output regressions plus `yq_fuzz` and arbitrary-input proptest | **MITIGATED** | | TM-DOS-107 | Static-analysis command validation amplification | A large comment before thousands of commands makes a whole-script ordered-subsequence scan per command | Build one source-character position index, then validate each analyzed command-name character with a logarithmic position lookup instead of rescanning source | **MITIGATED** |