Skip to main content

iicp_client/
policy.rs

1// SPDX-License-Identifier: Apache-2.0
2//! Client-side policy guardrails.
3//!
4//! This is intentionally narrow: it refuses intent URNs aligned with EU AI Act
5//! prohibited-practice families before discovery, without turning the SDK into a
6//! broad legal compliance engine.
7
8use crate::errors::{IicpError, Result};
9
10pub const POLICY_REFUSAL_CODE: &str = "IICP-POLICY-001";
11
12#[derive(Debug, Clone, Copy, PartialEq, Eq)]
13pub enum IntentRiskCategory {
14    Prohibited,
15    HighRisk,
16    TransparencyRisk,
17    MinimalOrGeneral,
18}
19
20#[derive(Debug, Clone, Copy, PartialEq, Eq)]
21pub struct ProhibitedIntentRule {
22    pub rule_id: &'static str,
23    pub label: &'static str,
24    pub fragments: &'static [&'static str],
25}
26
27pub const PROHIBITED_INTENT_RULES: &[ProhibitedIntentRule] = &[
28    ProhibitedIntentRule {
29        rule_id: "eu-ai-act-social-scoring",
30        label: "social scoring",
31        fragments: &["social-scoring", "social_scoring", "social:scoring"],
32    },
33    ProhibitedIntentRule {
34        rule_id: "eu-ai-act-criminal-risk",
35        label: "individual criminal risk prediction",
36        fragments: &[
37            "criminal-risk",
38            "criminal_risk",
39            "criminal:risk",
40            "predict-crime",
41        ],
42    },
43    ProhibitedIntentRule {
44        rule_id: "eu-ai-act-workplace-education-emotion",
45        label: "workplace or education emotion recognition",
46        fragments: &[
47            "emotion:workplace",
48            "emotion:education",
49            "workplace-monitoring",
50            "education-monitoring",
51            "worker-monitoring",
52        ],
53    },
54    ProhibitedIntentRule {
55        rule_id: "eu-ai-act-protected-trait-biometric",
56        label: "biometric protected-trait classification",
57        fragments: &["protected-trait", "protected_trait", "biometric:protected"],
58    },
59    ProhibitedIntentRule {
60        rule_id: "eu-ai-act-untargeted-face-scraping",
61        label: "untargeted facial image scraping for recognition databases",
62        fragments: &[
63            "untargeted-scraping",
64            "untargeted_scraping",
65            "face-scraping",
66            "facial-scraping",
67        ],
68    },
69    ProhibitedIntentRule {
70        rule_id: "eu-ai-act-realtime-remote-biometric-id",
71        label: "real-time remote biometric identification",
72        fragments: &[
73            "remote-biometric:realtime",
74            "realtime-remote-biometric",
75            "real-time-remote-biometric",
76        ],
77    },
78    ProhibitedIntentRule {
79        rule_id: "eu-ai-act-nonconsensual-sexual-deepfake",
80        label: "non-consensual sexual deepfake or CSAM generation",
81        fragments: &[
82            "nonconsensual-sexual",
83            "non-consensual-sexual",
84            "child-sexual-abuse",
85            "csam",
86        ],
87    },
88];
89
90pub const HIGH_RISK_INTENT_RULES: &[ProhibitedIntentRule] = &[
91    ProhibitedIntentRule {
92        rule_id: "eu-ai-act-employment-workforce",
93        label: "employment or workforce decision",
94        fragments: &[
95            "employment:hiring",
96            "employment:screen",
97            "employment:rank",
98            "recruitment:decision",
99            "workforce:decision",
100            "worker-management",
101            "worker:performance",
102            "worker:discipline",
103        ],
104    },
105    ProhibitedIntentRule {
106        rule_id: "eu-ai-act-education-admission-grading",
107        label: "education admission or grading decision",
108        fragments: &[
109            "education:admission",
110            "education:grading",
111            "education:grade",
112            "student:admission",
113            "student:assess",
114            "exam-grading",
115        ],
116    },
117    ProhibitedIntentRule {
118        rule_id: "eu-ai-act-credit-essential-services",
119        label: "credit or essential-services decision",
120        fragments: &[
121            "credit-scoring",
122            "credit:score",
123            "credit:decision",
124            "essential-services",
125            "benefits:eligibility",
126            "public-benefit:eligibility",
127        ],
128    },
129    ProhibitedIntentRule {
130        rule_id: "eu-ai-act-law-enforcement-border-justice",
131        label: "law enforcement, border, justice or democratic-process decision",
132        fragments: &[
133            "law-enforcement",
134            "law_enforcement",
135            "migration:decision",
136            "asylum:decision",
137            "border-control",
138            "justice:decision",
139            "democratic-process",
140            "election:decision",
141        ],
142    },
143    ProhibitedIntentRule {
144        rule_id: "eu-ai-act-healthcare-critical-infrastructure",
145        label: "healthcare or critical-infrastructure safety decision",
146        fragments: &[
147            "healthcare:decision",
148            "medical:diagnosis",
149            "medical:triage",
150            "clinical:decision",
151            "critical-infrastructure",
152            "grid:stabilize",
153            "hospital:surge-capacity",
154        ],
155    },
156    ProhibitedIntentRule {
157        rule_id: "eu-ai-act-physical-world-control",
158        label: "physical-world control",
159        fragments: &[
160            "robotics:control",
161            "robotics:fleet",
162            "drone:control",
163            "drone:search",
164            "iot:actuate",
165            "physical-world",
166            "system_control",
167        ],
168    },
169];
170
171const TRANSPARENCY_FRAGMENTS: &[&str] = &[
172    "chatbot",
173    "ai-assistant",
174    "synthetic-media",
175    "deepfake:labelled",
176    "content:generate-public",
177    "creative:generate",
178];
179
180pub fn classify_intent(intent: &str) -> IntentRiskCategory {
181    let normalized = intent.trim().to_ascii_lowercase();
182    if PROHIBITED_INTENT_RULES
183        .iter()
184        .any(|r| r.fragments.iter().any(|f| normalized.contains(f)))
185    {
186        return IntentRiskCategory::Prohibited;
187    }
188    if HIGH_RISK_INTENT_RULES
189        .iter()
190        .any(|r| r.fragments.iter().any(|f| normalized.contains(f)))
191    {
192        return IntentRiskCategory::HighRisk;
193    }
194    if TRANSPARENCY_FRAGMENTS
195        .iter()
196        .any(|f| normalized.contains(f))
197    {
198        return IntentRiskCategory::TransparencyRisk;
199    }
200    IntentRiskCategory::MinimalOrGeneral
201}
202
203pub fn prohibited_intent_reason(intent: &str) -> Option<String> {
204    let normalized = intent.trim().to_ascii_lowercase();
205    for rule in PROHIBITED_INTENT_RULES {
206        if rule
207            .fragments
208            .iter()
209            .any(|fragment| normalized.contains(fragment))
210        {
211            return Some(format!("{} ({})", rule.label, rule.rule_id));
212        }
213    }
214    None
215}
216
217pub fn ensure_intent_allowed(intent: &str) -> Result<()> {
218    let category = classify_intent(intent);
219    let normalized = intent.trim().to_ascii_lowercase();
220    let rule = PROHIBITED_INTENT_RULES
221        .iter()
222        .chain(HIGH_RISK_INTENT_RULES.iter())
223        .find(|r| r.fragments.iter().any(|f| normalized.contains(f)));
224    if matches!(
225        category,
226        IntentRiskCategory::Prohibited | IntentRiskCategory::HighRisk
227    ) {
228        let reason = rule
229            .map(|r| format!("{} ({})", r.label, r.rule_id))
230            .unwrap_or_else(|| "restricted intent".into());
231        return Err(IicpError::PolicyRefused {
232            code: POLICY_REFUSAL_CODE.into(),
233            message: format!(
234                "Intent refused by IICP client policy before discovery/routing: {reason} [{category:?}]. Use an explicit private, documented, human-reviewed compliance path outside the public mesh for restricted/high-risk workflows."
235            ),
236        });
237    }
238    Ok(())
239}
240
241#[cfg(test)]
242mod tests {
243    use super::*;
244
245    #[test]
246    fn refuses_prohibited_intent() {
247        let err = ensure_intent_allowed("urn:iicp:intent:social-scoring:score:v1").unwrap_err();
248        assert!(matches!(err, IicpError::PolicyRefused { .. }));
249    }
250
251    #[test]
252    fn allows_normal_chat_intent() {
253        ensure_intent_allowed("urn:iicp:intent:llm:chat:v1").unwrap();
254    }
255}