1use std::collections::BTreeSet;
16
17use lonca_core::keyed;
18use lonca_core::oplog::{
19 FORMAT_AFFECTED, FORMAT_INSERT_NAMES, FORMAT_ISOLATE, FORMAT_KEYED, FORMAT_LEASE_INTENT,
20 FORMAT_LEASE_LIVENESS, FORMAT_LEGACY, FORMAT_REPLACES, FORMAT_SECURITY_KEYS,
21 FORMAT_SESSION_HARNESS,
22};
23use lonca_core::ops::OpKind;
24use lonca_core::ops::{
25 Actor, Attestation, BindChecks, CheckKind, CheckResult, Continuation, Decision, ENGINE_DEFS,
26 Facts, HumanKey, HumanSignature, LeaseItem, LeaseKind, MAX_UNPROPOSED_PATHS, NegotiationMsg,
27 ObsKind, Observation, ObservationLevel, Op, Outcome, Scope,
28};
29use lonca_core::paths::{
30 path_matches, path_pattern_within, symbol_matches, symbol_pattern_within,
31 validate_path_pattern, validate_repo_path, validate_symbol_pattern,
32};
33
34use crate::config::{
35 CRITICAL_ESCALATE, CVal, CedarOutcome, MAX_CHECKS, PolicyBundle, Quota, Verification,
36};
37use crate::state::{
38 AFFECTED_LEASE, ActiveLease, ChangeStatus, HEARTBEAT_TIMEOUT, INTEREST_BLOCK, LEASES_KEPT,
39 RELEASED, REPLACES, RESERVATION_WINDOW, Session, State,
40};
41use crate::symbols;
42
43pub struct Ctx<'a> {
44 pub format: u32,
46 pub time_ms: i64,
47 pub policy: &'a PolicyBundle,
48 pub facts: Option<&'a Facts>,
49 pub evidence: &'a [Attestation],
50 pub symbols_engine: Option<u32>,
52}
53
54#[derive(Default)]
55struct Verdict {
56 deny: Vec<String>,
57 escalate: Vec<String>,
58 approvals: u32,
59 info: Vec<String>,
60}
61
62impl Verdict {
63 fn deny(&mut self, r: impl Into<String>) {
64 push_unique(&mut self.deny, r.into());
65 }
66
67 fn escalate(&mut self, r: impl Into<String>, approvals: u32) {
68 push_unique(&mut self.escalate, r.into());
69 self.approvals = self.approvals.max(approvals);
70 }
71
72 fn cedar(&mut self, out: CedarOutcome) {
73 match out {
74 CedarOutcome::Allow => {}
75 CedarOutcome::Deny(rs) => rs.into_iter().for_each(|r| self.deny(r)),
76 CedarOutcome::Escalate { rules, approvals } => {
77 rules.into_iter().for_each(|r| self.escalate(r, approvals))
78 }
79 }
80 }
81
82 fn finish(self, enrolled_humans: usize, cap: bool) -> Decision {
83 if !self.deny.is_empty() {
84 return Decision {
85 outcome: Outcome::Deny,
86 reasons: self.deny,
87 };
88 }
89 if !self.escalate.is_empty() {
90 let mut reasons = self.escalate;
91 reasons.extend(self.info);
92 let mut n = self.approvals.max(1);
93 if cap && n as usize > enrolled_humans {
94 n = enrolled_humans.max(1) as u32;
95 reasons.push("approvals_capped".into());
96 }
97 reasons.push(format!("approvals={n}"));
98 return Decision {
99 outcome: Outcome::Escalate,
100 reasons,
101 };
102 }
103 Decision {
104 outcome: Outcome::Allow,
105 reasons: self.info,
106 }
107 }
108}
109
110fn push_unique(v: &mut Vec<String>, r: String) {
111 if !v.contains(&r) {
112 v.push(r);
113 }
114}
115
116fn is_daemon_op(op: &Op) -> bool {
117 matches!(
118 op,
119 Op::ChangeIntegrate { .. }
120 | Op::ChangeIntegrateBatch { .. }
121 | Op::Observe { .. }
122 | Op::Taint { .. }
123 | Op::SessionBind { .. }
124 | Op::ImportUnmediated { .. }
125 | Op::Freeze { .. }
126 )
127}
128
129fn is_human_op(op: &Op) -> bool {
130 matches!(
131 op,
132 Op::HumanEnroll { .. }
133 | Op::HumanRotate { .. }
134 | Op::HumanRevoke { .. }
135 | Op::HumanRecoveryKey { .. }
136 | Op::HumanRecover { .. }
137 | Op::DaemonRotate { .. }
138 | Op::DaemonRecover { .. }
139 | Op::PolicyUpdate { .. }
140 | Op::SessionStart { .. }
141 | Op::SessionIsolate { .. }
142 | Op::SessionEnd { .. }
143 | Op::CapRevoke { .. }
144 | Op::LeaseRelease { .. }
145 | Op::ChangePropose { .. }
146 | Op::ChangeWithdraw { .. }
147 | Op::Approve { .. }
148 | Op::Reject { .. }
149 | Op::ImportExternal { .. }
150 | Op::BridgePush { .. }
151 | Op::Freeze { .. }
152 | Op::Unfreeze { .. }
153 | Op::Redact { .. }
154 )
155}
156
157fn is_key_op(op: &Op) -> bool {
162 matches!(
163 op,
164 Op::HumanRotate { .. }
165 | Op::HumanRevoke { .. }
166 | Op::HumanRecoveryKey { .. }
167 | Op::HumanRecover { .. }
168 | Op::DaemonRotate { .. }
169 | Op::DaemonRecover { .. }
170 )
171}
172
173fn check_lease_request(
175 s: &Session,
176 items: &[LeaseItem],
177 intent: &str,
178 ctx: &Ctx,
179 keys: bool,
180 v: &mut Verdict,
181) {
182 if items.is_empty() || items.len() > ctx.policy.config.leases.max_items {
183 v.deny("lease_item_count");
184 }
185 if ctx.format >= FORMAT_LEASE_INTENT && intent.trim().is_empty() {
186 v.deny("intent_required");
187 }
188 if intent.len() > 500 {
189 v.deny("intent_too_long");
190 }
191 if items.iter().any(LeaseItem::is_shared) && !ctx.policy.config.shared_leases() {
192 v.deny("shared_off");
193 }
194 for item in items {
195 if !item.names.is_empty() {
196 if item.kind != LeaseKind::Insert {
197 v.deny(format!("names_need_insert:{}", item.symbol));
198 }
199 let valid = item.names.iter().all(|n| symbols::valid_reserved_name(n));
200 if !valid || item.names.len() > symbols::MAX_RESERVED_NAMES {
201 v.deny(format!("invalid_names:{}", item.symbol));
202 }
203 }
204 let Some((path, name)) = symbols::split(&item.symbol) else {
205 v.deny(format!("invalid_symbol:{}", item.symbol));
206 continue;
207 };
208 if validate_repo_path(path).is_err() || name.is_empty() {
209 v.deny(format!("invalid_symbol:{}", item.symbol));
210 continue;
211 }
212 let keyed = ctx.format >= FORMAT_KEYED && name == keyed::DIR;
213 if keyed {
214 check_key_item(s, item, path, ctx, v);
215 } else if ctx.format >= FORMAT_KEYED && ctx.policy.keyed.get(path).is_some() {
216 v.deny(format!("key_container:{}", keyed::container(path)));
218 } else if !s.scope.paths.iter().any(|p| path_matches(p, path)) {
219 v.deny(format!("scope_path:{path}"));
220 }
221 if let Some(pats) = &s.scope.symbols {
222 let bare = keys.then(|| symbols::bare(&item.symbol));
224 let bare = bare.as_deref().unwrap_or(name);
225 let ok = if name.starts_with('<') {
226 pats.iter().any(|p| p == "*")
227 } else {
228 pats.iter()
229 .any(|p| symbol_matches(p, name) || symbol_matches(p, bare))
230 };
231 if !ok {
232 v.deny(format!("scope_symbol:{}", item.symbol));
233 }
234 }
235 }
236}
237
238fn check_key_item(s: &Session, item: &LeaseItem, dir: &str, ctx: &Ctx, v: &mut Verdict) {
243 let Some(d) = ctx.policy.keyed.get(dir) else {
244 v.deny(format!("not_keyed:{dir}"));
245 return;
246 };
247 if item.kind != LeaseKind::Insert || item.names.is_empty() {
248 v.deny(format!("key_required:{}", item.symbol));
249 }
250 for n in item.names.iter().filter(|n| !d.valid_key(n)) {
251 v.deny(format!("invalid_key:{dir}#{n}"));
252 }
253 let probe = format!("{dir}/{}", keyed::DIR);
256 let in_dir = |p: &String| {
257 path_matches(p, &probe)
258 || p.strip_prefix(dir)
259 .and_then(|rest| rest.strip_prefix('/'))
260 .is_some_and(|name| !name.is_empty() && !name.contains('/'))
261 };
262 if !s.scope.paths.iter().any(in_dir) {
263 v.deny(format!("scope_path:{dir}"));
264 }
265}
266
267fn check_keys(
272 state: &State,
273 mine: &[&LeaseItem],
274 base: &lonca_core::GitId,
275 ctx: &Ctx,
276 v: &mut Verdict,
277) {
278 let Some(f) = ctx.facts else {
279 return;
280 };
281 let dirs = &ctx.policy.keyed;
282 for t in &f.taken_keys {
283 v.deny(format!("key_taken:{t}"));
284 }
285 for path in &f.added_files {
286 let Some((d, name)) = dirs.of(path) else {
287 continue;
288 };
289 let Some(key) = d.key_of(name) else {
290 v.deny(format!("key_unreadable:{path}"));
291 continue;
292 };
293 let container = d.container();
294 let reserved = mine.iter().any(|i| {
295 i.kind == LeaseKind::Insert && i.symbol == container && i.names.contains(&key)
296 });
297 if !reserved {
298 v.deny(format!("key_not_reserved:{}#{key}", d.dir));
299 }
300 let same = |p: &str| {
301 p != path.as_str()
302 && dirs
303 .of(p)
304 .is_some_and(|(o, n)| o.dir == d.dir && o.key_of(n).as_ref() == Some(&key))
305 };
306 if state.created_since_where(base, same) {
307 v.deny(format!("key_taken:{}#{key}", d.dir));
308 }
309 }
310}
311
312fn check_handover(state: &State, own: &str, set: &str, scope: &Scope, t: i64, v: &mut Verdict) {
315 let Some(l) = state.leases.get(set).filter(|l| state.holder(l, t) == own) else {
316 v.deny("not_own_lease");
317 return;
318 };
319 if !l.pins.is_empty() {
320 v.deny("lease_pinned");
321 }
322 if !state.active_leases(t).any(|h| h.lease.set == set) {
323 v.deny("lease_expired");
324 }
325 for item in &l.items {
326 let path = symbols::split(&item.symbol).map(|(path, _)| path);
327 let path = path.unwrap_or_default();
328 if !scope.paths.iter().any(|p| path_matches(p, path)) {
329 v.deny(format!("scope_path:{path}"));
330 }
331 }
332}
333
334fn check_yield(
335 state: &State,
336 own: &str,
337 set: &str,
338 to: Option<&str>,
339 to_session: &str,
340 t: i64,
341 v: &mut Verdict,
342) {
343 let Some(to) = to else {
344 if state.leases.get(set).map(|l| state.holder(l, t)) != Some(own) {
345 v.deny("not_own_lease");
346 }
347 return;
348 };
349 if to != to_session {
350 v.deny("yield_to_another_session");
351 }
352 match state.sessions.get(to) {
353 Some(receiver) => check_handover(state, own, set, &receiver.scope, t, v),
354 None => v.deny("unknown_session"),
355 }
356}
357
358fn check_continuation(state: &State, c: &Continuation, scope: &Scope, v: &mut Verdict) {
361 let Some(old) = state.sessions.get(&c.session) else {
362 v.deny("unknown_session");
363 return;
364 };
365 if old.parent.is_some() {
366 v.deny("continue_child_session");
367 }
368 if old.continued_by.is_some() {
369 v.deny("session_continued");
370 }
371 if !c.widen
372 && let Err(e) = scope_within(scope, &old.scope)
373 {
374 v.deny(format!("scope_widening:{e}"));
375 }
376}
377
378pub fn valid_daemon_key(k: &str) -> bool {
381 let key = HumanKey {
382 alg: "ed25519".into(),
383 public: k.to_string(),
384 };
385 crate::human::valid_key(&key, FORMAT_LEGACY)
386}
387
388fn check_new_key(state: &State, k: &HumanKey, format: u32, v: &mut Verdict) {
393 if !crate::human::valid_key(k, format) {
394 v.deny("invalid_key");
395 } else if state.key_known(&k.public) {
396 v.deny("key_reused");
397 }
398}
399
400fn minutes(m: i64) -> i64 {
404 m.saturating_mul(60_000)
405}
406
407pub fn valid_token(s: &str, max: usize) -> bool {
408 !s.is_empty()
409 && s.len() <= max
410 && s.bytes()
411 .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'_' | b'-' | b'.'))
412}
413
414fn session_used(state: &State, s: &Session) -> bool {
416 s.level == ObservationLevel::Isolated
417 || s.heartbeat.is_some()
418 || s.taint.is_some()
419 || s.shell_seen
420 || !s.writes.is_empty()
421 || s.continued_from.is_some()
422 || state.leases.values().any(|l| l.session == s.id)
423 || state
424 .changes
425 .values()
426 .any(|c| c.actor.session() == Some(s.id.as_str()))
427 || state
428 .sessions
429 .values()
430 .any(|c| c.parent.as_ref() == Some(&s.id))
431}
432
433fn valid_digest(s: &str) -> bool {
435 s.len() == 64 && s.bytes().all(|b| matches!(b, b'0'..=b'9' | b'a'..=b'f'))
436}
437
438pub fn level_for(c: &BindChecks) -> ObservationLevel {
440 if !c.managed_hooks || !c.hook_binary_protected || c.sudo_without_password {
441 ObservationLevel::Unobserved
442 } else if c.locked_sandbox {
443 ObservationLevel::HooksSandbox
444 } else {
445 ObservationLevel::Hooks
446 }
447}
448
449pub const MAX_SCRATCH_ROOTS: usize = 8;
451
452pub const MAX_REJECT_REASON: usize = 4096;
454
455pub fn valid_scratch_root(p: &str) -> bool {
459 p.starts_with('/')
460 && p.len() <= 1024
461 && !p.ends_with('/')
462 && !p.chars().any(char::is_control)
463 && p[1..]
464 .split('/')
465 .all(|c| !c.is_empty() && c != "." && c != "..")
466}
467
468fn validate_scope(scope: &Scope, v: &mut Verdict) {
469 if scope.paths.is_empty() {
470 v.deny("scope_invalid:no_paths");
471 }
472 for p in &scope.paths {
473 if validate_path_pattern(p).is_err() {
474 v.deny(format!("scope_invalid:path:{p}"));
475 }
476 }
477 for s in scope.symbols.iter().flatten() {
478 if validate_symbol_pattern(s).is_err() {
479 v.deny(format!("scope_invalid:symbol:{s}"));
480 }
481 }
482 if scope.ops.is_empty() {
483 v.deny("scope_invalid:no_ops");
484 }
485 if scope.scratch.len() > MAX_SCRATCH_ROOTS {
486 v.deny("scope_invalid:scratch:too_many");
487 }
488 for s in &scope.scratch {
489 if !valid_scratch_root(s) {
490 v.deny(format!("scope_invalid:scratch:{s}"));
491 }
492 }
493 for k in &scope.ops {
494 if !k.agent_grantable() {
495 v.deny(format!("scope_invalid:not_grantable:{}", k.as_str()));
496 }
497 }
498}
499
500pub fn scope_within(inner: &Scope, outer: &Scope) -> Result<(), String> {
502 for p in &inner.paths {
503 if !outer.paths.iter().any(|o| path_pattern_within(p, o)) {
504 return Err(format!("path:{p}"));
505 }
506 }
507 for s in &inner.scratch {
508 if !outer
509 .scratch
510 .iter()
511 .any(|o| s == o || s.starts_with(&format!("{o}/")))
512 {
513 return Err(format!("scratch:{s}"));
514 }
515 }
516 match (&inner.symbols, &outer.symbols) {
517 (_, None) => {}
518 (None, Some(_)) => return Err("symbols:unrestricted".into()),
519 (Some(i), Some(o)) => {
520 for s in i {
521 if !o.iter().any(|x| symbol_pattern_within(s, x)) {
522 return Err(format!("symbol:{s}"));
523 }
524 }
525 }
526 }
527 for k in &inner.ops {
528 if !outer.ops.contains(k) {
529 return Err(format!("op:{}", k.as_str()));
530 }
531 }
532 for (name, i, o) in [
533 ("max_files", inner.max_files, outer.max_files),
534 ("max_lines", inner.max_lines, outer.max_lines),
535 ] {
536 if let Some(o) = o
537 && i.is_none_or(|i| i > o)
538 {
539 return Err(name.into());
540 }
541 }
542 Ok(())
543}
544
545pub fn leases_conflict(a: &LeaseItem, b: &LeaseItem, keys: bool) -> bool {
549 use LeaseKind::*;
550 match (a.kind, b.kind) {
551 (Insert, Insert) => symbols::names_meet(a, b, keys),
552 (Shared, Shared) => false,
553 (Insert, _) => {
554 symbols::covers(&b.symbol, &a.symbol, keys)
555 || (keys && symbols::stands_for(&a.symbol, &b.symbol))
556 }
557 (_, Insert) => {
558 symbols::covers(&a.symbol, &b.symbol, keys)
559 || (keys && symbols::stands_for(&b.symbol, &a.symbol))
560 }
561 _ => symbols::overlaps(&a.symbol, &b.symbol, keys),
562 }
563}
564
565fn check_name(k: CheckKind) -> &'static str {
566 match k {
567 CheckKind::Syntax => "syntax",
568 CheckKind::Scope => "scope",
569 CheckKind::ClaimDiff => "claim_diff",
570 CheckKind::Merge => "merge",
571 CheckKind::Build => "build",
572 CheckKind::Tests => "tests",
573 CheckKind::TestIntegrity => "test_integrity",
574 CheckKind::Extended => "extended",
575 CheckKind::Named => "named",
576 }
577}
578
579pub fn evidence_name(a: &Attestation) -> &str {
582 let name = a
583 .details
584 .get("name")
585 .filter(|_| a.check == CheckKind::Named);
586 name.map_or(check_name(a.check), String::as_str)
587}
588
589fn sorted(v: &[String]) -> Vec<String> {
590 let mut v = v.to_vec();
591 v.sort();
592 v.dedup();
593 v
594}
595
596pub(crate) fn session_context(
597 state: &State,
598 actor: &Actor,
599 format: u32,
600) -> Vec<(&'static str, CVal)> {
601 let (tainted, level, task) = match actor.session().and_then(|s| state.sessions.get(s)) {
602 Some(s) => (
603 s.taint.is_some(),
604 format!("{:?}", s.level).to_lowercase(),
605 s.task.clone(),
606 ),
607 None => (false, "none".into(), String::new()),
608 };
609 let mut context = vec![
610 ("tainted", CVal::Bool(tainted)),
611 ("observation", CVal::Str(level)),
612 ];
613 if format >= FORMAT_ISOLATE {
615 context.push(("task", CVal::Str(task)));
616 }
617 context
618}
619
620pub fn decide(
621 state: &State,
622 actor: &Actor,
623 op: &Op,
624 human: Option<&HumanSignature>,
625 ctx: &Ctx,
626) -> Decision {
627 let t = ctx.time_ms;
628 let cfg = &ctx.policy.config;
629
630 if state.next_seq == 0 {
631 let Op::RepoInit {
632 repo,
633 human: owner,
634 human_key,
635 ..
636 } = op
637 else {
638 return Decision::deny("not_initialized");
639 };
640 let Actor::Human { name } = actor else {
641 return Decision::deny("genesis_needs_human");
642 };
643 if name != owner {
644 return Decision::deny("genesis_actor_mismatch");
645 }
646 if !valid_token(repo, 64) || !valid_token(owner, 64) {
647 return Decision::deny("invalid_name");
648 }
649 let Some(sig) = human else {
650 return Decision::deny("missing_human_signature");
651 };
652 if let Err(e) = crate::human::verify(human_key, repo, op, sig, t, ctx.format) {
653 return Decision::deny(e);
654 }
655 return Decision::allow();
656 }
657 if matches!(op, Op::RepoInit { .. }) {
658 return Decision::deny("already_initialized");
659 }
660 if t < state.last_time_ms {
661 return Decision::deny("time_backwards");
662 }
663 if let Some(reason) = &state.frozen
664 && !matches!(
665 op,
666 Op::Unfreeze { .. } | Op::Observe { .. } | Op::Taint { .. } | Op::Freeze { .. }
667 )
668 && !is_key_op(op)
669 {
670 return Decision::deny(format!("frozen:{reason}"));
671 }
672
673 match actor {
675 Actor::Daemon => {
676 if !is_daemon_op(op) {
677 return Decision::deny(format!("not_a_daemon_op:{}", op.kind().as_str()));
678 }
679 if human.is_some() {
680 return Decision::deny("unexpected_human_signature");
681 }
682 }
683 Actor::Human { name } => {
684 let recover = op.signed_by_recovery_key();
685 if !state.humans.contains_key(name) {
686 return Decision::deny("unknown_human");
687 }
688 let key = if recover {
691 state.recovery.get(name)
692 } else {
693 state.humans.get(name)
694 };
695 let Some(key) = key else {
696 return Decision::deny("no_recovery_key");
697 };
698 let Some(sig) = human else {
699 return Decision::deny("missing_human_signature");
700 };
701 if &sig.human != name {
702 return Decision::deny("signature_human_mismatch");
703 }
704 if state.nonces.contains(&sig.nonce) {
705 return Decision::deny("nonce_reused");
706 }
707 if let Err(e) = crate::human::verify(key, &state.repo, op, sig, t, ctx.format) {
708 return Decision::deny(e);
709 }
710 if cfg.approvals.require_presence
713 && !crate::human::proves_presence(&key.alg)
714 && !recover
715 {
716 return Decision::deny("presence_required");
717 }
718 if !is_human_op(op) {
719 return Decision::deny(format!("not_a_human_op:{}", op.kind().as_str()));
720 }
721 }
722 Actor::Agent { name, session } => {
723 if human.is_some() {
724 return Decision::deny("unexpected_human_signature");
725 }
726 let Some(s) = state.sessions.get(session) else {
727 return Decision::deny("unknown_session");
728 };
729 if &s.agent != name {
730 return Decision::deny("session_agent_mismatch");
731 }
732 if !state.session_active(session, t) {
733 return Decision::deny("session_inactive");
734 }
735 let own_lifecycle = matches!(op, Op::SessionEnd { .. } | Op::CapRevoke { .. });
736 if !own_lifecycle {
737 let k = op.kind();
738 if !k.agent_grantable() {
739 return Decision::deny(format!("not_grantable:{}", k.as_str()));
740 }
741 let k = match k {
743 OpKind::LeaseInterest => OpKind::LeaseAcquire,
744 k => k,
745 };
746 if !s.scope.ops.contains(&k) {
747 return Decision::deny(format!("scope_op:{}", k.as_str()));
748 }
749 }
750 }
751 }
752
753 let mut v = Verdict::default();
755 match op {
756 Op::RepoInit { .. } => unreachable!("handled above"),
757 Op::HumanEnroll { human: who, key } => {
758 if !valid_token(who, 64) {
759 v.deny("invalid_name");
760 }
761 if state.humans.contains_key(who) {
762 v.deny("already_enrolled");
763 }
764 let valid = if ctx.format >= FORMAT_SECURITY_KEYS {
767 crate::human::valid_key(key, ctx.format)
768 } else {
769 crate::human::alg(&key.alg, ctx.format).is_some()
770 && hex::decode(&key.public).is_ok()
771 };
772 if !valid {
773 v.deny("invalid_key");
774 }
775 if state.retired_keys.contains(&key.public) {
776 v.deny("key_reused");
777 }
778 }
779 Op::HumanRotate { human: who, key }
780 | Op::HumanRecoveryKey { human: who, key }
781 | Op::HumanRecover { human: who, key } => {
782 if actor.label() != format!("human:{who}") {
783 v.deny("not_own_key");
784 }
785 check_new_key(state, key, ctx.format, &mut v);
786 }
787 Op::DaemonRotate { key } | Op::DaemonRecover { key, .. } => {
788 if !valid_daemon_key(key) {
789 v.deny("invalid_key");
790 } else if *key == state.daemon_key
791 || state.retired_daemon_keys.contains(key)
792 || state.key_known(key)
793 {
794 v.deny("key_reused");
795 }
796 if let Op::DaemonRecover {
799 recover: Some(r), ..
800 } = op
801 {
802 if actor.label() != format!("human:{}", r.human) {
803 v.deny("not_own_key");
804 }
805 check_new_key(state, &r.key, ctx.format, &mut v);
806 }
807 }
808 Op::HumanRevoke { human: who, reason } => {
809 if !state.humans.contains_key(who) {
810 v.deny("not_enrolled");
811 } else if state.humans.len() == 1 {
812 v.deny("last_human");
813 }
814 if reason.trim().is_empty() {
815 v.deny("reason_required");
816 }
817 if reason.len() > 500 {
818 v.deny("reason_too_long");
819 }
820 }
821 Op::PolicyUpdate { .. } => {
822 let n = cfg.approvals.policy_updates;
823 if n > 1 {
824 v.escalate("policy_update", n - 1);
825 }
826 }
827 Op::SessionStart {
828 session,
829 agent,
830 task,
831 scope,
832 expires_ms,
833 continues,
834 harness,
835 ..
836 } => {
837 if !valid_token(session, 64) || !valid_token(agent, 64) {
838 v.deny("invalid_name");
839 }
840 if harness.as_ref().is_some_and(|h| !valid_token(h, 64)) {
841 v.deny("invalid_harness");
842 }
843 if state.sessions.contains_key(session) {
844 v.deny("session_exists");
845 }
846 if task.len() > 200 {
847 v.deny("task_too_long");
848 }
849 validate_scope(scope, &mut v);
850 if *expires_ms <= t
851 || expires_ms.saturating_sub(t) > minutes(cfg.sessions.max_ttl_minutes)
852 {
853 v.deny("session_ttl");
854 }
855 if let Some(c) = continues {
856 check_continuation(state, c, scope, &mut v);
857 }
858 }
859 Op::SessionBind {
860 session,
861 checks,
862 level,
863 } => {
864 if !state.session_active(session, t) {
865 v.deny("session_inactive");
866 }
867 if *level != level_for(checks) {
868 v.deny("bind_level_mismatch");
869 }
870 let s = state.sessions.get(session);
871 if s.is_some_and(|s| s.level == ObservationLevel::Isolated) {
873 v.deny("session_isolated");
874 }
875 if ctx.format >= FORMAT_SESSION_HARNESS {
876 let named = s.and_then(|s| s.harness.as_ref());
879 if *level > ObservationLevel::Unobserved && named != Some(&checks.harness) {
880 v.deny("bind_other_harness");
881 }
882 if s.is_some_and(|s| s.bound && *level > s.level) {
884 v.deny("bind_raises_level");
885 }
886 }
887 }
888 Op::SessionIsolate {
889 session,
890 sandbox,
891 profile,
892 } => {
893 match state.sessions.get(session) {
894 None => v.deny("unknown_session"),
895 Some(s) if s.parent.is_some() => v.deny("session_is_a_child"),
898 Some(s) if !matches!(actor, Actor::Human { name } if *name == s.root_human) => {
899 v.deny("not_own_session")
900 }
901 Some(s) if session_used(state, s) => v.deny("session_used"),
903 Some(_) => {}
904 }
905 if !state.session_active(session, t) {
906 v.deny("session_inactive");
907 }
908 if !valid_token(sandbox, 32) || sandbox == "none" {
909 v.deny("invalid_sandbox");
910 }
911 if !valid_digest(profile) {
912 v.deny("invalid_profile");
913 }
914 }
915 Op::SessionEnd { session, .. } | Op::CapRevoke { session, .. } => {
916 match state.sessions.get(session) {
917 None => v.deny("unknown_session"),
918 Some(s) if s.ended.is_some() && matches!(op, Op::SessionEnd { .. }) => {
919 v.deny("session_ended")
920 }
921 Some(_) => {}
922 }
923 if let Some(own) = actor.session()
924 && !state.descends_from(session, own)
925 {
926 v.deny("not_own_session");
927 }
928 }
929 Op::CapAttenuate {
930 parent,
931 child,
932 agent,
933 scope,
934 expires_ms,
935 leases,
936 } => {
937 let own = actor.session().unwrap_or_default();
938 if parent != own {
939 v.deny("not_own_session");
940 }
941 for set in leases {
942 check_handover(state, own, set, scope, t, &mut v);
943 }
944 if !valid_token(child, 64) || !valid_token(agent, 64) {
945 v.deny("invalid_name");
946 }
947 if state.sessions.contains_key(child) {
948 v.deny("session_exists");
949 }
950 validate_scope(scope, &mut v);
951 if let Some(p) = state.sessions.get(parent) {
952 if let Err(e) = scope_within(scope, &p.scope) {
953 v.deny(format!("scope_widening:{e}"));
954 }
955 if *expires_ms > p.expires_ms || *expires_ms <= t {
956 v.deny("session_ttl");
957 }
958 }
959 }
960 Op::LeaseAcquire {
961 set,
962 items,
963 intent,
964 ttl_ms,
965 } => {
966 let own = actor.session().unwrap_or_default();
967 let Some(s) = state.sessions.get(own) else {
968 return Decision::deny("unknown_session");
969 };
970 if !valid_token(set, 64) || state.leases.contains_key(set) {
971 v.deny("invalid_lease_set");
972 }
973 if *ttl_ms <= 0 || *ttl_ms > minutes(cfg.leases.max_ttl_minutes) {
974 v.deny("lease_ttl");
975 }
976 check_lease_request(s, items, intent, ctx, state.symbol_keys, &mut v);
977 if let Some(holder) = state.reserved_against(own, items, t) {
978 v.deny(format!("lease_reserved:{holder}"));
979 }
980 for i in state.wanted_against(own, items, t) {
981 v.deny(format!("lease_wanted:{}", i.session));
982 }
983 for held in state.active_leases(t) {
984 if held.holder == own {
985 continue;
986 }
987 for a in items {
988 for b in &held.items {
989 if state.conflict(a, b) {
990 v.deny(format!(
991 "lease_held:{}:{}:{}",
992 b.symbol, held.holder, held.lease.intent
993 ));
994 }
995 }
996 }
997 }
998 }
999 Op::LeaseInterest { items, intent } => {
1000 let own = actor.session().unwrap_or_default();
1001 let Some(s) = state.sessions.get(own) else {
1002 return Decision::deny("unknown_session");
1003 };
1004 check_lease_request(s, items, intent, ctx, state.symbol_keys, &mut v);
1005 if !state.taken(own, items, t) {
1008 v.deny("lease_free");
1009 }
1010 if state.waiting(own, items) {
1011 v.deny("interest_exists");
1012 }
1013 v.info
1014 .push(format!("{RESERVATION_WINDOW}{}", cfg.leases.reservation_ms));
1015 if ctx.format >= FORMAT_LEASE_INTENT {
1016 v.info
1017 .push(format!("{INTEREST_BLOCK}{}", cfg.leases.interest_block_ms));
1018 }
1019 }
1020 Op::LeaseRelease { set } => match state.leases.get(set) {
1021 None => v.deny("unknown_lease_set"),
1022 Some(l) => {
1023 if let Some(own) = actor.session()
1024 && !state.descends_from(state.holder(l, t), own)
1025 {
1026 v.deny("not_own_lease");
1027 }
1028 }
1029 },
1030 Op::LeaseRenew { set, ttl_ms } => match state.leases.get(set) {
1031 None => v.deny("unknown_lease_set"),
1032 Some(l) => {
1033 let liveness = ctx.format >= FORMAT_LEASE_LIVENESS;
1034 let holder = state.holder(l, t);
1035 if Some(holder) != actor.session() {
1036 v.deny("not_own_lease");
1037 }
1038 if l.expires_ms <= t || (liveness && state.session_lost(holder, t)) {
1039 v.deny("lease_expired");
1040 }
1041 if *ttl_ms <= 0 || *ttl_ms > minutes(cfg.leases.max_ttl_minutes) {
1042 v.deny("lease_ttl");
1043 }
1044 if liveness
1047 && t.saturating_sub(l.acquired_ms) >= minutes(cfg.leases.max_hold_minutes)
1048 && state.contended(l, t, minutes(cfg.leases.max_ttl_minutes))
1049 {
1050 v.deny("lease_hold_limit");
1051 }
1052 }
1053 },
1054 Op::ChangePropose {
1055 change,
1056 base,
1057 claim,
1058 ..
1059 } => propose(state, actor, *change, base, claim, ctx, &mut v),
1060 Op::ChangeWithdraw { change } => match state.changes.get(change) {
1061 None => v.deny("unknown_change"),
1062 Some(c) => {
1063 if !matches!(
1064 c.status,
1065 ChangeStatus::Queued | ChangeStatus::Pending { .. }
1066 ) {
1067 v.deny("change_not_in_flight");
1068 }
1069 if let Some(own) = actor.session()
1070 && !c
1071 .actor
1072 .session()
1073 .is_some_and(|s| state.descends_from(s, own) || state.continues(own, s))
1074 {
1075 v.deny("not_own_change");
1076 }
1077 }
1078 },
1079 Op::ChangeIntegrate {
1080 change,
1081 trunk_before,
1082 merged,
1083 conflicts,
1084 } => {
1085 match state.changes.get(change) {
1086 None => v.deny("unknown_change"),
1087 Some(c) if c.status != ChangeStatus::Queued => v.deny("change_not_queued"),
1088 Some(_) => {}
1089 }
1090 if state.queue.first() != Some(change) {
1091 v.deny("not_queue_head");
1092 }
1093 if state.trunk.as_ref() != Some(trunk_before) {
1094 v.deny("stale_trunk");
1095 }
1096 match merged {
1097 None => {
1098 v.deny("merge_conflict");
1099 for c in conflicts {
1100 v.deny(format!("conflict:{c}"));
1101 }
1102 }
1103 Some(_) => {
1104 if !conflicts.is_empty() {
1105 v.deny("conflicts_with_merge");
1106 }
1107 verify_evidence(state, [change], ctx, &mut v);
1108 check_quota(state, [change], ctx, &mut v);
1109 if !cfg.leases.release_on_integrate {
1112 v.info.push(LEASES_KEPT.into());
1113 }
1114 }
1115 }
1116 }
1117 Op::ChangeIntegrateBatch {
1118 trunk_before,
1119 merged,
1120 } => {
1121 let size = cfg.queue.batch_size as usize;
1122 if merged.len() < 2 || merged.len() > size {
1123 v.deny("batch_size");
1124 }
1125 let changes = merged.iter().map(|(change, _)| change);
1126 if !changes.eq(state.queue.iter().take(merged.len())) {
1127 v.deny("not_queue_head");
1128 }
1129 if state.trunk.as_ref() != Some(trunk_before) {
1130 v.deny("stale_trunk");
1131 }
1132 let changes = merged.iter().map(|(change, _)| change);
1133 verify_evidence(state, changes, ctx, &mut v);
1134 let changes = merged.iter().map(|(change, _)| change);
1135 check_quota(state, changes, ctx, &mut v);
1136 if !cfg.leases.release_on_integrate {
1137 v.info.push(LEASES_KEPT.into());
1138 }
1139 }
1140 Op::Approve {
1141 target_seq,
1142 note,
1143 checks,
1144 } => match state.pending.get(target_seq) {
1145 None => v.deny("nothing_pending"),
1146 Some(p) => {
1147 if let Actor::Human { name } = actor
1148 && p.approvals.contains(name)
1149 {
1150 v.deny("already_approved");
1151 }
1152 if note.len() > 500 {
1153 v.deny("note_too_long");
1154 }
1155 check_approved_checks(checks, &p.op, &cfg.verification, &mut v);
1156 if p.approvals.len() as u32 + 1 >= p.required {
1157 v.info.push(RELEASED.into());
1158 match &p.op {
1161 Op::HumanRevoke { human: who, .. }
1162 if state.humans.len() == 1 && state.humans.contains_key(who) =>
1163 {
1164 v.deny("last_human")
1165 }
1166 Op::HumanRotate { key, .. }
1167 | Op::HumanRecoveryKey { key, .. }
1168 | Op::HumanRecover { key, .. }
1169 if state.key_known(&key.public) =>
1170 {
1171 v.deny("key_reused")
1172 }
1173 _ => {}
1174 }
1175 }
1176 }
1177 },
1178 Op::Reject { target_seq, reason } => {
1179 if !state.pending.contains_key(target_seq) {
1180 v.deny("nothing_pending");
1181 }
1182 if reason.trim().is_empty() {
1183 v.deny("reason_required");
1184 }
1185 if reason.len() > MAX_REJECT_REASON {
1186 v.deny("reason_too_long");
1187 }
1188 }
1189 Op::Observe { session, events } => {
1190 if !state.sessions.contains_key(session) {
1191 v.deny("unknown_session");
1192 }
1193 if events.len() > 256 {
1194 v.deny("too_many_events");
1195 }
1196 let listed = |e: &Observation| match &e.kind {
1197 ObsKind::Unproposed { paths, .. } => paths.len(),
1198 _ => 0,
1199 };
1200 if events.iter().map(listed).any(|n| n > MAX_UNPROPOSED_PATHS) {
1201 v.deny("too_many_events");
1202 }
1203 if ctx.format >= FORMAT_LEASE_LIVENESS
1206 && events.iter().any(|e| e.kind == ObsKind::Heartbeat)
1207 {
1208 let timeout = cfg.leases.heartbeat_timeout_ms;
1209 v.info.push(format!("{HEARTBEAT_TIMEOUT}{timeout}"));
1210 }
1211 }
1212 Op::Taint { session, .. } => {
1213 if !state.sessions.contains_key(session) {
1214 v.deny("unknown_session");
1215 }
1216 }
1217 Op::ImportExternal { trunk_before, .. } => {
1218 if state.trunk.as_ref() != Some(trunk_before) {
1219 v.deny("stale_trunk");
1220 }
1221 }
1222 Op::ImportUnmediated { .. } | Op::Freeze { .. } => {}
1223 Op::Unfreeze { .. } => {
1224 if state.frozen.is_none() {
1225 v.deny("not_frozen");
1226 }
1227 }
1228 Op::BridgePush {
1229 oplog_seq,
1230 oplog_hash,
1231 ..
1232 } => {
1233 if oplog_seq.checked_add(1) != Some(state.next_seq) || *oplog_hash != state.head_hash {
1234 v.deny("stale_anchor");
1235 }
1236 }
1237 Op::Negotiate {
1238 thread,
1239 to_session,
1240 message,
1241 } => {
1242 let own = actor.session().unwrap_or_default();
1243 if to_session == own || !state.session_active(to_session, t) {
1244 v.deny("invalid_counterparty");
1245 }
1246 match state.threads.get(thread) {
1247 Some(th) => {
1248 let pair = [th.a.as_str(), th.b.as_str()];
1249 if !(pair.contains(&own) && pair.contains(&to_session.as_str())) {
1250 v.deny("not_thread_participant");
1251 }
1252 if th.closed {
1253 v.deny("thread_closed");
1254 }
1255 if th.rounds >= cfg.negotiation.max_rounds {
1256 v.deny("negotiation_exhausted");
1257 }
1258 }
1259 None => {
1260 if !valid_token(thread, 64) {
1261 v.deny("invalid_name");
1262 }
1263 }
1264 }
1265 if let NegotiationMsg::Yield { set, to } = message {
1266 check_yield(state, own, set, to.as_deref(), to_session, t, &mut v);
1267 }
1268 }
1269 Op::Redact { blob, reason } => {
1270 if state.redacted.contains(blob) {
1271 v.deny("already_redacted");
1272 }
1273 if reason.trim().is_empty() {
1274 v.deny("reason_required");
1275 }
1276 }
1277 }
1278
1279 if !matches!(actor, Actor::Daemon) && !matches!(op, Op::ChangePropose { .. }) {
1281 let ctxv = session_context(state, actor, ctx.format);
1282 v.cedar(ctx.policy.evaluate(actor, op.kind().as_str(), ctxv));
1283 }
1284 if op.daemon_key_switch().is_some() {
1288 for r in std::mem::take(&mut v.escalate) {
1289 v.deny(format!("not_escalatable:{r}"));
1290 }
1291 }
1292 v.finish(state.humans.len(), cfg.approvals.cap_to_enrolled_humans)
1293}
1294
1295fn check_approved_checks(checks: &[String], op: &Op, vc: &Verification, v: &mut Verdict) {
1298 if checks.is_empty() {
1299 return;
1300 }
1301 if !matches!(op, Op::ChangePropose { .. }) {
1302 v.deny("checks_not_a_change");
1303 }
1304 let canonical = checks.is_sorted_by(|a, b| a < b);
1305 if !canonical || checks.len() > MAX_CHECKS {
1306 v.deny("invalid_checks");
1307 }
1308 for name in checks.iter().filter(|n| !vc.checks.contains_key(*n)) {
1309 v.deny(format!("unknown_check:{name}"));
1310 }
1311}
1312
1313fn verify_evidence<'a>(
1314 state: &State,
1315 changes: impl IntoIterator<Item = &'a lonca_core::ChangeId>,
1316 ctx: &Ctx,
1317 v: &mut Verdict,
1318) {
1319 let records: Vec<_> = changes
1320 .into_iter()
1321 .filter_map(|c| state.changes.get(c))
1322 .collect();
1323 let reaches = records.iter().any(|c| c.facts.reaches_others());
1324 let vc = &ctx.policy.config.verification;
1325 let named = records.iter().flat_map(|c| c.owed_checks(vc)).collect();
1326 let owed = vc.for_change(reaches, &named);
1327 for problem in evidence_problems(&owed, ctx.evidence) {
1328 v.deny(problem);
1329 }
1330}
1331
1332fn check_quota<'a>(
1333 state: &State,
1334 changes: impl IntoIterator<Item = &'a lonca_core::ChangeId>,
1335 ctx: &Ctx,
1336 v: &mut Verdict,
1337) {
1338 for c in changes.into_iter().filter_map(|c| state.changes.get(c)) {
1339 let quota = &ctx.policy.config.quota;
1340 for problem in quota_problems(state, &c.actor, ctx.time_ms, quota) {
1341 v.deny(problem);
1342 }
1343 }
1344}
1345
1346pub fn quota_problems(state: &State, actor: &Actor, time_ms: i64, quota: &Quota) -> Vec<String> {
1349 let past = |limit: u64, spent: u64| limit > 0 && spent >= limit.saturating_mul(1000);
1350 let mut problems = Vec::new();
1351 if let Some(session) = actor.session()
1352 && past(quota.session_seconds, state.session_spent(session))
1353 {
1354 problems.push("verification_quota:session".to_string());
1355 }
1356 if past(quota.day_seconds, state.day_spent(time_ms)) {
1357 problems.push("verification_quota:day".to_string());
1358 }
1359 problems
1360}
1361
1362pub fn evidence_problems(vc: &Verification, evidence: &[Attestation]) -> Vec<String> {
1365 let mut problems = Vec::new();
1366 for a in evidence {
1367 match a.result {
1368 CheckResult::Fail => problems.push(format!("verification_failed:{}", evidence_name(a))),
1369 CheckResult::Error => problems.push(format!("verification_error:{}", evidence_name(a))),
1370 CheckResult::Pass | CheckResult::Skipped => {}
1371 }
1372 if vc.require_sandbox
1373 && matches!(
1374 a.check,
1375 CheckKind::Build | CheckKind::Tests | CheckKind::Extended | CheckKind::Named
1376 )
1377 && a.details.get("sandbox").is_none_or(|s| s == "none")
1378 {
1379 problems.push("unsandboxed_verification".to_string());
1380 }
1381 }
1382 if vc.required {
1383 let passed = |k: CheckKind| {
1384 let mut all = evidence.iter();
1385 all.any(|a| a.check == k && a.result == CheckResult::Pass)
1386 };
1387 if vc.build.is_some() && !passed(CheckKind::Build) {
1388 problems.push("verification_missing:build".to_string());
1389 }
1390 if vc.test.is_some() && !passed(CheckKind::Tests) {
1391 problems.push("verification_missing:tests".to_string());
1392 }
1393 if vc.on_signature_change.is_some() && !passed(CheckKind::Extended) {
1394 problems.push("verification_missing:extended".to_string());
1395 }
1396 }
1397 for name in vc.checks.keys() {
1400 let passed = |a: &Attestation| {
1401 a.check == CheckKind::Named
1402 && a.details.get("name") == Some(name)
1403 && a.result == CheckResult::Pass
1404 };
1405 if !evidence.iter().any(passed) {
1406 problems.push(format!("verification_missing:{name}"));
1407 }
1408 }
1409 problems
1410}
1411
1412fn affected_holders(
1416 state: &State,
1417 actor: &Actor,
1418 f: &Facts,
1419 base: &lonca_core::GitId,
1420 t: i64,
1421) -> Vec<(String, String)> {
1422 let keys = state.symbol_keys;
1423 let reaches = |item: &&LeaseItem| {
1424 let met = |a: &String| {
1425 symbols::overlaps(&item.symbol, a, keys)
1426 || symbols::overlaps(&item.symbol, &state.renamed_to(a, Some(base)), keys)
1427 };
1428 item.kind != LeaseKind::Insert && f.affected.iter().any(met)
1429 };
1430 let own = |l: &ActiveLease| {
1431 let me = actor.session();
1432 me.is_some_and(|s| state.continues(s, l.holder))
1433 };
1434 let held = state.active_leases(t).filter(|l| !own(l));
1435 let held = held.filter(|l| l.items.iter().copied().any(|i| reaches(&i)));
1436 held.map(|l| (l.holder.to_string(), l.lease.set.clone()))
1437 .collect()
1438}
1439
1440fn reserved_by_others(state: &State, actor: &Actor, f: &Facts, t: i64) -> Vec<(String, String)> {
1444 let keys = state.symbol_keys;
1445 let own = |l: &ActiveLease| {
1446 let me = actor.session();
1447 me.is_some_and(|s| state.continues(s, l.holder))
1448 };
1449 let named = |i: &&LeaseItem| i.kind == LeaseKind::Insert && !i.names.is_empty();
1450 let mut out = BTreeSet::new();
1451 for l in state.active_leases(t).filter(|l| !own(l)) {
1452 for item in l.items.iter().copied().filter(named) {
1453 let reserved =
1454 |a: &&String| symbols::covers(&item.symbol, a, keys) && symbols::reserves(item, a);
1455 for a in f.adds.iter().filter(reserved) {
1456 out.insert((a.clone(), l.holder.to_string()));
1457 }
1458 }
1459 }
1460 out.into_iter().collect()
1461}
1462
1463fn propose(
1464 state: &State,
1465 actor: &Actor,
1466 change: lonca_core::ChangeId,
1467 base: &lonca_core::GitId,
1468 claim: &lonca_core::ops::Claim,
1469 ctx: &Ctx,
1470 v: &mut Verdict,
1471) {
1472 let t = ctx.time_ms;
1473 let policy = ctx.policy;
1474 let cfg = &policy.config;
1475 let Some(f) = ctx.facts else {
1476 v.deny("missing_facts");
1477 return;
1478 };
1479 for problem in quota_problems(state, actor, t, &cfg.quota) {
1480 v.deny(problem);
1481 }
1482 if !state.trunk_history.contains(base) {
1483 v.deny("unknown_base");
1484 }
1485 if let Some(existing) = state.changes.get(&change) {
1486 match existing.status {
1487 ChangeStatus::Conflicted { .. }
1488 | ChangeStatus::Rejected { .. }
1489 | ChangeStatus::Withdrawn => {
1490 if existing.actor.label() != actor.label() {
1491 v.deny("change_owned_by_other");
1492 }
1493 }
1494 _ => v.deny("change_in_flight"),
1495 }
1496 }
1497 for (name, claimed, actual) in [
1498 ("edits", &claim.edits, &f.edits),
1499 ("adds", &claim.adds, &f.adds),
1500 ("deletes", &claim.deletes, &f.deletes),
1501 ("deleted_files", &claim.deleted_files, &f.deleted_files),
1502 (
1503 "signature_changes",
1504 &claim.signature_changes,
1505 &f.signature_changes,
1506 ),
1507 ] {
1508 if sorted(claimed) != sorted(actual) {
1509 v.deny(format!("claim_mismatch:{name}"));
1510 }
1511 }
1512 if claim.tests != f.tests {
1513 v.deny("claim_mismatch:tests");
1514 }
1515 if let Some(r) = claim.revert_of
1516 && !state.changes.contains_key(&r)
1517 {
1518 v.deny("unknown_revert_target");
1519 }
1520 let files: BTreeSet<&String> = f
1521 .added_files
1522 .iter()
1523 .chain(&f.modified_files)
1524 .chain(&f.deleted_files)
1525 .collect();
1526 if files.is_empty() {
1527 v.deny("empty_change");
1528 }
1529 if files.len() > cfg.changes.max_files as usize {
1530 v.deny("too_many_files");
1531 }
1532 let critical: Vec<&String> = files
1533 .iter()
1534 .copied()
1535 .filter(|p| policy.is_critical(p))
1536 .collect();
1537
1538 match actor {
1539 Actor::Daemon => v.deny("not_a_daemon_op:change.propose"),
1540 Actor::Human { .. } => {}
1541 Actor::Agent { session, .. } => {
1542 let Some(s) = state.sessions.get(session) else {
1543 v.deny("unknown_session");
1544 return;
1545 };
1546 for p in &files {
1547 if !s.scope.paths.iter().any(|pat| path_matches(pat, p)) {
1548 v.deny(format!("scope_path:{p}"));
1549 }
1550 }
1551 if s.scope.max_files.is_some_and(|m| files.len() > m as usize) {
1552 v.deny("scope_max_files");
1553 }
1554 if s.scope
1555 .max_lines
1556 .is_some_and(|m| f.lines_added.saturating_add(f.lines_removed) > m)
1557 {
1558 v.deny("scope_max_lines");
1559 }
1560 let touched: BTreeSet<&String> = f
1561 .edits
1562 .iter()
1563 .chain(&f.adds)
1564 .chain(&f.deletes)
1565 .chain(&f.signature_changes)
1566 .collect();
1567 if let Some(pats) = &s.scope.symbols {
1568 for sym in &touched {
1569 let name = symbols::split(sym).map(|(_, n)| n).unwrap_or(sym);
1570 let defs = ctx.symbols_engine.is_some_and(|v| v >= ENGINE_DEFS);
1574 let of = symbols::def_of(name).filter(|_| defs);
1575 let of = of.unwrap_or(name);
1576 let bare = state.symbol_keys.then(|| symbols::bare(sym));
1577 let bare = bare.as_deref();
1578 let bare = bare.map(|b| symbols::def_of(b).filter(|_| defs).unwrap_or(b));
1579 let bare = bare.unwrap_or(of);
1580 let ok = if name.starts_with('<') {
1581 pats.iter().any(|p| p == "*")
1582 } else {
1583 pats.iter()
1584 .any(|p| p == name || symbol_matches(p, of) || symbol_matches(p, bare))
1585 };
1586 if !ok {
1587 v.deny(format!("scope_symbol:{sym}"));
1588 }
1589 }
1590 }
1591
1592 let held: Vec<ActiveLease> = state
1593 .active_leases(t)
1594 .filter(|l| l.holder == session)
1595 .collect();
1596 let mine: Vec<&LeaseItem> = held.iter().flat_map(|l| l.items.iter().copied()).collect();
1597 let unparsed = cfg.leases.unparsed_files;
1598 for need in symbols::lease_needs_under(f, unparsed, ctx.format) {
1599 if !mine.iter().any(|l| state.covers_need(&need, l, Some(base))) {
1600 v.deny(format!("lease_missing:{}", need.symbol));
1601 }
1602 }
1603 for (added, holder) in reserved_by_others(state, actor, f, t) {
1604 v.deny(format!("name_reserved:{added}:{holder}"));
1605 }
1606 if ctx.format >= FORMAT_INSERT_NAMES {
1607 for p in &f.added_files {
1608 if state.created_since(base, p) {
1609 v.deny(format!("created_since_base:{p}"));
1610 }
1611 }
1612 }
1613 if ctx.format >= FORMAT_KEYED {
1614 check_keys(state, &mine, base, ctx, v);
1615 }
1616
1617 let unobserved =
1618 s.level == ObservationLevel::Unobserved && cfg.observation.unobserved_is_tainted;
1619 if s.taint.is_some() || unobserved {
1620 let open = |word: &str| word == CRITICAL_ESCALATE;
1624 let unhooked = s.unhooked.is_some();
1627 if !critical.is_empty() {
1628 if s.taint.is_some() && !open(&cfg.taint.critical) {
1629 v.deny(if unhooked {
1630 "unhooked_critical_path"
1631 } else {
1632 "tainted_critical_path"
1633 });
1634 } else if unobserved && !open(&cfg.observation.unobserved_critical) {
1635 v.deny("unobserved_critical_path");
1636 }
1637 }
1638 v.escalate(
1639 if unhooked {
1640 "unhooked_session"
1641 } else if s.taint.is_some() {
1642 "tainted_session"
1643 } else {
1644 "unobserved_session"
1645 },
1646 1,
1647 );
1648 }
1649 for p in &critical {
1650 v.escalate(format!("critical_path:{p}"), 1);
1651 }
1652 if f.tests.deleted > 0 || f.tests.skipped > 0 {
1653 v.escalate("tests_weakened", 1);
1654 }
1655 for p in &f.unread_files {
1656 v.escalate(format!("unread_file:{p}"), 1);
1657 }
1658 if matches!(
1664 s.level,
1665 ObservationLevel::Hooks | ObservationLevel::HooksSandbox
1666 ) && !s.shell_seen
1667 {
1668 for p in &files {
1669 if !s.writes.contains(*p) {
1670 v.escalate(format!("unobserved_write:{p}"), 1);
1671 }
1672 }
1673 }
1674 if ctx.format >= FORMAT_REPLACES {
1676 let replaced = state.replaced_by(session);
1677 v.info
1678 .extend(replaced.iter().map(|c| format!("{REPLACES}{c}")));
1679 }
1680 }
1681 }
1682
1683 let mut base_ctx = session_context(state, actor, ctx.format);
1684 for (action, list) in [
1685 ("file.add", &f.added_files),
1686 ("file.modify", &f.modified_files),
1687 ("file.delete", &f.deleted_files),
1688 ] {
1689 for p in list {
1690 let mut c = base_ctx.clone();
1691 c.push(("path", CVal::Str(p.clone())));
1692 v.cedar(policy.evaluate(actor, action, c));
1693 }
1694 }
1695 let window = minutes(cfg.reverts.window_minutes);
1696 let mine: BTreeSet<&String> = f.edits.iter().chain(&f.deletes).chain(&f.adds).collect();
1697 let reverted = |s: &String| {
1700 let same = |s: &String| {
1701 mine.contains(s)
1702 || (state.symbol_keys
1703 && mine
1704 .iter()
1705 .any(|m| symbols::stands_for(s, m) || symbols::stands_for(m, s)))
1706 };
1707 same(s) || same(&state.renamed_to(s, None))
1708 };
1709 let recent_reverts = state
1710 .reverts
1711 .iter()
1712 .filter(|(at, syms)| t.saturating_sub(*at) <= window && syms.iter().any(reverted))
1713 .count() as i64;
1714 let bare_ids = mine.iter().filter(|_| state.symbol_keys).filter_map(|s| {
1716 let (path, _) = symbols::split(s)?;
1717 Some(format!("{path}#{}", symbols::bare(s)))
1718 });
1719 let named: BTreeSet<String> = bare_ids.chain(mine.iter().map(|s| s.to_string())).collect();
1720 if ctx.format >= FORMAT_AFFECTED {
1721 let held = affected_holders(state, actor, f, base, t);
1722 let changed = f.signature_changes.iter();
1723 v.info
1724 .extend(changed.map(|s| format!("signature_change:{s}")));
1725 let sets = held.iter();
1726 v.info
1727 .extend(sets.map(|(s, set)| format!("{AFFECTED_LEASE}{s}:{set}")));
1728 if f.affected_more > 0 {
1729 v.info.push(format!("affected_more:{}", f.affected_more));
1730 }
1731 base_ctx.extend([
1732 (
1733 "signature_changes",
1734 CVal::Long(f.signature_changes.len() as i64),
1735 ),
1736 (
1737 "affects_others",
1738 CVal::Bool(!held.is_empty() || f.affected_more > 0),
1739 ),
1740 ]);
1741 }
1742 base_ctx.extend([
1743 ("files", CVal::Long(files.len() as i64)),
1744 (
1745 "lines",
1746 CVal::Long(i64::from(f.lines_added.saturating_add(f.lines_removed))),
1747 ),
1748 ("recent_reverts", CVal::Long(recent_reverts)),
1749 ("is_revert", CVal::Bool(claim.revert_of.is_some())),
1750 ("tests_deleted", CVal::Long(f.tests.deleted as i64)),
1751 ("tests_skipped", CVal::Long(f.tests.skipped as i64)),
1752 ("critical", CVal::Bool(!critical.is_empty())),
1753 ("symbols", CVal::StrSet(named.into_iter().collect())),
1754 ]);
1755 v.cedar(policy.evaluate(actor, "change.propose", base_ctx));
1756}