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;