#[cfg_attr(feature = "serde", derive(serde::Serialize))]
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub enum Effect {
Pure,
ReadLocal,
WriteLocal,
Network,
Process,
Destructive,
Exec,
Privileged,
}
impl Effect {
pub fn name(self) -> &'static str {
match self {
Effect::Pure => "pure",
Effect::ReadLocal => "read_local",
Effect::WriteLocal => "write_local",
Effect::Network => "network",
Effect::Process => "process",
Effect::Destructive => "destructive",
Effect::Exec => "exec",
Effect::Privileged => "privileged",
}
}
pub fn from_name(name: &str) -> Option<Effect> {
Some(match name {
"pure" => Effect::Pure,
"read_local" => Effect::ReadLocal,
"write_local" => Effect::WriteLocal,
"network" => Effect::Network,
"process" => Effect::Process,
"destructive" => Effect::Destructive,
"exec" => Effect::Exec,
"privileged" => Effect::Privileged,
_ => return None,
})
}
pub fn is_dangerous(self) -> bool {
matches!(
self,
Effect::Destructive | Effect::Process | Effect::Exec | Effect::Privileged
)
}
pub fn all() -> [Effect; 8] {
[
Effect::Pure,
Effect::ReadLocal,
Effect::WriteLocal,
Effect::Network,
Effect::Process,
Effect::Destructive,
Effect::Exec,
Effect::Privileged,
]
}
pub fn summary(self) -> &'static str {
match self {
Effect::Pure => "no observable effect (pure computation)",
Effect::ReadLocal => "reads local state (filesystem, env, process listing)",
Effect::WriteLocal => "creates or modifies local state non-destructively",
Effect::Network => "performs network I/O",
Effect::Process => "affects other processes (kill, signal, priority)",
Effect::Destructive => "irreversibly removes or overwrites local state",
Effect::Exec => "executes an arbitrary external command",
Effect::Privileged => "requires elevated privileges / affects system-wide state",
}
}
pub fn decision(self, mode: Mode) -> Decision {
decide(self, mode)
}
}
#[cfg_attr(feature = "serde", derive(serde::Serialize))]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Mode {
Human,
Agent,
}
impl Mode {
pub fn name(self) -> &'static str {
match self {
Mode::Human => "human",
Mode::Agent => "agent",
}
}
pub fn all() -> [Mode; 2] {
[Mode::Human, Mode::Agent]
}
}
#[cfg_attr(feature = "serde", derive(serde::Serialize))]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Decision {
Allow,
Approve,
Deny,
}
impl Decision {
pub fn name(self) -> &'static str {
match self {
Decision::Allow => "allow",
Decision::Approve => "approve",
Decision::Deny => "deny",
}
}
}
pub fn decide(effect: Effect, mode: Mode) -> Decision {
match mode {
Mode::Human => Decision::Allow,
Mode::Agent => match effect {
Effect::Pure | Effect::ReadLocal | Effect::WriteLocal | Effect::Network => {
Decision::Allow
}
Effect::Process | Effect::Destructive | Effect::Exec => Decision::Approve,
Effect::Privileged => Decision::Deny,
},
}
}
#[cfg_attr(feature = "serde", derive(serde::Serialize))]
#[derive(Debug, Clone)]
pub struct SafetyReport {
pub mode: Mode,
pub effects: usize,
pub allowed: usize,
pub approval_gated: usize,
pub denied: usize,
pub dangerous_ungated: usize,
pub bounded: bool,
pub score: f64,
pub grade: char,
}
pub fn assess_safety(effects: &[Effect], mode: Mode) -> SafetyReport {
let (mut allowed, mut approval_gated, mut denied, mut dangerous, mut dangerous_ungated) =
(0, 0, 0, 0usize, 0usize);
for &e in effects {
let d = decide(e, mode);
match d {
Decision::Allow => allowed += 1,
Decision::Approve => approval_gated += 1,
Decision::Deny => denied += 1,
}
if e.is_dangerous() {
dangerous += 1;
if d == Decision::Allow {
dangerous_ungated += 1;
}
}
}
let score = if dangerous == 0 {
1.0
} else {
(dangerous - dangerous_ungated) as f64 / dangerous as f64
};
let grade = if score >= 0.9 {
'A'
} else if score >= 0.75 {
'B'
} else if score >= 0.5 {
'C'
} else if score >= 0.25 {
'D'
} else {
'F'
};
SafetyReport {
mode,
effects: effects.len(),
allowed,
approval_gated,
denied,
dangerous_ungated,
bounded: dangerous_ungated == 0,
score,
grade,
}
}
impl std::fmt::Display for SafetyReport {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
"grade {} bounded={} (allowed={} approval-gated={} denied={}, {} dangerous ungated)",
self.grade,
self.bounded,
self.allowed,
self.approval_gated,
self.denied,
self.dangerous_ungated
)
}
}
pub fn assess_safety_named<F: Fn(&str) -> Option<Effect>>(
names: &[&str],
classify: F,
mode: Mode,
) -> SafetyReport {
let effects: Vec<Effect> = names.iter().filter_map(|n| classify(n)).collect();
assess_safety(&effects, mode)
}
#[cfg_attr(feature = "serde", derive(serde::Serialize))]
#[derive(Debug, Clone)]
pub struct ReversibilityReport {
pub dangerous: usize,
pub reversible: usize,
pub irreversible: usize,
pub score: f64,
pub recoverable: bool,
}
pub fn assess_reversibility(ops: &[(Effect, bool)]) -> ReversibilityReport {
let mut dangerous = 0usize;
let mut reversible = 0usize;
for &(effect, rev) in ops {
if effect.is_dangerous() {
dangerous += 1;
if rev {
reversible += 1;
}
}
}
let irreversible = dangerous - reversible;
let score = if dangerous == 0 {
1.0
} else {
reversible as f64 / dangerous as f64
};
ReversibilityReport {
dangerous,
reversible,
irreversible,
score,
recoverable: irreversible == 0,
}
}
impl std::fmt::Display for ReversibilityReport {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
"reversible {}/{} dangerous (score {:.2}, recoverable={})",
self.reversible, self.dangerous, self.score, self.recoverable
)
}
}
#[cfg_attr(feature = "serde", derive(serde::Serialize))]
#[derive(Debug, Clone)]
pub struct ExfiltrationReport {
pub has_source: bool,
pub has_network: bool,
pub has_exec: bool,
pub exposed: bool,
pub risk: f64,
}
pub fn assess_exfiltration(effects: &[Effect]) -> ExfiltrationReport {
let has_source = effects.contains(&Effect::ReadLocal);
let has_network = effects.contains(&Effect::Network);
let has_exec = effects.contains(&Effect::Exec);
let exposed = has_source && (has_network || has_exec);
let risk = match (exposed, has_network, has_exec) {
(false, _, _) => 0.0,
(true, true, true) => 1.0,
(true, _, true) => 0.9,
(true, true, false) => 0.6,
_ => 0.0,
};
ExfiltrationReport {
has_source,
has_network,
has_exec,
exposed,
risk,
}
}
impl std::fmt::Display for ExfiltrationReport {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
"exfiltration risk {:.2} (exposed={}; source={} network={} exec={})",
self.risk, self.exposed, self.has_source, self.has_network, self.has_exec
)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn agent_policy_gates_every_dangerous_class() {
let effects = [
Effect::ReadLocal,
Effect::WriteLocal,
Effect::Destructive,
Effect::Exec,
Effect::Privileged,
];
let r = assess_safety(&effects, Mode::Agent);
assert!(r.bounded, "no dangerous effect left ungated");
assert_eq!(r.dangerous_ungated, 0);
assert_eq!(r.score, 1.0);
assert_eq!(r.grade, 'A');
assert_eq!(r.denied, 1); assert_eq!(r.approval_gated, 2); assert_eq!(r.allowed, 2); }
#[test]
fn human_mode_allows_everything_so_dangerous_is_ungated() {
let effects = [Effect::Destructive, Effect::Exec];
let r = assess_safety(&effects, Mode::Human);
assert_eq!(r.allowed, 2);
assert!(
!r.bounded,
"human mode does not gate — blast radius unbounded"
);
assert_eq!(r.dangerous_ungated, 2);
assert_eq!(r.score, 0.0);
assert_eq!(r.grade, 'F');
}
#[test]
fn pure_program_is_trivially_safe() {
let r = assess_safety(&[Effect::Pure, Effect::ReadLocal], Mode::Agent);
assert_eq!(r.score, 1.0); assert!(r.bounded);
assert_eq!(r.grade, 'A');
}
#[test]
fn effects_are_ordered_by_danger() {
assert!(Effect::Pure < Effect::Destructive);
assert!(Effect::Network < Effect::Privileged);
assert!(Effect::Destructive.is_dangerous());
assert!(!Effect::ReadLocal.is_dangerous());
}
#[test]
fn from_name_round_trips_every_effect() {
for e in [
Effect::Pure,
Effect::ReadLocal,
Effect::WriteLocal,
Effect::Network,
Effect::Process,
Effect::Destructive,
Effect::Exec,
Effect::Privileged,
] {
assert_eq!(Effect::from_name(e.name()), Some(e));
}
assert_eq!(Effect::from_name("nonsense"), None);
}
#[test]
fn assess_safety_named_maps_and_skips_unknown() {
let r = assess_safety_named(
&["read_local", "destructive", "exec", "??unknown??"],
Effect::from_name,
Mode::Agent,
);
assert_eq!(r.effects, 3, "unknown name skipped");
assert!(r.bounded); assert_eq!(r.approval_gated, 2);
assert_eq!(r.grade, 'A');
}
#[test]
fn reversibility_scores_only_dangerous_effects() {
let ops = [
(Effect::ReadLocal, false), (Effect::Destructive, true),
(Effect::Exec, false),
];
let r = assess_reversibility(&ops);
assert_eq!(r.dangerous, 2);
assert_eq!(r.reversible, 1);
assert_eq!(r.irreversible, 1);
assert_eq!(r.score, 0.5);
assert!(!r.recoverable);
let ok = assess_reversibility(&[(Effect::Destructive, true), (Effect::Process, true)]);
assert!(ok.recoverable && ok.score == 1.0);
assert_eq!(assess_reversibility(&[(Effect::Pure, false)]).score, 1.0);
}
#[test]
fn exfiltration_needs_both_source_and_sink() {
let r = assess_exfiltration(&[Effect::ReadLocal, Effect::Network]);
assert!(r.exposed && r.risk == 0.6);
assert_eq!(
assess_exfiltration(&[Effect::ReadLocal, Effect::Exec]).risk,
0.9
);
assert_eq!(
assess_exfiltration(&[Effect::ReadLocal, Effect::Network, Effect::Exec]).risk,
1.0
);
assert!(!assess_exfiltration(&[Effect::ReadLocal]).exposed);
assert!(!assess_exfiltration(&[Effect::Network]).exposed);
assert_eq!(assess_exfiltration(&[Effect::Network]).risk, 0.0);
}
}