Skip to main content

chio_guards/
patch_integrity.rs

1//! Patch integrity guard -- validates patch/diff safety.
2//!
3//! Adapted from ClawdStrike's `guards/patch_integrity.rs`. Checks for:
4//! - Maximum additions/deletions thresholds
5//! - Forbidden patterns in added lines (security disablement, backdoors, etc.)
6//! - Optional addition/deletion imbalance checks
7
8use regex::Regex;
9
10use chio_kernel::{GuardContext, KernelError, Verdict};
11
12use crate::action::{extract_action, ToolAction};
13
14/// Errors produced when building a [`PatchIntegrityGuard`].
15#[derive(Debug, thiserror::Error)]
16pub enum PatchIntegrityConfigError {
17    /// A forbidden pattern was not a valid regex.
18    #[error("invalid patch integrity forbidden pattern `{pattern}`: {source}")]
19    InvalidForbiddenPattern {
20        pattern: String,
21        #[source]
22        source: regex::Error,
23    },
24}
25
26/// Configuration for `PatchIntegrityGuard`.
27pub struct PatchIntegrityConfig {
28    /// Enable/disable this guard.
29    pub enabled: bool,
30    /// Maximum lines added in a single patch.
31    pub max_additions: usize,
32    /// Maximum lines deleted in a single patch.
33    pub max_deletions: usize,
34    /// Patterns that are forbidden in added patch lines.
35    pub forbidden_patterns: Vec<String>,
36    /// Require patches to have balanced additions/deletions.
37    pub require_balance: bool,
38    /// Maximum imbalance ratio (additions / deletions).
39    pub max_imbalance_ratio: f64,
40}
41
42fn default_forbidden_patterns() -> Vec<String> {
43    vec![
44        // Disable security features
45        r"(?i)disable[ _\-]?(security|auth|ssl|tls)".to_string(),
46        r"(?i)skip[ _\-]?(verify|validation|check)".to_string(),
47        // Dangerous operations
48        r"(?i)rm\s+-rf\s+/".to_string(),
49        r"(?i)chmod\s+777".to_string(),
50        r"(?i)eval\s*\(".to_string(),
51        r"(?i)exec\s*\(".to_string(),
52        // Backdoor indicators
53        r"(?i)reverse[_\-]?shell".to_string(),
54        r"(?i)bind[_\-]?shell".to_string(),
55        r"base64[_\-]?decode.*exec".to_string(),
56    ]
57}
58
59impl Default for PatchIntegrityConfig {
60    fn default() -> Self {
61        Self {
62            enabled: true,
63            max_additions: 1000,
64            max_deletions: 500,
65            forbidden_patterns: default_forbidden_patterns(),
66            require_balance: false,
67            max_imbalance_ratio: 10.0,
68        }
69    }
70}
71
72/// A forbidden pattern match found in a patch.
73#[derive(Clone, Debug)]
74pub struct ForbiddenMatch {
75    pub line: String,
76    pub pattern: String,
77}
78
79/// Analysis result for a patch.
80#[derive(Clone, Debug)]
81pub struct PatchAnalysis {
82    pub additions: usize,
83    pub deletions: usize,
84    pub imbalance_ratio: f64,
85    pub forbidden_matches: Vec<ForbiddenMatch>,
86    pub exceeds_max_additions: bool,
87    pub exceeds_max_deletions: bool,
88    pub exceeds_imbalance: bool,
89}
90
91impl PatchAnalysis {
92    /// Returns true when the patch passes all safety checks.
93    pub fn is_safe(&self) -> bool {
94        self.forbidden_matches.is_empty()
95            && !self.exceeds_max_additions
96            && !self.exceeds_max_deletions
97            && !self.exceeds_imbalance
98    }
99}
100
101/// Guard that validates the safety of applied patches/diffs.
102pub struct PatchIntegrityGuard {
103    enabled: bool,
104    config: PatchIntegrityConfig,
105    forbidden_regexes: Vec<Regex>,
106}
107
108impl PatchIntegrityGuard {
109    pub fn new() -> Self {
110        match Self::with_config(PatchIntegrityConfig::default()) {
111            Ok(guard) => guard,
112            Err(error) => panic!("default patch integrity config must be valid: {error}"),
113        }
114    }
115
116    pub fn with_config(config: PatchIntegrityConfig) -> Result<Self, PatchIntegrityConfigError> {
117        let enabled = config.enabled;
118        let forbidden_regexes = config
119            .forbidden_patterns
120            .iter()
121            .map(|pattern| {
122                Regex::new(pattern).map_err(|source| {
123                    PatchIntegrityConfigError::InvalidForbiddenPattern {
124                        pattern: pattern.clone(),
125                        source,
126                    }
127                })
128            })
129            .collect::<Result<Vec<_>, _>>()?;
130
131        Ok(Self {
132            enabled,
133            config,
134            forbidden_regexes,
135        })
136    }
137
138    /// Analyze a unified diff and return a `PatchAnalysis`.
139    pub fn analyze(&self, diff: &str) -> PatchAnalysis {
140        let mut additions = 0;
141        let mut deletions = 0;
142        let mut forbidden_matches = Vec::new();
143
144        for line in diff.lines() {
145            if line.starts_with('+') && !line.starts_with("+++") {
146                additions += 1;
147
148                // Check added lines for forbidden patterns.
149                for (idx, regex) in self.forbidden_regexes.iter().enumerate() {
150                    if regex.is_match(line) {
151                        forbidden_matches.push(ForbiddenMatch {
152                            line: line.to_string(),
153                            pattern: self.config.forbidden_patterns[idx].clone(),
154                        });
155                    }
156                }
157            } else if line.starts_with('-') && !line.starts_with("---") {
158                deletions += 1;
159            }
160        }
161
162        let imbalance_ratio = if deletions > 0 {
163            additions as f64 / deletions as f64
164        } else if additions > 0 {
165            f64::INFINITY
166        } else {
167            1.0
168        };
169
170        PatchAnalysis {
171            additions,
172            deletions,
173            imbalance_ratio,
174            forbidden_matches,
175            exceeds_max_additions: additions > self.config.max_additions,
176            exceeds_max_deletions: deletions > self.config.max_deletions,
177            exceeds_imbalance: self.config.require_balance
178                && imbalance_ratio > self.config.max_imbalance_ratio,
179        }
180    }
181}
182
183impl Default for PatchIntegrityGuard {
184    fn default() -> Self {
185        Self::new()
186    }
187}
188
189impl chio_kernel::Guard for PatchIntegrityGuard {
190    fn name(&self) -> &str {
191        "patch-integrity"
192    }
193
194    fn evaluate(&self, ctx: &GuardContext) -> Result<Verdict, KernelError> {
195        if !self.enabled {
196            return Ok(Verdict::Allow);
197        }
198
199        let action = extract_action(&ctx.request.tool_name, &ctx.request.arguments);
200
201        let diff = match &action {
202            ToolAction::Patch(_, diff) => diff.as_str(),
203            _ => return Ok(Verdict::Allow),
204        };
205
206        let analysis = self.analyze(diff);
207
208        if analysis.is_safe() {
209            Ok(Verdict::Allow)
210        } else {
211            Ok(Verdict::Deny)
212        }
213    }
214}
215
216#[cfg(test)]
217mod tests {
218    use super::*;
219    use chio_kernel::Guard;
220
221    #[test]
222    fn safe_patch_is_allowed() {
223        let guard = PatchIntegrityGuard::new();
224
225        let diff = "\
226--- a/file.txt
227+++ b/file.txt
228@@ -1,3 +1,4 @@
229 unchanged
230+added line 1
231+added line 2
232-deleted line";
233
234        let analysis = guard.analyze(diff);
235        assert_eq!(analysis.additions, 2);
236        assert_eq!(analysis.deletions, 1);
237        assert!(analysis.is_safe());
238    }
239
240    #[test]
241    fn forbidden_pattern_blocks() {
242        let guard = PatchIntegrityGuard::new();
243
244        let diff = "\
245+disable_security = True
246+disable security = True
247+rm -rf /";
248
249        let analysis = guard.analyze(diff);
250        assert!(!analysis.forbidden_matches.is_empty());
251        assert!(analysis
252            .forbidden_matches
253            .iter()
254            .any(|m| m.line.contains("disable security")));
255        assert!(!analysis.is_safe());
256    }
257
258    #[test]
259    fn eval_blocks_patch_with_eval() {
260        let guard = PatchIntegrityGuard::new();
261
262        let diff = "+eval(user_input)";
263        let analysis = guard.analyze(diff);
264        assert!(!analysis.is_safe());
265    }
266
267    #[test]
268    fn max_additions_exceeded() {
269        let config = PatchIntegrityConfig {
270            max_additions: 5,
271            ..Default::default()
272        };
273        let guard = PatchIntegrityGuard::with_config(config).expect("valid patch integrity config");
274
275        let diff = "+line1\n+line2\n+line3\n+line4\n+line5\n+line6";
276        let analysis = guard.analyze(diff);
277        assert!(analysis.exceeds_max_additions);
278        assert!(!analysis.is_safe());
279    }
280
281    #[test]
282    fn max_deletions_exceeded() {
283        let config = PatchIntegrityConfig {
284            max_deletions: 2,
285            ..Default::default()
286        };
287        let guard = PatchIntegrityGuard::with_config(config).expect("valid patch integrity config");
288
289        let diff = "-del1\n-del2\n-del3";
290        let analysis = guard.analyze(diff);
291        assert!(analysis.exceeds_max_deletions);
292        assert!(!analysis.is_safe());
293    }
294
295    #[test]
296    fn imbalance_check() {
297        let config = PatchIntegrityConfig {
298            require_balance: true,
299            max_imbalance_ratio: 2.0,
300            ..Default::default()
301        };
302        let guard = PatchIntegrityGuard::with_config(config).expect("valid patch integrity config");
303
304        // 6 additions, 1 deletion = ratio 6.0, exceeds 2.0
305        let diff = "+a\n+b\n+c\n+d\n+e\n+f\n-x";
306        let analysis = guard.analyze(diff);
307        assert!(analysis.exceeds_imbalance);
308        assert!(!analysis.is_safe());
309    }
310
311    #[test]
312    fn evaluate_allows_safe_patch() {
313        let guard = PatchIntegrityGuard::new();
314
315        let kp = chio_core::crypto::Keypair::generate();
316        let scope = chio_core::capability::ChioScope::default();
317        let agent_id = kp.public_key().to_hex();
318        let server_id = "srv-test".to_string();
319
320        let cap_body = chio_core::capability::CapabilityTokenBody {
321            id: "cap-test".to_string(),
322            issuer: kp.public_key(),
323            subject: kp.public_key(),
324            scope: scope.clone(),
325            issued_at: 0,
326            expires_at: u64::MAX,
327            delegation_chain: vec![],
328        };
329        let cap = chio_core::capability::CapabilityToken::sign(cap_body, &kp).expect("sign cap");
330
331        let request = chio_kernel::ToolCallRequest {
332            request_id: "req-test".to_string(),
333            capability: cap,
334            tool_name: "apply_patch".to_string(),
335            server_id: server_id.clone(),
336            agent_id: agent_id.clone(),
337            arguments: serde_json::json!({
338                "path": "file.txt",
339                "diff": "+added line\n-deleted line",
340            }),
341            dpop_proof: None,
342            governed_intent: None,
343            approval_token: None,
344            model_metadata: None,
345            federated_origin_kernel_id: None,
346        };
347
348        let ctx = chio_kernel::GuardContext {
349            request: &request,
350            scope: &scope,
351            agent_id: &agent_id,
352            server_id: &server_id,
353            session_filesystem_roots: None,
354            matched_grant_index: None,
355        };
356
357        let result = guard.evaluate(&ctx).expect("evaluate should not error");
358        assert_eq!(result, Verdict::Allow);
359    }
360
361    #[test]
362    fn evaluate_blocks_unsafe_patch() {
363        let guard = PatchIntegrityGuard::new();
364
365        let kp = chio_core::crypto::Keypair::generate();
366        let scope = chio_core::capability::ChioScope::default();
367        let agent_id = kp.public_key().to_hex();
368        let server_id = "srv-test".to_string();
369
370        let cap_body = chio_core::capability::CapabilityTokenBody {
371            id: "cap-test".to_string(),
372            issuer: kp.public_key(),
373            subject: kp.public_key(),
374            scope: scope.clone(),
375            issued_at: 0,
376            expires_at: u64::MAX,
377            delegation_chain: vec![],
378        };
379        let cap = chio_core::capability::CapabilityToken::sign(cap_body, &kp).expect("sign cap");
380
381        let request = chio_kernel::ToolCallRequest {
382            request_id: "req-test".to_string(),
383            capability: cap,
384            tool_name: "apply_patch".to_string(),
385            server_id: server_id.clone(),
386            agent_id: agent_id.clone(),
387            arguments: serde_json::json!({
388                "path": "file.py",
389                "diff": "+eval(user_input)",
390            }),
391            dpop_proof: None,
392            governed_intent: None,
393            approval_token: None,
394            model_metadata: None,
395            federated_origin_kernel_id: None,
396        };
397
398        let ctx = chio_kernel::GuardContext {
399            request: &request,
400            scope: &scope,
401            agent_id: &agent_id,
402            server_id: &server_id,
403            session_filesystem_roots: None,
404            matched_grant_index: None,
405        };
406
407        let result = guard.evaluate(&ctx).expect("evaluate should not error");
408        assert_eq!(result, Verdict::Deny);
409    }
410
411    #[test]
412    fn disabled_guard_allows_everything() {
413        let config = PatchIntegrityConfig {
414            enabled: false,
415            ..Default::default()
416        };
417        let guard = PatchIntegrityGuard::with_config(config).expect("valid patch integrity config");
418
419        let kp = chio_core::crypto::Keypair::generate();
420        let scope = chio_core::capability::ChioScope::default();
421        let agent_id = kp.public_key().to_hex();
422        let server_id = "srv-test".to_string();
423
424        let cap_body = chio_core::capability::CapabilityTokenBody {
425            id: "cap-test".to_string(),
426            issuer: kp.public_key(),
427            subject: kp.public_key(),
428            scope: scope.clone(),
429            issued_at: 0,
430            expires_at: u64::MAX,
431            delegation_chain: vec![],
432        };
433        let cap = chio_core::capability::CapabilityToken::sign(cap_body, &kp).expect("sign cap");
434
435        let request = chio_kernel::ToolCallRequest {
436            request_id: "req-test".to_string(),
437            capability: cap,
438            tool_name: "apply_patch".to_string(),
439            server_id: server_id.clone(),
440            agent_id: agent_id.clone(),
441            arguments: serde_json::json!({
442                "path": "file.py",
443                "diff": "+eval(user_input)\n+reverse_shell()",
444            }),
445            dpop_proof: None,
446            governed_intent: None,
447            approval_token: None,
448            model_metadata: None,
449            federated_origin_kernel_id: None,
450        };
451
452        let ctx = chio_kernel::GuardContext {
453            request: &request,
454            scope: &scope,
455            agent_id: &agent_id,
456            server_id: &server_id,
457            session_filesystem_roots: None,
458            matched_grant_index: None,
459        };
460
461        let result = guard.evaluate(&ctx).expect("evaluate should not error");
462        assert_eq!(result, Verdict::Allow);
463    }
464
465    #[test]
466    fn with_config_rejects_invalid_forbidden_regex() {
467        let config = PatchIntegrityConfig {
468            forbidden_patterns: vec!["[".to_string()],
469            ..Default::default()
470        };
471
472        let error = match PatchIntegrityGuard::with_config(config) {
473            Ok(_) => panic!("invalid forbidden regex should fail closed"),
474            Err(error) => error,
475        };
476        assert!(matches!(
477            error,
478            PatchIntegrityConfigError::InvalidForbiddenPattern { .. }
479        ));
480    }
481}