Skip to main content

speccoder_runtime/approvals/
coverage.rs

1//! What a prefix entry would newly allow, from the project's journal: the
2//! preview a prefix is saved from (`[commands] allow_prefix`, `[sandbox]
3//! network_allow_prefix`). Every distinct argv the journal records that the
4//! prefix covers and today's policy does not allow, with how often, when
5//! last and how it was decided then; what a fixed rule still asks for or
6//! denies is marked. An unreadable journal or run makes the preview
7//! incomplete — never "covers nothing" — and an incomplete preview cannot
8//! be saved.
9
10use std::collections::BTreeMap;
11use std::path::Path;
12
13use serde_json::{Value, json};
14use sha2::{Digest as _, Sha256};
15use speccoder_core::actions::{PolicyList, Via};
16use speccoder_core::{
17    CommandData, CommandResult, CommandStatus, ConfigChange, CoverageDecision, CoverageEntry,
18    CoverageRow, PolicyCoverage,
19};
20
21use crate::tools::policy::{Decision, Policy, prefix_matches, prefix_refusal};
22
23/// The policy the committed `speccoder.toml` at `root` gives: the checks'
24/// prefixes and every allow list (what is in force).
25fn policy_now(root: &Path) -> Policy {
26    let dir = crate::verify::find_config(root).unwrap_or_else(|| root.to_path_buf());
27    let committed = crate::git::Git::at(&dir)
28        .run(&["show", &format!("HEAD:./{}", crate::verify::config::FILE)])
29        .ok()
30        .map(|b| String::from_utf8_lossy(&b).into_owned())
31        .or_else(|| std::fs::read_to_string(dir.join(crate::verify::config::FILE)).ok())
32        .unwrap_or_default();
33    let checks: Vec<Vec<String>> = crate::verify::config::parse(&committed)
34        .map(|cs| cs.into_iter().map(|c| c.argv).collect())
35        .unwrap_or_default();
36    let run = crate::substrate::run_config::parse(&committed).unwrap_or_default();
37    let sandbox = crate::sandbox::parse(&committed).unwrap_or_default();
38    Policy::new(&checks, &run.allow)
39        .with_prefixes(run.allow_prefix)
40        .with_network(sandbox.network_allow)
41        .with_network_prefixes(sandbox.network_allow_prefix)
42}
43
44/// Whether `policy` lets `argv` run as asked: without the network (the
45/// commands list) or with it (the network list).
46fn allowed(policy: &Policy, list: PolicyList, argv: &[String]) -> Decision {
47    let args = match list {
48        PolicyList::CommandsPrefix => json!({ "argv": argv }),
49        PolicyList::NetworkPrefix => json!({ "argv": argv, "network": true }),
50    };
51    policy.base("run_command", &args)
52}
53
54/// One argv's calls: how many, the latest, and how decided.
55type Tally = (u32, Option<u64>, BTreeMap<String, u32>);
56
57/// The preview of adding `prefix` to `list`, for the project at `root`
58/// whose state lives under `state`.
59#[must_use]
60pub fn preview(root: &Path, state: &Path, list: PolicyList, prefix: &[String]) -> PolicyCoverage {
61    let project = crate::format::at(root)
62        .detect(root)
63        .unwrap_or_else(|| root.to_path_buf());
64    let dir = crate::worktree::project_state_dir(state, &project);
65    let now = policy_now(&project);
66    let widened = match list {
67        PolicyList::CommandsPrefix => now.clone().with_prefixes(vec![prefix.to_vec()]),
68        PolicyList::NetworkPrefix => now.clone().with_network_prefixes(vec![prefix.to_vec()]),
69    };
70    let mut unreadable = Vec::new();
71    // argv → (count, last, decided)
72    let mut rows: BTreeMap<Vec<String>, Tally> = BTreeMap::new();
73    if dir.join(crate::substrate::JOURNAL).exists() {
74        match read_calls(&dir) {
75            Ok(recorded) => {
76                unreadable.extend(recorded.unreadable);
77                for call in recorded.calls {
78                    let network = call.arguments["network"] == true;
79                    if network != (list == PolicyList::NetworkPrefix) {
80                        continue;
81                    }
82                    let argv = crate::tools::policy::argv_of(&call.arguments);
83                    if !prefix_matches(prefix, &argv)
84                        || allowed(&now, list, &argv) == Decision::Allow
85                    {
86                        continue;
87                    }
88                    let how = if call.arguments.get(crate::tools::EXPECT).is_some() {
89                        "approved by a person"
90                    } else {
91                        "allowed by rule"
92                    };
93                    let row = rows.entry(argv).or_default();
94                    row.0 += 1;
95                    row.1 = row.1.max(Some(call.at));
96                    *row.2.entry(how.to_owned()).or_default() += 1;
97                }
98            }
99            Err(e) => unreadable.push(format!("the journal: {e}")),
100        }
101    }
102    let rows: Vec<CoverageRow> = rows
103        .into_iter()
104        .map(|(argv, (count, last_at, decided))| CoverageRow {
105            still_asks: match allowed(&widened, list, &argv) {
106                Decision::Allow => None,
107                Decision::Approval(r) => Some(format!("still asks: {r}")),
108                Decision::Deny(r) => Some(format!("still denied: {r}")),
109            },
110            argv,
111            count,
112            last_at,
113            decided: decided
114                .into_iter()
115                .map(|(decision, count)| CoverageDecision { decision, count })
116                .collect(),
117        })
118        .collect();
119    let entry = CoverageEntry {
120        key: list.key().to_owned(),
121        prefix: prefix.to_vec(),
122    };
123    let complete = unreadable.is_empty();
124    let digest = digest(&entry, &rows, complete);
125    PolicyCoverage {
126        entry,
127        complete,
128        unreadable,
129        rows,
130        digest,
131    }
132}
133
134fn read_calls(dir: &Path) -> Result<crate::substrate::RecordedCalls, String> {
135    let rt = crate::tokio_runtime()?;
136    let Some(plane) = crate::substrate::Plane::open_for_reading(dir).map_err(|e| e.0)? else {
137        return Ok(crate::substrate::RecordedCalls::default());
138    };
139    rt.block_on(plane.tool_calls("proc", "run_command"))
140        .map_err(|e| e.0)
141}
142
143/// SHA-256 over the canonical JSON of the entry, the rows and `complete`.
144#[must_use]
145pub fn digest(entry: &CoverageEntry, rows: &[CoverageRow], complete: bool) -> String {
146    let value = json!({ "entry": entry, "rows": rows, "complete": complete });
147    let text = canonical(&value);
148    Sha256::digest(text.as_bytes())
149        .iter()
150        .map(|b| format!("{b:02x}"))
151        .collect()
152}
153
154/// JSON with object keys sorted (`serde_json` keeps insertion order).
155fn canonical(v: &Value) -> String {
156    match v {
157        Value::Object(o) => {
158            let mut keys: Vec<&String> = o.keys().collect();
159            keys.sort();
160            let inner: Vec<String> = keys
161                .into_iter()
162                .map(|k| format!("{}:{}", Value::String(k.clone()), canonical(&o[k])))
163                .collect();
164            format!("{{{}}}", inner.join(","))
165        }
166        Value::Array(a) => format!(
167            "[{}]",
168            a.iter().map(canonical).collect::<Vec<_>>().join(",")
169        ),
170        other => other.to_string(),
171    }
172}
173
174/// `speccoder config coverage`: the preview as a result.
175#[must_use]
176pub fn show(
177    root: &Path,
178    state: Option<&Path>,
179    list: PolicyList,
180    prefix: &[String],
181) -> CommandResult {
182    let Some(state) = state else {
183        return CommandResult::new("config", CommandStatus::ConfigError, "no state directory")
184            .with_code("NO-STATE-DIR");
185    };
186    if let Some(why) = prefix_refusal(prefix) {
187        return CommandResult::new(
188            "config",
189            CommandStatus::UsageError,
190            &format!("`{}`: {why}", list.key()),
191        );
192    }
193    let c = preview(root, state, list, prefix);
194    let message = summary(&c);
195    CommandResult::new("config", CommandStatus::Ok, &message)
196        .with_data(CommandData::PolicyCoverage(Box::new(c)))
197}
198
199/// One line: what it would newly allow, or why the preview is incomplete.
200#[must_use]
201pub fn summary(c: &PolicyCoverage) -> String {
202    let what = format!(
203        "`{}` += {}: {} command(s) from the journal would run without asking",
204        c.entry.key,
205        serde_json::to_string(&c.entry.prefix).unwrap_or_default(),
206        c.rows.iter().filter(|r| r.still_asks.is_none()).count()
207    );
208    if c.complete {
209        what
210    } else {
211        format!(
212            "{what} — the preview is incomplete ({}); saving is refused until it can be read",
213            c.unreadable.join("; ")
214        )
215    }
216}
217
218/// `WidenPolicy`: adds `prefix` to `list` as the confirmation of the
219/// preview whose digest is `digest` — refused for a bare program, an
220/// incomplete preview, or one that changed since it was shown.
221#[must_use]
222pub fn widen(
223    root: &Path,
224    state: Option<&Path>,
225    list: PolicyList,
226    prefix: &[String],
227    digest: &str,
228    via: Via,
229) -> CommandResult {
230    let refuse = |m: String| CommandResult::new("config", CommandStatus::UsageError, &m);
231    let Some(state) = state else {
232        return CommandResult::new("config", CommandStatus::ConfigError, "no state directory")
233            .with_code("NO-STATE-DIR");
234    };
235    if let Some(why) = prefix_refusal(prefix) {
236        return refuse(format!("`{}`: {why}", list.key()));
237    }
238    let c = preview(root, state, list, prefix);
239    if !c.complete {
240        return refuse(format!(
241            "the preview is incomplete: {}; try again when the journal can be read",
242            c.unreadable.join("; ")
243        ))
244        .with_data(CommandData::PolicyCoverage(Box::new(c)));
245    }
246    if c.digest != digest {
247        return refuse("the preview changed; review it again".to_owned())
248            .with_data(CommandData::PolicyCoverage(Box::new(c)));
249    }
250    let r = crate::settings::apply_confirmed(
251        root,
252        &[ConfigChange {
253            key: list.key().to_owned(),
254            value: None,
255            append: Some(json!(prefix)),
256        }],
257    );
258    if r.status() != CommandStatus::Ok {
259        return r;
260    }
261    let by = crate::approvals::Who::local_in(root, via);
262    CommandResult::new(
263        "config",
264        CommandStatus::Ok,
265        &format!(
266            "added {} to `{}` from its preview ({} command(s) newly allowed) — by {} ({}); commit speccoder.toml to put it in force",
267            serde_json::to_string(prefix).unwrap_or_default(),
268            list.key(),
269            c.rows.iter().filter(|r| r.still_asks.is_none()).count(),
270            by.name,
271            by.via
272        ),
273    )
274    .with_next(r.next().to_vec())
275    .with_data(r.data().cloned().unwrap_or(CommandData::PolicyCoverage(Box::new(c))))
276}
277
278#[cfg(test)]
279mod tests {
280    use super::*;
281
282    #[test]
283    fn the_digest_covers_the_rows_and_completeness() {
284        let entry = CoverageEntry {
285            key: "commands.allow_prefix".to_owned(),
286            prefix: vec!["npm".to_owned(), "run".to_owned()],
287        };
288        let row = CoverageRow {
289            argv: vec!["npm".to_owned(), "run".to_owned(), "build".to_owned()],
290            count: 1,
291            last_at: Some(1),
292            decided: Vec::new(),
293            still_asks: None,
294        };
295        let empty = digest(&entry, &[], true);
296        assert_ne!(empty, digest(&entry, std::slice::from_ref(&row), true));
297        assert_ne!(empty, digest(&entry, &[], false));
298        assert_eq!(empty, digest(&entry, &[], true));
299    }
300}