Skip to main content

mati_core/hooks/
policy_match.rs

1//! Pure local-policy predicate compilation and matching.
2//!
3//! This module owns no store or daemon state. Milestone 3 can place the same
4//! compiled [`PolicyMatcherSet`] behind the daemon's live policy set without
5//! changing the matching semantics.
6
7use anyhow::{Context, Result};
8use globset::{Glob, GlobMatcher};
9
10use super::decide::{is_known_action_tool, Action};
11use crate::store::{PolicyRecord, PolicyStage, Record, RecordLifecycle};
12
13struct CompiledPolicy {
14    key: String,
15    policy: PolicyRecord,
16    host_glob: Option<GlobMatcher>,
17    target_path_glob: Option<GlobMatcher>,
18    command_glob: Option<GlobMatcher>,
19}
20
21/// A set of staged, active policies with their glob predicates compiled.
22pub struct PolicyMatcherSet {
23    policies: Vec<CompiledPolicy>,
24}
25
26/// A policy that matched an [`Action`].
27pub struct MatchedPolicy<'a> {
28    pub key: &'a str,
29    pub policy: &'a PolicyRecord,
30}
31
32impl PolicyMatcherSet {
33    /// Create an empty matcher set.
34    pub fn empty() -> Self {
35        Self {
36            policies: Vec::new(),
37        }
38    }
39
40    /// Compile active, non-off policy records from universal store records.
41    pub fn from_records(records: &[Record]) -> Result<Self> {
42        let policies = records.iter().filter_map(|record| {
43            if record.category != crate::store::Category::Policy
44                || !matches!(record.lifecycle, RecordLifecycle::Active)
45            {
46                return None;
47            }
48            let policy = record.payload_as::<PolicyRecord>()?;
49            (!matches!(policy.stage, PolicyStage::Off)).then(|| (record.key.clone(), policy))
50        });
51        Self::from_policies(policies)
52    }
53
54    /// Compile active, non-off policy records while isolating bad records.
55    ///
56    /// A malformed policy must not prevent the daemon from evaluating every
57    /// other policy at boot or after a live refresh.
58    pub fn from_records_lenient(records: &[Record]) -> Self {
59        let mut matcher = Self::empty();
60        for record in records {
61            if record.category != crate::store::Category::Policy
62                || !matches!(record.lifecycle, RecordLifecycle::Active)
63            {
64                continue;
65            }
66            let Some(policy) = record.payload_as::<PolicyRecord>() else {
67                tracing::warn!(key = %record.key, "skipping policy with invalid payload");
68                continue;
69            };
70            if matches!(policy.stage, PolicyStage::Off) {
71                continue;
72            }
73            match Self::from_policies([(record.key.clone(), policy)]) {
74                Ok(mut compiled) => matcher.policies.append(&mut compiled.policies),
75                Err(error) => tracing::warn!(
76                    key = %record.key,
77                    error = %error,
78                    "skipping policy that failed matcher compilation"
79                ),
80            }
81        }
82        matcher
83    }
84
85    /// Compile keyed policy payloads. This is useful to pure callers that have
86    /// already separated the universal record envelope.
87    pub fn from_policies<I>(policies: I) -> Result<Self>
88    where
89        I: IntoIterator<Item = (String, PolicyRecord)>,
90    {
91        let mut compiled = Vec::new();
92        for (key, policy) in policies {
93            // A trigger with no predicate would make every present-field check
94            // vacuous and gate every governed action. Authoring rejects it, but
95            // a record stored before that guard existed must not compile into a
96            // repo-wide gate either — refusing here makes it inert instead.
97            if policy.trigger.tool.is_none()
98                && policy.trigger.host_glob.is_none()
99                && policy.trigger.target_path_glob.is_none()
100                && policy.trigger.command_glob.is_none()
101            {
102                anyhow::bail!("policy {key} has an empty trigger; it would match every action");
103            }
104            let host_glob = policy
105                .trigger
106                .host_glob
107                .as_deref()
108                .map(|pattern| {
109                    Glob::new(pattern)
110                        .with_context(|| format!("invalid host_glob for policy {key}"))
111                        .map(|glob| glob.compile_matcher())
112                })
113                .transpose()?;
114            let target_path_glob = policy
115                .trigger
116                .target_path_glob
117                .as_deref()
118                .map(|pattern| {
119                    Glob::new(pattern)
120                        .with_context(|| format!("invalid target_path_glob for policy {key}"))
121                        .map(|glob| glob.compile_matcher())
122                })
123                .transpose()?;
124            let command_glob = policy
125                .trigger
126                .command_glob
127                .as_deref()
128                .map(|pattern| {
129                    Glob::new(pattern)
130                        .with_context(|| format!("invalid command_glob for policy {key}"))
131                        .map(|glob| glob.compile_matcher())
132                })
133                .transpose()?;
134            compiled.push(CompiledPolicy {
135                key,
136                policy,
137                host_glob,
138                target_path_glob,
139                command_glob,
140            });
141        }
142        Ok(Self { policies: compiled })
143    }
144
145    /// Return every policy whose present predicates all match the action.
146    pub fn matches(&self, action: &Action) -> Vec<MatchedPolicy<'_>> {
147        self.policies
148            .iter()
149            .filter(|compiled| {
150                let trigger = &compiled.policy.trigger;
151                let tool_matches = trigger
152                    .tool
153                    .as_deref()
154                    .is_none_or(|tool| is_known_action_tool(tool) && tool == action.tool);
155                let host_matches = compiled.host_glob.as_ref().is_none_or(|glob| {
156                    action
157                        .host
158                        .as_deref()
159                        .is_some_and(|host| glob.is_match(host))
160                });
161                let path_matches = compiled.target_path_glob.as_ref().is_none_or(|glob| {
162                    action
163                        .target_path
164                        .iter()
165                        .chain(action.files.iter())
166                        .any(|path| glob.is_match(path))
167                });
168                // Match the normalized command tokens joined by single spaces —
169                // the same shape `argv` carries after wrapper stripping and
170                // `sh -c` unwrap, so `sudo dd …` matches a `dd *` glob.
171                let command_matches = compiled.command_glob.as_ref().is_none_or(|glob| {
172                    !action.argv.is_empty() && glob.is_match(action.argv.join(" "))
173                });
174                tool_matches && host_matches && path_matches && command_matches
175            })
176            .map(|compiled| MatchedPolicy {
177                key: &compiled.key,
178                policy: &compiled.policy,
179            })
180            .collect()
181    }
182
183    /// Detect a record-only signal when an unclassified command mentions a
184    /// meaningful literal from an active host/path trigger; this is
185    /// intentionally false-positive because raw text alone cannot prove use.
186    pub fn detect_unclassified_literal_bypass(
187        &self,
188        action: &Action,
189        raw_command: &str,
190    ) -> Option<&str> {
191        if is_known_action_tool(&action.tool) {
192            return None;
193        }
194        self.policies.iter().find_map(|compiled| {
195            if matches!(compiled.policy.stage, PolicyStage::Off) {
196                return None;
197            }
198            let trigger = &compiled.policy.trigger;
199            let host_literal = trigger.host_glob.as_deref().and_then(longest_literal_run);
200            let path_literal = trigger
201                .target_path_glob
202                .as_deref()
203                .and_then(longest_literal_run);
204            [host_literal, path_literal]
205                .into_iter()
206                .flatten()
207                .any(|literal| raw_command.contains(literal))
208                .then_some(compiled.key.as_str())
209        })
210    }
211}
212
213fn longest_literal_run(pattern: &str) -> Option<&str> {
214    let mut best: Option<&str> = None;
215    let mut start = 0;
216    for (index, character) in pattern.char_indices() {
217        if matches!(character, '*' | '?' | '[' | ']' | '{' | '}') {
218            let literal = &pattern[start..index];
219            if best.is_none_or(|current| literal.len() > current.len()) {
220                best = Some(literal);
221            }
222            start = index + character.len_utf8();
223        }
224    }
225    let literal = &pattern[start..];
226    if best.is_none_or(|current| literal.len() > current.len()) {
227        best = Some(literal);
228    }
229    best.filter(|literal| literal.len() >= 4)
230}
231
232#[cfg(test)]
233mod tests {
234    use super::*;
235    use crate::hooks::decide::Action;
236    use crate::store::{
237        PolicyFreshness, PolicyMode, PolicyRequires, PolicyTrigger, Priority, ReceiptSource,
238    };
239
240    fn policy(name: &str, trigger: PolicyTrigger) -> PolicyRecord {
241        PolicyRecord {
242            name: name.into(),
243            rule: "Consult the required knowledge first.".into(),
244            reason: "The action needs current context because production state changes.".into(),
245            scope: "repo".into(),
246            mode: PolicyMode::Block,
247            trigger,
248            requires: PolicyRequires {
249                key: "schema:orders".into(),
250                via: vec![ReceiptSource::MemGet],
251                freshness: PolicyFreshness {
252                    ttl_secs: 900,
253                    fingerprint: false,
254                },
255            },
256            stage: PolicyStage::Enforce,
257            severity: Priority::High,
258            created_by: "test".into(),
259        }
260    }
261
262    fn action(host: Option<&str>, files: &[&str]) -> Action {
263        Action {
264            tool: "db_client".into(),
265            target_path: files.first().map(|path| (*path).into()),
266            host: host.map(str::to_string),
267            argv: vec!["psql".into()],
268            files: files.iter().map(|path| (*path).into()).collect(),
269        }
270    }
271
272    #[test]
273    fn tool_only_policy_matches() {
274        let set = PolicyMatcherSet::from_policies([(
275            "policy:db".into(),
276            policy(
277                "DB",
278                PolicyTrigger {
279                    tool: Some("db_client".into()),
280                    ..Default::default()
281                },
282            ),
283        )])
284        .unwrap();
285        assert_eq!(set.matches(&action(None, &[]))[0].key, "policy:db");
286        assert!(set
287            .matches(&Action {
288                tool: "file_read".into(),
289                ..action(None, &[])
290            })
291            .is_empty());
292    }
293
294    #[test]
295    fn command_glob_matches_pathless_verb() {
296        let set = PolicyMatcherSet::from_policies([(
297            "policy:dd".into(),
298            policy(
299                "dd",
300                PolicyTrigger {
301                    command_glob: Some("dd *".into()),
302                    ..Default::default()
303                },
304            ),
305        )])
306        .unwrap();
307        let dd = Action {
308            tool: "unknown".into(),
309            target_path: None,
310            host: None,
311            argv: vec!["dd".into(), "if=/dev/zero".into(), "of=/dev/sda".into()],
312            files: vec![],
313        };
314        assert_eq!(set.matches(&dd)[0].key, "policy:dd");
315        // `ddrescue` is a different command word: the space in `dd *` guards it.
316        let ddrescue = Action {
317            argv: vec!["ddrescue".into(), "x".into()],
318            ..dd.clone()
319        };
320        assert!(set.matches(&ddrescue).is_empty());
321        // Empty argv (an edit action) never matches a command glob.
322        let edit = Action {
323            argv: vec![],
324            ..dd.clone()
325        };
326        assert!(set.matches(&edit).is_empty());
327    }
328
329    #[test]
330    fn command_glob_matches_normalized_argv() {
331        // `sudo dd …` normalizes to argv starting `dd`, so the glob still fires.
332        let set = PolicyMatcherSet::from_policies([(
333            "policy:dd".into(),
334            policy(
335                "dd",
336                PolicyTrigger {
337                    command_glob: Some("dd *".into()),
338                    ..Default::default()
339                },
340            ),
341        )])
342        .unwrap();
343        let action = crate::hooks::decide::normalize_action(Some("sudo dd if=x of=/dev/sda"), None);
344        assert_eq!(set.matches(&action)[0].key, "policy:dd");
345    }
346
347    #[test]
348    fn host_glob_matches_and_rejects_nonmatching_hosts() {
349        let set = PolicyMatcherSet::from_policies([(
350            "policy:prod".into(),
351            policy(
352                "Production",
353                PolicyTrigger {
354                    host_glob: Some("*prod*".into()),
355                    ..Default::default()
356                },
357            ),
358        )])
359        .unwrap();
360        assert_eq!(set.matches(&action(Some("db.prod.internal"), &[])).len(), 1);
361        assert!(set
362            .matches(&action(Some("db.dev.internal"), &[]))
363            .is_empty());
364    }
365
366    #[test]
367    fn unclassified_literal_bypass_detection_is_record_only() {
368        let set = PolicyMatcherSet::from_policies([(
369            "policy:prod".into(),
370            policy(
371                "Production",
372                PolicyTrigger {
373                    tool: Some("db_client".into()),
374                    host_glob: Some("*prod-codex*".into()),
375                    ..Default::default()
376                },
377            ),
378        )])
379        .unwrap();
380        let unclassified = Action {
381            tool: "unknown".into(),
382            target_path: None,
383            host: None,
384            argv: vec![],
385            files: vec![],
386        };
387        assert_eq!(
388            set.detect_unclassified_literal_bypass(
389                &unclassified,
390                r#"db_client=psql; "$db_client" -h db.prod-codex.internal -c 'SELECT 1'"#,
391            ),
392            Some("policy:prod")
393        );
394        assert_eq!(
395            set.detect_unclassified_literal_bypass(
396                &unclassified,
397                r#"db_client=psql; "$db_client" -h db.dev.internal -c 'SELECT 1'"#,
398            ),
399            None
400        );
401        assert_eq!(
402            set.detect_unclassified_literal_bypass(
403                &action(Some("db.prod-codex.internal"), &[]),
404                "psql -h db.prod-codex.internal -c select",
405            ),
406            None
407        );
408    }
409
410    #[test]
411    fn unclassified_literal_bypass_skips_off_and_short_literals() {
412        let off = PolicyRecord {
413            stage: PolicyStage::Off,
414            ..policy(
415                "Off",
416                PolicyTrigger {
417                    host_glob: Some("*prod-codex*".into()),
418                    ..Default::default()
419                },
420            )
421        };
422        let short = policy(
423            "Short",
424            PolicyTrigger {
425                host_glob: Some("*abc*".into()),
426                ..Default::default()
427            },
428        );
429        let set = PolicyMatcherSet::from_policies([
430            ("policy:off".into(), off),
431            ("policy:short".into(), short),
432        ])
433        .unwrap();
434        let action = Action {
435            tool: "unknown".into(),
436            target_path: None,
437            host: None,
438            argv: vec![],
439            files: vec![],
440        };
441        assert_eq!(
442            set.detect_unclassified_literal_bypass(&action, "db.prod-codex.internal abc"),
443            None
444        );
445    }
446
447    #[test]
448    fn target_path_glob_and_predicates_use_and_semantics() {
449        let set = PolicyMatcherSet::from_policies([(
450            "policy:sql-prod".into(),
451            policy(
452                "SQL production",
453                PolicyTrigger {
454                    tool: Some("db_client".into()),
455                    host_glob: Some("*prod*".into()),
456                    target_path_glob: Some("**/*.sql".into()),
457                    command_glob: None,
458                },
459            ),
460        )])
461        .unwrap();
462        assert_eq!(
463            set.matches(&action(Some("prod"), &["migrations/x.sql"]))
464                .len(),
465            1
466        );
467        assert!(set
468            .matches(&action(Some("dev"), &["migrations/x.sql"]))
469            .is_empty());
470        assert!(set
471            .matches(&action(Some("prod"), &["migrations/x.rs"]))
472            .is_empty());
473    }
474
475    #[test]
476    fn disabled_and_tombstoned_records_are_excluded() {
477        let mut disabled = policy("Disabled", PolicyTrigger::default());
478        disabled.stage = PolicyStage::Off;
479        let mut disabled_record =
480            crate::store::policy_ops::record_for("policy:disabled", &disabled).unwrap();
481        let tombstone = crate::store::policy_ops::record_for(
482            "policy:tombstone",
483            &policy("Tombstone", PolicyTrigger::default()),
484        )
485        .unwrap();
486        let mut tombstone = tombstone;
487        tombstone.lifecycle = RecordLifecycle::Tombstoned {
488            reason: crate::store::TombstoneReason::ManualDeletion,
489            at: 1,
490        };
491        disabled_record.lifecycle = RecordLifecycle::Active;
492        let set = PolicyMatcherSet::from_records(&[disabled_record, tombstone]).unwrap();
493        assert!(set.matches(&action(None, &[])).is_empty());
494    }
495
496    #[test]
497    fn unknown_tool_values_never_match() {
498        let set = PolicyMatcherSet::from_policies([(
499            "policy:unknown".into(),
500            policy(
501                "Unknown",
502                PolicyTrigger {
503                    tool: Some("future_tool".into()),
504                    ..Default::default()
505                },
506            ),
507        )])
508        .unwrap();
509        assert!(set.matches(&action(None, &[])).is_empty());
510    }
511
512    #[test]
513    fn lenient_loader_skips_bad_glob_and_keeps_good_policy() {
514        let good = crate::store::policy_ops::record_for(
515            "policy:good",
516            &policy(
517                "Good",
518                PolicyTrigger {
519                    tool: Some("db_client".into()),
520                    ..Default::default()
521                },
522            ),
523        )
524        .unwrap();
525        let mut bad_policy = policy(
526            "Bad",
527            PolicyTrigger {
528                host_glob: Some("[".into()),
529                ..Default::default()
530            },
531        );
532        bad_policy.stage = PolicyStage::Enforce;
533        let bad = crate::store::policy_ops::record_for("policy:bad", &bad_policy).unwrap();
534
535        let matcher = PolicyMatcherSet::from_records_lenient(&[bad, good]);
536
537        assert_eq!(matcher.matches(&action(None, &[])).len(), 1);
538        assert_eq!(matcher.matches(&action(None, &[]))[0].key, "policy:good");
539    }
540
541    /// Before the guard, an empty trigger compiled into a matcher whose every
542    /// predicate was vacuous, so it denied every governed action.
543    #[test]
544    fn empty_trigger_never_compiles_into_a_universal_gate() {
545        assert!(PolicyMatcherSet::from_policies([(
546            "policy:everything".into(),
547            policy("Everything", PolicyTrigger::default()),
548        )])
549        .is_err());
550
551        let record = crate::store::policy_ops::record_for(
552            "policy:everything",
553            &policy("E", PolicyTrigger::default()),
554        )
555        .unwrap();
556        assert!(PolicyMatcherSet::from_records_lenient(&[record])
557            .matches(&action(None, &[]))
558            .is_empty());
559    }
560}