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