harn-vm 0.10.168

Async bytecode virtual machine for the Harn programming language
//! The built-in guards, and the gate each of their refusals belongs to.
//!
//! These run BEFORE any configured approval rule is consulted, and they
//! answer a different question: is this declared path one the session may
//! name at all. That answer does not change when a person turns approval
//! off. Keeping the guards and the source-to-gate mapping in one module is
//! what stops the two drifting apart, which is how every refusal came to
//! report itself as an approval decision.

use super::*;
use crate::orchestration::policy::external_roots::{governing_root, is_read_side};
use crate::orchestration::policy::ExternalRootAccess;

/// The deny-by-default sensitive-path guard.
pub const SOURCE_DEFAULT_SENSITIVE_PATH: &str = "default_sensitive_path";
/// The workspace path boundary refusing a malformed declared path.
pub const SOURCE_DEFAULT_PATH_GUARD: &str = "default_path_guard";
/// The workspace path boundary refusing a path outside every admitted root.
pub const SOURCE_DEFAULT_EXTERNAL_PATH: &str = "default_external_path";
/// Refusal id and risk label for a write-side call under a `read` external
/// root. Its source is [`SOURCE_DEFAULT_EXTERNAL_PATH`]: the same workspace
/// boundary, refusing on the root's mode rather than on its absence.
pub const EXTERNAL_ROOT_READ_ONLY: &str = "external_root_read_only";
/// The prepared-run network policy, evaluated before endpoint health.
pub const SOURCE_NET_POLICY: &str = "harn.net_policy";

pub(super) fn evaluate_context(
    policy: &ToolApprovalPolicy,
    ctx: &mut EvaluationContext,
) -> Option<PolicyEvaluation> {
    ctx.external_roots =
        super::super::external_roots::governing_roots(&policy.external_roots, &ctx.path_entries);
    default_guard(policy, ctx).map(|candidate| evaluation_from_candidate(candidate, ctx))
}

/// Which refusing mechanism a deciding rule belongs to.
///
/// Three of the sources this module produces are not approval decisions.
/// [`default_guard`] runs BEFORE any configured rule is consulted and answers
/// a scope question: is this declared path one the session may name at all.
/// That answer does not change when a person turns approval off, so reporting
/// it under the approval gate sends the reader to inspect a control that had
/// no part in the refusal.
///
/// The mapping is owned here, beside the rules that produce those sources,
/// rather than at the dispatch boundary that consumes it. A boundary that
/// re-derived the gate from the reason prose, or pinned one value the way the
/// dispatch seam used to, can name a gate the rule never chose.
pub fn denial_gate_for_source(source: Option<&str>) -> crate::agent_events::DenialGate {
    use crate::agent_events::DenialGate;
    match source {
        Some("approval_unavailable") => DenialGate::ApprovalUnavailable,
        Some(SOURCE_DEFAULT_SENSITIVE_PATH) => DenialGate::SensitivePath,
        Some(SOURCE_NET_POLICY) => DenialGate::NetworkPolicy,
        Some(SOURCE_DEFAULT_PATH_GUARD) | Some(SOURCE_DEFAULT_EXTERNAL_PATH) => {
            DenialGate::WorkspaceBoundary
        }
        // Every other source IS a configured approval decision: an
        // `auto_deny` pattern, a policy rule, the write-path allowlist, or
        // the repeat limit. So is a deny carrying no matched rule at all,
        // which only a future rule-less refusal could produce.
        _ => DenialGate::ApprovalPolicy,
    }
}

/// Answers whether a child spawned under the policy cannot write the path.
///
/// The sandbox layer owns that measurement and sits above this one, so it
/// installs the probe rather than this module reaching up for it. Until it
/// does, nothing is measured and the guard keeps refusing (harn#9622).
pub type ChildWriteProbe = fn(&crate::orchestration::CapabilityPolicy, &std::path::Path) -> bool;

static CHILD_WRITE_PROBE: std::sync::OnceLock<ChildWriteProbe> = std::sync::OnceLock::new();

/// Install the sandbox layer's child-write probe. The first install wins.
pub fn install_child_write_probe(probe: ChildWriteProbe) {
    let _ = CHILD_WRITE_PROBE.set(probe);
}

/// Whether a command-running call cannot write `host_path`, because the OS
/// sandbox its child will run under refuses every write there.
///
/// A `read` external root admits reads only, and a command is not a read. Its
/// child, though, runs under the active capability policy, and when that
/// policy's backend measures the path as read-only the root's mode is held by
/// the kernel rather than by this guard. Every other answer, an absent policy
/// or probe included, keeps the refusal.
fn child_cannot_write(side_effect: Option<&str>, host_path: &str) -> bool {
    if side_effect != Some("process_exec") {
        return false;
    }
    let (Some(probe), Some(policy)) = (
        CHILD_WRITE_PROBE.get(),
        crate::orchestration::current_execution_policy(),
    ) else {
        return false;
    };
    probe(&policy, std::path::Path::new(host_path))
}

