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;