ferrumdeck-policy 0.7.16

FerrumDeck enforcement engine: deny-by-default tool allowlists, Airlock RASP, the R1-R3 reversibility ladder, budgets, and an EU AI Act Art.50 transparency rule
Documentation
//! EU AI Act **Article 50** transparency enforcement — a governance rule that
//! sits on the R1–R3 response ladder.
//!
//! Article 50 of Regulation (EU) 2024/1689 places *transparency* obligations on
//! providers and deployers of generative / interactive AI systems. Two of them
//! are mechanically checkable on a produced response:
//!
//! 1. **Disclosure (Art. 50(1)/(4))** — a natural person interacting with, or
//!    consuming output from, an AI system must be *informed* that the content is
//!    AI-generated. On a text response this is a human-readable disclosure
//!    ("this response was generated by an AI").
//! 2. **Machine-readable marking (Art. 50(2))** — synthetic output must be
//!    marked as artificially generated in a **machine-readable** format
//!    (a provenance tag / content-credential, e.g. a `<ai-generated>` marker or
//!    an `"ai_generated": true` field), so downstream systems can detect it.
//!
//! FerrumDeck is an **enforcement** plane, not a monitor: this rule is applied
//! *before* a generative response is released. A response that fails a required
//! obligation is:
//!
//! - **`enforce` mode** → **denied** ([`Art50Status`] → [`PolicyDecision::deny`]) —
//!   the R3 rung ([`ResponseLevel::RequireApproval`] class): block-before-release,
//!   the agent must remediate (add the disclosure / marking) before the output
//!   ships.
//! - **`shadow` mode** → **allowed + logged** (the R1 rung
//!   [`ResponseLevel::AllowAndLog`]): recorded for review, never blocks — the
//!   safe-rollout posture used everywhere else in the Airlock.
//!
//! **Additive + fail-safe:** the checks are opt-in per obligation
//! ([`Art50Config`]); an obligation that is *required but absent* is a violation
//! (fail-closed), never a silent pass. This rule only ever *adds* friction on a
//! non-compliant response; it never loosens another gate.
//!
//! Honesty note: this is a **structural transparency check** — it verifies the
//! presence of a disclosure phrase and a machine-readable marker, not that a
//! disclosure is *truthful* or that the marking follows any specific standard
//! (C2PA, SynthID, …). It is a deterministic, regex-based enforcement of the
//! *form* Article 50 requires, in the same spirit as the anti-RCE matcher.

use serde::{Deserialize, Serialize};
use std::sync::LazyLock;

use regex::Regex;

use crate::airlock::AirlockMode;
use crate::decision::PolicyDecision;
use crate::reversibility::ResponseLevel;

/// Stable anchor recorded alongside the decision so audit consumers can cite the
/// regulatory reference without re-reading docstrings.
pub const EU_AI_ACT_ART50_ANCHOR: &str = "eu-ai-act-article-50-transparency";

/// Human-readable AI-disclosure phrases (Art. 50(1)/(4)). Case-insensitive; a
/// compliant response must inform the reader the content is AI-generated.
static DISCLOSURE_RE: LazyLock<Regex> = LazyLock::new(|| {
    Regex::new(
        r"(?i)((?:^|[^<\[\w-])ai[\s-]generated|generated by (an? )?ai\b|generated by artificial intelligence|created by (an? )?ai\b|produced by (an? )?ai\b|written by (an? )?ai\b|artificially generated|this (content|response|message|output|text) (was|is|has been) (generated|produced|created|written)( automatically)?( by)?( an?)?( ai\b| artificial intelligence)|you are (interacting|chatting|speaking) with (an? )?ai\b)",
    )
    .expect("valid Art.50 disclosure regex")
});

/// Machine-readable synthetic-content markers (Art. 50(2)). A provenance tag /
/// content-credential a downstream system can detect programmatically.
static MACHINE_READABLE_RE: LazyLock<Regex> = LazyLock::new(|| {
    Regex::new(
        r#"(?i)(<ai[\s-]generated\b|</ai[\s-]generated>|data-ai-generated\s*=|["']?ai_generated["']?\s*[:=]\s*true|x-ai-generated\s*:|content-credentials|c2pa\b|synthid|\[ai-generated\]|\{ai:generated\})"#,
    )
    .expect("valid Art.50 machine-readable regex")
});

/// Which Article 50 obligations the enforcement plane requires on a response.
/// Both default **on** (fail-closed): a governed generative agent must disclose
/// *and* mark. Turn one off to enforce only the other.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub struct Art50Config {
    /// Require a human-readable AI disclosure (Art. 50(1)/(4)).
    pub require_disclosure: bool,
    /// Require a machine-readable synthetic-content marker (Art. 50(2)).
    pub require_machine_readable: bool,
}

