Skip to main content

leviath_cli/
workdir_guard.rs

1//! Confirming a workdir that is somewhere an agent probably should not write.
2//!
3//! `lev run`'s workdir defaults to wherever it was invoked, and running from a
4//! home directory is an easy accident. Issue #252 is a machine that lost 115 GB
5//! to an agent writing under a profile root; the agent was doing what it was
6//! told, in the directory it was given.
7//!
8//! So this asks - once, and only about the two shapes that are alarming:
9//!
10//! - a **home directory** (`~`, `/home/x`, `/Users/x`, `C:\Users\x`), where an
11//!   agent's writes land among everything the user owns, and
12//! - a **filesystem root** (`/`, `C:\`), where they land among everything.
13//!
14//! Anything else - a project directory, a scratch dir, a repo checkout - passes
15//! without a word. This is deliberately not an allowlist that must be populated
16//! before leviath is usable: a tool that asks about everything trains people to
17//! say yes to everything, which is the failure mode it would be trying to stop.
18//!
19//! With no terminal to ask on - CI, a pipe, `--yolo` - the run **proceeds**
20//! with a warning rather than being refused; breaking every unattended
21//! caller to enforce a prompt would trade one failure mode for a worse one.
22//!
23//! The decision is a pure function over paths ([`assess`]) so it can be tested
24//! without a filesystem or a terminal; asking the question is the caller's.
25
26/// What to do about a run's workdir.
27#[derive(Debug, Clone, PartialEq, Eq)]
28pub enum WorkdirVerdict {
29    /// Nothing alarming, or the user has already said they work here.
30    Proceed,
31    /// Worth confirming. Carries what to tell the user.
32    Confirm(WorkdirConcern),
33}
34
35/// Why a workdir was questioned. Separate from the message so the caller can
36/// render it as a prompt, a refusal, or a log line without re-deriving it.
37#[derive(Debug, Clone, PartialEq, Eq)]
38pub enum WorkdirConcern {
39    /// The workdir is the user's home directory itself.
40    HomeDirectory,
41    /// The workdir is a filesystem root.
42    FilesystemRoot,
43}
44
45impl WorkdirConcern {
46    /// One line saying what is alarming about it.
47    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    /// What an agent could do there, concretely rather than in the abstract.
55    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
70/// Decide whether `workdir` needs confirming.
71///
72/// `home` is the user's home directory (`None` when it cannot be resolved, in
73/// which case the home check simply cannot fire). `allowed` is
74/// `[security] allowed_workdirs`; a workdir at or under any entry proceeds.
75///
76/// Comparison is textual on already-canonicalised paths - `effective_workdir`
77/// canonicalises the `--workdir` flag, and the invocation directory is
78/// canonical by construction. This deliberately does not touch the filesystem:
79/// the check runs on every `lev run`, and a stat storm on the startup path
80/// would be a poor trade for catching a symlinked home.
81pub 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
98/// Whether `path` is `base` or sits under it.
99///
100/// Component-wise rather than a string prefix: `/home/alice-old` starts with
101/// `/home/alice` as text and is a different directory.
102fn is_within(path: &std::path::Path, base: &std::path::Path) -> bool {
103    // An empty entry would otherwise match everything, silencing the guard for
104    // every workdir - a typo in the config should not disable it.
105    if base.as_os_str().is_empty() {
106        return false;
107    }
108    path.starts_with(base)
109}
110
111/// What to warn when there is no terminal to ask on.
112///
113/// The run **proceeds**. Refusing would break every unattended caller - CI, a
114/// pipe, `--yolo` - and a prompt nobody can answer is worse still, because it
115/// parks the run until something times it out and reads as a hang.
116///
117/// The cost of that choice is that the guard is advisory in exactly the
118/// unattended case issue #252 came from, so this line is the whole mitigation:
119/// it goes to stderr on every such run, names the directory, and says how to
120/// silence it. Someone reading the log afterwards should be able to find the
121/// moment an agent was pointed at a home directory.
122pub 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
136// ─── Asking ──────────────────────────────────────────────────────────────────
137
138/// Put the question on screen and wait for an answer.
139///
140/// Generic over the same [`crate::tui::TerminalSetup`]/[`crate::tui::EventSource`] seams `lev setup`
141/// uses, so the whole flow runs against a `TestBackend` with canned keys - the
142/// real crossterm pair lives in the binary, where the terminal I/O belongs.
143///
144/// Returns whether to proceed. Anything that is not an explicit yes is a no:
145/// Esc, `n`, a closed event source, or a draw that fails. A confirmation that
146/// defaults to yes on an error is not a confirmation.
147pub 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            // A tick, a resize, a key release: keep drawing and asking.
195            Ok(_) => {}
196            // The event source is gone. Nobody is going to answer, and the safe
197            // answer is the one that does not run.
198            Err(_) => break false,
199        }
200    };
201
202    setup.disable();
203    answer
204}
205
206/// The whole check, for `lev run`: assess, then ask or warn.
207///
208/// Returns whether the run may proceed. `interactive` is whether there is a
209/// terminal to ask on - when there is not (CI, a pipe, `--yolo`), the run
210/// proceeds with [`non_interactive_warning`] on stderr rather than being
211/// refused, because refusing would break every unattended caller.
212pub 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    /// A directory *inside* home is the normal case and must not prompt -
256    /// otherwise the guard fires on nearly every run and stops being read.
257    #[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    /// Textual prefixes are not enough: these are different directories.
294    #[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    /// A typo that produced an empty entry must not silence the guard for
307    /// every workdir.
308    #[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    /// The warning is the whole mitigation on the unattended path, so it has to
336    /// carry all three things someone needs: where it ran, that it was not
337    /// confirmed, and how to stop being asked.
338    #[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    // ─── the dialog ───────────────────────────────────────────────────────
352
353    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    /// Focus starts on Cancel, so a bare Enter must not run. This is the whole
386    /// point of using the two-button dialog rather than "y accepts, anything
387    /// else dismisses".
388    #[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    /// Ticks with no input keep the dialog up rather than answering it.
399    #[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    /// Every way of failing to ask resolves to "do not run". A confirmation
405    /// that defaults to yes when it cannot be shown is not a confirmation.
406    #[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    /// A terminal that cannot be drawn to cannot have shown the question, so
437    /// the answer is no. Same stance as the two failures above.
438    #[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    /// A key *release* is not an answer - on Windows crossterm reports both
467    /// press and release, and answering on either would take the first of a
468    /// pair as two answers.
469    #[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    // ─── the entry point ──────────────────────────────────────────────────
480
481    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        // No events at all: if it tried to ask, the source would run dry and
496        // the answer would be "no", so `true` here proves it did not ask.
497        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    /// The unattended path proceeds rather than refusing - breaking CI to
507    /// enforce a prompt trades one failure mode for a worse one. Again the
508    /// empty event list is the evidence that nothing was asked.
509    #[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}