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    if let Some(ref sup) = commitment.supersedes {
50        if sup.session_id.trim().is_empty() || sup.commitment_hash.trim().is_empty() {
51            return Err(MacpError::InvalidPayload);
52        }
53    }
54
55    // Validate outcome_positive consistency with action (RFC-0001 §7.3)
56    validate_outcome_positive(&commitment)?;
57
58    Ok(commitment)
59}
60
61/// Validate that `outcome_positive` is consistent with the `action` field.
62/// Actions ending in `rejected`, `failed`, or `declined` must have `outcome_positive = false`.
63/// Actions ending in `selected`, `accepted`, `completed`, or `approved` must have `outcome_positive = true`.
64fn validate_outcome_positive(commitment: &CommitmentPayload) -> Result<(), MacpError> {
65    let action = commitment.action.as_str();
66    let negative_actions = ["rejected", "failed", "declined"];
67    let positive_actions = ["selected", "accepted", "completed", "approved"];
68
69    let is_negative = negative_actions
70        .iter()
71        .any(|suffix| action.ends_with(suffix));
72    let is_positive = positive_actions
73        .iter()
74        .any(|suffix| action.ends_with(suffix));
75
76    if is_negative && commitment.outcome_positive {
77        return Err(MacpError::InvalidPayload);
78    }
79    if is_positive && !commitment.outcome_positive {
80        return Err(MacpError::InvalidPayload);
81    }
82    Ok(())
83}
84
85/// Shared commitment policy gate (extracted from five per-mode copies).
86/// Fail closed: only an explicit `Allow` proceeds — `PolicyDecision` is
87/// `#[non_exhaustive]`, and any unknown decision denies.
88pub fn enforce_commitment_policy(
89    session: &Session,
90    mode: macp_core::policy::CommitmentMode<'_>,
91    outcome_positive: bool,
92    evaluator: &dyn macp_core::policy::PolicyEvaluator,
93) -> Result<(), MacpError> {
94    let Some(ref policy) = session.policy_definition else {
95        return Ok(());
96    };
97    let decision = evaluator.evaluate_commitment(&macp_core::policy::CommitmentContext {
98        policy,
99        participants: &session.participants,
100        outcome_positive,
101        mode,
102    });
103    match decision {
104        macp_core::policy::PolicyDecision::Allow { .. } => Ok(()),
105        macp_core::policy::PolicyDecision::Deny { reasons } => {
106            tracing::warn!(
107                session_id = %session.session_id,
108                policy_id = %policy.policy_id,
109                reasons = ?reasons,
110                "policy denied commitment"
111            );
112            Err(MacpError::PolicyDenied { reasons })
113        }
114        other => {
115            tracing::warn!(
116                session_id = %session.session_id,
117                policy_id = %policy.policy_id,
118                decision = ?other,
119                "unrecognized policy decision treated as denial"
120            );
121            Err(MacpError::PolicyDenied {
122                reasons: vec!["unrecognized policy decision".into()],
123            })
124        }
125    }
126}
127
128/// Shared mode-state JSON codec (extracted from six per-mode copies).
129/// Encoding a mode-state struct cannot fail; if it ever does, panic loudly
130/// rather than silently persisting an empty state.
131pub fn encode_mode_state<T: serde::Serialize>(state: &T) -> Vec<u8> {
132    serde_json::to_vec(state).expect("mode state is always serializable")
133}
134
135pub fn decode_mode_state<T: serde::de::DeserializeOwned>(bytes: &[u8]) -> Result<T, MacpError> {
136    serde_json::from_slice(bytes).map_err(|_| MacpError::InvalidModeState)
137}
138
139pub fn is_declared_participant(participants: &[String], sender: &str) -> bool {
140    participants.iter().any(|participant| participant == sender)
141}
142
143/// Check whether the sender is authorized to commit per the policy's `commitment.authority` rule.
144///
145/// RFC-MACP-0012 §4: the `commitment` rule group controls who can emit a Commitment
146/// envelope. If no policy is bound, defaults to initiator-only (RFC-MACP-0001 §7.3).
147pub fn check_commitment_authority(session: &Session, sender: &str) -> Result<(), MacpError> {
148    if let Some(ref policy) = session.policy_definition {
149        let rules: macp_core::policy::rules::CommitmentRules =
150            extract_commitment_rules(&policy.rules);
151        match rules.authority.as_str() {
152            "any_participant" => {
153                if sender == session.initiator_sender
154                    || is_declared_participant(&session.participants, sender)
155                {
156                    Ok(())
157                } else {
158                    Err(MacpError::Forbidden)
159                }
160            }
161            "designated_role" => {
162                if rules.designated_roles.iter().any(|r| r == sender) {
163                    Ok(())
164                } else {
165                    Err(MacpError::Forbidden)
166                }
167            }
168            _ => {
169                // "initiator_only" (default)
170                if sender == session.initiator_sender {
171                    Ok(())
172                } else {
173                    Err(MacpError::Forbidden)
174                }
175            }
176        }
177    } else {
178        // No policy bound — default to initiator-only
179        if sender == session.initiator_sender {
180            Ok(())
181        } else {
182            Err(MacpError::Forbidden)
183        }
184    }
185}
186
187fn extract_commitment_rules(
188    rules: &serde_json::Value,
189) -> macp_core::policy::rules::CommitmentRules {
190    // Single implementation lives in macp-core (this was a byte-for-byte copy).
191    macp_core::policy::extract_commitment_rules(rules)
192}
193
194pub fn participants_all_accept(
195    participants: &[String],
196    accepts: &std::collections::BTreeMap<String, String>,
197    proposal_id: &str,
198) -> bool {
199    !participants.is_empty()
200        && participants
201            .iter()
202            .all(|participant| accepts.get(participant).map(String::as_str) == Some(proposal_id))
203}
204
205#[cfg(test)]
206mod tests {
207    use super::*;
208    use macp_pb::pb::CommitmentPayload;
209
210    fn make_commitment(action: &str, outcome_positive: bool) -> CommitmentPayload {
211        CommitmentPayload {
212            commitment_id: "c1".into(),
213            action: action.into(),
214            authority_scope: "scope".into(),
215            reason: "reason".into(),
216            mode_version: "1.0.0".into(),
217            policy_version: String::new(),
218            configuration_version: "cfg-1".into(),
219            outcome_positive,
220            supersedes: None,
221        }
222    }
223
224    // --- supersedes structural validation (RFC-MACP-0001 §7.3.1) ---
225
226    fn session_for_commitment() -> Session {
227        Session::builder("s1", "macp.mode.decision.v1", "agent://a")
228            .ttl_ms(60_000)
229            .mode_version("1.0.0")
230            .configuration_version("cfg-1")
231            .build()
232    }
233
234    #[test]
235    fn well_formed_supersedes_is_accepted() {
236        let session = session_for_commitment();
237        let mut c = make_commitment("decision.selected", true);
238        c.supersedes = Some(macp_pb::pb::CommitmentRef {
239            session_id: "prior-session".into(),
240            commitment_hash: "abc123".into(),
241        });
242        assert!(validate_commitment_payload_for_session(&session, &c.encode_to_vec()).is_ok());
243    }
244
245    #[test]
246    fn malformed_supersedes_is_rejected() {
247        let session = session_for_commitment();
248        for bad in [("", "abc123"), ("prior-session", ""), ("  ", "abc123")] {
249            let mut c = make_commitment("decision.selected", true);
250            c.supersedes = Some(macp_pb::pb::CommitmentRef {
251                session_id: bad.0.into(),
252                commitment_hash: bad.1.into(),
253            });
254            assert!(
255                validate_commitment_payload_for_session(&session, &c.encode_to_vec()).is_err(),
256                "expected rejection for supersedes {bad:?}"
257            );
258        }
259    }
260
261    // --- policy_version echo (master plan §2.3) ---
262
263    /// A session that started with empty policy_version is rewritten to
264    /// "policy.default" by the runtime; the client must not be required to echo
265    /// a value it never sent.
266    #[test]
267    fn empty_commitment_policy_version_matches_bound_policy() {
268        let mut session = session_for_commitment();
269        session.policy_version = "policy.default".into();
270        let c = make_commitment("decision.selected", true); // policy_version: ""
271        assert!(validate_commitment_payload_for_session(&session, &c.encode_to_vec()).is_ok());
272    }
273
274    #[test]
275    fn wrong_commitment_policy_version_rejected() {
276        let mut session = session_for_commitment();
277        session.policy_version = "policy.default".into();
278        let mut c = make_commitment("decision.selected", true);
279        c.policy_version = "policy.other.v1".into();
280        assert!(validate_commitment_payload_for_session(&session, &c.encode_to_vec()).is_err());
281    }
282
283    #[test]
284    fn exact_commitment_policy_version_accepted() {
285        let mut session = session_for_commitment();
286        session.policy_version = "policy.default".into();
287        let mut c = make_commitment("decision.selected", true);
288        c.policy_version = "policy.default".into();
289        assert!(validate_commitment_payload_for_session(&session, &c.encode_to_vec()).is_ok());
290    }
291
292    // --- outcome_positive validation: RFC-defined positive actions ---
293
294    #[test]
295    fn decision_selected_positive_ok() {
296        assert!(validate_outcome_positive(&make_commitment("decision.selected", true)).is_ok());
297    }
298
299    #[test]
300    fn decision_selected_negative_rejected() {
301        assert!(validate_outcome_positive(&make_commitment("decision.selected", false)).is_err());
302    }
303
304    #[test]
305    fn decision_rejected_negative_ok() {
306        assert!(validate_outcome_positive(&make_commitment("decision.rejected", false)).is_ok());
307    }
308
309    #[test]
310    fn decision_rejected_positive_rejected() {
311        assert!(validate_outcome_positive(&make_commitment("decision.rejected", true)).is_err());
312    }
313
314    #[test]
315    fn proposal_accepted_positive_ok() {
316        assert!(validate_outcome_positive(&make_commitment("proposal.accepted", true)).is_ok());
317    }
318
319    #[test]
320    fn proposal_accepted_negative_rejected() {
321        assert!(validate_outcome_positive(&make_commitment("proposal.accepted", false)).is_err());
322    }
323
324    #[test]
325    fn proposal_rejected_negative_ok() {
326        assert!(validate_outcome_positive(&make_commitment("proposal.rejected", false)).is_ok());
327    }
328
329    #[test]
330    fn proposal_rejected_positive_rejected() {
331        assert!(validate_outcome_positive(&make_commitment("proposal.rejected", true)).is_err());
332    }
333
334    #[test]
335    fn task_completed_positive_ok() {
336        assert!(validate_outcome_positive(&make_commitment("task.completed", true)).is_ok());
337    }
338
339    #[test]
340    fn task_completed_negative_rejected() {
341        assert!(validate_outcome_positive(&make_commitment("task.completed", false)).is_err());
342    }
343
344    #[test]
345    fn task_failed_negative_ok() {
346        assert!(validate_outcome_positive(&make_commitment("task.failed", false)).is_ok());
347    }
348
349    #[test]
350    fn task_failed_positive_rejected() {
351        assert!(validate_outcome_positive(&make_commitment("task.failed", true)).is_err());
352    }
353
354    #[test]
355    fn handoff_accepted_positive_ok() {
356        assert!(validate_outcome_positive(&make_commitment("handoff.accepted", true)).is_ok());
357    }
358
359    #[test]
360    fn handoff_declined_negative_ok() {
361        assert!(validate_outcome_positive(&make_commitment("handoff.declined", false)).is_ok());
362    }
363
364    #[test]
365    fn handoff_declined_positive_rejected() {
366        assert!(validate_outcome_positive(&make_commitment("handoff.declined", true)).is_err());
367    }
368
369    #[test]
370    fn quorum_approved_positive_ok() {
371        assert!(validate_outcome_positive(&make_commitment("quorum.approved", true)).is_ok());
372    }
373
374    #[test]
375    fn quorum_rejected_negative_ok() {
376        assert!(validate_outcome_positive(&make_commitment("quorum.rejected", false)).is_ok());
377    }
378
379    #[test]
380    fn quorum_rejected_positive_rejected() {
381        assert!(validate_outcome_positive(&make_commitment("quorum.rejected", true)).is_err());
382    }
383
384    #[test]
385    fn custom_action_no_known_suffix_any_outcome_ok() {
386        // Actions without recognized suffixes pass validation regardless of outcome_positive
387        assert!(validate_outcome_positive(&make_commitment("custom.action", true)).is_ok());
388        assert!(validate_outcome_positive(&make_commitment("custom.action", false)).is_ok());
389    }
390}