impl Default for Art50Config {
    fn default() -> Self {
        Self {
            require_disclosure: true,
            require_machine_readable: true,
        }
    }
}

impl Art50Config {
    /// Build from a governance JSON `art50` object, defaulting missing keys to
    /// the fail-closed default (both required).
    pub fn from_json(value: &serde_json::Value) -> Self {
        let d = Self::default();
        Self {
            require_disclosure: value
                .get("require_disclosure")
                .and_then(serde_json::Value::as_bool)
                .unwrap_or(d.require_disclosure),
            require_machine_readable: value
                .get("require_machine_readable")
                .and_then(serde_json::Value::as_bool)
                .unwrap_or(d.require_machine_readable),
        }
    }
}

/// The Article 50 compliance verdict for a single response.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Art50Status {
    /// Every required obligation is satisfied.
    Compliant,
    /// The human-readable disclosure is required but absent.
    MissingDisclosure,
    /// The machine-readable marker is required but absent.
    MissingMachineReadable,
    /// Both required obligations are absent.
    MissingBoth,
}

impl Art50Status {
    /// Stable snake_case wire label.
    pub fn as_str(self) -> &'static str {
        match self {
            Art50Status::Compliant => "compliant",
            Art50Status::MissingDisclosure => "missing_disclosure",
            Art50Status::MissingMachineReadable => "missing_machine_readable",
            Art50Status::MissingBoth => "missing_both",
        }
    }

    /// Whether the response satisfies every *required* obligation.
    pub fn is_compliant(self) -> bool {
        matches!(self, Art50Status::Compliant)
    }
}

/// Does the response text carry a human-readable AI disclosure?
pub fn has_disclosure(response_text: &str) -> bool {
    DISCLOSURE_RE.is_match(response_text)
}

/// Does the response text carry a machine-readable synthetic-content marker?
pub fn has_machine_readable_marker(response_text: &str) -> bool {
    MACHINE_READABLE_RE.is_match(response_text)
}

/// Evaluate a generative response against the required Article 50 obligations.
///
/// Only *required* obligations can fail — an obligation turned off in
/// [`Art50Config`] never contributes a violation.
pub fn check(response_text: &str, cfg: Art50Config) -> Art50Status {
    let missing_disclosure = cfg.require_disclosure && !has_disclosure(response_text);
    let missing_marker =
        cfg.require_machine_readable && !has_machine_readable_marker(response_text);
    match (missing_disclosure, missing_marker) {
        (false, false) => Art50Status::Compliant,
        (true, false) => Art50Status::MissingDisclosure,
        (false, true) => Art50Status::MissingMachineReadable,
        (true, true) => Art50Status::MissingBoth,
    }
}

/// Map a compliance verdict onto the graduated R1–R3 response rung, given the
/// Airlock mode.
///
/// - Compliant → **R1** `AllowAndLog` (nothing to escalate).
/// - Non-compliant + `Shadow` → **R1** `AllowAndLog` (record, don't block).
/// - Non-compliant + `Enforce` → **R3** `RequireApproval` (block-before-release
///   / remediate). Transparency is a release gate, not a spend gate, so there is
///   no R2 rung here.
pub fn response_level(status: Art50Status, mode: AirlockMode) -> ResponseLevel {
    if status.is_compliant() || mode == AirlockMode::Shadow {
        ResponseLevel::AllowAndLog
    } else {
        ResponseLevel::RequireApproval
    }
}

