Skip to main content

arct_core/
guard.rs

1//! Safety guard for real-filesystem lesson practice ("Playground" mode).
2//!
3//! When a lesson runs in real practice mode, commands execute against the
4//! real shell inside `~/ArcAcademy/playground`. This guard sits in front of
5//! that shell and refuses:
6//!
7//! 1. Commands that reference paths outside the playground (absolute paths,
8//!    `~`, or enough `..` to climb out given the tracked working directory).
9//! 2. A small denylist of catastrophic patterns (`rm -rf /`, `mkfs`,
10//!    `dd of=/dev/...`, fork bombs, `chmod -R 777 /`, redirects into `/dev`,
11//!    and anything run via `sudo`) — each refusal explains WHY the pattern
12//!    is dangerous, because that is itself a teaching moment.
13//! 3. Constructs we cannot reason about safely (command substitution,
14//!    environment-variable expansion, unbalanced quotes). The heuristics are
15//!    deliberately conservative: when in doubt, refuse politely.
16//!
17//! This guard applies ONLY to lesson real-practice mode. The normal free
18//! shell is never routed through it.
19
20use std::path::{Component, Path, PathBuf};
21
22/// Human-readable location of the playground, used in refusal messages.
23pub const PLAYGROUND_DISPLAY: &str = "~/ArcAcademy/playground";
24
25/// Outcome of checking a command against the playground guard.
26#[derive(Debug, Clone, PartialEq, Eq)]
27pub enum GuardVerdict {
28    /// Command is allowed to run in the real shell.
29    Allow,
30    /// Command is refused; `reason` is a friendly, novice-facing explanation.
31    Refuse { reason: String },
32}
33
34impl GuardVerdict {
35    pub fn is_allowed(&self) -> bool {
36        matches!(self, GuardVerdict::Allow)
37    }
38
39    fn refuse(reason: impl Into<String>) -> Self {
40        GuardVerdict::Refuse {
41            reason: reason.into(),
42        }
43    }
44}
45
46/// Guard that keeps real-mode lesson commands inside the playground.
47pub struct PlaygroundGuard {
48    /// Absolute path of the playground root on the real filesystem.
49    root: PathBuf,
50}
51
52impl PlaygroundGuard {
53    /// Create a guard for the given playground root (absolute path).
54    pub fn new(root: impl Into<PathBuf>) -> Self {
55        Self { root: root.into() }
56    }
57
58    /// Check a command line before it is sent to the real shell.
59    ///
60    /// `cwd` is the tracked working directory for the practice session; it
61    /// must be inside the playground root. The check is purely lexical — it
62    /// never touches the filesystem.
63    pub fn check(&self, command: &str, cwd: &Path) -> GuardVerdict {
64        let trimmed = command.trim();
65        if trimmed.is_empty() {
66            return GuardVerdict::Allow;
67        }
68
69        // --- 1. Catastrophic-pattern denylist (checked first so the user
70        // gets the specific teaching-moment explanation, not a generic
71        // path message) ---
72        if let Some(verdict) = Self::check_denylist(trimmed) {
73            return verdict;
74        }
75
76        // --- 2. Constructs we can't reason about safely ---
77        if trimmed.contains('`') || trimmed.contains("$(") {
78            return GuardVerdict::refuse(
79                "Command substitution (backticks or $(...)) runs a hidden inner command, \
80                 so there's no way to check where it will reach. In practice mode, type \
81                 the command directly instead.",
82            );
83        }
84        if trimmed.contains('$') {
85            return GuardVerdict::refuse(
86                "Environment variables like $HOME or $PATH can expand to locations outside \
87                 the playground, so commands using '$' are blocked in practice mode. Try \
88                 typing the actual path or value instead.",
89            );
90        }
91
92        // --- 3. Tokenize (handles quoted paths like "cat '/etc/passwd'") ---
93        let tokens = match shellwords::split(trimmed) {
94            Ok(tokens) => tokens,
95            Err(_) => {
96                return GuardVerdict::refuse(
97                    "That command has unbalanced quotes, so it can't be checked safely. \
98                     Close the quote and try again.",
99                );
100            }
101        };
102
103        // --- 4. Per-token path containment ---
104        let mut total_climb: usize = 0;
105        for raw_token in &tokens {
106            // Strip redirect prefixes so `>/etc/passwd` or `2>/tmp/x` are
107            // analyzed as paths
108            let token = {
109                let digits_stripped = raw_token.trim_start_matches(|c: char| c.is_ascii_digit());
110                if digits_stripped.starts_with(['<', '>', '&']) {
111                    digits_stripped.trim_start_matches(['<', '>', '&'])
112                } else {
113                    raw_token.as_str()
114                }
115            };
116            if token.is_empty() {
117                continue;
118            }
119
120            // Tilde expansion points at the home directory
121            if token.contains('~') {
122                if token.starts_with("~/ArcAcademy/playground") {
123                    continue;
124                }
125                return GuardVerdict::refuse(format!(
126                    "In practice mode, commands stay inside {PLAYGROUND_DISPLAY} — '~' points \
127                     at your real home directory. Try a relative path instead."
128                ));
129            }
130
131            // Absolute paths must stay inside the playground root
132            if token.starts_with('/') {
133                match Self::lexical_normalize(Path::new(token)) {
134                    Some(normalized) if normalized.starts_with(&self.root) => continue,
135                    _ => {
136                        return GuardVerdict::refuse(format!(
137                            "In practice mode, commands stay inside {PLAYGROUND_DISPLAY} — \
138                             '{token}' points outside it. Try a relative path instead."
139                        ));
140                    }
141                }
142            }
143
144            // Relative paths: accumulate the deepest upward climb (`..`)
145            total_climb += Self::upward_climb(Path::new(token));
146        }
147
148        // --- 5. `..` escape budget given the tracked cwd depth ---
149        let depth = cwd
150            .strip_prefix(&self.root)
151            .map(|rel| rel.components().count())
152            .unwrap_or(0);
153        if total_climb > depth {
154            return GuardVerdict::refuse(format!(
155                "That command uses enough '..' to climb out of {PLAYGROUND_DISPLAY}. In \
156                 practice mode, commands stay inside the playground — try a relative path."
157            ));
158        }
159
160        GuardVerdict::Allow
161    }
162
163    /// Denylist of catastrophic patterns, refused regardless of paths.
164    /// Each refusal explains WHY the pattern is dangerous.
165    fn check_denylist(command: &str) -> Option<GuardVerdict> {
166        // Fork bomb: :(){ :|:& };:  (whitespace-insensitive)
167        let squashed: String = command.chars().filter(|c| !c.is_whitespace()).collect();
168        if squashed.contains(":(){") || squashed.contains(":|:&") {
169            return Some(GuardVerdict::refuse(
170                "That's a fork bomb — a tiny function that endlessly launches copies of \
171                 itself until the computer runs out of memory and freezes. It looks like \
172                 line noise, which is exactly why people get tricked into running it.",
173            ));
174        }
175
176        // sudo anywhere in the pipeline/sequence
177        for segment in command.split(|c| c == ';' || c == '|' || c == '&') {
178            if segment.split_whitespace().next() == Some("sudo") {
179                return Some(GuardVerdict::refuse(
180                    "'sudo' runs a command with full administrator power, where a single \
181                     typo can change or delete critical system files. Practicing in the \
182                     playground never needs admin rights, so sudo is blocked here.",
183                ));
184            }
185        }
186
187        // mkfs formats disks
188        if command
189            .split_whitespace()
190            .any(|t| t == "mkfs" || t.starts_with("mkfs."))
191        {
192            return Some(GuardVerdict::refuse(
193                "'mkfs' formats a disk partition, instantly erasing every file on it. \
194                 It's used to prepare brand-new drives — never something to practice with.",
195            ));
196        }
197
198        // dd writing to a raw device
199        if command.split_whitespace().any(|t| t.starts_with("of=/dev/")) {
200            return Some(GuardVerdict::refuse(
201                "'dd' writing to a /dev device bypasses the filesystem and overwrites the \
202                 raw disk underneath your files — it can wipe an entire drive in seconds \
203                 with no confirmation and no undo.",
204            ));
205        }
206
207        // Redirecting output into /dev (e.g. `> /dev/sda`)
208        if command.contains('>') && command.contains("/dev/") {
209            return Some(GuardVerdict::refuse(
210                "Redirecting output into /dev writes bytes directly to a hardware device. \
211                 Aimed at a disk like /dev/sda, that corrupts the filesystem and destroys \
212                 data immediately.",
213            ));
214        }
215
216        // rm -rf / (or /*) — recursive force-delete from the root
217        if let Ok(tokens) = shellwords::split(command) {
218            if tokens.first().map(String::as_str) == Some("rm") {
219                let mut recursive = false;
220                let mut force = false;
221                let mut targets_root = false;
222                for t in &tokens[1..] {
223                    if t.starts_with('-') && !t.starts_with("--") {
224                        recursive |= t.contains('r') || t.contains('R');
225                        force |= t.contains('f');
226                    } else if t == "--recursive" {
227                        recursive = true;
228                    } else if t == "--force" {
229                        force = true;
230                    } else if t == "/" || t == "/*" {
231                        targets_root = true;
232                    }
233                }
234                if recursive && force && targets_root {
235                    return Some(GuardVerdict::refuse(
236                        "'rm -rf /' recursively force-deletes EVERYTHING starting from the \
237                         root of the filesystem — the operating system, your applications, \
238                         and all your files — with no confirmation and no undo. This is the \
239                         single most destructive command on a Unix system.",
240                    ));
241                }
242            }
243
244            // chmod -R 777 / — world-writable everything
245            if tokens.first().map(String::as_str) == Some("chmod") {
246                let recursive = tokens.iter().any(|t| t == "-R" || t == "-r");
247                let mode_777 = tokens.iter().any(|t| t == "777");
248                let targets_root = tokens.iter().any(|t| t == "/" || t == "/*");
249                if recursive && mode_777 && targets_root {
250                    return Some(GuardVerdict::refuse(
251                        "'chmod -R 777 /' makes every file on the system readable, writable \
252                         and executable by everyone — a massive security hole that also \
253                         breaks programs that refuse to run with unsafe permissions.",
254                    ));
255                }
256            }
257        }
258
259        None
260    }
261
262    /// Lexically resolve `.`/`..` in an absolute path (no filesystem access).
263    /// Returns None if the path climbs above `/`.
264    fn lexical_normalize(path: &Path) -> Option<PathBuf> {
265        let mut out = PathBuf::from("/");
266        for component in path.components() {
267            match component {
268                Component::RootDir | Component::Prefix(_) | Component::CurDir => {}
269                Component::ParentDir => {
270                    if !out.pop() {
271                        return None;
272                    }
273                }
274                Component::Normal(part) => out.push(part),
275            }
276        }
277        Some(out)
278    }
279
280    /// Deepest upward climb of a relative path: how many levels above the
281    /// starting directory it can reach (e.g. `a/../../b` climbs 1, `../..`
282    /// climbs 2). Summed across tokens, this is a conservative bound on how
283    /// far a command can escape.
284    fn upward_climb(path: &Path) -> usize {
285        let mut depth: isize = 0;
286        let mut min_depth: isize = 0;
287        for component in path.components() {
288            match component {
289                Component::ParentDir => {
290                    depth -= 1;
291                    min_depth = min_depth.min(depth);
292                }
293                Component::Normal(_) => depth += 1,
294                _ => {}
295            }
296        }
297        (-min_depth).max(0) as usize
298    }
299}
300
301#[cfg(test)]
302mod tests {
303    use super::*;
304
305    fn guard() -> PlaygroundGuard {
306        PlaygroundGuard::new("/home/user/ArcAcademy/playground")
307    }
308
309    /// cwd = playground/<lesson-id> (depth 1)
310    fn lesson_cwd() -> PathBuf {
311        PathBuf::from("/home/user/ArcAcademy/playground/file-mgmt")
312    }
313
314    /// cwd = playground root (depth 0)
315    fn root_cwd() -> PathBuf {
316        PathBuf::from("/home/user/ArcAcademy/playground")
317    }
318
319    fn assert_allowed(cmd: &str, cwd: &Path) {
320        let verdict = guard().check(cmd, cwd);
321        assert!(verdict.is_allowed(), "expected Allow for {cmd:?}, got {verdict:?}");
322    }
323
324    fn assert_refused(cmd: &str, cwd: &Path) -> String {
325        match guard().check(cmd, cwd) {
326            GuardVerdict::Refuse { reason } => reason,
327            GuardVerdict::Allow => panic!("expected Refuse for {cmd:?}, got Allow"),
328        }
329    }
330
331    // --- Everyday lesson commands pass through untouched ---
332
333    #[test]
334    fn test_allows_common_lesson_commands() {
335        let cwd = lesson_cwd();
336        for cmd in [
337            "pwd",
338            "ls",
339            "ls -lh",
340            "ls -a",
341            "mkdir practice",
342            "touch test.txt",
343            "cp test.txt test-backup.txt",
344            "mv test-backup.txt backup.txt",
345            "rm -i test.txt",
346            "cat notes.txt",
347            "grep http server.log",
348            "grep -i SSH server.log",
349            "head -n 5 server.log",
350            "tail server.log",
351            "wc -l access.log",
352            "echo hello > greeting.txt",
353            "echo more >> greeting.txt",
354            "cut -d':' -f1 fruits.txt | sort | uniq -c",
355            "ls -l | grep txt",
356            "cat \"my file.txt\"",
357        ] {
358            assert_allowed(cmd, &cwd);
359        }
360    }
361
362    #[test]
363    fn test_allows_dotdot_within_budget() {
364        // depth 1: one level up is still inside the playground
365        assert_allowed("cd ..", &lesson_cwd());
366        assert_allowed("cat ../notes.txt", &lesson_cwd());
367        assert_allowed("ls ..", &lesson_cwd());
368        // depth 2: two independent climbs of one level each
369        let deep = lesson_cwd().join("sub");
370        assert_allowed("cd .. && cat ../notes.txt", &deep);
371    }
372
373    #[test]
374    fn test_allows_absolute_paths_inside_playground() {
375        assert_allowed(
376            "cat /home/user/ArcAcademy/playground/file-mgmt/notes.txt",
377            &lesson_cwd(),
378        );
379        assert_allowed("ls ~/ArcAcademy/playground/file-mgmt", &lesson_cwd());
380    }
381
382    // --- Path escapes are refused with a friendly message ---
383
384    #[test]
385    fn test_refuses_absolute_paths_outside_playground() {
386        let reason = assert_refused("cat /etc/passwd", &lesson_cwd());
387        assert!(reason.contains("~/ArcAcademy/playground"), "reason: {reason}");
388        assert_refused("ls /", &lesson_cwd());
389        assert_refused("rm /tmp/somefile", &lesson_cwd());
390        assert_refused("echo x > /tmp/evil", &lesson_cwd());
391    }
392
393    #[test]
394    fn test_refuses_quoted_absolute_paths() {
395        assert_refused("cat \"/etc/passwd\"", &lesson_cwd());
396        assert_refused("cat '/etc/shadow'", &lesson_cwd());
397    }
398
399    #[test]
400    fn test_refuses_absolute_path_inside_root_that_climbs_out() {
401        // Lexically inside the playground prefix, but the .. escapes it
402        assert_refused(
403            "cat /home/user/ArcAcademy/playground/../../.ssh/id_rsa",
404            &lesson_cwd(),
405        );
406    }
407
408    #[test]
409    fn test_refuses_tilde_outside_playground() {
410        let reason = assert_refused("ls ~", &lesson_cwd());
411        assert!(reason.contains("~/ArcAcademy/playground"));
412        assert_refused("cd ~/Documents", &lesson_cwd());
413        assert_refused("cat ~/.bashrc", &lesson_cwd());
414        assert_refused("rm -rf ~", &lesson_cwd());
415    }
416
417    #[test]
418    fn test_refuses_dotdot_escape() {
419        // depth 0: any climb escapes
420        assert_refused("cd ..", &root_cwd());
421        assert_refused("cat ../secrets.txt", &root_cwd());
422        // depth 1: two levels climbs out
423        assert_refused("cat ../../outside.txt", &lesson_cwd());
424        // combined climb across a compound command
425        assert_refused("cd .. && rm -rf ../important", &lesson_cwd());
426        // sneaky mixed form
427        assert_refused("cat foo/../../../etc/hostname", &lesson_cwd());
428    }
429
430    #[test]
431    fn test_refuses_env_var_tricks() {
432        let reason = assert_refused("cat $HOME/.ssh/id_rsa", &lesson_cwd());
433        assert!(reason.to_lowercase().contains("environment"));
434        assert_refused("cd $HOME", &lesson_cwd());
435        assert_refused("ls ${HOME}", &lesson_cwd());
436        assert_refused("echo $PATH", &lesson_cwd());
437    }
438
439    #[test]
440    fn test_refuses_command_substitution() {
441        assert_refused("cat $(echo /etc/passwd)", &lesson_cwd());
442        assert_refused("cat `echo /etc/passwd`", &lesson_cwd());
443    }
444
445    #[test]
446    fn test_refuses_unbalanced_quotes() {
447        assert_refused("cat \"unclosed", &lesson_cwd());
448    }
449
450    // --- Catastrophic denylist, refused regardless of path rules ---
451
452    #[test]
453    fn test_refuses_rm_rf_root_with_explanation() {
454        let reason = assert_refused("rm -rf /", &lesson_cwd());
455        assert!(reason.contains("no undo"), "reason: {reason}");
456        assert_refused("rm -fr /", &lesson_cwd());
457        assert_refused("rm -r -f /", &lesson_cwd());
458        assert_refused("rm -rf /*", &lesson_cwd());
459        assert_refused("rm --recursive --force /", &lesson_cwd());
460    }
461
462    #[test]
463    fn test_refuses_mkfs() {
464        let reason = assert_refused("mkfs /dev/sda1", &lesson_cwd());
465        assert!(reason.contains("format"), "reason: {reason}");
466        assert_refused("mkfs.ext4 /dev/sda1", &lesson_cwd());
467    }
468
469    #[test]
470    fn test_refuses_dd_to_device() {
471        let reason = assert_refused("dd if=image.iso of=/dev/sda", &lesson_cwd());
472        assert!(reason.contains("raw disk"), "reason: {reason}");
473    }
474
475    #[test]
476    fn test_refuses_fork_bomb() {
477        let reason = assert_refused(":(){ :|:& };:", &lesson_cwd());
478        assert!(reason.contains("fork bomb"), "reason: {reason}");
479        assert_refused(":(){:|:&};:", &lesson_cwd());
480    }
481
482    #[test]
483    fn test_refuses_chmod_777_root() {
484        let reason = assert_refused("chmod -R 777 /", &lesson_cwd());
485        assert!(reason.contains("security"), "reason: {reason}");
486    }
487
488    #[test]
489    fn test_refuses_redirect_to_dev() {
490        let reason = assert_refused("echo garbage > /dev/sda", &lesson_cwd());
491        assert!(reason.contains("/dev"), "reason: {reason}");
492        assert_refused("cat file >/dev/sdb1", &lesson_cwd());
493    }
494
495    #[test]
496    fn test_refuses_sudo_anything() {
497        let reason = assert_refused("sudo ls", &lesson_cwd());
498        assert!(reason.contains("administrator"), "reason: {reason}");
499        assert_refused("sudo rm -rf /tmp", &lesson_cwd());
500        assert_refused("sudo apt install cowsay", &lesson_cwd());
501        // sudo hidden behind a separator
502        assert_refused("ls; sudo whoami", &lesson_cwd());
503        assert_refused("ls && sudo whoami", &lesson_cwd());
504        assert_refused("ls | sudo tee /etc/x", &lesson_cwd());
505    }
506
507    #[test]
508    fn test_empty_command_is_allowed() {
509        assert_allowed("", &lesson_cwd());
510        assert_allowed("   ", &lesson_cwd());
511    }
512}