From c7ece8db8a05c6d6a5b29a10fa5d926fb8e1e832 Mon Sep 17 00:00:00 2001 From: oxoxDev Date: Tue, 14 Jul 2026 13:27:34 +0530 Subject: [PATCH 01/12] feat(privacy): local-only egress enforcement helper + test override (#4441) --- src/openhuman/security/egress/enforce.rs | 189 ++++++++++++++++ .../security/egress/enforce_tests.rs | 206 ++++++++++++++++++ src/openhuman/security/egress/mod.rs | 2 + src/openhuman/security/live_policy.rs | 51 +++++ src/openhuman/security/mod.rs | 3 +- 5 files changed, 450 insertions(+), 1 deletion(-) create mode 100644 src/openhuman/security/egress/enforce.rs create mode 100644 src/openhuman/security/egress/enforce_tests.rs diff --git a/src/openhuman/security/egress/enforce.rs b/src/openhuman/security/egress/enforce.rs new file mode 100644 index 0000000000..c0f034eee1 --- /dev/null +++ b/src/openhuman/security/egress/enforce.rs @@ -0,0 +1,189 @@ +//! Local-only egress enforcement (privacy epic S7, #4441). +//! +//! S2 (#4436) made every external transfer *observable*: each egress point +//! builds an [`EgressDescriptor`] and hands it to +//! [`emit_external_transfer`](super::emit::emit_external_transfer). This module +//! makes the restrictive privacy mode (`LocalOnly`, S1 #4435) *enforceable* at +//! those same chokepoints. Before a site discloses-and-sends, it asks whether +//! the live policy permits the transfer and refuses it when not. +//! +//! ## Shape (mirrors the S1 inference block) +//! +//! Like `inference/provider/factory.rs`'s `local_only_violation` / +//! `enforce_local_only_inference` pair, enforcement is split into: +//! +//! - [`local_only_blocks`] — a **pure decision** over `(mode, descriptor)`, +//! unit-testable without the process-global live policy, and +//! - two thin side-effecting wrappers that read the live mode: +//! - [`enforce_egress`] for `anyhow`-returning egress sites (composio, +//! integrations, cloud embeddings) — `Err` with a clean, non-sensitive +//! message when blocked, mirroring the inference `bail!`. +//! - [`local_only_tool_block`] for agent tools whose `execute` returns +//! `Ok(ToolResult::error(..))` on a denied action (the network tools) — +//! returns the tool-facing, [`POLICY_BLOCKED_MARKER`]-prefixed message. +//! +//! [`current_privacy_mode`](crate::openhuman::security::live_policy::current_privacy_mode) +//! defaults to [`PrivacyMode::Standard`] (= no restriction) when no session +//! policy is installed (CLI / cron / background), so enforcement is a correct +//! no-op in those unmanaged contexts — exactly like the inference block. +//! +//! ## Control-plane exemption (do not brick sign-in) +//! +//! `LocalOnly` blocks the user's *data* from leaving the device — it must not +//! block the backend **control-plane** the app needs to stay usable (session / +//! auth / team / billing round-trips, plus the integration connection-management +//! and catalog reads that carry no user content). See [`is_control_plane`] for +//! the exact boundary and why it is drawn where it is. + +use super::types::{EgressDescriptor, EgressReason}; +use crate::openhuman::config::PrivacyMode; +use crate::openhuman::security::live_policy::current_privacy_mode; +use crate::openhuman::security::POLICY_BLOCKED_MARKER; + +/// Pure decision: under privacy `mode`, is the transfer described by `desc` +/// blocked? `true` only when the mode is [`PrivacyMode::LocalOnly`], the +/// transfer actually leaves the device (`desc.is_external`), and it is **not** +/// an exempt backend control-plane round-trip ([`is_control_plane`]). +/// +/// Every other mode (`Standard` / `Sensitive`) permits everything here — the +/// heightened-caution behaviours for `Sensitive` are approval/redaction slices, +/// not an egress block. Local runtimes (`desc.is_external == false`) are always +/// permitted: nothing leaves the device. +/// +/// Extracted as a pure fn so the truth table is unit-testable without touching +/// the process-global live policy. +pub fn local_only_blocks(mode: PrivacyMode, desc: &EgressDescriptor) -> bool { + mode == PrivacyMode::LocalOnly && desc.is_external && !is_control_plane(desc) +} + +/// Is `desc` a backend **control-plane** round-trip that must keep flowing even +/// under `LocalOnly` (blocking it would break sign-in / the Connections UI with +/// no privacy benefit, because it carries no user *content* — only auth tokens, +/// ids, and routing metadata)? +/// +/// Only [`EgressReason::Integration`] descriptors are ever eligible: inference, +/// composio tool calls, cloud embeddings, and agent network fetches all ship the +/// user's data and are never control-plane. +/// +/// For an integration transfer, the descriptor's `service` is the backend +/// endpoint path (query stripped by +/// [`emit_backend_egress`](crate::openhuman::integrations)). Two shapes are +/// exempt: +/// +/// 1. **Any path outside the user-data tool namespace** — i.e. it is *not* under +/// `/agent-integrations/`. Today every user-data integration call (composio +/// execute, parallel / tinyfish / financial-apis / google-places / twilio / +/// apify research + actions, file-storage uploads) routes through +/// `IntegrationClient` under `/agent-integrations/…`, while session / team / +/// billing / auth round-trips (`/teams/me/usage`, `/payments/…`, `/auth/…`) +/// go through `api::rest` and never build an egress descriptor at all. Any +/// such path only reaches here if a future caller re-homes a control-plane +/// call onto this descriptor — exempt it defensively so a re-home can never +/// brick login (per the epic's "do not block control-plane" directive). +/// +/// 2. **Integration connection-management / catalog / pricing sub-routes** that +/// set up or *describe* an integration without shipping user content: the +/// whole composio management surface (`connections`, `authorize`, `tools`, +/// `toolkits`, `triggers`) and `pricing`. The sole composio path that ships +/// the user's data — and therefore stays blocked — is `composio/execute`, +/// which carries the tool arguments. +/// +/// The boundary is deliberately conservative: when a path is plausibly +/// control-plane we exempt it (avoid breakage) and rely on the fact that the +/// genuine data-egress paths (`execute` and the non-composio tool namespaces) +/// are the ones that stay blocked. Widening the block to catalog reads is a +/// follow-up that needs its own UX review. +pub(crate) fn is_control_plane(desc: &EgressDescriptor) -> bool { + if desc.reason != EgressReason::Integration { + return false; + } + let path = desc.service.trim_start_matches('/'); + // (1) Not under the user-data tool namespace → control-plane. + let Some(subroute) = path.strip_prefix("agent-integrations/") else { + return true; + }; + // (2) Pricing metadata carries no user content. + if subroute == "pricing" { + return true; + } + // (3) Composio management/catalog/OAuth is control-plane; `execute` (which + // ships the tool arguments) is the one user-data path and stays blocked. + if let Some(rest) = subroute.strip_prefix("composio/") { + return rest != "execute" && !rest.starts_with("execute/"); + } + // Everything else under /agent-integrations/ ships user data → not exempt. + false +} + +/// Human-readable, non-sensitive reason the transfer was blocked. Names only the +/// destination *service* (already a coarse slug / endpoint / host on the +/// descriptor — never raw payload) and how to lift the block. No marker; callers +/// that need the [`POLICY_BLOCKED_MARKER`] prefix add it (see +/// [`local_only_tool_block`]). +fn block_message(desc: &EgressDescriptor) -> String { + format!( + "Local-only privacy mode is active: this action needs external service \ + `{}`. Disable local-only mode in Settings to allow it.", + desc.service + ) +} + +/// Enforce the live privacy policy for an `anyhow`-returning egress site +/// (composio tool calls, backend integrations, cloud embeddings). Reads the live +/// mode (defaulting to `Standard`/allow when no session policy is installed) and +/// returns `Err(block_message)` when [`local_only_blocks`] refuses the transfer, +/// else `Ok(())`. Call this **before** the disclose-and-send (i.e. before +/// [`emit_external_transfer`](super::emit::emit_external_transfer)) so a blocked +/// transfer is neither disclosed as pending nor dispatched. +pub fn enforce_egress(desc: &EgressDescriptor) -> anyhow::Result<()> { + let mode = current_privacy_mode(); + if local_only_blocks(mode, desc) { + log::warn!( + "[privacy][egress-enforce] LocalOnly BLOCK provider={} service={} reason={:?} — refused", + desc.provider_slug, + desc.service, + desc.reason, + ); + anyhow::bail!("{}", block_message(desc)); + } + log::debug!( + "[privacy][egress-enforce] privacy_mode={:?} provider={} service={} reason={:?} — permitted", + mode, + desc.provider_slug, + desc.service, + desc.reason, + ); + Ok(()) +} + +/// Enforce the live privacy policy for an agent tool whose `execute` returns +/// `Ok(ToolResult::error(..))` on a denied action (the network tools). Returns +/// `Some(message)` — prefixed with [`POLICY_BLOCKED_MARKER`] so the agent loop +/// treats it as a hard, cross-turn policy block (no pointless retries) — when the +/// transfer is refused, else `None`. Call it before the disclose-and-send; on +/// `Some`, short-circuit with `Ok(ToolResult::error(message))`. +pub fn local_only_tool_block(desc: &EgressDescriptor) -> Option { + let mode = current_privacy_mode(); + if local_only_blocks(mode, desc) { + log::warn!( + "[privacy][egress-enforce] LocalOnly BLOCK (tool) provider={} service={} reason={:?} — refused", + desc.provider_slug, + desc.service, + desc.reason, + ); + Some(format!("{POLICY_BLOCKED_MARKER} {}", block_message(desc))) + } else { + log::debug!( + "[privacy][egress-enforce] privacy_mode={:?} (tool) provider={} service={} reason={:?} — permitted", + mode, + desc.provider_slug, + desc.service, + desc.reason, + ); + None + } +} + +#[cfg(test)] +#[path = "enforce_tests.rs"] +mod tests; diff --git a/src/openhuman/security/egress/enforce_tests.rs b/src/openhuman/security/egress/enforce_tests.rs new file mode 100644 index 0000000000..e1dc192ab6 --- /dev/null +++ b/src/openhuman/security/egress/enforce_tests.rs @@ -0,0 +1,206 @@ +//! Tests for local-only egress enforcement (privacy epic S7, #4441). +//! +//! The pure [`local_only_blocks`] / [`is_control_plane`] truth tables need no +//! process-global state. The two side-effecting wrappers ([`enforce_egress`], +//! [`local_only_tool_block`]) read the live policy, so those tests install a +//! LocalOnly / Standard policy under [`TEST_ENV_LOCK`] (shared with the other +//! `live_policy`-touching tests so installs never race) and restore Standard on +//! the way out. + +use super::*; +use crate::openhuman::config::PrivacyMode; +use crate::openhuman::security::egress::{DataKind, EgressDescriptor, EgressReason}; + +// ── Pure decision: local_only_blocks truth table ────────────────────────── + +fn external_composio() -> EgressDescriptor { + EgressDescriptor::composio("GITHUB_CREATE_ISSUE") +} + +#[test] +fn local_only_blocks_external_transfer() { + // LocalOnly + external user-data transfer → blocked. + assert!(local_only_blocks( + PrivacyMode::LocalOnly, + &external_composio() + )); +} + +#[test] +fn local_only_blocks_each_external_surface() { + // AC7: the block applies across every egress surface's user-data descriptor. + let surfaces = [ + EgressDescriptor::inference("openai", "gpt-4o", true), + EgressDescriptor::composio("SLACK_SEND_MESSAGE"), + EgressDescriptor::embedding("cloud", "text-embedding-3-small"), + EgressDescriptor::network_fetch("api.example.com"), + EgressDescriptor::integration("/agent-integrations/parallel/research"), + ]; + for desc in &surfaces { + assert!( + local_only_blocks(PrivacyMode::LocalOnly, desc), + "surface {:?}/{} must block under LocalOnly", + desc.reason, + desc.service + ); + assert!( + !local_only_blocks(PrivacyMode::Standard, desc), + "surface {:?}/{} must be allowed under Standard", + desc.reason, + desc.service + ); + } +} + +#[test] +fn standard_mode_allows_everything() { + // Standard / Sensitive never block here. + assert!(!local_only_blocks( + PrivacyMode::Standard, + &external_composio() + )); + assert!(!local_only_blocks( + PrivacyMode::Sensitive, + &external_composio() + )); +} + +#[test] +fn local_only_allows_local_runtime() { + // A non-external transfer (local runtime — Ollama/LM Studio/etc.) is never + // blocked: nothing leaves the device. + let local = EgressDescriptor::inference("ollama", "llama3", false); + assert!(!local.is_external); + assert!(!local_only_blocks(PrivacyMode::LocalOnly, &local)); +} + +#[test] +fn local_only_allows_control_plane_integration() { + // LocalOnly + an exempt control-plane integration path → allowed. + let pricing = EgressDescriptor::integration("/agent-integrations/pricing"); + assert!(!local_only_blocks(PrivacyMode::LocalOnly, &pricing)); +} + +#[test] +fn local_only_blocks_user_data_integration() { + // LocalOnly + a user-data integration path → blocked. + let execute = EgressDescriptor::integration("/agent-integrations/composio/execute"); + assert!(local_only_blocks(PrivacyMode::LocalOnly, &execute)); +} + +// ── Pure decision: is_control_plane boundary ────────────────────────────── + +#[test] +fn control_plane_exempts_non_tool_namespace() { + // A control-plane call re-homed onto an integration descriptor (defensive + // path (1)): anything not under /agent-integrations/ is exempt. + for path in [ + "/teams/me/usage", + "/payments/stripe/currentPlan", + "/auth/refresh", + ] { + assert!( + is_control_plane(&EgressDescriptor::integration(path)), + "{path} must be treated as control-plane" + ); + } +} + +#[test] +fn control_plane_exempts_composio_management_and_pricing() { + // Connection-management / catalog / OAuth / pricing carry no user content. + for path in [ + "/agent-integrations/pricing", + "/agent-integrations/composio/connections", + "/agent-integrations/composio/authorize", + "/agent-integrations/composio/tools", + "/agent-integrations/composio/toolkits", + "/agent-integrations/composio/triggers", + "/agent-integrations/composio/triggers/available", + ] { + assert!( + is_control_plane(&EgressDescriptor::integration(path)), + "{path} must be exempt (control-plane)" + ); + } +} + +#[test] +fn control_plane_does_not_exempt_user_data_paths() { + // composio/execute ships tool arguments; the non-composio tool namespaces + // ship queries / content — none are exempt. + for path in [ + "/agent-integrations/composio/execute", + "/agent-integrations/parallel/research", + "/agent-integrations/tinyfish/fetch", + "/agent-integrations/twilio/call", + "/agent-integrations/file-storage/files", + "/agent-integrations/google-places/search", + ] { + assert!( + !is_control_plane(&EgressDescriptor::integration(path)), + "{path} must NOT be exempt (ships user data)" + ); + } +} + +#[test] +fn control_plane_only_applies_to_integration_reason() { + // A network fetch whose host happens to spell a backend path is still a + // user-data transfer, never control-plane. + let net = EgressDescriptor::network_fetch("agent-integrations"); + assert_eq!(net.reason, EgressReason::NetworkFetch); + assert!(!is_control_plane(&net)); +} + +// ── Side-effecting wrappers (read the live policy) ──────────────────────── +// +// These use the thread-scoped `test_privacy_scope` override rather than +// installing into the process-global policy, so they never race sibling tests +// that read `current_privacy_mode` on other threads (see `live_policy`). + +use crate::openhuman::security::live_policy::test_privacy_scope; + +#[test] +fn enforce_egress_blocks_under_local_only_and_allows_otherwise() { + { + let _mode = test_privacy_scope(PrivacyMode::LocalOnly); + // A user-data transfer is refused with a clean, service-naming message. + let err = enforce_egress(&external_composio()).expect_err("must block under LocalOnly"); + let msg = err.to_string(); + assert!(msg.contains("Local-only privacy mode is active"), "{msg}"); + assert!( + msg.contains("GITHUB_CREATE_ISSUE"), + "names the service: {msg}" + ); + // A control-plane integration round-trip still flows. + enforce_egress(&EgressDescriptor::integration( + "/agent-integrations/composio/connections", + )) + .expect("control-plane must be allowed under LocalOnly"); + } + + // Standard mode permits the same user-data transfer. + let _mode = test_privacy_scope(PrivacyMode::Standard); + enforce_egress(&external_composio()).expect("Standard mode must allow"); +} + +#[test] +fn local_only_tool_block_marks_message_and_clears_when_allowed() { + let mut desc = EgressDescriptor::network_fetch("api.example.com"); + desc = desc.with_data_kind(DataKind::ToolArguments); + + { + let _mode = test_privacy_scope(PrivacyMode::LocalOnly); + let msg = local_only_tool_block(&desc).expect("network fetch blocked under LocalOnly"); + assert!( + msg.starts_with(crate::openhuman::security::POLICY_BLOCKED_MARKER), + "tool block must carry the policy-blocked marker: {msg}" + ); + assert!(msg.contains("api.example.com"), "names the host: {msg}"); + } + + // Standard mode → no block. + let _mode = test_privacy_scope(PrivacyMode::Standard); + assert!(local_only_tool_block(&desc).is_none()); +} diff --git a/src/openhuman/security/egress/mod.rs b/src/openhuman/security/egress/mod.rs index 1975bb069c..47d7d61aa8 100644 --- a/src/openhuman/security/egress/mod.rs +++ b/src/openhuman/security/egress/mod.rs @@ -8,7 +8,9 @@ //! approval S4, identification-risk detector S5, enforcement S7). pub mod emit; +pub mod enforce; pub mod types; pub use emit::{dedup_turn_scope, emit_external_transfer}; +pub use enforce::{enforce_egress, local_only_blocks, local_only_tool_block}; pub use types::{DataKind, EgressDescriptor, EgressReason, IdentificationRisk}; diff --git a/src/openhuman/security/live_policy.rs b/src/openhuman/security/live_policy.rs index 330e7b9126..a163021efe 100644 --- a/src/openhuman/security/live_policy.rs +++ b/src/openhuman/security/live_policy.rs @@ -72,11 +72,55 @@ pub fn install( policy } +#[cfg(test)] +thread_local! { + /// Test-only, **thread-scoped** [`PrivacyMode`] override. When set it wins + /// over the process-global policy in [`current_privacy_mode`]. + /// + /// The egress-enforcement gate (privacy epic S7, #4441) reads + /// [`current_privacy_mode`] on every integration / network / composio / + /// embedding call. The process-global live policy is shared across the whole + /// test binary, so a test that installed `LocalOnly` into it would flake any + /// *other* parallel test whose tool happens to read the mode during that + /// window. This thread-local lets a single test exercise the gate under a + /// specific mode WITHOUT mutating the shared global — `#[tokio::test]` runs on + /// a current-thread runtime, so the tool's inline read observes the override + /// on the same thread while sibling tests on their own threads are unaffected. + static TEST_PRIVACY_MODE: std::cell::Cell> = + const { std::cell::Cell::new(None) }; +} + +/// RAII guard that restores the previous thread-local privacy override on drop. +#[cfg(test)] +pub(crate) struct TestPrivacyGuard(Option); + +#[cfg(test)] +impl Drop for TestPrivacyGuard { + fn drop(&mut self) { + TEST_PRIVACY_MODE.with(|c| c.set(self.0)); + } +} + +/// Override [`current_privacy_mode`] for the current thread until the returned +/// guard drops. Test-only; needs no `TEST_ENV_LOCK` because it never touches the +/// process-global policy. +#[cfg(test)] +pub(crate) fn test_privacy_scope(mode: PrivacyMode) -> TestPrivacyGuard { + let prev = TEST_PRIVACY_MODE.with(|c| c.replace(Some(mode))); + TestPrivacyGuard(prev) +} + /// The current live Privacy Mode, if a policy has been [`install`]ed. Falls back /// to [`PrivacyMode::Standard`] when no policy is installed (e.g. a CLI /// invocation that never started a session runtime) — i.e. no egress /// restriction by default. pub fn current_privacy_mode() -> PrivacyMode { + // Test-only per-thread override (see `TEST_PRIVACY_MODE`) wins so a test can + // drive the egress gate without mutating the shared process-global policy. + #[cfg(test)] + if let Some(mode) = TEST_PRIVACY_MODE.with(|c| c.get()) { + return mode; + } current().map(|p| p.privacy_mode).unwrap_or_default() } @@ -366,6 +410,13 @@ mod tests { PrivacyMode::LocalOnly, "privacy mode must survive an autonomy-only reload" ); + + // Restore Standard before releasing the lock. The live policy is + // process-global; since S7 (#4441) the egress-enforcement gate reads + // `current_privacy_mode()` on every integration/network/composio/ + // embedding call, so leaving LocalOnly installed here would block sibling + // mock-backend tests (which do not take TEST_ENV_LOCK) with a policy error. + reload_privacy(PrivacyMode::Standard).expect("restore Standard"); } #[test] diff --git a/src/openhuman/security/mod.rs b/src/openhuman/security/mod.rs index 4c8bc251a1..36b772ccb1 100644 --- a/src/openhuman/security/mod.rs +++ b/src/openhuman/security/mod.rs @@ -29,7 +29,8 @@ pub use core::*; pub use detect::create_sandbox; #[allow(unused_imports)] pub use egress::{ - emit_external_transfer, DataKind, EgressDescriptor, EgressReason, IdentificationRisk, + emit_external_transfer, enforce_egress, local_only_blocks, local_only_tool_block, DataKind, + EgressDescriptor, EgressReason, IdentificationRisk, }; pub use ops as rpc; pub use ops::*; From a7972a17d348d162726381eff95940f5506a468c Mon Sep 17 00:00:00 2001 From: oxoxDev Date: Tue, 14 Jul 2026 13:27:44 +0530 Subject: [PATCH 02/12] feat(privacy): enforce local-only on composio tool calls (#4441) --- src/openhuman/composio/client.rs | 16 +++++++---- src/openhuman/composio/client_tests.rs | 39 ++++++++++++++++++++++++++ 2 files changed, 49 insertions(+), 6 deletions(-) diff --git a/src/openhuman/composio/client.rs b/src/openhuman/composio/client.rs index c2a9fd4472..8ea1a2a492 100644 --- a/src/openhuman/composio/client.rs +++ b/src/openhuman/composio/client.rs @@ -194,9 +194,11 @@ impl ComposioClient { // Egress spine (privacy epic S2, #4436): a Composio tool call ships the // (already-normalized) arguments to the third-party provider — disclose // the transfer before the round-trip. S4 will add an approval arm here. - crate::openhuman::security::egress::emit_external_transfer( - crate::openhuman::security::egress::EgressDescriptor::composio(tool), - ); + let egress = crate::openhuman::security::egress::EgressDescriptor::composio(tool); + // Local-only enforcement (privacy epic S7, #4441): refuse the external + // tool call under LocalOnly BEFORE disclosing or sending it. + crate::openhuman::security::egress::enforce_egress(&egress)?; + crate::openhuman::security::egress::emit_external_transfer(egress); // PR #1827 routes all execute-side argument normalization // (including the bare-date → RFC 3339 fix #1802 brought to // `normalize_calendar_query_args` on `main`) through the @@ -237,9 +239,11 @@ impl ComposioClient { // Egress spine (privacy epic S2, #4436): see `execute_tool`. This is the // caller-owns-retry entry point (e.g. `auth_retry`), disjoint from // `execute_tool`, so each logical tool call emits exactly once. - crate::openhuman::security::egress::emit_external_transfer( - crate::openhuman::security::egress::EgressDescriptor::composio(tool), - ); + let egress = crate::openhuman::security::egress::EgressDescriptor::composio(tool); + // Local-only enforcement (privacy epic S7, #4441): same gate as + // `execute_tool` — this disjoint entry point must block too. + crate::openhuman::security::egress::enforce_egress(&egress)?; + crate::openhuman::security::egress::emit_external_transfer(egress); let arguments = super::execute_prepare::prepare_execute_arguments(tool, arguments) .map_err(anyhow::Error::msg)?; tracing::debug!(tool = %tool, "[composio] execute_tool_once (no built-in retry)"); diff --git a/src/openhuman/composio/client_tests.rs b/src/openhuman/composio/client_tests.rs index 87cb6c2ac9..60128ef890 100644 --- a/src/openhuman/composio/client_tests.rs +++ b/src/openhuman/composio/client_tests.rs @@ -48,6 +48,45 @@ async fn authorize_rejects_empty_toolkit() { ); } +/// Privacy epic S7 (#4441): under LocalOnly, both execute entry points refuse +/// the external tool call before any HTTP round-trip. The inner client points at +/// an unreachable address, so a passing test proves the gate short-circuits +/// before the network (a leaked call would fail with a transport error, not the +/// local-only message). +#[tokio::test] +async fn execute_tool_blocked_under_local_only() { + // Thread-scoped LocalOnly (see `live_policy::test_privacy_scope`) — never + // mutates the process-global policy, so it can't race sibling mock tests. + let _mode = crate::openhuman::security::live_policy::test_privacy_scope( + crate::openhuman::config::PrivacyMode::LocalOnly, + ); + let inner = Arc::new(crate::openhuman::integrations::IntegrationClient::new( + "http://127.0.0.1:0".into(), + "test".into(), + )); + let client = ComposioClient::new(inner); + + let err = client + .execute_tool("GITHUB_CREATE_ISSUE", None) + .await + .unwrap_err(); + assert!( + err.to_string() + .contains("Local-only privacy mode is active"), + "execute_tool: unexpected error: {err}" + ); + let err_once = client + .execute_tool_once("GITHUB_CREATE_ISSUE", None) + .await + .unwrap_err(); + assert!( + err_once + .to_string() + .contains("Local-only privacy mode is active"), + "execute_tool_once: unexpected error: {err_once}" + ); +} + /// `authorize()` must reject a non-object `extra_params` before making any HTTP call. #[tokio::test] async fn authorize_rejects_non_object_extra_params() { From 12d711da1da1e731694b3a65cde566e2a2ef5b72 Mon Sep 17 00:00:00 2001 From: oxoxDev Date: Tue, 14 Jul 2026 13:27:44 +0530 Subject: [PATCH 03/12] feat(privacy): enforce local-only on backend integrations with control-plane exemption (#4441) --- src/openhuman/integrations/client.rs | 39 +++++++++++++++++---- src/openhuman/integrations/client_tests.rs | 40 ++++++++++++++++++++++ 2 files changed, 72 insertions(+), 7 deletions(-) diff --git a/src/openhuman/integrations/client.rs b/src/openhuman/integrations/client.rs index 984771b24a..b3dd0a0a11 100644 --- a/src/openhuman/integrations/client.rs +++ b/src/openhuman/integrations/client.rs @@ -240,15 +240,30 @@ fn parse_content_disposition_filename(value: &str) -> Option { None } +/// Build the egress descriptor for a managed-backend round-trip. The query +/// string is stripped so only the endpoint (the "to where") is described, never +/// any data carried in the query. +fn backend_egress_descriptor(path: &str) -> crate::openhuman::security::egress::EgressDescriptor { + let endpoint = path.split('?').next().unwrap_or(path); + crate::openhuman::security::egress::EgressDescriptor::integration(endpoint) +} + /// Egress spine (privacy epic S2, #4436): disclose an OpenHuman managed-backend -/// round-trip before it leaves the device. The query string is stripped so only -/// the endpoint (the "to where") is disclosed, never any data carried in the -/// query. Fire-and-forget — never fails the caller. +/// round-trip before it leaves the device. Fire-and-forget — never fails the +/// caller. fn emit_backend_egress(path: &str) { - let endpoint = path.split('?').next().unwrap_or(path); - crate::openhuman::security::egress::emit_external_transfer( - crate::openhuman::security::egress::EgressDescriptor::integration(endpoint), - ); + crate::openhuman::security::egress::emit_external_transfer(backend_egress_descriptor(path)); +} + +/// Local-only enforcement (privacy epic S7, #4441): refuse a managed-backend +/// round-trip that ships user data when the live policy is `LocalOnly`. Returns +/// `Ok(())` for control-plane paths (session / team / billing / integration +/// connection-management + catalog) even under `LocalOnly` — blocking those +/// would break sign-in and the Connections UI for no privacy benefit. See +/// [`is_control_plane`](crate::openhuman::security::egress). Call before +/// [`emit_backend_egress`] so a blocked call is neither disclosed nor sent. +fn enforce_backend_egress(path: &str) -> anyhow::Result<()> { + crate::openhuman::security::egress::enforce_egress(&backend_egress_descriptor(path)) } /// Shared client for all integration tools. Holds backend URL, auth token, @@ -331,6 +346,7 @@ impl IntegrationClient { path: &str, body: &serde_json::Value, ) -> anyhow::Result { + enforce_backend_egress(path)?; emit_backend_egress(path); self.ensure_budget_available(path).await?; let url = crate::api::config::api_url(&self.backend_url, path); @@ -436,6 +452,7 @@ impl IntegrationClient { /// GET from a backend endpoint and parse the response `data` field. pub async fn get(&self, path: &str) -> anyhow::Result { + enforce_backend_egress(path)?; emit_backend_egress(path); self.ensure_budget_available(path).await?; let url = crate::api::config::api_url(&self.backend_url, path); @@ -611,6 +628,8 @@ impl IntegrationClient { path: &str, body: &serde_json::Value, ) -> anyhow::Result { + enforce_backend_egress(path)?; + emit_backend_egress(path); self.ensure_budget_available(path).await?; let url = crate::api::config::api_url(&self.backend_url, path); tracing::debug!("[integrations] PATCH {}", url); @@ -632,6 +651,8 @@ impl IntegrationClient { /// Mirrors [`Self::post`] (auth header, 401 → session-expiry, error /// classification). pub async fn delete(&self, path: &str) -> anyhow::Result { + enforce_backend_egress(path)?; + emit_backend_egress(path); self.ensure_budget_available(path).await?; let url = crate::api::config::api_url(&self.backend_url, path); tracing::debug!("[integrations] DELETE {}", url); @@ -656,6 +677,8 @@ impl IntegrationClient { path: &str, form: reqwest::multipart::Form, ) -> anyhow::Result { + enforce_backend_egress(path)?; + emit_backend_egress(path); self.ensure_budget_available(path).await?; let url = crate::api::config::api_url(&self.backend_url, path); tracing::debug!("[integrations] POST(multipart) {}", url); @@ -682,6 +705,8 @@ impl IntegrationClient { &self, path: &str, ) -> anyhow::Result<(bytes::Bytes, Option, Option)> { + enforce_backend_egress(path)?; + emit_backend_egress(path); self.ensure_budget_available(path).await?; let url = crate::api::config::api_url(&self.backend_url, path); tracing::debug!("[integrations] GET(bytes) {}", url); diff --git a/src/openhuman/integrations/client_tests.rs b/src/openhuman/integrations/client_tests.rs index ce096a6455..aeb0649155 100644 --- a/src/openhuman/integrations/client_tests.rs +++ b/src/openhuman/integrations/client_tests.rs @@ -101,6 +101,46 @@ fn managed_budget_gate_applies_to_agent_integration_paths() { assert!(!managed_budget_applies_to_path("/teams/me/usage")); } +// ── Unit: local-only egress enforcement (privacy epic S7, #4441) ── + +#[test] +fn backend_egress_descriptor_strips_query_and_targets_backend() { + let desc = backend_egress_descriptor("/agent-integrations/composio/execute?foo=bar"); + assert_eq!( + desc.reason, + crate::openhuman::security::EgressReason::Integration + ); + assert_eq!(desc.provider_slug, "openhuman_backend"); + // Query stripped — only the endpoint is disclosed, never carried data. + assert_eq!(desc.service, "/agent-integrations/composio/execute"); + assert!(desc.is_external); +} + +#[test] +fn enforce_backend_egress_blocks_user_data_but_allows_control_plane() { + use crate::openhuman::config::PrivacyMode; + use crate::openhuman::security::live_policy::test_privacy_scope; + { + // Thread-scoped LocalOnly — no process-global mutation, no cross-test race. + let _mode = test_privacy_scope(PrivacyMode::LocalOnly); + // User-data tool path → refused. + let blocked = enforce_backend_egress("/agent-integrations/composio/execute"); + assert!(blocked.is_err()); + assert!(blocked + .unwrap_err() + .to_string() + .contains("Local-only privacy mode is active")); + // Control-plane (connection-management + pricing) → allowed even under LocalOnly. + enforce_backend_egress("/agent-integrations/composio/connections") + .expect("connections is control-plane"); + enforce_backend_egress("/agent-integrations/pricing").expect("pricing is control-plane"); + } + + // Standard mode → everything allowed. + let _mode = test_privacy_scope(PrivacyMode::Standard); + enforce_backend_egress("/agent-integrations/composio/execute").expect("Standard allows"); +} + // ── Integration: HTTP error propagation through `post`/`get` ────── async fn start_mock_backend(app: Router) -> String { From cf6ac88efed48dafec5d33236701009ccf843182 Mon Sep 17 00:00:00 2001 From: oxoxDev Date: Tue, 14 Jul 2026 13:27:44 +0530 Subject: [PATCH 04/12] feat(privacy): enforce local-only on cloud embeddings (#4441) --- src/openhuman/embeddings/cloud_adapter.rs | 42 ++++++++++++++++++++--- 1 file changed, 37 insertions(+), 5 deletions(-) diff --git a/src/openhuman/embeddings/cloud_adapter.rs b/src/openhuman/embeddings/cloud_adapter.rs index bf827361b0..adecab5276 100644 --- a/src/openhuman/embeddings/cloud_adapter.rs +++ b/src/openhuman/embeddings/cloud_adapter.rs @@ -85,12 +85,44 @@ impl EmbeddingProvider for OpenHumanCloudEmbedding { async fn embed(&self, texts: &[&str]) -> anyhow::Result>> { // Egress spine (privacy epic S2, #4436): the input texts leave the // device for the cloud embedding backend — disclose before the request. - crate::openhuman::security::egress::emit_external_transfer( - crate::openhuman::security::egress::EgressDescriptor::embedding( - "cloud", - self.inner.model_id(), - ), + let egress = crate::openhuman::security::egress::EgressDescriptor::embedding( + "cloud", + self.inner.model_id(), ); + // Local-only enforcement (privacy epic S7, #4441): refuse cloud + // embedding under LocalOnly before disclosing or sending the texts. + crate::openhuman::security::egress::enforce_egress(&egress)?; + crate::openhuman::security::egress::emit_external_transfer(egress); self.inner.embed(texts).await } } + +#[cfg(test)] +mod tests { + use super::*; + + /// Privacy epic S7 (#4441): under LocalOnly, `embed` refuses before touching + /// the inner cloud transport (so no network / no auth is needed to observe + /// the block). Uses the thread-scoped privacy override so it never mutates + /// the process-global policy that sibling tests read. + #[tokio::test] + async fn embed_blocked_under_local_only() { + let _mode = crate::openhuman::security::live_policy::test_privacy_scope( + crate::openhuman::config::PrivacyMode::LocalOnly, + ); + let provider = OpenHumanCloudEmbedding::new( + Some("http://127.0.0.1:0".into()), + Some(std::env::temp_dir().join("openhuman_embeddings_localonly_state")), + false, + DEFAULT_CLOUD_EMBEDDING_MODEL, + DEFAULT_CLOUD_EMBEDDING_DIMENSIONS, + ); + + let err = provider.embed(&["hello"]).await.unwrap_err(); + assert!( + err.to_string() + .contains("Local-only privacy mode is active"), + "unexpected error: {err}" + ); + } +} From 97a2904c35a0d64a14e3bea4cec2911510cdb1ac Mon Sep 17 00:00:00 2001 From: oxoxDev Date: Tue, 14 Jul 2026 13:27:54 +0530 Subject: [PATCH 05/12] feat(privacy): enforce local-only on network tools (http_request/web_fetch/curl/polymarket) (#4441) --- src/openhuman/tools/impl/network/curl.rs | 62 +++++++++++++++++++ .../tools/impl/network/http_request.rs | 17 +++++ .../tools/impl/network/http_request_tests.rs | 28 +++++++++ src/openhuman/tools/impl/network/mod.rs | 13 ++++ .../tools/impl/network/polymarket.rs | 24 +++++++ .../tools/impl/network/polymarket_tests.rs | 31 ++++++++++ src/openhuman/tools/impl/network/web_fetch.rs | 38 ++++++++++++ 7 files changed, 213 insertions(+) diff --git a/src/openhuman/tools/impl/network/curl.rs b/src/openhuman/tools/impl/network/curl.rs index 6701150843..5ac0fe88e2 100644 --- a/src/openhuman/tools/impl/network/curl.rs +++ b/src/openhuman/tools/impl/network/curl.rs @@ -179,6 +179,22 @@ impl Tool for CurlTool { return Ok(ToolResult::error("Action blocked: rate limit exceeded")); } + // Local-only enforcement (privacy epic S7, #4441): mirror the read-only + // `can_act()` deny above — under LocalOnly, refuse the download before + // URL validation / DNS so nothing leaves the device. + { + let host = reqwest::Url::parse(url) + .ok() + .and_then(|u| u.host_str().map(str::to_string)) + .unwrap_or_else(|| "unknown".to_string()); + if let Some(msg) = crate::openhuman::security::egress::local_only_tool_block( + &crate::openhuman::security::egress::EgressDescriptor::network_fetch(host), + ) { + tracing::debug!(target: "[curl]", url = %url, "blocked: local-only privacy mode"); + return Ok(ToolResult::error(msg)); + } + } + let url = match self.validate_url(url).await { Ok(v) => v, Err(e) => { @@ -187,6 +203,28 @@ impl Tool for CurlTool { } }; + // Egress spine (privacy epic S2/S7, #4436/#4441): a curl download + // contacts an external host — disclose the destination (and that custom + // headers ride along) before the request. Enforcement already ran at the + // top of `execute`; this is the observe-only disclosure for permitted + // downloads. + { + use crate::openhuman::security::egress::{DataKind, EgressDescriptor}; + let host = reqwest::Url::parse(&url) + .ok() + .and_then(|u| u.host_str().map(str::to_string)) + .unwrap_or_else(|| "unknown".to_string()); + let has_headers = headers_val + .as_object() + .map(|h| !h.is_empty()) + .unwrap_or(false); + let mut desc = EgressDescriptor::network_fetch(host); + if has_headers { + desc = desc.with_data_kind(DataKind::Metadata); + } + crate::openhuman::security::egress::emit_external_transfer(desc); + } + let dest = match dest_arg { Some(d) => d.to_string(), None => Self::default_filename_from_url(&url), @@ -534,6 +572,30 @@ mod tests { assert!(result.output().contains("rate limit")); } + #[tokio::test] + async fn execute_blocked_under_local_only_privacy_mode() { + // Privacy epic S7 (#4441): under LocalOnly the download is refused with a + // `[policy-blocked]` result before URL validation / network. + let _mode = super::super::local_only_scope(); + let tmp = TempDir::new().unwrap(); + let t = tool(&tmp, vec!["example.com"]); + let result = t + .execute(serde_json::json!({"url": "https://example.com/x"})) + .await + .unwrap(); + assert!(result.is_error); + assert!( + result.output().contains("[policy-blocked]"), + "got: {}", + result.output() + ); + assert!( + result.output().contains("Local-only"), + "got: {}", + result.output() + ); + } + /// Live integration smoke: downloads example.com (a tiny, stable /// public page). Gated behind `OPENHUMAN_CURL_LIVE_TEST=1` so CI / /// offline runs don't depend on the network. diff --git a/src/openhuman/tools/impl/network/http_request.rs b/src/openhuman/tools/impl/network/http_request.rs index f133103ff6..8684bb58c3 100644 --- a/src/openhuman/tools/impl/network/http_request.rs +++ b/src/openhuman/tools/impl/network/http_request.rs @@ -379,6 +379,23 @@ impl Tool for HttpRequestTool { return Ok(ToolResult::error("Action blocked: rate limit exceeded")); } + // Local-only enforcement (privacy epic S7, #4441): mirror the read-only + // `can_act()` deny above — under LocalOnly, refuse the outbound request + // before URL validation / DNS so nothing (not even a DNS lookup for the + // host) leaves the device. The post-validation `emit_external_transfer` + // below stays the S2 disclosure point for permitted requests. + { + let host = reqwest::Url::parse(url) + .ok() + .and_then(|u| u.host_str().map(str::to_string)) + .unwrap_or_else(|| "unknown".to_string()); + if let Some(msg) = crate::openhuman::security::egress::local_only_tool_block( + &crate::openhuman::security::egress::EgressDescriptor::network_fetch(host), + ) { + return Ok(ToolResult::error(msg)); + } + } + let url = match self.validate_url(url).await { Ok(v) => v, Err(e) => return Ok(ToolResult::error(e.to_string())), diff --git a/src/openhuman/tools/impl/network/http_request_tests.rs b/src/openhuman/tools/impl/network/http_request_tests.rs index 8055055add..a82090338c 100644 --- a/src/openhuman/tools/impl/network/http_request_tests.rs +++ b/src/openhuman/tools/impl/network/http_request_tests.rs @@ -102,6 +102,34 @@ async fn execute_blocks_when_rate_limited() { assert!(result.output().contains("rate limit")); } +#[tokio::test] +async fn execute_blocked_under_local_only_privacy_mode() { + // Privacy epic S7 (#4441): under LocalOnly the request is refused with a + // `[policy-blocked]` result before URL validation / network. + let _mode = super::super::local_only_scope(); + let tool = HttpRequestTool::new( + Arc::new(SecurityPolicy::default()), + vec!["example.com".into()], + 1_000_000, + 30, + ); + let result = tool + .execute(json!({"url": "https://example.com"})) + .await + .unwrap(); + assert!(result.is_error); + assert!( + result.output().contains("[policy-blocked]"), + "got: {}", + result.output() + ); + assert!( + result.output().contains("Local-only"), + "got: {}", + result.output() + ); +} + #[test] fn truncate_response_within_limit() { let tool = test_tool(vec!["example.com"]); diff --git a/src/openhuman/tools/impl/network/mod.rs b/src/openhuman/tools/impl/network/mod.rs index b0cf2dc9ab..76db69598b 100644 --- a/src/openhuman/tools/impl/network/mod.rs +++ b/src/openhuman/tools/impl/network/mod.rs @@ -21,3 +21,16 @@ pub use mcp_setup::{ }; pub use polymarket::PolymarketTool; pub use web_fetch::WebFetchTool; + +/// Shared test helper for the network tools' local-only enforcement tests +/// (privacy epic S7, #4441). Returns a thread-scoped `LocalOnly` privacy +/// override guard: the egress gate (which reads +/// `live_policy::current_privacy_mode`) observes `LocalOnly` on this thread only, +/// so the test never mutates the process-global policy that sibling tests read +/// on other threads. Hold the returned guard for the duration of the tool call. +#[cfg(test)] +pub(crate) fn local_only_scope() -> crate::openhuman::security::live_policy::TestPrivacyGuard { + crate::openhuman::security::live_policy::test_privacy_scope( + crate::openhuman::config::PrivacyMode::LocalOnly, + ) +} diff --git a/src/openhuman/tools/impl/network/polymarket.rs b/src/openhuman/tools/impl/network/polymarket.rs index 7a8fcfe88a..fbfdc567c7 100644 --- a/src/openhuman/tools/impl/network/polymarket.rs +++ b/src/openhuman/tools/impl/network/polymarket.rs @@ -925,6 +925,30 @@ impl PolymarketTool { let url = format!("{base_url}{path}"); let method_label = method.as_str().to_string(); + // Egress spine (privacy epic S2/S7, #4436/#4441): every Polymarket + // read/write funnels through here — the single network chokepoint for + // this tool. Enforce local-only (refuse before any request leaves the + // device), then disclose the destination for permitted calls. Body => + // tool arguments, headers => metadata also ride along. + { + use crate::openhuman::security::egress::{DataKind, EgressDescriptor}; + let host = reqwest::Url::parse(&url) + .ok() + .and_then(|u| u.host_str().map(str::to_string)) + .unwrap_or_else(|| "unknown".to_string()); + let mut desc = EgressDescriptor::network_fetch(host); + if body.is_some() { + desc = desc.with_data_kind(DataKind::ToolArguments); + } + if headers.is_some() { + desc = desc.with_data_kind(DataKind::Metadata); + } + if let Some(msg) = crate::openhuman::security::egress::local_only_tool_block(&desc) { + anyhow::bail!("{msg}"); + } + crate::openhuman::security::egress::emit_external_transfer(desc); + } + for attempt in 1..=MAX_RETRY_ATTEMPTS { let mut request = self.http.request(method.clone(), &url); if !query.is_empty() { diff --git a/src/openhuman/tools/impl/network/polymarket_tests.rs b/src/openhuman/tools/impl/network/polymarket_tests.rs index 0e3ab3a5de..453a37bae4 100644 --- a/src/openhuman/tools/impl/network/polymarket_tests.rs +++ b/src/openhuman/tools/impl/network/polymarket_tests.rs @@ -445,6 +445,37 @@ async fn list_markets_happy_path() { assert_eq!(output["data"][0]["slug"], "will-eth-hit-10k"); } +#[tokio::test] +async fn blocked_under_local_only_privacy_mode() { + // Privacy epic S7 (#4441): every Polymarket request funnels through + // `send_with_retry`, which refuses under LocalOnly before any network — so + // this passes against an unreachable base URL (a leaked request would fail + // with a transport error, not the policy-blocked message). + let _mode = super::super::local_only_scope(); + let tool = test_tool( + "https://gamma.example.com".into(), + "https://clob.example.com".into(), + 15, + ); + + let result = tool + .execute(json!({ "action": "list_markets", "limit": 2, "offset": 0, "active": true })) + .await + .unwrap(); + + assert!(result.is_error); + assert!( + result.output().contains("[policy-blocked]"), + "got: {}", + result.output() + ); + assert!( + result.output().contains("Local-only"), + "got: {}", + result.output() + ); +} + #[tokio::test] async fn get_market_by_id_happy_path() { let (gamma_base, _) = start_mock_server(route( diff --git a/src/openhuman/tools/impl/network/web_fetch.rs b/src/openhuman/tools/impl/network/web_fetch.rs index bc5c9a9c4a..493205d37c 100644 --- a/src/openhuman/tools/impl/network/web_fetch.rs +++ b/src/openhuman/tools/impl/network/web_fetch.rs @@ -141,6 +141,21 @@ impl Tool for WebFetchTool { )); } + // Local-only enforcement (privacy epic S7, #4441): refuse the fetch under + // LocalOnly before URL validation / DNS. The post-validation + // `emit_external_transfer` below stays the S2 disclosure point. + { + let host = reqwest::Url::parse(raw_url) + .ok() + .and_then(|u| u.host_str().map(str::to_string)) + .unwrap_or_else(|| "unknown".to_string()); + if let Some(msg) = crate::openhuman::security::egress::local_only_tool_block( + &crate::openhuman::security::egress::EgressDescriptor::network_fetch(host), + ) { + return Ok(ToolResult::error(msg)); + } + } + let url = match validate_url_with_dns_check(raw_url, &self.allowed_domains).await { Ok(u) => u, Err(e) => return Ok(ToolResult::error(format!("URL rejected: {e}"))), @@ -291,6 +306,29 @@ mod tests { assert!(result.is_error); } + #[tokio::test] + async fn web_fetch_blocked_under_local_only_privacy_mode() { + // Privacy epic S7 (#4441): under LocalOnly the fetch is refused with a + // `[policy-blocked]` result before any URL validation / network. + let _mode = super::super::local_only_scope(); + let tool = WebFetchTool::new(test_security(), vec!["example.com".into()], None, None); + let result = tool + .execute(json!({ "url": "https://example.com/data" })) + .await + .unwrap(); + assert!(result.is_error); + assert!( + result.output().contains("[policy-blocked]"), + "got: {}", + result.output() + ); + assert!( + result.output().contains("Local-only"), + "got: {}", + result.output() + ); + } + #[test] fn test_web_fetch_truncation_utf8() { // Mock body with multi-byte char exactly at budget From 49a3c9b141be93f57a835af38f571e8b4bdb1c44 Mon Sep 17 00:00:00 2001 From: oxoxDev Date: Tue, 14 Jul 2026 15:10:32 +0530 Subject: [PATCH 06/12] fix(privacy): gate composio direct-mode dispatch under local-only (#4441) --- src/openhuman/composio/execute_dispatch.rs | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/src/openhuman/composio/execute_dispatch.rs b/src/openhuman/composio/execute_dispatch.rs index 7d8e821296..0bfc2facf4 100644 --- a/src/openhuman/composio/execute_dispatch.rs +++ b/src/openhuman/composio/execute_dispatch.rs @@ -156,6 +156,22 @@ pub async fn execute_composio_action_kind_with_connection( return Err("composio: tool slug must not be empty".to_string()); } + // Local-only enforcement (privacy epic S7, #4441): gate ABOVE the + // backend-vs-direct branch. The per-client gate lives inside + // `ComposioClient::execute_tool{,_once}` and therefore only covers the + // Backend arm — the Direct arm (`direct_execute`) never reaches it, so + // without this a Composio tool call would still POST its arguments to + // Composio under LocalOnly in direct mode (the dual-path drift this very + // review flagged). A Composio execute ships the tool arguments off-device + // in BOTH modes, so refuse here — once, before either dispatch — for a + // single chokepoint. The Backend arm stays gated too (defense in depth). + if let Err(e) = crate::openhuman::security::egress::enforce_egress( + &crate::openhuman::security::egress::EgressDescriptor::composio(tool_trim), + ) { + tracing::debug!(tool = %tool_trim, "[composio][dispatch] local-only egress block"); + return Err(e.to_string()); + } + let prepared = match prepare_execute_arguments(tool_trim, arguments) { Ok(args) => args, Err(msg) => { From c3a104c343fa45118b1d54f27b1de3d5933f5689 Mon Sep 17 00:00:00 2001 From: oxoxDev Date: Tue, 14 Jul 2026 15:10:32 +0530 Subject: [PATCH 07/12] fix(privacy): block polymarket credential derivation before HTTP under local-only (#4441) --- .../tools/impl/network/polymarket.rs | 21 +++++ .../tools/impl/network/polymarket_tests.rs | 89 +++++++++++++++++++ 2 files changed, 110 insertions(+) diff --git a/src/openhuman/tools/impl/network/polymarket.rs b/src/openhuman/tools/impl/network/polymarket.rs index fbfdc567c7..edadfc7f8d 100644 --- a/src/openhuman/tools/impl/network/polymarket.rs +++ b/src/openhuman/tools/impl/network/polymarket.rs @@ -574,6 +574,27 @@ impl PolymarketTool { return Ok(creds.clone()); } + // Egress spine (privacy epic S7, #4441): deriving CLOB credentials + // sends the wallet address + L1 EIP-712 signature to Polymarket's + // `/auth/api-key` (and `/auth/derive-api-key` fallback) directly on + // `self.http`, which does NOT pass through the `send_with_retry` + // egress chokepoint. Refuse BEFORE that round-trip under LocalOnly so + // no credential material leaves the device. A cached-credential hit + // (checked above) never reaches here, so a permitted local read is + // unaffected — every `self.http` path is now gated first (this + + // `send_with_retry`). + { + use crate::openhuman::security::egress::{DataKind, EgressDescriptor}; + let host = reqwest::Url::parse(&self.clob_base_url) + .ok() + .and_then(|u| u.host_str().map(str::to_string)) + .unwrap_or_else(|| "clob.polymarket.com".to_string()); + let desc = EgressDescriptor::network_fetch(host).with_data_kind(DataKind::Metadata); + if let Some(msg) = crate::openhuman::security::egress::local_only_tool_block(&desc) { + anyhow::bail!("{msg}"); + } + } + ensure_https(&self.clob_base_url)?; let creds = derive_credentials(&self.http, wallet, &self.clob_base_url, POLY_CHAIN_ID, user) diff --git a/src/openhuman/tools/impl/network/polymarket_tests.rs b/src/openhuman/tools/impl/network/polymarket_tests.rs index 453a37bae4..58ee98ecba 100644 --- a/src/openhuman/tools/impl/network/polymarket_tests.rs +++ b/src/openhuman/tools/impl/network/polymarket_tests.rs @@ -949,6 +949,95 @@ async fn place_order_happy_path_posts_signed_order() { .unwrap_or(false)); } +#[tokio::test] +async fn credential_derivation_blocked_before_http_under_local_only() { + // Privacy epic S7 (#4441): with NO cached CLOB credentials, a signed action + // derives them via `clob_auth::derive_credentials`, which POSTs the wallet + // address + L1 EIP-712 signature to `/auth/api-key` directly on `self.http` + // — a path that does NOT funnel through the `send_with_retry` egress gate. + // The gate in `ensure_clob_credentials` must refuse under LocalOnly BEFORE + // that round-trip. We mock the auth (+ nonce/order) routes so any leaked + // request would be captured; the decisive assertion is that NONE were made. + use crate::openhuman::config::TEST_ENV_LOCK; + + let _env_guard = TEST_ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + let tmp = tempfile::tempdir().expect("tempdir"); + let _workspace_guard = EnvVarGuard::set_to_path("OPENHUMAN_WORKSPACE", tmp.path()); + + let user = configure_wallet_for_place_order_test(tmp.path()) + .await + .expect("wallet setup"); + + // If the gate leaked, the derive path would POST here first. A "leaked" + // credential body is served so a bypass would visibly proceed to /nonce + + // /order — making a regression loud rather than silently green. + let leaked_creds = r#"{"apiKey":"leaked","secret":"bGVha2Vk","passphrase":"leaked"}"#; + let mut routes = HashMap::new(); + routes.insert( + "/auth/api-key".to_string(), + vec![MockResponse::body(200, leaked_creds)], + ); + routes.insert( + "/auth/derive-api-key".to_string(), + vec![MockResponse::body(200, leaked_creds)], + ); + routes.insert( + "/nonce".to_string(), + vec![MockResponse::body(200, r#"{"nonce":42}"#)], + ); + routes.insert( + "/order".to_string(), + vec![MockResponse::body(200, r#"{"success":true}"#)], + ); + + let (clob_base, _calls, captured) = start_mock_server_with_capture(routes).await; + let config = PolymarketConfig { + enabled: true, + gamma_base_url: clob_base.clone(), + clob_base_url: clob_base, + timeout_secs: 15, + eoa_address: Some(user.clone()), + // NO derived_clob_credentials → the tool MUST derive, hitting the gate. + ..PolymarketConfig::default() + }; + let tool = PolymarketTool::new(&config, test_security()); + + let _mode = super::super::local_only_scope(); + let result = tool + .execute(json!({ + "action": "place_order", + "user": user.clone(), + "side": "BUY", + "token_id": "1001", + "price": 0.5, + "size": 10.0, + "time_in_force": "GTC", + "approved": true + })) + .await + .unwrap(); + + assert!( + result.is_error, + "expected policy block, got: {}", + result.output() + ); + assert!( + result.output().contains("[policy-blocked]") && result.output().contains("Local-only"), + "expected local-only policy block, got: {}", + result.output() + ); + + // Decisive: credential derivation was refused BEFORE any HTTP — the mock + // recorded zero requests, so nothing (wallet address / L1 signature) ever + // left the device. + let requests = captured.lock().unwrap().clone(); + assert!( + requests.is_empty(), + "no request must reach transport under LocalOnly; observed: {requests:#?}" + ); +} + #[tokio::test] async fn cancel_order_requires_approval_and_does_not_issue_http() { let (clob_base, calls) = start_mock_server(route( From 0b623544c1a3fb4f4969db78957441804168a5bb Mon Sep 17 00:00:00 2001 From: oxoxDev Date: Tue, 14 Jul 2026 15:10:32 +0530 Subject: [PATCH 08/12] fix(privacy): narrow composio control-plane exemption to read-only allow-list (#4441) --- src/openhuman/security/egress/enforce.rs | 46 +++++++++++++------ .../security/egress/enforce_tests.rs | 20 ++++++-- 2 files changed, 46 insertions(+), 20 deletions(-) diff --git a/src/openhuman/security/egress/enforce.rs b/src/openhuman/security/egress/enforce.rs index c0f034eee1..0d7cdc0d6c 100644 --- a/src/openhuman/security/egress/enforce.rs +++ b/src/openhuman/security/egress/enforce.rs @@ -81,24 +81,37 @@ pub fn local_only_blocks(mode: PrivacyMode, desc: &EgressDescriptor) -> bool { /// call onto this descriptor — exempt it defensively so a re-home can never /// brick login (per the epic's "do not block control-plane" directive). /// -/// 2. **Integration connection-management / catalog / pricing sub-routes** that -/// set up or *describe* an integration without shipping user content: the -/// whole composio management surface (`connections`, `authorize`, `tools`, -/// `toolkits`, `triggers`) and `pricing`. The sole composio path that ships -/// the user's data — and therefore stays blocked — is `composio/execute`, -/// which carries the tool arguments. +/// 2. **A narrow allow-list of read-only composio connection-management / +/// catalog / OAuth sub-routes** that set up or *describe* an integration +/// without shipping or mutating user content: `connections` (list + +/// per-connection delete), `authorize` (OAuth handoff), `tools` and +/// `toolkits` (catalog), plus `pricing` (billing metadata). These are the +/// routes the Connections UI needs to stay usable under `LocalOnly`. /// -/// The boundary is deliberately conservative: when a path is plausibly -/// control-plane we exempt it (avoid breakage) and rely on the fact that the -/// genuine data-egress paths (`execute` and the non-composio tool namespaces) -/// are the ones that stay blocked. Widening the block to catalog reads is a -/// follow-up that needs its own UX review. +/// Everything else under `composio/` is **blocked**, including: +/// - `execute` — ships the tool arguments (the original user-data path); +/// - `triggers` / `triggers/available` — `create_trigger` / `enable_trigger` +/// POST user-supplied `slug` / `connectionId` / `triggerConfig` (a +/// user-data *write*); the descriptor carries only the path, not the HTTP +/// method, so the read variants on the same path are blocked with them +/// (fail-closed — trigger setup is an integration write surface, not +/// sign-in, so blocking it under `LocalOnly` breaks nothing that matters); +/// - `github/repos` and any future sub-route — reveals user-adjacent data, +/// and an *unknown* composio sub-route is treated as user-data, never +/// exempt. +/// +/// The boundary is deliberately **fail-closed** for user data: only the named +/// read-only routes are exempt; anything else under `/agent-integrations/` +/// (composio or otherwise) ships user data and stays blocked. Genuine +/// non-composio control-plane (session / auth / team / billing) is handled by +/// rule (1) so sign-in never breaks. pub(crate) fn is_control_plane(desc: &EgressDescriptor) -> bool { if desc.reason != EgressReason::Integration { return false; } let path = desc.service.trim_start_matches('/'); - // (1) Not under the user-data tool namespace → control-plane. + // (1) Not under the user-data tool namespace → control-plane (session / + // auth / team / billing, or a defensively re-homed control-plane call). let Some(subroute) = path.strip_prefix("agent-integrations/") else { return true; }; @@ -106,10 +119,13 @@ pub(crate) fn is_control_plane(desc: &EgressDescriptor) -> bool { if subroute == "pricing" { return true; } - // (3) Composio management/catalog/OAuth is control-plane; `execute` (which - // ships the tool arguments) is the one user-data path and stays blocked. + // (3) Composio: exempt ONLY the read-only connection-management / catalog / + // OAuth allow-list. `execute` (tool arguments), `triggers[/available]` + // (user-data writes), `github/repos`, and any unknown sub-route stay + // blocked — fail-closed for user data. if let Some(rest) = subroute.strip_prefix("composio/") { - return rest != "execute" && !rest.starts_with("execute/"); + let head = rest.split(['/', '?']).next().unwrap_or(rest); + return matches!(head, "connections" | "authorize" | "tools" | "toolkits"); } // Everything else under /agent-integrations/ ships user data → not exempt. false diff --git a/src/openhuman/security/egress/enforce_tests.rs b/src/openhuman/security/egress/enforce_tests.rs index e1dc192ab6..cc673e0c9f 100644 --- a/src/openhuman/security/egress/enforce_tests.rs +++ b/src/openhuman/security/egress/enforce_tests.rs @@ -108,15 +108,16 @@ fn control_plane_exempts_non_tool_namespace() { #[test] fn control_plane_exempts_composio_management_and_pricing() { - // Connection-management / catalog / OAuth / pricing carry no user content. + // Connection-management / catalog / OAuth / pricing carry no user content — + // the read-only allow-list the Connections UI needs under LocalOnly. for path in [ "/agent-integrations/pricing", "/agent-integrations/composio/connections", + // Per-connection delete rides the same `connections` head → exempt. + "/agent-integrations/composio/connections/conn_123", "/agent-integrations/composio/authorize", "/agent-integrations/composio/tools", "/agent-integrations/composio/toolkits", - "/agent-integrations/composio/triggers", - "/agent-integrations/composio/triggers/available", ] { assert!( is_control_plane(&EgressDescriptor::integration(path)), @@ -127,10 +128,19 @@ fn control_plane_exempts_composio_management_and_pricing() { #[test] fn control_plane_does_not_exempt_user_data_paths() { - // composio/execute ships tool arguments; the non-composio tool namespaces - // ship queries / content — none are exempt. + // composio/execute ships tool arguments; composio/triggers[/available] + // POST user-supplied slug/connectionId/triggerConfig (user-data writes); + // github/repos reveals user-adjacent data; the non-composio tool namespaces + // ship queries / content — none are exempt (fail-closed). for path in [ "/agent-integrations/composio/execute", + // Trigger writes: create_trigger / enable_trigger POST here. The + // descriptor carries no HTTP method, so the same-path reads block too. + "/agent-integrations/composio/triggers", + "/agent-integrations/composio/triggers/available", + "/agent-integrations/composio/github/repos", + // An unknown composio sub-route defaults to blocked (fail-closed). + "/agent-integrations/composio/some-future-write", "/agent-integrations/parallel/research", "/agent-integrations/tinyfish/fetch", "/agent-integrations/twilio/call", From 3a9047ade50ffb612a6c47eea5c8c2e9c8dd4a08 Mon Sep 17 00:00:00 2001 From: oxoxDev Date: Tue, 14 Jul 2026 15:10:32 +0530 Subject: [PATCH 09/12] test(privacy): verb-level end-to-end local-only enforcement on integrations (#4441) --- src/openhuman/integrations/client_tests.rs | 73 ++++++++++++++++++++++ 1 file changed, 73 insertions(+) diff --git a/src/openhuman/integrations/client_tests.rs b/src/openhuman/integrations/client_tests.rs index aeb0649155..845ffd225e 100644 --- a/src/openhuman/integrations/client_tests.rs +++ b/src/openhuman/integrations/client_tests.rs @@ -213,6 +213,79 @@ async fn post_500_propagates_html_body_truncated() { ); } +#[tokio::test] +async fn local_only_rejects_user_data_verb_before_transport() { + // End-to-end proof (privacy epic S7, #4441) that a PUBLIC verb + // (`post`/`get`) — not just the private `enforce_backend_egress` helper — + // honours LocalOnly and refuses a user-data backend call BEFORE it hits + // transport. The mock 403s on every route it actually receives, so if the + // gate short-circuits first the error is the policy message, never the + // mock body. + use crate::openhuman::config::PrivacyMode; + use crate::openhuman::security::live_policy::test_privacy_scope; + + let app = Router::new() + .route( + "/agent-integrations/composio/execute", + post(|| async { + ( + StatusCode::FORBIDDEN, + Json(json!({ "success": false, "error": "mock must not be reached" })), + ) + .into_response() + }), + ) + .route( + "/agent-integrations/composio/connections", + get(|| async { + ( + StatusCode::FORBIDDEN, + Json(json!({ "success": false, "error": "control-plane reached transport" })), + ) + .into_response() + }), + ); + let base = start_mock_backend(app).await; + let client = client_for(base); + + // `#[tokio::test]` runs on a current-thread runtime, so the gate's inline + // `current_privacy_mode()` read observes this override on the same thread + // (see `TEST_PRIVACY_MODE`). + let _mode = test_privacy_scope(PrivacyMode::LocalOnly); + + // (1) User-data verb call is refused by the gate BEFORE transport — the + // error carries the policy message and NOT the mock's 403 body. + let err = client + .post::( + "/agent-integrations/composio/execute", + &json!({ "tool": "GMAIL_FETCH_EMAILS" }), + ) + .await + .expect_err("LocalOnly must block the user-data POST before transport"); + let msg = format!("{err:#}"); + assert!( + msg.contains("Local-only privacy mode is active"), + "expected policy block, got: {msg}" + ); + assert!( + !msg.contains("mock must not be reached"), + "gate must short-circuit before the request reaches the mock: {msg}" + ); + + // (2) A control-plane verb call under the SAME LocalOnly scope passes the + // gate and DOES reach transport (surfaces the mock's 403) — proving the + // gate is selective, not a blanket network kill. + let err = client + .get::("/agent-integrations/composio/connections") + .await + .expect_err("control-plane reaches transport and surfaces the mock 403"); + let msg = format!("{err:#}"); + assert!( + msg.contains("control-plane reached transport"), + "control-plane must reach transport under LocalOnly, got: {msg}" + ); +} + #[tokio::test] async fn get_403_propagates_backend_error_envelope_message() { let app = Router::new().route( From a1707d669924d4f250c5e6c8d27007b2e72d397d Mon Sep 17 00:00:00 2001 From: oxoxDev Date: Tue, 14 Jul 2026 15:10:32 +0530 Subject: [PATCH 10/12] fix(privacy): log only host on curl local-only block path (#4441) --- src/openhuman/tools/impl/network/curl.rs | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/openhuman/tools/impl/network/curl.rs b/src/openhuman/tools/impl/network/curl.rs index 5ac0fe88e2..4e7f740779 100644 --- a/src/openhuman/tools/impl/network/curl.rs +++ b/src/openhuman/tools/impl/network/curl.rs @@ -188,9 +188,12 @@ impl Tool for CurlTool { .and_then(|u| u.host_str().map(str::to_string)) .unwrap_or_else(|| "unknown".to_string()); if let Some(msg) = crate::openhuman::security::egress::local_only_tool_block( - &crate::openhuman::security::egress::EgressDescriptor::network_fetch(host), + &crate::openhuman::security::egress::EgressDescriptor::network_fetch(host.clone()), ) { - tracing::debug!(target: "[curl]", url = %url, "blocked: local-only privacy mode"); + // Log only the host, never the full URL: a raw URL can carry + // secrets in its query string (pre-signed links, tokens). The + // gate helper already logs `desc.service` (host only) too. + tracing::debug!(target: "[curl]", host = %host, "blocked: local-only privacy mode"); return Ok(ToolResult::error(msg)); } } From 7c0db70f54e43b9bc5f8b661e48a5b59992f3db1 Mon Sep 17 00:00:00 2001 From: M3gA-Mind Date: Tue, 14 Jul 2026 19:47:56 +0530 Subject: [PATCH 11/12] fix(privacy): disclose permitted Polymarket credential-derivation egress (S7) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ensure_clob_credentials gated the /auth/api-key credential-derivation round-trip under LocalOnly but never called emit_external_transfer on the permitted path, so this off-device transfer (wallet address + L1 EIP-712 signature) produced no ExternalTransferPending disclosure — unlike send_with_retry and every other egress chokepoint in this PR. Add the emit after the block check, mirroring send_with_retry, so a permitted derive is disclosed consistently. LocalOnly still bails before the emit, so the block path is unchanged. Addresses CodeRabbit review (polymarket.rs L597). --- .../tools/impl/network/polymarket.rs | 22 +++++++++++-------- 1 file changed, 13 insertions(+), 9 deletions(-) diff --git a/src/openhuman/tools/impl/network/polymarket.rs b/src/openhuman/tools/impl/network/polymarket.rs index edadfc7f8d..c0b3218f9e 100644 --- a/src/openhuman/tools/impl/network/polymarket.rs +++ b/src/openhuman/tools/impl/network/polymarket.rs @@ -574,15 +574,18 @@ impl PolymarketTool { return Ok(creds.clone()); } - // Egress spine (privacy epic S7, #4441): deriving CLOB credentials - // sends the wallet address + L1 EIP-712 signature to Polymarket's - // `/auth/api-key` (and `/auth/derive-api-key` fallback) directly on - // `self.http`, which does NOT pass through the `send_with_retry` - // egress chokepoint. Refuse BEFORE that round-trip under LocalOnly so - // no credential material leaves the device. A cached-credential hit - // (checked above) never reaches here, so a permitted local read is - // unaffected — every `self.http` path is now gated first (this + - // `send_with_retry`). + // Egress spine (privacy epic S2/S7, #4436/#4441): deriving CLOB + // credentials sends the wallet address + L1 EIP-712 signature to + // Polymarket's `/auth/api-key` (and `/auth/derive-api-key` fallback) + // directly on `self.http`, which does NOT pass through the + // `send_with_retry` egress chokepoint. Refuse BEFORE that round-trip + // under LocalOnly so no credential material leaves the device, then + // disclose the destination for a permitted derive — mirroring + // `send_with_retry` so this off-device transfer produces the same + // `ExternalTransferPending` disclosure as every other egress point. A + // cached-credential hit (checked above) never reaches here, so a + // permitted local read is unaffected — every `self.http` path is now + // gated and disclosed (this + `send_with_retry`). { use crate::openhuman::security::egress::{DataKind, EgressDescriptor}; let host = reqwest::Url::parse(&self.clob_base_url) @@ -593,6 +596,7 @@ impl PolymarketTool { if let Some(msg) = crate::openhuman::security::egress::local_only_tool_block(&desc) { anyhow::bail!("{msg}"); } + crate::openhuman::security::egress::emit_external_transfer(desc); } ensure_https(&self.clob_base_url)?; From 636cf51274e90fb35782f89923acbf2a57a7c65c Mon Sep 17 00:00:00 2001 From: M3gA-Mind Date: Tue, 14 Jul 2026 20:47:59 +0530 Subject: [PATCH 12/12] test(integrations): exercise local-only gate through public verb methods MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses CodeRabbit review (#4850): the only LocalOnly test called the private enforce_backend_egress helper directly. Add a verb-level test that drives post/get/patch/delete/upload_multipart/get_bytes under a thread-scoped LocalOnly override against an unreachable backend, asserting each is refused with the policy message before any request leaves the device — proving the gate is wired into every public verb, not just the helper. --- src/openhuman/integrations/client_tests.rs | 71 ++++++++++++++++++++++ 1 file changed, 71 insertions(+) diff --git a/src/openhuman/integrations/client_tests.rs b/src/openhuman/integrations/client_tests.rs index 845ffd225e..83321cc5f6 100644 --- a/src/openhuman/integrations/client_tests.rs +++ b/src/openhuman/integrations/client_tests.rs @@ -141,6 +141,77 @@ fn enforce_backend_egress_blocks_user_data_but_allows_control_plane() { enforce_backend_egress("/agent-integrations/composio/execute").expect("Standard allows"); } +/// Privacy epic S7 (#4441): the LocalOnly gate must fire through the PUBLIC verb +/// methods, not only via the `enforce_backend_egress` helper. Each of the six +/// verbs (`post`/`get`/`patch`/`delete`/`upload_multipart`/`get_bytes`) runs the +/// gate synchronously on first poll — before any `.await` — so a user-data path +/// is refused before the request leaves the device. The client points at an +/// unreachable address, so a leaked call would surface a transport error, not +/// the local-only message; asserting the policy string therefore proves the +/// short-circuit fired end-to-end through the verb, not just the helper. +#[tokio::test] +async fn verb_methods_block_user_data_egress_under_local_only() { + use crate::openhuman::config::PrivacyMode; + use crate::openhuman::security::live_policy::test_privacy_scope; + + let _mode = test_privacy_scope(PrivacyMode::LocalOnly); + let client = client_for("http://127.0.0.1:0".into()); + let path = "/agent-integrations/composio/execute"; // user-data egress (blocked) + let body = json!({ "arguments": { "secret": "leak-me" } }); + + let assert_blocked = |verb: &str, msg: String| { + assert!( + msg.contains("Local-only privacy mode is active"), + "{verb}: expected a local-only block before network, got: {msg}" + ); + }; + + assert_blocked( + "post", + client + .post::(path, &body) + .await + .unwrap_err() + .to_string(), + ); + assert_blocked( + "get", + client + .get::(path) + .await + .unwrap_err() + .to_string(), + ); + assert_blocked( + "patch", + client + .patch::(path, &body) + .await + .unwrap_err() + .to_string(), + ); + assert_blocked( + "delete", + client + .delete::(path) + .await + .unwrap_err() + .to_string(), + ); + assert_blocked( + "upload_multipart", + client + .upload_multipart::(path, reqwest::multipart::Form::new()) + .await + .unwrap_err() + .to_string(), + ); + assert_blocked( + "get_bytes", + client.get_bytes(path).await.unwrap_err().to_string(), + ); +} + // ── Integration: HTTP error propagation through `post`/`get` ────── async fn start_mock_backend(app: Router) -> String {