crates/signaltty-agent/src/permission.rs
//! Narrow native PermissionRequest contract shared by Claude and Codex.
use serde_json::{json, Value};
use signaltty_core::model::DecisionOption;
pub struct NativePermission {
pub session_id: String,
pub prompt: String,
}
pub fn native_permission(agent: &str, payload: &Value) -> Result<NativePermission, String> {
if !matches!(agent, "claude" | "codex") {
return Err("agent has no native permission channel".into());
}
let string = |key: &str| {
payload
.get(key)
.and_then(Value::as_str)
.filter(|s| !s.trim().is_empty())
.map(str::to_owned)
.ok_or_else(|| format!("PermissionRequest needs a nonempty {key}"))
};
let session_id = string("session_id")?;
let tool = string("tool_name")?;
let input = payload
.get("tool_input")
.filter(|v| v.is_object())
.ok_or_else(|| "PermissionRequest needs an object tool_input".to_owned())?;
let input = serde_json::to_string_pretty(input).map_err(|e| e.to_string())?;
Ok(NativePermission {
session_id,
prompt: format!("Allow {tool}?\n{input}"),
})
}
pub fn permission_options() -> Vec<DecisionOption> {
vec![
DecisionOption {
id: "once".into(),
label: "Allow once".into(),
},
DecisionOption {
id: "deny".into(),
label: "Deny".into(),
},
]
}
pub fn permission_output(option: &str) -> Option<Value> {
let decision = match option {
"once" => json!({"behavior":"allow"}),
"deny" => json!({"behavior":"deny","message":"Denied in signaltty"}),
_ => return None,
};
Some(json!({"hookSpecificOutput":{"hookEventName":"PermissionRequest","decision":decision}}))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn native_payload_and_verdict_match_the_documented_provider_contract() {
let payload = json!({"session_id":"s","tool_name":"Bash","tool_input":{"command":"cargo test"},"extra":"ignored"});
for provider in ["codex", "claude"] {
let permission = native_permission(provider, &payload).unwrap();
assert_eq!(permission.session_id, "s");
assert!(permission.prompt.contains("cargo test"));
}
assert_eq!(
permission_output("once"),
Some(
json!({"hookSpecificOutput":{"hookEventName":"PermissionRequest","decision":{"behavior":"allow"}}})
)
);
assert_eq!(
permission_output("deny").unwrap()["hookSpecificOutput"]["decision"]["behavior"],
"deny"
);
assert!(permission_output("always").is_none());
assert!(native_permission("opencode", &payload).is_err());
for payload in [
json!({}),
json!({"session_id":"s","tool_name":"Bash","tool_input":null}),
json!({"session_id":"","tool_name":"Bash","tool_input":{}}),
] {
assert!(native_permission("codex", &payload).is_err());
}
}
}