1use 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}