1use perspt_sdk::{
14 BarrierEvaluator, BarrierWitness, CandidateTransition, EffectKind, EffectProposal, SdkError,
15};
16
17#[derive(Debug, Clone, PartialEq)]
19struct ChannelReading {
20 name: &'static str,
21 h_after: f64,
22}
23
24#[derive(Debug, Clone)]
26pub struct OperationalSafetyBarrier {
27 pub protected_paths: Vec<String>,
29 pub secret_markers: Vec<String>,
31 pub network_allowlist: Vec<String>,
33}
34
35impl Default for OperationalSafetyBarrier {
36 fn default() -> Self {
37 Self {
38 protected_paths: vec![".git/".into(), ".github/workflows/".into()],
39 secret_markers: vec![
40 ".env".into(),
41 ".pem".into(),
42 "id_rsa".into(),
43 "credentials".into(),
44 ".aws/".into(),
45 ".ssh/".into(),
46 ],
47 network_allowlist: Vec::new(),
48 }
49 }
50}
51
52impl OperationalSafetyBarrier {
53 pub fn measure_channels<'a>(
59 &self,
60 mutated_paths: impl IntoIterator<Item = &'a str>,
61 ) -> std::collections::BTreeMap<String, f64> {
62 let mut protected = false;
63 let mut secret = false;
64 let mut escape = false;
65 for path in mutated_paths {
66 let touches = |marker: &str| path.contains(marker.trim_end_matches('/'));
67 protected |= self.protected_paths.iter().any(|p| touches(p));
68 secret |= self.secret_markers.iter().any(|m| touches(m));
69 escape |= path.starts_with('/') || path.split('/').any(|c| c == "..");
70 }
71 let boolean = |unsafe_now: bool| if unsafe_now { 1.0 } else { 0.0 };
72 std::collections::BTreeMap::from([
73 (
74 "protected-path-modification".to_string(),
75 boolean(protected),
76 ),
77 ("secret-exposure".to_string(), boolean(secret)),
78 ("sandbox-escape".to_string(), boolean(escape)),
79 ])
80 }
81
82 fn readings(&self, proposal: &EffectProposal) -> Vec<ChannelReading> {
84 let path = proposal.path.as_deref().unwrap_or("");
85 let mutating = !proposal.effect.is_read_only();
86 let touches =
87 |marker: &str| !path.is_empty() && path.contains(marker.trim_end_matches('/'));
88
89 let protected = mutating && self.protected_paths.iter().any(|p| touches(p));
90 let escape = mutating && (path.starts_with('/') || path.split('/').any(|c| c == ".."));
93 let secret = mutating && self.secret_markers.iter().any(|m| touches(m));
94 let network = match proposal.network_target.as_deref() {
95 None => false,
96 Some(target) => !self
97 .network_allowlist
98 .iter()
99 .any(|allowed| target.contains(allowed.as_str())),
100 };
101 let dependency =
102 proposal.effect == EffectKind::MutateDependencies && proposal.command.is_none();
103
104 let boolean = |unsafe_now: bool| if unsafe_now { 1.0 } else { 0.0 };
105 vec![
106 ChannelReading {
107 name: "protected-path-modification",
108 h_after: boolean(protected),
109 },
110 ChannelReading {
111 name: "sandbox-escape",
112 h_after: boolean(escape),
113 },
114 ChannelReading {
115 name: "secret-exposure",
116 h_after: boolean(secret),
117 },
118 ChannelReading {
119 name: "unapproved-network-reachability",
120 h_after: boolean(network),
121 },
122 ChannelReading {
123 name: "dependency-policy-violation",
124 h_after: boolean(dependency),
125 },
126 ChannelReading {
129 name: "resource-limits",
130 h_after: 0.0,
131 },
132 ]
133 }
134}
135
136impl BarrierEvaluator for OperationalSafetyBarrier {
137 fn evaluate(&self, transition: &CandidateTransition) -> Result<BarrierWitness, SdkError> {
138 let proposal = &transition.proposal;
139 let readings = self.readings(proposal);
140 let h_before = transition
141 .before
142 .barrier_channels
143 .values()
144 .copied()
145 .fold(0.0, f64::max);
146 let measured_after = transition
147 .after
148 .barrier_channels
149 .values()
150 .copied()
151 .fold(0.0, f64::max);
152 let h_after = readings
153 .iter()
154 .map(|r| r.h_after)
155 .fold(measured_after, f64::max);
156 let c_t = (h_after - h_before).max(0.0);
157 Ok(BarrierWitness {
158 h_before,
159 expected_h_after_upper: h_after,
160 certified_increment: c_t,
161 unsafe_threshold: 1.0,
162 evidence_refs: readings
163 .iter()
164 .map(|r| format!("channel:{}@1={}", r.name, r.h_after))
165 .chain([
166 format!("state-before:{}", transition.before.state_root),
167 format!("state-after:{}", transition.after.state_root),
168 ])
169 .collect(),
170 })
171 }
172}
173
174#[cfg(test)]
175mod tests {
176 use super::*;
177 use perspt_sdk::{ActorId, EffectProposal};
178
179 fn proposal(effect: EffectKind, path: Option<&str>) -> EffectProposal {
180 let mut p = EffectProposal::new(ActorId::new("worker"), "n1", effect);
181 if let Some(path) = path {
182 p = p.with_path(path);
183 }
184 p
185 }
186
187 fn transition(effect: EffectKind, path: Option<&str>) -> CandidateTransition {
188 CandidateTransition::unmeasured(proposal(effect, path))
189 }
190
191 #[test]
192 fn a_workspace_edit_has_zero_increment_by_measurement() {
193 let barrier = OperationalSafetyBarrier::default();
194 let witness = barrier
195 .evaluate(&transition(EffectKind::ApplyPatch, Some("src/lib.rs")))
196 .unwrap();
197 assert_eq!(witness.certified_increment, 0.0);
198 assert!(witness.clause_holds(0.0));
199 }
200
201 #[test]
202 fn protected_path_modification_crosses_the_boundary() {
203 let barrier = OperationalSafetyBarrier::default();
204 let witness = barrier
205 .evaluate(&transition(EffectKind::ApplyPatch, Some(".git/config")))
206 .unwrap();
207 assert!(!witness.clause_holds(1.0));
210 }
211
212 #[test]
213 fn reading_a_protected_path_is_not_a_barrier_event() {
214 let barrier = OperationalSafetyBarrier::default();
215 let witness = barrier
216 .evaluate(&transition(EffectKind::ReadFile, Some(".git/config")))
217 .unwrap();
218 assert_eq!(witness.certified_increment, 0.0);
219 }
220
221 #[test]
222 fn parent_traversal_is_a_sandbox_escape() {
223 let barrier = OperationalSafetyBarrier::default();
224 let witness = barrier
225 .evaluate(&transition(
226 EffectKind::WriteArtifact,
227 Some("../outside.rs"),
228 ))
229 .unwrap();
230 assert!(!witness.clause_holds(1.0));
231 }
232
233 #[test]
234 fn secret_material_is_a_channel_violation() {
235 let barrier = OperationalSafetyBarrier::default();
236 let witness = barrier
237 .evaluate(&transition(EffectKind::WriteArtifact, Some(".env")))
238 .unwrap();
239 assert!(!witness.clause_holds(1.0));
240 }
241
242 #[test]
243 fn unallowlisted_network_target_violates() {
244 let barrier = OperationalSafetyBarrier::default();
245 let mut p = proposal(EffectKind::NetworkFetch, None);
246 p = p.with_network_target("https://example.com/payload");
247 let witness = barrier
248 .evaluate(&CandidateTransition::unmeasured(p))
249 .unwrap();
250 assert!(!witness.clause_holds(1.0));
251 }
252}