car-ffi-common 0.26.0

Shared logic for FFI bindings (NAPI, PyO3) — JSON wrappers for verify, multi-agent, scheduler
//! JSON wrappers for the permission-tier gate (`car_policy::permission`).
//!
//! Exposes the harness safety-governor surface (survey §3.4.3, §5.2.5) to
//! the language bindings as stateless JSON functions: classify a
//! proposal's actions into risk tiers, evaluate them against a granted
//! standing tier (consulting a durable approval ledger), and record
//! durable human-in-the-loop approve/reject decisions. The granted tier
//! is passed per call (the product owns the session's standing authority);
//! durability is provided by an optional JSONL `ledger_path`, so approvals
//! survive restarts and are auditable.

use car_ir::ActionProposal;
use car_policy::{
    action_fingerprint, ApprovalDecision, ApprovalLedger, PermissionGate, PermissionTier,
    RiskClassifier,
};
use serde_json::json;

fn parse_tier(s: &str) -> Result<PermissionTier, String> {
    PermissionTier::from_str_opt(s)
        .ok_or_else(|| format!("invalid tier '{s}' (expected read_only|sandbox_edit|full_access)"))
}

fn parse_proposal(proposal_json: &str) -> Result<ActionProposal, String> {
    serde_json::from_str(proposal_json).map_err(|e| format!("invalid proposal JSON: {e}"))
}

/// Classify each action in a proposal into its minimum required tier.
/// Stateless — uses the default [`RiskClassifier`]. Returns JSON array of
/// `{ action_id, tool, required_tier }`.
pub fn classify(proposal_json: &str) -> Result<String, String> {
    let proposal = parse_proposal(proposal_json)?;
    let classifier = RiskClassifier::new();
    let rows: Vec<_> = proposal
        .actions
        .iter()
        .map(|a| {
            json!({
                "action_id": a.id,
                "tool": a.tool,
                "required_tier": classifier.classify(a).as_str(),
            })
        })
        .collect();
    serde_json::to_string(&rows).map_err(|e| e.to_string())
}

/// Evaluate each action in a proposal against a granted standing tier,
/// consulting the durable approval ledger at `ledger_path` when supplied.
/// Returns JSON array of per-action gate decisions
/// (`allow` / `needs_approval` / `deny`) plus the fingerprint, so a caller
/// can drive a human-in-the-loop approval flow.
pub fn evaluate(
    proposal_json: &str,
    granted_tier: &str,
    ledger_path: Option<&str>,
) -> Result<String, String> {
    let proposal = parse_proposal(proposal_json)?;
    let granted = parse_tier(granted_tier)?;
    let mut gate = PermissionGate::new(granted);
    if let Some(path) = ledger_path {
        let ledger = ApprovalLedger::with_journal(path)
            .map_err(|e| format!("could not open ledger '{path}': {e}"))?;
        gate = gate.with_ledger(ledger);
    }
    let rows: Vec<_> = proposal
        .actions
        .iter()
        .map(|a| {
            let decision = gate.evaluate(a);
            let mut obj = serde_json::to_value(&decision).unwrap_or(serde_json::Value::Null);
            if let Some(map) = obj.as_object_mut() {
                map.insert("action_id".into(), json!(a.id));
                map.insert("fingerprint".into(), json!(action_fingerprint(a)));
            }
            obj
        })
        .collect();
    serde_json::to_string(&rows).map_err(|e| e.to_string())
}

/// Record a durable human-in-the-loop decision (approve or reject) for the
/// operation a single action represents, appending it to the JSONL ledger
/// at `ledger_path`. `approve` selects approve vs reject. Returns the
/// stored [`car_policy::ApprovalRecord`] as JSON.
pub fn record_decision(
    action_json: &str,
    approve: bool,
    reviewer: &str,
    reason: &str,
    evidence: Option<&str>,
    ledger_path: &str,
) -> Result<String, String> {
    let action: car_ir::Action =
        serde_json::from_str(action_json).map_err(|e| format!("invalid action JSON: {e}"))?;
    let ledger = ApprovalLedger::with_journal(ledger_path)
        .map_err(|e| format!("could not open ledger '{ledger_path}': {e}"))?;
    // Granted tier is irrelevant to recording a decision; use the most
    // permissive so the gate never re-escalates before recording.
    let mut gate = PermissionGate::new(PermissionTier::FullAccess).with_ledger(ledger);
    let evidence = evidence.map(str::to_string);
    let record = if approve {
        gate.approve(&action, reviewer, reason, evidence)
    } else {
        gate.reject(&action, reviewer, reason, evidence)
    };
    serde_json::to_string(&record).map_err(|e| e.to_string())
}

