harn-vm 0.10.125

Async bytecode virtual machine for the Harn programming language
Documentation
//! Approval-policy construction for one declared run-authority posture.

use serde::{Deserialize, Serialize};

use super::approval_resolver::ApprovalResolver;

use super::{PolicyAction, ToolApprovalPolicy};

#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum RunInteractivity {
    Interactive,
    NonInteractive,
}

#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ApprovalAvailability {
    Available,
    Unavailable,
}

/// Why the host permits or refuses project-scoped workspace policy.
///
/// `HostMaterialized` is deliberately distinct from durable user trust. It
/// lets CI, eval, scheduled, and hosted adapters declare that they created the
/// run's isolated workspace without adding disposable paths to a user trust
/// store or recognizing one product's directory layout inside Harn.
#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum WorkspaceTrust {
    Untrusted,
    Trusted,
    HostMaterialized,
}

impl WorkspaceTrust {
    pub fn permits_project_policy(self) -> bool {
        matches!(self, Self::Trusted | Self::HostMaterialized)
    }
}

/// Facts that policy construction must know before a run starts.
#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
pub struct RunAuthorityPosture {
    pub interactivity: RunInteractivity,
    pub approval_availability: ApprovalAvailability,
    pub workspace_trust: WorkspaceTrust,
}

impl RunAuthorityPosture {
    /// Whether an `ask` on this run has nobody to answer it.
    ///
    /// Takes the resolver, because that is the whole question. A non-interactive
    /// run with no approval bridge genuinely cannot reach a person -- but
    /// "cannot reach a person" and "cannot obtain an answer" stopped being the
    /// same statement once a resolver could answer. Collapsing `Ask` to `Deny`
    /// under a resolver that was installed precisely to answer asks would make
    /// the feature a no-op on headless, which is the only surface the evals run
    /// on.
    fn approval_is_unsatisfiable(self, resolver: ApprovalResolver) -> bool {
        if resolver.answers_ask_without_a_person() {
            return false;
        }
        self.interactivity == RunInteractivity::NonInteractive
            && self.approval_availability == ApprovalAvailability::Unavailable
    }
}

/// A tool-approval policy constructed with the run facts that determine
/// whether approval and workspace trust are usable.
///
/// The fields stay private so `PreparedRun` cannot once again receive a policy
/// and posture assembled independently. Hosts construct this value through
/// [`RunApprovalPolicy::construct`], where their adapter can select trust
/// layers from the typed posture. Harn then resolves every legacy form of an
/// unsatisfiable `ask` to a deterministic denial.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct RunApprovalPolicy {
    posture: RunAuthorityPosture,
    effective: ToolApprovalPolicy,
    resolver: ApprovalResolver,
}

impl RunApprovalPolicy {
    /// Construct with the default [`ApprovalResolver::Host`].
    ///
    /// Kept as-is so every existing caller keeps its exact behavior. Adding the
    /// resolver as a field on [`RunAuthorityPosture`] would have been the
    /// tidier shape and was rejected: it is a public struct with struct-literal
    /// construction across the workspace and one downstream product, so the
    /// field would be a breaking change to a type that is not itself changing.
    pub fn construct(
        posture: RunAuthorityPosture,
        build: impl FnOnce(RunAuthorityPosture) -> ToolApprovalPolicy,
    ) -> Self {
        Self::construct_with_resolver(posture, ApprovalResolver::Host, build)
    }

    /// Construct with an explicit resolver.
    ///
    /// The resolver is host authority and arrives as typed input. It is never
    /// read from ambient config here: a resolver that could be picked up from
    /// the environment would make "which policy did this run enforce" a
    /// question the receipt could not answer.
    pub fn construct_with_resolver(
        posture: RunAuthorityPosture,
        resolver: ApprovalResolver,
        build: impl FnOnce(RunAuthorityPosture) -> ToolApprovalPolicy,
    ) -> Self {
        let mut effective = build(posture);
        if posture.approval_is_unsatisfiable(resolver) {
            deny_unsatisfiable_approval(&mut effective);
        }
        Self {
            posture,
            effective,
            resolver,
        }
    }

    pub fn posture(&self) -> RunAuthorityPosture {
        self.posture
    }

    /// The resolver this run installed.
    pub fn resolver(&self) -> ApprovalResolver {
        self.resolver
    }

    pub fn effective(&self) -> &ToolApprovalPolicy {
        &self.effective
    }
}

fn deny_unsatisfiable_approval(policy: &mut ToolApprovalPolicy) {
    for rule in &mut policy.rules {
        if rule.action == PolicyAction::Ask {
            rule.action = PolicyAction::Deny;
            rule.reason = Some(match rule.reason.take() {
                Some(reason) => format!("approval unavailable: {reason}"),
                None => "approval unavailable: the matched rule requires approval".to_string(),
            });
        }
    }

    for pattern in std::mem::take(&mut policy.require_approval) {
        if !policy.auto_deny.contains(&pattern) {
            policy.auto_deny.push(pattern);
        }
    }

    if policy.repeat_limit.is_some()
        && matches!(policy.repeat_action, None | Some(PolicyAction::Ask))
    {
        policy.repeat_action = Some(PolicyAction::Deny);
    }
}

#[cfg(test)]
mod tests {
    use serde_json::json;

    use super::*;

    fn posture(workspace_trust: WorkspaceTrust) -> RunAuthorityPosture {
        RunAuthorityPosture {
            interactivity: RunInteractivity::NonInteractive,
            approval_availability: ApprovalAvailability::Unavailable,
            workspace_trust,
        }
    }

