openlatch-provider 0.0.0

Self-service onboarding CLI + runtime daemon for OpenLatch Editors and Providers
//! Verdict shape + outbound HMAC sign.
//!
//! The verdict body the runtime returns to the platform conforms to the
//! `provider-call.schema.json` response subset (vendored from
//! `openlatch-platform/schemas/provider-call.schema.json`). Field names are
//! camelCase on the wire (`riskScore`, `severityHint`, `verdictHint`, …).
//!
//! Codex review #2 superseded the older PRD projection
//! (`decision.*`/`rationale.*`/`metrics.*`) — the `provider-call` shape is the
//! actual platform contract.
//!
//! Per the schema:
//!   - `riskScore`: 0..=100, nullable
//!   - `severityHint`: enum `low`/`medium`/`high`/`critical`, nullable
//!   - `verdictHint`: enum `allow`/`approve`/`deny`, nullable — **NOT** `block`/`flag`
//!   - `ruleId`: ≤120 chars, nullable
//!   - `rationaleSummary`: ≤500 chars, nullable
//!   - `userFacing`: optional ProviderRationale (headline ≤120, body ≤2000,
//!     evidence[] ≤16, optional remediation)
//!   - `enrichment`: arbitrary JSONB
//!   - `latencyMs`: int ≥ 0, nullable

use bytes::Bytes;
use secrecy::SecretString;
use serde::{Deserialize, Serialize};
use serde_json::Value;

use crate::error::{OlError, OL_4221_MALFORMED_BODY, OL_4223_VERDICT_TOO_LARGE};
use crate::runtime::proxy::MAX_VERDICT_BYTES;
use crate::runtime::webhook::{self, SignedHeaders};

#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum SeverityHint {
    Low,
    Medium,
    High,
    Critical,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum VerdictHint {
    Allow,
    Approve,
    Deny,
}

/// Subset of the platform's `ProviderRationale` schema (`provider-rationale.schema.json`).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct UserFacing {
    pub headline: String,
    pub body: String,
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub evidence: Vec<Evidence>,
    /// Stored on platform but not forwarded to the agent client (per
    /// [[OpenRouter of Security]] D-16).
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub remediation: Option<String>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Evidence {
    pub label: String,
    #[serde(rename = "valueRedacted")]
    pub value_redacted: String,
}

/// Provider response body — subset of `provider-call.schema.json`. Optional
/// fields collapse to `null` on the wire when omitted.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Verdict {
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub risk_score: Option<u8>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub severity_hint: Option<SeverityHint>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub verdict_hint: Option<VerdictHint>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub rule_id: Option<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub rationale_summary: Option<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub user_facing: Option<UserFacing>,
    #[serde(default, skip_serializing_if = "Value::is_null")]
    pub enrichment: Value,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub latency_ms: Option<u64>,
}

impl Default for Verdict {
    fn default() -> Self {
        Self {
            risk_score: None,
            severity_hint: None,
            verdict_hint: None,
            rule_id: None,
            rationale_summary: None,
            user_facing: None,
            enrichment: Value::Null,
            latency_ms: None,
        }
    }
}

/// Validate that a tool-emitted JSON body conforms to the published schema
/// (size cap, schema-shape sanity). We don't crack open every field — typed
/// `Verdict` deserialization handles structural conformance — but we DO
/// enforce field-length caps so a buggy tool can't poison platform storage.
pub fn validate_body_size(bytes: &[u8]) -> Result<(), OlError> {
    if bytes.len() > MAX_VERDICT_BYTES {
        return Err(OlError::new(
            OL_4223_VERDICT_TOO_LARGE,
            format!("verdict {} bytes > {} cap", bytes.len(), MAX_VERDICT_BYTES),
        ));
    }
    Ok(())
}

/// Try to parse a verdict body for structured logging. Returns `None` for
/// bodies the tool emitted in a non-`Verdict` shape — we still pass them
/// through to the platform unmodified, but we won't extract fields for the
/// audit log.
pub fn parse_lossy(bytes: &[u8]) -> Option<Verdict> {
    serde_json::from_slice(bytes).ok()
}

/// Sign the response body and produce the headers we attach when echoing
/// back to the platform.
pub fn sign(secret: &SecretString, body: &[u8]) -> Result<SignedHeaders, OlError> {
    webhook::sign_response(secret, body)
}

/// Reject body that fails to decode as JSON entirely. The platform expects
/// JSON; if the tool returns yaml or HTML, that's an OL-4221.
pub fn ensure_json(bytes: &[u8]) -> Result<(), OlError> {
    serde_json::from_slice::<Value>(bytes)
        .map(|_| ())
        .map_err(|e| {
            OlError::new(
                OL_4221_MALFORMED_BODY,
                format!("tool body is not valid JSON: {e}"),
            )
        })
}

/// Wrap the parts the server hands back to axum.
#[derive(Debug, Clone)]
pub struct SignedResponse {
    pub body: Bytes,
    pub headers: SignedHeaders,
}

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

    #[test]
    fn verdict_serializes_to_camel_case() {
        let v = Verdict {
            risk_score: Some(92),
            severity_hint: Some(SeverityHint::High),
            verdict_hint: Some(VerdictHint::Deny),
            rule_id: Some("pii.ssn".into()),
            rationale_summary: Some("SSN detected".into()),
            user_facing: None,
            enrichment: Value::Null,
            latency_ms: Some(47),
        };
        let s = serde_json::to_string(&v).unwrap();
        assert!(s.contains("\"riskScore\":92"));
        assert!(s.contains("\"severityHint\":\"high\""));
        assert!(s.contains("\"verdictHint\":\"deny\""));
        assert!(s.contains("\"ruleId\":\"pii.ssn\""));
        assert!(s.contains("\"rationaleSummary\":\"SSN detected\""));
        assert!(s.contains("\"latencyMs\":47"));
    }

    #[test]
    fn verdict_omits_none_fields_in_serialized_form() {
        let v = Verdict::default();
        let s = serde_json::to_string(&v).unwrap();
        assert_eq!(s, "{}");
    }

    #[test]
    fn verdict_round_trips_via_serde() {
        let v = Verdict {
            risk_score: Some(5),
            severity_hint: Some(SeverityHint::Low),
            verdict_hint: Some(VerdictHint::Allow),
            rule_id: None,
            rationale_summary: Some("no issue".into()),
            user_facing: Some(UserFacing {
                headline: "All clear".into(),
                body: "Nothing to do.".into(),
                evidence: vec![Evidence {
                    label: "ssn".into(),
                    value_redacted: "***-**-1234".into(),
                }],
                remediation: None,
            }),
            enrichment: serde_json::json!({"pii_types": ["ssn"]}),
            latency_ms: Some(8),
        };
        let s = serde_json::to_vec(&v).unwrap();
        let back: Verdict = serde_json::from_slice(&s).unwrap();
        assert_eq!(back.risk_score, Some(5));
        assert_eq!(back.user_facing.unwrap().evidence[0].label, "ssn");
    }

    #[test]
    fn validate_body_size_rejects_oversize() {
        let big = vec![b'x'; MAX_VERDICT_BYTES + 1];
        let err = validate_body_size(&big).unwrap_err();
        assert_eq!(err.code.code, "OL-4223");
    }

    #[test]
    fn ensure_json_rejects_non_json() {
        let err = ensure_json(b"<html>oops").unwrap_err();
        assert_eq!(err.code.code, "OL-4221");
    }

    #[test]
    fn ensure_json_accepts_object() {
        ensure_json(b"{\"riskScore\":1}").unwrap();
    }
}