harn-serve 0.10.122

Shared outbound workflow server core for Harn adapters
//! Runtime types for a single permission check: the request the agent
//! makes and the verdict the store returns.
//!
//! Kept deliberately small and `serde`-friendly so the same struct
//! crosses the wire (ACP `session/request_permission`, the REST
//! `/v1/permissions/check` surface, audit events) without translation.

use std::collections::BTreeMap;

use serde::{Deserialize, Serialize};
use time::OffsetDateTime;

use super::policy::PolicyVersion;
use super::rules::RuleId;

/// Coarse classification of the action being requested. The store uses
/// it to consult the right `PermissionPolicy` slice and to derive a
/// default [`Risk`] when the caller does not declare one. New action
/// classes only land here when a new policy slice is added — keep this
/// closed.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum ActionClass {
    Read,
    Write,
    Exec,
    Net,
    Llm,
    /// Free-form action that doesn't slot into a built-in policy axis.
    /// Always falls through to `escalate` unless a remember-rule pins it.
    Custom,
}

impl ActionClass {
    /// Human-readable name for audit logs and UX.
    pub fn name(self) -> &'static str {
        match self {
            ActionClass::Read => "read",
            ActionClass::Write => "write",
            ActionClass::Exec => "exec",
            ActionClass::Net => "net",
            ActionClass::Llm => "llm",
            ActionClass::Custom => "custom",
        }
    }
}

/// Risk tier surfaced to approval UX. The store derives a default
/// from the [`ActionClass`] + the policy slice the request hits, but
/// callers can override (an LLM call against an unfamiliar provider is
/// `High` regardless of class).
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Risk {
    Low,
    Medium,
    High,
    Critical,
}

impl Risk {
    /// The default risk for an [`ActionClass`] when a policy or caller
    /// does not specify one. Conservative: `write` and `exec` default
    /// to `High`, `net` to `Medium`, `read` to `Low`.
    pub fn default_for(class: ActionClass) -> Self {
        match class {
            ActionClass::Read => Risk::Low,
            ActionClass::Net | ActionClass::Llm => Risk::Medium,
            ActionClass::Write | ActionClass::Exec => Risk::High,
            ActionClass::Custom => Risk::High,
        }
    }
}

/// Scope a decision (or a remember-rule derived from it) applies to.
/// Order matters: `Session` is narrowest, `Always` is broadest, and
/// the store consults rules narrow-first so a session-scoped "deny"
/// overrides a workspace-scoped "always allow."
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum DecisionScope {
    Session,
    Workspace,
    User,
    /// Persisted across all surfaces and sessions for this tenant
    /// until soft- or hard-revoked.
    Always,
}

/// One permission check. Built by the agent before performing a side
/// effect, handed to the store, and (if not auto-satisfied by a rule
/// or by the policy) suspended out to a human via ACP.
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct PermissionRequest {
    /// Stable id for correlating the request, the eventual decision,
    /// and the audit entry. Generated by the caller.
    pub id: String,
    /// Tenant scope. `None` means "local single-tenant deployment";
    /// the multi-tenant cloud always populates this from `DispatchCtx`.
    pub tenant_id: Option<String>,
    /// Session this check belongs to. Required — every check is bound
    /// to a session even when the scope ends up broader.
    pub session_id: String,
    /// Workspace the request is taking place in. `None` means
    /// "workspace-less" (e.g. a global host action with no project root).
    pub workspace_id: Option<String>,
    /// The persona / agent identity making the call. Used for
    /// escalation routing and audit attribution.
    pub actor: String,
    pub class: ActionClass,
    /// Free-form action verb (e.g. `fs.read`, `shell.exec`,
    /// `net.fetch`, `llm.complete`). The store matches it against the
    /// policy's glob/host/provider lists and against remember-rules.
    pub action: String,
    /// Concrete target the action acts on: a path, host, provider id,
    /// command argv as one string, etc. Kept as a single string so
    /// the matcher and the audit log are uniform across classes.
    pub target: String,
    /// Caller-declared risk override. When `None`, the store uses
    /// [`Risk::default_for`].
    pub risk: Option<Risk>,
    /// Free-form structured context (cwd, parent action, callee
    /// stack). Surfaced to the approver verbatim; never matched on.
    #[serde(default)]
    pub context: BTreeMap<String, serde_json::Value>,
    /// Human-readable reason. Surfaced to the approver. Optional but
    /// strongly recommended — high-risk requests without a reason are
    /// rendered with an explicit "no reason given" warning in UX.
    pub reason: Option<String>,
    /// Created-at timestamp. Audit-only — never used for evaluation.
    pub requested_at: OffsetDateTime,
}