    #[test]
    fn host_materialized_workspace_reaches_policy_construction() {
        let materialized =
            RunApprovalPolicy::construct(posture(WorkspaceTrust::HostMaterialized), |posture| {
                ToolApprovalPolicy {
                    auto_deny: (!posture.workspace_trust.permits_project_policy())
                        .then(|| "edit".to_string())
                        .into_iter()
                        .collect(),
                    ..ToolApprovalPolicy::default()
                }
            });
        let untrusted =
            RunApprovalPolicy::construct(posture(WorkspaceTrust::Untrusted), |posture| {
                ToolApprovalPolicy {
                    auto_deny: (!posture.workspace_trust.permits_project_policy())
                        .then(|| "edit".to_string())
                        .into_iter()
                        .collect(),
                    ..ToolApprovalPolicy::default()
                }
            });

        assert_eq!(
            materialized.effective().evaluate("edit", &json!({})),
            super::super::ToolApprovalDecision::AutoApproved
        );
        assert!(matches!(
            untrusted.effective().evaluate("edit", &json!({})),
            super::super::ToolApprovalDecision::AutoDenied { .. }
        ));
    }

    /// THE MATCHED PAIR for the conditional collapse.
    ///
    /// The same posture -- non-interactive, no approval bridge -- and the same
    /// `ask` rule, resolved two ways. Under `Host` the ask has nobody to answer
    /// it and must still collapse to a denial; under a resolver that answers,
    /// it must survive as an ask. Either test alone proves nothing: the first
    /// passes against code that always collapses, the second against code that
    /// never does.
    fn ask_rule_policy() -> ToolApprovalPolicy {
        serde_json::from_value(json!({
            "rules": [{
                "id": "rule-ask",
                "action": "ask",
                "match": {"tool": "rule_tool"},
                "reason": "review the rule tool"
            }]
        }))
        .expect("policy parses")
    }

    #[test]
    fn host_resolver_still_collapses_an_unanswerable_ask() {
        let policy = RunApprovalPolicy::construct_with_resolver(
            posture(WorkspaceTrust::Trusted),
            ApprovalResolver::Host,
            |_| ask_rule_policy(),
        );
        assert!(matches!(
            policy.effective().evaluate("rule_tool", &json!({})),
            super::super::ToolApprovalDecision::AutoDenied { .. }
        ));
        assert_eq!(policy.resolver(), ApprovalResolver::Host);
    }

    #[test]
    fn a_resolver_that_answers_keeps_the_ask_askable() {
        for resolver in [ApprovalResolver::AutoReview, ApprovalResolver::AllowAll] {
            let policy = RunApprovalPolicy::construct_with_resolver(
                posture(WorkspaceTrust::Trusted),
                resolver,
                |_| ask_rule_policy(),
            );
            // Not AutoDenied. Collapsing here is what would make the whole
            // feature a no-op on headless -- the only surface the evals run on.
            assert_eq!(
                policy.effective().evaluate("rule_tool", &json!({})),
                super::super::ToolApprovalDecision::RequiresHostApproval,
                "{resolver:?} answers asks, so the ask must reach it"
            );
            assert_eq!(policy.resolver(), resolver);
        }
    }

    #[test]
    fn the_default_constructor_is_unchanged() {
        // Every existing caller must keep its exact behavior, or this becomes a
        // silent auto-approver rollout rather than an opt-in one.
        let legacy =
            RunApprovalPolicy::construct(posture(WorkspaceTrust::Trusted), |_| ask_rule_policy());
        assert_eq!(legacy.resolver(), ApprovalResolver::Host);
        assert!(matches!(
            legacy.effective().evaluate("rule_tool", &json!({})),
            super::super::ToolApprovalDecision::AutoDenied { .. }
        ));
    }

    #[test]
    fn unavailable_noninteractive_policy_has_no_satisfiable_ask_form() {
        let policy = RunApprovalPolicy::construct(posture(WorkspaceTrust::Trusted), |_| {
            serde_json::from_value(json!({
                "rules": [{
                    "id": "rule-ask",
                    "action": "ask",
                    "match": {"tool": "rule_tool"},
                    "reason": "review the rule tool"
                }],
                "require_approval": ["legacy_tool"],
                "repeat_limit": 1
            }))
            .expect("policy")
        });

        for (tool, repeat_count) in [("rule_tool", 0), ("legacy_tool", 0), ("other", 2)] {
            let decision =
                policy
                    .effective()
                    .evaluate_detailed_with_repeat(tool, &json!({}), repeat_count);
            assert!(decision.is_deny(), "{tool} remained {:?}", decision.action);
            assert!(
                !decision.is_ask(),
                "{tool} still requires unavailable approval"
            );
        }
    }

    #[test]
    fn other_postures_preserve_reviewable_asks() {
        for posture in [
            RunAuthorityPosture {
                interactivity: RunInteractivity::Interactive,
                approval_availability: ApprovalAvailability::Unavailable,
                workspace_trust: WorkspaceTrust::Trusted,
            },
            RunAuthorityPosture {
                interactivity: RunInteractivity::NonInteractive,
                approval_availability: ApprovalAvailability::Available,
                workspace_trust: WorkspaceTrust::Trusted,
            },
        ] {
            let policy = RunApprovalPolicy::construct(posture, |_| ToolApprovalPolicy {
                require_approval: vec!["edit".to_string()],
                ..ToolApprovalPolicy::default()
            });
            assert!(policy
                .effective()
                .evaluate_detailed("edit", &json!({}))
                .is_ask());
        }
    }
}