Skip to main content

agent_abstraction/
approval.rs

1//! Putting a human in the loop for tool calls the agent wants to make.
2//!
3//! Every [`crate::Permission`] posture answers the approval question up front,
4//! which is what lets a headless run finish unattended: a gated call is
5//! pre-approved or auto-denied and the model carries on. That is wrong for a
6//! desktop app, where the point is to *ask*.
7//!
8//! [`crate::Request::approvals`] switches a run to asking. Gated calls arrive as
9//! [`crate::Event::ApprovalRequest`] and the run waits, mid-turn, until
10//! [`crate::Run::respond`] answers. Deciding stays entirely with the caller;
11//! this crate carries the question out and the answer back.
12//!
13//! # Claude only
14//!
15//! Verified against claude 2.1.212. Codex `exec` has no approval callback: its
16//! sandbox mode *is* the answer, decided before the run starts. Copilot needs
17//! `--allow-all-tools` to run headlessly at all, and gates only through
18//! `--deny-tool`. Asking either for approvals is
19//! [`crate::Error::Unsupported`] rather than a run that quietly never asks.
20//!
21//! # A run that asks must be streamed
22//!
23//! [`crate::run`] waits for an outcome and hands back no events, so nobody could
24//! answer. Requesting approvals there is [`crate::Error::Unsupported`] too,
25//! raised before spawning rather than discovered as a hang.
26//!
27//! # What "gated" means is the agent's decision, not this crate's
28//!
29//! Claude decides which calls need asking, and read-only commands are allowed
30//! without one: verified on 2.1.212, `whoami` runs unasked while
31//! `touch some-file` asks. So a caller must not treat the absence of a request
32//! as proof that nothing ran.
33
34use serde::{Deserialize, Serialize};
35use serde_json::Value;
36
37/// A tool call the agent is waiting for permission to make.
38#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
39#[non_exhaustive]
40pub struct Approval {
41    /// The agent's own id for this question. Pass it back to
42    /// [`crate::Run::respond`]; an answer carrying any other id is ignored by
43    /// the agent, which then keeps waiting.
44    pub id: String,
45    /// The tool being asked about, e.g. `Bash` or `Write`.
46    pub tool: String,
47    /// The arguments it would be called with, exactly as the agent sent them.
48    ///
49    /// **Show this to the user before they decide.** For `Bash` it carries the
50    /// command; approving on the tool name alone approves an unseen command.
51    pub input: Value,
52}
53
54/// What to do about an [`Approval`].
55#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
56#[serde(tag = "decision", rename_all = "snake_case")]
57#[non_exhaustive]
58pub enum Decision {
59    /// Let the call proceed as asked.
60    Allow,
61    /// Refuse it. The turn continues: the model is told no and works around it,
62    /// so a denial is not a failed run. Claude also lists every refusal in its
63    /// terminal record.
64    Deny {
65        /// Shown to the model as the reason. Worth writing something useful,
66        /// since the model reads it and may explain the refusal to the user.
67        reason: String,
68    },
69}
70
71impl Decision {
72    /// Refuse with a stock reason, for a caller that has nothing to add.
73    #[must_use]
74    pub fn deny() -> Decision {
75        Decision::Deny {
76            reason: "the user declined this action".into(),
77        }
78    }
79
80    /// The wire form Claude expects on stdin.
81    ///
82    /// Verified against claude 2.1.212, which answers a `can_use_tool` control
83    /// request with a `control_response` carrying `behavior` of `allow` or
84    /// `deny`.
85    pub(crate) fn wire(&self, id: &str) -> String {
86        let response = match self {
87            Decision::Allow => serde_json::json!({"behavior": "allow"}),
88            Decision::Deny { reason } => {
89                serde_json::json!({"behavior": "deny", "message": reason})
90            }
91        };
92        format!(
93            "{}\n",
94            serde_json::json!({
95                "type": "control_response",
96                "response": {
97                    "request_id": id,
98                    "subtype": "success",
99                    "response": response,
100                },
101            })
102        )
103    }
104}
105
106/// The handshake that tells Claude this client will answer approval questions.
107///
108/// Without it the `can_use_tool` requests never arrive and gated calls resolve
109/// on their own, so a caller would see a run that silently never asked.
110/// Verified against claude 2.1.212.
111pub(crate) fn handshake() -> String {
112    format!(
113        "{}\n",
114        serde_json::json!({
115            "type": "control_request",
116            "request_id": "agent-abstraction-init",
117            "request": {"subtype": "initialize"},
118        })
119    )
120}
121
122/// Wrap a prompt as the stream-json user message Claude reads from stdin.
123///
124/// Under `--input-format stream-json` the prompt cannot ride the argv, so this
125/// is how it is delivered. Verified against claude 2.1.212.
126pub(crate) fn user_message(prompt: &str) -> String {
127    format!(
128        "{}\n",
129        serde_json::json!({
130            "type": "user",
131            "message": {"role": "user", "content": [{"type": "text", "text": prompt}]},
132        })
133    )
134}
135
136#[cfg(test)]
137mod tests {
138    use super::*;
139
140    #[test]
141    fn a_decision_serializes_to_the_shape_claude_answers() {
142        let allow: Value =
143            serde_json::from_str(Decision::Allow.wire("req-1").trim()).expect("json");
144        assert_eq!(allow["type"], "control_response");
145        assert_eq!(allow["response"]["request_id"], "req-1");
146        assert_eq!(allow["response"]["subtype"], "success");
147        assert_eq!(allow["response"]["response"]["behavior"], "allow");
148
149        let deny: Value =
150            serde_json::from_str(Decision::deny().wire("req-2").trim()).expect("json");
151        assert_eq!(deny["response"]["response"]["behavior"], "deny");
152        assert!(
153            deny["response"]["response"]["message"]
154                .as_str()
155                .is_some_and(|m| !m.is_empty()),
156            "a denial should carry a reason the model can read"
157        );
158    }
159
160    /// A reason with a quote or a newline must not break the line-delimited
161    /// protocol, which is why this is built with serde rather than formatted.
162    #[test]
163    fn an_awkward_reason_stays_one_json_line() {
164        let decision = Decision::Deny {
165            reason: "no \"rm -rf\" here\nand no newlines either".into(),
166        };
167        let wire = decision.wire("req-3");
168        assert_eq!(
169            wire.matches('\n').count(),
170            1,
171            "exactly one trailing newline"
172        );
173        let parsed: Value = serde_json::from_str(wire.trim()).expect("still valid json");
174        assert!(
175            parsed["response"]["response"]["message"]
176                .as_str()
177                .expect("message")
178                .contains("rm -rf"),
179            "the reason survives intact"
180        );
181    }
182
183    #[test]
184    fn a_prompt_with_control_characters_survives_the_wrapper() {
185        let wire = user_message("say \"ok\"\nthen stop");
186        assert_eq!(wire.matches('\n').count(), 1);
187        let parsed: Value = serde_json::from_str(wire.trim()).expect("json");
188        assert_eq!(parsed["type"], "user");
189        assert_eq!(
190            parsed["message"]["content"][0]["text"],
191            "say \"ok\"\nthen stop"
192        );
193    }
194
195    #[test]
196    fn the_handshake_is_one_valid_line() {
197        let wire = handshake();
198        assert_eq!(wire.matches('\n').count(), 1);
199        let parsed: Value = serde_json::from_str(wire.trim()).expect("json");
200        assert_eq!(parsed["type"], "control_request");
201        assert_eq!(parsed["request"]["subtype"], "initialize");
202    }
203}