#[derive(Debug, Clone, PartialEq, Eq)]
pub enum WorkdirVerdict {
Proceed,
Confirm(WorkdirConcern),
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum WorkdirConcern {
HomeDirectory,
FilesystemRoot,
}
impl WorkdirConcern {
pub fn headline(&self) -> &'static str {
match self {
Self::HomeDirectory => "That is your home directory.",
Self::FilesystemRoot => "That is a filesystem root.",
}
}
pub fn detail(&self) -> &'static str {
match self {
Self::HomeDirectory => {
"An agent's file tools are confined to its workdir, so this run could read \
and write anything in your home - including SSH keys, browser data, and \
every other project you have."
}
Self::FilesystemRoot => {
"An agent's file tools are confined to its workdir, so this run would be \
confined to the whole machine."
}
}
}
}
pub fn assess(
workdir: &std::path::Path,
home: Option<&std::path::Path>,
allowed: &[String],
) -> WorkdirVerdict {
if allowed.iter().any(|a| is_within(workdir, a.as_ref())) {
return WorkdirVerdict::Proceed;
}
if workdir.parent().is_none() {
return WorkdirVerdict::Confirm(WorkdirConcern::FilesystemRoot);
}
if home.is_some_and(|h| h == workdir) {
return WorkdirVerdict::Confirm(WorkdirConcern::HomeDirectory);
}
WorkdirVerdict::Proceed
}
fn is_within(path: &std::path::Path, base: &std::path::Path) -> bool {
if base.as_os_str().is_empty() {
return false;
}
path.starts_with(base)
}
pub fn non_interactive_warning(workdir: &std::path::Path, concern: &WorkdirConcern) -> String {
format!(
"warning: running in '{}'. {} {}\n\
Proceeding without confirmation - there is no terminal to ask on. Silence this by \
adding it to your config:\n\n\
[security]\nallowed_workdirs = [\"{}\"]\n\n\
Or pass --workdir to run somewhere else.",
workdir.display(),
concern.headline(),
concern.detail(),
workdir.display(),
)
}
pub async fn confirm_core<S: crate::tui::TerminalSetup, E: crate::tui::EventSource>(
workdir: &std::path::Path,
concern: &WorkdirConcern,
setup: &mut S,
events: &mut E,
) -> bool {
use crate::tui::widgets::confirm::{Confirm, ConfirmOutcome};
use ratatui::text::Line;
let mut dialog = Confirm::new(
"Confirm working directory",
vec![
Line::from(format!("{}", workdir.display())),
Line::from(""),
Line::from(concern.headline()),
Line::from(""),
Line::from(concern.detail()),
Line::from(""),
Line::from("Add it to [security] allowed_workdirs to stop being asked."),
],
"Run here",
"Cancel",
)
.danger();
if setup.enable().is_err() {
return false;
}
let Ok(mut terminal) = setup.create_terminal() else {
setup.disable();
return false;
};
let answer = loop {
if terminal.draw(|f| dialog.draw(f, f.area())).is_err() {
break false;
}
match events.poll_event(std::time::Duration::from_millis(120)) {
Ok(Some(crossterm::event::Event::Key(key)))
if key.kind == crossterm::event::KeyEventKind::Press =>
{
match dialog.handle(&key) {
ConfirmOutcome::Yes => break true,
ConfirmOutcome::No => break false,
ConfirmOutcome::Pending => {}
}
}
Ok(_) => {}
Err(_) => break false,
}
};
setup.disable();
answer
}
pub async fn check<S: crate::tui::TerminalSetup, E: crate::tui::EventSource>(
workdir: &std::path::Path,
home: Option<&std::path::Path>,
allowed: &[String],
interactive: bool,
setup: &mut S,
events: &mut E,
) -> bool {
let WorkdirVerdict::Confirm(concern) = assess(workdir, home, allowed) else {
return true;
};
if !interactive {
eprintln!("{}", non_interactive_warning(workdir, &concern));
return true;
}
confirm_core(workdir, &concern, setup, events).await
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::Path;
fn home() -> Option<&'static Path> {
Some(Path::new("/Users/alice"))
}
#[test]
fn an_ordinary_project_directory_passes() {
assert_eq!(
assess(Path::new("/Users/alice/code/leviath"), home(), &[]),
WorkdirVerdict::Proceed
);
}
#[test]
fn the_home_directory_itself_is_questioned() {
assert_eq!(
assess(Path::new("/Users/alice"), home(), &[]),
WorkdirVerdict::Confirm(WorkdirConcern::HomeDirectory)
);
}
#[test]
fn a_directory_under_home_is_not_questioned() {
assert_eq!(
assess(Path::new("/Users/alice/projects"), home(), &[]),
WorkdirVerdict::Proceed
);
}
#[test]
fn a_filesystem_root_is_questioned() {
assert_eq!(
assess(Path::new("/"), home(), &[]),
WorkdirVerdict::Confirm(WorkdirConcern::FilesystemRoot)
);
}
#[test]
fn an_allowed_directory_proceeds_even_when_it_is_home() {
assert_eq!(
assess(
Path::new("/Users/alice"),
home(),
&["/Users/alice".to_string()]
),
WorkdirVerdict::Proceed
);
}
#[test]
fn an_allowed_directory_covers_what_is_under_it() {
assert_eq!(
assess(Path::new("/"), home(), &["/".to_string()]),
WorkdirVerdict::Proceed
);
}
#[test]
fn a_sibling_with_a_shared_prefix_is_not_allowed_by_it() {
assert_eq!(
assess(
Path::new("/Users/alice-old"),
Some(Path::new("/Users/alice-old")),
&["/Users/alice".to_string()]
),
WorkdirVerdict::Confirm(WorkdirConcern::HomeDirectory)
);
}
#[test]
fn an_empty_allowed_entry_matches_nothing() {
assert_eq!(
assess(Path::new("/Users/alice"), home(), &[String::new()]),
WorkdirVerdict::Confirm(WorkdirConcern::HomeDirectory)
);
}
#[test]
fn without_a_resolvable_home_the_home_check_cannot_fire() {
assert_eq!(
assess(Path::new("/Users/alice"), None, &[]),
WorkdirVerdict::Proceed
);
}
#[test]
fn both_concerns_explain_themselves() {
for c in [
WorkdirConcern::HomeDirectory,
WorkdirConcern::FilesystemRoot,
] {
assert!(c.headline().ends_with('.'), "{c:?}");
assert!(c.detail().contains("confined"), "{c:?}");
}
}
#[test]
fn the_warning_names_the_directory_the_choice_and_the_fix() {
let msg =
non_interactive_warning(Path::new("/Users/alice"), &WorkdirConcern::HomeDirectory);
assert!(msg.contains("/Users/alice"), "{msg}");
assert!(msg.contains("Proceeding without confirmation"), "{msg}");
assert!(
msg.contains("allowed_workdirs = [\"/Users/alice\"]"),
"{msg}"
);
assert!(msg.contains("--workdir"), "{msg}");
}
use crate::tui::{TestEventSource, TestSetup};
use crossterm::event::{Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers};
fn key(code: KeyCode) -> Event {
Event::Key(KeyEvent::new(code, KeyModifiers::NONE))
}
async fn ask(events: Vec<Option<Event>>) -> bool {
confirm_core(
Path::new("/Users/alice"),
&WorkdirConcern::HomeDirectory,
&mut TestSetup::new(),
&mut TestEventSource::new_with_nones(events),
)
.await
}
#[tokio::test]
async fn y_runs_here() {
assert!(ask(vec![Some(key(KeyCode::Char('y')))]).await);
}
#[tokio::test]
async fn n_cancels() {
assert!(!ask(vec![Some(key(KeyCode::Char('n')))]).await);
}
#[tokio::test]
async fn esc_cancels() {
assert!(!ask(vec![Some(key(KeyCode::Esc))]).await);
}
#[tokio::test]
async fn enter_alone_takes_the_safe_answer() {
assert!(!ask(vec![Some(key(KeyCode::Enter))]).await);
}
#[tokio::test]
async fn moving_focus_then_entering_runs_here() {
assert!(ask(vec![Some(key(KeyCode::Right)), Some(key(KeyCode::Enter)),]).await);
}
#[tokio::test]
async fn a_quiet_poll_does_not_answer() {
assert!(ask(vec![None, None, Some(key(KeyCode::Char('y')))]).await);
}
#[tokio::test]
async fn a_terminal_that_will_not_enable_cancels() {
let mut setup = TestSetup::new();
setup.enable_should_fail = true;
assert!(
!confirm_core(
Path::new("/Users/alice"),
&WorkdirConcern::HomeDirectory,
&mut setup,
&mut TestEventSource::new(vec![key(KeyCode::Char('y'))]),
)
.await
);
}
#[tokio::test]
async fn a_terminal_that_will_not_open_cancels() {
let mut setup = TestSetup::new();
setup.create_should_fail = true;
assert!(
!confirm_core(
Path::new("/Users/alice"),
&WorkdirConcern::HomeDirectory,
&mut setup,
&mut TestEventSource::new(vec![key(KeyCode::Char('y'))]),
)
.await
);
}
#[tokio::test]
async fn a_terminal_that_cannot_be_drawn_to_cancels() {
let mut setup = TestSetup::new();
setup.draw_should_fail = true;
assert!(
!confirm_core(
Path::new("/Users/alice"),
&WorkdirConcern::HomeDirectory,
&mut setup,
&mut TestEventSource::new(vec![key(KeyCode::Char('y'))]),
)
.await
);
}
#[tokio::test]
async fn an_event_source_that_dies_cancels() {
assert!(
!confirm_core(
Path::new("/Users/alice"),
&WorkdirConcern::HomeDirectory,
&mut TestSetup::new(),
&mut TestEventSource::failing(),
)
.await
);
}
#[tokio::test]
async fn a_key_release_is_not_an_answer() {
let release = Event::Key(KeyEvent::new_with_kind(
KeyCode::Char('y'),
KeyModifiers::NONE,
KeyEventKind::Release,
));
assert!(!ask(vec![Some(release), Some(key(KeyCode::Esc))]).await);
}
async fn check_in(dir: &str, interactive: bool, events: Vec<Event>) -> bool {
check(
Path::new(dir),
home(),
&[],
interactive,
&mut TestSetup::new(),
&mut TestEventSource::new(events),
)
.await
}
#[tokio::test]
async fn an_unremarkable_workdir_never_asks() {
assert!(check_in("/Users/alice/code", true, vec![]).await);
}
#[tokio::test]
async fn an_alarming_workdir_asks_when_there_is_a_terminal() {
assert!(check_in("/Users/alice", true, vec![key(KeyCode::Char('y'))]).await);
assert!(!check_in("/Users/alice", true, vec![key(KeyCode::Char('n'))]).await);
}
#[tokio::test]
async fn without_a_terminal_it_proceeds_rather_than_refusing() {
assert!(check_in("/Users/alice", false, vec![]).await);
}
#[tokio::test]
async fn an_allowed_workdir_does_not_ask_even_interactively() {
assert!(
check(
Path::new("/Users/alice"),
home(),
&["/Users/alice".to_string()],
true,
&mut TestSetup::new(),
&mut TestEventSource::new(vec![]),
)
.await
);
}
}