1use std::collections::BTreeSet;
6use std::fmt;
7
8use execsurface_diff::{ChangedEffect, DiffReport, TargetOutcome};
9use execsurface_model::canonical::{
10 CanonicalEffect, CanonicalNetworkEndpoint, OpenIntent, PathClass, PathResolution,
11};
12use execsurface_model::FileOperation;
13use serde::{Deserialize, Serialize};
14
15pub const LEGACY_POLICY_SCHEMA_VERSION: u32 = 1;
16pub const POLICY_SCHEMA_VERSION: u32 = 2;
17pub const POLICY_SCHEMA_VERSION_V3: u32 = 3;
18pub const VERDICT_SCHEMA_VERSION: u32 = 2;
19
20#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
21#[serde(rename_all = "snake_case")]
22pub enum FindingAction {
23 Allow,
24 Review,
25 Block,
26}
27
28#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
29#[serde(rename_all = "snake_case")]
30pub enum Verdict {
31 Pass,
32 Review,
33 Block,
34 Error,
35}
36
37#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
38#[serde(rename_all = "snake_case")]
39pub enum ChangeKind {
40 Added,
41 Removed,
42 Changed,
43}
44
45#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
46#[serde(rename_all = "snake_case")]
47pub enum EffectKind {
48 ProcessSpawn,
49 ProcessExec,
50 FileOpen,
51 FileCreate,
52 FileDelete,
53 FileRead,
54 FileWrite,
55 FileRename,
56 NetworkConnect,
57}
58
59#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
60#[serde(deny_unknown_fields)]
61pub struct Policy {
62 pub schema_version: u32,
63 pub default_action: FindingAction,
64 pub rules: Vec<PolicyRule>,
65}
66
67#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
68#[serde(deny_unknown_fields)]
69pub struct PolicyRule {
70 pub id: String,
71 pub action: FindingAction,
72 #[serde(rename = "match")]
73 pub matcher: RuleMatcher,
74}
75
76#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
77#[serde(deny_unknown_fields)]
78pub struct OpenIntentMatcher {
79 #[serde(default)]
80 pub read: Option<bool>,
81 #[serde(default)]
82 pub write: Option<bool>,
83 #[serde(default)]
84 pub create: Option<bool>,
85 #[serde(default)]
86 pub truncate: Option<bool>,
87 #[serde(default)]
88 pub append: Option<bool>,
89 #[serde(default)]
90 pub path_only: Option<bool>,
91 #[serde(default)]
92 pub resolve_flags: Option<u64>,
93 #[serde(default)]
94 pub other_flags: Option<u64>,
95}
96
97impl OpenIntentMatcher {
98 fn is_empty(&self) -> bool {
99 self.read.is_none()
100 && self.write.is_none()
101 && self.create.is_none()
102 && self.truncate.is_none()
103 && self.append.is_none()
104 && self.path_only.is_none()
105 && self.resolve_flags.is_none()
106 && self.other_flags.is_none()
107 }
108
109 fn matches(&self, intent: &OpenIntent) -> bool {
110 !self.read.is_some_and(|expected| expected != intent.read)
111 && !self.write.is_some_and(|expected| expected != intent.write)
112 && !self
113 .create
114 .is_some_and(|expected| expected != intent.create)
115 && !self
116 .truncate
117 .is_some_and(|expected| expected != intent.truncate)
118 && !self
119 .append
120 .is_some_and(|expected| expected != intent.append)
121 && !self
122 .path_only
123 .is_some_and(|expected| expected != intent.path_only)
124 && !self
125 .resolve_flags
126 .is_some_and(|expected| expected != intent.resolve_flags)
127 && !self
128 .other_flags
129 .is_some_and(|expected| expected != intent.other_flags)
130 }
131}
132
133#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
134#[serde(deny_unknown_fields)]
135pub struct RuleMatcher {
136 #[serde(default)]
137 pub change: Option<ChangeKind>,
138 #[serde(default)]
139 pub effect: Option<EffectKind>,
140 #[serde(default)]
141 pub path_class: Option<PathClass>,
142 #[serde(default)]
143 pub path_prefix: Option<String>,
144 #[serde(default)]
145 pub executable_family: Option<String>,
146 #[serde(default)]
147 pub network_ip: Option<String>,
148 #[serde(default)]
149 pub network_port: Option<u16>,
150 #[serde(default)]
151 pub path_resolution: Option<PathResolution>,
152 #[serde(default)]
153 pub open_intent: Option<OpenIntentMatcher>,
154 #[serde(default)]
155 pub rename_from_class: Option<PathClass>,
156 #[serde(default)]
157 pub rename_from_prefix: Option<String>,
158 #[serde(default)]
159 pub rename_from_resolution: Option<PathResolution>,
160}
161
162#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
163pub struct VerdictReport {
164 pub schema_version: u32,
165 pub baseline_digest: Option<String>,
166 pub target: Option<TargetOutcome>,
167 pub verdict: Verdict,
168 pub policy: Option<PolicySummary>,
169 pub findings: Vec<FindingDecision>,
170 pub error: Option<String>,
171}
172
173#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
174pub struct PolicySummary {
175 pub schema_version: u32,
176 pub source: String,
177 pub default_action: FindingAction,
178}
179
180#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
181pub struct FindingDecision {
182 pub change: ChangeKind,
183 pub effect_kind: EffectKind,
184 pub action: FindingAction,
185 pub matched_rules: Vec<String>,
186 pub evidence: FindingEvidence,
187}
188
189#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
190#[serde(tag = "change", rename_all = "snake_case")]
191pub enum FindingEvidence {
192 Added { effect: CanonicalEffect },
193 Removed { effect: CanonicalEffect },
194 Changed { finding: Box<ChangedEffect> },
195}
196
197#[derive(Debug, Clone, PartialEq, Eq)]
198pub enum PolicyError {
199 UnsupportedSchema(u32),
200 EmptyRuleId,
201 DuplicateRuleId(String),
202 EmptyMatcher(String),
203 InvalidMatcher { rule_id: String, reason: String },
204}
205
206impl fmt::Display for PolicyError {
207 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
208 match self {
209 Self::UnsupportedSchema(version) => {
210 write!(f, "unsupported policy schema version: {version}")
211 }
212 Self::EmptyRuleId => write!(f, "policy rule id must not be empty"),
213 Self::DuplicateRuleId(id) => write!(f, "duplicate policy rule id: {id}"),
214 Self::EmptyMatcher(id) => {
215 write!(f, "policy rule {id} must specify at least one match field")
216 }
217 Self::InvalidMatcher { rule_id, reason } => {
218 write!(f, "invalid matcher for policy rule {rule_id}: {reason}")
219 }
220 }
221 }
222}
223
224impl std::error::Error for PolicyError {}
225
226pub fn builtin_review_policy() -> Policy {
227 Policy {
228 schema_version: POLICY_SCHEMA_VERSION,
229 default_action: FindingAction::Review,
230 rules: Vec::new(),
231 }
232}
233
234pub fn validate_policy(policy: &Policy) -> Result<(), PolicyError> {
235 if policy.schema_version != LEGACY_POLICY_SCHEMA_VERSION
236 && policy.schema_version != POLICY_SCHEMA_VERSION
237 && policy.schema_version != POLICY_SCHEMA_VERSION_V3
238 {
239 return Err(PolicyError::UnsupportedSchema(policy.schema_version));
240 }
241
242 let mut ids = BTreeSet::new();
243 for rule in &policy.rules {
244 if rule.id.trim().is_empty() {
245 return Err(PolicyError::EmptyRuleId);
246 }
247 if !ids.insert(rule.id.clone()) {
248 return Err(PolicyError::DuplicateRuleId(rule.id.clone()));
249 }
250 if rule.matcher.is_empty() {
251 return Err(PolicyError::EmptyMatcher(rule.id.clone()));
252 }
253 if rule
254 .matcher
255 .path_prefix
256 .as_deref()
257 .is_some_and(str::is_empty)
258 {
259 return Err(PolicyError::InvalidMatcher {
260 rule_id: rule.id.clone(),
261 reason: "path_prefix must not be empty".to_owned(),
262 });
263 }
264 if rule
265 .matcher
266 .rename_from_prefix
267 .as_deref()
268 .is_some_and(str::is_empty)
269 {
270 return Err(PolicyError::InvalidMatcher {
271 rule_id: rule.id.clone(),
272 reason: "rename_from_prefix must not be empty".to_owned(),
273 });
274 }
275 if rule
276 .matcher
277 .open_intent
278 .as_ref()
279 .is_some_and(OpenIntentMatcher::is_empty)
280 {
281 return Err(PolicyError::InvalidMatcher {
282 rule_id: rule.id.clone(),
283 reason: "open_intent must specify at least one field".to_owned(),
284 });
285 }
286
287 let uses_v3_matcher = rule.matcher.path_resolution.is_some()
288 || rule.matcher.open_intent.is_some()
289 || rule.matcher.rename_from_class.is_some()
290 || rule.matcher.rename_from_prefix.is_some()
291 || rule.matcher.rename_from_resolution.is_some();
292 if uses_v3_matcher && policy.schema_version != POLICY_SCHEMA_VERSION_V3 {
293 return Err(PolicyError::InvalidMatcher {
294 rule_id: rule.id.clone(),
295 reason: "path_resolution/open_intent/rename_from_* require policy schema version 3"
296 .to_owned(),
297 });
298 }
299 if let Some(effect) = rule.matcher.effect {
300 if (rule.matcher.network_ip.is_some() || rule.matcher.network_port.is_some())
301 && effect != EffectKind::NetworkConnect
302 {
303 return Err(PolicyError::InvalidMatcher {
304 rule_id: rule.id.clone(),
305 reason: "network_ip/network_port require effect=network_connect when effect is specified"
306 .to_owned(),
307 });
308 }
309 if rule.matcher.open_intent.is_some() && effect != EffectKind::FileOpen {
310 return Err(PolicyError::InvalidMatcher {
311 rule_id: rule.id.clone(),
312 reason: "open_intent requires effect=file_open when effect is specified"
313 .to_owned(),
314 });
315 }
316 if (rule.matcher.rename_from_class.is_some()
317 || rule.matcher.rename_from_prefix.is_some()
318 || rule.matcher.rename_from_resolution.is_some())
319 && effect != EffectKind::FileRename
320 {
321 return Err(PolicyError::InvalidMatcher {
322 rule_id: rule.id.clone(),
323 reason: "rename_from_* require effect=file_rename when effect is specified"
324 .to_owned(),
325 });
326 }
327 }
328 if policy.schema_version == LEGACY_POLICY_SCHEMA_VERSION
329 && matches!(
330 rule.matcher.effect,
331 Some(EffectKind::FileRead | EffectKind::FileWrite)
332 )
333 {
334 return Err(PolicyError::InvalidMatcher {
335 rule_id: rule.id.clone(),
336 reason: "file_read/file_write require policy schema version 2".to_owned(),
337 });
338 }
339 }
340 Ok(())
341}
342
343pub fn evaluate(
344 diff: &DiffReport,
345 policy: &Policy,
346 source: impl Into<String>,
347) -> Result<VerdictReport, PolicyError> {
348 validate_policy(policy)?;
349
350 let mut findings = Vec::new();
351
352 for effect in &diff.added {
353 findings.push(decide_finding(
354 ChangeKind::Added,
355 effect,
356 FindingEvidence::Added {
357 effect: effect.clone(),
358 },
359 policy,
360 ));
361 }
362 for effect in &diff.removed {
363 findings.push(decide_finding(
364 ChangeKind::Removed,
365 effect,
366 FindingEvidence::Removed {
367 effect: effect.clone(),
368 },
369 policy,
370 ));
371 }
372 for changed in &diff.changed {
373 findings.push(decide_finding(
374 ChangeKind::Changed,
375 &changed.after,
376 FindingEvidence::Changed {
377 finding: Box::new(changed.clone()),
378 },
379 policy,
380 ));
381 }
382
383 let verdict = if findings.is_empty() {
384 Verdict::Pass
385 } else {
386 match findings
387 .iter()
388 .map(|finding| finding.action)
389 .max()
390 .unwrap_or(FindingAction::Allow)
391 {
392 FindingAction::Allow => Verdict::Pass,
393 FindingAction::Review => Verdict::Review,
394 FindingAction::Block => Verdict::Block,
395 }
396 };
397
398 Ok(VerdictReport {
399 schema_version: VERDICT_SCHEMA_VERSION,
400 baseline_digest: Some(diff.baseline_digest.clone()),
401 target: Some(diff.target.clone()),
402 verdict,
403 policy: Some(PolicySummary {
404 schema_version: policy.schema_version,
405 source: source.into(),
406 default_action: policy.default_action,
407 }),
408 findings,
409 error: None,
410 })
411}
412
413pub fn error_report(message: impl Into<String>) -> VerdictReport {
414 VerdictReport {
415 schema_version: VERDICT_SCHEMA_VERSION,
416 baseline_digest: None,
417 target: None,
418 verdict: Verdict::Error,
419 policy: None,
420 findings: Vec::new(),
421 error: Some(message.into()),
422 }
423}
424
425fn decide_finding(
426 change: ChangeKind,
427 effect: &CanonicalEffect,
428 evidence: FindingEvidence,
429 policy: &Policy,
430) -> FindingDecision {
431 let effect_kind = effect_kind(effect);
432 let mut matches = policy
433 .rules
434 .iter()
435 .filter(|rule| rule.matcher.matches(change, effect, effect_kind))
436 .map(|rule| (rule.action, rule.id.clone()))
437 .collect::<Vec<_>>();
438
439 matches.sort_by(|left, right| left.1.cmp(&right.1));
440 let action = matches
441 .iter()
442 .map(|(action, _)| *action)
443 .max()
444 .unwrap_or(policy.default_action);
445 let matched_rules = matches.into_iter().map(|(_, id)| id).collect();
446
447 FindingDecision {
448 change,
449 effect_kind,
450 action,
451 matched_rules,
452 evidence,
453 }
454}
455
456impl RuleMatcher {
457 fn is_empty(&self) -> bool {
458 self.change.is_none()
459 && self.effect.is_none()
460 && self.path_class.is_none()
461 && self.path_prefix.is_none()
462 && self.executable_family.is_none()
463 && self.network_ip.is_none()
464 && self.network_port.is_none()
465 && self.path_resolution.is_none()
466 && self.open_intent.is_none()
467 && self.rename_from_class.is_none()
468 && self.rename_from_prefix.is_none()
469 && self.rename_from_resolution.is_none()
470 }
471
472 fn matches(&self, change: ChangeKind, effect: &CanonicalEffect, kind: EffectKind) -> bool {
473 if self.change.is_some_and(|expected| expected != change) {
474 return false;
475 }
476 if self.effect.is_some_and(|expected| expected != kind) {
477 return false;
478 }
479
480 let metadata = EffectMetadata::from_effect(effect);
481
482 if self
483 .path_class
484 .is_some_and(|expected| metadata.path_class != Some(expected))
485 {
486 return false;
487 }
488 if self
489 .path_resolution
490 .is_some_and(|expected| metadata.path_resolution != Some(expected))
491 {
492 return false;
493 }
494 if let Some(prefix) = &self.path_prefix {
495 let Some(path) = metadata.path else {
496 return false;
497 };
498 if !path_prefix_matches(path, prefix) {
499 return false;
500 }
501 }
502 if let Some(expected) = &self.executable_family {
503 if metadata.executable_family != Some(expected.as_str()) {
504 return false;
505 }
506 }
507 if let Some(expected) = &self.network_ip {
508 if metadata.network_ip != Some(expected.as_str()) {
509 return false;
510 }
511 }
512 if self
513 .network_port
514 .is_some_and(|expected| metadata.network_port != Some(expected))
515 {
516 return false;
517 }
518 if let Some(expected) = &self.open_intent {
519 let Some(actual) = metadata.open_intent else {
520 return false;
521 };
522 if !expected.matches(actual) {
523 return false;
524 }
525 }
526 if self
527 .rename_from_class
528 .is_some_and(|expected| metadata.rename_from_class != Some(expected))
529 {
530 return false;
531 }
532 if let Some(prefix) = &self.rename_from_prefix {
533 let Some(path) = metadata.rename_from_path else {
534 return false;
535 };
536 if !path_prefix_matches(path, prefix) {
537 return false;
538 }
539 }
540 if self
541 .rename_from_resolution
542 .is_some_and(|expected| metadata.rename_from_resolution != Some(expected))
543 {
544 return false;
545 }
546
547 true
548 }
549}
550
551struct EffectMetadata<'a> {
552 path: Option<&'a str>,
553 path_class: Option<PathClass>,
554 path_resolution: Option<PathResolution>,
555 executable_family: Option<&'a str>,
556 network_ip: Option<&'a str>,
557 network_port: Option<u16>,
558 open_intent: Option<&'a OpenIntent>,
559 rename_from_path: Option<&'a str>,
560 rename_from_class: Option<PathClass>,
561 rename_from_resolution: Option<PathResolution>,
562}
563
564impl<'a> EffectMetadata<'a> {
565 fn from_effect(effect: &'a CanonicalEffect) -> Self {
566 match effect {
567 CanonicalEffect::ProcessSpawn { actor, .. } => Self {
568 path: actor.as_ref().map(|actor| actor.path.value.as_str()),
569 path_class: actor.as_ref().map(|actor| actor.path.class),
570 path_resolution: actor.as_ref().map(|actor| actor.path.resolution),
571 executable_family: actor.as_ref().map(|actor| actor.family.as_str()),
572 network_ip: None,
573 network_port: None,
574 open_intent: None,
575 rename_from_path: None,
576 rename_from_class: None,
577 rename_from_resolution: None,
578 },
579 CanonicalEffect::ProcessExec { executable, .. } => Self {
580 path: Some(executable.path.value.as_str()),
581 path_class: Some(executable.path.class),
582 path_resolution: Some(executable.path.resolution),
583 executable_family: Some(executable.family.as_str()),
584 network_ip: None,
585 network_port: None,
586 open_intent: None,
587 rename_from_path: None,
588 rename_from_class: None,
589 rename_from_resolution: None,
590 },
591 CanonicalEffect::FilePathAccess {
592 actor,
593 target,
594 open_intent,
595 ..
596 } => Self {
597 path: Some(target.value.as_str()),
598 path_class: Some(target.class),
599 path_resolution: Some(target.resolution),
600 executable_family: actor.as_ref().map(|actor| actor.family.as_str()),
601 network_ip: None,
602 network_port: None,
603 open_intent: open_intent.as_ref(),
604 rename_from_path: None,
605 rename_from_class: None,
606 rename_from_resolution: None,
607 },
608 CanonicalEffect::FileRename {
609 actor, from, to, ..
610 } => Self {
611 path: Some(to.value.as_str()),
612 path_class: Some(to.class),
613 path_resolution: Some(to.resolution),
614 executable_family: actor.as_ref().map(|actor| actor.family.as_str()),
615 network_ip: None,
616 network_port: None,
617 open_intent: None,
618 rename_from_path: Some(from.value.as_str()),
619 rename_from_class: Some(from.class),
620 rename_from_resolution: Some(from.resolution),
621 },
622 CanonicalEffect::NetworkConnectAttempt {
623 actor, endpoint, ..
624 } => match endpoint {
625 CanonicalNetworkEndpoint::Inet { ip, port }
626 | CanonicalNetworkEndpoint::Inet6 { ip, port } => Self {
627 path: None,
628 path_class: None,
629 path_resolution: None,
630 executable_family: actor.as_ref().map(|actor| actor.family.as_str()),
631 network_ip: Some(ip.as_str()),
632 network_port: Some(*port),
633 open_intent: None,
634 rename_from_path: None,
635 rename_from_class: None,
636 rename_from_resolution: None,
637 },
638 CanonicalNetworkEndpoint::Unix { path } => Self {
639 path: path.as_ref().map(|path| path.value.as_str()),
640 path_class: path.as_ref().map(|path| path.class),
641 path_resolution: path.as_ref().map(|path| path.resolution),
642 executable_family: actor.as_ref().map(|actor| actor.family.as_str()),
643 network_ip: None,
644 network_port: None,
645 open_intent: None,
646 rename_from_path: None,
647 rename_from_class: None,
648 rename_from_resolution: None,
649 },
650 CanonicalNetworkEndpoint::Other { .. } => Self {
651 path: None,
652 path_class: None,
653 path_resolution: None,
654 executable_family: actor.as_ref().map(|actor| actor.family.as_str()),
655 network_ip: None,
656 network_port: None,
657 open_intent: None,
658 rename_from_path: None,
659 rename_from_class: None,
660 rename_from_resolution: None,
661 },
662 },
663 }
664 }
665}
666
667fn effect_kind(effect: &CanonicalEffect) -> EffectKind {
668 match effect {
669 CanonicalEffect::ProcessSpawn { .. } => EffectKind::ProcessSpawn,
670 CanonicalEffect::ProcessExec { .. } => EffectKind::ProcessExec,
671 CanonicalEffect::FilePathAccess { operation, .. } => match operation {
672 FileOperation::Open => EffectKind::FileOpen,
673 FileOperation::Create => EffectKind::FileCreate,
674 FileOperation::Delete => EffectKind::FileDelete,
675 FileOperation::Read => EffectKind::FileRead,
676 FileOperation::Write => EffectKind::FileWrite,
677 },
678 CanonicalEffect::FileRename { .. } => EffectKind::FileRename,
679 CanonicalEffect::NetworkConnectAttempt { .. } => EffectKind::NetworkConnect,
680 }
681}
682
683fn path_prefix_matches(path: &str, prefix: &str) -> bool {
684 if path == prefix {
685 return true;
686 }
687 if prefix.ends_with('/') {
688 return path.starts_with(prefix);
689 }
690 path.strip_prefix(prefix)
691 .is_some_and(|suffix| suffix.starts_with('/'))
692}
693
694#[cfg(test)]
695mod tests {
696 use super::*;
697 use execsurface_diff::{ChangedEffect, DiffReport, EffectSubject};
698 use execsurface_model::canonical::{
699 CanonicalExecutable, CanonicalPath, OpenIntent, PathResolution,
700 };
701
702 fn executable(name: &str) -> CanonicalExecutable {
703 CanonicalExecutable {
704 path: CanonicalPath {
705 value: format!("/usr/bin/{name}"),
706 class: PathClass::System,
707 resolution: PathResolution::Lexical,
708 },
709 family: name.to_owned(),
710 }
711 }
712
713 fn file_effect(path: &str) -> CanonicalEffect {
714 CanonicalEffect::FilePathAccess {
715 actor: Some(executable("demo")),
716 execution_chain: vec![executable("demo")],
717 operation: FileOperation::Open,
718 target: CanonicalPath {
719 value: path.to_owned(),
720 class: PathClass::Workspace,
721 resolution: PathResolution::Lexical,
722 },
723 open_intent: Some(OpenIntent {
724 read: true,
725 write: false,
726 create: false,
727 truncate: false,
728 append: false,
729 path_only: false,
730 resolve_flags: 0,
731 other_flags: 0,
732 }),
733 }
734 }
735
736 fn diff_with_added(effect: CanonicalEffect) -> DiffReport {
737 DiffReport {
738 schema_version: 1,
739 baseline_digest: "sha256:test".to_owned(),
740 target: TargetOutcome {
741 exit_code: Some(0),
742 signal: None,
743 },
744 added: vec![effect],
745 removed: vec![],
746 changed: vec![],
747 }
748 }
749
750 #[test]
751 fn no_drift_is_pass_even_with_block_default() {
752 let diff = DiffReport {
753 schema_version: 1,
754 baseline_digest: "sha256:test".to_owned(),
755 target: TargetOutcome {
756 exit_code: Some(1),
757 signal: None,
758 },
759 added: vec![],
760 removed: vec![],
761 changed: vec![],
762 };
763 let policy = Policy {
764 schema_version: 1,
765 default_action: FindingAction::Block,
766 rules: vec![],
767 };
768 let report = evaluate(&diff, &policy, "test").unwrap();
769 assert_eq!(report.verdict, Verdict::Pass);
770 }
771
772 #[test]
773 fn builtin_policy_reviews_unmatched_drift() {
774 let report = evaluate(
775 &diff_with_added(file_effect("$WORKSPACE/file")),
776 &builtin_review_policy(),
777 "builtin",
778 )
779 .unwrap();
780 assert_eq!(report.verdict, Verdict::Review);
781 }
782
783 #[test]
784 fn allow_rule_can_accept_matching_drift() {
785 let policy = Policy {
786 schema_version: 1,
787 default_action: FindingAction::Review,
788 rules: vec![PolicyRule {
789 id: "allow-workspace".to_owned(),
790 action: FindingAction::Allow,
791 matcher: RuleMatcher {
792 change: Some(ChangeKind::Added),
793 effect: Some(EffectKind::FileOpen),
794 path_prefix: Some("$WORKSPACE/fixtures".to_owned()),
795 ..RuleMatcher::default()
796 },
797 }],
798 };
799 let report = evaluate(
800 &diff_with_added(file_effect("$WORKSPACE/fixtures/a.txt")),
801 &policy,
802 "test",
803 )
804 .unwrap();
805 assert_eq!(report.verdict, Verdict::Pass);
806 assert_eq!(report.findings[0].action, FindingAction::Allow);
807 }
808
809 #[test]
810 fn most_restrictive_matching_rule_wins_independent_of_order() {
811 let allow = PolicyRule {
812 id: "a-allow".to_owned(),
813 action: FindingAction::Allow,
814 matcher: RuleMatcher {
815 effect: Some(EffectKind::FileOpen),
816 ..RuleMatcher::default()
817 },
818 };
819 let block = PolicyRule {
820 id: "z-block".to_owned(),
821 action: FindingAction::Block,
822 matcher: RuleMatcher {
823 effect: Some(EffectKind::FileOpen),
824 ..RuleMatcher::default()
825 },
826 };
827 for rules in [
828 vec![allow.clone(), block.clone()],
829 vec![block.clone(), allow.clone()],
830 ] {
831 let policy = Policy {
832 schema_version: 1,
833 default_action: FindingAction::Review,
834 rules,
835 };
836 let report = evaluate(
837 &diff_with_added(file_effect("$WORKSPACE/a")),
838 &policy,
839 "test",
840 )
841 .unwrap();
842 assert_eq!(report.verdict, Verdict::Block);
843 assert_eq!(
844 report.findings[0].matched_rules,
845 vec!["a-allow".to_owned(), "z-block".to_owned()]
846 );
847 }
848 }
849
850 #[test]
851 fn path_prefix_uses_component_boundary() {
852 let policy = Policy {
853 schema_version: 1,
854 default_action: FindingAction::Review,
855 rules: vec![PolicyRule {
856 id: "allow-foo".to_owned(),
857 action: FindingAction::Allow,
858 matcher: RuleMatcher {
859 path_prefix: Some("$WORKSPACE/foo".to_owned()),
860 ..RuleMatcher::default()
861 },
862 }],
863 };
864 let allowed = evaluate(
865 &diff_with_added(file_effect("$WORKSPACE/foo/a")),
866 &policy,
867 "test",
868 )
869 .unwrap();
870 assert_eq!(allowed.verdict, Verdict::Pass);
871
872 let boundary = evaluate(
873 &diff_with_added(file_effect("$WORKSPACE/foobar/a")),
874 &policy,
875 "test",
876 )
877 .unwrap();
878 assert_eq!(boundary.verdict, Verdict::Review);
879 }
880
881 #[test]
882 fn duplicate_rule_ids_are_rejected() {
883 let rule = PolicyRule {
884 id: "same".to_owned(),
885 action: FindingAction::Allow,
886 matcher: RuleMatcher {
887 effect: Some(EffectKind::FileOpen),
888 ..RuleMatcher::default()
889 },
890 };
891 let policy = Policy {
892 schema_version: 1,
893 default_action: FindingAction::Review,
894 rules: vec![rule.clone(), rule],
895 };
896 assert_eq!(
897 validate_policy(&policy),
898 Err(PolicyError::DuplicateRuleId("same".to_owned()))
899 );
900 }
901
902 #[test]
903 fn empty_matcher_is_rejected() {
904 let policy = Policy {
905 schema_version: 1,
906 default_action: FindingAction::Review,
907 rules: vec![PolicyRule {
908 id: "bad".to_owned(),
909 action: FindingAction::Block,
910 matcher: RuleMatcher::default(),
911 }],
912 };
913 assert_eq!(
914 validate_policy(&policy),
915 Err(PolicyError::EmptyMatcher("bad".to_owned()))
916 );
917 }
918
919 #[test]
920 fn changed_rule_matches_after_state() {
921 let before = CanonicalEffect::NetworkConnectAttempt {
922 actor: Some(executable("demo")),
923 execution_chain: vec![executable("demo")],
924 endpoint: CanonicalNetworkEndpoint::Inet {
925 ip: "192.0.2.1".to_owned(),
926 port: 443,
927 },
928 };
929 let after = CanonicalEffect::NetworkConnectAttempt {
930 actor: Some(executable("demo")),
931 execution_chain: vec![executable("demo")],
932 endpoint: CanonicalNetworkEndpoint::Inet {
933 ip: "192.0.2.1".to_owned(),
934 port: 8443,
935 },
936 };
937 let diff = DiffReport {
938 schema_version: 1,
939 baseline_digest: "sha256:test".to_owned(),
940 target: TargetOutcome {
941 exit_code: Some(0),
942 signal: None,
943 },
944 added: vec![],
945 removed: vec![],
946 changed: vec![ChangedEffect {
947 subject: EffectSubject::NetworkInet {
948 actor: Some(executable("demo")),
949 address_family: "inet".to_owned(),
950 ip: "192.0.2.1".to_owned(),
951 },
952 before,
953 after,
954 }],
955 };
956 let policy = Policy {
957 schema_version: 1,
958 default_action: FindingAction::Review,
959 rules: vec![PolicyRule {
960 id: "block-alt-port".to_owned(),
961 action: FindingAction::Block,
962 matcher: RuleMatcher {
963 change: Some(ChangeKind::Changed),
964 effect: Some(EffectKind::NetworkConnect),
965 network_port: Some(8443),
966 ..RuleMatcher::default()
967 },
968 }],
969 };
970 assert_eq!(
971 evaluate(&diff, &policy, "test").unwrap().verdict,
972 Verdict::Block
973 );
974 }
975
976 #[test]
977 fn error_report_is_explicit_error_state() {
978 let report = error_report("observer failed");
979 assert_eq!(report.verdict, Verdict::Error);
980 assert_eq!(report.error.as_deref(), Some("observer failed"));
981 }
982 #[test]
983 fn policy_can_distinguish_actual_file_read_from_open_attempt() {
984 let read = CanonicalEffect::FilePathAccess {
985 actor: Some(executable("demo")),
986 execution_chain: vec![executable("demo")],
987 operation: FileOperation::Read,
988 target: CanonicalPath {
989 value: "$WORKSPACE/secrets/input".to_owned(),
990 class: PathClass::Workspace,
991 resolution: PathResolution::KernelFdResolved,
992 },
993 open_intent: None,
994 };
995 let policy = Policy {
996 schema_version: POLICY_SCHEMA_VERSION,
997 default_action: FindingAction::Allow,
998 rules: vec![PolicyRule {
999 id: "review-actual-read".to_owned(),
1000 action: FindingAction::Review,
1001 matcher: RuleMatcher {
1002 effect: Some(EffectKind::FileRead),
1003 ..RuleMatcher::default()
1004 },
1005 }],
1006 };
1007
1008 let report = evaluate(&diff_with_added(read), &policy, "test").unwrap();
1009 assert_eq!(report.verdict, Verdict::Review);
1010 assert_eq!(report.findings[0].effect_kind, EffectKind::FileRead);
1011 }
1012 #[test]
1013 fn legacy_v1_policy_rejects_v2_file_io_matchers() {
1014 let policy = Policy {
1015 schema_version: LEGACY_POLICY_SCHEMA_VERSION,
1016 default_action: FindingAction::Review,
1017 rules: vec![PolicyRule {
1018 id: "legacy-read".to_owned(),
1019 action: FindingAction::Block,
1020 matcher: RuleMatcher {
1021 effect: Some(EffectKind::FileRead),
1022 ..RuleMatcher::default()
1023 },
1024 }],
1025 };
1026 assert!(matches!(
1027 validate_policy(&policy),
1028 Err(PolicyError::InvalidMatcher { rule_id, .. }) if rule_id == "legacy-read"
1029 ));
1030 }
1031}