Skip to main content

safe_chains/
suggest.rs

1//! `--suggest`: generate the minimal custom-command TOML that would let safe-chains recognize a
2//! command it currently denies because the command NAME is unknown.
3//!
4//! OPT-IN only. Nothing here is ever referenced by the deny/hook output — a user reaches it solely by
5//! running `safe-chains --suggest "<cmd>"`. It rides the existing trust rails (`registry::custom`):
6//! the generated `[[command]]` block goes in a project `.safe-chains.toml`, which is inert until the
7//! user hand-pins its SHA-256 in the write-protected `~/.config/safe-chains.toml`. This module never
8//! touches `~/`, so it cannot self-approve.
9//!
10//! Scope (v1): the "I don't recognize this tool" case. For each simple command whose name safe-chains
11//! doesn't know, emit a `[[command]]` scoped to exactly the observed flags and positional count.
12//! Commands safe-chains DOES recognize are never overridden — a recognized command that is denied is
13//! a deliberate classification (a flag, subcommand, or path), not an unknown, so `--suggest` reports
14//! that rather than generating a bypass.
15
16use std::collections::{BTreeMap, BTreeSet};
17use std::sync::OnceLock;
18
19use sha2::{Digest, Sha256};
20
21use crate::cst::{self, Cmd, Script, SimpleCmd, Word, WordPart};
22use crate::registry;
23
24/// safe-chains cannot infer an unknown tool's real risk, so the generated entry defaults to the
25/// developer band's ceiling — it auto-approves at the default level and the user narrows it (to
26/// `SafeRead`/`Inert`) if the tool is lighter. The `~/` hash-pin review is the backstop.
27const DEFAULT_LEVEL: &str = "SafeWrite";
28
29/// A scoped custom-command entry for one unknown command name.
30#[derive(Debug, Clone, PartialEq, Eq)]
31pub struct GeneratedEntry {
32    pub name: String,
33    /// Observed flags, sorted and de-duplicated (every `-`-prefixed argument, verbatim).
34    pub standalone: Vec<String>,
35    /// The largest positional-argument count observed across occurrences.
36    pub max_positional: usize,
37    pub level: String,
38}
39
40/// What `analyze` concluded about a command.
41#[derive(Debug, Clone, PartialEq, Eq)]
42pub enum Outcome {
43    /// Already auto-approves — nothing to suggest.
44    AlreadyAllowed,
45    /// Could not parse; a command definition can't fix a structural parse failure.
46    Unparseable,
47    /// Denied, but every command in it is one safe-chains RECOGNIZES — the block is a classification
48    /// decision, not an unknown command. `--suggest` will not generate an override.
49    RecognizedButDenied { names: Vec<String> },
50    /// One or more unknown commands can be supported by the generated entries. `also_recognized`
51    /// lists any recognized commands that also appear (informational).
52    Generated {
53        entries: Vec<GeneratedEntry>,
54        also_recognized: Vec<String>,
55    },
56}
57
58/// Analyze `command` and decide what, if anything, `--suggest` can generate. Pure — no I/O.
59pub fn analyze(command: &str) -> Outcome {
60    if cst::command_verdict(command).is_allowed() {
61        return Outcome::AlreadyAllowed;
62    }
63    let Some(script) = cst::parse(command) else {
64        return Outcome::Unparseable;
65    };
66
67    let mut simples: Vec<&SimpleCmd> = Vec::new();
68    collect_script(&script, &mut simples);
69
70    // name -> (observed flags, max observed positional count)
71    let mut unknown: BTreeMap<String, (BTreeSet<String>, usize)> = BTreeMap::new();
72    let mut recognized: BTreeSet<String> = BTreeSet::new();
73    for sc in simples {
74        let Some(name) = command_basename(sc) else {
75            continue;
76        };
77        if is_known(&name) {
78            recognized.insert(name);
79            continue;
80        }
81        let (flags, positionals) = observed_shape(sc);
82        let entry = unknown.entry(name).or_default();
83        entry.0.extend(flags);
84        entry.1 = entry.1.max(positionals);
85    }
86
87    if unknown.is_empty() {
88        return Outcome::RecognizedButDenied {
89            names: recognized.into_iter().collect(),
90        };
91    }
92    let entries = unknown
93        .into_iter()
94        .map(|(name, (flags, max_positional))| GeneratedEntry {
95            name,
96            standalone: flags.into_iter().collect(),
97            max_positional,
98            level: DEFAULT_LEVEL.to_string(),
99        })
100        .collect();
101    Outcome::Generated {
102        entries,
103        also_recognized: recognized.into_iter().collect(),
104    }
105}
106
107/// The basename of a simple command's name word (`/usr/bin/foo` → `foo`), or `None` for an env-only
108/// command with no name word.
109fn command_basename(sc: &SimpleCmd) -> Option<String> {
110    let raw = sc.words.first()?.eval();
111    if raw.is_empty() {
112        return None;
113    }
114    Some(crate::parse::Token::from_raw(raw).command_name().to_string())
115}
116
117/// The observed flags (each `-`-prefixed argument, but not a lone `-`) and the count of positional
118/// arguments. A valued flag's value counts as a positional, which is fine: the generated grammar
119/// still admits the observed invocation, just classified as flag + positional.
120fn observed_shape(sc: &SimpleCmd) -> (Vec<String>, usize) {
121    let mut flags = Vec::new();
122    let mut positionals = 0;
123    for word in sc.words.iter().skip(1) {
124        let s = word.eval();
125        if s.starts_with('-') && s != "-" {
126            flags.push(s);
127        } else {
128            positionals += 1;
129        }
130    }
131    (flags, positionals)
132}
133
134fn collect_script<'a>(script: &'a Script, out: &mut Vec<&'a SimpleCmd>) {
135    for stmt in &script.0 {
136        for cmd in &stmt.pipeline.commands {
137            collect_cmd(cmd, out);
138        }
139    }
140}
141
142fn collect_cmd<'a>(cmd: &'a Cmd, out: &mut Vec<&'a SimpleCmd>) {
143    match cmd {
144        Cmd::Simple(sc) => {
145            out.push(sc);
146            for word in &sc.words {
147                collect_word(word, out);
148            }
149        }
150        Cmd::Subshell { body, .. } | Cmd::BraceGroup { body, .. } => collect_script(body, out),
151        Cmd::For { items, body, .. } => {
152            for word in items {
153                collect_word(word, out);
154            }
155            collect_script(body, out);
156        }
157        Cmd::While { cond, body, .. } | Cmd::Until { cond, body, .. } => {
158            collect_script(cond, out);
159            collect_script(body, out);
160        }
161        Cmd::If {
162            branches,
163            else_body,
164            ..
165        } => {
166            for branch in branches {
167                collect_script(&branch.cond, out);
168                collect_script(&branch.body, out);
169            }
170            if let Some(body) = else_body {
171                collect_script(body, out);
172            }
173        }
174        Cmd::DoubleBracket { words, .. } => {
175            for word in words {
176                collect_word(word, out);
177            }
178        }
179        Cmd::FunctionDef { body, .. } => collect_script(body, out),
180    }
181}
182
183/// Recurse into command/process substitutions nested inside a word (`$(foo)`, `<(bar)`, and the same
184/// inside double quotes), so an unknown command hidden in a substitution is surfaced too. Backticks
185/// (`WordPart::Backtick`) hold an UNPARSED string, so an unknown command inside one is not surfaced —
186/// harmless: the classifier still denies an unsafe backtick, so the command stays blocked (a coverage
187/// gap, never a bypass). The user can `--suggest` the inner command directly.
188fn collect_word<'a>(word: &'a Word, out: &mut Vec<&'a SimpleCmd>) {
189    for part in &word.0 {
190        match part {
191            WordPart::CmdSub(s) | WordPart::ProcSub(s) => collect_script(s, out),
192            WordPart::DQuote(w) => collect_word(w, out),
193            _ => {}
194        }
195    }
196}
197
198/// Whether safe-chains recognizes `name` (built-in handler or registry command, via its canonical
199/// spelling). Custom user commands that already ALLOW an invocation are caught earlier by the
200/// `AlreadyAllowed` check, so they need not be enumerated here.
201fn is_known(name: &str) -> bool {
202    known_names().contains(registry::canonical_name(name))
203}
204
205fn known_names() -> &'static BTreeSet<String> {
206    static KNOWN: OnceLock<BTreeSet<String>> = OnceLock::new();
207    KNOWN.get_or_init(|| {
208        let mut set: BTreeSet<String> = crate::docs::all_command_docs()
209            .into_iter()
210            .map(|d| d.name)
211            .collect();
212        for name in registry::toml_command_names() {
213            set.insert(name.to_string());
214        }
215        set
216    })
217}
218
219/// Render the generated entries as `[[command]]` TOML blocks. Minimal and always valid — this is
220/// exactly the text written to `.safe-chains.toml` and fed to the SHA-256 pin, so it must be stable.
221pub fn render_toml(entries: &[GeneratedEntry]) -> String {
222    let mut out = String::new();
223    for (i, entry) in entries.iter().enumerate() {
224        if i > 0 {
225            out.push('\n');
226        }
227        out.push_str("[[command]]\n");
228        out.push_str(&format!("name = {}\n", toml_str(&entry.name)));
229        if !entry.standalone.is_empty() {
230            let items: Vec<String> = entry.standalone.iter().map(|f| toml_str(f)).collect();
231            out.push_str(&format!("standalone = [{}]\n", items.join(", ")));
232        }
233        out.push_str(&format!("max_positional = {}\n", entry.max_positional));
234        out.push_str(&format!("level = {}\n", toml_str(&entry.level)));
235    }
236    out
237}
238
239/// A TOML basic (double-quoted) string with the control/quote/backslash escapes TOML requires, so
240/// even an odd flag spelling produces valid TOML.
241fn toml_str(s: &str) -> String {
242    let mut out = String::from("\"");
243    for c in s.chars() {
244        match c {
245            '"' => out.push_str("\\\""),
246            '\\' => out.push_str("\\\\"),
247            '\n' => out.push_str("\\n"),
248            '\r' => out.push_str("\\r"),
249            '\t' => out.push_str("\\t"),
250            c if (c as u32) < 0x20 || c == '\u{7f}' => {
251                out.push_str(&format!("\\u{:04X}", c as u32));
252            }
253            c => out.push(c),
254        }
255    }
256    out.push('"');
257    out
258}
259
260/// SHA-256 of `bytes` as lowercase hex — the exact hash `registry::custom::repo_is_trusted` computes
261/// over a `.safe-chains.toml`, so the printed pin matches what the trust check will require.
262pub fn config_hash(bytes: &[u8]) -> String {
263    Sha256::digest(bytes).iter().map(|b| format!("{b:02x}")).collect()
264}
265
266/// The new `.safe-chains.toml` content: the existing file (empty if none) with the generated blocks
267/// appended, separated by a blank line.
268pub fn merged_content(existing: &str, entries: &[GeneratedEntry]) -> String {
269    let block = render_toml(entries);
270    if existing.trim().is_empty() {
271        return block;
272    }
273    let mut content = existing.to_string();
274    if !content.ends_with('\n') {
275        content.push('\n');
276    }
277    content.push('\n');
278    content.push_str(&block);
279    content
280}
281
282/// The `[[trusted]]` pin the user pastes into `~/.config/safe-chains.toml` to approve the file.
283pub fn pin_block(canonical_dir: &str, hash: &str) -> String {
284    format!(
285        "[[trusted]]\npath = {}\nsha256 = {}\n",
286        toml_str(canonical_dir),
287        toml_str(hash),
288    )
289}
290
291#[cfg(test)]
292mod tests;