1use crate::streaming::stringify_ndjson_line;
20use serde_json::{json, Value};
21
22pub const ASK_SUPPORTED_TOOLS: &[&str] = &["agent", "claude"];
24
25pub const ASK_DECISIONS: &[&str] = &["once", "always", "reject"];
27
28pub fn ask_scope(tool: &str) -> Option<&'static str> {
38 match tool {
39 "agent" => Some("session"),
40 "claude" => Some("tool-input"),
41 _ => None,
42 }
43}
44
45pub fn supports_ask(tool: &str) -> bool {
47 ASK_SUPPORTED_TOOLS.contains(&tool)
48}
49
50pub fn ask_unsupported_error(tool: &str) -> String {
53 format!(
54 "Tool \"{}\" does not support enforceable per-command approval (ask mode). Choose one of: {}; or run without --approve-each.",
55 tool,
56 ASK_SUPPORTED_TOOLS.join(", ")
57 )
58}
59
60#[allow(clippy::derive_partial_eq_without_eq)]
63#[derive(Debug, Clone, PartialEq)]
64pub struct NormalizedPermissionRequest {
65 pub r#type: String,
67 pub tool: String,
69 pub id: Option<String>,
71 pub session_id: Option<String>,
72 pub call_id: Option<String>,
73 pub tool_name: Option<String>,
75 pub title: Option<String>,
76 pub command: Option<String>,
78 pub pattern: Option<String>,
79 pub scope: String,
81 pub input: Option<Value>,
83 pub raw: Value,
85}
86
87fn value_str(message: &Value, key: &str) -> Option<String> {
88 message
89 .get(key)
90 .and_then(|v| v.as_str())
91 .map(|s| s.to_string())
92}
93
94fn derive_claude_command(tool_name: Option<&str>, input: Option<&Value>) -> Option<String> {
96 if let Some(input) = input {
97 for key in ["command", "file_path", "path", "url"] {
99 if let Some(value) = input.get(key).and_then(|v| v.as_str()) {
100 return Some(value.to_string());
101 }
102 }
103 }
104 tool_name.map(|s| s.to_string())
105}
106
107pub fn normalize_permission_request(
112 tool: &str,
113 message: &Value,
114) -> Option<NormalizedPermissionRequest> {
115 if !message.is_object() {
116 return None;
117 }
118
119 if tool == "agent" {
120 if message.get("type").and_then(|v| v.as_str()) != Some("permission_request") {
121 return None;
122 }
123 let id = value_str(message, "permissionID").or_else(|| value_str(message, "permission_id"));
124 let metadata = message.get("metadata").filter(|v| v.is_object());
125 let title = value_str(message, "title");
126 let command = metadata
127 .and_then(|m| m.get("command"))
128 .and_then(|v| v.as_str())
129 .map(|s| s.to_string())
130 .or_else(|| title.clone());
131 let pattern = value_str(message, "pattern").or_else(|| {
132 metadata
133 .and_then(|m| m.get("patterns"))
134 .and_then(|v| v.as_str())
135 .map(|s| s.to_string())
136 });
137 return Some(NormalizedPermissionRequest {
138 r#type: "permission_request".to_string(),
139 tool: "agent".to_string(),
140 id,
141 session_id: value_str(message, "sessionID")
142 .or_else(|| value_str(message, "session_id")),
143 call_id: value_str(message, "callID").or_else(|| value_str(message, "call_id")),
144 tool_name: value_str(message, "tool"),
145 title,
146 command,
147 pattern,
148 scope: ask_scope("agent").unwrap().to_string(),
149 input: None,
150 raw: message.clone(),
151 });
152 }
153
154 if tool == "claude" {
155 let request = message.get("request");
156 let is_can_use_tool = message.get("type").and_then(|v| v.as_str())
157 == Some("control_request")
158 && request
159 .and_then(|r| r.get("subtype"))
160 .and_then(|v| v.as_str())
161 == Some("can_use_tool");
162 if !is_can_use_tool {
163 return None;
164 }
165 let request = request.unwrap();
166 let tool_name = request
167 .get("tool_name")
168 .and_then(|v| v.as_str())
169 .map(|s| s.to_string());
170 let input = request.get("input").filter(|v| v.is_object()).cloned();
171 return Some(NormalizedPermissionRequest {
172 r#type: "permission_request".to_string(),
173 tool: "claude".to_string(),
174 id: value_str(message, "request_id"),
175 session_id: value_str(message, "session_id"),
176 call_id: request
177 .get("tool_use_id")
178 .and_then(|v| v.as_str())
179 .map(|s| s.to_string()),
180 tool_name: tool_name.clone(),
181 title: tool_name.clone(),
182 command: derive_claude_command(tool_name.as_deref(), input.as_ref()),
183 pattern: None,
184 scope: ask_scope("claude").unwrap().to_string(),
185 input,
186 raw: message.clone(),
187 });
188 }
189
190 None
191}
192
193pub fn build_permission_response(
197 tool: &str,
198 request: &NormalizedPermissionRequest,
199 decision: &str,
200) -> Result<Value, String> {
201 if !ASK_DECISIONS.contains(&decision) {
202 return Err(format!(
203 "Invalid permission decision \"{}\". Expected one of: once, always, reject.",
204 decision
205 ));
206 }
207
208 let id = request.id.clone().unwrap_or_default();
209
210 if tool == "agent" {
211 return Ok(json!({
213 "type": "permission_response",
214 "permissionID": id,
215 "response": decision,
216 }));
217 }
218
219 if tool == "claude" {
220 if decision == "reject" {
224 return Ok(json!({
225 "type": "control_response",
226 "response": {
227 "subtype": "success",
228 "request_id": id,
229 "response": {
230 "behavior": "deny",
231 "message": "Denied by consumer (ask mode).",
232 },
233 },
234 }));
235 }
236 let updated_input = request.input.clone().unwrap_or_else(|| json!({}));
237 return Ok(json!({
238 "type": "control_response",
239 "response": {
240 "subtype": "success",
241 "request_id": id,
242 "response": {
243 "behavior": "allow",
244 "updatedInput": updated_input,
245 },
246 },
247 }));
248 }
249
250 Err(ask_unsupported_error(tool))
251}
252
253#[derive(Debug, Clone)]
255pub struct PermissionParityRow {
256 pub tool: &'static str,
257 pub native_mechanism: &'static str,
258 pub scope: &'static str,
259 pub relay: bool,
260 pub notes: &'static str,
261}
262
263pub fn permission_parity() -> Vec<PermissionParityRow> {
269 vec![
270 PermissionParityRow {
271 tool: "agent",
272 native_mechanism: "--permission-mode ask (+ --input-format stream-json)",
273 scope: "session",
274 relay: true,
275 notes: "Native JSON permission_request/permission_response protocol; once | always | reject map 1:1.",
276 },
277 PermissionParityRow {
278 tool: "claude",
279 native_mechanism: "--permission-mode default (stream-json can_use_tool)",
280 scope: "tool-input",
281 relay: true,
282 notes: "control_request/control_response handshake; no session-wide always, so once and always both allow this call.",
283 },
284 PermissionParityRow {
285 tool: "codex",
286 native_mechanism: "--ask-for-approval (coupled with --sandbox)",
287 scope: "sandbox-coupled",
288 relay: false,
289 notes: "Approval is coupled with the sandbox policy and not exposed as a tool-agnostic JSON request/response stream.",
290 },
291 PermissionParityRow {
292 tool: "qwen",
293 native_mechanism: "--approval-mode default",
294 scope: "interactive-only",
295 relay: false,
296 notes: "Headless mode has no relayable per-command JSON approval handshake.",
297 },
298 PermissionParityRow {
299 tool: "gemini",
300 native_mechanism: "--approval-mode default",
301 scope: "interactive-only",
302 relay: false,
303 notes: "No JSON stdin channel (prompt is passed via -p), so approvals cannot be relayed.",
304 },
305 PermissionParityRow {
306 tool: "opencode",
307 native_mechanism: "OPENCODE_PERMISSION (static {edit,bash,task} policy)",
308 scope: "static-policy",
309 relay: false,
310 notes: "Only a static up-front policy is available; there is no per-command request/response relay.",
311 },
312 ]
313}
314
315pub struct PermissionRelay<'a> {
326 tool: String,
327 on_request: Box<dyn FnMut(&NormalizedPermissionRequest) -> String + 'a>,
328 write: Box<dyn FnMut(&str) + 'a>,
329 compact: bool,
330 handled: Vec<(NormalizedPermissionRequest, String, Value)>,
331}
332
333impl<'a> PermissionRelay<'a> {
334 pub fn new<F, W>(tool: &str, on_request: F, write: W) -> Self
340 where
341 F: FnMut(&NormalizedPermissionRequest) -> String + 'a,
342 W: FnMut(&str) + 'a,
343 {
344 Self {
345 tool: tool.to_string(),
346 on_request: Box::new(on_request),
347 write: Box::new(write),
348 compact: true,
349 handled: Vec::new(),
350 }
351 }
352
353 pub fn handle_message(
358 &mut self,
359 message: &Value,
360 ) -> Option<(NormalizedPermissionRequest, String)> {
361 let request = normalize_permission_request(&self.tool, message)?;
362
363 let mut decision = (self.on_request)(&request);
364 if !ASK_DECISIONS.contains(&decision.as_str()) {
366 decision = "reject".to_string();
367 }
368
369 let frame = build_permission_response(&self.tool, &request, &decision)
372 .expect("relayable tool with validated decision");
373 (self.write)(&stringify_ndjson_line(&frame, self.compact));
374
375 self.handled
376 .push((request.clone(), decision.clone(), frame));
377 Some((request, decision))
378 }
379
380 pub fn get_handled(&self) -> &[(NormalizedPermissionRequest, String, Value)] {
382 &self.handled
383 }
384}