Skip to main content

git_paw/supervisor/
approve.rs

1//! Auto-approval keystroke dispatch.
2//!
3//! Implements the `automatic-approval` capability of the
4//! `auto-approve-patterns` change: when the supervisor poll loop classifies
5//! a stalled agent's prompt as safe, this module sends the
6//! `BTab Down Enter` sequence to the pane via three separate
7//! `tmux send-keys` invocations and publishes an `agent.status` audit log
8//! entry to the broker before the keystrokes go out.
9//!
10//! The send-keys invoker is abstracted through [`KeyDispatcher`] so unit
11//! tests can record argument vectors without spawning tmux.
12
13use std::process::Command;
14
15use crate::broker::publish::{build_status_message, publish_to_broker_http};
16use crate::error::PawError;
17use crate::supervisor::permission_prompt::PermissionType;
18
19/// Keys (in tmux notation) sent to a pane to approve and remember a
20/// permission prompt.
21///
22/// `BTab` focuses the prompt's "Yes, don't ask again" choice,
23/// `Down` selects it, and `Enter` submits.
24pub const APPROVAL_KEYS: &[&str] = &["BTab", "Down", "Enter"];
25
26/// Abstraction over `tmux send-keys` so [`auto_approve_pane`] can be tested
27/// without spawning tmux.
28pub trait KeyDispatcher {
29    /// Sends a single key (in tmux key-name notation) to the given pane.
30    ///
31    /// Returns the dispatch result; failures are surfaced to the caller so
32    /// it can decide whether to log or abort.
33    fn send_key(&mut self, session: &str, pane_index: usize, key: &str) -> std::io::Result<()>;
34}
35
36/// Production [`KeyDispatcher`] that shells out to `tmux send-keys`.
37pub struct TmuxKeyDispatcher;
38
39impl KeyDispatcher for TmuxKeyDispatcher {
40    fn send_key(&mut self, session: &str, pane_index: usize, key: &str) -> std::io::Result<()> {
41        let target = format!("{session}:0.{pane_index}");
42        let status = Command::new("tmux")
43            .args(["send-keys", "-t", &target, key])
44            .status()?;
45        if !status.success() {
46            return Err(std::io::Error::other(format!(
47                "tmux send-keys exited with {status}"
48            )));
49        }
50        Ok(())
51    }
52}
53
54/// Inputs for [`auto_approve_pane`].
55///
56/// Bundled into a struct so the API has a single grow point as the spec
57/// adds new fields (e.g. an optional reason string).
58#[derive(Debug, Clone, Copy)]
59pub struct ApprovalRequest<'a> {
60    /// Whether `[supervisor.auto_approve] enabled` is `true`.
61    pub enabled: bool,
62    /// tmux session name (e.g. `"paw-myproject"`).
63    pub session: &'a str,
64    /// Pane index inside `session:0.<idx>` to receive the keystrokes.
65    pub pane_index: usize,
66    /// Agent ID for the audit log entry (slugified branch name).
67    pub agent_id: &'a str,
68    /// Classification of the detected prompt.
69    pub kind: PermissionType,
70    /// Whitelist entry that matched the captured command, included in the
71    /// audit log when `Some`.
72    pub matched_entry: Option<&'a str>,
73    /// Broker URL for the audit log message; `None` skips logging.
74    pub broker_url: Option<&'a str>,
75}
76
77/// Dispatches the auto-approval sequence and publishes an audit log entry.
78///
79/// Returns `Ok(true)` when the keystrokes were dispatched, `Ok(false)`
80/// when the no-op rules apply (auto-approval disabled or class is
81/// `Unknown`).
82///
83/// Order of operations is fixed:
84///
85/// 1. If `req.enabled == false` or `req.kind == Unknown`, return `Ok(false)`.
86/// 2. Publish an `agent.status` message tagged `auto_approved` to the
87///    broker. Failures are non-fatal — see the spec rationale.
88/// 3. Send `BTab`, `Down`, `Enter` as three separate `send-keys` calls.
89///
90/// The `matched_entry` field is included in the audit log so the human
91/// can see which whitelist entry triggered the approval.
92pub fn auto_approve_pane<D: KeyDispatcher>(
93    dispatcher: &mut D,
94    req: ApprovalRequest<'_>,
95) -> Result<bool, PawError> {
96    if !req.enabled || req.kind == PermissionType::Unknown {
97        return Ok(false);
98    }
99
100    // Publish the audit log BEFORE sending keystrokes so a crash mid-action
101    // still leaves a trail.
102    if let Some(url) = req.broker_url {
103        let summary = req.matched_entry.map_or_else(
104            || "auto_approved".to_string(),
105            |e| format!("auto_approved: matched {e}"),
106        );
107        let msg = build_status_message(req.agent_id, "auto_approved", Some(summary), None);
108        if let Err(e) = publish_to_broker_http(url, &msg) {
109            eprintln!(
110                "warning: failed to publish auto-approve status for {}: {e}",
111                req.agent_id
112            );
113        }
114    }
115
116    for key in APPROVAL_KEYS {
117        dispatcher
118            .send_key(req.session, req.pane_index, key)
119            .map_err(|e| PawError::TmuxError(format!("send-keys {key} failed: {e}")))?;
120    }
121    Ok(true)
122}
123
124#[cfg(test)]
125mod tests {
126    use super::*;
127
128    /// Recording dispatcher that captures every (session, pane, key) tuple
129    /// instead of touching tmux.
130    struct Recorder {
131        events: Vec<(String, usize, String)>,
132    }
133
134    impl Recorder {
135        fn new() -> Self {
136            Self { events: Vec::new() }
137        }
138    }
139
140    impl KeyDispatcher for Recorder {
141        fn send_key(&mut self, session: &str, pane_index: usize, key: &str) -> std::io::Result<()> {
142            self.events
143                .push((session.to_string(), pane_index, key.to_string()));
144            Ok(())
145        }
146    }
147
148    fn req(
149        enabled: bool,
150        kind: PermissionType,
151        matched_entry: Option<&str>,
152    ) -> ApprovalRequest<'_> {
153        ApprovalRequest {
154            enabled,
155            session: "paw-test",
156            pane_index: 2,
157            agent_id: "feat-foo",
158            kind,
159            matched_entry,
160            broker_url: None,
161        }
162    }
163
164    #[test]
165    fn safe_prompt_dispatches_btab_down_enter_in_order() {
166        let mut rec = Recorder::new();
167        let fired = auto_approve_pane(
168            &mut rec,
169            req(true, PermissionType::Cargo, Some("cargo test")),
170        )
171        .unwrap();
172        assert!(fired, "should fire when enabled and class is safe");
173        let keys: Vec<&str> = rec.events.iter().map(|(_, _, k)| k.as_str()).collect();
174        assert_eq!(keys, vec!["BTab", "Down", "Enter"]);
175        for (s, p, _) in &rec.events {
176            assert_eq!(s, "paw-test");
177            assert_eq!(*p, 2);
178        }
179    }
180
181    #[test]
182    fn each_key_dispatched_separately() {
183        let mut rec = Recorder::new();
184        auto_approve_pane(&mut rec, req(true, PermissionType::Curl, None)).unwrap();
185        // Three distinct invocations (no concatenated string).
186        assert_eq!(rec.events.len(), 3);
187    }
188
189    #[test]
190    fn disabled_config_is_noop() {
191        let mut rec = Recorder::new();
192        let fired = auto_approve_pane(
193            &mut rec,
194            req(false, PermissionType::Cargo, Some("cargo test")),
195        )
196        .unwrap();
197        assert!(!fired);
198        assert!(rec.events.is_empty(), "disabled => no keystrokes");
199    }
200
201    #[test]
202    fn unknown_class_is_noop() {
203        let mut rec = Recorder::new();
204        let fired = auto_approve_pane(&mut rec, req(true, PermissionType::Unknown, None)).unwrap();
205        assert!(!fired);
206        assert!(rec.events.is_empty(), "Unknown => no keystrokes");
207    }
208
209    #[test]
210    fn approval_keys_constant_matches_spec() {
211        // Spec scenario: "Default Claude approval sequence" requires
212        // BTab Down Enter in order, sent via tmux send-keys.
213        assert_eq!(APPROVAL_KEYS, &["BTab", "Down", "Enter"]);
214    }
215
216    /// Spec scenario `auto-approve-patterns/automatic-approval` —
217    /// "Approval emits broker message": when `auto_approve_pane` fires for
218    /// a safe-class prompt, an `agent.status` message tagged
219    /// `auto_approved` MUST be published to the broker BEFORE any
220    /// `send-keys` keystrokes are dispatched. This test stands up a
221    /// localhost TCP listener that accepts the broker's `/publish` request,
222    /// records the receive time relative to the recorded keystroke times,
223    /// and asserts the audit log entry preceded every key.
224    #[test]
225    #[allow(clippy::items_after_statements)]
226    fn broker_audit_message_published_before_keystrokes() {
227        use std::io::{Read, Write};
228        use std::net::TcpListener;
229        use std::sync::{Arc, Mutex};
230        use std::time::Instant;
231
232        // Shared timeline: each event records a label and the moment it
233        // was observed. The test asserts the broker event comes first.
234        #[derive(Debug)]
235        #[allow(dead_code)] // Fields are read via Debug formatting in failure messages.
236        enum Event {
237            Published(String),
238            Key(String),
239        }
240
241        // Recording dispatcher that timestamps each key as it arrives.
242        struct TimedRecorder {
243            timeline: Arc<Mutex<Vec<(Instant, Event)>>>,
244        }
245        impl KeyDispatcher for TimedRecorder {
246            fn send_key(
247                &mut self,
248                _session: &str,
249                _pane_index: usize,
250                key: &str,
251            ) -> std::io::Result<()> {
252                self.timeline
253                    .lock()
254                    .unwrap()
255                    .push((Instant::now(), Event::Key(key.to_string())));
256                Ok(())
257            }
258        }
259
260        // Bind a real listener on an ephemeral port so we can drive
261        // publish_to_broker_http end-to-end without depending on the
262        // production broker.
263        let listener = TcpListener::bind("127.0.0.1:0").expect("bind");
264        let addr = listener.local_addr().expect("local_addr");
265
266        let timeline: Arc<Mutex<Vec<(Instant, Event)>>> = Arc::new(Mutex::new(Vec::new()));
267
268        // Spawn a thread that accepts a single connection, reads the HTTP
269        // request, replies 202, and pushes a Published event onto the
270        // timeline keyed on the moment the body was received.
271        let server_timeline = Arc::clone(&timeline);
272        let server = std::thread::spawn(move || {
273            let (mut stream, _) = listener.accept().expect("accept");
274            let mut buf = [0u8; 4096];
275            // We only need enough bytes to confirm the body arrived.
276            let n = stream.read(&mut buf).unwrap_or(0);
277            let request = String::from_utf8_lossy(&buf[..n]).to_string();
278            server_timeline
279                .lock()
280                .unwrap()
281                .push((Instant::now(), Event::Published(request.clone())));
282            let _ = stream.write_all(b"HTTP/1.1 202 Accepted\r\nContent-Length: 0\r\n\r\n");
283            let _ = stream.flush();
284            request
285        });
286
287        let mut dispatcher = TimedRecorder {
288            timeline: Arc::clone(&timeline),
289        };
290        let broker_url = format!("http://{addr}");
291        let req = ApprovalRequest {
292            enabled: true,
293            session: "paw-test",
294            pane_index: 0,
295            agent_id: "feat-foo",
296            kind: PermissionType::Cargo,
297            matched_entry: Some("cargo test"),
298            broker_url: Some(&broker_url),
299        };
300        let fired = auto_approve_pane(&mut dispatcher, req).expect("auto_approve_pane");
301        assert!(fired, "safe class with enabled=true must fire");
302
303        // Wait for the server thread so the Published event is recorded.
304        let request = server.join().expect("server thread");
305
306        // The broker received an HTTP POST whose body identifies an
307        // auto_approved status for the agent.
308        assert!(
309            request.contains("POST /publish"),
310            "expected a /publish request, got: {request}"
311        );
312        assert!(
313            request.contains("auto_approved"),
314            "expected auto_approved tag in body, got: {request}"
315        );
316        assert!(
317            request.contains("feat-foo"),
318            "expected agent_id in body, got: {request}"
319        );
320
321        // The Published event must precede every Key event in the timeline.
322        let events = timeline.lock().unwrap();
323        let publish_idx = events
324            .iter()
325            .position(|(_, e)| matches!(e, Event::Published(_)))
326            .expect("publish event recorded");
327        let first_key_idx = events
328            .iter()
329            .position(|(_, e)| matches!(e, Event::Key(_)))
330            .expect("key event recorded");
331        assert!(
332            publish_idx < first_key_idx,
333            "audit message must be published BEFORE keystrokes; timeline: {events:?}"
334        );
335
336        // Sanity: all three keys were dispatched in order.
337        let keys: Vec<String> = events
338            .iter()
339            .filter_map(|(_, e)| match e {
340                Event::Key(k) => Some(k.clone()),
341                Event::Published(_) => None,
342            })
343            .collect();
344        assert_eq!(keys, vec!["BTab", "Down", "Enter"]);
345    }
346}