hooks.rs 25 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853
  1. use std::ffi::OsStr;
  2. use std::io::Write;
  3. use std::process::{Command, Stdio};
  4. use std::sync::{
  5. atomic::{AtomicBool, Ordering},
  6. Arc,
  7. };
  8. use std::thread;
  9. use std::time::Duration;
  10. use serde_json::{json, Value};
  11. use crate::config::{RuntimeFeatureConfig, RuntimeHookConfig};
  12. use crate::permissions::PermissionOverride;
  13. pub type HookPermissionDecision = PermissionOverride;
  14. #[derive(Debug, Clone, Copy, PartialEq, Eq)]
  15. pub enum HookEvent {
  16. PreToolUse,
  17. PostToolUse,
  18. PostToolUseFailure,
  19. }
  20. impl HookEvent {
  21. #[must_use]
  22. pub fn as_str(self) -> &'static str {
  23. match self {
  24. Self::PreToolUse => "PreToolUse",
  25. Self::PostToolUse => "PostToolUse",
  26. Self::PostToolUseFailure => "PostToolUseFailure",
  27. }
  28. }
  29. }
  30. #[derive(Debug, Clone, PartialEq, Eq)]
  31. pub enum HookProgressEvent {
  32. Started {
  33. event: HookEvent,
  34. tool_name: String,
  35. command: String,
  36. },
  37. Completed {
  38. event: HookEvent,
  39. tool_name: String,
  40. command: String,
  41. },
  42. Cancelled {
  43. event: HookEvent,
  44. tool_name: String,
  45. command: String,
  46. },
  47. }
  48. pub trait HookProgressReporter {
  49. fn on_event(&mut self, event: &HookProgressEvent);
  50. }
  51. #[derive(Debug, Clone, Default)]
  52. pub struct HookAbortSignal {
  53. aborted: Arc<AtomicBool>,
  54. }
  55. impl HookAbortSignal {
  56. #[must_use]
  57. pub fn new() -> Self {
  58. Self::default()
  59. }
  60. pub fn abort(&self) {
  61. self.aborted.store(true, Ordering::SeqCst);
  62. }
  63. #[must_use]
  64. pub fn is_aborted(&self) -> bool {
  65. self.aborted.load(Ordering::SeqCst)
  66. }
  67. }
  68. #[derive(Debug, Clone, PartialEq, Eq)]
  69. pub struct HookRunResult {
  70. denied: bool,
  71. cancelled: bool,
  72. messages: Vec<String>,
  73. permission_override: Option<PermissionOverride>,
  74. permission_reason: Option<String>,
  75. updated_input: Option<String>,
  76. }
  77. impl HookRunResult {
  78. #[must_use]
  79. pub fn allow(messages: Vec<String>) -> Self {
  80. Self {
  81. denied: false,
  82. cancelled: false,
  83. messages,
  84. permission_override: None,
  85. permission_reason: None,
  86. updated_input: None,
  87. }
  88. }
  89. #[must_use]
  90. pub fn is_denied(&self) -> bool {
  91. self.denied
  92. }
  93. #[must_use]
  94. pub fn is_cancelled(&self) -> bool {
  95. self.cancelled
  96. }
  97. #[must_use]
  98. pub fn messages(&self) -> &[String] {
  99. &self.messages
  100. }
  101. #[must_use]
  102. pub fn permission_override(&self) -> Option<PermissionOverride> {
  103. self.permission_override
  104. }
  105. #[must_use]
  106. pub fn permission_decision(&self) -> Option<HookPermissionDecision> {
  107. self.permission_override
  108. }
  109. #[must_use]
  110. pub fn permission_reason(&self) -> Option<&str> {
  111. self.permission_reason.as_deref()
  112. }
  113. #[must_use]
  114. pub fn updated_input(&self) -> Option<&str> {
  115. self.updated_input.as_deref()
  116. }
  117. #[must_use]
  118. pub fn updated_input_json(&self) -> Option<&str> {
  119. self.updated_input()
  120. }
  121. }
  122. #[derive(Debug, Clone, PartialEq, Eq, Default)]
  123. pub struct HookRunner {
  124. config: RuntimeHookConfig,
  125. }
  126. impl HookRunner {
  127. #[must_use]
  128. pub fn new(config: RuntimeHookConfig) -> Self {
  129. Self { config }
  130. }
  131. #[must_use]
  132. pub fn from_feature_config(feature_config: &RuntimeFeatureConfig) -> Self {
  133. Self::new(feature_config.hooks().clone())
  134. }
  135. #[must_use]
  136. pub fn run_pre_tool_use(&self, tool_name: &str, tool_input: &str) -> HookRunResult {
  137. self.run_pre_tool_use_with_context(tool_name, tool_input, None, None)
  138. }
  139. #[must_use]
  140. pub fn run_pre_tool_use_with_context(
  141. &self,
  142. tool_name: &str,
  143. tool_input: &str,
  144. abort_signal: Option<&HookAbortSignal>,
  145. reporter: Option<&mut dyn HookProgressReporter>,
  146. ) -> HookRunResult {
  147. Self::run_commands(
  148. HookEvent::PreToolUse,
  149. self.config.pre_tool_use(),
  150. tool_name,
  151. tool_input,
  152. None,
  153. false,
  154. abort_signal,
  155. reporter,
  156. )
  157. }
  158. #[must_use]
  159. pub fn run_pre_tool_use_with_signal(
  160. &self,
  161. tool_name: &str,
  162. tool_input: &str,
  163. abort_signal: Option<&HookAbortSignal>,
  164. ) -> HookRunResult {
  165. self.run_pre_tool_use_with_context(tool_name, tool_input, abort_signal, None)
  166. }
  167. #[must_use]
  168. pub fn run_post_tool_use(
  169. &self,
  170. tool_name: &str,
  171. tool_input: &str,
  172. tool_output: &str,
  173. is_error: bool,
  174. ) -> HookRunResult {
  175. self.run_post_tool_use_with_context(
  176. tool_name,
  177. tool_input,
  178. tool_output,
  179. is_error,
  180. None,
  181. None,
  182. )
  183. }
  184. #[must_use]
  185. pub fn run_post_tool_use_with_context(
  186. &self,
  187. tool_name: &str,
  188. tool_input: &str,
  189. tool_output: &str,
  190. is_error: bool,
  191. abort_signal: Option<&HookAbortSignal>,
  192. reporter: Option<&mut dyn HookProgressReporter>,
  193. ) -> HookRunResult {
  194. Self::run_commands(
  195. HookEvent::PostToolUse,
  196. self.config.post_tool_use(),
  197. tool_name,
  198. tool_input,
  199. Some(tool_output),
  200. is_error,
  201. abort_signal,
  202. reporter,
  203. )
  204. }
  205. #[must_use]
  206. pub fn run_post_tool_use_with_signal(
  207. &self,
  208. tool_name: &str,
  209. tool_input: &str,
  210. tool_output: &str,
  211. is_error: bool,
  212. abort_signal: Option<&HookAbortSignal>,
  213. ) -> HookRunResult {
  214. self.run_post_tool_use_with_context(
  215. tool_name,
  216. tool_input,
  217. tool_output,
  218. is_error,
  219. abort_signal,
  220. None,
  221. )
  222. }
  223. #[must_use]
  224. pub fn run_post_tool_use_failure(
  225. &self,
  226. tool_name: &str,
  227. tool_input: &str,
  228. tool_error: &str,
  229. ) -> HookRunResult {
  230. self.run_post_tool_use_failure_with_context(tool_name, tool_input, tool_error, None, None)
  231. }
  232. #[must_use]
  233. pub fn run_post_tool_use_failure_with_context(
  234. &self,
  235. tool_name: &str,
  236. tool_input: &str,
  237. tool_error: &str,
  238. abort_signal: Option<&HookAbortSignal>,
  239. reporter: Option<&mut dyn HookProgressReporter>,
  240. ) -> HookRunResult {
  241. Self::run_commands(
  242. HookEvent::PostToolUseFailure,
  243. self.config.post_tool_use_failure(),
  244. tool_name,
  245. tool_input,
  246. Some(tool_error),
  247. true,
  248. abort_signal,
  249. reporter,
  250. )
  251. }
  252. #[must_use]
  253. pub fn run_post_tool_use_failure_with_signal(
  254. &self,
  255. tool_name: &str,
  256. tool_input: &str,
  257. tool_error: &str,
  258. abort_signal: Option<&HookAbortSignal>,
  259. ) -> HookRunResult {
  260. self.run_post_tool_use_failure_with_context(
  261. tool_name,
  262. tool_input,
  263. tool_error,
  264. abort_signal,
  265. None,
  266. )
  267. }
  268. #[allow(clippy::too_many_arguments)]
  269. fn run_commands(
  270. event: HookEvent,
  271. commands: &[String],
  272. tool_name: &str,
  273. tool_input: &str,
  274. tool_output: Option<&str>,
  275. is_error: bool,
  276. abort_signal: Option<&HookAbortSignal>,
  277. mut reporter: Option<&mut dyn HookProgressReporter>,
  278. ) -> HookRunResult {
  279. if commands.is_empty() {
  280. return HookRunResult::allow(Vec::new());
  281. }
  282. if abort_signal.is_some_and(HookAbortSignal::is_aborted) {
  283. return HookRunResult {
  284. denied: false,
  285. cancelled: true,
  286. messages: vec![format!(
  287. "{} hook cancelled before execution",
  288. event.as_str()
  289. )],
  290. permission_override: None,
  291. permission_reason: None,
  292. updated_input: None,
  293. };
  294. }
  295. let payload = hook_payload(event, tool_name, tool_input, tool_output, is_error).to_string();
  296. let mut result = HookRunResult::allow(Vec::new());
  297. for command in commands {
  298. if let Some(reporter) = reporter.as_deref_mut() {
  299. reporter.on_event(&HookProgressEvent::Started {
  300. event,
  301. tool_name: tool_name.to_string(),
  302. command: command.clone(),
  303. });
  304. }
  305. match Self::run_command(
  306. command,
  307. event,
  308. tool_name,
  309. tool_input,
  310. tool_output,
  311. is_error,
  312. &payload,
  313. abort_signal,
  314. ) {
  315. HookCommandOutcome::Allow { parsed } => {
  316. if let Some(reporter) = reporter.as_deref_mut() {
  317. reporter.on_event(&HookProgressEvent::Completed {
  318. event,
  319. tool_name: tool_name.to_string(),
  320. command: command.clone(),
  321. });
  322. }
  323. merge_parsed_hook_output(&mut result, parsed);
  324. }
  325. HookCommandOutcome::Deny { parsed } => {
  326. if let Some(reporter) = reporter.as_deref_mut() {
  327. reporter.on_event(&HookProgressEvent::Completed {
  328. event,
  329. tool_name: tool_name.to_string(),
  330. command: command.clone(),
  331. });
  332. }
  333. merge_parsed_hook_output(&mut result, parsed);
  334. result.denied = true;
  335. return result;
  336. }
  337. HookCommandOutcome::Warn { message } => {
  338. if let Some(reporter) = reporter.as_deref_mut() {
  339. reporter.on_event(&HookProgressEvent::Completed {
  340. event,
  341. tool_name: tool_name.to_string(),
  342. command: command.clone(),
  343. });
  344. }
  345. result.messages.push(message);
  346. }
  347. HookCommandOutcome::Cancelled { message } => {
  348. if let Some(reporter) = reporter.as_deref_mut() {
  349. reporter.on_event(&HookProgressEvent::Cancelled {
  350. event,
  351. tool_name: tool_name.to_string(),
  352. command: command.clone(),
  353. });
  354. }
  355. result.cancelled = true;
  356. result.messages.push(message);
  357. return result;
  358. }
  359. }
  360. }
  361. result
  362. }
  363. #[allow(clippy::too_many_arguments)]
  364. fn run_command(
  365. command: &str,
  366. event: HookEvent,
  367. tool_name: &str,
  368. tool_input: &str,
  369. tool_output: Option<&str>,
  370. is_error: bool,
  371. payload: &str,
  372. abort_signal: Option<&HookAbortSignal>,
  373. ) -> HookCommandOutcome {
  374. let mut child = shell_command(command);
  375. child.stdin(Stdio::piped());
  376. child.stdout(Stdio::piped());
  377. child.stderr(Stdio::piped());
  378. child.env("HOOK_EVENT", event.as_str());
  379. child.env("HOOK_TOOL_NAME", tool_name);
  380. child.env("HOOK_TOOL_INPUT", tool_input);
  381. child.env("HOOK_TOOL_IS_ERROR", if is_error { "1" } else { "0" });
  382. if let Some(tool_output) = tool_output {
  383. child.env("HOOK_TOOL_OUTPUT", tool_output);
  384. }
  385. match child.output_with_stdin(payload.as_bytes(), abort_signal) {
  386. Ok(CommandExecution::Finished(output)) => {
  387. let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
  388. let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
  389. let parsed = parse_hook_output(&stdout);
  390. match output.status.code() {
  391. Some(0) => {
  392. if parsed.deny {
  393. HookCommandOutcome::Deny { parsed }
  394. } else {
  395. HookCommandOutcome::Allow { parsed }
  396. }
  397. }
  398. Some(2) => HookCommandOutcome::Deny {
  399. parsed: parsed.with_fallback_message(format!(
  400. "{} hook denied tool `{tool_name}`",
  401. event.as_str()
  402. )),
  403. },
  404. Some(code) => HookCommandOutcome::Warn {
  405. message: format_hook_warning(
  406. command,
  407. code,
  408. parsed.primary_message(),
  409. stderr.as_str(),
  410. ),
  411. },
  412. None => HookCommandOutcome::Warn {
  413. message: format!(
  414. "{} hook `{command}` terminated by signal while handling `{tool_name}`",
  415. event.as_str()
  416. ),
  417. },
  418. }
  419. }
  420. Ok(CommandExecution::Cancelled) => HookCommandOutcome::Cancelled {
  421. message: format!(
  422. "{} hook `{command}` cancelled while handling `{tool_name}`",
  423. event.as_str()
  424. ),
  425. },
  426. Err(error) => HookCommandOutcome::Warn {
  427. message: format!(
  428. "{} hook `{command}` failed to start for `{tool_name}`: {error}",
  429. event.as_str()
  430. ),
  431. },
  432. }
  433. }
  434. }
  435. enum HookCommandOutcome {
  436. Allow { parsed: ParsedHookOutput },
  437. Deny { parsed: ParsedHookOutput },
  438. Warn { message: String },
  439. Cancelled { message: String },
  440. }
  441. #[derive(Debug, Clone, PartialEq, Eq, Default)]
  442. struct ParsedHookOutput {
  443. messages: Vec<String>,
  444. deny: bool,
  445. permission_override: Option<PermissionOverride>,
  446. permission_reason: Option<String>,
  447. updated_input: Option<String>,
  448. }
  449. impl ParsedHookOutput {
  450. fn with_fallback_message(mut self, fallback: String) -> Self {
  451. if self.messages.is_empty() {
  452. self.messages.push(fallback);
  453. }
  454. self
  455. }
  456. fn primary_message(&self) -> Option<&str> {
  457. self.messages.first().map(String::as_str)
  458. }
  459. }
  460. fn merge_parsed_hook_output(target: &mut HookRunResult, parsed: ParsedHookOutput) {
  461. target.messages.extend(parsed.messages);
  462. if parsed.permission_override.is_some() {
  463. target.permission_override = parsed.permission_override;
  464. }
  465. if parsed.permission_reason.is_some() {
  466. target.permission_reason = parsed.permission_reason;
  467. }
  468. if parsed.updated_input.is_some() {
  469. target.updated_input = parsed.updated_input;
  470. }
  471. }
  472. fn parse_hook_output(stdout: &str) -> ParsedHookOutput {
  473. if stdout.is_empty() {
  474. return ParsedHookOutput::default();
  475. }
  476. let Ok(Value::Object(root)) = serde_json::from_str::<Value>(stdout) else {
  477. return ParsedHookOutput {
  478. messages: vec![stdout.to_string()],
  479. ..ParsedHookOutput::default()
  480. };
  481. };
  482. let mut parsed = ParsedHookOutput::default();
  483. if let Some(message) = root.get("systemMessage").and_then(Value::as_str) {
  484. parsed.messages.push(message.to_string());
  485. }
  486. if let Some(message) = root.get("reason").and_then(Value::as_str) {
  487. parsed.messages.push(message.to_string());
  488. }
  489. if root.get("continue").and_then(Value::as_bool) == Some(false)
  490. || root.get("decision").and_then(Value::as_str) == Some("block")
  491. {
  492. parsed.deny = true;
  493. }
  494. if let Some(Value::Object(specific)) = root.get("hookSpecificOutput") {
  495. if let Some(Value::String(additional_context)) = specific.get("additionalContext") {
  496. parsed.messages.push(additional_context.clone());
  497. }
  498. if let Some(decision) = specific.get("permissionDecision").and_then(Value::as_str) {
  499. parsed.permission_override = match decision {
  500. "allow" => Some(PermissionOverride::Allow),
  501. "deny" => Some(PermissionOverride::Deny),
  502. "ask" => Some(PermissionOverride::Ask),
  503. _ => None,
  504. };
  505. }
  506. if let Some(reason) = specific
  507. .get("permissionDecisionReason")
  508. .and_then(Value::as_str)
  509. {
  510. parsed.permission_reason = Some(reason.to_string());
  511. }
  512. if let Some(updated_input) = specific.get("updatedInput") {
  513. parsed.updated_input = serde_json::to_string(updated_input).ok();
  514. }
  515. }
  516. if parsed.messages.is_empty() {
  517. parsed.messages.push(stdout.to_string());
  518. }
  519. parsed
  520. }
  521. fn hook_payload(
  522. event: HookEvent,
  523. tool_name: &str,
  524. tool_input: &str,
  525. tool_output: Option<&str>,
  526. is_error: bool,
  527. ) -> Value {
  528. match event {
  529. HookEvent::PostToolUseFailure => json!({
  530. "hook_event_name": event.as_str(),
  531. "tool_name": tool_name,
  532. "tool_input": parse_tool_input(tool_input),
  533. "tool_input_json": tool_input,
  534. "tool_error": tool_output,
  535. "tool_result_is_error": true,
  536. }),
  537. _ => json!({
  538. "hook_event_name": event.as_str(),
  539. "tool_name": tool_name,
  540. "tool_input": parse_tool_input(tool_input),
  541. "tool_input_json": tool_input,
  542. "tool_output": tool_output,
  543. "tool_result_is_error": is_error,
  544. }),
  545. }
  546. }
  547. fn parse_tool_input(tool_input: &str) -> Value {
  548. serde_json::from_str(tool_input).unwrap_or_else(|_| json!({ "raw": tool_input }))
  549. }
  550. fn format_hook_warning(command: &str, code: i32, stdout: Option<&str>, stderr: &str) -> String {
  551. let mut message =
  552. format!("Hook `{command}` exited with status {code}; allowing tool execution to continue");
  553. if let Some(stdout) = stdout.filter(|stdout| !stdout.is_empty()) {
  554. message.push_str(": ");
  555. message.push_str(stdout);
  556. } else if !stderr.is_empty() {
  557. message.push_str(": ");
  558. message.push_str(stderr);
  559. }
  560. message
  561. }
  562. fn shell_command(command: &str) -> CommandWithStdin {
  563. #[cfg(windows)]
  564. let mut command_builder = {
  565. let mut command_builder = Command::new("cmd");
  566. command_builder.arg("/C").arg(command);
  567. CommandWithStdin::new(command_builder)
  568. };
  569. #[cfg(not(windows))]
  570. let command_builder = {
  571. let mut command_builder = Command::new("sh");
  572. command_builder.arg("-lc").arg(command);
  573. CommandWithStdin::new(command_builder)
  574. };
  575. command_builder
  576. }
  577. struct CommandWithStdin {
  578. command: Command,
  579. }
  580. impl CommandWithStdin {
  581. fn new(command: Command) -> Self {
  582. Self { command }
  583. }
  584. fn stdin(&mut self, cfg: Stdio) -> &mut Self {
  585. self.command.stdin(cfg);
  586. self
  587. }
  588. fn stdout(&mut self, cfg: Stdio) -> &mut Self {
  589. self.command.stdout(cfg);
  590. self
  591. }
  592. fn stderr(&mut self, cfg: Stdio) -> &mut Self {
  593. self.command.stderr(cfg);
  594. self
  595. }
  596. fn env<K, V>(&mut self, key: K, value: V) -> &mut Self
  597. where
  598. K: AsRef<OsStr>,
  599. V: AsRef<OsStr>,
  600. {
  601. self.command.env(key, value);
  602. self
  603. }
  604. fn output_with_stdin(
  605. &mut self,
  606. stdin: &[u8],
  607. abort_signal: Option<&HookAbortSignal>,
  608. ) -> std::io::Result<CommandExecution> {
  609. let mut child = self.command.spawn()?;
  610. if let Some(mut child_stdin) = child.stdin.take() {
  611. child_stdin.write_all(stdin)?;
  612. }
  613. loop {
  614. if abort_signal.is_some_and(HookAbortSignal::is_aborted) {
  615. let _ = child.kill();
  616. let _ = child.wait_with_output();
  617. return Ok(CommandExecution::Cancelled);
  618. }
  619. match child.try_wait()? {
  620. Some(_) => return child.wait_with_output().map(CommandExecution::Finished),
  621. None => thread::sleep(Duration::from_millis(20)),
  622. }
  623. }
  624. }
  625. }
  626. enum CommandExecution {
  627. Finished(std::process::Output),
  628. Cancelled,
  629. }
  630. #[cfg(test)]
  631. mod tests {
  632. use std::thread;
  633. use std::time::Duration;
  634. use super::{
  635. HookAbortSignal, HookEvent, HookProgressEvent, HookProgressReporter, HookRunResult,
  636. HookRunner,
  637. };
  638. use crate::config::{RuntimeFeatureConfig, RuntimeHookConfig};
  639. use crate::permissions::PermissionOverride;
  640. struct RecordingReporter {
  641. events: Vec<HookProgressEvent>,
  642. }
  643. impl HookProgressReporter for RecordingReporter {
  644. fn on_event(&mut self, event: &HookProgressEvent) {
  645. self.events.push(event.clone());
  646. }
  647. }
  648. #[test]
  649. fn allows_exit_code_zero_and_captures_stdout() {
  650. let runner = HookRunner::new(RuntimeHookConfig::new(
  651. vec![shell_snippet("printf 'pre ok'")],
  652. Vec::new(),
  653. Vec::new(),
  654. ));
  655. let result = runner.run_pre_tool_use("Read", r#"{"path":"README.md"}"#);
  656. assert_eq!(result, HookRunResult::allow(vec!["pre ok".to_string()]));
  657. }
  658. #[test]
  659. fn denies_exit_code_two() {
  660. let runner = HookRunner::new(RuntimeHookConfig::new(
  661. vec![shell_snippet("printf 'blocked by hook'; exit 2")],
  662. Vec::new(),
  663. Vec::new(),
  664. ));
  665. let result = runner.run_pre_tool_use("Bash", r#"{"command":"pwd"}"#);
  666. assert!(result.is_denied());
  667. assert_eq!(result.messages(), &["blocked by hook".to_string()]);
  668. }
  669. #[test]
  670. fn warns_for_other_non_zero_statuses() {
  671. let runner = HookRunner::from_feature_config(&RuntimeFeatureConfig::default().with_hooks(
  672. RuntimeHookConfig::new(
  673. vec![shell_snippet("printf 'warning hook'; exit 1")],
  674. Vec::new(),
  675. Vec::new(),
  676. ),
  677. ));
  678. let result = runner.run_pre_tool_use("Edit", r#"{"file":"src/lib.rs"}"#);
  679. assert!(!result.is_denied());
  680. assert!(result
  681. .messages()
  682. .iter()
  683. .any(|message| message.contains("allowing tool execution to continue")));
  684. }
  685. #[test]
  686. fn parses_pre_hook_permission_override_and_updated_input() {
  687. let runner = HookRunner::new(RuntimeHookConfig::new(
  688. vec![shell_snippet(
  689. r#"printf '%s' '{"systemMessage":"updated","hookSpecificOutput":{"permissionDecision":"allow","permissionDecisionReason":"hook ok","updatedInput":{"command":"git status"}}}'"#,
  690. )],
  691. Vec::new(),
  692. Vec::new(),
  693. ));
  694. let result = runner.run_pre_tool_use("bash", r#"{"command":"pwd"}"#);
  695. assert_eq!(
  696. result.permission_override(),
  697. Some(PermissionOverride::Allow)
  698. );
  699. assert_eq!(result.permission_reason(), Some("hook ok"));
  700. assert_eq!(result.updated_input(), Some(r#"{"command":"git status"}"#));
  701. assert!(result.messages().iter().any(|message| message == "updated"));
  702. }
  703. #[test]
  704. fn runs_post_tool_use_failure_hooks() {
  705. let runner = HookRunner::new(RuntimeHookConfig::new(
  706. Vec::new(),
  707. Vec::new(),
  708. vec![shell_snippet("printf 'failure hook ran'")],
  709. ));
  710. let result =
  711. runner.run_post_tool_use_failure("bash", r#"{"command":"false"}"#, "command failed");
  712. assert!(!result.is_denied());
  713. assert_eq!(result.messages(), &["failure hook ran".to_string()]);
  714. }
  715. #[test]
  716. fn abort_signal_cancels_long_running_hook_and_reports_progress() {
  717. let runner = HookRunner::new(RuntimeHookConfig::new(
  718. vec![shell_snippet("sleep 5")],
  719. Vec::new(),
  720. Vec::new(),
  721. ));
  722. let abort_signal = HookAbortSignal::new();
  723. let abort_signal_for_thread = abort_signal.clone();
  724. let mut reporter = RecordingReporter { events: Vec::new() };
  725. thread::spawn(move || {
  726. thread::sleep(Duration::from_millis(100));
  727. abort_signal_for_thread.abort();
  728. });
  729. let result = runner.run_pre_tool_use_with_context(
  730. "bash",
  731. r#"{"command":"sleep 5"}"#,
  732. Some(&abort_signal),
  733. Some(&mut reporter),
  734. );
  735. assert!(result.is_cancelled());
  736. assert!(reporter.events.iter().any(|event| matches!(
  737. event,
  738. HookProgressEvent::Started {
  739. event: HookEvent::PreToolUse,
  740. ..
  741. }
  742. )));
  743. assert!(reporter.events.iter().any(|event| matches!(
  744. event,
  745. HookProgressEvent::Cancelled {
  746. event: HookEvent::PreToolUse,
  747. ..
  748. }
  749. )));
  750. }
  751. #[cfg(windows)]
  752. fn shell_snippet(script: &str) -> String {
  753. script.replace('\'', "\"")
  754. }
  755. #[cfg(not(windows))]
  756. fn shell_snippet(script: &str) -> String {
  757. script.to_string()
  758. }
  759. }