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