deepstrike_core/governance/
constraint.rs1use crate::types::message::ToolCall;
2use crate::types::policy::GovernanceVerdict;
3
4#[derive(Debug, Clone)]
13pub struct ParamConstraint {
14 pub tool_name: String,
15 pub param_path: String,
16 pub rule: ConstraintRule,
17}
18
19#[derive(Debug, Clone)]
20pub enum ConstraintRule {
21 Range { min: Option<f64>, max: Option<f64> },
23 Enum(Vec<String>),
25 Required,
27}
28
29pub struct ConstraintValidator {
31 constraints: Vec<ParamConstraint>,
32}
33
34impl ConstraintValidator {
35 pub fn new() -> Self {
36 Self {
37 constraints: Vec::new(),
38 }
39 }
40
41 pub fn add(&mut self, constraint: ParamConstraint) {
42 self.constraints.push(constraint);
43 }
44
45 pub fn validate(&self, call: &ToolCall) -> Option<GovernanceVerdict> {
46 for c in &self.constraints {
47 if c.tool_name != call.name.as_str() {
48 continue;
49 }
50 let value = call
51 .arguments
52 .pointer(&format!("/{}", c.param_path.replace('.', "/")));
53
54 match &c.rule {
55 ConstraintRule::Required => {
56 if value.is_none() || value == Some(&serde_json::Value::Null) {
57 return Some(GovernanceVerdict::Deny {
58 stage: "constraint",
59 reason: format!(
60 "parameter '{}' is required for '{}'",
61 c.param_path, c.tool_name
62 ),
63 });
64 }
65 }
66 ConstraintRule::Enum(allowed) => {
67 if let Some(val) = value.and_then(|v| v.as_str()) {
68 if !allowed.iter().any(|a| a == val) {
69 return Some(GovernanceVerdict::Deny {
70 stage: "constraint",
71 reason: format!(
72 "parameter '{}' value '{}' not in allowed: {:?}",
73 c.param_path, val, allowed
74 ),
75 });
76 }
77 }
78 }
79 ConstraintRule::Range { min, max } => {
80 if let Some(val) = value.and_then(|v| v.as_f64()) {
81 if let Some(lo) = min {
82 if val < *lo {
83 return Some(GovernanceVerdict::Deny {
84 stage: "constraint",
85 reason: format!(
86 "parameter '{}' value {} below minimum {}",
87 c.param_path, val, lo
88 ),
89 });
90 }
91 }
92 if let Some(hi) = max {
93 if val > *hi {
94 return Some(GovernanceVerdict::Deny {
95 stage: "constraint",
96 reason: format!(
97 "parameter '{}' value {} above maximum {}",
98 c.param_path, val, hi
99 ),
100 });
101 }
102 }
103 }
104 }
105 }
106 }
107 None
108 }
109}
110
111impl Default for ConstraintValidator {
112 fn default() -> Self {
113 Self::new()
114 }
115}
116
117#[cfg(test)]
118mod tests {
119 use super::*;
120 use compact_str::CompactString;
121
122 fn call(name: &str, args: serde_json::Value) -> ToolCall {
123 ToolCall {
124 id: CompactString::new("c1"),
125 name: CompactString::new(name),
126 arguments: args,
127 }
128 }
129
130 #[test]
131 fn required_param_missing_denies() {
132 let mut v = ConstraintValidator::new();
133 v.add(ParamConstraint {
134 tool_name: "writefile".into(),
135 param_path: "path".into(),
136 rule: ConstraintRule::Required,
137 });
138 let verdict = v.validate(&call("writefile", serde_json::json!({})));
139 assert!(matches!(
140 verdict,
141 Some(GovernanceVerdict::Deny {
142 stage: "constraint",
143 ..
144 })
145 ));
146 }
147
148 #[test]
149 fn enum_rule_rejects_unknown_value() {
150 let mut v = ConstraintValidator::new();
151 v.add(ParamConstraint {
152 tool_name: "set_mode".into(),
153 param_path: "mode".into(),
154 rule: ConstraintRule::Enum(vec!["read".into(), "write".into()]),
155 });
156 let verdict = v.validate(&call("set_mode", serde_json::json!({"mode": "exec"})));
157 assert!(matches!(verdict, Some(GovernanceVerdict::Deny { .. })));
158 }
159
160 #[test]
161 fn range_rule_enforces_bounds() {
162 let mut v = ConstraintValidator::new();
163 v.add(ParamConstraint {
164 tool_name: "sleep".into(),
165 param_path: "seconds".into(),
166 rule: ConstraintRule::Range {
167 min: Some(0.0),
168 max: Some(10.0),
169 },
170 });
171 assert!(
172 v.validate(&call("sleep", serde_json::json!({"seconds": 5})))
173 .is_none()
174 );
175 assert!(
176 v.validate(&call("sleep", serde_json::json!({"seconds": 100})))
177 .is_some()
178 );
179 }
180}