Skip to main content

mati_core/hooks/decide/
config_change.rs

1//! Pure invariant checks for Claude Code `ConfigChange` payloads.
2//!
3//! The platform has already written the new settings value when this check
4//! runs. These functions therefore compare that value with mati's expected
5//! registrations; they never attempt to compute a diff or infer an old value.
6
7use std::collections::BTreeSet;
8use std::path::Path;
9
10use serde_json::Value;
11
12/// A single mati-owned configuration invariant that was absent or altered.
13#[derive(Debug, Clone, PartialEq, Eq)]
14pub struct ConfigViolation {
15    /// Stable label for the setting or registration that was checked.
16    pub setting: String,
17    /// The value mati registered or otherwise expects.
18    pub old_value: String,
19    /// The value present in the new settings content, or `<removed>`.
20    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
37/// Check every command hook that mati's scaffold registers in project
38/// `settings.json`. User hooks can remain alongside these entries.
39pub 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            // Every entry sharing this matcher, not just the first. Users add
61            // their own hooks beside mati's under the same matcher, and taking
62            // only the first meant mati's entry looked repointed to the user's
63            // command — a block that repair could never satisfy.
64            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                // `hooks.<event>[<command>]`, not a dotted join: mati's commands
79                // are dotfile paths, so joining with `.` produced
80                // `hooks.PreToolUse..claude/hooks/pre-read.sh` in a hash-chained
81                // audit record that mati-cloud reads.
82                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/// The sandbox floor `mati sandbox compile` currently produces, as the guard
141/// expects to find it in `.claude/settings.local.json`.
142///
143/// Ownership differs per surface. Filesystem denies and credential files carry
144/// a path, so containment under `repo_root` decides. A denied domain carries
145/// none, so `domain_universe` — every `db_client` host glob the policy corpus
146/// has named — stands in for it.
147#[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
158/// Check the dynamic sandbox entries that mati materializes in
159/// `.claude/settings.local.json`: filesystem denies, credential files, and
160/// denied domains. Entries mati does not own remain the user's and are neither
161/// required nor reported.
162pub 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        // Only another mati-owned entry can be what this one was changed to.
198        // Naming a user's out-of-repo deny would write a change the user never
199        // made into a hash-chained audit log that mati-cloud reports on.
200        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            // The mode is half the protection, so a downgrade counts: the entry
239            // is still there and the path still matches, but `deny` refuses the
240            // read outright where `mask` only substitutes a sentinel.
241            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    /// A user's own hook sitting beside mati's under the same matcher must not
318    /// read as mati's entry being repointed. Taking only the first matching
319    /// entry reported `<mati command> changed to /my/own/hook.sh`, and no repair
320    /// could ever satisfy it — the guard blocked every settings change forever.
321    #[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    /// The audit `setting` field is read downstream, so it must not carry the
350    /// double dot that joining an event name to a dotfile command produced.
351    #[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    /// Builds an `ExpectedFloor` over borrowed sets. Every field defaults to
471    /// empty, so a test names only the surface it is about.
472    #[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    /// A user's out-of-repo deny is not what mati's entry "changed to". Naming
522    /// it would put a change the user never made into the audit chain.
523    #[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        // Whole array gone, whole block gone, whole file empty — all removal.
577        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    /// Downgrading `deny` to `mask` leaves an entry at the same path, so a
585    /// path-only check reads it as intact. It is not: `deny` refuses the read.
586    #[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    /// A domain has no path, so ownership comes from the policy corpus. A user
642    /// domain left in the array is not the value mati's entry was changed to.
643    #[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        // A glob the policy corpus authored IS a repoint, and is named as one.
655        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    /// Every surface at once: stripping the whole sandbox block must produce a
661    /// violation for each entry mati compiled, not just the filesystem ones.
662    #[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    /// mati owning nothing on a surface means the user's entries there are
684    /// theirs. Extra entries are not tamper and must not block.
685    #[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}