diff --git a/src/openhuman/agent/agents/loader.rs b/src/openhuman/agent/agents/loader.rs index 6c0018c93..d5eb6d240 100644 --- a/src/openhuman/agent/agents/loader.rs +++ b/src/openhuman/agent/agents/loader.rs @@ -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"); diff --git a/src/openhuman/agent/agents/planner/agent.toml b/src/openhuman/agent/agents/planner/agent.toml index 15c835af4..8ba5077c0 100644 --- a/src/openhuman/agent/agents/planner/agent.toml +++ b/src/openhuman/agent/agents/planner/agent.toml @@ -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", ] diff --git a/src/openhuman/agent/bus.rs b/src/openhuman/agent/bus.rs index 0d78aa26d..ed6207fd4 100644 --- a/src/openhuman/agent/bus.rs +++ b/src/openhuman/agent/bus.rs @@ -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>` 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>` 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())?; diff --git a/src/openhuman/agent/harness/mod.rs b/src/openhuman/agent/harness/mod.rs index 8faf7d9c3..f317f3bb8 100644 --- a/src/openhuman/agent/harness/mod.rs +++ b/src/openhuman/agent/harness/mod.rs @@ -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; diff --git a/src/openhuman/agent/harness/sandbox_context.rs b/src/openhuman/agent/harness/sandbox_context.rs new file mode 100644 index 000000000..12ce99483 --- /dev/null +++ b/src/openhuman/agent/harness/sandbox_context.rs @@ -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 { + 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(mode: SandboxMode, future: F) -> R +where + F: std::future::Future, +{ + 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; + } +} diff --git a/src/openhuman/agent/harness/subagent_runner/ops.rs b/src/openhuman/agent/harness/subagent_runner/ops.rs index 79955766d..c8ba4107a 100644 --- a/src/openhuman/agent/harness/subagent_runner/ops.rs +++ b/src/openhuman/agent/harness/subagent_runner/ops.rs @@ -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, diff --git a/src/openhuman/composio/action_tool.rs b/src/openhuman/composio/action_tool.rs index c93b23994..7413b2fac 100644 --- a/src/openhuman/composio/action_tool.rs +++ b/src/openhuman/composio/action_tool.rs @@ -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 { + // 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::>() + .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}" + ); + } +} diff --git a/src/openhuman/composio/tools.rs b/src/openhuman/composio/tools.rs index 6512c4634..a6f069d48 100644 --- a/src/openhuman/composio/tools.rs +++ b/src/openhuman/composio/tools.rs @@ -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 { .. } => {} diff --git a/src/openhuman/composio/tools_tests.rs b/src/openhuman/composio/tools_tests.rs index 4b5812ddc..6c6d28f3f 100644 --- a/src/openhuman/composio/tools_tests.rs +++ b/src/openhuman/composio/tools_tests.rs @@ -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::>() + .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}" + ); +}