switchyard/policy/
gating.rs

1use crate::api::DebugOwnershipOracle;
2use crate::policy::types::{RiskLevel, SourceTrustPolicy};
3use crate::policy::Policy;
4use crate::types::plan::Action;
5use crate::types::Plan;
6
7/// Centralized evaluation result for a single action under a given Policy.
8#[derive(Debug, Default, Clone)]
9pub(crate) struct Evaluation {
10    pub policy_ok: bool,
11    pub stops: Vec<String>,
12    pub notes: Vec<String>,
13}
14
15/// Evaluate policy gating for a single action.
16#[allow(
17    clippy::too_many_lines,
18    reason = "Will be decomposed into typed checks in PR8"
19)]
20pub(crate) fn evaluate_action(
21    policy: &Policy,
22    owner: Option<&dyn DebugOwnershipOracle>,
23    act: &Action,
24) -> Evaluation {
25    let mut stops: Vec<String> = Vec::new();
26    let mut notes: Vec<String> = Vec::new();
27
28    match act {
29        Action::EnsureSymlink { source, target } => {
30            // Policy-driven extra mount checks (replaces any hard-coded paths)@@
31            for p in &policy.apply.extra_mount_checks {
32                if let Err(e) = crate::preflight::checks::ensure_mount_rw_exec(p.as_path()) {
33                    stops.push(format!("{} not rw+exec: {}", p.display(), e));
34                    notes.push(format!("mount: {} not rw+exec", p.display()));
35                } else {
36                    notes.push(format!("mount ok: {} rw+exec", p.display()));
37                }
38            }
39            if let Err(e) = crate::preflight::checks::ensure_mount_rw_exec(&target.as_path()) {
40                stops.push(format!(
41                    "target not rw+exec: {} (target={})",
42                    e,
43                    target.as_path().display()
44                ));
45                notes.push("mount: target not rw+exec".to_string());
46            } else {
47                notes.push("mount ok: target rw+exec".to_string());
48            }
49            if let Err(e) = crate::preflight::checks::check_immutable(&target.as_path()) {
50                stops.push(format!(
51                    "immutable target: {} (target={})",
52                    e,
53                    target.as_path().display()
54                ));
55                notes.push("immutable target".to_string());
56            }
57            if let Ok(hard) = crate::preflight::checks::check_hardlink_hazard(&target.as_path()) {
58                if hard {
59                    match policy.risks.hardlinks {
60                        RiskLevel::Stop => {
61                            stops.push("hardlink risk".to_string());
62                            notes.push("hardlink risk".to_string());
63                        }
64                        RiskLevel::Warn | RiskLevel::Allow => {
65                            notes.push("hardlink risk allowed by policy".to_string());
66                        }
67                    }
68                }
69            }
70            if let Ok(risk) = crate::preflight::checks::check_suid_sgid_risk(&target.as_path()) {
71                if risk {
72                    match policy.risks.suid_sgid {
73                        RiskLevel::Stop => {
74                            stops.push(format!("suid/sgid risk: {}", target.as_path().display()));
75                            notes.push("suid/sgid risk".to_string());
76                        }
77                        RiskLevel::Warn | RiskLevel::Allow => {
78                            notes.push("suid/sgid risk allowed by policy".to_string());
79                        }
80                    }
81                }
82            }
83            // REQ-S3 (bounded for testability): STOP when source is world-writable.
84            #[cfg(unix)]
85            {
86                use std::os::unix::fs::MetadataExt;
87                if let Ok(md) = std::fs::metadata(source.as_path()) {
88                    let mode = md.mode();
89                    if (mode & 0o002) != 0 {
90                        stops.push(format!(
91                            "source world-writable: {}",
92                            source.as_path().display()
93                        ));
94                        notes.push("untrusted source ownership or mode".to_string());
95                    }
96                }
97            }
98            match crate::preflight::checks::check_source_trust(
99                &source.as_path(),
100                policy.risks.source_trust != SourceTrustPolicy::RequireTrusted,
101            ) {
102                Ok(()) => {}
103                Err(e) => {
104                    if policy.risks.source_trust == SourceTrustPolicy::RequireTrusted {
105                        stops.push(format!("untrusted source: {e}"));
106                        notes.push("untrusted source".to_string());
107                    } else {
108                        notes.push(format!("untrusted source allowed by policy: {e}"));
109                    }
110                }
111            }
112            if policy.risks.ownership_strict {
113                if let Some(oracle) = owner {
114                    if let Err(e) = oracle.owner_of(target) {
115                        stops.push(format!("strict ownership check failed: {e}"));
116                        notes.push("strict ownership check failed".to_string());
117                    }
118                } else {
119                    stops.push("strict ownership policy requires OwnershipOracle".to_string());
120                    notes.push("missing OwnershipOracle for strict ownership".to_string());
121                }
122            }
123            if !policy.scope.allow_roots.is_empty() {
124                let target_abs = target.as_path();
125                let in_allowed = policy
126                    .scope
127                    .allow_roots
128                    .iter()
129                    .any(|r| target_abs.starts_with(r));
130                if !in_allowed {
131                    stops.push(format!(
132                        "target outside allowed roots: {}",
133                        target_abs.display()
134                    ));
135                    notes.push("target outside allowed roots".to_string());
136                }
137            }
138            if policy
139                .scope
140                .forbid_paths
141                .iter()
142                .any(|f| target.as_path().starts_with(f))
143            {
144                stops.push(format!(
145                    "target in forbidden path: {}",
146                    target.as_path().display()
147                ));
148                notes.push("target in forbidden path".to_string());
149            }
150        }
151        Action::RestoreFromBackup { target } => {
152            for p in &policy.apply.extra_mount_checks {
153                if let Err(e) = crate::preflight::checks::ensure_mount_rw_exec(p.as_path()) {
154                    stops.push(format!("{} not rw+exec: {}", p.display(), e));
155                    notes.push(format!("mount: {} not rw+exec", p.display()));
156                } else {
157                    notes.push(format!("mount ok: {} rw+exec", p.display()));
158                }
159            }
160            if let Err(e) = crate::preflight::checks::ensure_mount_rw_exec(&target.as_path()) {
161                stops.push(format!(
162                    "target not rw+exec: {} (target={})",
163                    e,
164                    target.as_path().display()
165                ));
166                notes.push("mount: target not rw+exec".to_string());
167            } else {
168                notes.push("mount ok: target rw+exec".to_string());
169            }
170            if let Err(e) = crate::preflight::checks::check_immutable(&target.as_path()) {
171                stops.push(format!(
172                    "immutable target: {} (target={})",
173                    e,
174                    target.as_path().display()
175                ));
176                notes.push("immutable target".to_string());
177            }
178            if let Ok(risk) = crate::preflight::checks::check_suid_sgid_risk(&target.as_path()) {
179                if risk {
180                    match policy.risks.suid_sgid {
181                        RiskLevel::Stop => {
182                            stops.push("suid/sgid risk".to_string());
183                            notes.push("suid/sgid risk".to_string());
184                        }
185                        RiskLevel::Warn | RiskLevel::Allow => {
186                            notes.push("suid/sgid risk allowed by policy".to_string());
187                        }
188                    }
189                }
190            }
191            if !policy.scope.allow_roots.is_empty() {
192                let target_abs = target.as_path();
193                let in_allowed = policy
194                    .scope
195                    .allow_roots
196                    .iter()
197                    .any(|r| target_abs.starts_with(r));
198                if !in_allowed {
199                    stops.push(format!(
200                        "target outside allowed roots: {}",
201                        target_abs.display()
202                    ));
203                    notes.push("target outside allowed roots".to_string());
204                }
205            }
206            if policy
207                .scope
208                .forbid_paths
209                .iter()
210                .any(|f| target.as_path().starts_with(f))
211            {
212                stops.push(format!(
213                    "target in forbidden path: {}",
214                    target.as_path().display()
215                ));
216                notes.push("target in forbidden path".to_string());
217            }
218        }
219    }
220
221    Evaluation {
222        policy_ok: stops.is_empty(),
223        stops,
224        notes,
225    }
226}
227
228/// Compute policy gating errors for a given plan under the current Switchyard policy.
229/// This mirrors the gating performed in apply.rs before executing actions.
230pub(crate) fn gating_errors(
231    policy: &Policy,
232    owner: Option<&dyn DebugOwnershipOracle>,
233    plan: &Plan,
234) -> Vec<String> {
235    let mut errs: Vec<String> = Vec::new();
236
237    // Global rescue verification: if required by policy, STOP when unavailable.
238    if policy.rescue.require
239        && !crate::policy::rescue::verify_rescue_tools_with_exec_min(
240            policy.rescue.exec_check,
241            policy.rescue.min_count,
242        )
243    {
244        errs.push("rescue profile unavailable".to_string());
245    }
246
247    for act in &plan.actions {
248        let eval = evaluate_action(policy, owner, act);
249        errs.extend(eval.stops);
250    }
251
252    errs
253}