systemprompt-security 0.53.0

Security infrastructure for systemprompt.io AI governance: JWT, OAuth2 token extraction, scope enforcement, ChaCha20-Poly1305 secret encryption, the four-layer tool-call governance pipeline, and the unified authz decision plane (deny-overrides resolver + AuthzDecisionHook) shared by gateway and MCP enforcement.
Documentation
//! Tool-name and argument-condition rules for approval holds.
//!
//! Rules without conditions match by tool name. An invalid rule definition
//! rejects the policy configuration (and so the boot); a valid rule whose
//! conditions cannot be evaluated at call time requires approval.
//!
//! Copyright (c) systemprompt.io — Business Source License 1.1.
//! See <https://systemprompt.io> for licensing details.

use serde::Deserialize;
use serde_yaml::Value as YamlValue;

use super::operators::{Op, erase_indices};
use crate::policy::governed::GovernedScalar;
use crate::policy::registry::PolicyConfigurationError;

#[derive(Debug, Clone, Copy, Default, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub(super) enum Quantifier {
    #[default]
    Any,
    All,
}

#[derive(Debug, Deserialize)]
#[serde(untagged)]
enum RuleSpec {
    Bare(String),
    Conditional {
        tool: String,
        #[serde(default)]
        name: Option<String>,
        #[serde(default)]
        when: Vec<ConditionSpec>,
    },
}

#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
struct ConditionSpec {
    path: String,
    op: Op,
    #[serde(default)]
    value: Option<YamlValue>,
    #[serde(default)]
    values: Vec<YamlValue>,
    #[serde(default)]
    negate: bool,
    #[serde(default, rename = "match")]
    quantifier: Quantifier,
}

#[derive(Debug)]
pub(super) struct Rule {
    tool: String,
    name: Option<String>,
    conditions: Vec<Condition>,
}

#[derive(Debug)]
struct Condition {
    path: String,
    op: Op,
    strings: Vec<String>,
    number: Option<f64>,
    negate: bool,
    quantifier: Quantifier,
}

pub(super) enum Verdict {
    Hold(String),
    Pass,
}

impl Rule {
    pub(super) fn matches_tool(&self, tool: &str) -> bool {
        tool.contains(self.tool.as_str())
    }

    fn label(&self, detail: &str) -> String {
        self.name.as_ref().map_or_else(
            || format!("{}: {detail}", self.tool),
            |name| format!("{} [{name}]: {detail}", self.tool),
        )
    }

    pub(super) fn evaluate(&self, scalars: &[GovernedScalar<'_>]) -> Verdict {
        if self.conditions.is_empty() {
            return Verdict::Hold(self.tool.clone());
        }
        for condition in &self.conditions {
            match condition.evaluate(scalars) {
                ConditionOutcome::Met(detail) => return Verdict::Hold(self.label(&detail)),
                ConditionOutcome::Unresolved(detail) => {
                    return Verdict::Hold(self.label(&format!("{detail} (fail-closed)")));
                },
                ConditionOutcome::NotMet => {},
            }
        }
        Verdict::Pass
    }
}

enum ConditionOutcome {
    Met(String),
    NotMet,
    Unresolved(String),
}

impl Condition {
    fn describe(&self, path: &str) -> String {
        let negation = if self.negate { "not " } else { "" };
        let operand = self.number.map_or_else(
            || {
                self.strings
                    .iter()
                    .map(|s| format!("{s:?}"))
                    .collect::<Vec<_>>()
                    .join(" | ")
            },
            |n| n.to_string(),
        );
        if self.op == Op::Exists {
            return format!("{path} {negation}exists");
        }
        format!("{path} {negation}{} {operand}", self.op.label())
    }

    fn evaluate(&self, scalars: &[GovernedScalar<'_>]) -> ConditionOutcome {
        let candidates: Vec<&GovernedScalar<'_>> = scalars
            .iter()
            .filter(|scalar| erase_indices(&scalar.path) == self.path)
            .collect();

        if candidates.is_empty() {
            return ConditionOutcome::Unresolved(format!("{} unresolved", self.path));
        }

        let mut hit: Option<String> = None;
        let mut all_hit = true;
        for candidate in candidates {
            let Some(raw) = self.op.test(candidate.value, &self.strings, self.number) else {
                return ConditionOutcome::Unresolved(format!(
                    "{} not comparable with {}",
                    candidate.path,
                    self.op.label()
                ));
            };
            if raw == self.negate {
                all_hit = false;
            } else if hit.is_none() {
                hit = Some(self.describe(&candidate.path));
            }
        }

        match self.quantifier {
            Quantifier::Any => hit.map_or(ConditionOutcome::NotMet, ConditionOutcome::Met),
            Quantifier::All => {
                if all_hit {
                    hit.map_or(ConditionOutcome::NotMet, ConditionOutcome::Met)
                } else {
                    ConditionOutcome::NotMet
                }
            },
        }
    }
}


pub(super) fn compile(v: &YamlValue) -> Result<Vec<Rule>, PolicyConfigurationError> {
    v.get("patterns")
        .and_then(YamlValue::as_sequence)
        .map_or_else(
            || Ok(Vec::new()),
            |seq| seq.iter().map(compile_one).collect(),
        )
}

// Why: a rule that cannot be compiled must reject the config rather than be
// dropped — dropping it silently removes the approval gate for that tool.
fn compile_one(entry: &YamlValue) -> Result<Rule, PolicyConfigurationError> {
    match serde_yaml::from_value::<RuleSpec>(entry.clone()) {
        Ok(RuleSpec::Bare(tool)) => Ok(Rule {
            tool,
            name: None,
            conditions: Vec::new(),
        }),
        Ok(RuleSpec::Conditional { tool, name, when }) => {
            let conditions = when
                .into_iter()
                .map(|spec| compile_condition(&tool, spec))
                .collect::<Result<Vec<_>, _>>()?;
            Ok(Rule {
                tool,
                name,
                conditions,
            })
        },
        Err(error) => Err(PolicyConfigurationError(format!(
            "malformed require_approval patterns entry: {error}"
        ))),
    }
}

fn compile_condition(
    tool: &str,
    spec: ConditionSpec,
) -> Result<Condition, PolicyConfigurationError> {
    let mut literals = spec.values;
    if let Some(single) = spec.value {
        literals.insert(0, single);
    }
    let number = literals.first().and_then(YamlValue::as_f64);
    let strings: Vec<String> = literals
        .iter()
        .filter_map(|v| v.as_str().map(str::to_owned))
        .collect();

    let usable = match spec.op {
        Op::Exists => true,
        op if op.is_numeric() => number.is_some(),
        _ => !strings.is_empty(),
    };
    if !usable {
        return Err(PolicyConfigurationError(format!(
            "require_approval condition on `{tool}` at `{}` has no operand its `{}` operator can \
             use",
            spec.path,
            spec.op.label()
        )));
    }
    Ok(Condition {
        path: spec.path,
        op: spec.op,
        strings,
        number,
        negate: spec.negate,
        quantifier: spec.quantifier,
    })
}