1use regex::Regex;
9
10use chio_kernel::{GuardContext, KernelError, Verdict};
11
12use crate::action::{extract_action, ToolAction};
13
14#[derive(Debug, thiserror::Error)]
16pub enum PatchIntegrityConfigError {
17 #[error("invalid patch integrity forbidden pattern `{pattern}`: {source}")]
19 InvalidForbiddenPattern {
20 pattern: String,
21 #[source]
22 source: regex::Error,
23 },
24}
25
26pub struct PatchIntegrityConfig {
28 pub enabled: bool,
30 pub max_additions: usize,
32 pub max_deletions: usize,
34 pub forbidden_patterns: Vec<String>,
36 pub require_balance: bool,
38 pub max_imbalance_ratio: f64,
40}
41
42fn default_forbidden_patterns() -> Vec<String> {
43 vec![
44 r"(?i)disable[ _\-]?(security|auth|ssl|tls)".to_string(),
46 r"(?i)skip[ _\-]?(verify|validation|check)".to_string(),
47 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 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#[derive(Clone, Debug)]
74pub struct ForbiddenMatch {
75 pub line: String,
76 pub pattern: String,
77}
78
79#[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 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
101pub 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 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 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 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}