Skip to main content

deepstrike_core/governance/
constraint.rs

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