pub(super) fn default_guard(
    policy: &ToolApprovalPolicy,
    ctx: &EvaluationContext,
) -> Option<Candidate> {
    if !policy.allow_sensitive_paths {
        if let Some(path) = sensitive_paths::first_candidate(policy, &ctx.path_candidates) {
            let path = sensitive_paths::bounded_evidence(&path);
            return Some(Candidate {
                source: SOURCE_DEFAULT_SENSITIVE_PATH.to_string(),
                source_rank: PolicyRuleSource::Policy,
                index: None,
                id: Some("sensitive_path".to_string()),
                action: PolicyAction::Deny,
                reason: format!("path '{path}' is denied by the sensitive-path default"),
                approval: ApprovalShape::default(),
                risk_labels: vec!["sensitive_path".to_string()],
                denied_paths: vec![path],
                contributing_rules: Vec::new(),
            });
        }
    }

    // The external-root mode is checked even when `allow_external_paths` opts
    // out of the boundary: that switch admits paths no root names, and a root
    // the host declared `read` is still a declaration that it is read-only.
    for entry in &ctx.path_entries {
        if !policy.allow_external_paths && matches!(entry.kind, WorkspacePathKind::Invalid) {
            return Some(Candidate {
                source: SOURCE_DEFAULT_PATH_GUARD.to_string(),
                source_rank: PolicyRuleSource::Policy,
                index: None,
                id: Some("invalid_path".to_string()),
                action: PolicyAction::Deny,
                reason: entry
                    .reason
                    .clone()
                    .unwrap_or_else(|| format!("path '{}' is invalid", entry.display_path())),
                approval: ApprovalShape::default(),
                risk_labels: vec!["invalid_path".to_string()],
                denied_paths: vec![entry.display_path().to_string()],
                contributing_rules: Vec::new(),
            });
        }
        if entry.workspace_path.is_some() {
            continue;
        }
        let Some(host_path) = entry.host_path.as_deref() else {
            continue;
        };
        if let Some(root) = governing_root(host_path, &policy.external_roots) {
            if root.access == ExternalRootAccess::Read
                && !is_read_side(ctx.side_effect.as_deref(), ctx.tool_kind.as_deref())
                && !child_cannot_write(ctx.side_effect.as_deref(), host_path)
            {
                return Some(Candidate {
                    source: SOURCE_DEFAULT_EXTERNAL_PATH.to_string(),
                    source_rank: PolicyRuleSource::Policy,
                    index: None,
                    id: Some(EXTERNAL_ROOT_READ_ONLY.to_string()),
                    action: PolicyAction::Deny,
                    reason: format!(
                        "path '{}' is under external root '{}' whose access is '{}'; \
                         tool '{}' may only read there",
                        entry.display_path(),
                        root.path,
                        root.access,
                        ctx.tool_name,
                    ),
                    approval: ApprovalShape::default(),
                    risk_labels: vec![EXTERNAL_ROOT_READ_ONLY.to_string()],
                    denied_paths: vec![entry.display_path().to_string()],
                    contributing_rules: Vec::new(),
                });
            }
        } else if !policy.allow_external_paths {
            return Some(Candidate {
                source: SOURCE_DEFAULT_EXTERNAL_PATH.to_string(),
                source_rank: PolicyRuleSource::Policy,
                index: None,
                id: Some("external_path".to_string()),
                action: PolicyAction::Deny,
                reason: format!(
                    "path '{}' is outside the workspace and no external root allows it",
                    entry.display_path()
                ),
                approval: ApprovalShape::default(),
                risk_labels: vec!["external_path".to_string()],
                denied_paths: vec![entry.display_path().to_string()],
                contributing_rules: Vec::new(),
            });
        }
    }

    None
}

impl PolicyEvaluation {
    /// The gate a refusal from this decision belongs to.
    ///
    /// Reads the rule that actually decided rather than assuming one gate for
    /// the whole evaluator, so the built-in path guards do not report
    /// themselves as approval decisions. See [`denial_gate_for_source`].
    pub fn denial_gate(&self) -> crate::agent_events::DenialGate {
        denial_gate_for_source(self.matched_rule.as_ref().map(|rule| rule.source.as_str()))
    }

    /// Turn this refusal into the terminal denial a dispatch seam reports.
    ///
    /// The seam gets no gate argument on purpose. Every caller that could
    /// name one could name the wrong one, and one of them did: the tool
    /// dispatch path passed a literal `ApprovalPolicy` for every refusal this
    /// evaluator produced, so a boundary refusal announced itself as an
    /// approval denial to a reader who had approval switched off. Keeping the
    /// gate un-nameable at the call site is what stops that recurring in the
    /// next seam rather than only in the one that was caught.
    ///
    /// Borrows rather than consumes because the same refusal still has to
    /// supply its receipt to the denial evidence alongside this.
    pub fn terminal_denial(&self) -> crate::agent_events::ToolDenial {
        let mut denial = crate::agent_events::ToolDenial::terminal(
            self.denial_gate(),
            None,
            self.reason.clone(),
        );
        denial.denied_paths = self.denied_paths.clone();
        denial.denied_network_targets = self.denied_network_targets.clone();
        denial
    }

    pub fn is_approval_unavailable(&self) -> bool {
        self.denial_gate() == crate::agent_events::DenialGate::ApprovalUnavailable
    }

    pub fn approval_unavailable_denial(
        &self,
        session: &str,
        reason: impl Into<String>,
    ) -> crate::agent_events::ToolDenial {
        let (class, repeat_count) =
            super::next_approval_unavailable_class_repeat_count(session, &self.risk_labels);
        crate::agent_events::ToolDenial::terminal(
            crate::agent_events::DenialGate::ApprovalUnavailable,
            None,
            reason,
        )
        .with_denial_class(class, repeat_count)
    }
}