Skip to main content

perspt_coding/
barrier.rs

1//! The coding operational safety barrier (PSP-9 system 12).
2//!
3//! A narrow, useful barrier rather than a pretend solution to semantic
4//! correctness: versioned policy channels `h_j(x) ∈ [0, ∞)` whose unsafe
5//! boundary is 1, with `h(x) = max_j h_j(x)` and the exact deterministic
6//! increment `c_t = max(0, h(x') − h(x))`. Correctness evidence — compiler
7//! errors, test failures — stays in `V` and is never duplicated into `h`.
8//!
9//! The old shortcut "deterministic transition, therefore `c_c = 0`" is
10//! prohibited: read-only effects get `c_t = 0` by *measurement* (no channel
11//! moves), not by declaration.
12
13use perspt_sdk::{
14    BarrierEvaluator, BarrierWitness, CandidateTransition, EffectKind, EffectProposal, SdkError,
15};
16
17/// Channel verdict: the channel's value for a candidate transition.
18#[derive(Debug, Clone, PartialEq)]
19struct ChannelReading {
20    name: &'static str,
21    h_after: f64,
22}
23
24/// The versioned channel set for the coding domain.
25#[derive(Debug, Clone)]
26pub struct OperationalSafetyBarrier {
27    /// Path prefixes that must never be modified.
28    pub protected_paths: Vec<String>,
29    /// Filename fragments that indicate secret material.
30    pub secret_markers: Vec<String>,
31    /// Allow-listed network hosts; empty means every target is unapproved.
32    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    /// Measure the path-derived channels over the *realized* mutated paths of
54    /// a candidate, for `CandidateStateWitness::barrier_channels`. This is
55    /// what lets `evaluate` observe the materialized candidate instead of
56    /// only the declared proposal (Paper I Def. 12.2): a witness built from
57    /// these readings carries `h(x)` as measured on disk.
58    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    /// Evaluate every channel against the proposal's candidate transition.
83    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        // Sandbox escape: a mutating effect addressing an absolute path or a
91        // parent traversal is outside the candidate overlay by construction.
92        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            // Resource limits are enforced by the sandbox at execution; the
127            // channel reports safe unless a limit is already breached.
128            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        // h(x') = 1 is at the unsafe boundary: the clause fails whatever the
208        // budget, because promotion requires h(x') < 1.
209        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}