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