Skip to main content

context_harness/agent_runtime/
terminal.rs

1//! A cancellable terminal-only approval reader. Piped stdin never grants consent.
2use super::policy::{ApprovalHandler, ApprovalRequest};
3use anyhow::Context;
4use async_trait::async_trait;
5
6pub(super) struct TerminalApprovals;
7#[async_trait]
8impl ApprovalHandler for TerminalApprovals {
9    async fn approve(&self, request: &ApprovalRequest) -> bool {
10        match read_approval(request).await {
11            Ok(granted) => granted,
12            Err(error) => {
13                eprintln!("Agent approval unavailable; denying invocation: {error:#}");
14                false
15            }
16        }
17    }
18}
19
20#[cfg(unix)]
21async fn read_approval(request: &ApprovalRequest) -> anyhow::Result<bool> {
22    use std::{fs::OpenOptions, io::IsTerminal, os::unix::fs::OpenOptionsExt};
23
24    if !std::io::stdin().is_terminal() || !std::io::stderr().is_terminal() {
25        eprintln!("Agent approval unavailable; stdin and stderr must both be terminals.");
26        return Ok(false);
27    }
28    let tty = OpenOptions::new()
29        .read(true)
30        .write(true)
31        .custom_flags(libc::O_NONBLOCK)
32        .open("/dev/tty")
33        .context("opening /dev/tty")?;
34    read_from_tty(tty, request).await
35}
36
37#[cfg(unix)]
38async fn read_from_tty(mut tty: std::fs::File, request: &ApprovalRequest) -> anyhow::Result<bool> {
39    use std::{
40        io::{Read, Write},
41        os::fd::AsRawFd,
42    };
43    // Discard queued answers before exposing a new invocation. Consent must be
44    // entered after this prompt, never reused from a previous pasted line.
45    if unsafe { libc::tcflush(tty.as_raw_fd(), libc::TCIFLUSH) } != 0 {
46        return Err(std::io::Error::last_os_error()).context("flushing queued terminal input");
47    }
48    // JSON escapes terminal control characters in untrusted model arguments.
49    let details = serde_json::json!({"run":request.run_id,"call":request.call_id,
50        "tool":request.tool,"capabilities":request.capabilities,"arguments":request.arguments});
51    let details: String = serde_json::to_string_pretty(&details)?
52        .chars()
53        .map(|c| {
54            if c == '\n' || (c.is_ascii() && !c.is_control()) {
55                c.to_string()
56            } else {
57                c.escape_default().to_string()
58            }
59        })
60        .collect();
61    // Never ask a user to approve incomplete/truncated invocation details.
62    if details.len() > 32 * 1024 {
63        eprintln!("Agent approval unavailable; invocation details exceed the display limit.");
64        return Ok(false);
65    }
66    let warning = if request.tool == "process.exec"
67        || request.tool.starts_with("runtime.mcp.start.")
68        || request.tool.starts_with("mcp.")
69    {
70        "\nThis process is not sandboxed: it can access files, credentials, network, and child processes with your user permissions."
71    } else {
72        ""
73    };
74    eprintln!("\nAgent requests approval:\n{details}{warning}\nAllow this invocation once? Type yes to approve; anything else denies:");
75    std::io::stderr().flush()?;
76    let mut answer = Vec::new();
77    loop {
78        let mut buf = [0u8; 128];
79        match tty.read(&mut buf) {
80            Ok(0) => {
81                eprintln!("Agent approval denied; terminal input closed before a response.");
82                return Ok(false);
83            }
84            Ok(n) => {
85                answer.extend_from_slice(&buf[..n]);
86                if answer.len() > 128 {
87                    return Ok(false);
88                }
89                if answer.contains(&b'\n') {
90                    return Ok(answer == b"yes\n" || answer == b"yes\r\n");
91                }
92            }
93            Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => {
94                // macOS kqueue cannot register every terminal device with
95                // AsyncFd. The descriptor is nonblocking, so poll it without
96                // tying up a runtime thread or leaving a blocking read behind
97                // if this approval future is cancelled.
98                tokio::time::sleep(std::time::Duration::from_millis(25)).await;
99            }
100            Err(error) if error.kind() == std::io::ErrorKind::Interrupted => continue,
101            Err(error) => return Err(error).context("reading terminal approval response"),
102        }
103    }
104}
105#[cfg(not(unix))]
106async fn read_approval(_: &ApprovalRequest) -> anyhow::Result<bool> {
107    // No cancellable native terminal adapter yet; fail closed on these hosts.
108    Ok(false)
109}
110
111#[cfg(all(test, unix))]
112mod tests {
113    use super::*;
114    use std::{fs::File, io::Write, os::fd::FromRawFd, time::Duration};
115    #[tokio::test]
116    async fn queued_answers_do_not_approve_a_new_invocation() {
117        let (mut master, mut slave) = (-1, -1);
118        assert_eq!(
119            unsafe {
120                libc::openpty(
121                    &mut master,
122                    &mut slave,
123                    std::ptr::null_mut(),
124                    std::ptr::null_mut(),
125                    std::ptr::null_mut(),
126                )
127            },
128            0
129        );
130        let mut master = unsafe { File::from_raw_fd(master) };
131        let tty = unsafe { File::from_raw_fd(slave) };
132        assert_ne!(
133            unsafe { libc::fcntl(slave, libc::F_SETFL, libc::O_NONBLOCK) },
134            -1
135        );
136        master.write_all(b"yes\nyes\n").unwrap();
137        // Give the line discipline time to queue the pasted answers.
138        tokio::time::sleep(Duration::from_millis(20)).await;
139        let request = ApprovalRequest {
140            run_id: "run".into(),
141            call_id: "call".into(),
142            tool: "workspace.patch".into(),
143            capabilities: vec![],
144            arguments: serde_json::json!({}),
145        };
146        let future = read_from_tty(tty, &request);
147        tokio::pin!(future);
148        assert!(tokio::time::timeout(Duration::from_millis(30), &mut future)
149            .await
150            .is_err());
151        master.write_all(b"yes\n").unwrap();
152        assert!(tokio::time::timeout(Duration::from_secs(1), future)
153            .await
154            .unwrap()
155            .unwrap());
156    }
157}