1#[derive(Debug, Clone, PartialEq, Eq)]
28pub enum WorkdirVerdict {
29 Proceed,
31 Confirm(WorkdirConcern),
33}
34
35#[derive(Debug, Clone, PartialEq, Eq)]
38pub enum WorkdirConcern {
39 HomeDirectory,
41 FilesystemRoot,
43}
44
45impl WorkdirConcern {
46 pub fn headline(&self) -> &'static str {
48 match self {
49 Self::HomeDirectory => "That is your home directory.",
50 Self::FilesystemRoot => "That is a filesystem root.",
51 }
52 }
53
54 pub fn detail(&self) -> &'static str {
56 match self {
57 Self::HomeDirectory => {
58 "An agent's file tools are confined to its workdir, so this run could read \
59 and write anything in your home - including SSH keys, browser data, and \
60 every other project you have."
61 }
62 Self::FilesystemRoot => {
63 "An agent's file tools are confined to its workdir, so this run would be \
64 confined to the whole machine."
65 }
66 }
67 }
68}
69
70pub fn assess(
82 workdir: &std::path::Path,
83 home: Option<&std::path::Path>,
84 allowed: &[String],
85) -> WorkdirVerdict {
86 if allowed.iter().any(|a| is_within(workdir, a.as_ref())) {
87 return WorkdirVerdict::Proceed;
88 }
89 if workdir.parent().is_none() {
90 return WorkdirVerdict::Confirm(WorkdirConcern::FilesystemRoot);
91 }
92 if home.is_some_and(|h| h == workdir) {
93 return WorkdirVerdict::Confirm(WorkdirConcern::HomeDirectory);
94 }
95 WorkdirVerdict::Proceed
96}
97
98fn is_within(path: &std::path::Path, base: &std::path::Path) -> bool {
103 if base.as_os_str().is_empty() {
106 return false;
107 }
108 path.starts_with(base)
109}
110
111pub fn non_interactive_warning(workdir: &std::path::Path, concern: &WorkdirConcern) -> String {
123 format!(
124 "warning: running in '{}'. {} {}\n\
125 Proceeding without confirmation - there is no terminal to ask on. Silence this by \
126 adding it to your config:\n\n\
127 [security]\nallowed_workdirs = [\"{}\"]\n\n\
128 Or pass --workdir to run somewhere else.",
129 workdir.display(),
130 concern.headline(),
131 concern.detail(),
132 workdir.display(),
133 )
134}
135
136pub async fn confirm_core<S: crate::tui::TerminalSetup, E: crate::tui::EventSource>(
148 workdir: &std::path::Path,
149 concern: &WorkdirConcern,
150 setup: &mut S,
151 events: &mut E,
152) -> bool {
153 use crate::tui::widgets::confirm::{Confirm, ConfirmOutcome};
154 use ratatui::text::Line;
155
156 let mut dialog = Confirm::new(
157 "Confirm working directory",
158 vec![
159 Line::from(format!("{}", workdir.display())),
160 Line::from(""),
161 Line::from(concern.headline()),
162 Line::from(""),
163 Line::from(concern.detail()),
164 Line::from(""),
165 Line::from("Add it to [security] allowed_workdirs to stop being asked."),
166 ],
167 "Run here",
168 "Cancel",
169 )
170 .danger();
171
172 if setup.enable().is_err() {
173 return false;
174 }
175 let Ok(mut terminal) = setup.create_terminal() else {
176 setup.disable();
177 return false;
178 };
179
180 let answer = loop {
181 if terminal.draw(|f| dialog.draw(f, f.area())).is_err() {
182 break false;
183 }
184 match events.poll_event(std::time::Duration::from_millis(120)) {
185 Ok(Some(crossterm::event::Event::Key(key)))
186 if key.kind == crossterm::event::KeyEventKind::Press =>
187 {
188 match dialog.handle(&key) {
189 ConfirmOutcome::Yes => break true,
190 ConfirmOutcome::No => break false,
191 ConfirmOutcome::Pending => {}
192 }
193 }
194 Ok(_) => {}
196 Err(_) => break false,
199 }
200 };
201
202 setup.disable();
203 answer
204}
205
206pub async fn check<S: crate::tui::TerminalSetup, E: crate::tui::EventSource>(
213 workdir: &std::path::Path,
214 home: Option<&std::path::Path>,
215 allowed: &[String],
216 interactive: bool,
217 setup: &mut S,
218 events: &mut E,
219) -> bool {
220 let WorkdirVerdict::Confirm(concern) = assess(workdir, home, allowed) else {
221 return true;
222 };
223 if !interactive {
224 eprintln!("{}", non_interactive_warning(workdir, &concern));
225 return true;
226 }
227 confirm_core(workdir, &concern, setup, events).await
228}
229
230#[cfg(test)]
231mod tests {
232 use super::*;
233 use std::path::Path;
234
235 fn home() -> Option<&'static Path> {
236 Some(Path::new("/Users/alice"))
237 }
238
239 #[test]
240 fn an_ordinary_project_directory_passes() {
241 assert_eq!(
242 assess(Path::new("/Users/alice/code/leviath"), home(), &[]),
243 WorkdirVerdict::Proceed
244 );
245 }
246
247 #[test]
248 fn the_home_directory_itself_is_questioned() {
249 assert_eq!(
250 assess(Path::new("/Users/alice"), home(), &[]),
251 WorkdirVerdict::Confirm(WorkdirConcern::HomeDirectory)
252 );
253 }
254
255 #[test]
258 fn a_directory_under_home_is_not_questioned() {
259 assert_eq!(
260 assess(Path::new("/Users/alice/projects"), home(), &[]),
261 WorkdirVerdict::Proceed
262 );
263 }
264
265 #[test]
266 fn a_filesystem_root_is_questioned() {
267 assert_eq!(
268 assess(Path::new("/"), home(), &[]),
269 WorkdirVerdict::Confirm(WorkdirConcern::FilesystemRoot)
270 );
271 }
272
273 #[test]
274 fn an_allowed_directory_proceeds_even_when_it_is_home() {
275 assert_eq!(
276 assess(
277 Path::new("/Users/alice"),
278 home(),
279 &["/Users/alice".to_string()]
280 ),
281 WorkdirVerdict::Proceed
282 );
283 }
284
285 #[test]
286 fn an_allowed_directory_covers_what_is_under_it() {
287 assert_eq!(
288 assess(Path::new("/"), home(), &["/".to_string()]),
289 WorkdirVerdict::Proceed
290 );
291 }
292
293 #[test]
295 fn a_sibling_with_a_shared_prefix_is_not_allowed_by_it() {
296 assert_eq!(
297 assess(
298 Path::new("/Users/alice-old"),
299 Some(Path::new("/Users/alice-old")),
300 &["/Users/alice".to_string()]
301 ),
302 WorkdirVerdict::Confirm(WorkdirConcern::HomeDirectory)
303 );
304 }
305
306 #[test]
309 fn an_empty_allowed_entry_matches_nothing() {
310 assert_eq!(
311 assess(Path::new("/Users/alice"), home(), &[String::new()]),
312 WorkdirVerdict::Confirm(WorkdirConcern::HomeDirectory)
313 );
314 }
315
316 #[test]
317 fn without_a_resolvable_home_the_home_check_cannot_fire() {
318 assert_eq!(
319 assess(Path::new("/Users/alice"), None, &[]),
320 WorkdirVerdict::Proceed
321 );
322 }
323
324 #[test]
325 fn both_concerns_explain_themselves() {
326 for c in [
327 WorkdirConcern::HomeDirectory,
328 WorkdirConcern::FilesystemRoot,
329 ] {
330 assert!(c.headline().ends_with('.'), "{c:?}");
331 assert!(c.detail().contains("confined"), "{c:?}");
332 }
333 }
334
335 #[test]
339 fn the_warning_names_the_directory_the_choice_and_the_fix() {
340 let msg =
341 non_interactive_warning(Path::new("/Users/alice"), &WorkdirConcern::HomeDirectory);
342 assert!(msg.contains("/Users/alice"), "{msg}");
343 assert!(msg.contains("Proceeding without confirmation"), "{msg}");
344 assert!(
345 msg.contains("allowed_workdirs = [\"/Users/alice\"]"),
346 "{msg}"
347 );
348 assert!(msg.contains("--workdir"), "{msg}");
349 }
350
351 use crate::tui::{TestEventSource, TestSetup};
354 use crossterm::event::{Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers};
355
356 fn key(code: KeyCode) -> Event {
357 Event::Key(KeyEvent::new(code, KeyModifiers::NONE))
358 }
359
360 async fn ask(events: Vec<Option<Event>>) -> bool {
361 confirm_core(
362 Path::new("/Users/alice"),
363 &WorkdirConcern::HomeDirectory,
364 &mut TestSetup::new(),
365 &mut TestEventSource::new_with_nones(events),
366 )
367 .await
368 }
369
370 #[tokio::test]
371 async fn y_runs_here() {
372 assert!(ask(vec![Some(key(KeyCode::Char('y')))]).await);
373 }
374
375 #[tokio::test]
376 async fn n_cancels() {
377 assert!(!ask(vec![Some(key(KeyCode::Char('n')))]).await);
378 }
379
380 #[tokio::test]
381 async fn esc_cancels() {
382 assert!(!ask(vec![Some(key(KeyCode::Esc))]).await);
383 }
384
385 #[tokio::test]
389 async fn enter_alone_takes_the_safe_answer() {
390 assert!(!ask(vec![Some(key(KeyCode::Enter))]).await);
391 }
392
393 #[tokio::test]
394 async fn moving_focus_then_entering_runs_here() {
395 assert!(ask(vec![Some(key(KeyCode::Right)), Some(key(KeyCode::Enter)),]).await);
396 }
397
398 #[tokio::test]
400 async fn a_quiet_poll_does_not_answer() {
401 assert!(ask(vec![None, None, Some(key(KeyCode::Char('y')))]).await);
402 }
403
404 #[tokio::test]
407 async fn a_terminal_that_will_not_enable_cancels() {
408 let mut setup = TestSetup::new();
409 setup.enable_should_fail = true;
410 assert!(
411 !confirm_core(
412 Path::new("/Users/alice"),
413 &WorkdirConcern::HomeDirectory,
414 &mut setup,
415 &mut TestEventSource::new(vec![key(KeyCode::Char('y'))]),
416 )
417 .await
418 );
419 }
420
421 #[tokio::test]
422 async fn a_terminal_that_will_not_open_cancels() {
423 let mut setup = TestSetup::new();
424 setup.create_should_fail = true;
425 assert!(
426 !confirm_core(
427 Path::new("/Users/alice"),
428 &WorkdirConcern::HomeDirectory,
429 &mut setup,
430 &mut TestEventSource::new(vec![key(KeyCode::Char('y'))]),
431 )
432 .await
433 );
434 }
435
436 #[tokio::test]
439 async fn a_terminal_that_cannot_be_drawn_to_cancels() {
440 let mut setup = TestSetup::new();
441 setup.draw_should_fail = true;
442 assert!(
443 !confirm_core(
444 Path::new("/Users/alice"),
445 &WorkdirConcern::HomeDirectory,
446 &mut setup,
447 &mut TestEventSource::new(vec![key(KeyCode::Char('y'))]),
448 )
449 .await
450 );
451 }
452
453 #[tokio::test]
454 async fn an_event_source_that_dies_cancels() {
455 assert!(
456 !confirm_core(
457 Path::new("/Users/alice"),
458 &WorkdirConcern::HomeDirectory,
459 &mut TestSetup::new(),
460 &mut TestEventSource::failing(),
461 )
462 .await
463 );
464 }
465
466 #[tokio::test]
470 async fn a_key_release_is_not_an_answer() {
471 let release = Event::Key(KeyEvent::new_with_kind(
472 KeyCode::Char('y'),
473 KeyModifiers::NONE,
474 KeyEventKind::Release,
475 ));
476 assert!(!ask(vec![Some(release), Some(key(KeyCode::Esc))]).await);
477 }
478
479 async fn check_in(dir: &str, interactive: bool, events: Vec<Event>) -> bool {
482 check(
483 Path::new(dir),
484 home(),
485 &[],
486 interactive,
487 &mut TestSetup::new(),
488 &mut TestEventSource::new(events),
489 )
490 .await
491 }
492
493 #[tokio::test]
494 async fn an_unremarkable_workdir_never_asks() {
495 assert!(check_in("/Users/alice/code", true, vec![]).await);
498 }
499
500 #[tokio::test]
501 async fn an_alarming_workdir_asks_when_there_is_a_terminal() {
502 assert!(check_in("/Users/alice", true, vec![key(KeyCode::Char('y'))]).await);
503 assert!(!check_in("/Users/alice", true, vec![key(KeyCode::Char('n'))]).await);
504 }
505
506 #[tokio::test]
510 async fn without_a_terminal_it_proceeds_rather_than_refusing() {
511 assert!(check_in("/Users/alice", false, vec![]).await);
512 }
513
514 #[tokio::test]
515 async fn an_allowed_workdir_does_not_ask_even_interactively() {
516 assert!(
517 check(
518 Path::new("/Users/alice"),
519 home(),
520 &["/Users/alice".to_string()],
521 true,
522 &mut TestSetup::new(),
523 &mut TestEventSource::new(vec![]),
524 )
525 .await
526 );
527 }
528}