car-ffi-common 0.47.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.
//!
//! # Two axes, reported side by side
//!
//! Every row [`classify`] and [`evaluate`] returns carries a `reversibility`
//! next to its `required_tier`. They answer different questions —
//! `required_tier` is *who may authorize this*, `reversibility` is *can this be
//! undone* — and the pair is the whole point: on the authority ladder alone a
//! `git push` and a charged card are the same row (`full_access`,
//! `needs_approval`), and nothing a JS or Python caller could read told them
//! apart. This mirrors what `car-engine`'s `TierPermissionHandler` already
//! writes to the event log, so the FFI projection and the audit trail agree
//! field-for-field. See `docs/proposals/shepherd-substrate-adoption.md`.

use car_ir::{Action, ActionProposal};
use car_policy::{
    action_fingerprint, classify_reversibility, 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 on **both** authorization-adjacent
/// axes. Stateless — uses the default [`RiskClassifier`] and
/// [`classify_reversibility`]. Returns JSON array of
/// `{ action_id, tool, required_tier, reversibility, missing_compensation }`.
///
/// `reversibility` is the **classifier's** answer (`"reversible"` |
/// `"compensable"` | `"irreversible"`), not an echo of the action's declared
/// `reversibility` field. The two are deliberately not the same thing, and the
/// classified one is what is useful here: `Action::reversibility` is
/// `#[serde(default)]` to the conservative `"irreversible"`, so an action that
/// simply predates the axis is indistinguishable from one whose author
/// deliberately declared it permanent. Echoing that back would tell a caller
/// only what it already sent. See [`classify_reversibility`] for what the
/// classification can and cannot see — it reads a tool name and a flattened
/// parameter blob, and an unrecognized tool comes back `"irreversible"`.
///
/// `missing_compensation` is `Action::missing_required_compensation()`: the
/// action *declared* `"compensable"` and supplied no `compensation`, which is
/// the one incoherent combination the IR could not exclude by construction. It
/// is keyed off the declared field on purpose and stays `false` for every
/// proposal that never opted into the axis — demanding a compensation on the
/// strength of a keyword heuristic would be noise, not a finding.
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| classification_row(&classifier, a))
        .collect();
    serde_json::to_string(&rows).map_err(|e| e.to_string())
}

/// One `permission.classify` row — the single definition of that shape.
///
/// The daemon's `permission.classify` builds its rows against the *session's*
/// classifier (which may carry custom rules) rather than a fresh one, so it
/// cannot call [`classify`] itself; it calls this instead. Sharing only the
/// `classify_reversibility` call and re-spelling the row on each side left the
/// two projections free to drift on the column set — and
/// `check-ffi-parity.sh` greps method names, not row keys, so nothing would
/// have caught it. Add a column here and both surfaces get it.
pub fn classification_row(classifier: &RiskClassifier, action: &Action) -> serde_json::Value {
    // Both axes read the same flattened text, so build it once. On a batch of
    // actions carrying large payloads (a `write_file` whose `contents` is a
    // whole document) the second build was a second full copy plus a second
    // lowercasing, for a column the gate does not decide on.
    let hay = car_policy::action_text(action);
    json!({
        "action_id": action.id,
        "tool": action.tool,
        "required_tier": classifier.classify_with_haystack(action, Some(&hay)).as_str(),
        "reversibility": car_policy::classify_reversibility_with_haystack(action, Some(&hay)).as_str(),
        "missing_compensation": action.missing_required_compensation(),
    })
}

/// Stamp the second axis onto a serialized gate decision, in place.
///
/// Shared by [`evaluate`] and by the daemon's `permission.evaluate` /
/// `permission.pending` (which build their rows against the *session's* gate
/// and the shared daemon ledger rather than a per-call one) so the field name
/// and the classifier call are single-sourced and the two projections cannot
/// drift — the failure mode project convention #2 exists to prevent. It is
/// derived from the **action**, not the decision: the gate's verdict carries
/// no information about whether the effect can be undone, which is exactly why
/// the axis had to be split out of `PermissionTier` in the first place.
pub fn annotate_reversibility(row: &mut serde_json::Value, action: &Action) {
    stamp_reversibility(row, classify_reversibility(action));
}

/// [`annotate_reversibility`] for a caller that already has the contract —
/// notably one that got it from `PermissionGate::evaluate_axes`, which
/// computes both axes from a single flatten of the parameters
/// (Parslee-ai/car#856). Same key, so the two paths cannot drift.
pub fn stamp_reversibility(row: &mut serde_json::Value, reversibility: car_ir::Reversibility) {
    if let Some(map) = row.as_object_mut() {
        map.insert("reversibility".into(), json!(reversibility.as_str()));
    }
}

