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::{
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}