trusty-mpm 1.8.2

trusty-mpm: unified multi-agent orchestration platform (core, daemon, CLI, TUI, Telegram)
//! Fail-closed tokenizer for the read-only dispatch rule (#8439).
//!
//! Why: the first cut of #8439 lexed the whole shell grammar and tried to
//! classify every construct. Its lexer skipped only ` ` and `\t` between words
//! while its word reader stopped at any Unicode whitespace, so a `\r` or NBSP
//! left the index still — the hook looped until its 10 s timeout and the
//! command then ran. This lexer accepts a small, closed alphabet and refuses
//! every byte outside it, so there is no construct it can half-read.
//! What: [`lex`] turns a command into [`Tok`]s or an `Err` naming what it
//! refused. Accepted bytes are printable ASCII plus space, tab and newline.
//! Words are unquoted runs of [`BARE`] bytes, `'…'` spans, and `"…"` spans
//! with no backtick or `!`. Inside `"…"`, a `\` escape and a `$` the shell
//! leaves literal are decoded as pattern text and mark the word
//! [`Word::pattern`] (#8586); any other `$` is an `Err`. Two expansions are
//! recognised so the caller can confine them: `"$NAME"`/`"${NAME}"`
//! ([`WordKind::Var`]) and a bare path word led by `/` or `~/` carrying
//! `*`/`?` ([`WordKind::Expanding`]).
//! Operators are `|`, `;`/newline, `&&`, and the exact tokens `2>&1` and
//! `2>/dev/null`; the parser admits `&&` only after a leading `cd <dir>`
//! (#8578). Everything else — `$`, backtick, `\`, `<`, `>`, a lone `&`, parens,
//! braces, brackets, `#`, `!`, a leading `=` or `^`, `~` anywhere but a word's
//! start or mid-word — is an `Err`. Every loop iteration consumes at least one
//! byte, so lexing is linear in the input and always terminates.
//! Test: `read_only_allow_tests::lexer_refuses_every_byte_outside_its_alphabet`,
//! `read_only_allow_tests::lexing_terminates_on_arbitrary_bytes`,
//! `read_only_allow_tests::quoted_rg_grep_patterns_are_pattern_text`.

/// Longest command the rule will read; longer is refused (#8439).
const MAX_COMMAND_BYTES: usize = 16 * 1024;

/// Bytes a bare (unquoted) word may carry beyond ASCII alphanumerics.
///
/// Why: each is inert in bash and zsh when unquoted and not at a word's start;
/// `*`/`?` are globs, confined by the caller to a `for` list.
const BARE: &[u8] = b"_-./,:@%+=*?~^";

/// One lexed token.
#[derive(Debug, Clone, PartialEq, Eq)]
pub(super) enum Tok {
    /// A word.
    Word(Word),
    /// `|`.
    Pipe,
    /// `;` or a newline.
    Sep,
    /// `&&` (#8578).
    AndIf,
    /// The exact token `2>&1` or `2>/dev/null`: stderr joins stdout or is dropped.
    StderrRedirect,
}

/// How much of a word's value is known before the shell runs it.
#[derive(Debug, Clone, PartialEq, Eq)]
pub(super) enum WordKind {
    /// No expansion: the value is [`Word::text`].
    Lit,
    /// `"$NAME"` or `"${NAME}"`: the named variable's value, unsplit.
    Var(String),
    /// A bare path led by `/` or `~/`, expanded by tilde and/or glob.
    Expanding,
}

/// A lexed word.
#[derive(Debug, Clone, PartialEq, Eq)]
pub(super) struct Word {
    /// The decoded value for [`WordKind::Lit`]; the raw text otherwise.
    pub(super) text: String,
    /// No quote character appears in the word.
    pub(super) bare: bool,
    /// See [`WordKind`].
    pub(super) kind: WordKind,
    /// #8586: a `"…"` span decoded a `\` escape or a literal `$`; the parser
    /// admits such a word only as an `rg`/`grep` argument.
    pub(super) pattern: bool,
}

impl Word {
    /// The literal value, when the word has one.
    pub(super) fn lit(&self) -> Option<&str> {
        (self.kind == WordKind::Lit).then_some(self.text.as_str())
    }

