Skip to main content

deepstrike_core/governance/
constraint.rs

1use crate::types::message::ToolCall;
2use crate::types::policy::GovernanceVerdict;
3
4/// A parameter constraint for tool arguments.
5///
6/// **Scope**: built-in rules cover the structural validation cases
7/// (required / range / enum). For pattern matching or custom predicates,
8/// do richer matching SDK-side — keeps the kernel free of regex deps
9/// and lets the SDK use whatever pattern engine suits its host language.
10#[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    /// Numeric value in range
20    Range { min: Option<f64>, max: Option<f64> },
21    /// Value must be one of these
22    Enum(Vec<String>),
23    /// Value must not be empty
24    Required,
25}
26
27/// Validates tool call arguments against registered constraints.
28pub 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}