/// Record a durable decision against an explicit `fingerprint` (when the
/// caller holds it from a prior `needs_approval` decision rather than the
/// full action). `required_tier` annotates the record. Returns the stored
/// record as JSON.
pub fn record_for_fingerprint(
    fingerprint: &str,
    required_tier: &str,
    approve: bool,
    reviewer: &str,
    reason: &str,
    evidence: Option<&str>,
    ledger_path: &str,
) -> Result<String, String> {
    let required = parse_tier(required_tier)?;
    let ledger = ApprovalLedger::with_journal(ledger_path)
        .map_err(|e| format!("could not open ledger '{ledger_path}': {e}"))?;
    let mut gate = PermissionGate::new(PermissionTier::FullAccess).with_ledger(ledger);
    let decision = if approve {
        ApprovalDecision::Approved
    } else {
        ApprovalDecision::Rejected
    };
    let record = gate.record_for_fingerprint(
        fingerprint,
        required,
        decision,
        reviewer,
        reason,
        evidence.map(str::to_string),
    );
    serde_json::to_string(&record).map_err(|e| e.to_string())
}

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

    const PROPOSAL: &str = r#"{
        "id": "p1", "source": "test",
        "actions": [
            {"id": "a1", "type": "state_read"},
            {"id": "a2", "type": "tool_call", "tool": "deploy_service"}
        ]
    }"#;

    #[test]
    fn classify_returns_tier_per_action() {
        let out = classify(PROPOSAL).unwrap();
        let rows: Vec<Value> = serde_json::from_str(&out).unwrap();
        assert_eq!(rows[0]["required_tier"], "read_only");
        assert_eq!(rows[1]["required_tier"], "full_access");
    }

    #[test]
    fn evaluate_escalates_full_access() {
        let out = evaluate(PROPOSAL, "sandbox_edit", None).unwrap();
        let rows: Vec<Value> = serde_json::from_str(&out).unwrap();
        // state_read is allowed under sandbox_edit; deploy needs approval.
        assert_eq!(rows[0]["decision"], "allow");
        assert_eq!(rows[1]["decision"], "needs_approval");
        assert!(rows[1]["fingerprint"].as_str().unwrap().contains("deploy_service"));
    }

    #[test]
    fn evaluate_rejects_bad_tier() {
        assert!(evaluate(PROPOSAL, "nonsense", None).is_err());
    }

    #[test]
    fn durable_approval_changes_later_evaluation() {
        let path = std::env::temp_dir()
            .join(format!("car-permgate-test-{}.jsonl", std::process::id()));
        let _ = std::fs::remove_file(&path);
        let path_s = path.to_str().unwrap();

        // deploy needs approval at first.
        let before = evaluate(PROPOSAL, "sandbox_edit", Some(path_s)).unwrap();
        let before: Vec<Value> = serde_json::from_str(&before).unwrap();
        assert_eq!(before[1]["decision"], "needs_approval");

        // Approve the deploy action durably.
        let action = r#"{"id":"a2","type":"tool_call","tool":"deploy_service"}"#;
        let rec = record_decision(action, true, "matt", "ok", None, path_s).unwrap();
        assert!(rec.contains("approved"));

        // Re-evaluation now allows it.
        let after = evaluate(PROPOSAL, "sandbox_edit", Some(path_s)).unwrap();
        let after: Vec<Value> = serde_json::from_str(&after).unwrap();
        assert_eq!(after[1]["decision"], "allow");

        let _ = std::fs::remove_file(&path);
    }
}