impl PermissionRequest {
    /// Construct a minimal request. Convenience for tests and for
    /// callers that fill the rest field-by-field.
    pub fn new(
        id: impl Into<String>,
        session_id: impl Into<String>,
        actor: impl Into<String>,
        class: ActionClass,
        action: impl Into<String>,
        target: impl Into<String>,
    ) -> Self {
        Self {
            id: id.into(),
            tenant_id: None,
            session_id: session_id.into(),
            workspace_id: None,
            actor: actor.into(),
            class,
            action: action.into(),
            target: target.into(),
            risk: None,
            context: BTreeMap::new(),
            reason: None,
            requested_at: OffsetDateTime::now_utc(),
        }
    }

    /// Effective risk for this request: caller override if present,
    /// otherwise the action-class default.
    pub fn effective_risk(&self) -> Risk {
        self.risk.unwrap_or_else(|| Risk::default_for(self.class))
    }
}

/// Verdict the store returns. The four kinds map 1:1 to the spec on
/// #2503 (granted / denied / escalated) — `Suspend` is the operational
/// shape of `escalated` once the request has been handed off to ACP.
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(tag = "outcome", rename_all = "snake_case")]
pub enum PermissionDecision {
    Granted {
        scope: DecisionScope,
        /// Policy version that satisfied the request. Lets audit replay
        /// know which ruleset was live.
        policy_version: PolicyVersion,
        /// Reason. Either copied from a matching rule (e.g. "matches
        /// remembered rule: src/**") or filled by the human approver.
        reason: Option<String>,
        /// When set, the grant auto-revokes at this timestamp. The
        /// store enforces revocation lazily on each subsequent check.
        expires_at: Option<OffsetDateTime>,
        /// Set when the grant was satisfied by a persistent rule.
        rule_id: Option<RuleId>,
    },
    Denied {
        scope: DecisionScope,
        policy_version: PolicyVersion,
        reason: Option<String>,
        rule_id: Option<RuleId>,
    },
    /// Routed to a human / supervising persona via the ACP suspend
    /// channel; the agent loop yields and resumes when the decision
    /// is recorded through `PermissionStore::record_decision`.
    Suspend {
        policy_version: PolicyVersion,
        /// Chain of escalators in the order they will be tried. The
        /// store picks the first that's online; the rest are tried on
        /// timeout. Each entry is a free-form identifier (persona URI,
        /// `user`, group name).
        escalate_to: Vec<String>,
        reason: Option<String>,
    },
}

impl PermissionDecision {
    /// `true` when the verdict allows the request to proceed.
    pub fn is_granted(&self) -> bool {
        matches!(self, PermissionDecision::Granted { .. })
    }

    pub fn policy_version(&self) -> &PolicyVersion {
        match self {
            PermissionDecision::Granted { policy_version, .. }
            | PermissionDecision::Denied { policy_version, .. }
            | PermissionDecision::Suspend { policy_version, .. } => policy_version,
        }
    }

    pub fn reason(&self) -> Option<&str> {
        match self {
            PermissionDecision::Granted { reason, .. }
            | PermissionDecision::Denied { reason, .. }
            | PermissionDecision::Suspend { reason, .. } => reason.as_deref(),
        }
    }
}

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

    #[test]
    fn risk_defaults_reflect_action_class() {
        assert_eq!(Risk::default_for(ActionClass::Read), Risk::Low);
        assert_eq!(Risk::default_for(ActionClass::Net), Risk::Medium);
        assert_eq!(Risk::default_for(ActionClass::Llm), Risk::Medium);
        assert_eq!(Risk::default_for(ActionClass::Write), Risk::High);
        assert_eq!(Risk::default_for(ActionClass::Exec), Risk::High);
        assert_eq!(Risk::default_for(ActionClass::Custom), Risk::High);
    }

    #[test]
    fn effective_risk_prefers_override() {
        let mut req =
            PermissionRequest::new("p1", "s1", "alice", ActionClass::Read, "fs.read", "/tmp/a");
        assert_eq!(req.effective_risk(), Risk::Low);
        req.risk = Some(Risk::Critical);
        assert_eq!(req.effective_risk(), Risk::Critical);
    }

    #[test]
    fn decision_scope_orders_narrow_to_broad() {
        assert!(DecisionScope::Session < DecisionScope::Workspace);
        assert!(DecisionScope::Workspace < DecisionScope::User);
        assert!(DecisionScope::User < DecisionScope::Always);
    }
}