feat(planner): read-only sandbox gate on composio meta-tools (#904)

This commit is contained in:
obchain
2026-04-29 11:37:23 -07:00
committed by GitHub
parent fc063992ef
commit 74074cd8a7
9 changed files with 488 additions and 33 deletions
+35
View File
@@ -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
View File
@@ -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())?;
+2
View File
@@ -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,
+120
View File
@@ -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}"
);
}
}
+53
View File
@@ -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 { .. } => {}
+113
View File
@@ -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}"
);
}