Skip to main content

mur_common/hitl/
dest.rs

1//! Destination-scoped read-only classification of a `bash` command.
2//!
3//! ## Why this exists
4//!
5//! The session grant is keyed on the TOOL NAME (`session_tool_allow`), and the
6//! ceiling that bounds it (`tier_may_be_granted`) stops at `Write`. Together
7//! those two facts make "don't ask again for `bash`" a lie for the one workflow
8//! that needs it most: remote diagnosis. Every `ssh host tail -n 120 log`
9//! classifies as `NetworkEgress`, `tier_may_be_granted` returns false, the
10//! session grant is never consulted, and the operator is asked again — with no
11//! feedback explaining why the answer they already gave did not stick.
12//!
13//! The grant was also the wrong SHAPE. `bash` is not a blast radius; a host is.
14//! "I trust reading on `karajan@people.example.edu`" is a sentence an operator
15//! can actually mean, and can actually audit. "I trust `bash`" is not.
16//!
17//! So a grant here is keyed on a DESTINATION plus a read-only proof:
18//!
19//! ```text
20//! ssh:karajan@people.example.edu:ro     ← reads on that host, this session
21//! local:ro                              ← reads on this machine, this session
22//! ```
23//!
24//! Nothing that writes is ever grantable, at either end. A write still asks
25//! every time, which is the whole reason the read grant can be this wide.
26//!
27//! ## What is proved, and what is not
28//!
29//! Same posture as [`super::bash_class`]: this is a deny-list over command
30//! heads wrapped in a quote-aware parser, and a deny-list is only as good as
31//! its last audit. Every uncertainty returns `None` (→ an ordinary prompt).
32//! In particular:
33//!
34//! - **Unknown head ⇒ not read-only.** Never the other way round.
35//! - **Substitution is fatal.** A backtick or `$(…)` anywhere outside single
36//!   quotes ends the classification: its output is a command this parser
37//!   cannot see.
38//! - **Redirection is fatal.** `>`, `>>` and `<` all mean bytes move somewhere
39//!   this parser is not modelling. Heredocs are the one exception, because
40//!   they are parsed (below) rather than guessed at.
41//! - **One hop only.** An `ssh` inside an `ssh` payload is a second
42//!   destination; the human named one. Two hops ⇒ `None`.
43//! - **One destination per command.** `ssh a … && ssh b …` is not grantable
44//!   under either host's key.
45//!
46//! ## Heredocs, recursively
47//!
48//! The command that provoked this module looks like:
49//!
50//! ```text
51//! ssh -o BatchMode=yes user@host 'sh -s' <<'REMOTE'
52//! cd /srv/app || exit 1
53//! echo --TAIL--; tail -n 120 storage/logs/app.log
54//! REMOTE
55//! ```
56//!
57//! The interesting half is inside the heredoc, so a parser that stops at the
58//! `<<` has classified nothing. [`strip_heredocs`] lifts each body out and
59//! leaves a placeholder token in its place, which keeps the body bound to the
60//! exact segment that redirected it. Each body is then classified by the same
61//! entry point that classified the outer line — so a heredoc inside a heredoc
62//! is handled by construction, not by a second code path.
63//!
64//! A heredoc whose tag is UNQUOTED (`<<EOF`, not `<<'EOF'`) is expanded by the
65//! local shell before it is sent. If such a body contains `$` or a backtick,
66//! the expansion is local execution this parser cannot see, and the whole
67//! command is refused.
68
69use std::fmt;
70
71use super::bash_class;
72
73/// Where a command's effects land.
74#[derive(Clone, PartialEq, Eq, Debug)]
75pub enum Destination {
76    /// This machine.
77    Local,
78    /// One `ssh` hop, stored as the operator wrote it (`[user@]host`),
79    /// lowercased so `Host` and `host` cannot buy two separate grants.
80    Remote(String),
81}
82
83impl fmt::Display for Destination {
84    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
85        match self {
86            Self::Local => f.write_str("this machine"),
87            Self::Remote(h) => f.write_str(h),
88        }
89    }
90}
91
92/// A grantable scope: one destination, proved read-only there.
93///
94/// Constructed ONLY by [`classify`], and only when the proof succeeded — there
95/// is deliberately no way to build a writing scope, because a writing scope is
96/// not a thing a session grant may hold.
97#[derive(Clone, PartialEq, Eq, Debug)]
98pub struct Scope {
99    pub dest: Destination,
100}
101
102impl Scope {
103    /// The session-grant key. Carries `:ro` even though every scope is
104    /// read-only today, so that a future writing scope cannot silently inherit
105    /// a key an operator granted for reads.
106    pub fn key(&self) -> String {
107        match &self.dest {
108            Destination::Local => "local:ro".to_string(),
109            Destination::Remote(h) => format!("ssh:{h}:ro"),
110        }
111    }
112
113    /// The menu-row wording. Says the destination and the limit, because a
114    /// grant an operator cannot restate is a grant they cannot audit.
115    pub fn label(&self) -> String {
116        match &self.dest {
117            Destination::Local => {
118                "Yes, and don't ask again for read-only commands on this machine this session"
119                    .to_string()
120            }
121            Destination::Remote(h) => {
122                format!("Yes, and don't ask again for read-only commands on `{h}` this session")
123            }
124        }
125    }
126}
127
128/// Heads that read, on top of [`bash_class`]'s audited list, and that a remote
129/// diagnosis session cannot do without.
130///
131/// DELIBERATELY ABSENT — do not add without reading why:
132///   `sed`  — `-i` edits in place and a `w` command writes a file; telling
133///            those apart from `/warn/p` needs a sed parser, not a guard.
134///   `sort` — `-o FILE` overwrites (already noted in `bash_class`).
135///   `env`  — execs its argv, so it is arbitrary execution wearing a read's
136///            clothes (already noted in `bash_class`).
137///   `php`/`python`/`perl` — arbitrary programs. `php artisan tinker` is the
138///            exact call this module must keep asking about.
139const EXTRA_READONLY_HEADS: &[&str] = &[
140    // `exit` and `cd` change the shell's own state, not any bytes — and both
141    // are unavoidable in a remote script (`cd /srv/app || exit 1`).
142    "cd", "exit", "ps", "uptime", "free", "id", "groups", "vmstat", "iostat", "lsof", "netstat",
143    "ss", "last",
144];
145
146/// `awk` reads UNLESS its program writes. `print > "f"`, `printf >> "f"` and
147/// `system("…")` all escape, and all of them need a character this test can
148/// look for. Checked against the WHOLE segment, so a redirect hidden inside
149/// the quoted program is still seen.
150const AWK_WRITE_MARKERS: &[&str] = &["system(", ">", "close(", "|&", "ENVIRON["];
151
152/// One parsed token. `quoted` records whether the operator wrapped it, which
153/// is what distinguishes `ssh host 'cd /x && tail f'` (one remote payload)
154/// from `ssh host cd /x && tail f` (a local `tail` after the hop).
155#[derive(Clone, Debug, PartialEq, Eq)]
156pub struct Word {
157    pub text: String,
158    pub quoted: bool,
159}
160
161/// What a tokenizer pass produced: words and the operators between them.
162#[derive(Clone, Debug, PartialEq, Eq)]
163pub enum Token {
164    Word(Word),
165    /// A separator that ends one simple command: `;` `&&` `||` `|` or newline.
166    Sep,
167}
168
169/// Placeholder left in the line where a heredoc body was lifted out. Uses
170/// control characters so no real command can collide with it.
171fn heredoc_marker(i: usize) -> String {
172    format!("\u{1}HEREDOC{i}\u{1}")
173}
174
175/// A heredoc body lifted out of a command line.
176struct Heredoc {
177    body: String,
178    /// `<<'TAG'` / `<<"TAG"` — the body is sent literally. An unquoted tag is
179    /// expanded locally first, which is a different (and more dangerous) thing.
180    tag_quoted: bool,
181}
182
183/// Lift every heredoc body out of `cmd`, leaving a placeholder word where the
184/// `<<TAG` operator stood.
185///
186/// The placeholder matters: it keeps each body attached to the segment that
187/// redirected it, so `a <<X` and `b <<Y` cannot have their bodies swapped by a
188/// later pass. Returns `None` if a heredoc is opened and never terminated —
189/// an unterminated body is content this parser never sees.
190fn strip_heredocs(cmd: &str) -> Option<(String, Vec<Heredoc>)> {
191    let mut line = String::new();
192    let mut docs: Vec<Heredoc> = Vec::new();
193    let mut rest = cmd.to_string();
194    loop {
195        let Some(pos) = find_heredoc_op(&rest) else {
196            line.push_str(&rest);
197            return Some((line, docs));
198        };
199        line.push_str(&rest[..pos]);
200        // Past the `<<`, and past a `-` (tab-stripping form).
201        let after = &rest[pos + 2..];
202        let after = after.strip_prefix('-').unwrap_or(after);
203        let after = after.trim_start_matches(' ');
204        let (tag, tag_quoted, consumed) = read_heredoc_tag(after)?;
205        let body_start = &after[consumed..];
206        // The body begins on the NEXT line.
207        let nl = body_start.find('\n')?;
208        let (head_tail, body_and_rest) = body_start.split_at(nl + 1);
209        let (body, remainder) = split_at_tag(body_and_rest, &tag)?;
210        line.push_str(&heredoc_marker(docs.len()));
211        line.push_str(head_tail);
212        docs.push(Heredoc { body, tag_quoted });
213        rest = remainder;
214    }
215}
216
217/// Byte offset of the next `<<` that is a heredoc operator — outside quotes,
218/// and not the `<<<` here-string (whose word is data, not a body).
219fn find_heredoc_op(s: &str) -> Option<usize> {
220    let b = s.as_bytes();
221    let mut i = 0;
222    let mut quote: Option<u8> = None;
223    while i < b.len() {
224        let c = b[i];
225        match quote {
226            Some(q) => {
227                if c == q {
228                    quote = None;
229                }
230            }
231            None => {
232                if c == b'\'' || c == b'"' {
233                    quote = Some(c);
234                } else if c == b'<' && b.get(i + 1) == Some(&b'<') {
235                    if b.get(i + 2) == Some(&b'<') {
236                        return None; // here-string: not a body, refuse later
237                    }
238                    return Some(i);
239                }
240            }
241        }
242        i += 1;
243    }
244    None
245}
246
247/// Read the heredoc tag at the start of `s`. Returns the tag, whether it was
248/// quoted, and how many bytes it occupied.
249fn read_heredoc_tag(s: &str) -> Option<(String, bool, usize)> {
250    let b = s.as_bytes();
251    let first = *b.first()?;
252    if first == b'\'' || first == b'"' {
253        let end = s[1..].find(first as char)? + 1;
254        return Some((s[1..end].to_string(), true, end + 1));
255    }
256    let end = s
257        .find(|c: char| c.is_whitespace() || c == ';' || c == '&' || c == '|')
258        .unwrap_or(s.len());
259    if end == 0 {
260        return None;
261    }
262    Some((s[..end].to_string(), false, end))
263}
264
265/// Split a heredoc body at its terminator line. The terminator is a line that
266/// is exactly the tag (after trimming, which covers the `<<-` form).
267fn split_at_tag(s: &str, tag: &str) -> Option<(String, String)> {
268    let mut offset = 0usize;
269    for line in s.split_inclusive('\n') {
270        if line.trim() == tag {
271            let body = s[..offset].to_string();
272            let rest = s[offset + line.len()..].to_string();
273            return Some((body, rest));
274        }
275        offset += line.len();
276    }
277    None
278}
279
280/// Tokenize one heredoc-free command line.
281///
282/// Returns `None` the moment it sees something whose behaviour it cannot
283/// model: command substitution, any redirection, a background `&`, or a
284/// subshell. Each of those is a place where bytes or control go somewhere the
285/// segment list does not describe.
286pub fn tokenize(line: &str) -> Option<Vec<Token>> {
287    let mut out: Vec<Token> = Vec::new();
288    let mut cur = String::new();
289    let mut cur_quoted = false;
290    let mut have_cur = false;
291    let b: Vec<char> = line.chars().collect();
292    let mut i = 0usize;
293
294    macro_rules! flush {
295        () => {
296            if have_cur {
297                out.push(Token::Word(Word {
298                    text: std::mem::take(&mut cur),
299                    quoted: cur_quoted,
300                }));
301                #[allow(unused_assignments)]
302                {
303                    cur_quoted = false;
304                    have_cur = false;
305                }
306            }
307        };
308    }
309
310    while i < b.len() {
311        let c = b[i];
312        match c {
313            '\'' => {
314                let end = b[i + 1..].iter().position(|&x| x == '\'')? + i + 1;
315                cur.push_str(&b[i + 1..end].iter().collect::<String>());
316                cur_quoted = true;
317                have_cur = true;
318                i = end + 1;
319            }
320            '"' => {
321                let end = b[i + 1..].iter().position(|&x| x == '"')? + i + 1;
322                let inner: String = b[i + 1..end].iter().collect();
323                // A double-quoted string still expands `$(…)` and backticks.
324                if inner.contains('`') || inner.contains("$(") {
325                    return None;
326                }
327                cur.push_str(&inner);
328                cur_quoted = true;
329                have_cur = true;
330                i = end + 1;
331            }
332            // Substitution: the output is a command we cannot see.
333            '`' => return None,
334            '$' if b.get(i + 1) == Some(&'(') => return None,
335            // #1878: `2>/dev/null`, `>/dev/null`, `2>&1` only discard or join
336            // the command's own streams; they cannot write a file. Skip them
337            // whole. Every other redirect is still refused below.
338            '>' | '1' | '2' if !have_cur => match harmless_redirect_len(&b[i..]) {
339                Some(n) => i += n,
340                None if c == '>' => return None,
341                None => {
342                    cur.push(c);
343                    have_cur = true;
344                    i += 1;
345                }
346            },
347            // Redirection of any kind (heredocs were lifted out already).
348            '>' | '<' => return None,
349            // Subshells and process substitution.
350            '(' | ')' => return None,
351            ';' | '\n' => {
352                flush!();
353                out.push(Token::Sep);
354                i += 1;
355            }
356            '&' => {
357                if b.get(i + 1) == Some(&'&') {
358                    flush!();
359                    out.push(Token::Sep);
360                    i += 2;
361                } else {
362                    return None; // background: the command outlives the gate
363                }
364            }
365            '|' => {
366                flush!();
367                out.push(Token::Sep);
368                i += if b.get(i + 1) == Some(&'|') { 2 } else { 1 };
369            }
370            c if c.is_whitespace() => {
371                flush!();
372                i += 1;
373            }
374            c => {
375                cur.push(c);
376                have_cur = true;
377                i += 1;
378            }
379        }
380    }
381    flush!();
382    Some(out)
383}
384
385/// Length of a stream-discard/join redirect at the start of `rest`, or `None`.
386///
387/// Accepts exactly `[1|2]>/dev/null` and `[1|2]>&[1|2]`, optionally with
388/// spaces after `>` before `/dev/null`, and only when followed by whitespace,
389/// an operator, or the end of the line — so `2>/dev/nullx`, `2>/dev/null/x`
390/// and `2>'/dev/null'` stay unparseable. `&>` and `>&-` never match.
391fn harmless_redirect_len(rest: &[char]) -> Option<usize> {
392    let mut i = usize::from(matches!(rest.first(), Some('1' | '2')));
393    if rest.get(i) != Some(&'>') {
394        return None;
395    }
396    i += 1;
397    let tail_ok = |j: usize| {
398        rest.get(j)
399            .is_none_or(|c| c.is_whitespace() || matches!(c, ';' | '|' | '&' | '\n'))
400    };
401    if rest.get(i) == Some(&'&') {
402        return (matches!(rest.get(i + 1), Some('1' | '2')) && tail_ok(i + 2)).then_some(i + 2);
403    }
404    while rest.get(i).is_some_and(|c| *c == ' ') {
405        i += 1;
406    }
407    let target: Vec<char> = DEV_NULL.chars().collect();
408    let end = i + target.len();
409    (rest.get(i..end) == Some(&target[..]) && tail_ok(end)).then_some(end)
410}
411
412/// The only file a redirect may name and still leave a read a read.
413const DEV_NULL: &str = "/dev/null";
414
415/// Group tokens into simple commands, dropping empty runs (`a ;; b`).
416pub fn segments(tokens: Vec<Token>) -> Vec<Vec<Word>> {
417    let mut out: Vec<Vec<Word>> = Vec::new();
418    let mut cur: Vec<Word> = Vec::new();
419    for t in tokens {
420        match t {
421            Token::Sep => {
422                if !cur.is_empty() {
423                    out.push(std::mem::take(&mut cur));
424                }
425            }
426            Token::Word(w) => cur.push(w),
427        }
428    }
429    if !cur.is_empty() {
430        out.push(cur);
431    }
432    out
433}
434
435/// `ssh` flags that consume the NEXT argument, so the target host is not
436/// mistaken for an option's value.
437const SSH_FLAGS_WITH_VALUE: &[&str] = &[
438    "-o", "-p", "-i", "-l", "-F", "-c", "-m", "-b", "-D", "-L", "-R", "-W", "-J", "-Q", "-E", "-S",
439];
440
441/// Is this one simple command a read, ignoring where it runs?
442pub fn segment_is_readonly(seg: &[Word]) -> bool {
443    let Some(head) = seg.first() else {
444        return false;
445    };
446    let whole = seg
447        .iter()
448        .map(|w| w.text.as_str())
449        .collect::<Vec<_>>()
450        .join(" ");
451    match head.text.as_str() {
452        // `awk` reads unless its program writes or shells out.
453        "awk" | "gawk" | "mawk" => !AWK_WRITE_MARKERS.iter().any(|m| whole.contains(m)),
454        // `cd` moves the cursor, it does not touch bytes.
455        "cd" => true,
456        other => {
457            if EXTRA_READONLY_HEADS.contains(&other) {
458                return true;
459            }
460            // Delegate to the audited list, carrying the quoting through: a
461            // metacharacter inside a quoted word is data this pass already
462            // unquoted, not an operator, and re-checking the re-joined text
463            // would reject it.
464            let words: Vec<(&str, bool)> =
465                seg.iter().map(|w| (w.text.as_str(), w.quoted)).collect();
466            bash_class::is_readonly_words(&words)
467        }
468    }
469}
470
471/// Classify a whole `bash` command into a grantable scope, or `None`.
472///
473/// `None` means "not grantable" and NEVER means "safe": every caller must
474/// treat it as an ordinary prompt.
475pub fn classify_bash(cmd: &str) -> Option<Scope> {
476    classify_inner(cmd, 0)
477}
478
479/// `depth` counts `ssh` hops. One is the most a grant may describe, because
480/// the operator named one host.
481fn classify_inner(cmd: &str, depth: u8) -> Option<Scope> {
482    if depth > 1 {
483        return None;
484    }
485    let (line, docs) = strip_heredocs(cmd)?;
486    let segs = segments(tokenize(&line)?);
487    if segs.is_empty() {
488        return None;
489    }
490    let mut dest: Option<Destination> = None;
491
492    for seg in &segs {
493        let seg_dest = if seg[0].text == "ssh" {
494            let (host, payload) = ssh_target_and_payload(seg, &docs)?;
495            // The payload must itself be a read, at the far end. Recursing
496            // through the same entry point is what makes a heredoc inside a
497            // heredoc work without a second parser.
498            let inner = classify_inner(&payload, depth + 1)?;
499            if inner.dest != Destination::Local {
500                return None; // a second hop; the human named one host
501            }
502            Destination::Remote(host)
503        } else {
504            if !segment_is_readonly(seg) {
505                return None;
506            }
507            // A placeholder surviving on a non-ssh segment means a heredoc fed
508            // some other command — content this pass did not classify.
509            if seg.iter().any(|w| w.text.contains('\u{1}')) {
510                return None;
511            }
512            Destination::Local
513        };
514        match (&dest, &seg_dest) {
515            (None, _) => dest = Some(seg_dest),
516            // Local setup around one remote hop keeps the remote scope.
517            (Some(Destination::Local), Destination::Remote(_)) => dest = Some(seg_dest),
518            (Some(Destination::Remote(_)), Destination::Local) => {}
519            (Some(a), b) if a == b => {}
520            // Two different hosts in one command: grantable under neither.
521            _ => return None,
522        }
523    }
524    dest.map(|dest| Scope { dest })
525}
526
527/// Pull the `[user@]host` and the remote payload out of one `ssh` segment.
528///
529/// The payload is whatever follows the target: either arguments (`ssh h tail
530/// -n 5 f`), a single quoted script (`ssh h 'a && b'`), or a heredoc fed to a
531/// stdin shell (`ssh h sh -s <<'EOF'`). The three produce the same thing — a
532/// string to be classified by the same rules as any other command line.
533fn ssh_target_and_payload(seg: &[Word], docs: &[Heredoc]) -> Option<(String, String)> {
534    let mut i = 1usize;
535    while i < seg.len() {
536        let t = seg[i].text.as_str();
537        if SSH_FLAGS_WITH_VALUE.contains(&t) {
538            i += 2;
539            continue;
540        }
541        if t.starts_with('-') {
542            i += 1;
543            continue;
544        }
545        break;
546    }
547    let target = seg.get(i)?.text.to_ascii_lowercase();
548    // A bare `-` or an option value that slipped through is not a host.
549    if target.is_empty() || target.starts_with('-') {
550        return None;
551    }
552    let rest = &seg[i + 1..];
553
554    // Any heredoc placeholder in the remaining words IS the payload: the
555    // command words are then just the stdin shell that consumes it.
556    for w in rest {
557        if let Some(body) = heredoc_for(&w.text, docs) {
558            return Some((target, body?));
559        }
560    }
561    if rest.is_empty() {
562        // `ssh host` with no command is an interactive login, not a read.
563        return None;
564    }
565    Some((
566        target,
567        rest.iter()
568            .map(|w| w.text.as_str())
569            .collect::<Vec<_>>()
570            .join(" "),
571    ))
572}
573
574/// Resolve a placeholder word to its body. The outer `Option` is "was this a
575/// placeholder at all"; the inner one is "is the body usable" — an unquoted
576/// tag whose body would be expanded locally is not.
577fn heredoc_for(word: &str, docs: &[Heredoc]) -> Option<Option<String>> {
578    if !word.contains('\u{1}') {
579        return None;
580    }
581    for (i, d) in docs.iter().enumerate() {
582        if word.contains(&heredoc_marker(i)) {
583            if !d.tag_quoted && (d.body.contains('$') || d.body.contains('`')) {
584                return Some(None);
585            }
586            return Some(Some(d.body.clone()));
587        }
588    }
589    Some(None)
590}
591
592#[cfg(test)]
593mod tests;