mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-28 13:32:23 +00:00
feat(planner): read-only sandbox gate on composio meta-tools (#904)
This commit is contained in:
@@ -331,6 +331,41 @@ mod tests {
|
||||
assert!(def.omit_safety_preamble);
|
||||
}
|
||||
|
||||
/// Planner runs `composio_execute` so it can ground plans in real
|
||||
/// integration data, but it must stay strictly read-only — issue
|
||||
/// #685. `sandbox_mode = "read_only"` in `planner/agent.toml` is the
|
||||
/// runtime hook that activates the agent-level gate inside
|
||||
/// `ComposioExecuteTool::execute`; this test pins that contract so a
|
||||
/// future TOML edit that drops the sandbox mode can never silently
|
||||
/// turn the planner into a write-capable agent.
|
||||
#[test]
|
||||
fn planner_is_read_only_with_composio_meta_tools() {
|
||||
let def = find("planner");
|
||||
assert_eq!(
|
||||
def.sandbox_mode,
|
||||
SandboxMode::ReadOnly,
|
||||
"planner.sandbox_mode must be read_only — gates Write/Admin composio actions",
|
||||
);
|
||||
match &def.tools {
|
||||
ToolScope::Named(names) => {
|
||||
for required in [
|
||||
"composio_list_toolkits",
|
||||
"composio_list_connections",
|
||||
"composio_list_tools",
|
||||
"composio_execute",
|
||||
] {
|
||||
assert!(
|
||||
names.iter().any(|n| n == required),
|
||||
"planner tool list missing `{required}` — composio meta-tools must \
|
||||
all be present so the planner can inspect integrations under the \
|
||||
read-only sandbox gate",
|
||||
);
|
||||
}
|
||||
}
|
||||
other => panic!("planner must use Named tool scope, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn integrations_agent_tool_scope_honours_toml() {
|
||||
let def = find("integrations_agent");
|
||||
|
||||
@@ -18,6 +18,14 @@ hint = "reasoning"
|
||||
# the workspace, memory, or state. Any writes the plan requires get
|
||||
# executed by downstream agents (code_executor, archivist, …) at the
|
||||
# orchestrator's direction.
|
||||
#
|
||||
# Composio meta-tools are included so the planner can inspect connected
|
||||
# integrations, list available actions, and pull data needed to ground
|
||||
# a plan. `sandbox_mode = "read_only"` above triggers the agent-level
|
||||
# gate in `composio_execute` that rejects Write/Admin-scoped action
|
||||
# slugs (#685), so the planner cannot send email / create pages / etc.
|
||||
# even though `composio_execute` is on this list. Only `Read`-scoped
|
||||
# actions slip past the gate.
|
||||
named = [
|
||||
"file_read",
|
||||
"memory_recall",
|
||||
@@ -32,4 +40,8 @@ named = [
|
||||
"stock_exchange_rate",
|
||||
"stock_crypto_series",
|
||||
"stock_commodity",
|
||||
"composio_list_toolkits",
|
||||
"composio_list_connections",
|
||||
"composio_list_tools",
|
||||
"composio_execute",
|
||||
]
|
||||
|
||||
+44
-27
@@ -24,7 +24,8 @@ use crate::openhuman::config::MultimodalConfig;
|
||||
use crate::openhuman::providers::{ChatMessage, Provider};
|
||||
use crate::openhuman::tools::Tool;
|
||||
|
||||
use super::harness::run_tool_call_loop;
|
||||
use super::harness::definition::{AgentDefinitionRegistry, SandboxMode};
|
||||
use super::harness::{run_tool_call_loop, with_current_sandbox_mode};
|
||||
|
||||
/// Method name used to dispatch an agentic turn through the native bus.
|
||||
pub const AGENT_RUN_TURN_METHOD: &str = "agent.run_turn";
|
||||
@@ -171,32 +172,48 @@ pub fn register_agent_handlers() {
|
||||
"[agent::bus] dispatching {AGENT_RUN_TURN_METHOD}"
|
||||
);
|
||||
|
||||
let text = run_tool_call_loop(
|
||||
provider.as_ref(),
|
||||
&mut history,
|
||||
tools_registry.as_ref(),
|
||||
&provider_name,
|
||||
&model,
|
||||
temperature,
|
||||
silent,
|
||||
// Approval is not wired into the channel path today; if
|
||||
// CLI migrates to the bus later, extend AgentTurnRequest
|
||||
// with `approval: Option<Arc<ApprovalManager>>` and pass
|
||||
// it through here.
|
||||
None,
|
||||
&channel_name,
|
||||
&multimodal,
|
||||
max_tool_iterations,
|
||||
on_delta,
|
||||
visible_tool_names.as_ref(),
|
||||
&extra_tools,
|
||||
on_progress,
|
||||
// Bus path runs ad-hoc agent turns without an Agent
|
||||
// handle, so we pass None — payload summarization is
|
||||
// wired into the orchestrator session via Agent::turn,
|
||||
// not the bus dispatcher.
|
||||
None,
|
||||
)
|
||||
// Resolve the target agent's declared sandbox mode so any
|
||||
// tool executed inside the loop can read it via the
|
||||
// `CURRENT_AGENT_SANDBOX_MODE` task-local. Falls back to
|
||||
// `SandboxMode::None` when the request doesn't pin an agent
|
||||
// id (legacy "generic unfiltered turn" path) or when the
|
||||
// global registry hasn't been initialised (tests that stub
|
||||
// the bus without bootstrapping definitions).
|
||||
let sandbox_mode = target_agent_id
|
||||
.as_deref()
|
||||
.and_then(|id| AgentDefinitionRegistry::global().and_then(|reg| reg.get(id)))
|
||||
.map(|def| def.sandbox_mode)
|
||||
.unwrap_or(SandboxMode::None);
|
||||
|
||||
let text = with_current_sandbox_mode(sandbox_mode, async {
|
||||
run_tool_call_loop(
|
||||
provider.as_ref(),
|
||||
&mut history,
|
||||
tools_registry.as_ref(),
|
||||
&provider_name,
|
||||
&model,
|
||||
temperature,
|
||||
silent,
|
||||
// Approval is not wired into the channel path today; if
|
||||
// CLI migrates to the bus later, extend AgentTurnRequest
|
||||
// with `approval: Option<Arc<ApprovalManager>>` and pass
|
||||
// it through here.
|
||||
None,
|
||||
&channel_name,
|
||||
&multimodal,
|
||||
max_tool_iterations,
|
||||
on_delta,
|
||||
visible_tool_names.as_ref(),
|
||||
&extra_tools,
|
||||
on_progress,
|
||||
// Bus path runs ad-hoc agent turns without an Agent
|
||||
// handle, so we pass None — payload summarization is
|
||||
// wired into the orchestrator session via Agent::turn,
|
||||
// not the bus dispatcher.
|
||||
None,
|
||||
)
|
||||
.await
|
||||
})
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
|
||||
@@ -32,6 +32,7 @@ pub mod interrupt;
|
||||
pub(crate) mod memory_context;
|
||||
mod parse;
|
||||
pub(crate) mod payload_summarizer;
|
||||
pub mod sandbox_context;
|
||||
pub(crate) mod self_healing;
|
||||
pub mod session;
|
||||
pub(crate) mod session_queue;
|
||||
@@ -48,6 +49,7 @@ pub use fork_context::{
|
||||
ParentExecutionContext,
|
||||
};
|
||||
pub use interrupt::{check_interrupt, InterruptFence, InterruptedError};
|
||||
pub use sandbox_context::{current_sandbox_mode, with_current_sandbox_mode};
|
||||
pub use subagent_runner::{run_subagent, SubagentRunError, SubagentRunOptions};
|
||||
|
||||
pub(crate) use instructions::build_tool_instructions_filtered;
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
//! Task-local carrier for the **calling agent's `sandbox_mode`** so tool
|
||||
//! implementations can enforce sandbox semantics at execution time without
|
||||
//! widening the [`crate::openhuman::tools::Tool`] trait signature.
|
||||
//!
|
||||
//! Sibling of the existing [`super::fork_context`] task-locals but serves
|
||||
//! a different concept: `PARENT_CONTEXT` / `FORK_CONTEXT` carry the
|
||||
//! *parent agent's* runtime context so that `spawn_subagent` can inherit
|
||||
//! it, whereas [`CURRENT_AGENT_SANDBOX_MODE`] carries the *currently-
|
||||
//! executing agent's* sandbox mode so that any tool it invokes can gate
|
||||
//! on that mode.
|
||||
//!
|
||||
//! Why a task-local instead of an argument on [`Tool::execute`]: the tool
|
||||
//! trait is called from many places (CLI, JSON-RPC, tests, agent loops).
|
||||
//! Threading an optional context argument through every call site would
|
||||
//! touch every tool implementation and every caller. A task-local keeps
|
||||
//! the additive path scoped to the agent runtime that actually needs it.
|
||||
//!
|
||||
//! Tools read the current mode via [`current_sandbox_mode`]. When the
|
||||
//! task-local isn't set (direct CLI / JSON-RPC / unit-test invocation),
|
||||
//! the function returns `None` and tools fall through to their default
|
||||
//! pre-sandbox behavior, so this change is strictly additive.
|
||||
|
||||
use super::definition::SandboxMode;
|
||||
|
||||
tokio::task_local! {
|
||||
/// Sandbox mode declared in the currently-executing agent's
|
||||
/// `agent.toml`. Scoped per agent turn by the tool loop so any tool
|
||||
/// executed inside that turn can read it. `None` when unset (direct
|
||||
/// tool invocation outside an agent turn).
|
||||
pub static CURRENT_AGENT_SANDBOX_MODE: SandboxMode;
|
||||
}
|
||||
|
||||
/// Returns the current agent's `sandbox_mode`, if the scope is active.
|
||||
///
|
||||
/// Returns `None` when called from outside
|
||||
/// [`with_current_sandbox_mode`] — e.g. CLI tool invocation, JSON-RPC
|
||||
/// tool dispatch, or unit tests that call a [`Tool`] directly.
|
||||
pub fn current_sandbox_mode() -> Option<SandboxMode> {
|
||||
CURRENT_AGENT_SANDBOX_MODE.try_with(|mode| *mode).ok()
|
||||
}
|
||||
|
||||
/// Run `future` with `mode` installed as the current sandbox mode.
|
||||
///
|
||||
/// Intended call site is the tool loop (and subagent runner) immediately
|
||||
/// around each `tool.execute(args)` invocation so every tool the agent
|
||||
/// calls observes the correct mode. The scope does not leak into any
|
||||
/// detached task spawned inside `future` — that is standard
|
||||
/// [`tokio::task_local!`] semantics.
|
||||
pub async fn with_current_sandbox_mode<F, R>(mode: SandboxMode, future: F) -> R
|
||||
where
|
||||
F: std::future::Future<Output = R>,
|
||||
{
|
||||
CURRENT_AGENT_SANDBOX_MODE.scope(mode, future).await
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
async fn current_sandbox_mode_returns_none_outside_scope() {
|
||||
assert_eq!(current_sandbox_mode(), None);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn with_current_sandbox_mode_installs_read_only() {
|
||||
let observed =
|
||||
with_current_sandbox_mode(SandboxMode::ReadOnly, async { current_sandbox_mode() })
|
||||
.await;
|
||||
assert_eq!(observed, Some(SandboxMode::ReadOnly));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn with_current_sandbox_mode_does_not_leak_across_scopes() {
|
||||
with_current_sandbox_mode(SandboxMode::ReadOnly, async {
|
||||
assert_eq!(current_sandbox_mode(), Some(SandboxMode::ReadOnly));
|
||||
})
|
||||
.await;
|
||||
assert_eq!(current_sandbox_mode(), None);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn nested_scope_overrides_outer() {
|
||||
with_current_sandbox_mode(SandboxMode::ReadOnly, async {
|
||||
assert_eq!(current_sandbox_mode(), Some(SandboxMode::ReadOnly));
|
||||
with_current_sandbox_mode(SandboxMode::Sandboxed, async {
|
||||
assert_eq!(current_sandbox_mode(), Some(SandboxMode::Sandboxed));
|
||||
})
|
||||
.await;
|
||||
assert_eq!(current_sandbox_mode(), Some(SandboxMode::ReadOnly));
|
||||
})
|
||||
.await;
|
||||
}
|
||||
}
|
||||
@@ -29,6 +29,7 @@ use super::tool_prep::{
|
||||
};
|
||||
use super::types::{SubagentMode, SubagentRunError, SubagentRunOptions, SubagentRunOutcome};
|
||||
use crate::openhuman::agent::harness::definition::{AgentDefinition, PromptSource};
|
||||
use crate::openhuman::agent::harness::with_current_sandbox_mode;
|
||||
use crate::openhuman::context::prompt::{
|
||||
render_subagent_system_prompt, PromptContext, PromptTool, SubagentRenderOptions,
|
||||
};
|
||||
@@ -65,12 +66,20 @@ pub async fn run_subagent(
|
||||
"[subagent_runner] dispatching"
|
||||
);
|
||||
|
||||
let outcome = if definition.uses_fork_context {
|
||||
let fork = current_fork().ok_or(SubagentRunError::NoForkContext)?;
|
||||
run_fork_mode(definition, task_prompt, &options, &parent, &fork, &task_id).await?
|
||||
} else {
|
||||
run_typed_mode(definition, task_prompt, &options, &parent, &task_id).await?
|
||||
};
|
||||
// Install the sub-agent's declared `sandbox_mode` as the active
|
||||
// task-local for every tool invocation inside this run. Tools that
|
||||
// want to gate on it (e.g. `composio_execute` rejecting
|
||||
// Write/Admin slugs under `ReadOnly`) read it via
|
||||
// `current_sandbox_mode()`; tools that don't care just ignore it.
|
||||
let outcome = with_current_sandbox_mode(definition.sandbox_mode, async {
|
||||
if definition.uses_fork_context {
|
||||
let fork = current_fork().ok_or(SubagentRunError::NoForkContext)?;
|
||||
run_fork_mode(definition, task_prompt, &options, &parent, &fork, &task_id).await
|
||||
} else {
|
||||
run_typed_mode(definition, task_prompt, &options, &parent, &task_id).await
|
||||
}
|
||||
})
|
||||
.await?;
|
||||
|
||||
tracing::info!(
|
||||
agent_id = %definition.id,
|
||||
|
||||
@@ -22,6 +22,10 @@ use async_trait::async_trait;
|
||||
use serde_json::Value;
|
||||
|
||||
use super::client::ComposioClient;
|
||||
use super::providers::ToolScope;
|
||||
use super::tools::resolve_action_scope;
|
||||
use crate::openhuman::agent::harness::current_sandbox_mode;
|
||||
use crate::openhuman::agent::harness::definition::SandboxMode;
|
||||
use crate::openhuman::tools::traits::{PermissionLevel, Tool, ToolCategory, ToolResult};
|
||||
|
||||
/// A single Composio action exposed as a first-class tool.
|
||||
@@ -82,6 +86,36 @@ impl Tool for ComposioActionTool {
|
||||
}
|
||||
|
||||
async fn execute(&self, args: Value) -> anyhow::Result<ToolResult> {
|
||||
// Agent-level sandbox gate (issue #685, CodeRabbit follow-up on
|
||||
// PR #904) — mirrors the check in
|
||||
// [`super::tools::ComposioExecuteTool::execute`] so a read-only
|
||||
// agent cannot slip a mutating call through the per-action
|
||||
// surface. The dispatcher path (`composio_execute`) and this
|
||||
// per-action path are the only two routes to the Composio
|
||||
// backend; both must honour the same invariant. Today no
|
||||
// read-only agent spawns per-action tools (only
|
||||
// `integrations_agent` registers them and it is
|
||||
// `sandbox_mode = "none"`), so this is strict defense-in-depth
|
||||
// for any future configuration that pairs the two.
|
||||
if matches!(current_sandbox_mode(), Some(SandboxMode::ReadOnly)) {
|
||||
let scope = resolve_action_scope(&self.action_name).await;
|
||||
if matches!(scope, ToolScope::Write | ToolScope::Admin) {
|
||||
tracing::info!(
|
||||
tool = %self.action_name,
|
||||
scope = scope.as_str(),
|
||||
"[composio][sandbox] per-action execute blocked: agent is read-only, action is {}",
|
||||
scope.as_str()
|
||||
);
|
||||
return Ok(ToolResult::error(format!(
|
||||
"{}: action is classified `{}` and is refused because the calling \
|
||||
agent is in strict read-only mode. Only `read`-scoped actions are \
|
||||
available to this agent.",
|
||||
self.action_name,
|
||||
scope.as_str()
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
||||
let started = std::time::Instant::now();
|
||||
let res = self
|
||||
.client
|
||||
@@ -119,3 +153,89 @@ impl Tool for ComposioActionTool {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::openhuman::agent::harness::with_current_sandbox_mode;
|
||||
use crate::openhuman::integrations::IntegrationClient;
|
||||
use std::sync::Arc;
|
||||
|
||||
/// Build a `ComposioClient` whose backend is the loopback dead-drop
|
||||
/// used by the tests in `composio/tools.rs`. The sandbox gate runs
|
||||
/// *before* any HTTP call, so these tests never reach the network.
|
||||
fn fake_client() -> ComposioClient {
|
||||
let inner =
|
||||
IntegrationClient::new("http://127.0.0.1:0".to_string(), "test-token".to_string());
|
||||
ComposioClient::new(Arc::new(inner))
|
||||
}
|
||||
|
||||
fn error_text(result: &ToolResult) -> String {
|
||||
result
|
||||
.content
|
||||
.iter()
|
||||
.filter_map(|c| match c {
|
||||
crate::openhuman::tools::traits::ToolContent::Text { text } => Some(text.clone()),
|
||||
_ => None,
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ")
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn sandbox_read_only_blocks_per_action_write_call() {
|
||||
let t = ComposioActionTool::new(
|
||||
fake_client(),
|
||||
"GMAIL_SEND_EMAIL".to_string(),
|
||||
"send a gmail message".to_string(),
|
||||
None,
|
||||
);
|
||||
let result = with_current_sandbox_mode(SandboxMode::ReadOnly, async {
|
||||
t.execute(serde_json::json!({})).await.unwrap()
|
||||
})
|
||||
.await;
|
||||
assert!(
|
||||
result.is_error,
|
||||
"per-action Write under read-only must error"
|
||||
);
|
||||
let msg = error_text(&result);
|
||||
assert!(msg.contains("strict read-only"), "got: {msg}");
|
||||
assert!(msg.contains("`write`"), "got: {msg}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn sandbox_read_only_blocks_per_action_admin_call() {
|
||||
let t = ComposioActionTool::new(
|
||||
fake_client(),
|
||||
"GMAIL_DELETE_EMAIL".to_string(),
|
||||
"destructive".to_string(),
|
||||
None,
|
||||
);
|
||||
let result = with_current_sandbox_mode(SandboxMode::ReadOnly, async {
|
||||
t.execute(serde_json::json!({})).await.unwrap()
|
||||
})
|
||||
.await;
|
||||
assert!(result.is_error);
|
||||
let msg = error_text(&result);
|
||||
assert!(msg.contains("`admin`"), "got: {msg}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn sandbox_unset_leaves_per_action_execute_to_downstream() {
|
||||
// Outside any `with_current_sandbox_mode` scope the task-local
|
||||
// is `None` and the gate is a no-op. The downstream HTTP call
|
||||
// still fails (loopback :0), but never with the sandbox text.
|
||||
let t = ComposioActionTool::new(
|
||||
fake_client(),
|
||||
"GMAIL_SEND_EMAIL".to_string(),
|
||||
"send".to_string(),
|
||||
None,
|
||||
);
|
||||
let result = t.execute(serde_json::json!({})).await.unwrap();
|
||||
let msg = error_text(&result);
|
||||
assert!(
|
||||
!msg.contains("strict read-only"),
|
||||
"unset sandbox must never trigger the gate, got: {msg}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,6 +24,8 @@
|
||||
use async_trait::async_trait;
|
||||
use serde_json::{json, Value};
|
||||
|
||||
use crate::openhuman::agent::harness::current_sandbox_mode;
|
||||
use crate::openhuman::agent::harness::definition::SandboxMode;
|
||||
use crate::openhuman::tools::traits::{PermissionLevel, Tool, ToolCategory, ToolResult};
|
||||
|
||||
use super::client::ComposioClient;
|
||||
@@ -47,6 +49,29 @@ enum ToolDecision {
|
||||
PassthroughCheckScope { scope: ToolScope },
|
||||
}
|
||||
|
||||
/// Resolve a Composio action slug to its [`ToolScope`] classification.
|
||||
///
|
||||
/// Prefers the toolkit's curated catalog when available (most accurate
|
||||
/// — curated entries are hand-classified) and falls back to the
|
||||
/// [`classify_unknown`] heuristic for un-curated toolkits. Unparseable
|
||||
/// slugs default to `Write` so the sandbox gate errs on the side of
|
||||
/// blocking rather than letting a potentially-mutating action slip
|
||||
/// through uncategorised.
|
||||
pub(super) async fn resolve_action_scope(slug: &str) -> ToolScope {
|
||||
let Some(toolkit) = toolkit_from_slug(slug) else {
|
||||
return ToolScope::Write;
|
||||
};
|
||||
let catalog = get_provider(&toolkit)
|
||||
.and_then(|p| p.curated_tools())
|
||||
.or_else(|| catalog_for_toolkit(&toolkit));
|
||||
if let Some(cat) = catalog {
|
||||
if let Some(entry) = find_curated(cat, slug) {
|
||||
return entry.scope;
|
||||
}
|
||||
}
|
||||
classify_unknown(slug)
|
||||
}
|
||||
|
||||
/// Decide whether a Composio action slug should be visible / executable
|
||||
/// for the current user, given the registered provider's curated list
|
||||
/// (if any) and the user's stored scope preference.
|
||||
@@ -434,6 +459,34 @@ impl Tool for ComposioExecuteTool {
|
||||
let arguments = args.get("arguments").cloned();
|
||||
tracing::debug!(tool = %tool, "[composio] tool execute.execute");
|
||||
|
||||
// Agent-level sandbox gate (issue #685) — applies on top of the
|
||||
// user's scope preference below. When the currently-executing
|
||||
// agent declares `sandbox_mode = "read_only"` in its
|
||||
// `agent.toml`, we refuse to dispatch any Write- or Admin-scoped
|
||||
// composio action regardless of what the user's scope pref
|
||||
// allows, so a strictly-read-only agent (planner, critic,
|
||||
// morning_briefing, …) can never mutate user state via the
|
||||
// composio surface. `SandboxMode::None` / `Sandboxed` (and the
|
||||
// `None` task-local value used by direct CLI / JSON-RPC / unit
|
||||
// tests) pass through unchanged.
|
||||
if matches!(current_sandbox_mode(), Some(SandboxMode::ReadOnly)) {
|
||||
let scope = resolve_action_scope(&tool).await;
|
||||
if matches!(scope, ToolScope::Write | ToolScope::Admin) {
|
||||
tracing::info!(
|
||||
tool = %tool,
|
||||
scope = scope.as_str(),
|
||||
"[composio][sandbox] execute blocked: agent is read-only, action is {}",
|
||||
scope.as_str()
|
||||
);
|
||||
return Ok(ToolResult::error(format!(
|
||||
"composio_execute: action `{tool}` is classified `{}` and is refused \
|
||||
because the calling agent is in strict read-only mode. Only `read`-scoped \
|
||||
actions are available to this agent.",
|
||||
scope.as_str()
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
||||
// Enforce per-user scope preferences before delegating to backend.
|
||||
match evaluate_tool_visibility(&tool).await {
|
||||
ToolDecision::Allow | ToolDecision::PassthroughCheckScope { .. } => {}
|
||||
|
||||
@@ -188,3 +188,116 @@ fn all_composio_agent_tools_registers_five_when_session_available() {
|
||||
let tools = all_composio_agent_tools(&config);
|
||||
assert_eq!(tools.len(), 5);
|
||||
}
|
||||
|
||||
// ── Sandbox-mode gate (issue #685) ───────────────────────────────
|
||||
//
|
||||
// These tests stand alone from the backend client — they only exercise
|
||||
// the gate added to `ComposioExecuteTool::execute` that keys on the
|
||||
// `CURRENT_AGENT_SANDBOX_MODE` task-local. The backend is never reached
|
||||
// when the gate rejects, so `fake_composio_client()` is fine.
|
||||
|
||||
fn error_text(result: &ToolResult) -> String {
|
||||
result
|
||||
.content
|
||||
.iter()
|
||||
.filter_map(|c| match c {
|
||||
crate::openhuman::tools::traits::ToolContent::Text { text } => Some(text.clone()),
|
||||
_ => None,
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ")
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn sandbox_read_only_blocks_write_scope_action() {
|
||||
let t = ComposioExecuteTool::new(fake_composio_client());
|
||||
let result =
|
||||
crate::openhuman::agent::harness::with_current_sandbox_mode(SandboxMode::ReadOnly, async {
|
||||
t.execute(serde_json::json!({ "tool": "GMAIL_SEND_EMAIL" }))
|
||||
.await
|
||||
.unwrap()
|
||||
})
|
||||
.await;
|
||||
assert!(
|
||||
result.is_error,
|
||||
"send-email under read-only must be an error"
|
||||
);
|
||||
let msg = error_text(&result);
|
||||
assert!(msg.contains("strict read-only"), "got: {msg}");
|
||||
assert!(msg.contains("`write`"), "got: {msg}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn sandbox_read_only_blocks_admin_scope_action() {
|
||||
let t = ComposioExecuteTool::new(fake_composio_client());
|
||||
let result =
|
||||
crate::openhuman::agent::harness::with_current_sandbox_mode(SandboxMode::ReadOnly, async {
|
||||
t.execute(serde_json::json!({ "tool": "GMAIL_DELETE_EMAIL" }))
|
||||
.await
|
||||
.unwrap()
|
||||
})
|
||||
.await;
|
||||
assert!(result.is_error);
|
||||
let msg = error_text(&result);
|
||||
assert!(msg.contains("`admin`"), "got: {msg}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn sandbox_read_only_passes_through_read_scope_actions_to_downstream_gates() {
|
||||
// Read-scoped slugs should survive the sandbox gate; they may
|
||||
// still be rejected by the user's scope-pref check or the
|
||||
// curated-catalog check downstream, but the sandbox layer itself
|
||||
// must not block them.
|
||||
let t = ComposioExecuteTool::new(fake_composio_client());
|
||||
let result =
|
||||
crate::openhuman::agent::harness::with_current_sandbox_mode(SandboxMode::ReadOnly, async {
|
||||
t.execute(serde_json::json!({ "tool": "GMAIL_FETCH_EMAILS" }))
|
||||
.await
|
||||
.unwrap()
|
||||
})
|
||||
.await;
|
||||
let msg = error_text(&result);
|
||||
assert!(
|
||||
!msg.contains("strict read-only"),
|
||||
"read-scoped slug must not hit the sandbox gate, got: {msg}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn sandbox_unset_leaves_all_scopes_to_downstream_gates() {
|
||||
// Outside any `with_current_sandbox_mode` scope the task-local
|
||||
// returns `None` and the gate becomes a no-op (backward
|
||||
// compatible — this is the CLI / JSON-RPC / unit-test path).
|
||||
let t = ComposioExecuteTool::new(fake_composio_client());
|
||||
let result = t
|
||||
.execute(serde_json::json!({ "tool": "GMAIL_SEND_EMAIL" }))
|
||||
.await
|
||||
.unwrap();
|
||||
let msg = error_text(&result);
|
||||
assert!(
|
||||
!msg.contains("strict read-only"),
|
||||
"no sandbox scope must never trigger the gate, got: {msg}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn sandbox_sandboxed_mode_does_not_trigger_readonly_gate() {
|
||||
// `SandboxMode::Sandboxed` is a privilege-drop / filesystem
|
||||
// restriction — orthogonal to write permissions on external
|
||||
// APIs. The gate only fires for `ReadOnly`, by design.
|
||||
let t = ComposioExecuteTool::new(fake_composio_client());
|
||||
let result = crate::openhuman::agent::harness::with_current_sandbox_mode(
|
||||
SandboxMode::Sandboxed,
|
||||
async {
|
||||
t.execute(serde_json::json!({ "tool": "GMAIL_SEND_EMAIL" }))
|
||||
.await
|
||||
.unwrap()
|
||||
},
|
||||
)
|
||||
.await;
|
||||
let msg = error_text(&result);
|
||||
assert!(
|
||||
!msg.contains("strict read-only"),
|
||||
"Sandboxed mode must not trigger the read-only gate, got: {msg}"
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user