//! Raw-line oriented E2E coverage for the tool_registry and approval domains. //! //! These tests intentionally mix JSON-RPC calls with the public domain APIs that //! back those calls. JSON-RPC drives the externally visible controller paths; //! direct public API calls cover persistence/redaction/provider branches that //! are otherwise only indirectly reachable from the controllers. use std::path::{Path, PathBuf}; use std::sync::{Arc, Mutex, OnceLock}; use std::time::{Duration, Instant}; use axum::http::header::AUTHORIZATION; use reqwest::StatusCode; use rusqlite::{params, Connection}; use serde_json::{json, Map, Value}; use tempfile::{tempdir, TempDir}; use openhuman_core::core::auth::{init_rpc_token, CORE_TOKEN_ENV_VAR}; use openhuman_core::core::jsonrpc::build_core_http_router; use openhuman_core::openhuman::agent::turn_origin::{self, AgentTurnOrigin}; use openhuman_core::openhuman::approval::gate::{ parse_approval_reply, ApprovalChatContext, ApprovalGate, APPROVAL_CHAT_CONTEXT, }; use openhuman_core::openhuman::approval::store as approval_store; use openhuman_core::openhuman::approval::{ all_approval_controller_schemas, all_approval_registered_controllers, redact_args, summarize_action, ApprovalDecision, ExecutionOutcome, GateOutcome, PendingApproval, }; use openhuman_core::openhuman::config::schema::{ CapabilityProviderConfig, CapabilityProviderTrustState, }; use openhuman_core::openhuman::config::Config; use openhuman_core::openhuman::mcp_registry::connections; use openhuman_core::openhuman::mcp_registry::types::{CommandKind, InstalledServer, Transport}; use openhuman_core::openhuman::security::{live_policy, SecurityPolicy}; use openhuman_core::openhuman::tool_registry::{ all_tool_registry_controller_schemas, all_tool_registry_registered_controllers, capability_provider_by_id, capability_provider_diagnostics, capability_provider_registry, denials, get_tool, is_capability_provider_trusted_enabled, list_capability_providers, list_tools, normalize_capability_provider_id, registry_entries, CapabilityProviderRegistryError, }; const TEST_RPC_TOKEN: &str = "tool-registry-approval-raw-e2e-token"; static AUTH_INIT: OnceLock<()> = OnceLock::new(); static ENV_LOCK: OnceLock> = OnceLock::new(); struct EnvVarGuard { key: &'static str, old: Option, } impl EnvVarGuard { fn set_to_path(key: &'static str, path: &Path) -> Self { let old = std::env::var(key).ok(); std::env::set_var(key, path.as_os_str()); Self { key, old } } fn set(key: &'static str, value: &str) -> Self { let old = std::env::var(key).ok(); std::env::set_var(key, value); Self { key, old } } fn unset(key: &'static str) -> Self { let old = std::env::var(key).ok(); std::env::remove_var(key); Self { key, old } } } impl Drop for EnvVarGuard { fn drop(&mut self) { match &self.old { Some(value) => std::env::set_var(self.key, value), None => std::env::remove_var(self.key), } } } struct TestHarness { _tmp: TempDir, _guards: Vec, rpc_base: String, rpc_join: tokio::task::JoinHandle>, } fn env_lock() -> std::sync::MutexGuard<'static, ()> { ENV_LOCK .get_or_init(|| Mutex::new(())) .lock() .unwrap_or_else(|poisoned| poisoned.into_inner()) } fn ensure_rpc_auth() { AUTH_INIT.get_or_init(|| { std::env::set_var(CORE_TOKEN_ENV_VAR, TEST_RPC_TOKEN); let token_dir = std::env::temp_dir().join("openhuman-tool-registry-approval-e2e-auth"); init_rpc_token(&token_dir).expect("init rpc auth token"); }); } async fn serve_rpc() -> ( std::net::SocketAddr, tokio::task::JoinHandle>, ) { ensure_rpc_auth(); let listener = tokio::net::TcpListener::bind("127.0.0.1:0") .await .expect("bind rpc listener"); let addr = listener.local_addr().expect("rpc listener addr"); let router = build_core_http_router(false); let join = tokio::spawn(async move { axum::serve(listener, router).await }); (addr, join) } fn write_config(openhuman_dir: &Path, capability_providers: &str) { std::fs::create_dir_all(openhuman_dir).expect("create .openhuman"); let cfg = format!( r#"api_url = "http://127.0.0.1:9" default_model = "e2e-model" default_temperature = 0.2 [secrets] encrypt = false [local_ai] enabled = false [memory] provider = "none" embedding_provider = "none" embedding_model = "none" embedding_dimensions = 0 [memory_tree] embedding_strict = false [autonomy] level = "supervised" workspace_only = false max_actions_per_hour = 17 require_approval_for_medium_risk = false block_high_risk_commands = false auto_approve = [] [mcp_client] enabled = true [[mcp_client.servers]] name = "filesystem" command = "node" args = ["server.js"] enabled = true allowed_tools = ["read_file", "list_directory"] disallowed_tools = ["write_file"] {capability_providers} "# ); std::fs::write(openhuman_dir.join("config.toml"), cfg).expect("write config.toml"); } async fn setup(capability_providers: &str) -> TestHarness { let tmp = tempdir().expect("tempdir"); let home = tmp.path(); let workspace = home.join("openhuman-workspace"); write_config(&workspace, capability_providers); write_config(&home.join(".openhuman"), capability_providers); let guards = vec![ EnvVarGuard::set_to_path("HOME", home), EnvVarGuard::set_to_path("OPENHUMAN_WORKSPACE", &workspace), EnvVarGuard::unset("BACKEND_URL"), EnvVarGuard::unset("VITE_BACKEND_URL"), EnvVarGuard::unset("OPENHUMAN_API_URL"), EnvVarGuard::set("OPENHUMAN_KEYRING_BACKEND", "file"), EnvVarGuard::set("OPENHUMAN_MEMORY_EMBED_STRICT", "false"), EnvVarGuard::set("OPENHUMAN_MEMORY_EMBED_ENDPOINT", ""), EnvVarGuard::set("OPENHUMAN_MEMORY_EMBED_MODEL", ""), ]; let (addr, rpc_join) = serve_rpc().await; TestHarness { _tmp: tmp, _guards: guards, rpc_base: format!("http://{addr}"), rpc_join, } } async fn rpc(rpc_base: &str, id: i64, method: &str, params: Value) -> Value { let client = reqwest::Client::builder() .timeout(Duration::from_secs(30)) .build() .expect("client"); let url = format!("{}/rpc", rpc_base.trim_end_matches('/')); let response = client .post(&url) .header(AUTHORIZATION, format!("Bearer {TEST_RPC_TOKEN}")) .json(&json!({ "jsonrpc": "2.0", "id": id, "method": method, "params": params, })) .send() .await .unwrap_or_else(|err| panic!("POST {url} {method}: {err}")); assert_eq!(response.status(), StatusCode::OK, "{method} HTTP status"); response .json::() .await .unwrap_or_else(|err| panic!("json for {method}: {err}")) } fn ok<'a>(value: &'a Value, context: &str) -> &'a Value { if let Some(error) = value.get("error") { panic!("{context}: unexpected JSON-RPC error: {error}"); } value .get("result") .unwrap_or_else(|| panic!("{context}: missing result: {value}")) } fn payload<'a>(value: &'a Value, context: &str) -> &'a Value { let result = ok(value, context); result.get("result").unwrap_or(result) } fn error_message<'a>(value: &'a Value, context: &str) -> &'a str { value .get("error") .and_then(|error| error.get("message")) .and_then(Value::as_str) .unwrap_or_else(|| panic!("{context}: error missing message: {value}")) } fn provider( id: &str, display_name: &str, trust_state: CapabilityProviderTrustState, enabled: bool, ) -> CapabilityProviderConfig { CapabilityProviderConfig { id: id.to_string(), display_name: display_name.to_string(), source_uri: Some(format!(" https://example.com/providers/{id} ")), source_digest: Some(" sha256:feedface ".to_string()), trust_state, enabled, } } fn approval_db_path(config: &Config) -> PathBuf { config.workspace_dir.join("approval").join("approval.db") } fn pending( request_id: &str, _session_id: &str, expires_at: Option>, ) -> PendingApproval { PendingApproval::new( request_id, "tools.composio_execute", "tools.composio_execute(action=execute, 42 bytes)", json!({ "action": "execute", "tool_slug": "GMAIL_SEND_EMAIL" }), expires_at, ) } fn test_mcp_server() -> InstalledServer { InstalledServer { server_id: format!("tool-registry-test-{}", uuid::Uuid::new_v4()), qualified_name: "@openhuman-test/echo".to_string(), display_name: "Test Echo".to_string(), description: Some("Stub MCP server used by tool registry coverage tests.".to_string()), icon_url: None, command_kind: CommandKind::Binary, command: env!("CARGO_BIN_EXE_test-mcp-stub").to_string(), args: Vec::new(), env_keys: Vec::new(), config: None, installed_at: 0, last_connected_at: None, transport: Transport::Stdio, enabled: true, } } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn tool_registry_rpc_diagnostics_include_denials_and_provider_errors() { let _lock = env_lock(); let harness = setup( r#" [[capability_providers]] id = "Acme Tools" display_name = "Acme Tools" trust_state = "trusted" enabled = true [[capability_providers]] id = "acme-tools" display_name = "Duplicate Acme" trust_state = "trusted" enabled = true "#, ) .await; denials::record(" ", "policy", "blocked", "ignored blank tool"); denials::record( "tools.secret", "external-write", "denied", "blocked Authorization: Bearer sk-secret-abcdefghijklmnopqrstuvwxyz", ); denials::record("tools.long", "", "", &"x".repeat(320)); let diagnostics = rpc( &harness.rpc_base, 10, "openhuman.tool_registry_diagnostics", json!({}), ) .await; let diagnostics = payload(&diagnostics, "tool_registry_diagnostics"); assert!( diagnostics .get("total_tools") .and_then(Value::as_u64) .is_some_and(|count| count > 0), "registry should expose tools: {diagnostics}" ); assert_eq!( diagnostics .pointer("/mcp_allowlists/enabled") .and_then(Value::as_bool), Some(true) ); assert_eq!( diagnostics .pointer("/mcp_allowlists/servers/0/allowed_tools_count") .and_then(Value::as_u64), Some(2) ); assert!(diagnostics .pointer("/possible_write_surfaces") .and_then(Value::as_array) .expect("write surfaces") .iter() .any(|tool| tool.as_str() == Some("tools.composio_execute"))); let recent_denials = diagnostics .get("recent_denials") .and_then(Value::as_array) .expect("recent denials array"); assert!(recent_denials .iter() .any(|row| row.get("reason").and_then(Value::as_str) == Some("[redacted: sensitive content]"))); assert!(recent_denials.iter().any(|row| { row.get("policy").and_then(Value::as_str) == Some("unknown") && row.get("action").and_then(Value::as_str) == Some("blocked") && row .get("reason") .and_then(Value::as_str) .is_some_and(|reason| reason.ends_with('…')) })); assert_eq!( diagnostics .pointer("/capability_providers/total_providers") .and_then(Value::as_u64), Some(2) ); assert!(diagnostics .pointer("/capability_providers/registry_errors/0") .and_then(Value::as_str) .is_some_and(|err| err.contains("duplicate provider id after normalization"))); let list = rpc( &harness.rpc_base, 11, "openhuman.tool_registry_list", json!({}), ) .await; let listed_tools = payload(&list, "tool_registry_list") .get("tools") .and_then(Value::as_array) .expect("tool registry list"); let first_tool_id = listed_tools .first() .and_then(|tool| tool.get("tool_id")) .and_then(Value::as_str) .expect("first tool id") .to_string(); let found = rpc( &harness.rpc_base, 12, "openhuman.tool_registry_get", json!({ "tool_id": format!(" {first_tool_id} ") }), ) .await; assert_eq!( payload(&found, "tool_registry_get success") .get("tool_id") .and_then(Value::as_str), Some(first_tool_id.as_str()) ); let empty = rpc( &harness.rpc_base, 13, "openhuman.tool_registry_get", json!({ "tool_id": " " }), ) .await; assert!(error_message(&empty, "empty tool id").contains("non-empty")); let missing = rpc( &harness.rpc_base, 14, "openhuman.tool_registry_get", json!({ "tool_id": "missing.tool" }), ) .await; assert!(error_message(&missing, "missing tool").contains("missing.tool")); harness.rpc_join.abort(); } #[test] fn tool_registry_public_api_lists_gets_and_validates_ids() { let listed = list_tools() .into_cli_compatible_json() .expect("list_tools json"); let tools = listed .get("tools") .and_then(Value::as_array) .expect("listed tools"); let first_tool_id = tools .first() .and_then(|tool| tool.get("tool_id")) .and_then(Value::as_str) .expect("first tool id"); let found = get_tool(first_tool_id) .expect("get first tool") .into_cli_compatible_json() .expect("get_tool json"); assert_eq!( found.get("tool_id").and_then(Value::as_str), Some(first_tool_id) ); assert!(get_tool(" ") .expect_err("blank id should fail") .contains("non-empty")); assert!(get_tool("missing.tool") .expect_err("missing id should fail") .contains("missing.tool")); } #[test] fn capability_provider_public_api_normalizes_lookup_and_error_branches() { let config = Config { capability_providers: vec![ provider( " Team Tools ", " ", CapabilityProviderTrustState::Trusted, true, ), provider( "draft_tools", "Draft Tools", CapabilityProviderTrustState::Untrusted, true, ), provider( "disabled.tools", "Disabled Tools", CapabilityProviderTrustState::Trusted, false, ), ], ..Config::default() }; assert_eq!( normalize_capability_provider_id(" Team Tools "), Ok("team-tools".to_string()) ); assert!(normalize_capability_provider_id("!!!").is_err()); assert!(normalize_capability_provider_id(&"x".repeat(120)).is_err()); let registry = capability_provider_registry(&config).expect("provider registry"); let listed = registry.list(); assert_eq!(listed.len(), 3); assert_eq!(listed[2].id, "team-tools"); assert_eq!( listed[2].display_name, "team-tools", "empty display_name should fall back to normalized id" ); assert_eq!( listed[2].source_uri.as_deref(), Some("https://example.com/providers/ Team Tools") ); assert!(registry.get("TEAM TOOLS").is_some()); assert!(registry.get("!!!").is_none()); assert!(registry.is_trusted_enabled("team tools")); assert!(!registry.is_trusted_enabled("draft_tools")); assert!(!registry.is_trusted_enabled("disabled.tools")); assert_eq!(list_capability_providers(&config).unwrap().len(), 3); let diagnostics = capability_provider_diagnostics(&config); assert_eq!(diagnostics.total_providers, 3); assert_eq!(diagnostics.enabled_providers, 2); assert_eq!(diagnostics.trusted_providers, 2); assert_eq!(diagnostics.trusted_enabled_providers, 1); assert!(diagnostics.registry_errors.is_empty()); assert_eq!( capability_provider_by_id(&config, "team tools") .unwrap() .expect("team provider") .id, "team-tools" ); assert!(is_capability_provider_trusted_enabled( &config, "team tools" )); let duplicate_config = Config { capability_providers: vec![ provider( "Team Tools", "Team Tools", CapabilityProviderTrustState::Trusted, true, ), provider( "team-tools", "Team Tools", CapabilityProviderTrustState::Trusted, true, ), ], ..Config::default() }; assert!(list_capability_providers(&duplicate_config).is_err()); let diagnostics = capability_provider_diagnostics(&duplicate_config); assert_eq!(diagnostics.total_providers, 2); assert!(diagnostics.registry_errors[0].contains("duplicate")); let invalid_config = Config { capability_providers: vec![provider( "!!!", "Invalid Tools", CapabilityProviderTrustState::Trusted, true, )], ..Config::default() }; let invalid_err = list_capability_providers(&invalid_config).expect_err("invalid provider id"); assert_eq!( invalid_err.to_string(), CapabilityProviderRegistryError::InvalidId { raw: "!!!".to_string() } .to_string() ); assert!(!is_capability_provider_trusted_enabled( &invalid_config, "invalid" )); let invalid_diagnostics = capability_provider_diagnostics(&invalid_config); assert_eq!(invalid_diagnostics.total_providers, 1); assert_eq!(invalid_diagnostics.enabled_providers, 0); assert!(invalid_diagnostics.registry_errors[0].contains("invalid provider id")); } #[test] fn tool_registry_diagnostics_for_config_reports_audit_success_and_policy_shape() { let dir = tempdir().expect("tempdir"); let config = Config { workspace_dir: dir.path().to_path_buf(), ..Config::default() }; let diagnostics = openhuman_core::openhuman::tool_registry::ops::diagnostics_for_config(&config) .into_cli_compatible_json() .expect("diagnostics json"); assert!(diagnostics .get("total_tools") .and_then(Value::as_u64) .is_some_and(|count| count > 0)); assert_eq!( diagnostics.pointer("/mcp_write_audit/enabled"), Some(&json!(true)) ); assert_eq!( diagnostics.pointer("/mcp_write_audit/last_error"), Some(&Value::Null) ); assert!(diagnostics .pointer("/mcp_write_audit/recent_rows") .and_then(Value::as_u64) .is_some()); assert_eq!( diagnostics.pointer("/posture/autonomy_level"), Some(&json!("supervised")) ); assert!(diagnostics .pointer("/policy_surfaces") .and_then(Value::as_array) .expect("policy surfaces") .iter() .any(|surface| surface.as_str() == Some("tool_registry.diagnostics"))); assert_eq!( diagnostics.pointer("/mcp_allowlists/server_count"), Some(&json!(0)) ); } #[tokio::test(flavor = "current_thread")] async fn tool_registry_entries_fall_back_on_current_thread_runtime() { let entries = registry_entries(); assert!(entries .iter() .any(|entry| entry.tool_id == "tools.web_search")); assert!(entries .iter() .all(|entry| !entry.tool_id.starts_with("mcp-client::"))); } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn tool_registry_entries_include_connected_mcp_client_tools() { let tmp = tempdir().expect("tempdir"); let config = Config { workspace_dir: tmp.path().to_path_buf(), ..Config::default() }; let server = test_mcp_server(); let tools = connections::connect(&config, &server) .await .expect("connect test mcp server"); assert_eq!(tools.first().map(|tool| tool.name.as_str()), Some("echo")); let entries = registry_entries(); let client_entry = entries .iter() .find(|entry| entry.tool_id == format!("mcp-client::{}::echo", server.server_id)) .expect("connected mcp client entry"); assert_eq!(client_entry.name, "echo"); assert_eq!(client_entry.route["protocol"], json!("mcp-client")); assert_eq!(client_entry.route["server_id"], json!(server.server_id)); assert!(client_entry.tags.iter().any(|tag| tag == "mcp_client")); assert!(connections::disconnect(&server.server_id).await); } #[tokio::test] async fn tool_registry_schema_handlers_validate_and_return_payloads() { // Acquire the env lock — this test loads Config via the diagnostics // handler, and a sibling test temporarily points OPENHUMAN_WORKSPACE at // a file to exercise the load-failure branch. Without the lock those // two can race and this test sees the corrupted env. let _lock = env_lock(); let schemas = all_tool_registry_controller_schemas(); assert_eq!( schemas .iter() .map(|schema| schema.function) .collect::>(), vec!["list", "get", "diagnostics"] ); let controllers = all_tool_registry_registered_controllers(); assert_eq!(controllers.len(), schemas.len()); let list_handler = controllers .iter() .find(|controller| controller.schema.function == "list") .expect("list controller") .handler; let list_value = list_handler(Map::new()).await.expect("list handler"); let tools = list_value .get("tools") .and_then(Value::as_array) .expect("tools array"); assert!(tools .iter() .any(|tool| tool.get("tool_id").and_then(Value::as_str) == Some("memory.search"))); let get_handler = controllers .iter() .find(|controller| controller.schema.function == "get") .expect("get controller") .handler; assert!(get_handler(Map::new()) .await .expect_err("missing tool_id") .contains("non-empty string")); let mut numeric_tool_id = Map::new(); numeric_tool_id.insert("tool_id".to_string(), json!(42)); assert!(get_handler(numeric_tool_id) .await .expect_err("numeric tool_id") .contains("non-empty string")); let mut blank_tool_id = Map::new(); blank_tool_id.insert("tool_id".to_string(), json!(" ")); assert!(get_handler(blank_tool_id) .await .expect_err("blank tool_id") .contains("non-empty string")); let mut valid_tool_id = Map::new(); valid_tool_id.insert("tool_id".to_string(), json!("tools.web_search")); let tool_value = get_handler(valid_tool_id).await.expect("get handler"); assert_eq!( tool_value.get("tool_id").and_then(Value::as_str), Some("tools.web_search") ); let diagnostics_handler = controllers .iter() .find(|controller| controller.schema.function == "diagnostics") .expect("diagnostics controller") .handler; let diagnostics_value = diagnostics_handler(Map::new()) .await .expect("diagnostics handler"); assert!(diagnostics_value .get("total_tools") .and_then(Value::as_u64) .is_some_and(|count| count > 0)); } #[tokio::test] async fn tool_registry_diagnostics_reports_config_and_audit_store_failures() { let _lock = env_lock(); let tmp = tempdir().expect("tempdir"); let workspace_file = tmp.path().join("workspace-file"); std::fs::write(&workspace_file, "not a directory").expect("workspace sentinel"); let _workspace_guard = EnvVarGuard::set_to_path("OPENHUMAN_WORKSPACE", &workspace_file); let err = openhuman_core::openhuman::tool_registry::ops::diagnostics() .await .expect_err("workspace file should prevent config load"); assert!(err.contains("failed to load config for tool registry diagnostics")); let broken_audit_config = Config { workspace_dir: workspace_file, ..Config::default() }; let diagnostics = openhuman_core::openhuman::tool_registry::ops::diagnostics_for_config(&broken_audit_config); assert!(diagnostics.value.mcp_write_audit.enabled); assert_eq!(diagnostics.value.mcp_write_audit.recent_rows, None); assert!(diagnostics .value .mcp_write_audit .last_error .as_deref() .is_some_and(|error| !error.is_empty())); } #[test] fn approval_redaction_and_store_cover_shape_expiry_migration_and_audit_branches() { let dir = tempdir().expect("tempdir"); let config = Config { workspace_dir: dir.path().to_path_buf(), ..Config::default() }; let raw_args = json!({ "action": "execute", "tool_slug": "GMAIL_SEND_EMAIL", "integration": "gmail", "body": "Hello from /Users/alice/private.txt", "recipients": ["a@example.com", "b@example.com"], "metadata": { "Subject": "Confidential subject", "token": "sk-secret", "auth": true, "message": 42, "password": null, "user": { "id": "user-123", "name": "Alice" }, "attempts": 3, "confirmed": true, "nullable": null, "safe_path": "C:\\Users\\bob\\Desktop\\report.txt", "safe_list": [ "open /Users/frank/Desktop/report.txt", { "content": "nested secret" } ] } }); let redacted = redact_args(&raw_args); assert_eq!(redacted["action"], json!("execute")); assert_eq!(redacted["body"], json!("")); assert_eq!(redacted["recipients"], json!("")); assert_eq!( redacted.pointer("/metadata/Subject"), Some(&json!("")) ); assert_eq!( redacted.pointer("/metadata/attempts"), Some(&json!(3)), "non-sensitive numeric fields should pass through" ); assert_eq!( redacted.pointer("/metadata/auth"), Some(&json!("")) ); assert_eq!( redacted.pointer("/metadata/message"), Some(&json!("")) ); assert_eq!(redacted.pointer("/metadata/password"), Some(&Value::Null)); assert_eq!( redacted.pointer("/metadata/user"), Some(&json!("")) ); assert_eq!( redacted.pointer("/metadata/safe_list/0"), Some(&json!("open /Desktop/report.txt")) ); assert_eq!( redacted.pointer("/metadata/safe_list/1/content"), Some(&json!("")) ); assert_eq!( redact_args(&json!( "open /home/carol/report.md and C:\\Users\\dave\\x.txt" )), json!("open /report.md and \\x.txt") ); assert_eq!(redact_args(&json!("/Users/erin")), json!("")); let summary = summarize_action("tools.composio_execute", &raw_args); assert!(summary.contains("action=execute")); assert!(summary.contains("tool_slug=GMAIL_SEND_EMAIL")); assert!(summary.contains("integration=gmail")); let summary_without_safe_fields = summarize_action("tools.empty", &json!(["opaque"])); assert!(summary_without_safe_fields.starts_with("tools.empty (")); approval_store::insert_pending( &config, &pending( "expired", "session-a", Some(chrono::Utc::now() - chrono::Duration::minutes(5)), ), "session-a", ) .expect("insert expired"); approval_store::insert_pending( &config, &pending( "active", "session-a", Some(chrono::Utc::now() + chrono::Duration::minutes(5)), ), "session-a", ) .expect("insert active"); approval_store::insert_pending( &config, &pending("other-session", "session-b", None), "session-b", ) .expect("insert no-ttl"); let rows = approval_store::list_pending(&config).expect("list pending"); let ids = rows .iter() .map(|row| row.request_id.as_str()) .collect::>(); assert_eq!(ids, vec!["active", "other-session"]); assert_eq!( approval_store::get_decision(&config, "expired").expect("expired decision"), Some(ApprovalDecision::Deny) ); let removed = approval_store::purge_session(&config, "session-b").expect("purge session"); assert_eq!(removed, 1); assert_eq!( approval_store::purge_session(&config, "missing-session").unwrap(), 0 ); let decided = approval_store::decide(&config, "active", ApprovalDecision::ApproveOnce) .expect("decide active") .expect("active row"); assert_eq!(decided.request_id, "active"); assert!(!approval_store::record_execution( &config, "missing", ExecutionOutcome::Aborted, Some("not found"), ) .expect("unknown record execution")); assert!(approval_store::record_execution( &config, "active", ExecutionOutcome::Failure, Some("upstream Authorization: Bearer sk-live-abcdefghijklmnopqrstuvwxyz failed"), ) .expect("record failed execution")); assert!(!approval_store::record_execution( &config, "active", ExecutionOutcome::Success, Some("late rewrite"), ) .expect("idempotent execution")); let audit = approval_store::list_recent_decisions(&config, 0).expect("recent decisions"); assert_eq!(audit.len(), 1, "zero limit should clamp to one"); assert_eq!(audit[0].request_id, "active"); assert_eq!(audit[0].decision, ApprovalDecision::ApproveOnce); let db_path = approval_db_path(&config); let conn = Connection::open(&db_path).expect("open approval db"); conn.execute( "INSERT INTO pending_approvals (request_id, tool_name, action_summary, args_redacted, session_id, created_at, decided_at, decision) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)", params![ "corrupt-json", "tools.web_search", "corrupt args", "{not valid json", "session-a", chrono::Utc::now().to_rfc3339(), chrono::Utc::now().to_rfc3339(), "deny", ], ) .expect("insert corrupt audit row"); drop(conn); let audit = approval_store::list_recent_decisions(&config, 10).expect("audit with corrupt row"); let corrupt = audit .iter() .find(|row| row.request_id == "corrupt-json") .expect("corrupt audit row"); assert_eq!( corrupt.args_redacted, json!({ "_error": "args_redacted not valid JSON" }) ); let legacy_dir = tempdir().expect("legacy tempdir"); let legacy_config = Config { workspace_dir: legacy_dir.path().to_path_buf(), ..Config::default() }; let legacy_db = approval_db_path(&legacy_config); std::fs::create_dir_all(legacy_db.parent().expect("legacy db parent")) .expect("create legacy db dir"); let legacy_conn = Connection::open(&legacy_db).expect("open legacy db"); legacy_conn .execute_batch( "CREATE TABLE pending_approvals ( request_id TEXT PRIMARY KEY, tool_name TEXT NOT NULL, action_summary TEXT NOT NULL, args_redacted TEXT NOT NULL, session_id TEXT NOT NULL, created_at TEXT NOT NULL, expires_at TEXT, decided_at TEXT, decision TEXT );", ) .expect("create legacy schema"); legacy_conn .execute( "INSERT INTO pending_approvals (request_id, tool_name, action_summary, args_redacted, session_id, created_at) VALUES ('legacy', 'tools.web_search', 'legacy', '{}', 'legacy-session', ?1)", params![chrono::Utc::now().to_rfc3339()], ) .expect("insert legacy row"); drop(legacy_conn); assert_eq!( approval_store::list_pending(&legacy_config) .expect("migrated pending") .len(), 1 ); approval_store::decide(&legacy_config, "legacy", ApprovalDecision::ApproveOnce) .expect("decide legacy"); assert!(approval_store::record_execution( &legacy_config, "legacy", ExecutionOutcome::Success, None, ) .expect("record execution after migration")); } #[test] fn approval_reply_parser_accepts_explicit_yes_no_only() { for decision in [ ApprovalDecision::ApproveOnce, ApprovalDecision::ApproveAlwaysForTool, ApprovalDecision::Deny, ] { assert_eq!( ApprovalDecision::from_str(decision.as_str()), Some(decision) ); } assert_eq!(ApprovalDecision::from_str("maybe"), None); assert!(ApprovalDecision::ApproveOnce.is_approve()); assert!(ApprovalDecision::ApproveAlwaysForTool.is_approve()); assert!(!ApprovalDecision::Deny.is_approve()); for outcome in [ ExecutionOutcome::Success, ExecutionOutcome::Failure, ExecutionOutcome::Aborted, ] { assert_eq!(ExecutionOutcome::from_str(outcome.as_str()), Some(outcome)); } assert_eq!(ExecutionOutcome::from_str("partial"), None); assert_eq!( serde_json::to_string(&ExecutionOutcome::Aborted).expect("serialize outcome"), "\"aborted\"" ); assert_eq!( parse_approval_reply(" yes "), Some(ApprovalDecision::ApproveOnce) ); assert_eq!( parse_approval_reply("APPROVED"), Some(ApprovalDecision::ApproveOnce) ); assert_eq!(parse_approval_reply("n"), Some(ApprovalDecision::Deny)); assert_eq!(parse_approval_reply("denied"), Some(ApprovalDecision::Deny)); assert_eq!(parse_approval_reply("maybe later"), None); } #[tokio::test] async fn approval_schema_handlers_validate_params_and_surface_empty_gate_state() { let schemas = all_approval_controller_schemas(); assert_eq!( schemas .iter() .map(|schema| schema.function) .collect::>(), vec![ "list_pending", "list_recent_decisions", "decide", "get_gate_state" ] ); let unknown = openhuman_core::openhuman::approval::schemas::schemas("missing"); assert_eq!(unknown.namespace, "approval"); assert_eq!(unknown.function, "unknown"); assert_eq!(unknown.outputs[0].name, "error"); let controllers = all_approval_registered_controllers(); assert_eq!(controllers.len(), schemas.len()); let list_handler = controllers .iter() .find(|controller| controller.schema.function == "list_pending") .expect("list pending controller") .handler; let list_value = list_handler(Map::new()).await.expect("list pending value"); assert!(list_value .get("result") .or(Some(&list_value)) .and_then(Value::as_array) .is_some()); let recent_handler = controllers .iter() .find(|controller| controller.schema.function == "list_recent_decisions") .expect("recent decisions controller") .handler; let mut invalid_limit = Map::new(); invalid_limit.insert("limit".to_string(), json!("ten")); assert!(recent_handler(invalid_limit) .await .expect_err("string limit") .contains("expected unsigned integer")); for invalid in [json!(true), json!([]), json!({ "limit": 10 })] { let mut invalid_limit = Map::new(); invalid_limit.insert("limit".to_string(), invalid); assert!(recent_handler(invalid_limit) .await .expect_err("non-numeric limit") .contains("expected unsigned integer")); } let mut negative_limit = Map::new(); negative_limit.insert("limit".to_string(), json!(-1)); assert!(recent_handler(negative_limit) .await .expect_err("negative limit") .contains("expected unsigned integer")); let mut null_limit = Map::new(); null_limit.insert("limit".to_string(), Value::Null); let recent_value = recent_handler(null_limit) .await .expect("null limit should use default"); assert!(recent_value .get("result") .or(Some(&recent_value)) .and_then(Value::as_array) .is_some()); let decide_handler = controllers .iter() .find(|controller| controller.schema.function == "decide") .expect("decide controller") .handler; assert!(decide_handler(Map::new()) .await .expect_err("missing request id") .contains("missing required param 'request_id'")); let mut numeric_request = Map::new(); numeric_request.insert("request_id".to_string(), json!(42)); numeric_request.insert("decision".to_string(), json!("deny")); assert!(decide_handler(numeric_request) .await .expect_err("numeric request id") .contains("expected string")); for invalid in [ Value::Null, json!(false), json!([]), json!({ "id": "missing" }), ] { let mut invalid_request = Map::new(); invalid_request.insert("request_id".to_string(), invalid); invalid_request.insert("decision".to_string(), json!("deny")); assert!(decide_handler(invalid_request) .await .expect_err("non-string request id") .contains("expected string")); } let mut numeric_decision = Map::new(); numeric_decision.insert("request_id".to_string(), json!("missing")); numeric_decision.insert("decision".to_string(), json!(42)); assert!(decide_handler(numeric_decision) .await .expect_err("numeric decision") .contains("expected string")); let mut invalid_decision = Map::new(); invalid_decision.insert("request_id".to_string(), json!("missing")); invalid_decision.insert("decision".to_string(), json!("maybe")); assert!(decide_handler(invalid_decision) .await .expect_err("invalid decision") .contains("approve_once|approve_always_for_tool|deny")); } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn approval_rpc_decision_paths_persist_always_allow_and_recent_audit() { let _lock = env_lock(); let harness = setup("").await; let config = Config::load_or_init() .await .expect("load config for approval gate"); let test_session_id = format!("session-{}", uuid::Uuid::new_v4()); let gate = ApprovalGate::init_global(config.clone(), test_session_id.clone()); let gate_for_task = gate.clone(); let approval_task = tokio::spawn(async move { // Scope a WebChat origin alongside the chat context — the gate now // requires an origin label or it fails closed on `Unknown`. turn_origin::with_origin( AgentTurnOrigin::WebChat { thread_id: "approval-raw-thread".to_string(), client_id: "approval-raw-client".to_string(), }, APPROVAL_CHAT_CONTEXT.scope( ApprovalChatContext { thread_id: "approval-raw-thread".to_string(), client_id: "approval-raw-client".to_string(), }, async move { gate_for_task .intercept_audited( "tools.composio_execute", "tools.composio_execute(action=execute, 123 bytes)", json!({ "action": "execute", "tool_slug": "GMAIL_SEND_EMAIL", "body": "" }), ) .await }, ), ) .await }); let deadline = Instant::now() + Duration::from_secs(5); let request_id = loop { let pending = rpc( &harness.rpc_base, 20, "openhuman.approval_list_pending", json!({}), ) .await; let rows = payload(&pending, "approval_list_pending") .as_array() .expect("pending rows"); if let Some(row) = rows.iter().find(|row| { row.get("tool_name").and_then(Value::as_str) == Some("tools.composio_execute") }) { break row .get("request_id") .and_then(Value::as_str) .expect("request id") .to_string(); } assert!(Instant::now() < deadline, "pending approval did not appear"); tokio::time::sleep(Duration::from_millis(25)).await; }; assert_eq!( gate.pending_for_thread("approval-raw-thread").as_deref(), Some(request_id.as_str()) ); let invalid = rpc( &harness.rpc_base, 21, "openhuman.approval_decide", json!({ "request_id": request_id, "decision": "maybe" }), ) .await; assert!(error_message(&invalid, "invalid decision").contains("invalid 'decision'")); let decide = rpc( &harness.rpc_base, 22, "openhuman.approval_decide", json!({ "request_id": request_id, "decision": "approve_always_for_tool" }), ) .await; assert_eq!( payload(&decide, "approval_decide") .get("tool_name") .and_then(Value::as_str), Some("tools.composio_execute") ); let (outcome, approved_id) = approval_task.await.expect("approval task"); assert!(matches!( outcome, openhuman_core::openhuman::approval::GateOutcome::Allow )); assert_eq!(approved_id.as_deref(), Some(request_id.as_str())); gate.record_execution( &request_id, ExecutionOutcome::Aborted, Some("aborted after approval"), ); gate.record_execution( "missing-gate-row", ExecutionOutcome::Failure, Some("missing row"), ); assert!(gate.pending_for_thread("approval-raw-thread").is_none()); let duplicate_decide = rpc( &harness.rpc_base, 23, "openhuman.approval_decide", json!({ "request_id": request_id, "decision": "deny" }), ) .await; assert!(error_message(&duplicate_decide, "duplicate decide").contains("no pending approval")); let recent = rpc( &harness.rpc_base, 24, "openhuman.approval_list_recent_decisions", json!({ "limit": 1 }), ) .await; let rows = payload(&recent, "approval_list_recent_decisions") .as_array() .expect("recent decisions"); assert_eq!(rows.len(), 1); assert_eq!( rows[0].get("decision").and_then(Value::as_str), Some("approve_always_for_tool") ); let config_after = Config::load_or_init() .await .expect("reload config after always allow"); assert!( config_after .autonomy .auto_approve .iter() .any(|tool| tool == "tools.composio_execute"), "approve_always_for_tool should persist an auto-approve entry" ); // Bare call with neither a chat context nor an AgentTurnOrigin scope: // the gate now treats this as `Unknown` and fails closed (refuses to // execute an external_effect tool from an unlabelled call site). The // earlier "non-chat ⇒ Allow" behaviour leaked trusted execution to any // caller that forgot to scope a label. let no_chat = gate .intercept_audited( "tools.web_search", "tools.web_search(query=coverage)", json!({ "query": "coverage" }), ) .await; match &no_chat.0 { openhuman_core::openhuman::approval::GateOutcome::Deny { reason } => { assert!( reason.contains("no origin label"), "unlabelled call should be denied for missing origin: {reason}" ); } other => panic!("expected Deny for unlabelled call, got {other:?}"), } assert_eq!( no_chat.1, None, "denied calls should not create approval rows" ); assert!(matches!( gate.intercept( "tools.web_search", "tools.web_search(query=legacy)", json!({ "query": "legacy" }), ) .await, GateOutcome::Deny { .. } )); // Always-allowed tools should bypass approval even when an origin is // scoped — the auto_approve allowlist short-circuit runs before the // origin branch. Install a live policy with the persisted entry so the // gate sees the latest auto_approve set (the gate's boot-time config // snapshot predates the approve_always_for_tool decision we just made). live_policy::install( Arc::new(SecurityPolicy { workspace_dir: config.workspace_dir.clone(), auto_approve: vec!["tools.composio_execute".to_string()], ..SecurityPolicy::default() }), config.workspace_dir.clone(), config.workspace_dir.clone(), ); let auto_approved = turn_origin::with_origin( AgentTurnOrigin::WebChat { thread_id: "approval-auto-thread".to_string(), client_id: "approval-auto-client".to_string(), }, gate.intercept_audited( "tools.composio_execute", "tools.composio_execute(action=execute)", json!({ "action": "execute" }), ), ) .await; assert!(matches!( auto_approved.0, openhuman_core::openhuman::approval::GateOutcome::Allow )); assert_eq!( auto_approved.1, None, "always-allowed tools should bypass persisted approvals" ); live_policy::install( Arc::new(SecurityPolicy { workspace_dir: config.workspace_dir.clone(), auto_approve: vec!["tools.live_policy_allowed".to_string()], ..SecurityPolicy::default() }), config.workspace_dir.clone(), config.workspace_dir.clone(), ); let live_policy_auto_approved = APPROVAL_CHAT_CONTEXT .scope( ApprovalChatContext { thread_id: "approval-live-policy-thread".to_string(), client_id: "approval-live-policy-client".to_string(), }, gate.intercept_audited( "tools.live_policy_allowed", "tools.live_policy_allowed(action=coverage)", json!({ "action": "coverage" }), ), ) .await; assert!(matches!(live_policy_auto_approved.0, GateOutcome::Allow)); assert_eq!(live_policy_auto_approved.1, None); assert!(gate .pending_for_thread("approval-live-policy-thread") .is_none()); let gate_for_deny_task = gate.clone(); let deny_task = tokio::spawn(async move { turn_origin::with_origin( AgentTurnOrigin::WebChat { thread_id: "approval-deny-thread".to_string(), client_id: "approval-deny-client".to_string(), }, APPROVAL_CHAT_CONTEXT.scope( ApprovalChatContext { thread_id: "approval-deny-thread".to_string(), client_id: "approval-deny-client".to_string(), }, async move { gate_for_deny_task .intercept_audited( "tools.web_search", "tools.web_search(query=deny)", json!({ "query": "deny" }), ) .await }, ), ) .await }); let deny_request_id = loop { let pending = rpc( &harness.rpc_base, 25, "openhuman.approval_list_pending", json!({}), ) .await; let rows = payload(&pending, "approval_list_pending deny") .as_array() .expect("pending rows for deny"); if let Some(row) = rows .iter() .find(|row| row.get("tool_name").and_then(Value::as_str) == Some("tools.web_search")) { break row .get("request_id") .and_then(Value::as_str) .expect("deny request id") .to_string(); } assert!( Instant::now() < deadline, "pending deny approval did not appear" ); tokio::time::sleep(Duration::from_millis(25)).await; }; let deny = rpc( &harness.rpc_base, 26, "openhuman.approval_decide", json!({ "request_id": deny_request_id, "decision": "deny" }), ) .await; assert_eq!( payload(&deny, "approval_decide deny") .get("request_id") .and_then(Value::as_str), Some(deny_request_id.as_str()) ); let (deny_outcome, deny_approved_id) = deny_task.await.expect("deny task"); match deny_outcome { openhuman_core::openhuman::approval::GateOutcome::Deny { reason } => { assert!(reason.contains("User denied")); } other => panic!("expected deny outcome, got {other:?}"), } assert_eq!(deny_approved_id, None); assert!(gate.pending_for_thread("approval-deny-thread").is_none()); assert!(gate.session_id().starts_with("session-")); let second_init = ApprovalGate::init_global(Config::default(), "session-ignored-second"); assert_eq!(second_init.session_id(), gate.session_id()); let approval_dir = config.workspace_dir.join("approval"); if approval_dir.exists() { std::fs::remove_dir_all(&approval_dir).expect("remove approval dir before failure branch"); } std::fs::write(&approval_dir, "not a directory").expect("replace approval dir with file"); gate.record_execution( &request_id, ExecutionOutcome::Success, Some("store path is blocked"), ); let list_failure = rpc( &harness.rpc_base, 27, "openhuman.approval_list_pending", json!({}), ) .await; assert!(list_failure.get("error").is_some()); let recent_failure = rpc( &harness.rpc_base, 28, "openhuman.approval_list_recent_decisions", json!({}), ) .await; assert!(recent_failure.get("error").is_some()); let decide_failure = rpc( &harness.rpc_base, 29, "openhuman.approval_decide", json!({ "request_id": "blocked-store", "decision": "deny" }), ) .await; assert!(decide_failure.get("error").is_some()); let persist_failure = turn_origin::with_origin( AgentTurnOrigin::WebChat { thread_id: "approval-persist-failure-thread".to_string(), client_id: "approval-persist-failure-client".to_string(), }, APPROVAL_CHAT_CONTEXT.scope( ApprovalChatContext { thread_id: "approval-persist-failure-thread".to_string(), client_id: "approval-persist-failure-client".to_string(), }, gate.intercept_audited( "tools.persistence_failure", "tools.persistence_failure(action=coverage)", json!({ "action": "coverage" }), ), ), ) .await; match persist_failure.0 { GateOutcome::Deny { reason } => { assert!(reason.contains("Approval gate could not persist the request")); } other => panic!("expected persistence failure deny, got {other:?}"), } assert_eq!(persist_failure.1, None); assert!(gate .pending_for_thread("approval-persist-failure-thread") .is_none()); harness.rpc_join.abort(); }