harn-vm 0.10.47

Async bytecode virtual machine for the Harn programming language
Documentation
use std::collections::BTreeMap;

use super::*;
use crate::orchestration::{pop_execution_policy, push_execution_policy, CapabilityPolicy};
use crate::tool_annotations::{SideEffectLevel, ToolAnnotations, ToolArgSchema, ToolKind};

fn policy_with_path_annotation(tool: &str, kind: ToolKind) {
    let mut annotations = BTreeMap::new();
    annotations.insert(
        tool.to_string(),
        ToolAnnotations {
            kind,
            side_effect_level: match kind {
                ToolKind::Fetch => SideEffectLevel::Network,
                ToolKind::Execute => SideEffectLevel::ProcessExec,
                ToolKind::Edit | ToolKind::Delete | ToolKind::Move => {
                    SideEffectLevel::WorkspaceWrite
                }
                _ => SideEffectLevel::ReadOnly,
            },
            arg_schema: ToolArgSchema {
                path_params: vec!["path".to_string()],
                ..Default::default()
            },
            ..Default::default()
        },
    );
    push_execution_policy(CapabilityPolicy {
        tool_annotations: annotations,
        ..Default::default()
    });
}

#[test]
fn compact_rule_shorthand_deserializes() {
    let rule: PolicyRule = serde_json::from_value(serde_json::json!({
        "deny": {"tool": "read_*", "path": "**/.env"},
        "reason": "secret file"
    }))
    .expect("rule");
    assert_eq!(rule.action, PolicyAction::Deny);
    assert_eq!(rule.matches.tool, vec!["read_*"]);
    assert_eq!(rule.matches.path, vec!["**/.env"]);
    assert_eq!(rule.reason.as_deref(), Some("secret file"));
}

#[test]
fn ambiguous_or_invalid_rule_shapes_are_rejected() {
    let invalid_action = serde_json::from_value::<PolicyRule>(serde_json::json!({
        "action": "maybe",
        "match": {"tool": "read_file"}
    }));
    assert!(invalid_action.is_err());

    let mixed_matchers = serde_json::from_value::<PolicyRule>(serde_json::json!({
        "action": "deny",
        "match": {"tool": "read_file"},
        "path": "**/.env"
    }));
    assert!(mixed_matchers.is_err());
}

#[test]
fn deny_beats_ask_and_allow_regardless_of_order() {
    let policy: ToolApprovalPolicy = serde_json::from_value(serde_json::json!({
        "rules": [
            {"allow": {"tool": "write_file"}},
            {"ask": {"tool": "write_*"}},
            {"deny": {"tool": "write_file"}, "reason": "blocked"}
        ]
    }))
    .expect("policy");
    let decision =
        evaluate_tool_approval_policy(&policy, "write_file", &serde_json::json!({}), None);
    assert!(decision.is_deny());
    assert_eq!(decision.reason, "blocked");
    assert_eq!(
        decision.matched_rule.as_ref().and_then(|rule| rule.index),
        Some(2)
    );
}

#[test]
fn sensitive_paths_are_denied_by_default() {
    let policy = ToolApprovalPolicy::default();
    let decision = evaluate_tool_approval_policy(
        &policy,
        "read_file",
        &serde_json::json!({"path": "config/.env"}),
        None,
    );
    assert!(decision.is_deny());
    assert!(decision.risk_labels.contains(&"sensitive_path".to_string()));
}

#[test]
fn explicit_sensitive_opt_out_allows_regular_evaluation() {
    let policy = ToolApprovalPolicy {
        allow_sensitive_paths: true,
        ..Default::default()
    };
    let decision = evaluate_tool_approval_policy(
        &policy,
        "read_file",
        &serde_json::json!({"path": "config/.env"}),
        None,
    );
    assert!(decision.is_allow());
    assert!(!decision.has_audit_signal());
}

