assay-cli 3.27.0

Policy-as-code gate for MCP agent tool calls, with verifiable evidence and Linux kernel enforcement.
use super::constants::MAX_REF_CHARS;
use super::validate::optional_bounded_string;
use anyhow::{bail, Result};
use serde_json::{Map, Value};
use sha2::{Digest, Sha256};
use std::collections::BTreeSet;

pub(super) enum HashOrRef {
    Hash(String),
    Ref(String),
    Absent,
}

pub(super) fn hash_or_ref(
    record: &Map<String, Value>,
    raw_key: &str,
    ref_key: &str,
    field_context: &str,
    document_number: usize,
) -> Result<HashOrRef> {
    if record.contains_key(raw_key) && record.contains_key(ref_key) {
        bail!("document {document_number} {field_context}: {raw_key} and {ref_key} must not both be present");
    }
    if let Some(raw) = record.get(raw_key) {
        return Ok(HashOrRef::Hash(sha256_json_value(raw)?));
    }
    if let Some(reference) = optional_bounded_string(
        record.get(ref_key),
        &format!("{field_context}.{ref_key}"),
        MAX_REF_CHARS,
        document_number,
    )? {
        return Ok(HashOrRef::Ref(reference));
    }
    Ok(HashOrRef::Absent)
}

fn sha256_json_value(value: &Value) -> Result<String> {
    let canonical = canonical_json(value)?;
    let mut hasher = Sha256::new();
    hasher.update(canonical.as_bytes());
    Ok(format!("sha256:{}", hex::encode(hasher.finalize())))
}

fn canonical_json(value: &Value) -> Result<String> {
    match value {
        Value::Null => Ok("null".to_string()),
        Value::Bool(value) => Ok(value.to_string()),
        Value::String(value) => Ok(serde_json::to_string(value)?),
        Value::Number(number) => {
            if let Some(value) = number.as_i64() {
                return Ok(value.to_string());
            }
            if let Some(value) = number.as_u64() {
                return Ok(value.to_string());
            }
            let Some(value) = number.as_f64() else {
                bail!("unsupported JSON number in canonical JSON");
            };
            if !value.is_finite() {
                bail!("non-finite floats are not valid in canonical JSON");
            }
            if value.fract() != 0.0 {
                bail!("non-integer floats are not valid in LiveKit tool-action canonical JSON");
            }
            Ok(format!("{value:.0}"))
        }
        Value::Array(values) => {
            let items = values
                .iter()
                .map(canonical_json)
                .collect::<Result<Vec<_>>>()?;
            Ok(format!("[{}]", items.join(",")))
        }
        Value::Object(map) => {
            let mut parts = Vec::with_capacity(map.len());
            for key in map.keys().collect::<BTreeSet<_>>() {
                let key_json = serde_json::to_string(key)?;
                let value_json = canonical_json(map.get(key).unwrap())?;
                parts.push(format!("{key_json}:{value_json}"));
            }
            Ok(format!("{{{}}}", parts.join(",")))
        }
    }
}