Skip to main content

tracevault_core/
bash_command.rs

1//! Minimal, conservative classification of Bash command strings for policy
2//! decisions. The first consumer is the verification-phase gate, which must
3//! recognize a standalone `tracevault verify-start` (the command that opens
4//! the phase) WITHOUT being fooled by compounds that hide other commands.
5
6/// True iff `command` is a single, simple invocation of `tracevault <subcommand>`
7/// — no shell compounding of any kind. Rejecting compounds is the security
8/// crux: `tracevault verify-start && rm -rf /` must NOT match, or the gate
9/// could be tricked into ignoring a hidden command.
10pub fn is_standalone_tracevault_subcommand(command: &str, subcommand: &str) -> bool {
11    let trimmed = command.trim();
12
13    // Reject anything that could chain, redirect, background, or substitute a
14    // second command. ("&&"/"||" are covered by "&"/"|".) VT/FF are added
15    // because Rust's split_whitespace treats them as whitespace but Bash does not.
16    const COMPOUND_MARKERS: &[&str] = &[
17        ";", "|", "&", "\n", "\r", "\u{0B}", "\u{0C}", "`", "$(", ">", "<",
18    ];
19    if COMPOUND_MARKERS.iter().any(|m| trimmed.contains(m)) {
20        return false;
21    }
22
23    let mut tokens = trimmed.split_whitespace();
24    let Some(arg0) = tokens.next() else {
25        return false;
26    };
27    // An env-assignment prefix (NAME=value) makes the *next* token the real
28    // command, so `EVIL=/tracevault evil` would run `evil`. Reject it.
29    if arg0.contains('=') {
30        return false;
31    }
32    // basename of argv[0] must be exactly `tracevault` (allow an absolute/relative path prefix).
33    let prog = arg0.rsplit(['/', '\\']).next().unwrap_or(arg0);
34    if prog != "tracevault" {
35        return false;
36    }
37    // argv[1] must be exactly the expected subcommand.
38    tokens.next() == Some(subcommand)
39}
40
41#[cfg(test)]
42mod tests {
43    use super::*;
44
45    #[test]
46    fn matches_bare_invocation() {
47        assert!(is_standalone_tracevault_subcommand(
48            "tracevault verify-start",
49            "verify-start"
50        ));
51    }
52
53    #[test]
54    fn matches_with_path_and_flags() {
55        assert!(is_standalone_tracevault_subcommand(
56            "/usr/local/bin/tracevault verify-start --session-id abc",
57            "verify-start"
58        ));
59    }
60
61    #[test]
62    fn matches_with_surrounding_whitespace() {
63        assert!(is_standalone_tracevault_subcommand(
64            "  tracevault   verify-start  ",
65            "verify-start"
66        ));
67    }
68
69    #[test]
70    fn rejects_compound_with_and() {
71        assert!(!is_standalone_tracevault_subcommand(
72            "tracevault verify-start && rm -rf /",
73            "verify-start"
74        ));
75    }
76
77    #[test]
78    fn rejects_compound_with_semicolon_and_pipe() {
79        assert!(!is_standalone_tracevault_subcommand(
80            "tracevault verify-start; evil",
81            "verify-start"
82        ));
83        assert!(!is_standalone_tracevault_subcommand(
84            "tracevault verify-start | tee log",
85            "verify-start"
86        ));
87    }
88
89    #[test]
90    fn rejects_command_substitution_and_background() {
91        assert!(!is_standalone_tracevault_subcommand(
92            "tracevault verify-start $(evil)",
93            "verify-start"
94        ));
95        assert!(!is_standalone_tracevault_subcommand(
96            "tracevault verify-start &",
97            "verify-start"
98        ));
99        assert!(!is_standalone_tracevault_subcommand(
100            "tracevault verify-start `evil`",
101            "verify-start"
102        ));
103    }
104
105    #[test]
106    fn rejects_disguised_argv0() {
107        assert!(!is_standalone_tracevault_subcommand(
108            "echo tracevault verify-start",
109            "verify-start"
110        ));
111    }
112
113    #[test]
114    fn rejects_wrong_subcommand() {
115        assert!(!is_standalone_tracevault_subcommand(
116            "tracevault check",
117            "verify-start"
118        ));
119    }
120
121    #[test]
122    fn rejects_newline_compound() {
123        assert!(!is_standalone_tracevault_subcommand(
124            "tracevault verify-start\nrm -rf /",
125            "verify-start"
126        ));
127    }
128
129    #[test]
130    fn rejects_env_assignment_prefix_bypass() {
131        // `NAME=value cmd` runs `cmd`, not tracevault — must not be accepted.
132        assert!(!is_standalone_tracevault_subcommand(
133            "EVIL=/tracevault verify-start",
134            "verify-start"
135        ));
136        assert!(!is_standalone_tracevault_subcommand(
137            "X=/tracevault evil-cmd",
138            "verify-start"
139        ));
140        assert!(!is_standalone_tracevault_subcommand(
141            "tracevault=x verify-start",
142            "verify-start"
143        ));
144    }
145
146    #[test]
147    fn rejects_vertical_tab_and_form_feed_separators() {
148        assert!(!is_standalone_tracevault_subcommand(
149            "tracevault verify-start\u{0B}evil",
150            "verify-start"
151        ));
152        assert!(!is_standalone_tracevault_subcommand(
153            "tracevault verify-start\u{0C}evil",
154            "verify-start"
155        ));
156    }
157}