1use 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#[derive(Debug, thiserror::Error)]
20pub enum PatchIntegrityConfigError {
21 #[error("invalid patch integrity forbidden pattern `{pattern}`: {source}")]
23 InvalidForbiddenPattern {
24 pattern: String,
25 #[source]
26 source: regex::Error,
27 },
28}
29
30#[derive(Clone)]
32pub struct PatchIntegrityConfig {
33 pub enabled: bool,
35 pub max_additions: usize,
37 pub max_deletions: usize,
39 pub forbidden_patterns: Vec<String>,
41 pub require_balance: bool,
43 pub max_imbalance_ratio: f64,
45}
46
47fn default_forbidden_patterns() -> Vec<String> {
48 vec![
49 r"(?i)disable[ _\-]?(security|auth|ssl|tls)".to_string(),
51 r"(?i)skip[ _\-]?(verify|validation|check)".to_string(),
52 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 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#[derive(Clone, Debug)]
79pub struct ForbiddenMatch {
80 pub line: String,
81 pub pattern: String,
82}
83
84#[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 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#[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 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 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 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}