use macp_core::error::MacpError;
use macp_core::session::Session;
use macp_pb::pb::CommitmentPayload;
use prost::Message;
pub fn decode_commitment_payload(payload: &[u8]) -> Result<CommitmentPayload, MacpError> {
CommitmentPayload::decode(payload).map_err(|_| MacpError::InvalidPayload)
}
pub fn validate_commitment_payload_for_session(
session: &Session,
payload: &[u8],
) -> Result<CommitmentPayload, MacpError> {
let commitment = decode_commitment_payload(payload)?;
if commitment.commitment_id.trim().is_empty()
|| commitment.action.trim().is_empty()
|| commitment.authority_scope.trim().is_empty()
|| commitment.reason.trim().is_empty()
{
return Err(MacpError::InvalidPayload);
}
if commitment.mode_version != session.mode_version
|| commitment.configuration_version != session.configuration_version
{
return Err(MacpError::InvalidPayload);
}
if !commitment.policy_version.is_empty()
&& !session.policy_version.is_empty()
&& commitment.policy_version != session.policy_version
{
return Err(MacpError::InvalidPayload);
}
if let Some(ref sup) = commitment.supersedes {
if sup.session_id.trim().is_empty() {
tracing::warn!(
session_id = %sup.session_id,
"supersedes.session_id must be non-empty"
);
return Err(MacpError::InvalidPayload);
}
if !is_canonical_commitment_hash(&sup.commitment_hash) {
tracing::warn!(
commitment_hash = %sup.commitment_hash,
"supersedes.commitment_hash must be a canonical RFC-MACP-0013 hash: \
'sha256:' followed by 64 lowercase hex characters"
);
return Err(MacpError::InvalidPayload);
}
}
validate_outcome_positive(&commitment)?;
Ok(commitment)
}
fn is_canonical_commitment_hash(s: &str) -> bool {
match s.strip_prefix("sha256:") {
Some(rest) => {
rest.len() == 64
&& rest
.bytes()
.all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
}
None => false,
}
}
fn validate_outcome_positive(commitment: &CommitmentPayload) -> Result<(), MacpError> {
let action = commitment.action.as_str();
let negative_actions = ["rejected", "failed", "declined"];
let positive_actions = ["selected", "accepted", "completed", "approved"];
let is_negative = negative_actions
.iter()
.any(|suffix| action.ends_with(suffix));
let is_positive = positive_actions
.iter()
.any(|suffix| action.ends_with(suffix));
if is_negative && commitment.outcome_positive {
return Err(MacpError::InvalidPayload);
}
if is_positive && !commitment.outcome_positive {
return Err(MacpError::InvalidPayload);
}
Ok(())
}
pub fn enforce_commitment_policy(
session: &Session,
mode: macp_core::policy::CommitmentMode<'_>,
outcome_positive: bool,
evaluator: &dyn macp_core::policy::PolicyEvaluator,
) -> Result<(), MacpError> {
let Some(ref policy) = session.policy_definition else {
return Ok(());
};
let decision = evaluator.evaluate_commitment(&macp_core::policy::CommitmentContext {
policy,
participants: &session.participants,
outcome_positive,
mode,
});
match decision {
macp_core::policy::PolicyDecision::Allow { .. } => Ok(()),
macp_core::policy::PolicyDecision::Deny { reasons } => {
tracing::warn!(
session_id = %session.session_id,
policy_id = %policy.policy_id,
reasons = ?reasons,
"policy denied commitment"
);
Err(MacpError::PolicyDenied { reasons })
}
other => {
tracing::warn!(
session_id = %session.session_id,
policy_id = %policy.policy_id,
decision = ?other,
"unrecognized policy decision treated as denial"
);
Err(MacpError::PolicyDenied {
reasons: vec!["unrecognized policy decision".into()],
})
}
}
}
pub fn encode_mode_state<T: serde::Serialize>(state: &T) -> Vec<u8> {
serde_json::to_vec(state).expect("mode state is always serializable")
}
pub fn decode_mode_state<T: serde::de::DeserializeOwned>(bytes: &[u8]) -> Result<T, MacpError> {
serde_json::from_slice(bytes).map_err(|_| MacpError::InvalidModeState)
}
pub fn is_declared_participant(participants: &[String], sender: &str) -> bool {
participants.iter().any(|participant| participant == sender)
}
pub fn check_commitment_authority(session: &Session, sender: &str) -> Result<(), MacpError> {
if let Some(ref policy) = session.policy_definition {
let rules: macp_core::policy::rules::CommitmentRules =
extract_commitment_rules(&policy.rules);
match rules.authority.as_str() {
"any_participant" => {
if sender == session.initiator_sender
|| is_declared_participant(&session.participants, sender)
{
Ok(())
} else {
Err(MacpError::Forbidden)
}
}
"designated_role" => {
if rules.designated_roles.iter().any(|r| r == sender) {
Ok(())
} else {
Err(MacpError::Forbidden)
}
}
_ => {
if sender == session.initiator_sender {
Ok(())
} else {
Err(MacpError::Forbidden)
}
}
}
} else {
if sender == session.initiator_sender {
Ok(())
} else {
Err(MacpError::Forbidden)
}
}
}
fn extract_commitment_rules(
rules: &serde_json::Value,
) -> macp_core::policy::rules::CommitmentRules {
macp_core::policy::extract_commitment_rules(rules)
}
pub fn participants_all_accept(
participants: &[String],
accepts: &std::collections::BTreeMap<String, String>,
proposal_id: &str,
) -> bool {
!participants.is_empty()
&& participants
.iter()
.all(|participant| accepts.get(participant).map(String::as_str) == Some(proposal_id))
}
#[cfg(test)]
mod tests {
use super::*;
use macp_pb::pb::CommitmentPayload;
fn make_commitment(action: &str, outcome_positive: bool) -> CommitmentPayload {
CommitmentPayload {
commitment_id: "c1".into(),
action: action.into(),
authority_scope: "scope".into(),
reason: "reason".into(),
mode_version: "1.0.0".into(),
policy_version: String::new(),
configuration_version: "cfg-1".into(),
outcome_positive,
supersedes: None,
}
}
fn session_for_commitment() -> Session {
Session::builder("s1", "macp.mode.decision.v1", "agent://a")
.ttl_ms(60_000)
.mode_version("1.0.0")
.configuration_version("cfg-1")
.build()
}
const VALID_COMMITMENT_HASH: &str =
"sha256:9f58e9d114d11860d48aa2bcb8cda458b9618b1cc8560595a802b68c4af85d41";
#[test]
fn well_formed_supersedes_is_accepted() {
let session = session_for_commitment();
let mut c = make_commitment("decision.selected", true);
c.supersedes = Some(macp_pb::pb::CommitmentRef {
session_id: "prior-session".into(),
commitment_hash: VALID_COMMITMENT_HASH.into(),
});
assert!(validate_commitment_payload_for_session(&session, &c.encode_to_vec()).is_ok());
}
#[test]
fn malformed_supersedes_is_rejected() {
let session = session_for_commitment();
for bad in [
("", VALID_COMMITMENT_HASH),
("prior-session", ""),
(" ", VALID_COMMITMENT_HASH),
("prior-session", "not-a-hash"),
(
"prior-session",
"sha256:9F58E9D114D11860D48AA2BCB8CDA458B9618B1CC8560595A802B68C4AF85D41",
),
(
"prior-session",
"sha256:9f58e9d114d11860d48aa2bcb8cda458b9618b1cc8560595a802b68c4af85d4",
),
] {
let mut c = make_commitment("decision.selected", true);
c.supersedes = Some(macp_pb::pb::CommitmentRef {
session_id: bad.0.into(),
commitment_hash: bad.1.into(),
});
assert!(
validate_commitment_payload_for_session(&session, &c.encode_to_vec()).is_err(),
"expected rejection for supersedes {bad:?}"
);
}
}
#[test]
fn canonical_hash_valid_is_accepted() {
assert!(is_canonical_commitment_hash(VALID_COMMITMENT_HASH));
}
#[test]
fn canonical_hash_rejects_uppercase() {
assert!(!is_canonical_commitment_hash(
"sha256:9F58E9D114D11860D48AA2BCB8CDA458B9618B1CC8560595A802B68C4AF85D41"
));
}
#[test]
fn canonical_hash_rejects_uppercase_prefix() {
assert!(!is_canonical_commitment_hash(&format!(
"SHA256:{}",
VALID_COMMITMENT_HASH.strip_prefix("sha256:").unwrap()
)));
}
#[test]
fn canonical_hash_rejects_63_chars() {
assert!(!is_canonical_commitment_hash(
"sha256:9f58e9d114d11860d48aa2bcb8cda458b9618b1cc8560595a802b68c4af85d4"
));
}
#[test]
fn canonical_hash_rejects_65_chars() {
assert!(!is_canonical_commitment_hash(
"sha256:9f58e9d114d11860d48aa2bcb8cda458b9618b1cc8560595a802b68c4af85d411"
));
}
#[test]
fn canonical_hash_rejects_missing_prefix() {
assert!(!is_canonical_commitment_hash(
"9f58e9d114d11860d48aa2bcb8cda458b9618b1cc8560595a802b68c4af85d41"
));
}
#[test]
fn canonical_hash_rejects_wrong_prefix() {
assert!(!is_canonical_commitment_hash(
"sha512:9f58e9d114d11860d48aa2bcb8cda458b9618b1cc8560595a802b68c4af85d41"
));
}
#[test]
fn canonical_hash_rejects_empty_string() {
assert!(!is_canonical_commitment_hash(""));
}
#[test]
fn canonical_hash_rejects_leading_whitespace() {
assert!(!is_canonical_commitment_hash(&format!(
" {VALID_COMMITMENT_HASH}"
)));
}
#[test]
fn canonical_hash_rejects_trailing_whitespace() {
assert!(!is_canonical_commitment_hash(&format!(
"{VALID_COMMITMENT_HASH} "
)));
}
#[test]
fn canonical_hash_rejects_non_hex_characters() {
assert!(!is_canonical_commitment_hash(
"sha256:gf58e9d114d11860d48aa2bcb8cda458b9618b1cc8560595a802b68c4af85d41"
));
assert!(!is_canonical_commitment_hash(
"sha256:9f58e9d114d11860d48aa2bcb8cda458b9618b1cc8560595a802b68c4af85d4!"
));
}
#[test]
fn empty_commitment_policy_version_matches_bound_policy() {
let mut session = session_for_commitment();
session.policy_version = "policy.default".into();
let c = make_commitment("decision.selected", true); assert!(validate_commitment_payload_for_session(&session, &c.encode_to_vec()).is_ok());
}
#[test]
fn wrong_commitment_policy_version_rejected() {
let mut session = session_for_commitment();
session.policy_version = "policy.default".into();
let mut c = make_commitment("decision.selected", true);
c.policy_version = "policy.other.v1".into();
assert!(validate_commitment_payload_for_session(&session, &c.encode_to_vec()).is_err());
}
#[test]
fn exact_commitment_policy_version_accepted() {
let mut session = session_for_commitment();
session.policy_version = "policy.default".into();
let mut c = make_commitment("decision.selected", true);
c.policy_version = "policy.default".into();
assert!(validate_commitment_payload_for_session(&session, &c.encode_to_vec()).is_ok());
}
#[test]
fn decision_selected_positive_ok() {
assert!(validate_outcome_positive(&make_commitment("decision.selected", true)).is_ok());
}
#[test]
fn decision_selected_negative_rejected() {
assert!(validate_outcome_positive(&make_commitment("decision.selected", false)).is_err());
}
#[test]
fn decision_rejected_negative_ok() {
assert!(validate_outcome_positive(&make_commitment("decision.rejected", false)).is_ok());
}
#[test]
fn decision_rejected_positive_rejected() {
assert!(validate_outcome_positive(&make_commitment("decision.rejected", true)).is_err());
}
#[test]
fn proposal_accepted_positive_ok() {
assert!(validate_outcome_positive(&make_commitment("proposal.accepted", true)).is_ok());
}
#[test]
fn proposal_accepted_negative_rejected() {
assert!(validate_outcome_positive(&make_commitment("proposal.accepted", false)).is_err());
}
#[test]
fn proposal_rejected_negative_ok() {
assert!(validate_outcome_positive(&make_commitment("proposal.rejected", false)).is_ok());
}
#[test]
fn proposal_rejected_positive_rejected() {
assert!(validate_outcome_positive(&make_commitment("proposal.rejected", true)).is_err());
}
#[test]
fn task_completed_positive_ok() {
assert!(validate_outcome_positive(&make_commitment("task.completed", true)).is_ok());
}
#[test]
fn task_completed_negative_rejected() {
assert!(validate_outcome_positive(&make_commitment("task.completed", false)).is_err());
}
#[test]
fn task_failed_negative_ok() {
assert!(validate_outcome_positive(&make_commitment("task.failed", false)).is_ok());
}
#[test]
fn task_failed_positive_rejected() {
assert!(validate_outcome_positive(&make_commitment("task.failed", true)).is_err());
}
#[test]
fn handoff_accepted_positive_ok() {
assert!(validate_outcome_positive(&make_commitment("handoff.accepted", true)).is_ok());
}
#[test]
fn handoff_declined_negative_ok() {
assert!(validate_outcome_positive(&make_commitment("handoff.declined", false)).is_ok());
}
#[test]
fn handoff_declined_positive_rejected() {
assert!(validate_outcome_positive(&make_commitment("handoff.declined", true)).is_err());
}
#[test]
fn quorum_approved_positive_ok() {
assert!(validate_outcome_positive(&make_commitment("quorum.approved", true)).is_ok());
}
#[test]
fn quorum_rejected_negative_ok() {
assert!(validate_outcome_positive(&make_commitment("quorum.rejected", false)).is_ok());
}
#[test]
fn quorum_rejected_positive_rejected() {
assert!(validate_outcome_positive(&make_commitment("quorum.rejected", true)).is_err());
}
#[test]
fn custom_action_no_known_suffix_any_outcome_ok() {
assert!(validate_outcome_positive(&make_commitment("custom.action", true)).is_ok());
assert!(validate_outcome_positive(&make_commitment("custom.action", false)).is_ok());
}
}