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 plane = crate::substrate::Plane::open_for_decisions(dir).map_err(|e| e.0)?;
137    rt.block_on(plane.tool_calls("proc", "run_command"))
138        .map_err(|e| e.0)
139}
140
141/// SHA-256 over the canonical JSON of the entry, the rows and `complete`.
142#[must_use]
143pub fn digest(entry: &CoverageEntry, rows: &[CoverageRow], complete: bool) -> String {
144    let value = json!({ "entry": entry, "rows": rows, "complete": complete });
145    let text = canonical(&value);
146    Sha256::digest(text.as_bytes())
147        .iter()
148        .map(|b| format!("{b:02x}"))
149        .collect()
150}
151
152/// JSON with object keys sorted (`serde_json` keeps insertion order).
153fn canonical(v: &Value) -> String {
154    match v {
155        Value::Object(o) => {
156            let mut keys: Vec<&String> = o.keys().collect();
157            keys.sort();
158            let inner: Vec<String> = keys
159                .into_iter()
160                .map(|k| format!("{}:{}", Value::String(k.clone()), canonical(&o[k])))
161                .collect();
162            format!("{{{}}}", inner.join(","))
163        }
164        Value::Array(a) => format!(
165            "[{}]",
166            a.iter().map(canonical).collect::<Vec<_>>().join(",")
167        ),
168        other => other.to_string(),
169    }
170}
171
172/// `speccoder config coverage`: the preview as a result.
173#[must_use]
174pub fn show(
175    root: &Path,
176    state: Option<&Path>,
177    list: PolicyList,
178    prefix: &[String],
179) -> CommandResult {
180    let Some(state) = state else {
181        return CommandResult::new("config", CommandStatus::ConfigError, "no state directory")
182            .with_code("NO-STATE-DIR");
183    };
184    if let Some(why) = prefix_refusal(prefix) {
185        return CommandResult::new(
186            "config",
187            CommandStatus::UsageError,
188            &format!("`{}`: {why}", list.key()),
189        );
190    }
191    let c = preview(root, state, list, prefix);
192    let message = summary(&c);
193    CommandResult::new("config", CommandStatus::Ok, &message)
194        .with_data(CommandData::PolicyCoverage(Box::new(c)))
195}
196
197/// One line: what it would newly allow, or why the preview is incomplete.
198#[must_use]
199pub fn summary(c: &PolicyCoverage) -> String {
200    let what = format!(
201        "`{}` += {}: {} command(s) from the journal would run without asking",
202        c.entry.key,
203        serde_json::to_string(&c.entry.prefix).unwrap_or_default(),
204        c.rows.iter().filter(|r| r.still_asks.is_none()).count()
205    );
206    if c.complete {
207        what
208    } else {
209        format!(
210            "{what} — the preview is incomplete ({}); saving is refused until it can be read",
211            c.unreadable.join("; ")
212        )
213    }
214}
215
216/// `WidenPolicy`: adds `prefix` to `list` as the confirmation of the
217/// preview whose digest is `digest` — refused for a bare program, an
218/// incomplete preview, or one that changed since it was shown.
219#[must_use]
220pub fn widen(
221    root: &Path,
222    state: Option<&Path>,
223    list: PolicyList,
224    prefix: &[String],
225    digest: &str,
226    via: Via,
227) -> CommandResult {
228    let refuse = |m: String| CommandResult::new("config", CommandStatus::UsageError, &m);
229    let Some(state) = state else {
230        return CommandResult::new("config", CommandStatus::ConfigError, "no state directory")
231            .with_code("NO-STATE-DIR");
232    };
233    if let Some(why) = prefix_refusal(prefix) {
234        return refuse(format!("`{}`: {why}", list.key()));
235    }
236    let c = preview(root, state, list, prefix);
237    if !c.complete {
238        return refuse(format!(
239            "the preview is incomplete: {}; try again when the journal can be read",
240            c.unreadable.join("; ")
241        ))
242        .with_data(CommandData::PolicyCoverage(Box::new(c)));
243    }
244    if c.digest != digest {
245        return refuse("the preview changed; review it again".to_owned())
246            .with_data(CommandData::PolicyCoverage(Box::new(c)));
247    }
248    let r = crate::settings::apply_confirmed(
249        root,
250        &[ConfigChange {
251            key: list.key().to_owned(),
252            value: None,
253            append: Some(json!(prefix)),
254        }],
255    );
256    if r.status() != CommandStatus::Ok {
257        return r;
258    }
259    let by = crate::approvals::Who::local_in(root, via);
260    CommandResult::new(
261        "config",
262        CommandStatus::Ok,
263        &format!(
264            "added {} to `{}` from its preview ({} command(s) newly allowed) — by {} ({}); commit speccoder.toml to put it in force",
265            serde_json::to_string(prefix).unwrap_or_default(),
266            list.key(),
267            c.rows.iter().filter(|r| r.still_asks.is_none()).count(),
268            by.name,
269            by.via
270        ),
271    )
272    .with_next(r.next().to_vec())
273    .with_data(r.data().cloned().unwrap_or(CommandData::PolicyCoverage(Box::new(c))))
274}
275
276#[cfg(test)]
277mod tests {
278    use super::*;
279
280    #[test]
281    fn the_digest_covers_the_rows_and_completeness() {
282        let entry = CoverageEntry {
283            key: "commands.allow_prefix".to_owned(),
284            prefix: vec!["npm".to_owned(), "run".to_owned()],
285        };
286        let row = CoverageRow {
287            argv: vec!["npm".to_owned(), "run".to_owned(), "build".to_owned()],
288            count: 1,
289            last_at: Some(1),
290            decided: Vec::new(),
291            still_asks: None,
292        };
293        let empty = digest(&entry, &[], true);
294        assert_ne!(empty, digest(&entry, std::slice::from_ref(&row), true));
295        assert_ne!(empty, digest(&entry, &[], false));
296        assert_eq!(empty, digest(&entry, &[], true));
297    }
298}