speccoder_runtime/approvals/
coverage.rs1use 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
23fn 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
44fn 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
54type Tally = (u32, Option<u64>, BTreeMap<String, u32>);
56
57#[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 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#[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
152fn 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#[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#[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#[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}