polyc-tools 2026.8.3

The in-process tool core for polychrome agents: local executors (coding, web fetch, wallet, ...), the tool registry, and MCP composition. The networked connectors live in polyc-connectors.
//! Reviewer-agent risk tiers and the global approval mode (`#377`).
//!
//! Two pure, attacker-independent pieces the control-plane reviewer composes
//! with the per-persona policy (`polyc_persona::approval`) and the crypto
//! signing contract (`polyc_crypto::approval`):
//!
//! - [`ApprovalMode`] — the deployment-global `POLYCHROME_APPROVAL_MODE`: how a
//!   PAUSED gated call is resolved (human prompt / reviewer auto-review).
//!   Default [`ApprovalMode::Human`] (fail-safe), so an unset or unrecognized
//!   value behaves exactly as today.
//! - [`RiskTier`] + [`classify_tier`] — the structural risk of a tool, computed
//!   from its [`ToolSpec`] annotations and name ALONE (never its arguments).
//!   This is the load-bearing security boundary: the model never sees a path to
//!   "auto-approve" for a tool that is not already [`RiskTier::Low`] by these
//!   structural facts, so attacker-controlled argument content cannot move a
//!   tool between tiers (the design's prompt-injection hardening point 1).
//!
//! [`auto_review_eligible`] is the gate that ties them together: a call is
//! reviewer-eligible only when the global mode is [`ApprovalMode::Reviewer`]
//! AND the tool is structurally [`RiskTier::Low`]. The classifier may only ever
//! VETO a Low call (escalate it to a human), never promote a Medium/High one —
//! so it is monotonic toward safety.

use polyc_llm::ToolSpec;

use crate::ToolRegistry;

/// Env var selecting the deployment-global [`ApprovalMode`].
pub const APPROVAL_MODE_ENV: &str = "POLYCHROME_APPROVAL_MODE";

/// How a paused, gated tool call is resolved deployment-wide (`#377`).
///
/// Decoupled from BOTH the sandbox mode (which bounds what a tool can do) and
/// the per-persona `ApprovalPolicy` axis in `polyc-persona` (which bounds WHICH
/// calls are gated): this axis bounds HOW a gated call is satisfied.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum ApprovalMode {
    /// Prompt a human on every gated call. The default and fail-safe — an unset
    /// or unrecognized env value resolves here, identical to the prior posture.
    #[default]
    Human,
    /// Reviewer-agent `auto_review`: auto-approve provably-[`RiskTier::Low`]
    /// calls server-side (signing the same `approval_response` a human would),
    /// escalate every other tier to a human.
    Reviewer,
    /// Blanket approve-all, including destructive and money-spending calls.
    /// Named to *say what it is* — for ephemeral test rigs only, never a
    /// production posture. This is the single, legible home for the unconditional
    /// approve-all behavior (it folds in and retires the legacy binary
    /// `POLYCHROME_APPROVE_ALL` flag), so exactly one approve-all surface exists.
    ApproveAllDangerous,
}

impl ApprovalMode {
    /// Resolve the active mode from [`APPROVAL_MODE_ENV`]; unset/unrecognized →
    /// [`ApprovalMode::Human`] (fail-safe).
    #[must_use]
    pub fn from_env() -> Self {
        std::env::var(APPROVAL_MODE_ENV)
            .ok()
            .as_deref()
            .and_then(Self::from_mode_str)
            .unwrap_or_default()
    }

    /// Parse a mode string. Returns `None` for an empty/unrecognized value so
    /// the caller falls back to the fail-safe default rather than guessing.
    #[must_use]
    pub fn from_mode_str(raw: &str) -> Option<Self> {
        match raw.trim() {
            "human" => Some(Self::Human),
            "reviewer" => Some(Self::Reviewer),
            "approve-all-dangerous" => Some(Self::ApproveAllDangerous),
            _ => None,
        }
    }

    /// The canonical string for this mode — inverse of [`from_mode_str`].
    ///
    /// [`from_mode_str`]: Self::from_mode_str
    #[must_use]
    pub const fn as_str(self) -> &'static str {
        match self {
            Self::Human => "human",
            Self::Reviewer => "reviewer",
            Self::ApproveAllDangerous => "approve-all-dangerous",
        }
    }
}

