Skip to main content

speccoder_runtime/approvals/
present.rs

1//! What a person sees when a call needs them: built live from the
2//! journaled [`Request`] and what only the live run knows — the file as it
3//! is now, the sandbox — and never journaled itself, so replay does not
4//! depend on the worktree. The terminal prompt, editors (ACP), the
5//! `approvals` listing and the dashboard all render this one value.
6
7use std::path::PathBuf;
8
9use serde::{Deserialize, Serialize};
10
11use super::Request;
12
13/// Lines of a body shown before "v to view all".
14pub const HEAD: usize = 20;
15
16/// Files larger than this are described, not shown.
17const MAX_BYTES: usize = 1024 * 1024;
18
19/// What only the live run knows.
20#[derive(Debug, Clone, Default, PartialEq, Eq)]
21pub struct PresentContext {
22    /// The worktree, to read the file a write or edit changes.
23    pub root: Option<PathBuf>,
24    /// The sandbox mechanism (`Seatbelt`, `Landlock+seccomp`); `None`: off.
25    pub sandbox: Option<String>,
26    /// What a command's network is without asking (`none`, `loopback`).
27    pub network: String,
28    /// The file's text, given instead of read from `root` (a saved attempt's).
29    pub file_text: Option<String>,
30}
31
32/// One choice: `once`, `task`, `run`, `reject` or `stop`.
33#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
34pub struct Choice {
35    /// Its name.
36    pub choice: String,
37    /// Its key (`y`, `t`, `r`, `n`, `s`).
38    pub key: char,
39    /// Its number, fixed whether or not the others are offered.
40    pub number: u8,
41    /// What it does, the scope included.
42    pub label: String,
43}
44
45/// The call as a person sees it.
46#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
47pub struct Presentation {
48    /// The question.
49    pub request: Request,
50    /// `Write this file?`, `Edit this file?`, `Run this command?`,
51    /// `Use the network?`, `Call MCP tool?`.
52    pub title: String,
53    /// The path, the whole command, or the MCP tool — never cut.
54    pub subject: String,
55    /// The body as shown: a diff, a file's head, the command, arguments.
56    pub body: Vec<String>,
57    /// The whole body (`v`).
58    pub full: Vec<String>,
59    /// What the call can reach.
60    pub sandbox: String,
61    /// The choices, in order.
62    pub options: Vec<Choice>,
63    /// What an approval for the task or run covers (`this path, any content`).
64    pub scope: String,
65    /// The file's text before and after, for editors that show a diff.
66    pub old_text: Option<String>,
67    /// The file's text after the call.
68    pub new_text: Option<String>,
69    /// The file's absolute path, for a write or edit in a known worktree.
70    pub file: Option<String>,
71}
72
73/// Model text made safe for a terminal: escape (`\x1b`) as `␛`, other
74/// control characters as `^X`; newlines and tabs kept.
75#[must_use]
76pub fn escape(text: &str) -> String {
77    text.chars()
78        .map(|c| match c {
79            '\x1b' => "␛".to_owned(),
80            '\n' | '\t' => c.to_string(),
81            c if (c as u32) < 0x20 => format!("^{}", char::from(b'@' + c as u8)),
82            '\x7f' => "^?".to_owned(),
83            c => c.to_string(),
84        })
85        .collect()
86}
87
88/// A word an argv element needs no quotes for.
89fn plain(a: &str) -> bool {
90    !a.is_empty()
91        && a.chars()
92            .all(|c| c.is_ascii_alphanumeric() || "-_./=:,+@%".contains(c))
93}
94
95/// The argv as a person would type it.
96#[must_use]
97pub fn shell_quote(argv: &[String]) -> String {
98    argv.iter()
99        .map(|a| {
100            if plain(a) {
101                a.clone()
102            } else {
103                format!("'{}'", a.replace('\'', "'\\''"))
104            }
105        })
106        .collect::<Vec<_>>()
107        .join(" ")
108}
109
110/// What "for the task" or "for this run" covers, by kind.
111#[must_use]
112pub fn scope_text(kind: &str) -> &'static str {
113    match kind {
114        "run" => "this exact command",
115        "network" => "this exact command with the network",
116        "mcp" => "any call of this MCP tool, any arguments",
117        _ => "this path, any content",
118    }
119}
120
121fn head(lines: &[String]) -> (Vec<String>, usize) {
122    let shown = lines.iter().take(HEAD).cloned().collect();
123    (shown, lines.len().saturating_sub(HEAD))
124}
125
126/// Numbered lines of a new file.
127fn numbered(text: &str) -> Vec<String> {
128    text.lines()
129        .enumerate()
130        .map(|(i, l)| format!("{:>5} │ {}", i + 1, escape(l)))
131        .collect()
132}
133
134/// A unified diff with three lines of context and old/new line numbers.
135#[must_use]
136pub fn diff(old: &str, new: &str) -> Vec<String> {
137    let d = similar::TextDiff::from_lines(old, new);
138    let mut out = Vec::new();
139    for (i, group) in d.grouped_ops(3).iter().enumerate() {
140        if i > 0 {
141            out.push("          ┈".to_owned());
142        }
143        for op in group {
144            for change in d.iter_changes(op) {
145                let (sign, old_no, new_no) = match change.tag() {
146                    similar::ChangeTag::Equal => (' ', change.old_index(), change.new_index()),
147                    similar::ChangeTag::Delete => ('-', change.old_index(), None),
148                    similar::ChangeTag::Insert => ('+', None, change.new_index()),
149                };
150                let no = |n: Option<usize>| n.map_or(String::new(), |n| (n + 1).to_string());
151                let line = format!(
152                    "{:>5} {:>5} {sign} {}",
153                    no(old_no),
154                    no(new_no),
155                    escape(change.value().trim_end_matches(['\n', '\r']))
156                );
157                out.push(line.trim_end().to_owned());
158            }
159        }
160    }
161    out
162}
163
164/// The text of `path` under `root`, or why it cannot be shown.
165fn read(context: &PresentContext, path: &str) -> Result<Option<String>, String> {
166    if let Some(text) = &context.file_text {
167        return Ok(Some(text.clone()));
168    }
169    let Some(root) = context.root.as_deref() else {
170        return Ok(None);
171    };
172    let file = root.join(path);
173    let Ok(bytes) = std::fs::read(&file) else {
174        return Ok(None);
175    };
176    if bytes.len() > MAX_BYTES {
177        return Err(format!("{} bytes — too large to show", bytes.len()));
178    }
179    String::from_utf8(bytes)
180        .map(Some)
181        .map_err(|_| "a binary file — not shown".to_owned())
182}
183
184/// Builds what the person sees.
185#[must_use]
186pub fn present(request: &Request, context: &PresentContext) -> Presentation {
187    let args = &request.arguments;
188    let s = |k: &str| args[k].as_str().unwrap_or_default().to_owned();
189    let path = s("path");
190    let kind = request.kind.as_str();
191    let mut old_text = None;
192    let mut new_text = None;
193    let (title, subject, full) = match kind {
194        "run" | "network" => {
195            let argv: Vec<String> = args["argv"]
196                .as_array()
197                .into_iter()
198                .flatten()
199                .filter_map(|v| v.as_str().map(str::to_owned))
200                .collect();
201            let cmd = escape(&shell_quote(&argv));
202            let cwd = args["cwd"]
203                .as_str()
204                .filter(|c| !c.is_empty())
205                .unwrap_or(".");
206            let net = if kind == "network" {
207                "this call".to_owned()
208            } else {
209                context.network.clone()
210            };
211            (
212                if kind == "network" {
213                    "Use the network?"
214                } else {
215                    "Run this command?"
216                },
217                cmd.clone(),
218                vec![format!("$ {cmd}"), format!("cwd: {cwd} · network: {net}")],
219            )
220        }
221        "mcp" => {
222            let pretty = serde_json::to_string_pretty(args).unwrap_or_default();
223            (
224                "Call MCP tool?",
225                request.tool.clone(),
226                pretty.lines().map(escape).collect(),
227            )
228        }
229        _ => {
230            let current = read(context, &path);
231            // A write over a file that exists now is an edit of it.
232            let title = if kind == "write" && matches!(current, Ok(None)) {
233                "Write this file?"
234            } else {
235                "Edit this file?"
236            };
237            let lines = match current {
238                Err(note) => vec![note],
239                Ok(current) => match (request.tool.as_str(), current) {
240                    ("write_file", None) => {
241                        let content = s("content");
242                        new_text = Some(content.clone());
243                        numbered(&content)
244                    }
245                    ("write_file", Some(old)) => {
246                        let content = s("content");
247                        let d = diff(&old, &content);
248                        old_text = Some(old);
249                        new_text = Some(content);
250                        d
251                    }
252                    (_, Some(old)) => {
253                        let (from, to) = (s("old_string"), s("new_string"));
254                        let all = args["replace_all"] == true;
255                        if !from.is_empty() && old.contains(&from) {
256                            let new = if all {
257                                old.replace(&from, &to)
258                            } else {
259                                old.replacen(&from, &to, 1)
260                            };
261                            let d = diff(&old, &new);
262                            old_text = Some(old);
263                            new_text = Some(new);
264                            d
265                        } else {
266                            let mut l = vec![
267                                "the text to replace is not in the file as it is now:".to_owned(),
268                            ];
269                            l.extend(from.lines().map(|x| format!("  - {}", escape(x))));
270                            l.extend(to.lines().map(|x| format!("  + {}", escape(x))));
271                            l
272                        }
273                    }
274                    (_, None) => {
275                        let (from, to) = (s("old_string"), s("new_string"));
276                        let mut l: Vec<String> =
277                            from.lines().map(|x| format!("  - {}", escape(x))).collect();
278                        l.extend(to.lines().map(|x| format!("  + {}", escape(x))));
279                        l
280                    }
281                },
282            };
283            (title, escape(&path), lines)
284        }
285    };
286    let (mut body, more) = head(&full);
287    if more > 0 {
288        body.push(format!("… {more} more lines (v to view all)"));
289    }
290    let sandbox = match kind {
291        "run" | "network" => match &context.sandbox {
292            Some(m) => format!(
293                "sandbox on ({m}) · network: {} · writes: worktree",
294                if kind == "network" {
295                    "this call"
296                } else {
297                    context.network.as_str()
298                }
299            ),
300            None => "sandbox off — runs unconfined".to_owned(),
301        },
302        "mcp" => "the MCP server runs outside speccoder's sandbox".to_owned(),
303        _ => "a file write — nothing is executed".to_owned(),
304    };
305    let scope = scope_text(kind).to_owned();
306    let task = request.task.rsplit('/').next().unwrap_or(&request.task);
307    let options = request
308        .options
309        .iter()
310        .filter_map(|o| {
311            let (key, number, label) = match o.as_str() {
312                "once" => ('y', 1, "Yes, once".to_owned()),
313                "task" => ('t', 2, format!("Yes, for the rest of {task}: {scope}")),
314                "run" => ('r', 3, format!("Yes, for this run: {scope}, in any task")),
315                "reject" => ('n', 4, "No, and tell the model why …".to_owned()),
316                "stop" => (
317                    's',
318                    5,
319                    "Stop this task here (it waits; decide later)".to_owned(),
320                ),
321                _ => return None,
322            };
323            Some(Choice {
324                choice: o.clone(),
325                key,
326                number,
327                label,
328            })
329        })
330        .collect();
331    Presentation {
332        request: request.clone(),
333        title: title.to_owned(),
334        subject,
335        body,
336        full,
337        sandbox,
338        options,
339        scope,
340        old_text,
341        new_text,
342        file: context
343            .root
344            .as_ref()
345            .filter(|_| !path.is_empty() && matches!(kind, "write" | "edit"))
346            .map(|r| r.join(&path).display().to_string()),
347    }
348}
349
350#[cfg(test)]
351mod tests {
352    use super::*;
353    use serde_json::json;
354    use std::path::Path;
355
356    fn request(tool: &str, arguments: serde_json::Value, kind: &str, risk: &str) -> Request {
357        Request {
358            task: "001-slugify/T004".to_owned(),
359            task_title: "Handle Unicode input".to_owned(),
360            turn: 8,
361            tool: tool.to_owned(),
362            arguments,
363            kind: kind.to_owned(),
364            reason: "why".to_owned(),
365            covers: "c".to_owned(),
366            risk: risk.to_owned(),
367            claim: None,
368            options: crate::approvals::options(risk, true),
369        }
370    }
371
372    fn ctx(root: &Path) -> PresentContext {
373        PresentContext {
374            root: Some(root.to_path_buf()),
375            sandbox: Some("Seatbelt".to_owned()),
376            network: "none".to_owned(),
377            file_text: None,
378        }
379    }
380
381    #[test]
382    fn a_new_file_shows_its_head() {
383        let d = tempfile::tempdir().unwrap();
384        let content: String = (1..=25).map(|i| format!("line {i}\n")).collect();
385        let p = present(
386            &request(
387                "write_file",
388                json!({ "path": "src/new.rs", "content": content }),
389                "write",
390                "low",
391            ),
392            &ctx(d.path()),
393        );
394        assert_eq!(
395            (p.title.as_str(), p.subject.as_str()),
396            ("Write this file?", "src/new.rs")
397        );
398        assert_eq!(p.body[0], "    1 │ line 1");
399        assert_eq!(p.body.last().unwrap(), "… 5 more lines (v to view all)");
400        assert_eq!(p.full.len(), 25);
401        assert_eq!(p.sandbox, "a file write — nothing is executed");
402        assert!(p.old_text.is_none() && p.new_text.is_some());
403    }
404
405    #[test]
406    fn an_edit_is_a_numbered_diff() {
407        let d = tempfile::tempdir().unwrap();
408        std::fs::write(
409            d.path().join("Cargo.toml"),
410            "[package]\nname = \"x\"\n\n[dependencies]\nserde = \"1\"\n",
411        )
412        .unwrap();
413        let p = present(
414            &request(
415                "edit_file",
416                json!({ "path": "Cargo.toml", "old_string": "serde = \"1\"\n", "new_string": "serde = \"1\"\nunicode-normalization = \"0.1\"\n" }),
417                "edit",
418                "medium",
419            ),
420            &ctx(d.path()),
421        );
422        assert_eq!(p.title, "Edit this file?");
423        assert!(
424            p.body
425                .contains(&"          6 + unicode-normalization = \"0.1\"".to_owned()),
426            "{:#?}",
427            p.body
428        );
429        assert!(
430            p.body.contains(&"    5     5   serde = \"1\"".to_owned()),
431            "{:#?}",
432            p.body
433        );
434        let labels: Vec<&str> = p.options.iter().map(|o| o.label.as_str()).collect();
435        assert_eq!(
436            labels[1],
437            "Yes, for the rest of T004: this path, any content"
438        );
439        assert_eq!(
440            labels[2],
441            "Yes, for this run: this path, any content, in any task"
442        );
443    }
444
445    #[test]
446    fn a_missing_old_string_is_said_and_shown() {
447        let d = tempfile::tempdir().unwrap();
448        std::fs::write(d.path().join("a.rs"), "fn a() {}\n").unwrap();
449        let p = present(
450            &request(
451                "edit_file",
452                json!({ "path": "a.rs", "old_string": "fn b() {}", "new_string": "fn c() {}" }),
453                "edit",
454                "low",
455            ),
456            &ctx(d.path()),
457        );
458        assert_eq!(
459            p.body[0],
460            "the text to replace is not in the file as it is now:"
461        );
462        assert_eq!(
463            p.body[1..],
464            ["  - fn b() {}".to_owned(), "  + fn c() {}".to_owned()]
465        );
466    }
467
468    #[test]
469    fn commands_network_and_mcp() {
470        let d = tempfile::tempdir().unwrap();
471        let p = present(
472            &request(
473                "run_command",
474                json!({ "argv": ["sh", "-c", "echo 'hi' > x"] }),
475                "run",
476                "high",
477            ),
478            &ctx(d.path()),
479        );
480        assert_eq!(p.title, "Run this command?");
481        assert_eq!(
482            p.body,
483            ["$ sh -c 'echo '\\''hi'\\'' > x'", "cwd: . · network: none"]
484        );
485        assert_eq!(
486            p.sandbox,
487            "sandbox on (Seatbelt) · network: none · writes: worktree"
488        );
489        // High risk: no "for this run"; the numbers stay fixed.
490        let numbers: Vec<u8> = p.options.iter().map(|o| o.number).collect();
491        assert_eq!(numbers, [1, 2, 4, 5]);
492        let p = present(
493            &request(
494                "run_command",
495                json!({ "argv": ["curl", "x"], "network": true }),
496                "network",
497                "high",
498            ),
499            &PresentContext {
500                sandbox: None,
501                ..ctx(d.path())
502            },
503        );
504        assert_eq!(p.title, "Use the network?");
505        assert_eq!(p.body[1], "cwd: . · network: this call");
506        assert_eq!(p.sandbox, "sandbox off — runs unconfined");
507        let p = present(
508            &request(
509                "mcp__gh__create_issue",
510                json!({ "title": "x" }),
511                "mcp",
512                "medium",
513            ),
514            &ctx(d.path()),
515        );
516        assert_eq!(
517            (p.title.as_str(), p.subject.as_str()),
518            ("Call MCP tool?", "mcp__gh__create_issue")
519        );
520        assert_eq!(p.sandbox, "the MCP server runs outside speccoder's sandbox");
521        assert!(
522            p.options[1]
523                .label
524                .ends_with("any call of this MCP tool, any arguments")
525        );
526    }
527
528    #[test]
529    fn model_text_cannot_drive_the_terminal() {
530        assert_eq!(escape("a\x1b[2Jb\x07c\nd"), "a␛[2Jb^Gc\nd");
531        let d = tempfile::tempdir().unwrap();
532        std::fs::write(d.path().join("bin"), [0u8, 159, 146, 150]).unwrap();
533        let p = present(
534            &request(
535                "write_file",
536                json!({ "path": "bin", "content": "x" }),
537                "edit",
538                "low",
539            ),
540            &ctx(d.path()),
541        );
542        assert_eq!(p.body, ["a binary file — not shown"]);
543    }
544}