switchyard/policy/
gating.rs1use crate::api::DebugOwnershipOracle;
2use crate::policy::types::{RiskLevel, SourceTrustPolicy};
3use crate::policy::Policy;
4use crate::types::plan::Action;
5use crate::types::Plan;
6
7#[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#[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 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 #[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
228pub(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 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}