Skip to main content

execsurface_policy/
lib.rs

1//! Deterministic policy evaluation over M4 execution-surface evidence.
2//!
3//! Policy never mutates the baseline or the underlying diff.
4
5use 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}