use std::io::{BufRead, IsTerminal, Write};
use basis::{ApprovalAnswer, ApprovalDecision, ApprovalRequest, Approver};
#[derive(Debug, Default)]
pub struct TerminalApprover;
impl TerminalApprover {
pub fn new() -> Self {
Self
}
}
fn refused(request: &ApprovalRequest, why: &str) -> ApprovalAnswer {
ApprovalAnswer::new(ApprovalDecision::Deny).because(format!(
"{} needs approval and {why}, so this run cannot allow it",
request.tool_name
))
}
fn ask(request: &ApprovalRequest) -> std::io::Result<ApprovalAnswer> {
let mut stderr = std::io::stderr();
writeln!(stderr)?;
writeln!(stderr, " basis: {} wants to run", request.tool_name)?;
writeln!(stderr, " {}", request.description)?;
if let Some(summary) = summarize_input(&request.input) {
writeln!(stderr, " {summary}")?;
}
write!(stderr, " allow? [y]es / [n]o / [a]lways / neve[r]: ")?;
stderr.flush()?;
let mut answer = String::new();
std::io::stdin().lock().read_line(&mut answer)?;
Ok(match answer.trim().to_ascii_lowercase().as_str() {
"y" | "yes" => ApprovalDecision::Allow.into(),
"a" | "always" => ApprovalDecision::AllowForSession.into(),
"r" | "never" => ApprovalAnswer::new(ApprovalDecision::DenyForSession).because(format!(
"{} was refused at the prompt, for the rest of this run",
request.tool_name
)),
_ => ApprovalAnswer::new(ApprovalDecision::Deny)
.because(format!("{} was refused at the prompt", request.tool_name)),
})
}
#[async_trait::async_trait]
impl Approver for TerminalApprover {
async fn approve(&mut self, request: &ApprovalRequest) -> ApprovalAnswer {
if !std::io::stdin().is_terminal() {
return refused(request, "there is no terminal to ask at");
}
let asked = request.clone();
match tokio::task::spawn_blocking(move || ask(&asked)).await {
Ok(Ok(answer)) => answer,
Ok(Err(_)) => refused(request, "the terminal could not be read"),
Err(_) => refused(request, "the question could not be put to the terminal"),
}
}
}
fn summarize_input(input: &serde_json::Value) -> Option<String> {
if let Some(command) = input.get("command").and_then(|value| value.as_str()) {
return Some(truncate(command, 160));
}
let object = input.as_object()?;
if object.is_empty() {
return None;
}
let keys: Vec<&str> = object.keys().map(String::as_str).collect();
Some(truncate(&keys.join(", "), 160))
}
fn truncate(text: &str, limit: usize) -> String {
let trimmed = text.trim();
if trimmed.chars().count() <= limit {
return trimmed.to_string();
}
let kept: String = trimmed.chars().take(limit).collect();
format!("{kept}…")
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn request(input: serde_json::Value) -> ApprovalRequest {
ApprovalRequest {
request_id: "r1".to_string(),
tool_call_id: "tc-1".to_string(),
tool_name: "shell".to_string(),
description: "wants to run a command".to_string(),
input,
}
}
#[tokio::test]
async fn with_no_terminal_the_request_is_denied() {
let mut approver = TerminalApprover::new();
let answer = approver.approve(&request(json!({}))).await;
assert_eq!(
answer.decision,
ApprovalDecision::Deny,
"an unattended run must fail safe rather than grant what it cannot ask about"
);
assert_eq!(
answer.reason.as_deref(),
Some(
"shell needs approval and there is no terminal to ask at, \
so this run cannot allow it"
),
"the model should read why nobody could answer, not just that nobody did"
);
}
#[test]
fn a_shell_command_is_shown_verbatim() {
let summary = summarize_input(&json!({"command": "rm -rf build", "cwd": "/repo"}))
.expect("a command is worth showing");
assert_eq!(summary, "rm -rf build");
}
#[test]
fn other_inputs_collapse_to_their_keys() {
let summary = summarize_input(&json!({"operations": [], "workingDirectory": "/repo"}))
.expect("keys are enough to tell calls apart");
assert!(summary.contains("operations"));
assert!(summary.contains("workingDirectory"));
}
#[test]
fn an_empty_input_says_nothing() {
assert_eq!(summarize_input(&json!({})), None);
assert_eq!(summarize_input(&json!("not an object")), None);
}
#[test]
fn a_long_command_is_cut_rather_than_flooding_the_prompt() {
let long = "echo ".to_string() + &"x".repeat(500);
let summary = summarize_input(&json!({ "command": long })).expect("a summary");
assert!(summary.chars().count() <= 161, "160 plus the ellipsis");
assert!(summary.ends_with('…'));
}
}