leviath-cli 0.3.8

Command-line interface for Leviath agent framework
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
//! Confirming a workdir that is somewhere an agent probably should not write.
//!
//! `lev run`'s workdir defaults to wherever it was invoked, and running from a
//! home directory is an easy accident. Issue #252 is a machine that lost 115 GB
//! to an agent writing under a profile root; the agent was doing what it was
//! told, in the directory it was given.
//!
//! So this asks - once, and only about the two shapes that are alarming:
//!
//! - a **home directory** (`~`, `/home/x`, `/Users/x`, `C:\Users\x`), where an
//!   agent's writes land among everything the user owns, and
//! - a **filesystem root** (`/`, `C:\`), where they land among everything.
//!
//! Anything else - a project directory, a scratch dir, a repo checkout - passes
//! without a word. This is deliberately not an allowlist that must be populated
//! before leviath is usable: a tool that asks about everything trains people to
//! say yes to everything, which is the failure mode it would be trying to stop.
//!
//! With no terminal to ask on - CI, a pipe, `--yolo` - the run **proceeds**
//! with a warning rather than being refused; breaking every unattended
//! caller to enforce a prompt would trade one failure mode for a worse one.
//!
//! The decision is a pure function over paths ([`assess`]) so it can be tested
//! without a filesystem or a terminal; asking the question is the caller's.

/// What to do about a run's workdir.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum WorkdirVerdict {
    /// Nothing alarming, or the user has already said they work here.
    Proceed,
    /// Worth confirming. Carries what to tell the user.
    Confirm(WorkdirConcern),
}

/// Why a workdir was questioned. Separate from the message so the caller can
/// render it as a prompt, a refusal, or a log line without re-deriving it.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum WorkdirConcern {
    /// The workdir is the user's home directory itself.
    HomeDirectory,
    /// The workdir is a filesystem root.
    FilesystemRoot,
}

impl WorkdirConcern {
    /// One line saying what is alarming about it.
    pub fn headline(&self) -> &'static str {
        match self {
            Self::HomeDirectory => "That is your home directory.",
            Self::FilesystemRoot => "That is a filesystem root.",
        }
    }

    /// What an agent could do there, concretely rather than in the abstract.
    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."
            }
        }
    }
}

/// Decide whether `workdir` needs confirming.
///
/// `home` is the user's home directory (`None` when it cannot be resolved, in
/// which case the home check simply cannot fire). `allowed` is
/// `[security] allowed_workdirs`; a workdir at or under any entry proceeds.
///
/// Comparison is textual on already-canonicalised paths - `effective_workdir`
/// canonicalises the `--workdir` flag, and the invocation directory is
/// canonical by construction. This deliberately does not touch the filesystem:
/// the check runs on every `lev run`, and a stat storm on the startup path
/// would be a poor trade for catching a symlinked home.
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
}

/// Whether `path` is `base` or sits under it.
///
/// Component-wise rather than a string prefix: `/home/alice-old` starts with
/// `/home/alice` as text and is a different directory.
fn is_within(path: &std::path::Path, base: &std::path::Path) -> bool {
    // An empty entry would otherwise match everything, silencing the guard for
    // every workdir - a typo in the config should not disable it.
    if base.as_os_str().is_empty() {
        return false;
    }
    path.starts_with(base)
}

/// What to warn when there is no terminal to ask on.
///
/// The run **proceeds**. Refusing would break every unattended caller - CI, a
/// pipe, `--yolo` - and a prompt nobody can answer is worse still, because it
/// parks the run until something times it out and reads as a hang.
///
/// The cost of that choice is that the guard is advisory in exactly the
/// unattended case issue #252 came from, so this line is the whole mitigation:
/// it goes to stderr on every such run, names the directory, and says how to
/// silence it. Someone reading the log afterwards should be able to find the
/// moment an agent was pointed at a home directory.
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(),
    )
}

// ─── Asking ──────────────────────────────────────────────────────────────────

/// Put the question on screen and wait for an answer.
///
/// Generic over the same [`crate::tui::TerminalSetup`]/[`crate::tui::EventSource`] seams `lev setup`
/// uses, so the whole flow runs against a `TestBackend` with canned keys - the
/// real crossterm pair lives in the binary, where the terminal I/O belongs.
///
/// Returns whether to proceed. Anything that is not an explicit yes is a no:
/// Esc, `n`, a closed event source, or a draw that fails. A confirmation that
/// defaults to yes on an error is not a confirmation.
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 => {}
                }
            }
            // A tick, a resize, a key release: keep drawing and asking.
            Ok(_) => {}
            // The event source is gone. Nobody is going to answer, and the safe
            // answer is the one that does not run.
            Err(_) => break false,
        }
    };

    setup.disable();
    answer
}

/// The whole check, for `lev run`: assess, then ask or warn.
///
/// Returns whether the run may proceed. `interactive` is whether there is a
/// terminal to ask on - when there is not (CI, a pipe, `--yolo`), the run
/// proceeds with [`non_interactive_warning`] on stderr rather than being
/// refused, because refusing would break every unattended caller.
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)
        );
    }

    /// A directory *inside* home is the normal case and must not prompt -
    /// otherwise the guard fires on nearly every run and stops being read.
    #[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
        );
    }

    /// Textual prefixes are not enough: these are different directories.
    #[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)
        );
    }

    /// A typo that produced an empty entry must not silence the guard for
    /// every workdir.
    #[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:?}");
        }
    }

    /// The warning is the whole mitigation on the unattended path, so it has to
    /// carry all three things someone needs: where it ran, that it was not
    /// confirmed, and how to stop being asked.
    #[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}");
    }

    // ─── the dialog ───────────────────────────────────────────────────────

    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);
    }

    /// Focus starts on Cancel, so a bare Enter must not run. This is the whole
    /// point of using the two-button dialog rather than "y accepts, anything
    /// else dismisses".
    #[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);
    }

    /// Ticks with no input keep the dialog up rather than answering it.
    #[tokio::test]
    async fn a_quiet_poll_does_not_answer() {
        assert!(ask(vec![None, None, Some(key(KeyCode::Char('y')))]).await);
    }

    /// Every way of failing to ask resolves to "do not run". A confirmation
    /// that defaults to yes when it cannot be shown is not a confirmation.
    #[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
        );
    }

    /// A terminal that cannot be drawn to cannot have shown the question, so
    /// the answer is no. Same stance as the two failures above.
    #[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
        );
    }

    /// A key *release* is not an answer - on Windows crossterm reports both
    /// press and release, and answering on either would take the first of a
    /// pair as two answers.
    #[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);
    }

    // ─── the entry point ──────────────────────────────────────────────────

    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() {
        // No events at all: if it tried to ask, the source would run dry and
        // the answer would be "no", so `true` here proves it did not ask.
        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);
    }

    /// The unattended path proceeds rather than refusing - breaking CI to
    /// enforce a prompt trades one failure mode for a worse one. Again the
    /// empty event list is the evidence that nothing was asked.
    #[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
        );
    }
}