Skip to main content

lonca_policy/
decide.rs

1//! The decision function.
2//!
3//! `decide` is pure: its inputs are the state before the operation, the
4//! request, the facts and evidence recorded with it, the time the daemon
5//! assigned and the policy in force. The same inputs always produce the same
6//! decision, so the auditor can replay every entry of the log.
7//!
8//! Order of evaluation:
9//! 1. hard invariants that no policy can change (who may do what at all),
10//! 2. per-operation checks (scope, leases, claim vs facts, taint, evidence),
11//! 3. the Cedar policy of the repository.
12//!
13//! Any denial wins over escalations; escalations win over allow.
14
15use 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    /// The format of the entry: what a recorded operation means.
45    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    /// The symbol engine that computed `facts`: what their names mean.
51    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
157/// The human and daemon key operations. They stay possible in a frozen
158/// repository: replacing or withdrawing a key is how a human answers an
159/// incident, and the only way back in when the key that would unfreeze is
160/// lost.
161fn 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
173/// What a lease request and an interest in one have in common.
174fn 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            // A keyed directory has one container, and it is not a file.
217            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            // Who may change a name may change every symbol of that name.
223            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
238/// An item on a keyed directory's `dir#<dir>`: an `insert` that reserves
239/// keys of a directory the policy names, written as the directory writes
240/// them, by a session that may add files there. Anything else on a `<dir>`
241/// is refused: deny by default.
242fn 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    // A file of the directory: `dir/**` and any pattern above it, or one
254    // file directly in it.
255    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
267/// What a session's proposal adds to keyed directories must have its key
268/// reserved by the session, and no other file may have that key: in the
269/// proposal's own tree (`taken_keys` in its facts) or among the files trunk
270/// gained since its base. A name the pattern does not read is refused.
271fn 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
312/// A set moves whole, while it is alive, unpinned (a pin stays with its
313/// change), and only to a session whose scope covers what is in it.
314fn 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
358/// A session can be continued once, by one successor, and only a session
359/// a human started: a child's scope and lifetime are its parent's.
360fn 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
378/// An Ed25519 public key as the daemon writes it: 32 bytes, lowercase hex,
379/// a valid point.
380pub 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
388/// Checks a key that a key operation adds: a kind of key the entry's
389/// format knows, with a public key in the form that kind uses. Stricter
390/// than the check `human.enroll` made before format 28, so a rotation can
391/// not lock a human out with a key nothing can sign for.
392fn 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
400/// Minutes from the policy as milliseconds. A policy is signed by humans but
401/// may still carry a number too large: it saturates, so a check against it
402/// fails closed instead of overflowing.
403fn 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
414/// Whether anything on the record came from a session or was left to it.
415fn 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
433/// A SHA-256 digest as the log writes it: 64 lowercase hex digits.
434fn 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
438/// Observation level implied by the checks a harness hook reported.
439pub 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
449/// How many scratch roots a scope may name.
450pub const MAX_SCRATCH_ROOTS: usize = 8;
451
452/// Longest reason of a `reject` (bytes): the text a worker reads.
453pub const MAX_REJECT_REASON: usize = 4096;
454
455/// A scratch root: an absolute, normalized directory path (no `..`, no
456/// `.`, no empty component, no trailing slash), at most 1024 bytes, no
457/// control characters.
458pub 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
500/// Every right in `inner` is also in `outer` (capabilities only narrow).
501pub 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
545/// Two lease items can not be held by different sessions at the same time.
546/// `shared` goes with `shared` only: against an `insert` it is an `edit`.
547/// Two inserts go together unless they reserve one name in one container.
548pub 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
579/// The word a piece of evidence goes by in a denial: a named check by its
580/// name, which can not be one of the built-in words.
581pub 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    // Old entries replay as they were: `context has task` was false.
614    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    // 1. Who may request this at all.
674    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            // A recovery is signed with the recovery key, everything else
689            // with the human's current key.
690            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            // The recovery key is the one signature without presence: the
711            // key it replaces is bound to a lost machine or a lost device.
712            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                // Waiting for a lease takes the right to acquire it.
742                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    // 2. Per-operation checks.
754    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            // Before format 28 an enrolment checked only that the kind
765            // was known and the key was hex; those entries replay so.
766            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            // With the recovery key: the lost key of its own human, as in
797            // `human.recover`.
798            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            // A hook in a sandbox must not take the session's level from it.
872            if s.is_some_and(|s| s.level == ObservationLevel::Isolated) {
873                v.deny("session_isolated");
874            }
875            if ctx.format >= FORMAT_SESSION_HARNESS {
876                // A level is read for the harness a human named, and whoever
877                // asks has a token: the checks must be for that harness.
878                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                // The first report is the hook's, before the agent has run.
883                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                // A sandbox is vouched for by the one who started what runs
896                // in it; a child takes its level from its parent.
897                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                // Whatever this session did so far was done outside.
902                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            // Nothing to wait for means nothing to hold: an interest in a
1006            // free symbol would be a way to reserve it.
1007            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                // Interest is a denied acquire, retried at least as often
1045                // as the longest TTL.
1046                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                    // `State::apply` has no policy: the switch travels in
1110                    // the decision.
1111                    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                    // A released key operation takes effect now: recheck
1159                    // what may have changed since it was requested.
1160                    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            // `State::apply` has no policy: the timeout travels in the
1204            // decision and the auditor recomputes it.
1205            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    // 3. Repository policy. Changes are evaluated per file inside `propose`.
1280    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    // A daemon key switch can not wait for approval: a recovery is signed
1285    // by the new key, so it can not sit in the log as pending while the old
1286    // key signs on, and a rotation follows the same rule.
1287    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
1295/// The named checks an approval adds: a proposal's only, each a check the
1296/// policy in force defines, sorted, each once.
1297fn 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
1346/// The quotas `actor` is past at `time_ms`. Also what the daemon asks before
1347/// it starts a verification: what is past a quota is not run.
1348pub 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
1362/// What is wrong with `evidence` under `vc`. Also what the daemon asks before
1363/// it logs a batch, so that a batch that would be denied is split instead.
1364pub 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    // A check the change owes by name was asked for: it is owed with or
1398    // without `required`.
1399    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
1412/// The lease sets of other sessions that hold what `f` lists as affected, as
1413/// (session, set). `Insert` holds no code. A lease may have the name trunk
1414/// has now, and `f` the one its base had.
1415fn 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
1440/// The symbols `f` adds that another session reserved by name, with that
1441/// session: under an unnamed insert, or as the new name of a rename, as much
1442/// as under an insert of its own.
1443fn 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                    // Who may change a type's own text keeps that right
1571                    // now that facts call it `Type.<def>`. `Type.*`, the
1572                    // members, did not reach it and does not.
1573                    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                // Each state answers for itself: opening one word leaves a
1621                // session in the other state refused. Only the known word
1622                // opens; `PolicyBundle::parse` refuses any other.
1623                let open = |word: &str| word == CRITICAL_ESCALATE;
1624                // A session whose hooks did not run is tainted and says so
1625                // in a word of its own; the taint's setting answers for it.
1626                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            // Observation/effect consistency: a file the session changed but
1659            // never wrote through an observed tool, with no shell activity
1660            // that could explain it.
1661            // In a sandbox nothing reports tool calls: the workspace against
1662            // its base is what happened.
1663            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            // What applying it withdraws, unless it is denied.
1675            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    // A revert of `Name` is one of `Name(key)`, and the other way around,
1698    // and it follows the symbol to its new name.
1699    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    // A policy names a symbol as a person does: by its name, too.
1715    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}