Skip to main content

agent_commander/
permissions.rs

1//! Permission normalization and relay for the uniform per-command approval
2//! ("ask") mode.
3//!
4//! Each backend CLI that supports interactive, per-command approval exposes the
5//! approval handshake over a different JSON wire format. This module translates
6//! those native frames into a single normalized `permission_request` event and
7//! translates a normalized decision (`once` | `always` | `reject`) back into the
8//! native response frame that the CLI expects on its stdin.
9//!
10//! Only tools with a *drivable* JSON request/response permission protocol can be
11//! relayed (see [`ASK_SUPPORTED_TOOLS`]). Tools whose only native approval
12//! mechanism is a static policy (`opencode`), a sandbox coupling (`codex`), or a
13//! non-streaming approval flag (`qwen`, `gemini`) are documented in the parity
14//! table but fail clearly when ask mode is requested — mirroring the
15//! `--read-only` unsupported-tool pattern.
16//!
17//! This is the Rust mirror of `js/src/permissions/`.
18
19use crate::streaming::stringify_ndjson_line;
20use serde_json::{json, Value};
21
22/// Tools that expose a relayable per-command approval protocol over JSON.
23pub const ASK_SUPPORTED_TOOLS: &[&str] = &["agent", "claude"];
24
25/// Normalized decisions a consumer may return for a permission request.
26pub const ASK_DECISIONS: &[&str] = &["once", "always", "reject"];
27
28/// Scope of an `always` decision for each backend. The reviewer (m13v) flagged
29/// that "always" does not mean the same thing across CLIs, so the normalized
30/// event and the parity table both carry this scope.
31///
32/// - `session`    — `always` auto-approves later matching requests for the rest
33///   of the session (agent's native `always`).
34/// - `tool-input` — approval binds to the tool name + input shape; Claude has no
35///   native session-wide `always`, so `once` and `always` both map to a single
36///   allow decision bound to that tool call's input.
37pub 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
45/// Whether agent-commander can relay per-command approvals for the given tool.
46pub fn supports_ask(tool: &str) -> bool {
47    ASK_SUPPORTED_TOOLS.contains(&tool)
48}
49
50/// Build the standard error for tools without a relayable per-command approval
51/// mechanism (ask mode). Mirrors [`crate::command_builder::read_only_unsupported_error`].
52pub 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/// A normalized permission request, uniform across every relayable backend.
61// `raw`/`input` carry `serde_json::Value`, which cannot implement `Eq`.
62#[allow(clippy::derive_partial_eq_without_eq)]
63#[derive(Debug, Clone, PartialEq)]
64pub struct NormalizedPermissionRequest {
65    /// Always `"permission_request"`.
66    pub r#type: String,
67    /// Backend tool name (`agent` | `claude`).
68    pub tool: String,
69    /// Opaque id used to correlate the response with this request.
70    pub id: Option<String>,
71    pub session_id: Option<String>,
72    pub call_id: Option<String>,
73    /// Native tool/action name (e.g. `bash`, `Edit`).
74    pub tool_name: Option<String>,
75    pub title: Option<String>,
76    /// Best-effort human-readable command/target string.
77    pub command: Option<String>,
78    pub pattern: Option<String>,
79    /// What an `always`/allow decision attaches to (see [`ask_scope`]).
80    pub scope: String,
81    /// Original tool input payload (Claude only), needed to echo `updatedInput`.
82    pub input: Option<Value>,
83    /// The raw native frame this was normalized from.
84    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
94/// Derive a human-readable command string from a Claude tool input payload.
95fn derive_claude_command(tool_name: Option<&str>, input: Option<&Value>) -> Option<String> {
96    if let Some(input) = input {
97        // Bash carries the literal shell command.
98        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
107/// Normalize a native permission request frame into a uniform event.
108///
109/// Returns `None` when the message is not a permission request for the tool, so
110/// callers can pass every parsed output message through unconditionally.
111pub 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
193/// Build the native response frame for a normalized decision.
194///
195/// Returns `Err` for an invalid decision or an unsupported tool.
196pub 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        // Agent's native protocol accepts once | always | reject verbatim.
212        return Ok(json!({
213            "type": "permission_response",
214            "permissionID": id,
215            "response": decision,
216        }));
217    }
218
219    if tool == "claude" {
220        // Claude's stream-json control protocol expects an allow/deny behavior.
221        // It has no native session-wide "always", so once and always both map to
222        // a single allow bound to this tool call's input (scope: tool-input).
223        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/// A single row of the per-command approval parity table.
254#[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
263/// Parity description for each tool's native per-command approval mechanism.
264///
265/// Surfaced in the docs parity table; `scope` documents what an `always`/allow
266/// decision attaches to, and `relay` indicates whether agent-commander can drive
267/// the handshake as normalized JSON.
268pub 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
315/// Relay native permission requests to a consumer and forward decisions back.
316///
317/// A `PermissionRelay` sits between a backend CLI's streaming output and a
318/// consumer: it watches parsed output messages for native permission requests,
319/// normalizes them, asks the consumer for a decision, and writes the native
320/// response frame back to the CLI's stdin as NDJSON.
321///
322/// The relay is intentionally transport-agnostic — it does not own the child
323/// process. The caller supplies a `write` closure (typically the child's stdin)
324/// and feeds it parsed messages, which keeps it fully unit-testable.
325pub 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    /// Create a new relay.
335    ///
336    /// * `on_request` resolves a normalized request to a decision
337    ///   (`once` | `always` | `reject`).
338    /// * `write` receives a serialized NDJSON frame to forward to the tool stdin.
339    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    /// Process a single parsed output message. When the message is a permission
354    /// request, resolves the consumer's decision and writes the native response.
355    /// Returns the normalized request and the applied decision, or `None` when the
356    /// message is not a permission request.
357    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        // Default to the safe choice if the consumer returns nothing usable.
365        if !ASK_DECISIONS.contains(&decision.as_str()) {
366            decision = "reject".to_string();
367        }
368
369        // build_permission_response only fails for unsupported tools / decisions,
370        // both of which are excluded above, so the frame is always available here.
371        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    /// All permission requests handled so far (for inspection/testing).
381    pub fn get_handled(&self) -> &[(NormalizedPermissionRequest, String, Value)] {
382        &self.handled
383    }
384}