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;