/// The structural risk tier of a tool, keyed off its [`ToolSpec`] annotations —
/// facts fixed at registration, never derived from a call's arguments.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RiskTier {
    /// Idempotent, side-effect-free, whole-argument-space-safe: `read_only`
    /// AND not `destructive` AND `cacheable_approval`. Reviewer-eligible.
    Low,
    /// Mutating but sandbox-confined: not `destructive` and not [`Self::Low`].
    /// Always a human prompt.
    Medium,
    /// `destructive` (irreversible / side-effecting / spends money / egress
    /// outside the sandbox). Always a human prompt; never reviewer-overridable.
    High,
}

impl RiskTier {
    /// The lowercase label for this tier, recorded in the signed auto-review
    /// `reason` so the audit log captures WHY a call was deemed auto-eligible.
    #[must_use]
    pub const fn as_str(self) -> &'static str {
        match self {
            Self::Low => "low",
            Self::Medium => "medium",
            Self::High => "high",
        }
    }
}

/// Classify a tool's structural risk from its annotations alone.
///
/// The design's risk-tier table: `destructive` dominates (→ [`RiskTier::High`]);
/// a tool that is read-only, non-destructive AND cacheable is [`RiskTier::Low`];
/// everything else is [`RiskTier::Medium`].
///
/// Argument content is intentionally NOT an input — the tier is the
/// attacker-independent eligibility gate, so an injected argument cannot lower
/// a call's tier toward auto-approval.
#[must_use]
pub const fn classify_tier(spec: &ToolSpec) -> RiskTier {
    if spec.destructive {
        RiskTier::High
    } else if spec.read_only && spec.cacheable_approval {
        RiskTier::Low
    } else {
        RiskTier::Medium
    }
}

/// Whether a paused call may be AUTO-APPROVED by the reviewer agent (`#377`):
/// the deployment runs [`ApprovalMode::Reviewer`] AND the tool is structurally
/// [`RiskTier::Low`].
///
/// This is the auto-approve *eligibility* gate — the deterministic policy rule
/// the design keeps load-bearing. A model-based classifier may run on top of an
/// eligible call to optionally VETO it (escalate to a human), but it can never
/// make an ineligible call eligible: only [`ApprovalMode::Reviewer`] paired with
/// a structurally [`RiskTier::Low`] tool ever passes this gate.
#[must_use]
pub const fn auto_review_eligible(mode: ApprovalMode, tier: RiskTier) -> bool {
    matches!(mode, ApprovalMode::Reviewer) && matches!(tier, RiskTier::Low)
}

/// Classify a tool by NAME against the built-in tool surface, fail-closed.
///
/// Returns the structural [`RiskTier`] of the named built-in (resolved from its
/// [`ToolSpec`] annotations via [`classify_tier`]), or [`RiskTier::High`] for
/// any name not in the built-in registry — a dynamic MCP connector tool, an
/// unannotated name, a typo. This is the design's default-deny rule: the
/// reviewer never auto-approves a tool whose structural annotations it cannot
/// see, so an unknown name can never reach [`RiskTier::Low`].
///
/// The control-plane reviewer calls this per paused call, then gates the verdict
/// through [`auto_review_eligible`] — so only a known, read-only, cacheable,
/// non-destructive built-in is ever auto-approved.
#[must_use]
pub fn classify_tool(name: &str) -> RiskTier {
    ToolRegistry::all_specs()
        .iter()
        .find(|s| s.name == name)
        .map_or(RiskTier::High, classify_tier)
}

#[cfg(test)]
mod tests {
    #![allow(clippy::pedantic, clippy::nursery, missing_docs)]
    use super::*;
    use serde_json::json;

    fn spec(name: &str) -> ToolSpec {
        ToolSpec::new(name, "d", json!({}))
    }

