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