Skip to main content

mdx_rust_core/
hooks.rs

1//! Lifecycle hooks for the safe optimization pipeline.
2//!
3//! Hooks are deliberately boring: deterministic inputs, deterministic
4//! decisions, no shell execution. External hook runners can come later, after
5//! the built-in contract has proven stable.
6
7use schemars::JsonSchema;
8use serde::{Deserialize, Serialize};
9
10#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
11pub enum HookStage {
12    PreEdit,
13    PostEdit,
14    PreCommand,
15    PostValidation,
16    PreAccept,
17}
18
19#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
20pub enum HookAction {
21    Allow,
22    Warn,
23    Deny,
24}
25
26#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
27pub struct HookContext {
28    pub stage: HookStage,
29    pub agent_name: String,
30    #[serde(default)]
31    pub edit_description: Option<String>,
32    #[serde(default)]
33    pub patch_bytes: usize,
34    #[serde(default)]
35    pub command: Option<String>,
36    #[serde(default)]
37    pub validation_passed: Option<bool>,
38    #[serde(default)]
39    pub score_delta: Option<f32>,
40}
41
42#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
43pub struct HookDecision {
44    pub stage: HookStage,
45    pub action: HookAction,
46    pub reason: String,
47}
48
49impl HookDecision {
50    pub fn allow(stage: HookStage, reason: impl Into<String>) -> Self {
51        Self {
52            stage,
53            action: HookAction::Allow,
54            reason: reason.into(),
55        }
56    }
57
58    pub fn warn(stage: HookStage, reason: impl Into<String>) -> Self {
59        Self {
60            stage,
61            action: HookAction::Warn,
62            reason: reason.into(),
63        }
64    }
65
66    pub fn deny(stage: HookStage, reason: impl Into<String>) -> Self {
67        Self {
68            stage,
69            action: HookAction::Deny,
70            reason: reason.into(),
71        }
72    }
73
74    pub fn denied(&self) -> bool {
75        self.action == HookAction::Deny
76    }
77}
78
79#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
80pub struct HookPolicy {
81    pub max_patch_bytes: usize,
82    pub require_positive_delta: bool,
83}
84
85impl Default for HookPolicy {
86    fn default() -> Self {
87        Self {
88            max_patch_bytes: 32 * 1024,
89            require_positive_delta: true,
90        }
91    }
92}
93
94pub fn evaluate_builtin_hook(policy: &HookPolicy, context: &HookContext) -> HookDecision {
95    match context.stage {
96        HookStage::PreEdit if context.patch_bytes > policy.max_patch_bytes => HookDecision::deny(
97            HookStage::PreEdit,
98            format!(
99                "patch is too large: {} bytes exceeds {}",
100                context.patch_bytes, policy.max_patch_bytes
101            ),
102        ),
103        HookStage::PostValidation if context.validation_passed == Some(false) => {
104            HookDecision::deny(HookStage::PostValidation, "validation failed")
105        }
106        HookStage::PreAccept
107            if policy.require_positive_delta
108                && context.score_delta.is_some_and(|delta| delta <= 0.0) =>
109        {
110            HookDecision::deny(HookStage::PreAccept, "score delta is not positive")
111        }
112        HookStage::PreCommand => HookDecision::allow(
113            HookStage::PreCommand,
114            context
115                .command
116                .as_deref()
117                .map(|command| format!("command allowed: {command}"))
118                .unwrap_or_else(|| "no command supplied".to_string()),
119        ),
120        ref stage => HookDecision::allow(stage.clone(), "built-in policy allowed stage"),
121    }
122}
123
124#[cfg(test)]
125mod tests {
126    use super::*;
127
128    #[test]
129    fn oversized_patch_is_denied() {
130        let context = HookContext {
131            stage: HookStage::PreEdit,
132            agent_name: "agent".to_string(),
133            edit_description: None,
134            patch_bytes: 99,
135            command: None,
136            validation_passed: None,
137            score_delta: None,
138        };
139        let policy = HookPolicy {
140            max_patch_bytes: 10,
141            require_positive_delta: true,
142        };
143
144        let decision = evaluate_builtin_hook(&policy, &context);
145
146        assert!(decision.denied());
147    }
148
149    #[test]
150    fn non_positive_acceptance_delta_is_denied() {
151        let context = HookContext {
152            stage: HookStage::PreAccept,
153            agent_name: "agent".to_string(),
154            edit_description: None,
155            patch_bytes: 0,
156            command: None,
157            validation_passed: None,
158            score_delta: Some(0.0),
159        };
160
161        let decision = evaluate_builtin_hook(&HookPolicy::default(), &context);
162
163        assert_eq!(decision.action, HookAction::Deny);
164    }
165}