1mod governance;
20mod substrate;
21
22pub use governance::LoopGovernance;
23pub use substrate::{loop_state_of, AreevSubstrate, BorrowedSubstrate};
24
25use std::time::{SystemTime, UNIX_EPOCH};
26
27pub fn scopes_for(authz: &areev_core::authz::AuthzSet) -> areev_loop::ScopeSet {
35 use areev_loop::Scope;
36 use areev_loop::LOOP_NS;
37 use areev_core::authz::Verb;
38 let mut scopes = Vec::new();
39 for (verb, scope) in [
40 (Verb::Read, Scope::Read),
41 (Verb::Write, Scope::Write),
42 (Verb::LoopReview, Scope::Review),
43 (Verb::LoopApply, Scope::Apply),
44 (Verb::Admin, Scope::Admin),
45 ] {
46 if authz.allows(verb, LOOP_NS) {
47 scopes.push(scope);
48 }
49 }
50 areev_loop::ScopeSet::of(&scopes)
51}
52
53pub fn scopes_for_rec(
76 authz: &areev_core::authz::AuthzSet,
77 scope: &[String],
78) -> areev_loop::ScopeSet {
79 use areev_core::authz::Verb;
80 use areev_loop::Scope;
81 use areev_loop::LOOP_NS;
82 let whole_queue = scopes_for(authz);
84 if scope.is_empty() {
85 return whole_queue;
86 }
87 let mut scopes = Vec::new();
88 for (verb, s) in [
89 (Verb::Read, Scope::Read),
90 (Verb::Write, Scope::Write),
91 (Verb::LoopReview, Scope::Review),
92 (Verb::LoopApply, Scope::Apply),
93 (Verb::Admin, Scope::Admin),
94 ] {
95 if authz.allows(verb, LOOP_NS) || scope.iter().all(|ns| authz.allows(verb, ns)) {
100 scopes.push(s);
101 }
102 }
103 areev_loop::ScopeSet::of(&scopes)
104}
105
106pub fn covers_rec(authz: &areev_core::authz::AuthzSet, scope: &[String]) -> bool {
113 scopes_for_rec(authz, scope).has(areev_loop::Scope::Read)
114}
115
116pub fn visible_recommendations<S: areev_loop::OmsSubstrate>(
123 engine: &areev_loop::Engine,
124 sub: &S,
125 authz: &areev_core::authz::AuthzSet,
126 status: Option<areev_loop::RecStatus>,
127) -> areev_loop::Result<Vec<areev_loop::Recommendation>> {
128 let all = engine.recommendations(sub, status)?;
129 Ok(all.into_iter().filter(|r| covers_rec(authz, &r.scope)).collect())
130}
131
132pub fn find_recommendation<'a>(
138 recs: &'a [areev_loop::Recommendation],
139 prefix: &str,
140) -> Result<&'a areev_loop::Recommendation, String> {
141 let matches: Vec<&areev_loop::Recommendation> =
142 recs.iter().filter(|r| r.hash.starts_with(prefix)).collect();
143 match matches.as_slice() {
144 [] => Err(format!("no recommendation matches '{prefix}'")),
145 [one] => Ok(one),
146 many => Err(format!(
147 "'{prefix}' is ambiguous ({} matches) — use more characters",
148 many.len()
149 )),
150 }
151}
152
153pub fn recommendation_detail(r: &areev_loop::Recommendation) -> serde_json::Value {
163 let mut out = serde_json::json!({
164 "hash": r.hash,
165 "status": r.status.as_str(),
166 "severity": r.severity.as_str(),
167 "analyzer": r.analyzer,
168 "origin": r.origin,
169 "target_ref": r.target_ref,
170 "action_kind": r.action_kind,
171 "summary": r.summary.render(),
172 "destructive": r.destructive,
173 "rollbackable": r.rollbackable,
174 "evidence": r.evidence,
175 "dedup_key": r.dedup_key,
176 "confidence": r.confidence,
177 });
178 let o = out.as_object_mut().expect("json! object");
179 if let Some(pin) = &r.evalset_hash {
180 o.insert("evalset_hash".into(), serde_json::json!(pin));
181 }
182 if !r.near_duplicate_of.is_empty() {
183 o.insert("near_duplicate_of".into(), serde_json::json!(r.near_duplicate_of));
184 }
185 if let Some(replay) = &r.replay {
189 o.insert("replay".into(), replay.clone());
190 }
191 o.extend(proposal_fields(r));
192 if let Some(m) = r.metric.as_ref().and_then(|m| serde_json::to_value(m).ok()) {
193 o.insert("metric".into(), m);
194 }
195 if let Some(gd) = &r.guidance {
196 o.insert("guidance".into(), serde_json::Value::from(gd.clone()));
197 }
198 out
199}
200
201pub fn proposal_fields(r: &areev_loop::Recommendation) -> serde_json::Map<String, serde_json::Value> {
204 match serde_json::to_value(&r.proposal) {
205 Ok(serde_json::Value::Object(p)) => p,
206 _ => serde_json::Map::new(),
207 }
208}
209
210pub fn observer_for_principal(actor: &str) -> areev_loop::ObserverType {
216 match areev_core::authz::observer_kind(actor) {
217 "agent" => areev_loop::ObserverType::Agent,
218 _ => areev_loop::ObserverType::Human,
219 }
220}
221
222pub fn now_ms() -> i64 {
233 if let Ok(v) = std::env::var("AREEV_LOOP_NOW_MS") {
234 return v
235 .trim()
236 .parse()
237 .unwrap_or_else(|_| panic!("AREEV_LOOP_NOW_MS is set but not epoch milliseconds: {v:?}"));
238 }
239 SystemTime::now()
240 .duration_since(UNIX_EPOCH)
241 .map(|d| d.as_millis() as i64)
242 .unwrap_or(0)
243}
244
245#[cfg(test)]
246mod authz_mapping_tests {
247 use super::*;
248 use areev_core::authz::{AuthzSet, Grant, Verb};
249
250 fn granted(principal: &str, verbs: Vec<Verb>, ns: &[&str]) -> AuthzSet {
251 AuthzSet::restricted(
252 principal,
253 vec![Grant {
254 verbs,
255 namespaces: ns.iter().map(|s| (*s).to_string()).collect(),
256 }],
257 )
258 }
259
260 #[test]
263 fn a_namespace_grant_covers_a_finding_derived_from_that_namespace() {
264 let amy = granted("user:amy", vec![Verb::Read, Verb::LoopReview], &["a"]);
268 let s = scopes_for_rec(&amy, &["a".into()]);
269 assert!(s.has(areev_loop::Scope::Read));
270 assert!(s.has(areev_loop::Scope::Review));
271 assert!(covers_rec(&amy, &["a".into()]));
272 }
273
274 #[test]
275 fn a_namespace_grant_does_not_cover_another_namespaces_finding() {
276 let amy = granted("user:amy", vec![Verb::Read, Verb::LoopReview], &["a"]);
277 assert!(!covers_rec(&amy, &["b".into()]));
278 let s = scopes_for_rec(&amy, &["b".into()]);
279 assert!(!s.has(areev_loop::Scope::Read));
280 assert!(!s.has(areev_loop::Scope::Review));
281 }
282
283 #[test]
284 fn coverage_needs_every_namespace_a_finding_was_derived_from() {
285 let amy = granted("user:amy", vec![Verb::Read], &["a"]);
288 assert!(!covers_rec(&amy, &["a".into(), "b".into()]));
289 let both = granted("user:both", vec![Verb::Read], &["a", "b"]);
290 assert!(covers_rec(&both, &["a".into(), "b".into()]));
291 }
292
293 #[test]
294 fn a_whole_queue_grant_still_means_the_whole_queue() {
295 let op = granted(
297 "user:op",
298 vec![Verb::Read, Verb::LoopReview],
299 &[areev_loop::LOOP_NS],
300 );
301 assert!(covers_rec(&op, &["a".into()]));
302 assert!(covers_rec(&op, &["a".into(), "b".into()]));
303 assert!(scopes_for_rec(&op, &["zzz".into()]).has(areev_loop::Scope::Review));
304 assert!(covers_rec(&AuthzSet::owner("user:local"), &["a".into()]));
305 }
306
307 #[test]
308 fn an_unscoped_finding_needs_a_whole_queue_grant() {
309 let amy = granted("user:amy", vec![Verb::Read, Verb::LoopReview], &["a"]);
312 assert!(!covers_rec(&amy, &[]));
313 let op = granted("user:op", vec![Verb::Read], &[areev_loop::LOOP_NS]);
314 assert!(covers_rec(&op, &[]));
315 assert!(covers_rec(&AuthzSet::owner("user:local"), &[]));
316 }
317
318 #[test]
319 fn coverage_is_per_verb() {
320 let amy = granted("user:amy", vec![Verb::Read], &["a"]);
322 let s = scopes_for_rec(&amy, &["a".into()]);
323 assert!(s.has(areev_loop::Scope::Read));
324 assert!(!s.has(areev_loop::Scope::Review));
325 assert!(!s.has(areev_loop::Scope::Apply));
326 }
327
328 #[test]
329 fn scope_normalization_is_order_and_duplicate_independent() {
330 use areev_loop::normalize_scope;
331 assert_eq!(
332 normalize_scope(&["b".into(), "a".into(), "a".into(), " ".into()]),
333 vec!["a".to_string(), "b".to_string()]
334 );
335 }
336
337 #[test]
338 fn owner_maps_to_every_scope() {
339 let scopes = scopes_for(&AuthzSet::owner("user:local"));
340 for s in [
341 areev_loop::Scope::Read,
342 areev_loop::Scope::Write,
343 areev_loop::Scope::Review,
344 areev_loop::Scope::Apply,
345 areev_loop::Scope::Admin,
346 ] {
347 assert!(scopes.has(s), "{s:?} missing for owner");
348 }
349 }
350
351 #[test]
352 fn loop_verbs_map_one_to_one_and_namespace_scoping_holds() {
353 let reviewer = AuthzSet::restricted(
355 "user:rev",
356 vec![Grant { verbs: vec![Verb::LoopReview], namespaces: vec!["*".into()] }],
357 );
358 let scopes = scopes_for(&reviewer);
359 assert!(scopes.has(areev_loop::Scope::Review));
360 for s in [
361 areev_loop::Scope::Read,
362 areev_loop::Scope::Write,
363 areev_loop::Scope::Apply,
364 areev_loop::Scope::Admin,
365 ] {
366 assert!(!scopes.has(s), "{s:?} must not be granted");
367 }
368
369 let elsewhere = AuthzSet::restricted(
372 "user:misscoped",
373 vec![Grant { verbs: vec![Verb::LoopReview], namespaces: vec!["caller".into()] }],
374 );
375 assert!(!scopes_for(&elsewhere).has(areev_loop::Scope::Review));
376 let on_loop_ns = AuthzSet::restricted(
377 "user:scoped",
378 vec![Grant {
379 verbs: vec![Verb::LoopReview],
380 namespaces: vec![areev_loop::LOOP_NS.into()],
381 }],
382 );
383 assert!(scopes_for(&on_loop_ns).has(areev_loop::Scope::Review));
384 }
385
386 #[test]
387 fn observer_derives_from_the_actor_label() {
388 use areev_loop::ObserverType;
389 assert_eq!(observer_for_principal("agent:mcp"), ObserverType::Agent);
390 assert_eq!(observer_for_principal("bot:sweeper"), ObserverType::Agent);
391 assert_eq!(observer_for_principal("job:retention"), ObserverType::Agent);
392 assert_eq!(observer_for_principal("engine:loop.llm/1"), ObserverType::Agent);
393 assert_eq!(observer_for_principal("user:anna"), ObserverType::Human);
394 assert_eq!(observer_for_principal("anna"), ObserverType::Human);
395 }
396}