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 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 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(&commitment)?;
72
73 Ok(commitment)
74}
75
76fn 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
92fn 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
116pub 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
159pub 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
174pub 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 if sender == session.initiator_sender {
202 Ok(())
203 } else {
204 Err(MacpError::Forbidden)
205 }
206 }
207 }
208 } else {
209 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 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 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 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 ("prior-session", "not-a-hash"),
291 (
293 "prior-session",
294 "sha256:9F58E9D114D11860D48AA2BCB8CDA458B9618B1CC8560595A802B68C4AF85D41",
295 ),
296 (
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 #[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 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 assert!(!is_canonical_commitment_hash(
389 "sha256:gf58e9d114d11860d48aa2bcb8cda458b9618b1cc8560595a802b68c4af85d41"
390 ));
391 assert!(!is_canonical_commitment_hash(
393 "sha256:9f58e9d114d11860d48aa2bcb8cda458b9618b1cc8560595a802b68c4af85d4!"
394 ));
395 }
396
397 #[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); 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 #[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 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}