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 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#[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
154fn 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#[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#[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#[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}