#[test]
fn external_declared_paths_are_denied_without_root() {
    let temp = tempfile::tempdir().unwrap();
    crate::stdlib::process::set_thread_execution_context(Some(
        crate::orchestration::RunExecutionRecord {
            cwd: Some(temp.path().to_string_lossy().into_owned()),
            project_root: None,
            source_dir: Some(temp.path().to_string_lossy().into_owned()),
            env: BTreeMap::new(),
            adapter: None,
            repo_path: None,
            worktree_path: None,
            branch: None,
            base_ref: None,
            cleanup: None,
            environment_policy: Default::default(),
            grants: Vec::new(),
        },
    ));
    policy_with_path_annotation("read_file", ToolKind::Read);
    let decision = evaluate_tool_approval_policy(
        &ToolApprovalPolicy::default(),
        "read_file",
        &serde_json::json!({"path": "/tmp/outside.txt"}),
        None,
    );
    assert!(decision.is_deny());
    assert!(decision.risk_labels.contains(&"external_path".to_string()));
    pop_execution_policy();
    crate::stdlib::process::set_thread_execution_context(None);
}

#[test]
fn path_rule_uses_declared_path_params() {
    policy_with_path_annotation("write_file", ToolKind::Edit);
    let policy: ToolApprovalPolicy = serde_json::from_value(serde_json::json!({
        "allow_sensitive_paths": true,
        "rules": [{"ask": {"tool": "write_*", "path": "src/**"}, "reason": "source edit"}]
    }))
    .expect("policy");
    let decision = evaluate_tool_approval_policy(
        &policy,
        "write_file",
        &serde_json::json!({"path": "src/lib.rs"}),
        None,
    );
    assert!(decision.is_ask());
    assert_eq!(decision.reason, "source edit");
    pop_execution_policy();
}

#[test]
fn command_url_mcp_identity_and_repeat_rules_match() {
    let policy: ToolApprovalPolicy = serde_json::from_value(serde_json::json!({
        "allow_sensitive_paths": true,
        "rules": [
            {"ask": {"tool": "run_command", "command_identity": "npm"}},
            {"deny": {"tool": "fetch_url", "domain": "*.example.com", "method": "POST"}},
            {"deny": {"mcp_server": "github", "mcp_tool": "create_issue"}},
            {"deny": {"tool": "read_file", "repeat_count_gte": 3}}
        ]
    }))
    .expect("policy");
    assert!(evaluate_tool_approval_policy(
        &policy,
        "run_command",
        &serde_json::json!({"argv": ["npm", "install"]}),
        None,
    )
    .is_ask());
    assert!(evaluate_tool_approval_policy(
        &policy,
        "fetch_url",
        &serde_json::json!({"url": "https://api.example.com/v1", "method": "post"}),
        None,
    )
    .is_deny());
    assert!(evaluate_tool_approval_policy(
        &policy,
        "github__create_issue",
        &serde_json::json!({}),
        None,
    )
    .is_deny());
    assert!(evaluate_tool_approval_policy(
        &policy,
        "read_file",
        &serde_json::json!({"path": "README.md"}),
        Some(3),
    )
    .is_deny());
}

#[test]
fn persona_agent_and_mode_rules_match_args() {
    let policy: ToolApprovalPolicy = serde_json::from_value(serde_json::json!({
        "allow_sensitive_paths": true,
        "rules": [{"deny": {"agent": "release-*", "persona": "shipper", "mode": "act"}}]
    }))
    .expect("policy");
    let decision = evaluate_tool_approval_policy(
        &policy,
        "publish",
        &serde_json::json!({"agent": "release-1", "persona": "shipper", "mode": "act"}),
        None,
    );
    assert!(decision.is_deny());
}

