Files
openhuman/tests/tool_registry_approval_raw_coverage_e2e.rs
T

1572 lines
52 KiB
Rust

//! 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<Mutex<()>> = OnceLock::new();
struct EnvVarGuard {
key: &'static str,
old: Option<String>,
}
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<EnvVarGuard>,
rpc_base: String,
rpc_join: tokio::task::JoinHandle<Result<(), std::io::Error>>,
}
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<Result<(), std::io::Error>>,
) {
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::<Value>()
.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<chrono::DateTime<chrono::Utc>>,
) -> 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<_>>(),
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!("<redacted: string (35 chars)>"));
assert_eq!(redacted["recipients"], json!("<redacted: array (2 items)>"));
assert_eq!(
redacted.pointer("/metadata/Subject"),
Some(&json!("<redacted: string (20 chars)>"))
);
assert_eq!(
redacted.pointer("/metadata/attempts"),
Some(&json!(3)),
"non-sensitive numeric fields should pass through"
);
assert_eq!(
redacted.pointer("/metadata/auth"),
Some(&json!("<redacted: bool>"))
);
assert_eq!(
redacted.pointer("/metadata/message"),
Some(&json!("<redacted: number>"))
);
assert_eq!(redacted.pointer("/metadata/password"), Some(&Value::Null));
assert_eq!(
redacted.pointer("/metadata/user"),
Some(&json!("<redacted: object (2 keys)>"))
);
assert_eq!(
redacted.pointer("/metadata/safe_list/0"),
Some(&json!("open <HOME>/Desktop/report.txt"))
);
assert_eq!(
redacted.pointer("/metadata/safe_list/1/content"),
Some(&json!("<redacted: string (13 chars)>"))
);
assert_eq!(
redact_args(&json!(
"open /home/carol/report.md and C:\\Users\\dave\\x.txt"
)),
json!("open <HOME>/report.md and <HOME>\\x.txt")
);
assert_eq!(redact_args(&json!("/Users/erin")), json!("<HOME>"));
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::<Vec<_>>();
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<_>>(),
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": "<redacted: string (500 chars)>"
}),
)
.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();
}