Skip to main content

forge_sdk/
control.rs

1//! Control-protocol messages exchanged over the main stream-json stdio channel.
2//!
3//! These carry out-of-band requests that the SDK must answer synchronously,
4//! such as permission checks (`can_use_tool`). The shapes here mirror what
5//! the CLI emits on that channel, observed rather than specified.
6
7use serde::{Deserialize, Serialize};
8use serde_json::Value;
9
10/// A control request emitted by the `claude` binary. The SDK must respond
11/// with a matching [`ControlResponse`] carrying the same `request_id`.
12#[derive(Debug, Clone, Serialize, Deserialize)]
13pub struct ControlRequest {
14    /// Fixed discriminant `"control_request"`.
15    #[serde(rename = "type")]
16    pub ty: ControlRequestType,
17    /// Opaque id correlating request to response.
18    pub request_id: String,
19    /// The request body.
20    pub request: ControlRequestKind,
21}
22
23/// The discriminant type for [`ControlRequest`]. Always `"control_request"`.
24#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
25#[serde(rename_all = "snake_case")]
26pub enum ControlRequestType {
27    /// The only current variant.
28    ControlRequest,
29}
30
31/// Enumerates the kinds of control requests we handle.
32///
33/// `Unknown` is the forward-compat catch-all - any `subtype` the CLI
34/// ships that forge-sdk doesn't yet model lands here with the full
35/// payload captured. The dispatcher in `client/control_dispatch.rs`
36/// responds with a `control_response` error (since we have no handler)
37/// and logs a `tracing::warn!` so callers notice drift.
38#[derive(Debug, Clone)]
39pub enum ControlRequestKind {
40    /// A permission check for a tool call.
41    CanUseTool {
42        /// Tool name the model wants to invoke.
43        tool_name: String,
44        /// The JSON input the model generated.
45        input: Value,
46        /// Permission-suggestion hints from the binary (may be empty).
47        permission_suggestions: Vec<Value>,
48        /// Path blocked by workspace sandboxing, when applicable.
49        blocked_path: Option<String>,
50        /// Identifier of this tool-use (required; string).
51        tool_use_id: String,
52        /// Agent identifier when this is a sub-agent request.
53        agent_id: Option<String>,
54        /// Free-form reason the CLI surfaced for why human review is
55        /// needed (e.g. `"workspace not yet trusted"`).
56        decision_reason: Option<String>,
57        /// Short title the CLI suggests for the prompt
58        /// (e.g. `"Run tests"`).
59        title: Option<String>,
60        /// Display name for the tool call (often a humanised tool name).
61        display_name: Option<String>,
62        /// Long-form description the CLI surfaces in the prompt body.
63        description: Option<String>,
64    },
65    /// MCP JSON-RPC message routed in-process. See `mcp` module for the MCP
66    /// routing pipeline.
67    McpMessage {
68        /// Name of the in-process MCP server this message is addressed to
69        /// (matches `mcpServers` config key).
70        server_name: String,
71        /// The JSON-RPC request body (initialize / tools/list / tools/call /
72        /// notifications/initialized).
73        message: Value,
74    },
75    /// Hook callback invocation. `callback_id` was registered at connect
76    /// time via the `initialize` `control_request`. See Plan 3 hooks section.
77    HookCallback {
78        /// Opaque id assigned by forge-sdk at initialize time.
79        callback_id: String,
80        /// Hook-specific payload; includes `hook_event_name` to discriminate.
81        input: Value,
82        /// Tool-use id when the hook fired in a tool-use context.
83        tool_use_id: Option<String>,
84    },
85    /// Forward-compat fallback for `subtype` values forge-sdk doesn't
86    /// recognise. The dispatcher responds with an error (since no handler
87    /// is registered) and logs a warning. This prevents an unknown CLI
88    /// subtype from panicking the session.
89    Unknown {
90        /// The unrecognised `subtype` value.
91        subtype: String,
92        /// Full request JSON including `request` body.
93        raw: Value,
94    },
95}
96
97impl Serialize for ControlRequestKind {
98    fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
99        use serde::ser::SerializeMap;
100        match self {
101            Self::CanUseTool {
102                tool_name,
103                input,
104                permission_suggestions,
105                blocked_path,
106                tool_use_id,
107                agent_id,
108                decision_reason,
109                title,
110                display_name,
111                description,
112            } => {
113                let mut field_count = 4;
114                if !permission_suggestions.is_empty() {
115                    field_count += 1;
116                }
117                if blocked_path.is_some() {
118                    field_count += 1;
119                }
120                if agent_id.is_some() {
121                    field_count += 1;
122                }
123                for opt in [decision_reason, title, display_name, description] {
124                    if opt.is_some() {
125                        field_count += 1;
126                    }
127                }
128                let mut map = serializer.serialize_map(Some(field_count))?;
129                map.serialize_entry("subtype", "can_use_tool")?;
130                map.serialize_entry("tool_name", tool_name)?;
131                map.serialize_entry("input", input)?;
132                if !permission_suggestions.is_empty() {
133                    map.serialize_entry("permission_suggestions", permission_suggestions)?;
134                }
135                if let Some(bp) = blocked_path {
136                    map.serialize_entry("blocked_path", bp)?;
137                }
138                map.serialize_entry("tool_use_id", tool_use_id)?;
139                if let Some(aid) = agent_id {
140                    map.serialize_entry("agent_id", aid)?;
141                }
142                if let Some(dr) = decision_reason {
143                    map.serialize_entry("decision_reason", dr)?;
144                }
145                if let Some(t) = title {
146                    map.serialize_entry("title", t)?;
147                }
148                if let Some(dn) = display_name {
149                    map.serialize_entry("display_name", dn)?;
150                }
151                if let Some(d) = description {
152                    map.serialize_entry("description", d)?;
153                }
154                map.end()
155            }
156            Self::McpMessage { server_name, message } => {
157                let mut map = serializer.serialize_map(Some(3))?;
158                map.serialize_entry("subtype", "mcp_message")?;
159                map.serialize_entry("server_name", server_name)?;
160                map.serialize_entry("message", message)?;
161                map.end()
162            }
163            Self::HookCallback { callback_id, input, tool_use_id } => {
164                let mut field_count = 3;
165                if tool_use_id.is_some() {
166                    field_count += 1;
167                }
168                let mut map = serializer.serialize_map(Some(field_count))?;
169                map.serialize_entry("subtype", "hook_callback")?;
170                map.serialize_entry("callback_id", callback_id)?;
171                map.serialize_entry("input", input)?;
172                if let Some(tid) = tool_use_id {
173                    map.serialize_entry("tool_use_id", tid)?;
174                }
175                map.end()
176            }
177            Self::Unknown { raw, .. } => raw.serialize(serializer),
178        }
179    }
180}
181
182impl<'de> Deserialize<'de> for ControlRequestKind {
183    fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
184        let raw = Value::deserialize(deserializer)?;
185        // `subtype` is required per the wire spec. Distinguish "missing"
186        // (wire corruption / CLI bug) from "unrecognised" (forward-compat
187        // drift) so debug logs + `Unknown` payloads carry the right
188        // signal for the dispatcher.
189        let subtype = raw.get("subtype").and_then(Value::as_str).unwrap_or("<missing>").to_string();
190        match subtype.as_str() {
191            "can_use_tool" => {
192                let tool_name = raw
193                    .get("tool_name")
194                    .and_then(Value::as_str)
195                    .ok_or_else(|| serde::de::Error::missing_field("tool_name"))?
196                    .to_string();
197                let input = raw.get("input").cloned().unwrap_or(Value::Null);
198                let permission_suggestions = raw
199                    .get("permission_suggestions")
200                    .and_then(|v| v.as_array().cloned())
201                    .unwrap_or_default();
202                let blocked_path =
203                    raw.get("blocked_path").and_then(Value::as_str).map(str::to_string);
204                let tool_use_id = raw
205                    .get("tool_use_id")
206                    .and_then(Value::as_str)
207                    .ok_or_else(|| serde::de::Error::missing_field("tool_use_id"))?
208                    .to_string();
209                let agent_id = raw.get("agent_id").and_then(Value::as_str).map(str::to_string);
210                let decision_reason =
211                    raw.get("decision_reason").and_then(Value::as_str).map(str::to_string);
212                let title = raw.get("title").and_then(Value::as_str).map(str::to_string);
213                let display_name =
214                    raw.get("display_name").and_then(Value::as_str).map(str::to_string);
215                let description =
216                    raw.get("description").and_then(Value::as_str).map(str::to_string);
217                Ok(Self::CanUseTool {
218                    tool_name,
219                    input,
220                    permission_suggestions,
221                    blocked_path,
222                    tool_use_id,
223                    agent_id,
224                    decision_reason,
225                    title,
226                    display_name,
227                    description,
228                })
229            }
230            "mcp_message" => {
231                let server_name = raw
232                    .get("server_name")
233                    .and_then(Value::as_str)
234                    .ok_or_else(|| serde::de::Error::missing_field("server_name"))?
235                    .to_string();
236                let message = raw.get("message").cloned().unwrap_or(Value::Null);
237                Ok(Self::McpMessage { server_name, message })
238            }
239            "hook_callback" => {
240                let callback_id = raw
241                    .get("callback_id")
242                    .and_then(Value::as_str)
243                    .ok_or_else(|| serde::de::Error::missing_field("callback_id"))?
244                    .to_string();
245                let input = raw.get("input").cloned().unwrap_or(Value::Null);
246                let tool_use_id =
247                    raw.get("tool_use_id").and_then(Value::as_str).map(str::to_string);
248                Ok(Self::HookCallback { callback_id, input, tool_use_id })
249            }
250            // Forward-compat catch-all. NOTE: when adding a new known
251            // `ControlRequestKind` variant, add a matching arm above -
252            // a new variant without an arm here will silently land in
253            // `Unknown` instead of being recognised.
254            other => Ok(Self::Unknown { subtype: other.to_string(), raw }),
255        }
256    }
257}
258
259/// Control response from SDK back to the `claude` binary.
260#[derive(Debug, Clone, Serialize, Deserialize)]
261pub struct ControlResponse {
262    /// Fixed discriminant `"control_response"`.
263    #[serde(rename = "type", default = "default_control_response_type")]
264    pub ty: ControlResponseType,
265    /// The response payload.
266    pub response: ControlResponseKind,
267}
268
269fn default_control_response_type() -> ControlResponseType {
270    ControlResponseType::ControlResponse
271}
272
273/// The discriminant type for [`ControlResponse`]. Always `"control_response"`.
274#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
275#[serde(rename_all = "snake_case")]
276pub enum ControlResponseType {
277    /// The only current variant.
278    ControlResponse,
279}
280
281/// Response body, success or error.
282#[derive(Debug, Clone, Serialize, Deserialize)]
283#[serde(tag = "subtype", rename_all = "snake_case")]
284pub enum ControlResponseKind {
285    /// Successful response. `response` carries the control-specific payload.
286    Success {
287        /// Request id being answered.
288        request_id: String,
289        /// The nested payload (e.g. an [`AllowBehavior`] serialised to JSON).
290        response: Value,
291    },
292    /// Error response. `error` is human-readable.
293    Error {
294        /// Request id being answered.
295        request_id: String,
296        /// Error message.
297        error: String,
298    },
299}
300
301/// Serialisable shape for the `response` field inside a successful
302/// `can_use_tool` response.
303///
304/// Wraps the CLI's `PermissionResultAllow` / `PermissionResultDeny`.
305/// NOTE: wire keys are camelCase (`updatedInput`, `updatedPermissions`) -
306/// do not rename to `snake_case`.
307#[derive(Debug, Clone, Serialize, Deserialize)]
308#[serde(tag = "behavior", rename_all = "snake_case")]
309pub enum AllowBehavior {
310    /// Allow the call. `updated_input` is ALWAYS populated on the wire -
311    /// when the callback had no override, caller must echo the original
312    /// `input` into this field (the CLI always does).
313    Allow {
314        /// The input to invoke the tool with (possibly modified by the
315        /// callback). Serialised on the wire as `updatedInput`.
316        #[serde(rename = "updatedInput")]
317        updated_input: Value,
318        /// Optional permission-policy updates (advanced). Serialised as
319        /// `updatedPermissions`. Typically `None` in v0.1 usage.
320        #[serde(default, rename = "updatedPermissions", skip_serializing_if = "Option::is_none")]
321        updated_permissions: Option<Value>,
322    },
323    /// Deny the call with a user-visible message, optionally signalling
324    /// that the model should be interrupted rather than continue a turn.
325    Deny {
326        /// Feedback forwarded to the model.
327        message: String,
328        /// Interrupt flag. The CLI emits this only when truthy;
329        /// mirror that by skipping when
330        /// `false`.
331        #[serde(default, skip_serializing_if = "std::ops::Not::not")]
332        interrupt: bool,
333    },
334}
335
336impl ControlRequest {
337    /// Helper: build a `control_response` envelope corresponding to this
338    /// request given an allow/deny behaviour.
339    ///
340    /// # Errors
341    ///
342    /// [`serde_json::Error`] if the decision can't be serialised (practically
343    /// impossible for valid decisions).
344    pub(crate) fn build_response(
345        &self,
346        behavior: AllowBehavior,
347    ) -> Result<ControlResponse, serde_json::Error> {
348        let response_value = serde_json::to_value(behavior)?;
349        Ok(ControlResponse {
350            ty: ControlResponseType::ControlResponse,
351            response: ControlResponseKind::Success {
352                request_id: self.request_id.clone(),
353                response: response_value,
354            },
355        })
356    }
357
358    /// For a `CanUseTool` request, return a reference to the original
359    /// `input` JSON. Used to echo the original input back in an allow
360    /// response when the callback supplied no override (the CLI always
361    /// populates `updatedInput`).
362    pub(crate) fn original_tool_input(&self) -> Option<&Value> {
363        match &self.request {
364            ControlRequestKind::CanUseTool { input, .. } => Some(input),
365            _ => None,
366        }
367    }
368}
369
370#[cfg(test)]
371mod tests_control_types {
372    // Test-mod `use super::*;` brings the parent's full surface in; not every test consumes every item.
373    #[allow(unused_imports)]
374    use super::*;
375
376    use crate::control::{
377        AllowBehavior, ControlRequest, ControlRequestKind, ControlResponse, ControlResponseKind,
378        ControlResponseType,
379    };
380    use serde_json::json;
381
382    #[test]
383    fn parse_can_use_tool_request() {
384        let raw = json!({
385            "type": "control_request",
386            "request_id": "req_1_a1b2c3d4",
387            "request": {
388                "subtype": "can_use_tool",
389                "tool_name": "Edit",
390                "input": {"file_path": "/tmp/x"},
391                "permission_suggestions": [],
392                "blocked_path": null,
393                "tool_use_id": "toolu_01abc",
394                "agent_id": null
395            }
396        });
397        let req: ControlRequest = serde_json::from_value(raw).expect("parse");
398        assert_eq!(req.request_id, "req_1_a1b2c3d4");
399        match req.request {
400            ControlRequestKind::CanUseTool { tool_name, input, tool_use_id, .. } => {
401                assert_eq!(tool_name, "Edit");
402                assert_eq!(input["file_path"], "/tmp/x");
403                assert_eq!(tool_use_id, "toolu_01abc");
404            }
405            other => panic!("expected CanUseTool, got: {other:?}"),
406        }
407    }
408
409    #[test]
410    fn serialize_allow_response_uses_camelcase_updated_input() {
411        let allow = AllowBehavior::Allow {
412            updated_input: json!({"file_path": "/tmp/x"}),
413            updated_permissions: None,
414        };
415        let raw = serde_json::to_value(&allow).expect("ser");
416        assert_eq!(raw["behavior"], "allow");
417        // CRITICAL: wire is camelCase `updatedInput`, not snake_case.
418        assert_eq!(raw["updatedInput"]["file_path"], "/tmp/x");
419        assert!(raw.get("updated_input").is_none(), "must NOT serialise snake_case");
420    }
421
422    #[test]
423    fn serialize_deny_response_with_interrupt_true() {
424        let deny = AllowBehavior::Deny { message: "not allowed".into(), interrupt: true };
425        let raw = serde_json::to_value(&deny).expect("ser");
426        assert_eq!(raw["behavior"], "deny");
427        assert_eq!(raw["message"], "not allowed");
428        assert_eq!(raw["interrupt"], true);
429    }
430
431    #[test]
432    fn deny_with_interrupt_false_omits_field() {
433        let deny = AllowBehavior::Deny { message: "nope".into(), interrupt: false };
434        let raw = serde_json::to_value(&deny).expect("ser");
435        assert_eq!(raw["behavior"], "deny");
436        assert!(raw.get("interrupt").is_none(), "interrupt must be absent when false");
437    }
438
439    #[test]
440    fn full_control_response_envelope() {
441        let resp = ControlResponse {
442            ty: ControlResponseType::ControlResponse,
443            response: ControlResponseKind::Success {
444                request_id: "req_1_a1b2c3d4".into(),
445                response: serde_json::to_value(AllowBehavior::Allow {
446                    updated_input: json!({"file_path": "/tmp/x"}),
447                    updated_permissions: None,
448                })
449                .unwrap(),
450            },
451        };
452        let raw = serde_json::to_value(&resp).expect("serialize");
453        assert_eq!(raw["type"], "control_response");
454        assert_eq!(raw["response"]["subtype"], "success");
455        assert_eq!(raw["response"]["request_id"], "req_1_a1b2c3d4");
456        assert_eq!(raw["response"]["response"]["behavior"], "allow");
457        assert_eq!(raw["response"]["response"]["updatedInput"]["file_path"], "/tmp/x");
458    }
459
460    #[test]
461    fn deserialize_missing_subtype_lands_in_unknown_with_missing_sentinel() {
462        // A `control_request` without `subtype` is wire corruption.
463        // The Deserialize impl distinguishes this from forward-compat
464        // drift by using the `<missing>` sentinel - pinned here so a
465        // future refactor can't silently flip back to `unwrap_or("")`
466        // without breaking a test.
467        let raw = json!({
468            "type": "control_request",
469            "request_id": "r1",
470            "request": {"some": "shape"}
471        });
472        let req: ControlRequest = serde_json::from_value(raw).expect("parse");
473        match req.request {
474            ControlRequestKind::Unknown { subtype, .. } => {
475                assert_eq!(
476                    subtype, "<missing>",
477                    "missing-subtype must use the explicit sentinel, not empty string"
478                );
479            }
480            other => panic!("expected ControlRequestKind::Unknown, got: {other:?}"),
481        }
482    }
483
484    #[test]
485    fn deserialize_unrecognised_subtype_distinguishable_from_missing() {
486        // Pairs with the test above. A real-but-unknown subtype must
487        // round-trip its actual string into Unknown so debug logs +
488        // dispatcher branches can tell forward-compat drift from
489        // wire corruption.
490        let raw = json!({
491            "type": "control_request",
492            "request_id": "r1",
493            "request": {"subtype": "future_thing", "data": 1}
494        });
495        let req: ControlRequest = serde_json::from_value(raw).expect("parse");
496        match req.request {
497            ControlRequestKind::Unknown { subtype, .. } => {
498                assert_eq!(subtype, "future_thing");
499                assert_ne!(subtype, "<missing>");
500            }
501            other => panic!("expected ControlRequestKind::Unknown, got: {other:?}"),
502        }
503    }
504}