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::Case { subject, arms, .. } => {
180            collect_word(subject, out);
181            for arm in arms {
182                collect_script(&arm.body, out);
183            }
184        }
185        Cmd::FunctionDef { body, .. } => collect_script(body, out),
186    }
187}
188
189/// Recurse into command/process substitutions nested inside a word (`$(foo)`, `<(bar)`, and the same
190/// inside double quotes), so an unknown command hidden in a substitution is surfaced too. Backticks
191/// (`WordPart::Backtick`) hold an UNPARSED string, so an unknown command inside one is not surfaced —
192/// harmless: the classifier still denies an unsafe backtick, so the command stays blocked (a coverage
193/// gap, never a bypass). The user can `--suggest` the inner command directly.
194fn collect_word<'a>(word: &'a Word, out: &mut Vec<&'a SimpleCmd>) {
195    for part in &word.0 {
196        match part {
197            WordPart::CmdSub(s) | WordPart::ProcSub(s) => collect_script(s, out),
198            WordPart::DQuote(w) => collect_word(w, out),
199            _ => {}
200        }
201    }
202}
203
204/// Whether safe-chains recognizes `name` (built-in handler or registry command, via its canonical
205/// spelling). Custom user commands that already ALLOW an invocation are caught earlier by the
206/// `AlreadyAllowed` check, so they need not be enumerated here.
207fn is_known(name: &str) -> bool {
208    known_names().contains(registry::canonical_name(name))
209}
210
211fn known_names() -> &'static BTreeSet<String> {
212    static KNOWN: OnceLock<BTreeSet<String>> = OnceLock::new();
213    KNOWN.get_or_init(|| {
214        let mut set: BTreeSet<String> = crate::docs::all_command_docs()
215            .into_iter()
216            .map(|d| d.name)
217            .collect();
218        for name in registry::toml_command_names() {
219            set.insert(name.to_string());
220        }
221        set
222    })
223}
224
225/// Render the generated entries as `[[command]]` TOML blocks. Minimal and always valid — this is
226/// exactly the text written to `.safe-chains.toml` and fed to the SHA-256 pin, so it must be stable.
227pub fn render_toml(entries: &[GeneratedEntry]) -> String {
228    let mut out = String::new();
229    for (i, entry) in entries.iter().enumerate() {
230        if i > 0 {
231            out.push('\n');
232        }
233        out.push_str("[[command]]\n");
234        out.push_str(&format!("name = {}\n", toml_str(&entry.name)));
235        if !entry.standalone.is_empty() {
236            let items: Vec<String> = entry.standalone.iter().map(|f| toml_str(f)).collect();
237            out.push_str(&format!("standalone = [{}]\n", items.join(", ")));
238        }
239        out.push_str(&format!("max_positional = {}\n", entry.max_positional));
240        out.push_str(&format!("level = {}\n", toml_str(&entry.level)));
241    }
242    out
243}
244
245/// A TOML basic (double-quoted) string with the control/quote/backslash escapes TOML requires, so
246/// even an odd flag spelling produces valid TOML.
247fn toml_str(s: &str) -> String {
248    let mut out = String::from("\"");
249    for c in s.chars() {
250        match c {
251            '"' => out.push_str("\\\""),
252            '\\' => out.push_str("\\\\"),
253            '\n' => out.push_str("\\n"),
254            '\r' => out.push_str("\\r"),
255            '\t' => out.push_str("\\t"),
256            c if (c as u32) < 0x20 || c == '\u{7f}' => {
257                out.push_str(&format!("\\u{:04X}", c as u32));
258            }
259            c => out.push(c),
260        }
261    }
262    out.push('"');
263    out
264}
265
266/// SHA-256 of `bytes` as lowercase hex — the exact hash `registry::custom::repo_is_trusted` computes
267/// over a `.safe-chains.toml`, so the printed pin matches what the trust check will require.
268pub fn config_hash(bytes: &[u8]) -> String {
269    Sha256::digest(bytes).iter().map(|b| format!("{b:02x}")).collect()
270}
271
272/// The new `.safe-chains.toml` content: the existing file (empty if none) with the generated blocks
273/// appended, separated by a blank line.
274pub fn merged_content(existing: &str, entries: &[GeneratedEntry]) -> String {
275    let block = render_toml(entries);
276    if existing.trim().is_empty() {
277        return block;
278    }
279    let mut content = existing.to_string();
280    if !content.ends_with('\n') {
281        content.push('\n');
282    }
283    content.push('\n');
284    content.push_str(&block);
285    content
286}
287
288/// The `[[trusted]]` pin the user pastes into `~/.config/safe-chains.toml` to approve the file.
289pub fn pin_block(canonical_dir: &str, hash: &str) -> String {
290    format!(
291        "[[trusted]]\npath = {}\nsha256 = {}\n",
292        toml_str(canonical_dir),
293        toml_str(hash),
294    )
295}
296
297#[cfg(test)]
298mod tests;