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