use sha2::{Digest, Sha256};
pub const PIN_CANON_VERSION: u32 = 2;
pub const NARRATION_FIELDS: [&str; 1] = ["description"];
fn strip_narration(input: &serde_json::Value) -> std::borrow::Cow<'_, serde_json::Value> {
let Some(map) = input.as_object() else {
return std::borrow::Cow::Borrowed(input);
};
if !NARRATION_FIELDS.iter().any(|k| map.contains_key(*k)) {
return std::borrow::Cow::Borrowed(input);
}
let stripped: serde_json::Map<String, serde_json::Value> = map
.iter()
.filter(|(k, _)| !NARRATION_FIELDS.contains(&k.as_str()))
.map(|(k, v)| (k.clone(), v.clone()))
.collect();
std::borrow::Cow::Owned(serde_json::Value::Object(stripped))
}
pub fn action_hash(
tool_name: &str,
input: &serde_json::Value,
channel_id: &str,
step_or_call_id: &str,
agent_id: &str,
) -> String {
let canon = serde_json::json!({
"v": PIN_CANON_VERSION,
"tool": tool_name,
"input": strip_narration(input).as_ref(),
"channel": channel_id,
"step": step_or_call_id,
"agent": agent_id,
});
let bytes = serde_json::to_vec(&canon).unwrap_or_default();
let mut h = Sha256::new();
h.update(&bytes);
format!("{:x}", h.finalize())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn hash_is_stable_and_order_independent() {
let a = action_hash("bash", &serde_json::json!({"b":1,"a":2}), "c", "s0", "mur");
let b = action_hash("bash", &serde_json::json!({"a":2,"b":1}), "c", "s0", "mur");
assert_eq!(a, b);
assert_eq!(a.len(), 64);
}
#[test]
fn drift_changes_the_hash() {
let approved = action_hash("bash", &serde_json::json!({"cmd":"rm a"}), "c", "s0", "mur");
let executed = action_hash("bash", &serde_json::json!({"cmd":"rm b"}), "c", "s0", "mur");
assert_ne!(approved, executed, "different args MUST fail the re-verify");
}
#[test]
fn narration_does_not_change_the_hash() {
let a = action_hash(
"bash",
&serde_json::json!({"command":"gh pr checks","description":"Checking CI"}),
"c",
"s0",
"mur",
);
let b = action_hash(
"bash",
&serde_json::json!({"command":"gh pr checks","description":"Re-checking PR status"}),
"c",
"s0",
"mur",
);
let none = action_hash(
"bash",
&serde_json::json!({"command":"gh pr checks"}),
"c",
"s0",
"mur",
);
assert_eq!(a, b, "re-worded narration must reuse the remembered pin");
assert_eq!(a, none, "absent narration must pin the same as any wording");
}
#[test]
fn nested_description_is_still_hashed() {
let a = action_hash(
"t",
&serde_json::json!({"x":{"description":"a"}}),
"c",
"s",
"m",
);
let b = action_hash(
"t",
&serde_json::json!({"x":{"description":"b"}}),
"c",
"s",
"m",
);
assert_ne!(a, b, "only TOP-level narration is dropped");
}
#[test]
fn non_object_input_hashes_whole() {
let a = action_hash("t", &serde_json::json!("raw"), "c", "s", "m");
let b = action_hash("t", &serde_json::json!("other"), "c", "s", "m");
assert_ne!(a, b);
}
}