    /// A bare literal equal to `keyword`.
    pub(super) fn is_keyword(&self, keyword: &str) -> bool {
        self.bare && self.lit() == Some(keyword)
    }
}

/// Is `b` a word delimiter?
fn delimits(b: u8) -> bool {
    matches!(b, b' ' | b'\t' | b'\n' | b';' | b'|')
}

/// Tokenize `command`, refusing every construct outside the closed alphabet.
///
/// Why: see the module doc.
/// What: `Ok(tokens)` or `Err(what was refused)`.
/// Test: as the module doc.
pub(super) fn lex(command: &str) -> Result<Vec<Tok>, String> {
    let bytes = command.as_bytes();
    if bytes.len() > MAX_COMMAND_BYTES {
        return Err("a command longer than the guard reads".into());
    }
    if let Some(bad) = bytes
        .iter()
        .find(|&&b| !(b == b'\n' || b == b'\t' || (0x20..=0x7e).contains(&b)))
    {
        return Err(format!(
            "the byte 0x{bad:02x}, which the guard does not read"
        ));
    }
    let mut toks = Vec::new();
    let mut i = 0;
    while let Some(&b) = bytes.get(i) {
        match b {
            b' ' | b'\t' => i += 1,
            b'\n' | b';' => {
                toks.push(Tok::Sep);
                i += 1;
            }
            b'|' => {
                if bytes.get(i + 1) == Some(&b'|') {
                    return Err("`||`".into());
                }
                toks.push(Tok::Pipe);
                i += 1;
            }
            // #8578: `&&` is a token so the parser can admit `cd <dir> && …`;
            // a lone `&` (background, `&>`) stays refused.
            b'&' if bytes.get(i + 1) == Some(&b'&') => {
                toks.push(Tok::AndIf);
                i += 2;
            }
            _ => {
                if let Some(len) = stderr_redirect_at(bytes, i) {
                    toks.push(Tok::StderrRedirect);
                    i += len;
                    continue;
                }
                let (word, next) = word_at(bytes, i)?;
                toks.push(Tok::Word(word));
                i = next;
            }
        }
    }
    Ok(toks)
}

/// The length of a `2>&1` / `2>/dev/null` token at `i`, when one stands there.
fn stderr_redirect_at(bytes: &[u8], i: usize) -> Option<usize> {
    [b"2>&1".as_slice(), b"2>/dev/null".as_slice()]
        .into_iter()
        .find(|t| bytes[i..].starts_with(t) && bytes.get(i + t.len()).is_none_or(|&b| delimits(b)))
        .map(<[u8]>::len)
}

/// Lex one word starting at `start`; returns it and the index after it.
///
/// What: every branch advances `j` or returns, so the loop terminates.
fn word_at(bytes: &[u8], start: usize) -> Result<(Word, usize), String> {
    if let Some(var) = var_word_at(bytes, start) {
        return Ok(var);
    }
    let mut text = String::new();
    let mut bare = true;
    let mut expands = false;
    let mut pattern = false;
    let mut j = start;
    while let Some(&b) = bytes.get(j) {
        if delimits(b) {
            break;
        }
        match b {
            b'\'' | b'"' => {
                let (decoded, close, escaped) = quoted_span(bytes, j)?;
                text.push_str(&decoded);
                pattern |= escaped;
                bare = false;
                j = close + 1;
            }
            b'~' if j == start && bytes.get(j + 1) == Some(&b'/') => {
                expands = true;
                text.push('~');
                j += 1;
            }
            b'~' if j == start || matches!(bytes[j - 1], b'=' | b':') => {
                return Err("a `~` expansion".into());
            }
            b'=' | b'^' if j == start => {
                return Err(format!("a word led by `{}`", char::from(b)));
            }
            b'^' if bytes[j - 1] == b'/' => return Err("a `/^` glob".into()),
            b'*' | b'?' => {
                expands = true;
                text.push(char::from(b));
                j += 1;
            }
            _ if b.is_ascii_alphanumeric() || BARE.contains(&b) => {
                text.push(char::from(b));
                j += 1;
            }
            _ => return Err(format!("`{}`", char::from(b))),
        }
    }
    if !expands {
        return Ok((
            Word {
                text,
                bare,
                kind: WordKind::Lit,
                pattern,
            },
            j,
        ));
    }
    // #8439: a glob or tilde word is kept only as a bare path, so its expansion
    // always starts with `/` (or stays a literal `~/…`) and can never be a flag.
    let path = text.starts_with('/') || text.starts_with("~/");
    if !bare || !path {
        return Err("a glob outside a bare absolute path".into());
    }
    Ok((
        Word {
            text,
            bare,
            kind: WordKind::Expanding,
            pattern: false,
        },
        j,
    ))
}

