1use std::collections::BTreeSet;
8use std::path::Path;
9
10use serde_json::Value;
11
12#[derive(Debug, Clone, PartialEq, Eq)]
14pub struct ConfigViolation {
15 pub setting: String,
17 pub old_value: String,
19 pub new_value: String,
21}
22
23impl ConfigViolation {
24 fn new(
25 setting: impl Into<String>,
26 old_value: impl Into<String>,
27 new_value: impl Into<String>,
28 ) -> Self {
29 Self {
30 setting: setting.into(),
31 old_value: old_value.into(),
32 new_value: new_value.into(),
33 }
34 }
35}
36
37pub fn project_violations(actual: &Value, expected: &Value) -> Vec<ConfigViolation> {
40 let mut violations = Vec::new();
41 let Some(expected_events) = expected.get("hooks").and_then(Value::as_object) else {
42 return violations;
43 };
44
45 for (event_name, expected_entries) in expected_events {
46 let Some(expected_entries) = expected_entries.as_array() else {
47 continue;
48 };
49 let actual_entries = actual
50 .get("hooks")
51 .and_then(|hooks| hooks.get(event_name))
52 .and_then(Value::as_array);
53
54 for expected_entry in expected_entries {
55 let expected_hooks = expected_entry
56 .get("hooks")
57 .and_then(Value::as_array)
58 .map(Vec::as_slice)
59 .unwrap_or(&[]);
60 let matching_entries: Vec<&Value> = actual_entries
65 .map(|entries| {
66 entries
67 .iter()
68 .filter(|entry| entry_matches(expected_entry, entry))
69 .collect()
70 })
71 .unwrap_or_default();
72
73 for expected_hook in expected_hooks {
74 let expected_command = expected_hook
75 .get("command")
76 .and_then(Value::as_str)
77 .unwrap_or("<invalid-scaffold-command>");
78 let setting = format!("hooks.{event_name}[{expected_command}]");
83 let expected_type = expected_hook
84 .get("type")
85 .and_then(Value::as_str)
86 .unwrap_or("command");
87
88 let hooks_of = |entry: &&Value| {
89 entry
90 .get("hooks")
91 .and_then(Value::as_array)
92 .cloned()
93 .unwrap_or_default()
94 };
95
96 let actual_hook = matching_entries.iter().flat_map(hooks_of).find(|hook| {
97 hook.get("type").and_then(Value::as_str) == Some(expected_type)
98 && hook.get("command").and_then(Value::as_str) == Some(expected_command)
99 });
100
101 let Some(actual_hook) = actual_hook else {
102 let found = matching_entries
103 .iter()
104 .flat_map(hooks_of)
105 .find_map(|hook| {
106 (hook.get("type").and_then(Value::as_str) == Some(expected_type)).then(
107 || {
108 hook.get("command")
109 .and_then(Value::as_str)
110 .unwrap_or("<invalid-command>")
111 .to_string()
112 },
113 )
114 })
115 .unwrap_or_else(|| "<removed>".to_string());
116 violations.push(ConfigViolation::new(setting, expected_command, found));
117 continue;
118 };
119
120 if let Some(expected_timeout) = expected_hook.get("timeout").and_then(Value::as_u64)
121 {
122 let actual_timeout = actual_hook.get("timeout").and_then(Value::as_u64);
123 if actual_timeout.is_none_or(|timeout| timeout < expected_timeout) {
124 violations.push(ConfigViolation::new(
125 format!("{setting}.timeout"),
126 expected_timeout.to_string(),
127 actual_hook
128 .get("timeout")
129 .map(value_label)
130 .unwrap_or_else(|| "<removed>".to_string()),
131 ));
132 }
133 }
134 }
135 }
136 }
137 violations
138}
139
140#[derive(Debug, Clone, Copy)]
148pub struct ExpectedFloor<'a> {
149 pub repo_root: &'a Path,
150 pub deny_read: &'a BTreeSet<String>,
151 pub deny_write: &'a BTreeSet<String>,
152 pub credentials_deny: &'a BTreeSet<String>,
153 pub credentials_mask: &'a BTreeSet<String>,
154 pub denied_domains: &'a BTreeSet<String>,
155 pub domain_universe: &'a BTreeSet<String>,
156}
157
158pub fn local_violations(actual: &Value, expected: &ExpectedFloor<'_>) -> Vec<ConfigViolation> {
163 let mut violations = Vec::new();
164 check_deny_array(
165 actual,
166 "denyRead",
167 expected.repo_root,
168 expected.deny_read,
169 &mut violations,
170 );
171 check_deny_array(
172 actual,
173 "denyWrite",
174 expected.repo_root,
175 expected.deny_write,
176 &mut violations,
177 );
178 check_credentials(actual, expected, &mut violations);
179 check_denied_domains(actual, expected, &mut violations);
180 violations
181}
182
183fn check_deny_array(
184 actual: &Value,
185 key: &str,
186 repo_root: &Path,
187 expected: &BTreeSet<String>,
188 violations: &mut Vec<ConfigViolation>,
189) {
190 let actual_values = actual
191 .pointer(&format!("/sandbox/filesystem/{key}"))
192 .and_then(Value::as_array);
193 for expected_value in expected {
194 if actual_values.is_some_and(|values| contains_str(values, expected_value)) {
195 continue;
196 }
197 let found = actual_values
201 .and_then(|values| {
202 values.iter().filter_map(Value::as_str).find(|value| {
203 Path::new(value).starts_with(repo_root) && !expected.contains(*value)
204 })
205 })
206 .unwrap_or("<removed>");
207 violations.push(ConfigViolation::new(
208 format!("sandbox.filesystem.{key}"),
209 expected_value,
210 found,
211 ));
212 }
213}
214
215fn check_credentials(
216 actual: &Value,
217 expected: &ExpectedFloor<'_>,
218 violations: &mut Vec<ConfigViolation>,
219) {
220 let files = actual
221 .pointer("/sandbox/credentials/files")
222 .and_then(Value::as_array)
223 .map(Vec::as_slice)
224 .unwrap_or(&[]);
225 for (mode, paths) in [
226 ("deny", expected.credentials_deny),
227 ("mask", expected.credentials_mask),
228 ] {
229 for path in paths {
230 let same_path =
231 |entry: &&Value| entry.get("path").and_then(Value::as_str) == Some(path.as_str());
232 let intact = files.iter().any(|entry| {
233 same_path(&entry) && entry.get("mode").and_then(Value::as_str) == Some(mode)
234 });
235 if intact {
236 continue;
237 }
238 let found = files
242 .iter()
243 .find(same_path)
244 .map(|entry| {
245 format!(
246 "mode={}",
247 entry
248 .get("mode")
249 .and_then(Value::as_str)
250 .unwrap_or("<invalid>")
251 )
252 })
253 .unwrap_or_else(|| "<removed>".to_string());
254 violations.push(ConfigViolation::new(
255 format!("sandbox.credentials.files[{mode}]"),
256 path,
257 found,
258 ));
259 }
260 }
261}
262
263fn check_denied_domains(
264 actual: &Value,
265 expected: &ExpectedFloor<'_>,
266 violations: &mut Vec<ConfigViolation>,
267) {
268 let actual_domains = actual
269 .pointer("/sandbox/network/deniedDomains")
270 .and_then(Value::as_array);
271 for domain in expected.denied_domains {
272 if actual_domains.is_some_and(|values| contains_str(values, domain)) {
273 continue;
274 }
275 let found = actual_domains
276 .and_then(|values| {
277 values.iter().filter_map(Value::as_str).find(|value| {
278 expected.domain_universe.contains(*value)
279 && !expected.denied_domains.contains(*value)
280 })
281 })
282 .unwrap_or("<removed>");
283 violations.push(ConfigViolation::new(
284 "sandbox.network.deniedDomains",
285 domain,
286 found,
287 ));
288 }
289}
290
291fn contains_str(values: &[Value], needle: &str) -> bool {
292 values.iter().any(|value| value.as_str() == Some(needle))
293}
294
295fn entry_matches(expected: &Value, actual: &Value) -> bool {
296 ["matcher", "async"]
297 .iter()
298 .all(|key| match (expected.get(*key), actual.get(*key)) {
299 (None, None) => true,
300 (Some(expected), Some(actual)) => expected == actual,
301 _ => false,
302 })
303}
304
305fn value_label(value: &Value) -> String {
306 value
307 .as_str()
308 .map(ToOwned::to_owned)
309 .unwrap_or_else(|| value.to_string())
310}
311
312#[cfg(test)]
313mod tests {
314 use super::*;
315 use serde_json::json;
316
317 #[test]
322 fn user_hook_beside_mati_under_the_same_matcher_is_not_a_violation() {
323 let expected = json!({
324 "hooks": {
325 "PreToolUse": [{
326 "matcher": "Bash",
327 "hooks": [{"type": "command", "command": ".claude/hooks/pre-bash.sh", "timeout": 4}]
328 }]
329 }
330 });
331 let actual = json!({
332 "hooks": {
333 "PreToolUse": [{
334 "matcher": "Bash",
335 "hooks": [{"type": "command", "command": "/my/own/hook.sh"}]
336 }, {
337 "matcher": "Bash",
338 "hooks": [{"type": "command", "command": ".claude/hooks/pre-bash.sh", "timeout": 4}]
339 }]
340 }
341 });
342 assert_eq!(
343 project_violations(&actual, &expected),
344 Vec::new(),
345 "mati's entry is present in a later group; a user hook alongside is not tamper"
346 );
347 }
348
349 #[test]
352 fn violation_setting_label_has_no_double_dot() {
353 let expected = json!({
354 "hooks": {"PreToolUse": [{
355 "matcher": "Bash",
356 "hooks": [{"type": "command", "command": ".claude/hooks/pre-bash.sh"}]
357 }]}
358 });
359 let violations = project_violations(&json!({"hooks": {}}), &expected);
360 assert_eq!(violations.len(), 1);
361 assert!(
362 !violations[0].setting.contains(".."),
363 "setting label must not contain `..`, got {}",
364 violations[0].setting
365 );
366 }
367
368 fn project_expected() -> Value {
369 json!({
370 "hooks": {
371 "PreToolUse": [{
372 "matcher": "Read|Glob|Grep",
373 "hooks": [{"type": "command", "command": ".claude/hooks/pre-read.sh", "timeout": 4}]
374 }, {
375 "matcher": "Edit|Write|NotebookEdit",
376 "hooks": [{"type": "command", "command": ".claude/hooks/pre-edit.sh", "timeout": 4}]
377 }],
378 "ConfigChange": [{
379 "matcher": "user_settings|project_settings|local_settings|policy_settings|skills",
380 "hooks": [{"type": "command", "command": ".claude/hooks/config-change.sh", "timeout": 4}]
381 }]
382 }
383 })
384 }
385
386 fn valid_project() -> Value {
387 json!({
388 "hooks": {
389 "PreToolUse": [
390 {"matcher": "Read|Glob|Grep", "hooks": [{"type": "command", "command": ".claude/hooks/pre-read.sh", "timeout": 4}, {"type": "command", "command": "./custom.sh"}]},
391 {"matcher": "Edit|Write|NotebookEdit", "hooks": [{"type": "command", "command": ".claude/hooks/pre-edit.sh", "timeout": 5}]}
392 ],
393 "ConfigChange": [{"matcher": "user_settings|project_settings|local_settings|policy_settings|skills", "hooks": [{"type": "command", "command": ".claude/hooks/config-change.sh", "timeout": 5}] }]
394 }
395 })
396 }
397
398 #[test]
399 fn intact_entries_allow_and_preserve_user_hooks() {
400 assert!(project_violations(&valid_project(), &project_expected()).is_empty());
401 }
402
403 #[test]
404 fn removed_hooks_key_denies() {
405 let actual = json!({});
406 assert!(!project_violations(&actual, &project_expected()).is_empty());
407 }
408
409 #[test]
410 fn empty_hooks_denies() {
411 let actual = json!({"hooks": {}});
412 assert!(!project_violations(&actual, &project_expected()).is_empty());
413 }
414
415 #[test]
416 fn removed_event_array_denies_but_unrelated_event_is_irrelevant() {
417 let actual = json!({"hooks": {"PreToolUse": valid_project()["hooks"]["PreToolUse"]}});
418 assert!(!project_violations(&actual, &project_expected()).is_empty());
419
420 let actual = json!({"hooks": {"ConfigChange": valid_project()["hooks"]["ConfigChange"]}});
421 assert!(!project_violations(&actual, &project_expected()).is_empty());
422 }
423
424 #[test]
425 fn repointed_command_denies_and_reports_new_command() {
426 let mut actual = valid_project();
427 actual["hooks"]["ConfigChange"][0]["hooks"][0]["command"] = json!("/bin/true");
428 let violations = project_violations(&actual, &project_expected());
429 assert!(
430 violations
431 .iter()
432 .any(|v| v.old_value == ".claude/hooks/config-change.sh"
433 && v.new_value == "/bin/true")
434 );
435 }
436
437 #[test]
438 fn lower_timeout_denies_but_raise_allows() {
439 let mut actual = valid_project();
440 actual["hooks"]["ConfigChange"][0]["hooks"][0]["timeout"] = json!(0);
441 assert!(!project_violations(&actual, &project_expected()).is_empty());
442
443 actual["hooks"]["ConfigChange"][0]["hooks"][0]["timeout"] = json!(1);
444 assert!(!project_violations(&actual, &project_expected()).is_empty());
445
446 actual["hooks"]["ConfigChange"][0]["hooks"][0]["timeout"] = json!(5);
447 assert!(project_violations(&actual, &project_expected()).is_empty());
448 }
449
450 #[test]
451 fn removed_entry_among_other_hooks_denies() {
452 let mut actual = valid_project();
453 actual["hooks"]["PreToolUse"][0]["hooks"] =
454 json!([{"type": "command", "command": "./custom.sh"}]);
455 assert!(!project_violations(&actual, &project_expected()).is_empty());
456 }
457
458 #[test]
459 fn removing_the_guard_entry_itself_denies() {
460 let mut actual = valid_project();
461 actual["hooks"]["ConfigChange"] = json!([]);
462 assert!(!project_violations(&actual, &project_expected()).is_empty());
463 assert!(project_violations(&valid_project(), &project_expected()).is_empty());
464 }
465
466 fn set(values: &[&str]) -> BTreeSet<String> {
467 values.iter().map(|v| (*v).to_string()).collect()
468 }
469
470 #[derive(Default)]
473 struct Floor {
474 deny_read: BTreeSet<String>,
475 deny_write: BTreeSet<String>,
476 credentials_deny: BTreeSet<String>,
477 credentials_mask: BTreeSet<String>,
478 denied_domains: BTreeSet<String>,
479 domain_universe: BTreeSet<String>,
480 }
481
482 impl Floor {
483 fn expected(&self) -> ExpectedFloor<'_> {
484 ExpectedFloor {
485 repo_root: Path::new("/repo"),
486 deny_read: &self.deny_read,
487 deny_write: &self.deny_write,
488 credentials_deny: &self.credentials_deny,
489 credentials_mask: &self.credentials_mask,
490 denied_domains: &self.denied_domains,
491 domain_universe: &self.domain_universe,
492 }
493 }
494 }
495
496 #[test]
497 fn local_entries_allow_user_entries_and_deny_missing_or_repointed_entries() {
498 let floor = Floor {
499 deny_read: set(&["/repo/secret.txt"]),
500 deny_write: set(&["/repo/src/lib.rs"]),
501 ..Floor::default()
502 };
503 let valid = json!({"sandbox": {"filesystem": {
504 "denyRead": ["/user/entry", "/repo/secret.txt"],
505 "denyWrite": ["/repo/src/lib.rs", "/user/other"]
506 }}});
507 assert!(local_violations(&valid, &floor.expected()).is_empty());
508
509 let removed = json!({"sandbox": {"filesystem": {
510 "denyRead": [], "denyWrite": ["/repo/src/lib.rs"]
511 }}});
512 assert!(!local_violations(&removed, &floor.expected()).is_empty());
513
514 let repointed = json!({"sandbox": {"filesystem": {
515 "denyRead": ["/repo/other.txt"], "denyWrite": ["/repo/src/lib.rs"]
516 }}});
517 let violations = local_violations(&repointed, &floor.expected());
518 assert!(violations.iter().any(|v| v.new_value == "/repo/other.txt"));
519 }
520
521 #[test]
524 fn removed_deny_beside_a_user_entry_reports_removed_not_the_user_entry() {
525 let floor = Floor {
526 deny_read: set(&["/repo/secret.txt"]),
527 ..Floor::default()
528 };
529 let actual = json!({"sandbox": {"filesystem": {"denyRead": ["~/.ssh", "/elsewhere/x"]}}});
530 let violations = local_violations(&actual, &floor.expected());
531 assert_eq!(violations.len(), 1);
532 assert_eq!(violations[0].new_value, "<removed>");
533 }
534
535 #[test]
536 fn empty_expected_local_floor_allows_empty_or_missing_sandbox() {
537 let floor = Floor::default();
538 assert!(local_violations(&json!({}), &floor.expected()).is_empty());
539 assert!(
540 local_violations(&json!({"sandbox": {"filesystem": {}}}), &floor.expected()).is_empty()
541 );
542 }
543
544 fn credentials(entries: Value) -> Value {
545 json!({"sandbox": {"credentials": {"files": entries}}})
546 }
547
548 #[test]
549 fn intact_credentials_entries_allow_and_preserve_user_entries() {
550 let floor = Floor {
551 credentials_deny: set(&["/repo/vault/prod.pem"]),
552 credentials_mask: set(&["/repo/.env"]),
553 ..Floor::default()
554 };
555 let actual = credentials(json!([
556 {"mode": "mask", "path": "~/.aws/credentials"},
557 {"mode": "deny", "path": "/repo/vault/prod.pem"},
558 {"mode": "mask", "path": "/repo/.env"}
559 ]));
560 assert!(local_violations(&actual, &floor.expected()).is_empty());
561 }
562
563 #[test]
564 fn removed_credentials_entry_denies() {
565 let floor = Floor {
566 credentials_deny: set(&["/repo/vault/prod.pem"]),
567 ..Floor::default()
568 };
569 let actual = credentials(json!([{"mode": "mask", "path": "~/.aws/credentials"}]));
570 let violations = local_violations(&actual, &floor.expected());
571 assert_eq!(violations.len(), 1);
572 assert_eq!(violations[0].setting, "sandbox.credentials.files[deny]");
573 assert_eq!(violations[0].old_value, "/repo/vault/prod.pem");
574 assert_eq!(violations[0].new_value, "<removed>");
575
576 assert!(
578 !local_violations(&json!({"sandbox": {"credentials": {}}}), &floor.expected())
579 .is_empty()
580 );
581 assert!(!local_violations(&json!({}), &floor.expected()).is_empty());
582 }
583
584 #[test]
587 fn downgraded_credentials_mode_denies_and_reports_the_new_mode() {
588 let floor = Floor {
589 credentials_deny: set(&["/repo/vault/prod.pem"]),
590 ..Floor::default()
591 };
592 let actual = credentials(json!([{"mode": "mask", "path": "/repo/vault/prod.pem"}]));
593 let violations = local_violations(&actual, &floor.expected());
594 assert_eq!(violations.len(), 1);
595 assert_eq!(violations[0].new_value, "mode=mask");
596 }
597
598 #[test]
599 fn repointed_credentials_path_denies() {
600 let floor = Floor {
601 credentials_mask: set(&["/repo/.env"]),
602 ..Floor::default()
603 };
604 let actual = credentials(json!([{"mode": "mask", "path": "/repo/.env.decoy"}]));
605 let violations = local_violations(&actual, &floor.expected());
606 assert_eq!(violations.len(), 1);
607 assert_eq!(violations[0].setting, "sandbox.credentials.files[mask]");
608 assert_eq!(violations[0].new_value, "<removed>");
609 }
610
611 fn domains(entries: Value) -> Value {
612 json!({"sandbox": {"network": {"deniedDomains": entries}}})
613 }
614
615 #[test]
616 fn intact_denied_domain_allows_beside_a_user_domain() {
617 let floor = Floor {
618 denied_domains: set(&["*.prod.internal"]),
619 domain_universe: set(&["*.prod.internal", "*.staging.internal"]),
620 ..Floor::default()
621 };
622 let actual = domains(json!(["user-added.example.com", "*.prod.internal"]));
623 assert!(local_violations(&actual, &floor.expected()).is_empty());
624 }
625
626 #[test]
627 fn removed_denied_domain_denies() {
628 let floor = Floor {
629 denied_domains: set(&["*.prod.internal"]),
630 domain_universe: set(&["*.prod.internal"]),
631 ..Floor::default()
632 };
633 let violations = local_violations(&domains(json!([])), &floor.expected());
634 assert_eq!(violations.len(), 1);
635 assert_eq!(violations[0].setting, "sandbox.network.deniedDomains");
636 assert_eq!(violations[0].old_value, "*.prod.internal");
637 assert_eq!(violations[0].new_value, "<removed>");
638 assert!(!local_violations(&json!({}), &floor.expected()).is_empty());
639 }
640
641 #[test]
644 fn removed_domain_beside_a_user_domain_reports_removed() {
645 let floor = Floor {
646 denied_domains: set(&["*.prod.internal"]),
647 domain_universe: set(&["*.prod.internal", "*.staging.internal"]),
648 ..Floor::default()
649 };
650 let actual = domains(json!(["user-added.example.com"]));
651 let violations = local_violations(&actual, &floor.expected());
652 assert_eq!(violations[0].new_value, "<removed>");
653
654 let actual = domains(json!(["user-added.example.com", "*.staging.internal"]));
656 let violations = local_violations(&actual, &floor.expected());
657 assert_eq!(violations[0].new_value, "*.staging.internal");
658 }
659
660 #[test]
663 fn stripping_the_whole_sandbox_block_denies_on_every_surface() {
664 let floor = Floor {
665 deny_read: set(&["/repo/secret.txt"]),
666 deny_write: set(&["/repo/src/lib.rs"]),
667 credentials_deny: set(&["/repo/vault/prod.pem"]),
668 credentials_mask: set(&["/repo/.env"]),
669 denied_domains: set(&["*.prod.internal"]),
670 domain_universe: set(&["*.prod.internal"]),
671 };
672 let violations = local_violations(&json!({"other": true}), &floor.expected());
673 assert_eq!(violations.len(), 5);
674 let settings: Vec<&str> = violations.iter().map(|v| v.setting.as_str()).collect();
675 assert!(settings.contains(&"sandbox.filesystem.denyRead"));
676 assert!(settings.contains(&"sandbox.filesystem.denyWrite"));
677 assert!(settings.contains(&"sandbox.credentials.files[deny]"));
678 assert!(settings.contains(&"sandbox.credentials.files[mask]"));
679 assert!(settings.contains(&"sandbox.network.deniedDomains"));
680 assert!(violations.iter().all(|v| v.new_value == "<removed>"));
681 }
682
683 #[test]
686 fn user_only_credentials_and_domains_are_never_judged() {
687 let floor = Floor::default();
688 let actual = json!({"sandbox": {
689 "credentials": {"files": [{"mode": "deny", "path": "~/.aws/credentials"}]},
690 "network": {"deniedDomains": ["user-added.example.com"]}
691 }});
692 assert!(local_violations(&actual, &floor.expected()).is_empty());
693 }
694
695 #[test]
696 fn successive_values_are_checked_statelessly() {
697 let expected = project_expected();
698 assert!(project_violations(&valid_project(), &expected).is_empty());
699 assert!(!project_violations(&json!({}), &expected).is_empty());
700 assert!(project_violations(&valid_project(), &expected).is_empty());
701 }
702}