use serde_json::{json, Value as JsonValue};
pub(crate) const METHOD_REQUEST_PERMISSION: &str = "session/request_permission";
pub(crate) const OPTION_ALLOW: &str = "allow";
pub(crate) const OPTION_REJECT: &str = "reject";
fn canonical_options() -> JsonValue {
json!([
{ "optionId": OPTION_ALLOW, "name": "Allow", "kind": "allow_once" },
{ "optionId": OPTION_REJECT, "name": "Reject", "kind": "reject_once" },
])
}
pub(crate) fn allow_response() -> JsonValue {
json!({
"outcome": { "outcome": "selected", "optionId": OPTION_ALLOW }
})
}
pub(crate) fn reject_response(reason: Option<String>) -> JsonValue {
let mut response = json!({
"outcome": { "outcome": "selected", "optionId": OPTION_REJECT }
});
if let Some(reason) = reason {
response
.as_object_mut()
.expect("object")
.insert("reason".to_string(), JsonValue::String(reason));
}
response
}
pub(crate) fn request_params(
session_id: Option<&str>,
tool_call_id: &str,
tool_name: &str,
raw_input: &JsonValue,
approval_request: JsonValue,
policy_decision: &JsonValue,
tool_descriptor: Option<JsonValue>,
tool_kind: crate::tool_annotations::ToolKind,
) -> JsonValue {
let mut params = serde_json::Map::new();
if let Some(session_id) = session_id {
params.insert("sessionId".to_string(), json!(session_id));
}
let mut harn_meta = json!({
"toolName": tool_name,
"approvalRequest": approval_request,
"policyDecision": policy_decision,
});
if let (Some(descriptor), Some(obj)) = (tool_descriptor, harn_meta.as_object_mut()) {
obj.insert("toolDescriptor".to_string(), descriptor);
}
let content = permission_content(&approval_request);
let mut tool_call = json!({
"sessionUpdate": "tool_call_update",
"toolCallId": tool_call_id,
"title": tool_name,
"kind": tool_kind,
"rawInput": raw_input,
"_meta": { "harn": harn_meta }
});
if !content.is_empty() {
tool_call
.as_object_mut()
.expect("tool call object")
.insert("content".to_string(), JsonValue::Array(content));
}
params.insert("toolCall".to_string(), tool_call);
params.insert("options".to_string(), canonical_options());
JsonValue::Object(params)
}
fn permission_content(approval_request: &JsonValue) -> Vec<JsonValue> {
approval_request
.get("evidence_refs")
.and_then(JsonValue::as_array)
.into_iter()
.flatten()
.filter(|evidence| evidence.get("kind").and_then(JsonValue::as_str) == Some("file_mutation_diff"))
.filter_map(|evidence| {
let path = evidence.get("path")?.as_str()?;
let new_text = evidence.get("newText")?.as_str()?;
let old_text = evidence.get("oldText").cloned().unwrap_or(JsonValue::Null);
let preview_meta = json!({
"source": evidence.get("source").cloned().unwrap_or_else(|| json!("pre_approval")),
"preimageSha256": evidence.get("preimageSha256").cloned().unwrap_or(JsonValue::Null),
"byteCount": evidence.get("byteCount").cloned().unwrap_or(JsonValue::Null),
});
Some(json!({
"type": "diff",
"path": path,
"oldText": old_text,
"newText": new_text,
"_meta": {
"harn": {
"permission_preview": preview_meta
}
}
}))
})
.collect()
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum WireOutcome {
Allowed,
Rejected { reason: String },
}
pub(crate) fn parse_response(response: &JsonValue) -> WireOutcome {
let outcome = response.get("outcome");
let kind = outcome
.and_then(|outcome| outcome.get("outcome"))
.and_then(JsonValue::as_str)
.unwrap_or("");
match kind {
"selected" => {
let option_id = outcome
.and_then(|outcome| outcome.get("optionId"))
.and_then(JsonValue::as_str)
.unwrap_or("");
if option_id == OPTION_ALLOW {
WireOutcome::Allowed
} else {
WireOutcome::Rejected {
reason: response
.get("reason")
.and_then(JsonValue::as_str)
.map(str::to_string)
.unwrap_or_else(|| "host rejected the tool call".to_string()),
}
}
}
"cancelled" => WireOutcome::Rejected {
reason: "permission request was cancelled".to_string(),
},
_ => WireOutcome::Rejected {
reason: "host did not return a canonical permission outcome".to_string(),
},
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::tool_annotations::ToolKind;
#[test]
fn request_params_carry_canonical_options_and_tool_call() {
let params = request_params(
Some("session-1"),
"tool-1",
"edit",
&json!({"path": "src/lib.rs"}),
json!({"id": "tool-1", "action": "edit"}),
&json!({"decision": "ask"}),
None,
ToolKind::Other,
);
assert_eq!(params["sessionId"], "session-1");
assert_eq!(params["toolCall"]["sessionUpdate"], "tool_call_update");
assert_eq!(params["toolCall"]["toolCallId"], "tool-1");
assert_eq!(params["toolCall"]["title"], "edit");
assert_eq!(params["toolCall"]["kind"], "other");
assert_eq!(params["toolCall"]["rawInput"]["path"], "src/lib.rs");
assert_eq!(params["toolCall"]["_meta"]["harn"]["toolName"], "edit");
assert_eq!(
params["toolCall"]["_meta"]["harn"]["policyDecision"]["decision"],
"ask"
);
let options = params["options"].as_array().expect("options array");
assert_eq!(options.len(), 2);
assert_eq!(options[0]["optionId"], OPTION_ALLOW);
assert_eq!(options[0]["kind"], "allow_once");
assert_eq!(options[1]["optionId"], OPTION_REJECT);
assert_eq!(options[1]["kind"], "reject_once");
}
#[test]
fn request_params_project_file_evidence_into_canonical_diff_content() {
let params = request_params(
Some("session-1"),
"tool-1",
"edit",
&json!({"path": "src/lib.rs"}),
json!({
"id": "tool-1",
"action": "edit",
"evidence_refs": [{
"kind": "file_mutation_diff",
"path": "/workspace/src/lib.rs",
"oldText": "old\n",
"newText": "new\n",
"preimageSha256": "abc123",
"byteCount": 4,
"source": "pre_approval"
}]
}),
&json!({"decision": "ask"}),
None,
ToolKind::Edit,
);
let diff = ¶ms["toolCall"]["content"][0];
assert_eq!(diff["type"], "diff");
assert_eq!(diff["path"], "/workspace/src/lib.rs");
assert_eq!(diff["oldText"], "old\n");
assert_eq!(diff["newText"], "new\n");
assert_eq!(
diff["_meta"]["harn"]["permission_preview"]["preimageSha256"],
"abc123"
);
}
#[test]
fn request_params_use_the_declared_acp_tool_kind() {
let params = request_params(
Some("session-1"),
"tool-1",
"edit",
&json!({"path": "src/lib.rs"}),
json!({"id": "tool-1", "evidence_refs": []}),
&json!({"decision": "ask"}),
None,
ToolKind::Edit,
);
assert_eq!(params["toolCall"]["kind"], "edit");
}
#[test]
fn selected_allow_is_allowed() {
let response = allow_response();
assert_eq!(parse_response(&response), WireOutcome::Allowed);
}
#[test]
fn every_offered_option_has_a_defined_parser_outcome() {
for option in canonical_options().as_array().expect("options") {
let option_id = option["optionId"].as_str().expect("option id");
let kind = option["kind"].as_str().expect("option kind");
let response = json!({
"outcome": { "outcome": "selected", "optionId": option_id }
});
assert_eq!(
matches!(parse_response(&response), WireOutcome::Allowed),
kind.starts_with("allow_"),
"offered option {option_id} ({kind})"
);
}
}
#[test]
fn selected_reject_is_rejected() {
let response = reject_response(None);
assert!(matches!(
parse_response(&response),
WireOutcome::Rejected { .. }
));
}
#[test]
fn cancelled_is_rejected() {
let response = json!({"outcome": {"outcome": "cancelled"}});
match parse_response(&response) {
WireOutcome::Rejected { reason } => assert!(reason.contains("cancelled")),
other => panic!("expected rejection, got {other:?}"),
}
}
#[test]
fn non_canonical_outcome_fails_closed() {
assert!(matches!(
parse_response(&json!({"outcome": "approved"})),
WireOutcome::Rejected { .. }
));
assert!(matches!(
parse_response(&json!({"granted": true})),
WireOutcome::Rejected { .. }
));
}
#[test]
fn selected_missing_option_id_fails_closed() {
let response = json!({"outcome": {"outcome": "selected"}});
assert!(matches!(
parse_response(&response),
WireOutcome::Rejected { .. }
));
}
}