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}