Skip to main content

noether_core/effects/
effect.rs

1use serde::{Deserialize, Serialize};
2use std::collections::BTreeSet;
3use std::path::PathBuf;
4
5#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
6#[serde(tag = "effect")]
7pub enum Effect {
8    Cost {
9        cents: u64,
10    },
11    Fallible,
12    /// Stage reads a specific host path. Use an absolute path; the
13    /// sandbox binds it at the same location inside the sandbox (via
14    /// a read-only bind mount). Multiple read paths are declared as
15    /// separate `FsRead` entries — one per path.
16    ///
17    /// `from_effects` on the isolation policy turns each `FsRead(p)`
18    /// into a `RoBind { host: p, sandbox: p }`.
19    FsRead {
20        path: PathBuf,
21    },
22    /// Stage writes to a specific host path. Use an absolute path;
23    /// the sandbox binds it RW at the same location inside. This is
24    /// a deliberate trust widening — the sandbox cannot validate
25    /// whether binding (say) `/home/user` RW is sensible. Callers
26    /// that need this are declaring the trust decision explicitly
27    /// via this effect.
28    ///
29    /// `from_effects` on the isolation policy turns each `FsWrite(p)`
30    /// into an `RwBind { host: p, sandbox: p }`.
31    FsWrite {
32        path: PathBuf,
33    },
34    Llm {
35        model: String,
36    },
37    Network,
38    NonDeterministic,
39    /// Stage spawns, signals, or waits on OS-level processes.
40    Process,
41    Pure,
42    Unknown,
43}
44
45/// The variant name of an [`Effect`], without associated data.
46///
47/// Used by [`EffectPolicy`] to allow/deny whole classes of effects regardless
48/// of their parameters (e.g. deny all `Llm` calls irrespective of which model).
49#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
50#[serde(rename_all = "snake_case")]
51pub enum EffectKind {
52    Cost,
53    Fallible,
54    FsRead,
55    FsWrite,
56    Llm,
57    Network,
58    NonDeterministic,
59    Process,
60    Pure,
61    Unknown,
62}
63
64impl Effect {
65    /// Return the kind (variant discriminant) of this effect, dropping any
66    /// associated data. Used for policy comparisons.
67    pub fn kind(&self) -> EffectKind {
68        match self {
69            Effect::Cost { .. } => EffectKind::Cost,
70            Effect::Fallible => EffectKind::Fallible,
71            Effect::FsRead { .. } => EffectKind::FsRead,
72            Effect::FsWrite { .. } => EffectKind::FsWrite,
73            Effect::Llm { .. } => EffectKind::Llm,
74            Effect::Network => EffectKind::Network,
75            Effect::NonDeterministic => EffectKind::NonDeterministic,
76            Effect::Process => EffectKind::Process,
77            Effect::Pure => EffectKind::Pure,
78            Effect::Unknown => EffectKind::Unknown,
79        }
80    }
81}
82
83impl std::fmt::Display for EffectKind {
84    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
85        let s = match self {
86            EffectKind::Cost => "cost",
87            EffectKind::Fallible => "fallible",
88            EffectKind::FsRead => "fs-read",
89            EffectKind::FsWrite => "fs-write",
90            EffectKind::Llm => "llm",
91            EffectKind::Network => "network",
92            EffectKind::NonDeterministic => "non-deterministic",
93            EffectKind::Process => "process",
94            EffectKind::Pure => "pure",
95            EffectKind::Unknown => "unknown",
96        };
97        write!(f, "{s}")
98    }
99}
100
101/// An ordered set of effects declared on a stage.
102///
103/// Uses `BTreeSet` for deterministic serialization order, which is
104/// critical for canonical JSON hashing.
105#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
106pub struct EffectSet {
107    effects: BTreeSet<Effect>,
108}
109
110impl EffectSet {
111    pub fn unknown() -> Self {
112        Self {
113            effects: BTreeSet::from([Effect::Unknown]),
114        }
115    }
116
117    pub fn pure() -> Self {
118        Self {
119            effects: BTreeSet::from([Effect::Pure]),
120        }
121    }
122
123    pub fn new(effects: impl IntoIterator<Item = Effect>) -> Self {
124        Self {
125            effects: effects.into_iter().collect(),
126        }
127    }
128
129    pub fn contains(&self, effect: &Effect) -> bool {
130        self.effects.contains(effect)
131    }
132
133    pub fn is_unknown(&self) -> bool {
134        self.effects.contains(&Effect::Unknown)
135    }
136
137    pub fn iter(&self) -> impl Iterator<Item = &Effect> {
138        self.effects.iter()
139    }
140}
141
142impl Default for EffectSet {
143    fn default() -> Self {
144        Self::unknown()
145    }
146}
147
148#[cfg(test)]
149mod tests {
150    use super::*;
151
152    #[test]
153    fn default_is_unknown() {
154        let es = EffectSet::default();
155        assert!(es.is_unknown());
156        assert!(es.contains(&Effect::Unknown));
157    }
158
159    #[test]
160    fn pure_does_not_contain_unknown() {
161        let es = EffectSet::pure();
162        assert!(!es.is_unknown());
163        assert!(es.contains(&Effect::Pure));
164    }
165
166    #[test]
167    fn serde_round_trip() {
168        let es = EffectSet::new([
169            Effect::Network,
170            Effect::Fallible,
171            Effect::Llm {
172                model: "claude-sonnet-4".into(),
173            },
174        ]);
175        let json = serde_json::to_string(&es).unwrap();
176        let deserialized: EffectSet = serde_json::from_str(&json).unwrap();
177        assert_eq!(es, deserialized);
178    }
179
180    #[test]
181    fn fs_effects_round_trip_through_json() {
182        // M3.x: path-bearing FsRead / FsWrite variants must round-trip
183        // cleanly through the `#[serde(tag = "effect")]` shape, and
184        // coexist with the existing effect variants (so a stage can
185        // declare `{Pure, FsRead(/etc), FsWrite(/tmp/out)}` and hit
186        // every branch of `from_effects`).
187        let es = EffectSet::new([
188            Effect::Pure,
189            Effect::FsRead {
190                path: PathBuf::from("/etc/ssl/certs"),
191            },
192            Effect::FsWrite {
193                path: PathBuf::from("/tmp/agent-output"),
194            },
195        ]);
196        let json = serde_json::to_string(&es).unwrap();
197        // Contract: the wire shape uses the same tag key as every
198        // other Effect variant. Downstream deserialisers (e.g. the
199        // Python bindings the agentspec PR will grow) see a uniform
200        // `{"effect": "FsRead", "path": "..."}` shape.
201        assert!(
202            json.contains(r#""effect":"FsRead""#),
203            "expected FsRead tag in wire: {json}"
204        );
205        assert!(
206            json.contains(r#""effect":"FsWrite""#),
207            "expected FsWrite tag in wire: {json}"
208        );
209        let deserialized: EffectSet = serde_json::from_str(&json).unwrap();
210        assert_eq!(es, deserialized);
211    }
212
213    #[test]
214    fn fs_effect_kinds_map_one_to_one() {
215        let read = Effect::FsRead {
216            path: PathBuf::from("/a"),
217        };
218        let write = Effect::FsWrite {
219            path: PathBuf::from("/b"),
220        };
221        assert_eq!(read.kind(), EffectKind::FsRead);
222        assert_eq!(write.kind(), EffectKind::FsWrite);
223        // Display for CLI surface (`--allow-effects fs-read,fs-write`).
224        assert_eq!(EffectKind::FsRead.to_string(), "fs-read");
225        assert_eq!(EffectKind::FsWrite.to_string(), "fs-write");
226    }
227
228    #[test]
229    fn distinct_fs_read_paths_are_distinct_elements() {
230        // EffectSet is a BTreeSet. Two FsRead effects with different
231        // paths must be stored as two elements — otherwise declaring
232        // "read /etc AND read /home" would collapse to one.
233        let es = EffectSet::new([
234            Effect::FsRead {
235                path: PathBuf::from("/etc"),
236            },
237            Effect::FsRead {
238                path: PathBuf::from("/home"),
239            },
240        ]);
241        assert_eq!(es.iter().count(), 2);
242    }
243}