Skip to main content

leviath_cli/
read_path_report.rs

1//! Whether the user's config actually grants what a blueprint's
2//! `[read_paths]` declares, entry by entry.
3//!
4//! Declaring is not granting (see [`leviath_core::read_paths`]), and for a long
5//! time nothing said so out loud: an agent that asked to read outside its
6//! workdir validated, listed, and spawned exactly like one that did not, then
7//! failed at its first read on any machine whose `config.toml` was missing the
8//! grant. This module is the one place that answers "is this declaration live
9//! here", so `lev validate`, `lev list`, `lev run`, `lev add`, and `lev ps` all
10//! answer it the same way.
11//!
12//! The check is deliberately pattern-level: each declared entry is compiled,
13//! reduced to one representative path
14//! ([`leviath_core::ReadPathEntry::sample_path`]), and offered to the compiled
15//! grant set with [`leviath_core::ReadPathSet::matches_lexically`]. Nothing
16//! touches the filesystem, so a grant naming a directory that does not exist
17//! yet still reads as a grant. The trade is that a report is not a promise: the
18//! runtime matches real, symlink-resolved paths, so an individual read can
19//! still be refused.
20
21use crate::config::Config;
22use std::path::Path;
23
24/// Whether one declared entry is live under the current config.
25#[derive(Debug, Clone, Copy, PartialEq, Eq)]
26pub enum GrantStatus {
27    /// The config grants it (itemized, or through the blanket override).
28    Granted,
29    /// Nothing in the config grants it; reads matching it will be refused.
30    NotGranted,
31    /// The entry's pattern admits no representative path, so this cannot be
32    /// answered without guessing. Reported as unknown rather than as inert.
33    Undetermined,
34}
35
36impl GrantStatus {
37    /// How this verdict reads in a report. Kept with the type so the wording
38    /// cannot drift from the meaning.
39    pub fn label(self) -> &'static str {
40        match self {
41            Self::Granted => "granted",
42            Self::NotGranted => "NOT granted",
43            Self::Undetermined => "cannot be checked from the pattern alone",
44        }
45    }
46}
47
48/// One declared entry and its verdict.
49#[derive(Debug, Clone, PartialEq, Eq)]
50pub struct EntryStatus {
51    /// The entry exactly as the blueprint wrote it.
52    pub raw: String,
53    /// Whether the config grants it.
54    pub status: GrantStatus,
55}
56
57/// Every `[read_paths]` entry a blueprint declares, with its grant verdict.
58#[derive(Debug, Clone, PartialEq, Eq)]
59pub struct GrantReport {
60    /// The agent name, which is also the `[agent_read_paths.<name>]` key.
61    pub agent: String,
62    /// Whether `[security] allow_blueprint_read_paths` is on, in which case
63    /// every declaration is granted wholesale.
64    pub allow_blueprint: bool,
65    /// One entry per declaration, in the order the blueprint wrote them.
66    pub entries: Vec<EntryStatus>,
67}
68
69/// Build the report for `blueprint` under `config`.
70///
71/// `None` when the blueprint declares nothing - the overwhelmingly common case,
72/// where every surface should stay silent. `Err` when the *user's own* grant
73/// list does not compile: that is worth saying out loud, because the same list
74/// is a hard spawn error.
75///
76/// `workdir` resolves relative entries, exactly as it does at spawn. Callers
77/// outside a run pass the current directory, which is what `lev run` defaults
78/// to.
79pub fn build(
80    blueprint: &leviath_core::Blueprint,
81    config: &Config,
82    workdir: &Path,
83) -> Option<Result<GrantReport, String>> {
84    let rp = blueprint
85        .read_paths
86        .as_ref()
87        .filter(|rp| !rp.allow.is_empty())?;
88    Some(report_entries(
89        &blueprint.name,
90        &rp.allow,
91        config,
92        workdir,
93        leviath_core::home_dir().as_deref(),
94        cfg!(windows),
95    ))
96}
97
98/// The report proper, with the platform inputs injected so every branch is
99/// testable on every OS.
100fn report_entries(
101    agent: &str,
102    declared: &[String],
103    config: &Config,
104    workdir: &Path,
105    home: Option<&Path>,
106    windows: bool,
107) -> Result<GrantReport, String> {
108    let grant_entries = config.read_path_grants_for_agent(agent);
109    let grants = leviath_core::ReadPathSet::compile(&grant_entries, workdir, home, windows)
110        .map_err(|e| format!("read_paths grant in your config.toml: {e}"))?;
111    let allow_blueprint = config.security.allow_blueprint_read_paths;
112    let entries = declared
113        .iter()
114        .map(|raw| EntryStatus {
115            raw: raw.clone(),
116            status: entry_status(raw, &grants, allow_blueprint, workdir, home, windows),
117        })
118        .collect();
119    Ok(GrantReport {
120        agent: agent.to_string(),
121        allow_blueprint,
122        entries,
123    })
124}
125
126/// The verdict for one declared entry.
127///
128/// A declaration that does not compile is [`GrantStatus::Undetermined`] rather
129/// than an error: the manifest parser already refuses malformed entries, so
130/// reaching this with one means the environment (a missing home directory, say)
131/// is what could not be resolved, and that is not something to report as
132/// "ungranted".
133fn entry_status(
134    raw: &str,
135    grants: &leviath_core::ReadPathSet,
136    allow_blueprint: bool,
137    workdir: &Path,
138    home: Option<&Path>,
139    windows: bool,
140) -> GrantStatus {
141    if allow_blueprint {
142        return GrantStatus::Granted;
143    }
144    if grants.is_empty() {
145        return GrantStatus::NotGranted;
146    }
147    let one = [raw.to_string()];
148    let sample = leviath_core::ReadPathSet::compile(&one, workdir, home, windows)
149        .ok()
150        .and_then(|set| set.entries().first().and_then(|e| e.sample_path()));
151    match sample {
152        Some(sample) if grants.matches_lexically(&sample) => GrantStatus::Granted,
153        Some(_) => GrantStatus::NotGranted,
154        None => GrantStatus::Undetermined,
155    }
156}
157
158impl GrantReport {
159    /// How many entries the blueprint declares.
160    pub fn declared(&self) -> usize {
161        self.entries.len()
162    }
163
164    /// How many of them the config grants.
165    pub fn granted(&self) -> usize {
166        self.entries
167            .iter()
168            .filter(|e| e.status == GrantStatus::Granted)
169            .count()
170    }
171
172    /// The entries that will be refused, in declaration order. Undetermined
173    /// entries are left out: they may well work, and offering to grant one the
174    /// user already granted would be worse than saying nothing.
175    pub fn ungranted(&self) -> Vec<&str> {
176        self.entries
177            .iter()
178            .filter(|e| e.status == GrantStatus::NotGranted)
179            .map(|e| e.raw.as_str())
180            .collect()
181    }
182
183    /// Whether anything is refused, which is what every surface warns about.
184    pub fn has_ungranted(&self) -> bool {
185        !self.ungranted().is_empty()
186    }
187
188    /// `"3 declared, 1 granted"` - the one-line count for compact surfaces.
189    pub fn summary(&self) -> String {
190        format!("{} declared, {} granted", self.declared(), self.granted())
191    }
192
193    /// The config stanza that would grant everything currently refused, ready
194    /// to paste. Empty when nothing is refused.
195    pub fn grant_stanza(&self) -> Vec<String> {
196        let ungranted = self.ungranted();
197        if ungranted.is_empty() {
198            return Vec::new();
199        }
200        let listed = ungranted
201            .iter()
202            .map(|e| format!("\"{e}\""))
203            .collect::<Vec<_>>()
204            .join(", ");
205        vec![
206            format!("[agent_read_paths.{}]", self.agent),
207            format!("allow = [{listed}]"),
208        ]
209    }
210
211    /// The one-line warning for surfaces that only have room for one, `None`
212    /// when nothing is refused.
213    pub fn warning_line(&self) -> Option<String> {
214        self.has_ungranted().then(|| {
215            format!(
216                "warning: agent '{}' declares [read_paths] your config does not grant ({}); \
217                 reads outside the workdir will be refused",
218                self.agent,
219                self.summary()
220            )
221        })
222    }
223}
224
225#[cfg(test)]
226mod tests;