    #[test]
    fn approval_mode_parses_and_defaults_to_human() {
        assert_eq!(
            ApprovalMode::from_mode_str("human"),
            Some(ApprovalMode::Human)
        );
        assert_eq!(
            ApprovalMode::from_mode_str("reviewer"),
            Some(ApprovalMode::Reviewer)
        );
        // `approve-all-dangerous` is the legible blanket-approve mode that folds
        // in (and retires) the legacy binary `POLYCHROME_APPROVE_ALL` flag — the
        // ONE approve-all surface now lives on this axis.
        assert_eq!(
            ApprovalMode::from_mode_str("approve-all-dangerous"),
            Some(ApprovalMode::ApproveAllDangerous)
        );
        // Unset / typo / unrecognized → None ⇒ caller uses the fail-safe default.
        assert_eq!(ApprovalMode::from_mode_str(""), None);
        assert_eq!(ApprovalMode::from_mode_str("approve_all"), None);
        assert_eq!(ApprovalMode::from_mode_str("approve-all"), None);
        assert_eq!(ApprovalMode::default(), ApprovalMode::Human);
        for m in [
            ApprovalMode::Human,
            ApprovalMode::Reviewer,
            ApprovalMode::ApproveAllDangerous,
        ] {
            assert_eq!(ApprovalMode::from_mode_str(m.as_str()), Some(m));
        }
    }

    #[test]
    fn classify_tier_keys_off_annotations_only() {
        // Low: read-only + non-destructive + cacheable (e.g. file_read/grep).
        let low = spec("file_read").read_only().cacheable_approval();
        assert_eq!(classify_tier(&low), RiskTier::Low);
        // High: destructive dominates, even if (incoherently) also cacheable.
        let high = spec("shell_exec").destructive();
        assert_eq!(classify_tier(&high), RiskTier::High);
        let paid = spec("paid_fetch").destructive().approval_required();
        assert_eq!(classify_tier(&paid), RiskTier::High);
        // Medium: mutating-but-confined / not low and not destructive.
        let medium = spec("rename");
        assert_eq!(classify_tier(&medium), RiskTier::Medium);
        // Read-only but NOT cacheable is Medium, not Low — the whole-argument
        // -space-safe flag is required for the Low tier.
        let ro_only = spec("list").read_only();
        assert_eq!(classify_tier(&ro_only), RiskTier::Medium);
    }

    #[test]
    fn auto_review_eligible_requires_reviewer_mode_and_low_tier() {
        // The ONLY auto-approve branch: reviewer mode + Low tier.
        assert!(auto_review_eligible(ApprovalMode::Reviewer, RiskTier::Low));
        // Reviewer can never auto-approve Medium/High — those escalate.
        assert!(!auto_review_eligible(
            ApprovalMode::Reviewer,
            RiskTier::Medium
        ));
        assert!(!auto_review_eligible(
            ApprovalMode::Reviewer,
            RiskTier::High
        ));
        // Human mode never auto-approves anything, not even a Low call.
        assert!(!auto_review_eligible(ApprovalMode::Human, RiskTier::Low));
        // The blanket mode is NOT the reviewer path: `auto_review_eligible`
        // gates only the classifier branch, so it is false for it too.
        assert!(!auto_review_eligible(
            ApprovalMode::ApproveAllDangerous,
            RiskTier::Low
        ));
    }

    #[test]
    fn classify_tool_by_name_is_fail_closed() {
        // The built-in Low tier: read-only + cacheable coding reads. These are
        // the only names the reviewer may ever auto-approve.
        for low in ["file_read", "grep", "glob"] {
            assert_eq!(classify_tool(low), RiskTier::Low, "{low} must be Low");
        }
        // Destructive built-ins are High — never reviewer-eligible.
        for high in ["file_write", "file_edit", "shell_exec"] {
            assert_eq!(classify_tool(high), RiskTier::High, "{high} must be High");
        }
        // An unknown tool (a dynamic MCP connector tool, a typo, an unannotated
        // name) is fail-closed to High: the reviewer never auto-approves a tool
        // whose structural annotations it cannot see (default-deny).
        assert_eq!(classify_tool("some_connector_tool"), RiskTier::High);
        assert_eq!(classify_tool(""), RiskTier::High);
        // The fail-closed unknown is NOT auto-review-eligible even under reviewer.
        assert!(!auto_review_eligible(
            ApprovalMode::Reviewer,
            classify_tool("some_connector_tool")
        ));
    }

    #[test]
    fn risk_tier_as_str_labels_each_tier() {
        assert_eq!(RiskTier::Low.as_str(), "low");
        assert_eq!(RiskTier::Medium.as_str(), "medium");
        assert_eq!(RiskTier::High.as_str(), "high");
    }
}