Skip to main content

macp_modes/mode/
util.rs

1use macp_core::error::MacpError;
2use macp_core::session::Session;
3use macp_pb::pb::CommitmentPayload;
4use prost::Message;
5
6pub fn decode_commitment_payload(payload: &[u8]) -> Result<CommitmentPayload, MacpError> {
7    CommitmentPayload::decode(payload).map_err(|_| MacpError::InvalidPayload)
8}
9
10pub fn validate_commitment_payload_for_session(
11    session: &Session,
12    payload: &[u8],
13) -> Result<CommitmentPayload, MacpError> {
14    let commitment = decode_commitment_payload(payload)?;
15
16    if commitment.commitment_id.trim().is_empty()
17        || commitment.action.trim().is_empty()
18        || commitment.authority_scope.trim().is_empty()
19        || commitment.reason.trim().is_empty()
20    {
21        return Err(MacpError::InvalidPayload);
22    }
23
24    if commitment.mode_version != session.mode_version
25        || commitment.configuration_version != session.configuration_version
26    {
27        return Err(MacpError::InvalidPayload);
28    }
29
30    // RFC-MACP-0012 §6.1: an empty policy_version at SessionStart resolves to
31    // "policy.default", and the runtime rewrites session.policy_version to the
32    // resolved id. A client that started with "" must not be forced to echo a
33    // value it never set, so an empty commitment.policy_version defers to the
34    // session's bound policy. A non-empty value must match the binding exactly.
35    // (The echo question is ambiguous upstream — filed as an RFC issue; empty-
36    // matches is forward-compatible with either resolution.)
37    if !commitment.policy_version.is_empty()
38        && !session.policy_version.is_empty()
39        && commitment.policy_version != session.policy_version
40    {
41        return Err(MacpError::InvalidPayload);
42    }
43
44    // RFC-MACP-0001 §7.3.1: if this commitment supersedes a prior one, the
45    // reference must be structurally well-formed. Supersession is inherently
46    // cross-session, so the kernel checks only well-formedness here (and
47    // authority, separately) — it does NOT verify the referenced commitment
48    // exists, was sealed, or is unforked. Those are consumer governance.
49    // RFC-MACP-0013 §9 additionally tightens `commitment_hash` to the
50    // canonical shape (`sha256:` + 64 lowercase hex chars) with an immediate
51    // hard reject — no dual-read/transitional window is permitted.
52    if let Some(ref sup) = commitment.supersedes {
53        if sup.session_id.trim().is_empty() {
54            tracing::warn!(
55                session_id = %sup.session_id,
56                "supersedes.session_id must be non-empty"
57            );
58            return Err(MacpError::InvalidPayload);
59        }
60        if !is_canonical_commitment_hash(&sup.commitment_hash) {
61            tracing::warn!(
62                commitment_hash = %sup.commitment_hash,
63                "supersedes.commitment_hash must be a canonical RFC-MACP-0013 hash: \
64                 'sha256:' followed by 64 lowercase hex characters"
65            );
66            return Err(MacpError::InvalidPayload);
67        }
68    }
69
70    // Validate outcome_positive consistency with action (RFC-0001 §7.3)
71    validate_outcome_positive(&commitment)?;
72
73    Ok(commitment)
74}
75
76/// Check whether `s` is a canonical RFC-MACP-0013 commitment hash: the
77/// literal prefix `sha256:` followed by exactly 64 lowercase hex characters.
78/// No trimming is performed — leading/trailing whitespace is a rejection,
79/// not something to be trimmed away before checking.
80fn is_canonical_commitment_hash(s: &str) -> bool {
81    match s.strip_prefix("sha256:") {
82        Some(rest) => {
83            rest.len() == 64
84                && rest
85                    .bytes()
86                    .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
87        }
88        None => false,
89    }
90}
91
92/// Validate that `outcome_positive` is consistent with the `action` field.
93/// Actions ending in `rejected`, `failed`, or `declined` must have `outcome_positive = false`.
94/// Actions ending in `selected`, `accepted`, `completed`, or `approved` must have `outcome_positive = true`.
95fn validate_outcome_positive(commitment: &CommitmentPayload) -> Result<(), MacpError> {
96    let action = commitment.action.as_str();
97    let negative_actions = ["rejected", "failed", "declined"];
98    let positive_actions = ["selected", "accepted", "completed", "approved"];
99
100    let is_negative = negative_actions
101        .iter()
102        .any(|suffix| action.ends_with(suffix));
103    let is_positive = positive_actions
104        .iter()
105        .any(|suffix| action.ends_with(suffix));
106
107    if is_negative && commitment.outcome_positive {
108        return Err(MacpError::InvalidPayload);
109    }
110    if is_positive && !commitment.outcome_positive {
111        return Err(MacpError::InvalidPayload);
112    }
113    Ok(())
114}
115
116/// Shared commitment policy gate (extracted from five per-mode copies).
117/// Fail closed: only an explicit `Allow` proceeds — `PolicyDecision` is
118/// `#[non_exhaustive]`, and any unknown decision denies.
119pub fn enforce_commitment_policy(
120    session: &Session,
121    mode: macp_core::policy::CommitmentMode<'_>,
122    outcome_positive: bool,
123    evaluator: &dyn macp_core::policy::PolicyEvaluator,
124) -> Result<(), MacpError> {
125    let Some(ref policy) = session.policy_definition else {
126        return Ok(());
127    };
128    let decision = evaluator.evaluate_commitment(&macp_core::policy::CommitmentContext {
129        policy,
130        participants: &session.participants,
131        outcome_positive,
132        mode,
133    });
134    match decision {
135        macp_core::policy::PolicyDecision::Allow { .. } => Ok(()),
136        macp_core::policy::PolicyDecision::Deny { reasons } => {
137            tracing::warn!(
138                session_id = %session.session_id,
139                policy_id = %policy.policy_id,
140                reasons = ?reasons,
141                "policy denied commitment"
142            );
143            Err(MacpError::PolicyDenied { reasons })
144        }
145        other => {
146            tracing::warn!(
147                session_id = %session.session_id,
148                policy_id = %policy.policy_id,
149                decision = ?other,
150                "unrecognized policy decision treated as denial"
151            );
152            Err(MacpError::PolicyDenied {
153                reasons: vec!["unrecognized policy decision".into()],
154            })
155        }
156    }
157}
158
159/// Shared mode-state JSON codec (extracted from six per-mode copies).
160/// Encoding a mode-state struct cannot fail; if it ever does, panic loudly
161/// rather than silently persisting an empty state.
162pub fn encode_mode_state<T: serde::Serialize>(state: &T) -> Vec<u8> {
163    serde_json::to_vec(state).expect("mode state is always serializable")
164}
165
166pub fn decode_mode_state<T: serde::de::DeserializeOwned>(bytes: &[u8]) -> Result<T, MacpError> {
167    serde_json::from_slice(bytes).map_err(|_| MacpError::InvalidModeState)
168}
169
170pub fn is_declared_participant(participants: &[String], sender: &str) -> bool {
171    participants.iter().any(|participant| participant == sender)
172}
173
174/// Check whether the sender is authorized to commit per the policy's `commitment.authority` rule.
175///
176/// RFC-MACP-0012 §4: the `commitment` rule group controls who can emit a Commitment
177/// envelope. If no policy is bound, defaults to initiator-only (RFC-MACP-0001 §7.3).
178pub fn check_commitment_authority(session: &Session, sender: &str) -> Result<(), MacpError> {
179    if let Some(ref policy) = session.policy_definition {
180        let rules: macp_core::policy::rules::CommitmentRules =
181            extract_commitment_rules(&policy.rules);
182        match rules.authority.as_str() {
183            "any_participant" => {
184                if sender == session.initiator_sender
185                    || is_declared_participant(&session.participants, sender)
186                {
187                    Ok(())
188                } else {
189                    Err(MacpError::Forbidden)
190                }
191            }
192            "designated_role" => {
193                if rules.designated_roles.iter().any(|r| r == sender) {
194                    Ok(())
195                } else {
196                    Err(MacpError::Forbidden)
197                }
198            }
199            _ => {
200                // "initiator_only" (default)
201                if sender == session.initiator_sender {
202                    Ok(())
203                } else {
204                    Err(MacpError::Forbidden)
205                }
206            }
207        }
208    } else {
209        // No policy bound — default to initiator-only
210        if sender == session.initiator_sender {
211            Ok(())
212        } else {
213            Err(MacpError::Forbidden)
214        }
215    }
216}
217
218fn extract_commitment_rules(
219    rules: &serde_json::Value,
220) -> macp_core::policy::rules::CommitmentRules {
221    // Single implementation lives in macp-core (this was a byte-for-byte copy).
222    macp_core::policy::extract_commitment_rules(rules)
223}
224
225pub fn participants_all_accept(
226    participants: &[String],
227    accepts: &std::collections::BTreeMap<String, String>,
228    proposal_id: &str,
229) -> bool {
230    !participants.is_empty()
231        && participants
232            .iter()
233            .all(|participant| accepts.get(participant).map(String::as_str) == Some(proposal_id))
234}
235
236#[cfg(test)]
237mod tests {
238    use super::*;
239    use macp_pb::pb::CommitmentPayload;
240
241    fn make_commitment(action: &str, outcome_positive: bool) -> CommitmentPayload {
242        CommitmentPayload {
243            commitment_id: "c1".into(),
244            action: action.into(),
245            authority_scope: "scope".into(),
246            reason: "reason".into(),
247            mode_version: "1.0.0".into(),
248            policy_version: String::new(),
249            configuration_version: "cfg-1".into(),
250            outcome_positive,
251            supersedes: None,
252        }
253    }
254
255    // --- supersedes structural validation (RFC-MACP-0001 §7.3.1) ---
256
257    fn session_for_commitment() -> Session {
258        Session::builder("s1", "macp.mode.decision.v1", "agent://a")
259            .ttl_ms(60_000)
260            .mode_version("1.0.0")
261            .configuration_version("cfg-1")
262            .build()
263    }
264
265    // Pinned vector hash for `cmt_hash_001_minimal` from the RFC-MACP-0013
266    // conformance vectors (see crates/macp-core/src/commitment_hash.rs's test
267    // module) — a real canonical commitment hash, not an arbitrary literal.
268    const VALID_COMMITMENT_HASH: &str =
269        "sha256:9f58e9d114d11860d48aa2bcb8cda458b9618b1cc8560595a802b68c4af85d41";
270
271    #[test]
272    fn well_formed_supersedes_is_accepted() {
273        let session = session_for_commitment();
274        let mut c = make_commitment("decision.selected", true);
275        c.supersedes = Some(macp_pb::pb::CommitmentRef {
276            session_id: "prior-session".into(),
277            commitment_hash: VALID_COMMITMENT_HASH.into(),
278        });
279        assert!(validate_commitment_payload_for_session(&session, &c.encode_to_vec()).is_ok());
280    }
281
282    #[test]
283    fn malformed_supersedes_is_rejected() {
284        let session = session_for_commitment();
285        for bad in [
286            ("", VALID_COMMITMENT_HASH),
287            ("prior-session", ""),
288            ("  ", VALID_COMMITMENT_HASH),
289            // Non-empty but no `sha256:` prefix at all.
290            ("prior-session", "not-a-hash"),
291            // Correct length, but uppercase hex (RFC requires lowercase).
292            (
293                "prior-session",
294                "sha256:9F58E9D114D11860D48AA2BCB8CDA458B9618B1CC8560595A802B68C4AF85D41",
295            ),
296            // Right prefix, one hex char short of 64.
297            (
298                "prior-session",
299                "sha256:9f58e9d114d11860d48aa2bcb8cda458b9618b1cc8560595a802b68c4af85d4",
300            ),
301        ] {
302            let mut c = make_commitment("decision.selected", true);
303            c.supersedes = Some(macp_pb::pb::CommitmentRef {
304                session_id: bad.0.into(),
305                commitment_hash: bad.1.into(),
306            });
307            assert!(
308                validate_commitment_payload_for_session(&session, &c.encode_to_vec()).is_err(),
309                "expected rejection for supersedes {bad:?}"
310            );
311        }
312    }
313
314    // --- is_canonical_commitment_hash direct unit tests (RFC-MACP-0013 §9) ---
315
316    #[test]
317    fn canonical_hash_valid_is_accepted() {
318        assert!(is_canonical_commitment_hash(VALID_COMMITMENT_HASH));
319    }
320
321    #[test]
322    fn canonical_hash_rejects_uppercase() {
323        assert!(!is_canonical_commitment_hash(
324            "sha256:9F58E9D114D11860D48AA2BCB8CDA458B9618B1CC8560595A802B68C4AF85D41"
325        ));
326    }
327
328    #[test]
329    fn canonical_hash_rejects_uppercase_prefix() {
330        // The "sha256:" prefix itself must be lowercase — case-insensitive
331        // prefix matching would silently loosen this guard.
332        assert!(!is_canonical_commitment_hash(&format!(
333            "SHA256:{}",
334            VALID_COMMITMENT_HASH.strip_prefix("sha256:").unwrap()
335        )));
336    }
337
338    #[test]
339    fn canonical_hash_rejects_63_chars() {
340        assert!(!is_canonical_commitment_hash(
341            "sha256:9f58e9d114d11860d48aa2bcb8cda458b9618b1cc8560595a802b68c4af85d4"
342        ));
343    }
344
345    #[test]
346    fn canonical_hash_rejects_65_chars() {
347        assert!(!is_canonical_commitment_hash(
348            "sha256:9f58e9d114d11860d48aa2bcb8cda458b9618b1cc8560595a802b68c4af85d411"
349        ));
350    }
351
352    #[test]
353    fn canonical_hash_rejects_missing_prefix() {
354        assert!(!is_canonical_commitment_hash(
355            "9f58e9d114d11860d48aa2bcb8cda458b9618b1cc8560595a802b68c4af85d41"
356        ));
357    }
358
359    #[test]
360    fn canonical_hash_rejects_wrong_prefix() {
361        assert!(!is_canonical_commitment_hash(
362            "sha512:9f58e9d114d11860d48aa2bcb8cda458b9618b1cc8560595a802b68c4af85d41"
363        ));
364    }
365
366    #[test]
367    fn canonical_hash_rejects_empty_string() {
368        assert!(!is_canonical_commitment_hash(""));
369    }
370
371    #[test]
372    fn canonical_hash_rejects_leading_whitespace() {
373        assert!(!is_canonical_commitment_hash(&format!(
374            " {VALID_COMMITMENT_HASH}"
375        )));
376    }
377
378    #[test]
379    fn canonical_hash_rejects_trailing_whitespace() {
380        assert!(!is_canonical_commitment_hash(&format!(
381            "{VALID_COMMITMENT_HASH} "
382        )));
383    }
384
385    #[test]
386    fn canonical_hash_rejects_non_hex_characters() {
387        // 'g' is not a valid hex digit.
388        assert!(!is_canonical_commitment_hash(
389            "sha256:gf58e9d114d11860d48aa2bcb8cda458b9618b1cc8560595a802b68c4af85d41"
390        ));
391        // '!' is not a valid hex digit either.
392        assert!(!is_canonical_commitment_hash(
393            "sha256:9f58e9d114d11860d48aa2bcb8cda458b9618b1cc8560595a802b68c4af85d4!"
394        ));
395    }
396
397    // --- policy_version echo (master plan §2.3) ---
398
399    /// A session that started with empty policy_version is rewritten to
400    /// "policy.default" by the runtime; the client must not be required to echo
401    /// a value it never sent.
402    #[test]
403    fn empty_commitment_policy_version_matches_bound_policy() {
404        let mut session = session_for_commitment();
405        session.policy_version = "policy.default".into();
406        let c = make_commitment("decision.selected", true); // policy_version: ""
407        assert!(validate_commitment_payload_for_session(&session, &c.encode_to_vec()).is_ok());
408    }
409
410    #[test]
411    fn wrong_commitment_policy_version_rejected() {
412        let mut session = session_for_commitment();
413        session.policy_version = "policy.default".into();
414        let mut c = make_commitment("decision.selected", true);
415        c.policy_version = "policy.other.v1".into();
416        assert!(validate_commitment_payload_for_session(&session, &c.encode_to_vec()).is_err());
417    }
418
419    #[test]
420    fn exact_commitment_policy_version_accepted() {
421        let mut session = session_for_commitment();
422        session.policy_version = "policy.default".into();
423        let mut c = make_commitment("decision.selected", true);
424        c.policy_version = "policy.default".into();
425        assert!(validate_commitment_payload_for_session(&session, &c.encode_to_vec()).is_ok());
426    }
427
428    // --- outcome_positive validation: RFC-defined positive actions ---
429
430    #[test]
431    fn decision_selected_positive_ok() {
432        assert!(validate_outcome_positive(&make_commitment("decision.selected", true)).is_ok());
433    }
434
435    #[test]
436    fn decision_selected_negative_rejected() {
437        assert!(validate_outcome_positive(&make_commitment("decision.selected", false)).is_err());
438    }
439
440    #[test]
441    fn decision_rejected_negative_ok() {
442        assert!(validate_outcome_positive(&make_commitment("decision.rejected", false)).is_ok());
443    }
444
445    #[test]
446    fn decision_rejected_positive_rejected() {
447        assert!(validate_outcome_positive(&make_commitment("decision.rejected", true)).is_err());
448    }
449
450    #[test]
451    fn proposal_accepted_positive_ok() {
452        assert!(validate_outcome_positive(&make_commitment("proposal.accepted", true)).is_ok());
453    }
454
455    #[test]
456    fn proposal_accepted_negative_rejected() {
457        assert!(validate_outcome_positive(&make_commitment("proposal.accepted", false)).is_err());
458    }
459
460    #[test]
461    fn proposal_rejected_negative_ok() {
462        assert!(validate_outcome_positive(&make_commitment("proposal.rejected", false)).is_ok());
463    }
464
465    #[test]
466    fn proposal_rejected_positive_rejected() {
467        assert!(validate_outcome_positive(&make_commitment("proposal.rejected", true)).is_err());
468    }
469
470    #[test]
471    fn task_completed_positive_ok() {
472        assert!(validate_outcome_positive(&make_commitment("task.completed", true)).is_ok());
473    }
474
475    #[test]
476    fn task_completed_negative_rejected() {
477        assert!(validate_outcome_positive(&make_commitment("task.completed", false)).is_err());
478    }
479
480    #[test]
481    fn task_failed_negative_ok() {
482        assert!(validate_outcome_positive(&make_commitment("task.failed", false)).is_ok());
483    }
484
485    #[test]
486    fn task_failed_positive_rejected() {
487        assert!(validate_outcome_positive(&make_commitment("task.failed", true)).is_err());
488    }
489
490    #[test]
491    fn handoff_accepted_positive_ok() {
492        assert!(validate_outcome_positive(&make_commitment("handoff.accepted", true)).is_ok());
493    }
494
495    #[test]
496    fn handoff_declined_negative_ok() {
497        assert!(validate_outcome_positive(&make_commitment("handoff.declined", false)).is_ok());
498    }
499
500    #[test]
501    fn handoff_declined_positive_rejected() {
502        assert!(validate_outcome_positive(&make_commitment("handoff.declined", true)).is_err());
503    }
504
505    #[test]
506    fn quorum_approved_positive_ok() {
507        assert!(validate_outcome_positive(&make_commitment("quorum.approved", true)).is_ok());
508    }
509
510    #[test]
511    fn quorum_rejected_negative_ok() {
512        assert!(validate_outcome_positive(&make_commitment("quorum.rejected", false)).is_ok());
513    }
514
515    #[test]
516    fn quorum_rejected_positive_rejected() {
517        assert!(validate_outcome_positive(&make_commitment("quorum.rejected", true)).is_err());
518    }
519
520    #[test]
521    fn custom_action_no_known_suffix_any_outcome_ok() {
522        // Actions without recognized suffixes pass validation regardless of outcome_positive
523        assert!(validate_outcome_positive(&make_commitment("custom.action", true)).is_ok());
524        assert!(validate_outcome_positive(&make_commitment("custom.action", false)).is_ok());
525    }
526}