Skip to main content

defect_agent/hooks/
command.rs

1//! Command hook handler — feeds the step envelope JSON to an external subprocess.
2//! The IO protocol passes stdout through as verdict JSON.
3//!
4//! ## Shape
5//!
6//! - [`CommandSpec`]: handler configuration — either direct argv spawn or explicit shell.
7//! - [`CommandHandler`]: implements [`StepHandler`]; spawn / kill_on_drop / timeout
8//!   follow the documented semantics.
9//!
10//! No shell dependency: direct argv spawn is the default; only the explicit `shell` field
11//! uses a shell.
12//!
13//! Platform fallback: on `cfg(unix)` and `cfg(windows)`, spawns the child process via
14//! `tokio::process::Command`.
15
16use std::collections::BTreeMap;
17use std::path::PathBuf;
18use std::process::Stdio;
19use std::time::Duration;
20
21use futures::future::BoxFuture;
22use serde_json::Value;
23use tokio::io::AsyncWriteExt;
24use tokio::process::Command;
25
26use crate::error::BoxError;
27
28use super::{HookCtx, HookError, StepHandler};
29
30// ---------------------------------------------------------------------------
31// Spec
32// ---------------------------------------------------------------------------
33
34/// Configuration for a command handler.
35///
36/// See module-level docs.
37///
38/// Conceptually equivalent to `defect_config::HookCommandSpec`, but lives in the agent
39/// crate. During CLI assembly, the config shape is translated into this form — the agent
40/// crate does not depend on the config crate.
41#[derive(Debug, Clone, PartialEq, Eq)]
42pub enum CommandSpec {
43    /// Spawn `argv` directly, without any shell.
44    Argv {
45        argv: Vec<String>,
46        /// Windows override; `None` falls back to `argv`.
47        argv_windows: Option<Vec<String>>,
48        cwd: Option<PathBuf>,
49        env: BTreeMap<String, String>,
50        timeout_sec: Option<u64>,
51    },
52    /// Explicit shell. The engine no longer auto-selects `sh`; an invalid shell kind is
53    /// reported as a configuration error.
54    Shell {
55        shell: ShellKind,
56        command: String,
57        cwd: Option<PathBuf>,
58        env: BTreeMap<String, String>,
59        timeout_sec: Option<u64>,
60    },
61}
62
63/// Explicit shell kind. The engine uses this tag to select the executable and its flag.
64#[non_exhaustive]
65#[derive(Debug, Clone, PartialEq, Eq)]
66pub enum ShellKind {
67    /// `sh -c <command>`.
68    Sh,
69    /// `bash -c <command>`.
70    Bash,
71    /// `pwsh -NoProfile -NonInteractive -Command <command>`.
72    Pwsh,
73    /// `cmd /C <command>`.
74    Cmd,
75    /// A user-supplied program with passthrough args (excluding the command itself).
76    Custom { program: String, args: Vec<String> },
77}
78
79impl CommandSpec {
80    fn timeout(&self) -> Option<Duration> {
81        let secs = match self {
82            Self::Argv { timeout_sec, .. } | Self::Shell { timeout_sec, .. } => *timeout_sec,
83        };
84        secs.map(Duration::from_secs)
85    }
86}
87
88// ---------------------------------------------------------------------------
89// Handler
90// ---------------------------------------------------------------------------
91
92/// `Command` handler implementation.
93///
94/// IO protocol:
95/// - stdin = JSON serialization of the step envelope, one line
96/// - stdout = verdict JSON object (empty = no intervention), passed through to the engine
97///   as-is
98/// - stderr = forwarded to tracing
99/// - exit 0 = determined by stdout; non-zero = `HookError::HandlerFailed`
100pub struct CommandHandler {
101    spec: CommandSpec,
102}
103
104impl CommandHandler {
105    #[must_use]
106    pub fn new(spec: CommandSpec) -> Self {
107        Self { spec }
108    }
109
110    /// The timeout configured on this handler. The CLI assembly forwards it into
111    /// [`StepHandlerEntry::with_timeout`](super::StepHandlerEntry::with_timeout); the
112    /// engine applies its own default fallback when this is `None`.
113    #[must_use]
114    pub fn timeout(&self) -> Option<Duration> {
115        self.spec.timeout()
116    }
117}
118
119impl StepHandler for CommandHandler {
120    /// Feeds the step envelope as JSON to the child process's stdin; stdout is the
121    /// verdict JSON (empty stdout means no intervention).
122    ///
123    /// Simpler than the old `handle` — the envelope is already a `Value`, so no
124    /// `CommandEventEnvelope` conversion is needed. stdout is passed directly as the
125    /// verdict to the engine's `apply_verdict`, and the IO protocol is reduced from
126    /// "parse into `HookOutcome`" to "pass JSON through as-is".
127    fn handle_step<'a>(
128        &'a self,
129        envelope: &'a Value,
130        ctx: HookCtx<'a>,
131    ) -> BoxFuture<'a, Result<Option<Value>, HookError>> {
132        Box::pin(async move {
133            let stdin_payload = serde_json::to_vec(envelope).map_err(|err| {
134                HookError::HandlerFailed(BoxError::new(io_invalid("serialize step envelope", err)))
135            })?;
136
137            let env_vars = step_env_vars(envelope, &ctx);
138            let mut cmd = build_command(&self.spec, &env_vars)?;
139            cmd.stdin(Stdio::piped())
140                .stdout(Stdio::piped())
141                .stderr(Stdio::piped())
142                .kill_on_drop(true);
143
144            let mut child = cmd
145                .spawn()
146                .map_err(|err| HookError::HandlerFailed(BoxError::new(err)))?;
147
148            if let Some(mut stdin) = child.stdin.take() {
149                // Writing to stdin may race with the child process exiting before reading
150                // it (e.g. a script like `exit 2`). In that case the pipe is closed by
151                // the peer and `write` returns `BrokenPipe`. This is legitimate: the
152                // script is allowed to ignore stdin; its exit code is the output. Treat
153                // `BrokenPipe` as "done feeding" and silently continue, letting the exit
154                // code decide the outcome. Other write errors are considered handler
155                // failures.
156                let write_res = async {
157                    stdin.write_all(&stdin_payload).await?;
158                    stdin.write_all(b"\n").await
159                }
160                .await;
161                match write_res {
162                    Ok(()) => {}
163                    Err(err) if err.kind() == std::io::ErrorKind::BrokenPipe => {}
164                    Err(err) => return Err(HookError::HandlerFailed(BoxError::new(err))),
165                }
166                drop(stdin);
167            }
168
169            let cancel = ctx.cancel.clone();
170            let output = tokio::select! {
171                () = cancel.cancelled() => return Err(HookError::Timeout),
172                result = child.wait_with_output() => {
173                    result.map_err(|err| HookError::HandlerFailed(BoxError::new(err)))?
174                }
175            };
176
177            let stderr_text = String::from_utf8_lossy(&output.stderr).into_owned();
178            if !stderr_text.is_empty() {
179                tracing::debug!(target: "defect_agent::hooks::command", stderr = %stderr_text, "command stderr");
180            }
181
182            // Exit code convention (aligned with Claude exit code 2):
183            // - 0 → decision based on stdout (empty or non-JSON stdout = no intervention)
184            // - 2 → veto this step (exact semantics interpreted by the step's
185            //   `apply_verdict`: turn-end → continue,
186            //         tool/turn/session → break, compact → skip); stderr is injected as
187            //         feedback
188            // - other non-zero / signal → handler error (engine degrades and skips)
189            match output.status.code() {
190                Some(0) => {
191                    let trimmed = output.stdout.trim_ascii();
192                    if trimmed.is_empty() {
193                        return Ok(None);
194                    }
195                    match serde_json::from_slice::<Value>(trimmed) {
196                        Ok(v) => Ok(Some(v)),
197                        Err(_) => Ok(None),
198                    }
199                }
200                Some(2) => {
201                    let mut obj = serde_json::Map::new();
202                    obj.insert("control".to_string(), Value::String("veto".to_string()));
203                    if !stderr_text.is_empty() {
204                        obj.insert(
205                            "additional_context".to_string(),
206                            Value::Array(vec![Value::String(stderr_text)]),
207                        );
208                    }
209                    Ok(Some(Value::Object(obj)))
210                }
211                Some(c) => Err(HookError::HandlerFailed(BoxError::new(io_invalid(
212                    format!("hook command exited with status {c}"),
213                    "",
214                )))),
215                None => Err(HookError::HandlerFailed(BoxError::new(io_invalid(
216                    "hook command terminated by signal",
217                    "",
218                )))),
219            }
220        })
221    }
222}
223
224// Command construction
225
226fn build_command(
227    spec: &CommandSpec,
228    env_vars: &BTreeMap<String, String>,
229) -> Result<Command, HookError> {
230    match spec {
231        CommandSpec::Argv {
232            argv,
233            argv_windows,
234            cwd,
235            env,
236            ..
237        } => {
238            let chosen = if cfg!(target_os = "windows") {
239                argv_windows.as_ref().unwrap_or(argv)
240            } else {
241                argv
242            };
243            let (program, args) = chosen.split_first().ok_or_else(|| {
244                HookError::Configuration("command handler `argv` must not be empty".into())
245            })?;
246            let mut cmd = Command::new(program);
247            cmd.args(args);
248            if let Some(dir) = cwd {
249                cmd.current_dir(dir);
250            }
251            for (k, v) in env_vars {
252                cmd.env(k, v);
253            }
254            for (k, v) in env {
255                cmd.env(k, v);
256            }
257            Ok(cmd)
258        }
259        CommandSpec::Shell {
260            shell,
261            command,
262            cwd,
263            env,
264            ..
265        } => {
266            let mut cmd = build_shell_command(shell, command);
267            if let Some(dir) = cwd {
268                cmd.current_dir(dir);
269            }
270            for (k, v) in env_vars {
271                cmd.env(k, v);
272            }
273            for (k, v) in env {
274                cmd.env(k, v);
275            }
276            Ok(cmd)
277        }
278    }
279}
280
281fn build_shell_command(shell: &ShellKind, command: &str) -> Command {
282    match shell {
283        ShellKind::Sh => {
284            let mut c = Command::new("sh");
285            c.arg("-c").arg(command);
286            c
287        }
288        ShellKind::Bash => {
289            let mut c = Command::new("bash");
290            c.arg("-c").arg(command);
291            c
292        }
293        ShellKind::Pwsh => {
294            let mut c = Command::new("pwsh");
295            c.arg("-NoProfile")
296                .arg("-NonInteractive")
297                .arg("-Command")
298                .arg(command);
299            c
300        }
301        ShellKind::Cmd => {
302            let mut c = Command::new("cmd");
303            c.arg("/C").arg(command);
304            c
305        }
306        ShellKind::Custom { program, args } => {
307            let mut c = Command::new(program);
308            c.args(args).arg(command);
309            c
310        }
311    }
312}
313
314/// Environment variables for the step model: common headers plus the tool name extracted
315/// from the envelope (if any). Script authors can read both env and stdin JSON.
316fn step_env_vars(envelope: &Value, ctx: &HookCtx<'_>) -> BTreeMap<String, String> {
317    let mut out = BTreeMap::new();
318    out.insert(
319        "DEFECT_SESSION_ID".to_string(),
320        ctx.session_id.0.to_string(),
321    );
322    out.insert(
323        "DEFECT_CWD".to_string(),
324        ctx.cwd.to_string_lossy().into_owned(),
325    );
326    if let Some(tool) = envelope.get("tool").and_then(Value::as_str) {
327        out.insert("DEFECT_TOOL_NAME".to_string(), tool.to_string());
328    }
329    out
330}
331
332// Helpers
333
334fn io_invalid(msg: impl Into<String>, detail: impl std::fmt::Display) -> std::io::Error {
335    let s = msg.into();
336    let body = if s.is_empty() {
337        detail.to_string()
338    } else if format!("{detail}").is_empty() {
339        s
340    } else {
341        format!("{s}: {detail}")
342    };
343    std::io::Error::new(std::io::ErrorKind::InvalidData, body)
344}
345
346#[cfg(test)]
347mod tests;