1use rhai::{AST, Dynamic, Engine, Scope};
43
44const STAGE_HOOK_MAX_OPERATIONS: u64 = 100_000;
50
51pub const HOOK_NAMES: &[&str] = &[
57 "on_stage_enter",
58 "on_stage_exit",
59 "before_inference",
60 "after_inference",
61 "on_tool_call",
62 "on_completion",
63 "on_error",
64];
65
66#[derive(Debug, Clone, PartialEq)]
73pub enum HookOutcome {
74 Allow,
76 Modify(serde_json::Value),
78 Cancel(Option<String>),
81 Retry,
84}
85
86#[derive(Debug, Clone)]
92pub struct HookScript {
93 pub path: String,
95 ast: AST,
96 defined: Vec<String>,
97}
98
99impl HookScript {
100 pub fn defines(&self, hook: &str) -> bool {
102 self.defined.iter().any(|d| d == hook)
103 }
104
105 pub fn defined(&self) -> &[String] {
107 &self.defined
108 }
109}
110
111fn build_engine() -> Engine {
113 let mut engine = Engine::new();
114 crate::harden(&mut engine, STAGE_HOOK_MAX_OPERATIONS);
115 crate::functions::register_functions(&mut engine);
116 crate::types::register_types(&mut engine);
117 engine
118}
119
120pub fn compile(path: &str, source: &str, wanted: &[&str]) -> crate::Result<HookScript> {
130 let engine = build_engine();
131 let ast = engine
132 .compile(source)
133 .map_err(|e| crate::Error::CompilationFailed(format!("{path}: {e}")))?;
134
135 let arity_of = |name: &str| -> Option<usize> {
136 ast.iter_functions()
137 .find(|f| f.name == name)
138 .map(|f| f.params.len())
139 };
140
141 let mut defined = Vec::new();
142 for hook in HOOK_NAMES {
143 match arity_of(hook) {
144 Some(1) => defined.push((*hook).to_string()),
145 Some(n) => {
146 return Err(crate::Error::ValidationFailed(format!(
147 "{path}: fn {hook} must take exactly one parameter (ctx), found {n}"
148 )));
149 }
150 None => {}
151 }
152 }
153
154 for hook in wanted {
155 if !defined.iter().any(|d| d == hook) {
156 return Err(crate::Error::ValidationFailed(format!(
157 "{path}: the blueprint names this file for '{hook}', but it defines no \
158 fn {hook}(ctx)"
159 )));
160 }
161 }
162
163 Ok(HookScript {
164 path: path.to_string(),
165 ast,
166 defined,
167 })
168}
169
170pub fn run(script: &HookScript, hook: &str, ctx: serde_json::Value) -> crate::Result<HookOutcome> {
175 let engine = build_engine();
176 let ctx_dyn = rhai::serde::to_dynamic(ctx).expect("JSON always converts to Dynamic");
180 let result: Dynamic = engine
181 .call_fn(&mut Scope::new(), &script.ast, hook, (ctx_dyn,))
182 .map_err(|e| crate::Error::ExecutionFailed(format!("{}: {hook}: {e}", script.path)))?;
183
184 if result.is_unit() {
185 return Ok(HookOutcome::Allow);
186 }
187 if let Ok(b) = result.as_bool() {
188 return Ok(match b {
189 true => HookOutcome::Allow,
190 false => HookOutcome::Cancel(None),
191 });
192 }
193
194 let value = rhai::serde::from_dynamic::<serde_json::Value>(&result).map_err(|e| {
195 crate::Error::ValidationFailed(format!(
196 "{}: {hook} returned a value that is not plain data: {e}",
197 script.path
198 ))
199 })?;
200 outcome_from(&script.path, hook, value)
201}
202
203fn outcome_from(path: &str, hook: &str, value: serde_json::Value) -> crate::Result<HookOutcome> {
208 let bad = |what: String| crate::Error::ValidationFailed(format!("{path}: {hook}: {what}"));
209
210 let Some(obj) = value.as_object() else {
211 return Err(bad(format!(
212 "expected (), a bool, or a map with an 'action', got: {value}"
213 )));
214 };
215 let Some(action) = obj.get("action").and_then(|a| a.as_str()) else {
216 return Err(bad(
217 "the returned map has no 'action' (expected allow, modify, cancel, or retry)"
218 .to_string(),
219 ));
220 };
221 match action {
222 "allow" => Ok(HookOutcome::Allow),
223 "retry" => Ok(HookOutcome::Retry),
224 "cancel" => Ok(HookOutcome::Cancel(
225 obj.get("reason")
226 .and_then(|r| r.as_str())
227 .map(str::to_string),
228 )),
229 "modify" => match obj.get("value") {
233 Some(v) => Ok(HookOutcome::Modify(v.clone())),
234 None => Err(bad(
235 "action 'modify' needs a 'value' saying what to proceed with".to_string(),
236 )),
237 },
238 other => Err(bad(format!(
239 "unknown action '{other}' (expected allow, modify, cancel, or retry)"
240 ))),
241 }
242}
243
244#[cfg(test)]
245mod tests {
246 use super::*;
247
248 fn script(src: &str) -> HookScript {
249 compile("hooks.rhai", src, &[]).expect("compiles")
250 }
251
252 #[test]
255 fn a_script_records_every_hook_it_defines() {
256 let s = script("fn on_stage_enter(ctx) { () } fn on_stage_exit(ctx) { () }");
257 assert!(s.defines("on_stage_enter"));
258 assert!(s.defines("on_stage_exit"));
259 assert_eq!(s.defined(), ["on_stage_enter", "on_stage_exit"]);
260 }
261
262 #[test]
263 fn a_hook_the_script_does_not_define_is_not_claimed() {
264 let s = script("fn on_stage_enter(ctx) { () }");
265 assert!(s.defines("on_stage_enter"));
266 assert!(!s.defines("on_stage_exit"));
267 }
268
269 #[test]
270 fn a_syntax_error_names_the_file() {
271 let err = compile("hooks.rhai", "fn on_stage_enter(ctx) {", &[])
272 .unwrap_err()
273 .to_string();
274 assert!(err.contains("hooks.rhai"), "{err}");
275 }
276
277 #[test]
281 fn a_hook_with_the_wrong_arity_is_rejected_at_compile() {
282 let err = compile("hooks.rhai", "fn on_stage_enter(a, b) { () }", &[])
283 .unwrap_err()
284 .to_string();
285 assert!(err.contains("exactly one parameter"), "{err}");
286 assert!(err.contains("found 2"), "{err}");
287 }
288
289 #[test]
293 fn a_file_named_for_a_hook_it_does_not_define_is_rejected() {
294 let err = compile(
295 "hooks.rhai",
296 "fn on_stage_exit(ctx) { () }",
297 &["on_stage_enter"],
298 )
299 .unwrap_err()
300 .to_string();
301 assert!(err.contains("on_stage_enter"), "{err}");
302 assert!(err.contains("defines no"), "{err}");
303 }
304
305 #[test]
306 fn a_file_that_defines_what_was_asked_for_compiles() {
307 assert!(
308 compile(
309 "hooks.rhai",
310 "fn on_stage_enter(ctx) { () }",
311 &["on_stage_enter"]
312 )
313 .is_ok()
314 );
315 }
316
317 fn run_returning(body: &str) -> crate::Result<HookOutcome> {
320 let s = script(&format!("fn on_stage_enter(ctx) {{ {body} }}"));
321 run(&s, "on_stage_enter", serde_json::json!({"stage": "main"}))
322 }
323
324 #[test]
325 fn unit_and_true_both_allow() {
326 assert_eq!(run_returning("()").unwrap(), HookOutcome::Allow);
327 assert_eq!(run_returning("true").unwrap(), HookOutcome::Allow);
328 }
329
330 #[test]
333 fn false_cancels_with_no_reason() {
334 assert_eq!(run_returning("false").unwrap(), HookOutcome::Cancel(None));
335 }
336
337 #[test]
338 fn a_written_out_allow_is_the_same_as_unit() {
339 assert_eq!(
340 run_returning(r#"#{ action: "allow" }"#).unwrap(),
341 HookOutcome::Allow
342 );
343 }
344
345 #[test]
346 fn modify_carries_its_value() {
347 let got = run_returning(r#"#{ action: "modify", value: #{ notes: "seeded" } }"#).unwrap();
348 assert_eq!(
349 got,
350 HookOutcome::Modify(serde_json::json!({"notes": "seeded"}))
351 );
352 }
353
354 #[test]
355 fn cancel_carries_its_reason() {
356 assert_eq!(
357 run_returning(r#"#{ action: "cancel", reason: "over budget" }"#).unwrap(),
358 HookOutcome::Cancel(Some("over budget".to_string()))
359 );
360 assert_eq!(
361 run_returning(r#"#{ action: "cancel" }"#).unwrap(),
362 HookOutcome::Cancel(None)
363 );
364 }
365
366 #[test]
367 fn retry_is_its_own_outcome() {
368 assert_eq!(
369 run_returning(r#"#{ action: "retry" }"#).unwrap(),
370 HookOutcome::Retry
371 );
372 }
373
374 #[test]
375 fn the_ctx_reaches_the_script() {
376 let s = script(r#"fn on_stage_enter(ctx) { #{ action: "modify", value: ctx.stage } }"#);
377 let got = run(&s, "on_stage_enter", serde_json::json!({"stage": "review"})).unwrap();
378 assert_eq!(got, HookOutcome::Modify(serde_json::json!("review")));
379 }
380
381 #[test]
386 fn an_unknown_action_is_an_error_not_an_allow() {
387 let err = run_returning(r#"#{ action: "modfiy" }"#)
388 .unwrap_err()
389 .to_string();
390 assert!(err.contains("unknown action 'modfiy'"), "{err}");
391 }
392
393 #[test]
394 fn a_map_without_an_action_is_an_error() {
395 let err = run_returning(r#"#{ value: 1 }"#).unwrap_err().to_string();
396 assert!(err.contains("no 'action'"), "{err}");
397 }
398
399 #[test]
402 fn modify_without_a_value_is_an_error() {
403 let err = run_returning(r#"#{ action: "modify" }"#)
404 .unwrap_err()
405 .to_string();
406 assert!(err.contains("needs a 'value'"), "{err}");
407 }
408
409 #[test]
410 fn a_bare_scalar_is_an_error() {
411 let err = run_returning("42").unwrap_err().to_string();
412 assert!(err.contains("expected (), a bool, or a map"), "{err}");
413 }
414
415 #[test]
416 fn a_script_that_throws_reports_the_hook_and_file() {
417 let err = run_returning(r#"throw "nope""#).unwrap_err().to_string();
418 assert!(err.contains("hooks.rhai"), "{err}");
419 assert!(err.contains("on_stage_enter"), "{err}");
420 }
421
422 #[test]
423 fn calling_a_hook_the_script_lacks_is_an_execution_error() {
424 let s = script("fn on_stage_enter(ctx) { () }");
425 assert!(run(&s, "on_stage_exit", serde_json::json!({})).is_err());
426 }
427
428 #[test]
431 fn a_return_that_is_not_plain_data_is_rejected() {
432 let err = run_returning("|| 1").unwrap_err().to_string();
433 assert!(err.contains("hooks.rhai"), "{err}");
434 }
435
436 #[test]
441 fn a_hook_cannot_reach_the_host() {
442 let s = script(r#"fn on_stage_enter(ctx) { open_file("/etc/passwd") }"#);
443 assert!(run(&s, "on_stage_enter", serde_json::json!({})).is_err());
444 }
445
446 #[test]
447 fn a_runaway_hook_is_stopped_by_the_operation_budget() {
448 let s = script("fn on_stage_enter(ctx) { let i = 0; while true { i += 1; } }");
449 let err = run(&s, "on_stage_enter", serde_json::json!({}))
450 .unwrap_err()
451 .to_string();
452 assert!(!err.is_empty(), "a runaway must fail, not hang");
453 }
454}