Skip to main content

leviath_scripting/
stage_hook.rs

1//! Script-backed stage lifecycle hooks (`[stages.<name>.hooks]`, issue #260).
2//!
3//! Where a custom region's script owns one region's behaviour, these let a
4//! blueprint observe and steer the agent's own lifecycle: what the context
5//! holds as a stage opens, what happens as it closes, and (in later hooks) what
6//! is about to be inferred or called.
7//!
8//! Same shape as [`crate::region_hook`], deliberately - a blueprint author who
9//! has written one has written the other:
10//!
11//! - **A return-value contract.** Rhai passes arguments by value, so mutating
12//!   `ctx` in place does nothing; the script returns its decision.
13//! - **Compiled once**, at agent spawn, by the CLI. A missing or malformed
14//!   script is a spawn error, not a runtime surprise.
15//! - **A fresh hardened engine per call** ([`crate::harden`]): no filesystem,
16//!   no network, no `eval`, operation-bounded.
17//! - **JSON at the boundary**, so `leviath-runtime` interprets the outcome
18//!   without depending on `rhai`.
19//!
20//! # The outcome contract
21//!
22//! Every hook returns one of four things, and the same four everywhere, so a
23//! reader does not have to learn a vocabulary per hook:
24//!
25//! | script returns | meaning |
26//! |---|---|
27//! | `()`, `true` | [`HookOutcome::Allow`] - proceed unchanged |
28//! | `false` | [`HookOutcome::Cancel`] with no reason given |
29//! | `#{ action: "allow" }` | as above, written out |
30//! | `#{ action: "modify", value: ... }` | [`HookOutcome::Modify`] - proceed with `value` |
31//! | `#{ action: "cancel", reason: "..." }` | [`HookOutcome::Cancel`] |
32//! | `#{ action: "retry" }` | [`HookOutcome::Retry`] |
33//!
34//! What `Modify` and `Retry` *mean* is the calling hook's business - the shape
35//! of `value` differs between "the regions to write" and "the request to send",
36//! and not every hook can honour `Retry`. This module decides only that the
37//! script returned a well-formed decision; the caller decides whether it is one
38//! it can act on. That split is why an unknown `action` is an error here (a
39//! typo'd `"modfiy"` must not read as `Allow`) while an unhonourable one is
40//! reported by the caller.
41
42use rhai::{AST, Dynamic, Engine, Scope};
43
44/// Operation budget for stage hooks.
45///
46/// The same 100k a region hook gets, and for the same reason: these are pure
47/// data transforms over a context snapshot, not the IO-driving script tools and
48/// providers that are given 500k.
49const STAGE_HOOK_MAX_OPERATIONS: u64 = 100_000;
50
51/// The hooks this build implements, as the function names a script defines.
52///
53/// The blueprint field and the Rhai function share a name on purpose: a
54/// blueprint saying `on_stage_enter = "hooks.rhai"` means "call
55/// `fn on_stage_enter(ctx)` in that file", with nothing in between to look up.
56pub 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/// What a hook decided.
67///
68/// Deliberately not `Option<Value>`: "proceed unchanged" and "proceed with
69/// this" are different answers, and so is "do not proceed". Collapsing them
70/// would make a cancelling hook indistinguishable from one that returned
71/// nothing, which is the failure mode the taint gate's own history warns about.
72#[derive(Debug, Clone, PartialEq)]
73pub enum HookOutcome {
74    /// Proceed unchanged.
75    Allow,
76    /// Proceed, using this value instead. Its shape is the caller's contract.
77    Modify(serde_json::Value),
78    /// Do not proceed. The reason is shown to the operator and, where the
79    /// caller can, written into the agent's context so the model learns why.
80    Cancel(Option<String>),
81    /// Do the thing again. Not every caller can honour this; one that cannot
82    /// says so rather than silently treating it as `Allow`.
83    Retry,
84}
85
86/// A compiled stage-hook script, ready to call.
87///
88/// Compiled once at spawn and shared via `Arc`, keyed by the path as written in
89/// the blueprint - the same lifecycle a [`crate::region_hook::RegionScript`]
90/// has, so one file backing several hooks is compiled once.
91#[derive(Debug, Clone)]
92pub struct HookScript {
93    /// The script path as written in the blueprint - log/error context only.
94    pub path: String,
95    ast: AST,
96    defined: Vec<String>,
97}
98
99impl HookScript {
100    /// Whether this script defines the named hook.
101    pub fn defines(&self, hook: &str) -> bool {
102        self.defined.iter().any(|d| d == hook)
103    }
104
105    /// Every hook this script defines, in [`HOOK_NAMES`] order.
106    pub fn defined(&self) -> &[String] {
107        &self.defined
108    }
109}
110
111/// Build the hardened engine every stage-hook call runs on.
112fn 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
120/// Compile a stage-hook script and record which hooks it defines.
121///
122/// `wanted` is what the blueprint asked this file for. A file that does not
123/// define a hook it was named for is a compile error: the blueprint asked for
124/// behaviour that would otherwise never run, and a hook that never runs looks
125/// exactly like one that ran and allowed everything.
126///
127/// Every hook takes exactly one parameter (`ctx`); a different arity is
128/// rejected here rather than failing at the first call, mid-run.
129pub 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
170/// Call `hook(ctx)` and interpret the decision.
171///
172/// The caller supplies `ctx` as JSON and gets a [`HookOutcome`]; nothing about
173/// `rhai` crosses this boundary.
174pub fn run(script: &HookScript, hook: &str, ctx: serde_json::Value) -> crate::Result<HookOutcome> {
175    let engine = build_engine();
176    // Total conversion: every JSON value has a Dynamic representation, so a
177    // failure here is a programmer error, not a script error (same stance as
178    // the region hooks and the provider layer).
179    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
203/// Read a returned map into an outcome.
204///
205/// Split out so every arm is reachable from a plain value in tests, without
206/// standing up an engine to produce each shape.
207fn 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        // A `modify` with no `value` is rejected rather than read as `allow`:
230        // the script asked to change something and naming nothing is a bug in
231        // it, not an instruction to proceed.
232        "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    // ─── compile ──────────────────────────────────────────────────────────
253
254    #[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    /// Arity is checked at compile rather than at the first call: a hook that
278    /// takes the wrong number of arguments fails on entry to some stage,
279    /// possibly minutes in, and the blueprint author is not there to see it.
280    #[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    /// The case that matters most: the blueprint asked this file for a hook and
290    /// the file does not implement it. Silently accepting would make a
291    /// never-called hook indistinguishable from one that allowed everything.
292    #[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    // ─── the outcome contract, through a real engine ──────────────────────
318
319    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    /// `false` cancels rather than allowing. Reading a bare `false` as "no
331    /// opinion" is how a hook that meant to stop something silently does not.
332    #[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    // ─── malformed decisions ──────────────────────────────────────────────
382
383    /// A typo must not read as `Allow`. This is the whole reason an unknown
384    /// action is an error rather than a default.
385    #[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    /// `modify` naming nothing is a bug in the script, not an instruction to
400    /// proceed unchanged - it asked to change something and said what to.
401    #[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    /// A value with no JSON representation cannot cross the boundary. The
429    /// caller gets a validation error naming the file, not a panic.
430    #[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    // ─── the sandbox actually applies ─────────────────────────────────────
437
438    /// The hardening is not decoration: a hook is agent-adjacent code and must
439    /// not be able to reach the filesystem or spin forever.
440    #[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}