use serde_json::{json, Value as JsonValue};
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 request_params(
session_id: Option<&str>,
tool_call_id: &str,
tool_name: &str,
raw_input: &JsonValue,
approval_request: JsonValue,
policy_decision: &JsonValue,
) -> JsonValue {
let mut params = serde_json::Map::new();
if let Some(session_id) = session_id {
params.insert("sessionId".to_string(), json!(session_id));
}
params.insert(
"toolCall".to_string(),
json!({
"sessionUpdate": "tool_call_update",
"toolCallId": tool_call_id,
"title": tool_name,
"kind": "other",
"rawInput": raw_input,
"_meta": {
"harn": {
"toolName": tool_name,
"approvalRequest": approval_request,
"policyDecision": policy_decision,
}
}
}),
);
params.insert("options".to_string(), canonical_options());
JsonValue::Object(params)
}
#[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::*;
#[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"}),
);
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"]["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 selected_allow_is_allowed() {
let response = json!({"outcome": {"outcome": "selected", "optionId": "allow"}});
assert_eq!(parse_response(&response), WireOutcome::Allowed);
}
#[test]
fn selected_reject_is_rejected() {
let response = json!({"outcome": {"outcome": "selected", "optionId": "reject"}});
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 { .. }
));
}
}