mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
Co-authored-by: M3gA-Mind <elvin@tinyhumans.ai>
This commit is contained in:
@@ -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)");
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -85,12 +85,44 @@ impl EmbeddingProvider for OpenHumanCloudEmbedding {
|
||||
async fn embed(&self, texts: &[&str]) -> anyhow::Result<Vec<Vec<f32>>> {
|
||||
// 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}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -240,15 +240,30 @@ fn parse_content_disposition_filename(value: &str) -> Option<String> {
|
||||
None
|
||||
}
|
||||
|
||||
/// 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.
|
||||
fn emit_backend_egress(path: &str) {
|
||||
/// 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::emit_external_transfer(
|
||||
crate::openhuman::security::egress::EgressDescriptor::integration(endpoint),
|
||||
);
|
||||
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. Fire-and-forget — never fails the
|
||||
/// caller.
|
||||
fn emit_backend_egress(path: &str) {
|
||||
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<T> {
|
||||
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<T: serde::de::DeserializeOwned>(&self, path: &str) -> anyhow::Result<T> {
|
||||
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<T> {
|
||||
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<T: serde::de::DeserializeOwned>(&self, path: &str) -> anyhow::Result<T> {
|
||||
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<T> {
|
||||
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<String>, Option<String>)> {
|
||||
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);
|
||||
|
||||
@@ -101,6 +101,117 @@ 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");
|
||||
}
|
||||
|
||||
/// 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::<serde_json::Value>(path, &body)
|
||||
.await
|
||||
.unwrap_err()
|
||||
.to_string(),
|
||||
);
|
||||
assert_blocked(
|
||||
"get",
|
||||
client
|
||||
.get::<serde_json::Value>(path)
|
||||
.await
|
||||
.unwrap_err()
|
||||
.to_string(),
|
||||
);
|
||||
assert_blocked(
|
||||
"patch",
|
||||
client
|
||||
.patch::<serde_json::Value>(path, &body)
|
||||
.await
|
||||
.unwrap_err()
|
||||
.to_string(),
|
||||
);
|
||||
assert_blocked(
|
||||
"delete",
|
||||
client
|
||||
.delete::<serde_json::Value>(path)
|
||||
.await
|
||||
.unwrap_err()
|
||||
.to_string(),
|
||||
);
|
||||
assert_blocked(
|
||||
"upload_multipart",
|
||||
client
|
||||
.upload_multipart::<serde_json::Value>(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 {
|
||||
@@ -173,6 +284,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::<serde_json::Value>(
|
||||
"/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::<serde_json::Value>("/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(
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
//! 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. **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`.
|
||||
///
|
||||
/// 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 (session /
|
||||
// auth / team / billing, or a defensively re-homed control-plane call).
|
||||
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: 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/") {
|
||||
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
|
||||
}
|
||||
|
||||
/// 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<String> {
|
||||
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;
|
||||
@@ -0,0 +1,216 @@
|
||||
//! 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 —
|
||||
// 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",
|
||||
] {
|
||||
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; 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",
|
||||
"/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());
|
||||
}
|
||||
@@ -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};
|
||||
|
||||
@@ -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<Option<PrivacyMode>> =
|
||||
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<PrivacyMode>);
|
||||
|
||||
#[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]
|
||||
|
||||
@@ -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::*;
|
||||
|
||||
@@ -179,6 +179,25 @@ 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.clone()),
|
||||
) {
|
||||
// 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));
|
||||
}
|
||||
}
|
||||
|
||||
let url = match self.validate_url(url).await {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
@@ -187,6 +206,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 +575,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.
|
||||
|
||||
@@ -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())),
|
||||
|
||||
@@ -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"]);
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -574,6 +574,31 @@ impl PolymarketTool {
|
||||
return Ok(creds.clone());
|
||||
}
|
||||
|
||||
// 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)
|
||||
.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}");
|
||||
}
|
||||
crate::openhuman::security::egress::emit_external_transfer(desc);
|
||||
}
|
||||
|
||||
ensure_https(&self.clob_base_url)?;
|
||||
let creds =
|
||||
derive_credentials(&self.http, wallet, &self.clob_base_url, POLY_CHAIN_ID, user)
|
||||
@@ -925,6 +950,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() {
|
||||
|
||||
@@ -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(
|
||||
@@ -918,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(
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user