/// 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.
///
/// Each row also carries `reversibility` — including the `allow` rows. An
/// action the gate waved through still has a rollback contract, and a caller
/// that only learns the contract of the actions it was *stopped* on is missing
/// exactly the rows an incident review reads first. Same reasoning, same
/// field, and the same classifier as the `PermissionDecision` event
/// `car-engine` writes on every gate check.
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| {
            // One flatten of the parameters for both axes (#856).
            let axes = gate.evaluate_axes(a, None, None);
            let mut obj = serde_json::to_value(&axes.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)));
            }
            stamp_reversibility(&mut obj, axes.reversibility);
            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)
    }
    .map_err(|e| format!("failed to persist approval decision: {e}"))?;
    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),
        )
        .map_err(|e| format!("failed to persist approval decision: {e}"))?;
    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 classify_returns_both_axes_per_action() {
        let out = classify(PROPOSAL).unwrap();
        let rows: Vec<Value> = serde_json::from_str(&out).unwrap();
        // A read observes: nothing to authorize, nothing to undo.
        assert_eq!(rows[0]["reversibility"], "reversible");
        // A deploy is top-authority AND recoverable — a rollback deploy. That
        // pair is unrepresentable on the ladder alone, which is the point.
        assert_eq!(rows[1]["required_tier"], "full_access");
        assert_eq!(rows[1]["reversibility"], "compensable");
        // Nothing declared the axis, so nothing is incoherent.
        assert_eq!(rows[0]["missing_compensation"], false);
        assert_eq!(rows[1]["missing_compensation"], false);
    }

    /// The two axes disagree in BOTH directions, and the FFI rows have to show
    /// it. A sent email and a deploy are the same row on the authority ladder;
    /// a secret read is top-authority and leaves nothing to undo.
    #[test]
    fn the_axes_are_independent_on_the_wire() {
        let proposal = r#"{
            "id": "p2", "source": "test",
            "actions": [
                {"id": "a1", "type": "tool_call", "tool": "send_email"},
                {"id": "a2", "type": "tool_call", "tool": "read_secret"}
            ]
        }"#;
        let rows: Vec<Value> = serde_json::from_str(&classify(proposal).unwrap()).unwrap();
        assert_eq!(rows[0]["required_tier"], "full_access");
        assert_eq!(rows[0]["reversibility"], "irreversible");
        assert_eq!(rows[1]["required_tier"], "full_access");
        assert_eq!(rows[1]["reversibility"], "reversible");
    }

    /// `missing_compensation` is keyed off what the author *declared*, not off
    /// the classifier — a keyword heuristic is not grounds to demand a
    /// compensating action be written down.
    #[test]
    fn missing_compensation_tracks_the_declared_field() {
        let proposal = r#"{
            "id": "p3", "source": "test",
            "actions": [
                {"id": "a1", "type": "tool_call", "tool": "db_insert",
                 "reversibility": "compensable"},
                {"id": "a2", "type": "tool_call", "tool": "db_insert",
                 "reversibility": "compensable",
                 "compensation": {"type": "tool", "tool": "db_delete"}},
                {"id": "a3", "type": "tool_call", "tool": "db_insert"}
            ]
        }"#;
        let rows: Vec<Value> = serde_json::from_str(&classify(proposal).unwrap()).unwrap();
        assert_eq!(rows[0]["missing_compensation"], true);
        assert_eq!(rows[1]["missing_compensation"], false);
        // a3 declares nothing (defaults to irreversible) while the classifier
        // reads the tool name as compensable. The classified value is
        // reported; the coherence check stays quiet.
        assert_eq!(rows[2]["reversibility"], "compensable");
        assert_eq!(rows[2]["missing_compensation"], false);
    }

    #[test]
    fn evaluate_rows_carry_reversibility_including_allows() {
        let out = evaluate(PROPOSAL, "sandbox_edit", None).unwrap();
        let rows: Vec<Value> = serde_json::from_str(&out).unwrap();
        assert_eq!(rows[0]["decision"], "allow");
        assert_eq!(rows[0]["reversibility"], "reversible");
        assert_eq!(rows[1]["decision"], "needs_approval");
        assert_eq!(rows[1]["reversibility"], "compensable");
    }

    #[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);
    }
}