/// The decoded text of the quoted span opened at `open`, the index of its
/// closing quote, and whether it decoded a `\` escape or a literal `$`.
///
/// Why (#8586): a read-only agent's `rg "fn \w+\("` or `grep "^\[x\]$"` was
/// refused because the span refused every `\` and `$`, though bash and zsh
/// pass both to the program as text there.
/// What: a `'…'` span may hold any accepted byte but a newline, verbatim. A
/// `"…"` span refuses backtick and `!`, which are live there. It decodes `\X`
/// as the shell does — `X` alone for `X` in `` $`"\ ``, `\X` otherwise — so
/// `\"` never closes the span; `\!` and `\` before a newline are refused. A
/// `$` is kept only before the closing quote, `|` or `)`, where bash, zsh
/// and sh all leave it literal; any other `$` (`$(`, `${`, `$NAME`) is an
/// `Err`. An unclosed span is an `Err`.
/// Test: `read_only_allow_tests::quoted_rg_grep_patterns_are_pattern_text`,
/// `read_only_allow_tests::shell_syntax_around_quoted_patterns_is_refused`.
fn quoted_span(bytes: &[u8], open: usize) -> Result<(String, usize, bool), String> {
    let quote = bytes[open];
    let mut text = String::new();
    let mut pattern = false;
    let mut k = open + 1;
    while let Some(&b) = bytes.get(k) {
        if b == quote {
            return Ok((text, k, pattern));
        }
        if b == b'\n' {
            return Err("a newline inside quotes".into());
        }
        if quote == b'"' {
            match b {
                b'`' | b'!' => return Err(format!("`{}` inside double quotes", char::from(b))),
                // #8586: `\X` is two bytes of pattern text; `\"` never closes.
                b'\\' => {
                    let next = *bytes.get(k + 1).ok_or("an unclosed quote")?;
                    if matches!(next, b'\n' | b'!') {
                        return Err("a `\\` before a newline or `!` inside double quotes".into());
                    }
                    if !matches!(next, b'$' | b'`' | b'"' | b'\\') {
                        text.push('\\');
                    }
                    text.push(char::from(next));
                    pattern = true;
                    k += 2;
                    continue;
                }
                // #8586: a regex anchor `$` the shell leaves literal.
                b'$' if matches!(bytes.get(k + 1), Some(b'"' | b'|' | b')')) => pattern = true,
                b'$' => return Err("a `$` expansion inside double quotes".into()),
                _ => {}
            }
        }
        text.push(char::from(b));
        k += 1;
    }
    Err("an unclosed quote".into())
}

/// A whole word that is exactly `"$NAME"` or `"${NAME}"`.
fn var_word_at(bytes: &[u8], start: usize) -> Option<(Word, usize)> {
    let rest = bytes.get(start..)?.strip_prefix(b"\"$")?;
    let close = rest.iter().position(|&b| b == b'"')?;
    let inner = &rest[..close];
    let name = match inner.strip_prefix(b"{") {
        Some(braced) => braced.strip_suffix(b"}")?,
        None => inner,
    };
    let valid = name
        .first()
        .is_some_and(|b| b.is_ascii_alphabetic() || *b == b'_')
        && name.iter().all(|b| b.is_ascii_alphanumeric() || *b == b'_');
    let end = start + 2 + close + 1;
    if !valid || bytes.get(end).is_some_and(|&b| !delimits(b)) {
        return None;
    }
    let name = String::from_utf8_lossy(name).into_owned();
    let text = String::from_utf8_lossy(&bytes[start..end]).into_owned();
    Some((
        Word {
            text,
            bare: false,
            kind: WordKind::Var(name),
            pattern: false,
        },
        end,
    ))
}