Skip to main content

chio_guards/
patch_integrity.rs

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