#[test]
fn host_request_normalizes_harn_context_and_emits_canonical_receipt() {
    let policy: ToolApprovalPolicy = serde_json::from_value(serde_json::json!({
        "allow_sensitive_paths": true,
        "rules": [
            {
                "id": "allow-fallback",
                "allow": {"tool": "run"}
            },
            {
                "id": "deny-release-publish",
                "deny": {
                    "tool": "run",
                    "tool_kind": "execute",
                    "side_effect": "process_exec",
                    "command_identity": "cargo",
                    "domain": "crates.io",
                    "method": "POST",
                    "agent": "release-*",
                    "persona": "shipper",
                    "mode": "act",
                    "env_mode": "patch",
                    "capability": "terminal.execute"
                },
                "reason": "release publishing is supervised"
            }
        ]
    }))
    .expect("policy");
    let request = ToolApprovalRequest {
        tool_name: "run".to_string(),
        arguments: serde_json::json!({
            "rawInput": {
                "argv": ["cargo", "publish"],
                "url": "https://crates.io/api/v1/crates",
                "method": "post",
                "envMode": "patch"
            }
        }),
        policy_decision: Some(serde_json::json!({
            "context": {
                "toolName": "run",
                "toolKind": "execute",
                "requestedSideEffectLevel": "process_exec",
                "agent_id": "release-1",
                "persona_id": "shipper",
                "action": "act",
                "policy_context": {
                    "capabilities": ["terminal.execute"]
                }
            }
        })),
        approval_request: None,
        repeat_count: None,
    };

    let decision = policy.evaluate_request(&request);

    assert!(decision.is_deny());
    assert_eq!(decision.reason, "release publishing is supervised");
    assert_eq!(
        decision
            .matched_rule
            .as_ref()
            .and_then(|rule| rule.id.as_deref()),
        Some("deny-release-publish")
    );
    assert_eq!(
        decision.receipt.get("type").and_then(JsonValue::as_str),
        Some(POLICY_RECEIPT_TYPE)
    );
    assert_eq!(
        decision
            .receipt
            .pointer("/context/command_identities/0")
            .and_then(JsonValue::as_str),
        Some("cargo")
    );
}

#[test]
fn write_environment_allows_require_an_exact_mode_match() {
    let wildcard: ToolApprovalPolicy = serde_json::from_value(serde_json::json!({
        "allow_sensitive_paths": true,
        "rules": [{"id": "wildcard", "allow": {"tool": "run", "env_mode": "*"}}]
    }))
    .expect("policy");
    let exact: ToolApprovalPolicy = serde_json::from_value(serde_json::json!({
        "allow_sensitive_paths": true,
        "rules": [{"id": "exact", "allow": {"tool": "run", "env_mode": "patch"}}]
    }))
    .expect("policy");
    let request = ToolApprovalRequest {
        tool_name: "run".to_string(),
        arguments: serde_json::json!({"env_mode": "patch"}),
        ..Default::default()
    };

    let wildcard_decision = wildcard.evaluate_request(&request);
    let exact_decision = exact.evaluate_request(&request);

    assert!(wildcard_decision.is_allow());
    assert!(
        wildcard_decision.matched_rule.is_none(),
        "a wildcard must not grant patch-mode writes"
    );
    assert_eq!(
        exact_decision
            .matched_rule
            .as_ref()
            .and_then(|rule| rule.id.as_deref()),
        Some("exact")
    );
}

#[test]
fn approval_request_undo_metadata_is_a_supported_host_boundary() {
    let policy: ToolApprovalPolicy = serde_json::from_value(serde_json::json!({
        "allow_sensitive_paths": true,
        "rules": [{"id": "ask-network", "ask": {"side_effect": "network"}}]
    }))
    .expect("policy");
    let request = ToolApprovalRequest {
        tool_name: "fetch".to_string(),
        arguments: serde_json::json!({}),
        approval_request: Some(serde_json::json!({
            "undo_metadata": {
                "policy_decision": {
                    "context": {"side_effect": "network"}
                }
            }
        })),
        ..Default::default()
    };

    let decision = policy.evaluate_request(&request);

    assert!(decision.is_ask());
    assert_eq!(
        decision
            .matched_rule
            .as_ref()
            .and_then(|rule| rule.id.as_deref()),
        Some("ask-network")
    );
}

#[test]
fn approval_unavailable_class_count_uses_sorted_risk_labels() {
    clear_all_approval_policy_repeat_counts();
    let labels = vec![
        "package_install".to_string(),
        "approval_required".to_string(),
    ];
    let reversed = vec![
        "approval_required".to_string(),
        "package_install".to_string(),
    ];

    let (class, count) = next_approval_unavailable_class_repeat_count("s1", &labels);
    assert_eq!(class, "approval_required+package_install");
    assert_eq!(count, 1);
    let (class, count) = next_approval_unavailable_class_repeat_count("s1", &reversed);
    assert_eq!(class, "approval_required+package_install");
    assert_eq!(count, 2);
    let (class, count) = next_approval_unavailable_class_repeat_count("s2", &[]);
    assert_eq!(class, "approval_required");
    assert_eq!(count, 1);

    clear_approval_policy_repeat_counts("s1");
    let (_, count) = next_approval_unavailable_class_repeat_count("s1", &labels);
    assert_eq!(count, 1);
}