1#[cfg_attr(feature = "serde", derive(serde::Serialize))]
15#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
16pub enum Effect {
17 Pure,
19 ReadLocal,
21 WriteLocal,
23 Network,
25 Process,
27 Destructive,
29 Exec,
31 Privileged,
33}
34
35impl Effect {
36 pub fn name(self) -> &'static str {
38 match self {
39 Effect::Pure => "pure",
40 Effect::ReadLocal => "read_local",
41 Effect::WriteLocal => "write_local",
42 Effect::Network => "network",
43 Effect::Process => "process",
44 Effect::Destructive => "destructive",
45 Effect::Exec => "exec",
46 Effect::Privileged => "privileged",
47 }
48 }
49
50 pub fn from_name(name: &str) -> Option<Effect> {
55 Some(match name {
56 "pure" => Effect::Pure,
57 "read_local" => Effect::ReadLocal,
58 "write_local" => Effect::WriteLocal,
59 "network" => Effect::Network,
60 "process" => Effect::Process,
61 "destructive" => Effect::Destructive,
62 "exec" => Effect::Exec,
63 "privileged" => Effect::Privileged,
64 _ => return None,
65 })
66 }
67
68 pub fn is_dangerous(self) -> bool {
71 matches!(
72 self,
73 Effect::Destructive | Effect::Process | Effect::Exec | Effect::Privileged
74 )
75 }
76
77 pub fn all() -> [Effect; 8] {
80 [
81 Effect::Pure,
82 Effect::ReadLocal,
83 Effect::WriteLocal,
84 Effect::Network,
85 Effect::Process,
86 Effect::Destructive,
87 Effect::Exec,
88 Effect::Privileged,
89 ]
90 }
91
92 pub fn summary(self) -> &'static str {
94 match self {
95 Effect::Pure => "no observable effect (pure computation)",
96 Effect::ReadLocal => "reads local state (filesystem, env, process listing)",
97 Effect::WriteLocal => "creates or modifies local state non-destructively",
98 Effect::Network => "performs network I/O",
99 Effect::Process => "affects other processes (kill, signal, priority)",
100 Effect::Destructive => "irreversibly removes or overwrites local state",
101 Effect::Exec => "executes an arbitrary external command",
102 Effect::Privileged => "requires elevated privileges / affects system-wide state",
103 }
104 }
105
106 pub fn decision(self, mode: Mode) -> Decision {
108 decide(self, mode)
109 }
110}
111
112#[cfg_attr(feature = "serde", derive(serde::Serialize))]
114#[derive(Debug, Clone, Copy, PartialEq, Eq)]
115pub enum Mode {
116 Human,
118 Agent,
120}
121
122impl Mode {
123 pub fn name(self) -> &'static str {
125 match self {
126 Mode::Human => "human",
127 Mode::Agent => "agent",
128 }
129 }
130
131 pub fn all() -> [Mode; 2] {
133 [Mode::Human, Mode::Agent]
134 }
135}
136
137#[cfg_attr(feature = "serde", derive(serde::Serialize))]
139#[derive(Debug, Clone, Copy, PartialEq, Eq)]
140pub enum Decision {
141 Allow,
143 Approve,
145 Deny,
147}
148
149impl Decision {
150 pub fn name(self) -> &'static str {
152 match self {
153 Decision::Allow => "allow",
154 Decision::Approve => "approve",
155 Decision::Deny => "deny",
156 }
157 }
158}
159
160pub fn decide(effect: Effect, mode: Mode) -> Decision {
164 match mode {
165 Mode::Human => Decision::Allow,
166 Mode::Agent => match effect {
167 Effect::Pure | Effect::ReadLocal | Effect::WriteLocal | Effect::Network => {
168 Decision::Allow
169 }
170 Effect::Process | Effect::Destructive | Effect::Exec => Decision::Approve,
171 Effect::Privileged => Decision::Deny,
172 },
173 }
174}
175
176#[cfg_attr(feature = "serde", derive(serde::Serialize))]
178#[derive(Debug, Clone)]
179pub struct SafetyReport {
180 pub mode: Mode,
182 pub effects: usize,
184 pub allowed: usize,
186 pub approval_gated: usize,
188 pub denied: usize,
190 pub dangerous_ungated: usize,
194 pub bounded: bool,
196 pub score: f64,
200 pub grade: char,
202}
203
204pub fn assess_safety(effects: &[Effect], mode: Mode) -> SafetyReport {
206 let (mut allowed, mut approval_gated, mut denied, mut dangerous, mut dangerous_ungated) =
207 (0, 0, 0, 0usize, 0usize);
208 for &e in effects {
209 let d = decide(e, mode);
210 match d {
211 Decision::Allow => allowed += 1,
212 Decision::Approve => approval_gated += 1,
213 Decision::Deny => denied += 1,
214 }
215 if e.is_dangerous() {
216 dangerous += 1;
217 if d == Decision::Allow {
218 dangerous_ungated += 1;
219 }
220 }
221 }
222 let score = if dangerous == 0 {
223 1.0
224 } else {
225 (dangerous - dangerous_ungated) as f64 / dangerous as f64
226 };
227 let grade = if score >= 0.9 {
228 'A'
229 } else if score >= 0.75 {
230 'B'
231 } else if score >= 0.5 {
232 'C'
233 } else if score >= 0.25 {
234 'D'
235 } else {
236 'F'
237 };
238 SafetyReport {
239 mode,
240 effects: effects.len(),
241 allowed,
242 approval_gated,
243 denied,
244 dangerous_ungated,
245 bounded: dangerous_ungated == 0,
246 score,
247 grade,
248 }
249}
250
251impl std::fmt::Display for SafetyReport {
252 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
253 write!(
254 f,
255 "grade {} bounded={} (allowed={} approval-gated={} denied={}, {} dangerous ungated)",
256 self.grade,
257 self.bounded,
258 self.allowed,
259 self.approval_gated,
260 self.denied,
261 self.dangerous_ungated
262 )
263 }
264}
265
266pub fn assess_safety_named<F: Fn(&str) -> Option<Effect>>(
282 names: &[&str],
283 classify: F,
284 mode: Mode,
285) -> SafetyReport {
286 let effects: Vec<Effect> = names.iter().filter_map(|n| classify(n)).collect();
287 assess_safety(&effects, mode)
288}
289
290#[cfg_attr(feature = "serde", derive(serde::Serialize))]
295#[derive(Debug, Clone)]
296pub struct ReversibilityReport {
297 pub dangerous: usize,
299 pub reversible: usize,
301 pub irreversible: usize,
303 pub score: f64,
305 pub recoverable: bool,
307}
308
309pub fn assess_reversibility(ops: &[(Effect, bool)]) -> ReversibilityReport {
313 let mut dangerous = 0usize;
314 let mut reversible = 0usize;
315 for &(effect, rev) in ops {
316 if effect.is_dangerous() {
317 dangerous += 1;
318 if rev {
319 reversible += 1;
320 }
321 }
322 }
323 let irreversible = dangerous - reversible;
324 let score = if dangerous == 0 {
325 1.0
326 } else {
327 reversible as f64 / dangerous as f64
328 };
329 ReversibilityReport {
330 dangerous,
331 reversible,
332 irreversible,
333 score,
334 recoverable: irreversible == 0,
335 }
336}
337
338impl std::fmt::Display for ReversibilityReport {
339 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
340 write!(
341 f,
342 "reversible {}/{} dangerous (score {:.2}, recoverable={})",
343 self.reversible, self.dangerous, self.score, self.recoverable
344 )
345 }
346}
347
348#[cfg_attr(feature = "serde", derive(serde::Serialize))]
352#[derive(Debug, Clone)]
353pub struct ExfiltrationReport {
354 pub has_source: bool,
356 pub has_network: bool,
358 pub has_exec: bool,
360 pub exposed: bool,
362 pub risk: f64,
365}
366
367pub fn assess_exfiltration(effects: &[Effect]) -> ExfiltrationReport {
371 let has_source = effects.contains(&Effect::ReadLocal);
372 let has_network = effects.contains(&Effect::Network);
373 let has_exec = effects.contains(&Effect::Exec);
374 let exposed = has_source && (has_network || has_exec);
375 let risk = match (exposed, has_network, has_exec) {
376 (false, _, _) => 0.0,
377 (true, true, true) => 1.0,
378 (true, _, true) => 0.9,
379 (true, true, false) => 0.6,
380 _ => 0.0,
381 };
382 ExfiltrationReport {
383 has_source,
384 has_network,
385 has_exec,
386 exposed,
387 risk,
388 }
389}
390
391impl std::fmt::Display for ExfiltrationReport {
392 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
393 write!(
394 f,
395 "exfiltration risk {:.2} (exposed={}; source={} network={} exec={})",
396 self.risk, self.exposed, self.has_source, self.has_network, self.has_exec
397 )
398 }
399}
400
401#[cfg(test)]
402mod tests {
403 use super::*;
404
405 #[test]
406 fn agent_policy_gates_every_dangerous_class() {
407 let effects = [
409 Effect::ReadLocal,
410 Effect::WriteLocal,
411 Effect::Destructive,
412 Effect::Exec,
413 Effect::Privileged,
414 ];
415 let r = assess_safety(&effects, Mode::Agent);
416 assert!(r.bounded, "no dangerous effect left ungated");
417 assert_eq!(r.dangerous_ungated, 0);
418 assert_eq!(r.score, 1.0);
419 assert_eq!(r.grade, 'A');
420 assert_eq!(r.denied, 1); assert_eq!(r.approval_gated, 2); assert_eq!(r.allowed, 2); }
424
425 #[test]
426 fn human_mode_allows_everything_so_dangerous_is_ungated() {
427 let effects = [Effect::Destructive, Effect::Exec];
428 let r = assess_safety(&effects, Mode::Human);
429 assert_eq!(r.allowed, 2);
430 assert!(
431 !r.bounded,
432 "human mode does not gate — blast radius unbounded"
433 );
434 assert_eq!(r.dangerous_ungated, 2);
435 assert_eq!(r.score, 0.0);
436 assert_eq!(r.grade, 'F');
437 }
438
439 #[test]
440 fn pure_program_is_trivially_safe() {
441 let r = assess_safety(&[Effect::Pure, Effect::ReadLocal], Mode::Agent);
442 assert_eq!(r.score, 1.0); assert!(r.bounded);
444 assert_eq!(r.grade, 'A');
445 }
446
447 #[test]
448 fn effects_are_ordered_by_danger() {
449 assert!(Effect::Pure < Effect::Destructive);
450 assert!(Effect::Network < Effect::Privileged);
451 assert!(Effect::Destructive.is_dangerous());
452 assert!(!Effect::ReadLocal.is_dangerous());
453 }
454
455 #[test]
456 fn from_name_round_trips_every_effect() {
457 for e in [
458 Effect::Pure,
459 Effect::ReadLocal,
460 Effect::WriteLocal,
461 Effect::Network,
462 Effect::Process,
463 Effect::Destructive,
464 Effect::Exec,
465 Effect::Privileged,
466 ] {
467 assert_eq!(Effect::from_name(e.name()), Some(e));
468 }
469 assert_eq!(Effect::from_name("nonsense"), None);
470 }
471
472 #[test]
473 fn assess_safety_named_maps_and_skips_unknown() {
474 let r = assess_safety_named(
476 &["read_local", "destructive", "exec", "??unknown??"],
477 Effect::from_name,
478 Mode::Agent,
479 );
480 assert_eq!(r.effects, 3, "unknown name skipped");
481 assert!(r.bounded); assert_eq!(r.approval_gated, 2);
483 assert_eq!(r.grade, 'A');
484 }
485
486 #[test]
487 fn reversibility_scores_only_dangerous_effects() {
488 let ops = [
490 (Effect::ReadLocal, false), (Effect::Destructive, true),
492 (Effect::Exec, false),
493 ];
494 let r = assess_reversibility(&ops);
495 assert_eq!(r.dangerous, 2);
496 assert_eq!(r.reversible, 1);
497 assert_eq!(r.irreversible, 1);
498 assert_eq!(r.score, 0.5);
499 assert!(!r.recoverable);
500
501 let ok = assess_reversibility(&[(Effect::Destructive, true), (Effect::Process, true)]);
503 assert!(ok.recoverable && ok.score == 1.0);
504 assert_eq!(assess_reversibility(&[(Effect::Pure, false)]).score, 1.0);
506 }
507
508 #[test]
509 fn exfiltration_needs_both_source_and_sink() {
510 let r = assess_exfiltration(&[Effect::ReadLocal, Effect::Network]);
512 assert!(r.exposed && r.risk == 0.6);
513 assert_eq!(
515 assess_exfiltration(&[Effect::ReadLocal, Effect::Exec]).risk,
516 0.9
517 );
518 assert_eq!(
520 assess_exfiltration(&[Effect::ReadLocal, Effect::Network, Effect::Exec]).risk,
521 1.0
522 );
523 assert!(!assess_exfiltration(&[Effect::ReadLocal]).exposed);
525 assert!(!assess_exfiltration(&[Effect::Network]).exposed);
526 assert_eq!(assess_exfiltration(&[Effect::Network]).risk, 0.0);
527 }
528}