1use 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
21pub struct PolicyMatcherSet {
23 policies: Vec<CompiledPolicy>,
24}
25
26pub struct MatchedPolicy<'a> {
28 pub key: &'a str,
29 pub policy: &'a PolicyRecord,
30}
31
32impl PolicyMatcherSet {
33 pub fn empty() -> Self {
35 Self {
36 policies: Vec::new(),
37 }
38 }
39
40 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 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 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 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 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 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 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 let ddrescue = Action {
317 argv: vec!["ddrescue".into(), "x".into()],
318 ..dd.clone()
319 };
320 assert!(set.matches(&ddrescue).is_empty());
321 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 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 #[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}