feat(privacy): S7 — local-only enforcement across integrations + tools (#4441) (#4850)

Co-authored-by: M3gA-Mind <elvin@tinyhumans.ai>
This commit is contained in:
oxoxDev
2026-07-14 22:58:19 +05:30
committed by GitHub
co-authored by M3gA-Mind
parent f416693233
commit 6c219fb8a5
18 changed files with 1125 additions and 20 deletions
+10 -6
View File
@@ -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)");
+39
View File
@@ -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) => {
+37 -5
View File
@@ -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}"
);
}
}
+33 -8
View File
@@ -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);
+184
View File
@@ -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(
+205
View File
@@ -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());
}
+2
View File
@@ -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};
+51
View File
@@ -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]
+2 -1
View File
@@ -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::*;
+65
View File
@@ -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"]);
+13
View File
@@ -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