Skip to main content

pidgin_lang/
safety.rs

1use crate::ast::{FieldValue, PgnPacket};
2use crate::registry::{ActionRegistry, SafetyRules, WorkflowRegistry};
3use crate::resolver::ResolvedRef;
4
5#[derive(Debug, Clone, PartialEq)]
6pub enum SafetyRuleId {
7    Sg1,
8    Sg2,
9    Sg3,
10    Sg4,
11    Sg5,
12    Sg6,
13    Sg7,
14    Sg8,
15    Sg9,
16}
17
18impl std::fmt::Display for SafetyRuleId {
19    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
20        match self {
21            SafetyRuleId::Sg1 => write!(f, "SG-1"),
22            SafetyRuleId::Sg2 => write!(f, "SG-2"),
23            SafetyRuleId::Sg3 => write!(f, "SG-3"),
24            SafetyRuleId::Sg4 => write!(f, "SG-4"),
25            SafetyRuleId::Sg5 => write!(f, "SG-5"),
26            SafetyRuleId::Sg6 => write!(f, "SG-6"),
27            SafetyRuleId::Sg7 => write!(f, "SG-7"),
28            SafetyRuleId::Sg8 => write!(f, "SG-8"),
29            SafetyRuleId::Sg9 => write!(f, "SG-9"),
30        }
31    }
32}
33
34#[derive(Debug, Clone)]
35pub struct SafetyResult {
36    pub allowed: bool,
37    pub blocked: bool,
38    pub fired_rules: Vec<SafetyRuleId>,
39    pub human_required: bool,
40    pub effective_risk: String,
41}
42
43#[derive(Debug, Clone)]
44pub struct BlockReason {
45    pub rule: SafetyRuleId,
46    pub message: String,
47}
48
49pub fn check_safety(
50    packet: &PgnPacket,
51    action_registry: &ActionRegistry,
52    safety_rules: &SafetyRules,
53    workflows: &WorkflowRegistry,
54) -> SafetyResult {
55    let mut fired_rules = Vec::new();
56    let mut human_required = false;
57
58    // Compute effective risk
59    let effective_risk = compute_effective_risk(packet, workflows);
60
61    // SG-1: do and deny conflict
62    if let (Some(FieldValue::List(do_list)), Some(FieldValue::List(denied_list))) =
63        (packet.fields.get("do"), packet.fields.get("deny"))
64    {
65        for action in do_list {
66            if denied_list.contains(action) {
67                fired_rules.push(SafetyRuleId::Sg1);
68                break;
69            }
70        }
71    }
72
73    // SG-2: human_gated action without human=yes
74    if let Some(FieldValue::List(do_list)) = packet.fields.get("do") {
75        let human_field = packet.fields.get("human");
76        let has_human_yes = matches!(human_field, Some(FieldValue::Scalar(s)) if s == "yes");
77
78        for action in do_list {
79            if action_registry.human_gated.contains(action) && !has_human_yes {
80                fired_rules.push(SafetyRuleId::Sg2);
81                break;
82            }
83        }
84    }
85
86    // SG-3: high/crit risk defaults to human=yes, explicit human=no on high/crit blocks
87    if effective_risk == "high" || effective_risk == "crit" {
88        human_required = true;
89        if let Some(FieldValue::Scalar(human)) = packet.fields.get("human")
90            && human == "no"
91        {
92            fired_rules.push(SafetyRuleId::Sg3);
93        }
94    }
95
96    // SG-4: private path referenced (check unresolved reference strings for now)
97    if let Some(FieldValue::List(references)) = packet.fields.get("in") {
98        for reference in references {
99            for pattern in &safety_rules.private_paths {
100                if reference.contains(pattern) || reference_matches_pattern(reference, pattern) {
101                    fired_rules.push(SafetyRuleId::Sg4);
102                    break;
103                }
104            }
105        }
106    }
107
108    // SG-5: unknown workflow
109    if let Some(FieldValue::Scalar(wf_name)) = packet.fields.get("wf")
110        && !workflows.workflows.contains_key(wf_name)
111    {
112        fired_rules.push(SafetyRuleId::Sg5);
113    }
114
115    // SG-6: invalid mode
116    if let (Some(FieldValue::Scalar(wf_name)), Some(FieldValue::Scalar(mode))) =
117        (packet.fields.get("wf"), packet.fields.get("mode"))
118        && let Some(workflow) = workflows.workflows.get(wf_name)
119        && !workflow.allowed_modes.contains(mode)
120    {
121        fired_rules.push(SafetyRuleId::Sg6);
122    }
123
124    // SG-7: note field is never parsed for instructions (tested implicitly - no action taken)
125
126    // SG-8: missing required inputs (checked at expansion time, not here)
127
128    // SG-9: critical risk requires approval packet (checked at expansion time)
129
130    let blocked = !fired_rules.is_empty();
131    let allowed = !blocked;
132
133    SafetyResult {
134        allowed,
135        blocked,
136        fired_rules,
137        human_required,
138        effective_risk,
139    }
140}
141
142fn compute_effective_risk(packet: &PgnPacket, workflows: &WorkflowRegistry) -> String {
143    let declared_risk = packet
144        .fields
145        .get("risk")
146        .and_then(|v| match v {
147            FieldValue::Scalar(s) => Some(s.as_str()),
148            _ => None,
149        })
150        .unwrap_or("med");
151
152    let workflow_default = packet
153        .fields
154        .get("wf")
155        .and_then(|v| match v {
156            FieldValue::Scalar(s) => workflows.workflows.get(s),
157            _ => None,
158        })
159        .map(|w| w.risk_default.as_str())
160        .unwrap_or("med");
161
162    // If the packet declares a risk, use it; otherwise use workflow default
163    if packet.fields.contains_key("risk") {
164        declared_risk.to_string()
165    } else {
166        workflow_default.to_string()
167    }
168}
169
170fn reference_matches_pattern(reference: &str, pattern: &str) -> bool {
171    // Simple glob-like matching for private path patterns
172    if let Some(suffix) = pattern.strip_prefix("**/") {
173        reference.contains(suffix)
174    } else if let Some(prefix) = pattern.strip_suffix(".*") {
175        reference.starts_with(prefix)
176    } else if pattern.starts_with('.') {
177        // Dot-prefixed patterns like .env, .env.*
178        reference == pattern || reference.starts_with(&format!("{}.", pattern))
179    } else {
180        reference == pattern
181    }
182}
183
184/// Post-resolution safety check: compares canonical resolved paths against
185/// private path patterns. This catches cases where aliases or path traversal
186/// disguise a reference to a private path.
187pub fn check_resolved_refs_safety(
188    resolved: &[ResolvedRef],
189    private_paths: &[String],
190) -> Vec<SafetyRuleId> {
191    let mut fired = Vec::new();
192    for r in resolved {
193        let path_str = r.resolved_path.as_ref().map(|p| p.display().to_string());
194        let check_str = path_str.as_deref().unwrap_or(&r.ref_id);
195        for pattern in private_paths {
196            if check_str.contains(pattern) || reference_matches_pattern(check_str, pattern) {
197                fired.push(SafetyRuleId::Sg4);
198                break;
199            }
200        }
201    }
202    fired
203}