/// Turn a compliance verdict into a [`PolicyDecision`] under the given mode.
///
/// - Compliant → `allow`.
/// - Non-compliant + `enforce` → `deny` (block the response; the agent must add
///   the missing disclosure / marking).
/// - Non-compliant + `shadow` → `allow` with the violation named in the reason
///   (logged for review, never blocks) — the safe-rollout posture.
pub fn enforce(status: Art50Status, mode: AirlockMode) -> PolicyDecision {
    if status.is_compliant() {
        return PolicyDecision::allow(
            "art50: response carries the required AI-transparency markings",
        );
    }
    let reason = format!(
        "art50: response violates EU AI Act Article 50 transparency ({}) [{}]",
        status.as_str(),
        EU_AI_ACT_ART50_ANCHOR
    );
    match mode {
        AirlockMode::Enforce => PolicyDecision::deny(reason),
        AirlockMode::Shadow => PolicyDecision::allow(format!("shadow: {reason}")),
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    const COMPLIANT: &str =
        "Here is the summary. This response was generated by an AI. <ai-generated model=\"x\"/>";

    #[test]
    fn compliant_response_passes() {
        let status = check(COMPLIANT, Art50Config::default());
        assert_eq!(status, Art50Status::Compliant);
        assert!(status.is_compliant());
    }

    #[test]
    fn missing_disclosure_only() {
        // Has the machine-readable marker, lacks the human-readable disclosure.
        let text = "Here is the answer. <ai-generated/>";
        assert_eq!(
            check(text, Art50Config::default()),
            Art50Status::MissingDisclosure
        );
    }

    #[test]
    fn missing_machine_readable_only() {
        // Has the disclosure prose, lacks a machine-readable marker.
        let text = "Here is the answer. Note: this content is AI-generated.";
        assert_eq!(
            check(text, Art50Config::default()),
            Art50Status::MissingMachineReadable
        );
    }

    #[test]
    fn missing_both() {
        let text = "Here is a perfectly ordinary answer with no markings at all.";
        assert_eq!(
            check(text, Art50Config::default()),
            Art50Status::MissingBoth
        );
    }

    #[test]
    fn disclosure_phrase_variants_detected() {
        for t in [
            "Generated by AI.",
            "This message was generated by an AI.",
            "You are interacting with an AI assistant.",
            "Content is artificially generated.",
            "This output has been produced by AI automatically.",
        ] {
            assert!(has_disclosure(t), "should detect disclosure in {t:?}");
        }
    }

    #[test]
    fn machine_readable_marker_variants_detected() {
        for t in [
            "<ai-generated/>",
            "prefix data-ai-generated=\"true\" suffix",
            "{\"ai_generated\": true}",
            "C2PA content-credentials attached",
            "[ai-generated]",
        ] {
            assert!(
                has_machine_readable_marker(t),
                "should detect marker in {t:?}"
            );
        }
    }

    #[test]
    fn config_can_relax_one_obligation() {
        let disclosure_only = Art50Config {
            require_disclosure: true,
            require_machine_readable: false,
        };
        // Prose disclosure, no marker — compliant when the marker isn't required.
        let text = "This content is AI-generated.";
        assert_eq!(check(text, disclosure_only), Art50Status::Compliant);
    }

    #[test]
    fn from_json_defaults_fail_closed() {
        // Empty object → both obligations required (fail-closed default).
        let cfg = Art50Config::from_json(&serde_json::json!({}));
        assert_eq!(cfg, Art50Config::default());
        assert!(cfg.require_disclosure && cfg.require_machine_readable);
        // Explicit override honored.
        let relaxed =
            Art50Config::from_json(&serde_json::json!({"require_machine_readable": false}));
        assert!(relaxed.require_disclosure && !relaxed.require_machine_readable);
    }

    #[test]
    fn enforce_mode_denies_noncompliant() {
        let status = Art50Status::MissingBoth;
        let d = enforce(status, AirlockMode::Enforce);
        assert!(d.is_denied());
        assert!(d.reason.contains(EU_AI_ACT_ART50_ANCHOR));
    }

    #[test]
    fn shadow_mode_allows_but_logs_noncompliant() {
        let d = enforce(Art50Status::MissingDisclosure, AirlockMode::Shadow);
        assert!(d.is_allowed());
        assert!(d.reason.starts_with("shadow:"));
    }

    #[test]
    fn compliant_always_allows() {
        for mode in [AirlockMode::Enforce, AirlockMode::Shadow] {
            assert!(enforce(Art50Status::Compliant, mode).is_allowed());
        }
    }

    #[test]
    fn response_level_maps_to_r_rungs() {
        // Non-compliant under enforce is the R3 block-before-release rung.
        assert_eq!(
            response_level(Art50Status::MissingBoth, AirlockMode::Enforce),
            ResponseLevel::RequireApproval
        );
        assert_eq!(
            response_level(Art50Status::MissingBoth, AirlockMode::Enforce).rung(),
            "R3"
        );
        // Shadow never escalates past R1.
        assert_eq!(
            response_level(Art50Status::MissingBoth, AirlockMode::Shadow),
            ResponseLevel::AllowAndLog
        );
        // Compliant is R1 regardless of mode.
        assert_eq!(
            response_level(Art50Status::Compliant, AirlockMode::Enforce),
            ResponseLevel::AllowAndLog
        );
    }

    #[test]
    fn status_wire_labels_round_trip() {
        for s in [
            Art50Status::Compliant,
            Art50Status::MissingDisclosure,
            Art50Status::MissingMachineReadable,
            Art50Status::MissingBoth,
        ] {
            let json = serde_json::to_string(&s).unwrap();
            assert_eq!(json, format!("\"{}\"", s.as_str()));
            let back: Art50Status = serde_json::from_str(&json).unwrap();
            assert_eq!(back, s);
        }
    }
}