From 43ca5197b7238acbb78b86037e51f3f284101279 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9CM4n5ter=E2=80=9D?= Date: Sat, 14 Nov 2026 23:48:00 +0800 Subject: [PATCH] fix(agent): allow approved web requests in sandboxed sessions Generated-by: OpenAI Codex --- platform/sandbox/src/policy.rs | 7 +++ plugins/agent/engine/src/reconcile/tests.rs | 1 + plugins/agent/facts/src/durable.rs | 9 ++++ plugins/agent/native/src/owner/capture.rs | 4 ++ .../agent/native/src/owner/driver/tests.rs | 1 + .../tests/model_recovery/preset_acceptance.rs | 44 +++++++++++++++++-- .../src/owner/imports/history_source.rs | 1 + .../agent/native/src/owner/notifications.rs | 4 ++ .../agent/native/src/owner/queue/controls.rs | 1 + plugins/agent/native/src/owner/steering.rs | 4 ++ plugins/agent/native/src/tools.rs | 9 +++- plugins/agent/state/src/fold/tests.rs | 1 + plugins/agent/state/src/store/stop_tests.rs | 1 + 13 files changed, 83 insertions(+), 4 deletions(-) diff --git a/platform/sandbox/src/policy.rs b/platform/sandbox/src/policy.rs index 7908949801e..81419421654 100644 --- a/platform/sandbox/src/policy.rs +++ b/platform/sandbox/src/policy.rs @@ -123,6 +123,13 @@ impl Approval { } impl Sandbox { + pub fn network(&self) -> &Network { + match self { + Self::Managed { network, .. } | Self::External { network } => network, + Self::Disabled => &Network::Allowed, + } + } + /// Prove that an existing resource needs no authority beyond this policy. /// Differently written deny globs are conservatively treated as different; /// no glob-language equivalence solver is needed for safe rebinding. diff --git a/plugins/agent/engine/src/reconcile/tests.rs b/plugins/agent/engine/src/reconcile/tests.rs index 3654ff43a37..cc273dcc7c8 100644 --- a/plugins/agent/engine/src/reconcile/tests.rs +++ b/plugins/agent/engine/src/reconcile/tests.rs @@ -56,6 +56,7 @@ pub(crate) fn policy_view(tokens: Option) -> RunView { sandbox: maka_sandbox::Sandbox::Disabled, approval: maka_sandbox::Approval::Never, }, + network_approval_ceiling: None, backend: BackendSpec::Model { binding: ModelBinding { connection_id: "policy".into(), diff --git a/plugins/agent/facts/src/durable.rs b/plugins/agent/facts/src/durable.rs index 05b643e3bea..c80069db8c5 100644 --- a/plugins/agent/facts/src/durable.rs +++ b/plugins/agent/facts/src/durable.rs @@ -158,6 +158,9 @@ pub struct RunSpec { pub workspace: String, pub permission_basis: PermissionBasis, pub permission_ceiling: maka_sandbox::Permissions, + /// Inherited network ceiling for separately approved Host requests. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub network_approval_ceiling: Option, pub backend: BackendSpec, pub collaboration: CollaborationMode, pub language: Language, @@ -760,6 +763,12 @@ impl Fact { } } impl RunSpec { + pub fn network_approval_ceiling(&self) -> &maka_sandbox::Network { + self.network_approval_ceiling + .as_ref() + .unwrap_or_else(|| self.permission_ceiling.sandbox.network()) + } + pub fn content_refs(&self) -> Vec<&ContentRef> { let mut refs = vec![&self.system_prompt, &self.tool_selection, &self.input]; match &self.backend { diff --git a/plugins/agent/native/src/owner/capture.rs b/plugins/agent/native/src/owner/capture.rs index 87f1a9665ae..af879dbd240 100644 --- a/plugins/agent/native/src/owner/capture.rs +++ b/plugins/agent/native/src/owner/capture.rs @@ -210,6 +210,9 @@ impl Owner { maka_sandbox::Approval::Never }, }; + let inherited_network = parent.sandbox_floor().network(); + let network_approval_ceiling = (inherited_network != permission_ceiling.sandbox.network()) + .then(|| inherited_network.clone()); let system_prompt = basis.behavior.clone(); if let Some(prompt) = &system_prompt { prompt.validate().map_err(|e| e.to_string())?; @@ -286,6 +289,7 @@ impl Owner { workspace: configuration.workspace.path.clone(), permission_basis: configuration.permission_basis(), permission_ceiling, + network_approval_ceiling, backend, collaboration: basis.collaboration_mode, language: basis.language, diff --git a/plugins/agent/native/src/owner/driver/tests.rs b/plugins/agent/native/src/owner/driver/tests.rs index 1265e8301b6..9a9b4d4aede 100644 --- a/plugins/agent/native/src/owner/driver/tests.rs +++ b/plugins/agent/native/src/owner/driver/tests.rs @@ -723,6 +723,7 @@ impl Rig { sandbox, approval: maka_sandbox::Approval::Never, }, + network_approval_ceiling: None, backend: match &external_backend { Some(external) => BackendSpec::Executor { selection: external.accepted.selection.clone(), diff --git a/plugins/agent/native/src/owner/driver/tests/model_recovery/preset_acceptance.rs b/plugins/agent/native/src/owner/driver/tests/model_recovery/preset_acceptance.rs index f5be160afbc..c2d5f9ec749 100644 --- a/plugins/agent/native/src/owner/driver/tests/model_recovery/preset_acceptance.rs +++ b/plugins/agent/native/src/owner/driver/tests/model_recovery/preset_acceptance.rs @@ -327,7 +327,7 @@ async fn tool_round( "{request}" ); let owner = rig.captured.lock().unwrap().clone().unwrap(); - let approve_web = name == "WebFetch" + let approve_web = matches!(name, "WebFetch" | "WebSearch") && owner .mutable_session(session) .await? @@ -418,13 +418,26 @@ async fn tool_matrix(code: bool) -> TestResult<()> { let listener = tokio::net::TcpListener::bind(("127.0.0.1", 0)).await?; let address = format!("http://{}", listener.local_addr()?); let web_calls = Arc::new(AtomicUsize::new(0)); + let search_calls = Arc::new(AtomicUsize::new(0)); let mcp_calls = Arc::new(AtomicUsize::new(0)); let web = web_calls.clone(); + let search = search_calls.clone(); let mcp = mcp_calls.clone(); let app = axum::Router::new() .route("/page",axum::routing::get(move || {let calls = web.clone(); async move { calls.fetch_add(1,Ordering::SeqCst); "web-acceptance-marker" }})) + .route("/search", axum::routing::get(move |uri: axum::http::Uri| { + let search = search.clone(); + async move { + assert_eq!(uri.query(), Some("q=Rust")); + search.fetch_add(1, Ordering::SeqCst); + ( + [("content-type", "application/json")], + json!({"results":[{"url":"https://www.rust-lang.org/"}]}).to_string(), + ) + } + })) .route("/mcp",axum::routing::post(move |body: axum::body::Bytes| { let calls = mcp.clone(); async move { use axum::response::IntoResponse; @@ -450,7 +463,8 @@ async fn tool_matrix(code: bool) -> TestResult<()> { config["tools"] = json!([ {"name":"Read","description":"Read local file","target":{"kind":"import","name":"files"},"method":"readText","nestable":true,"result":"json","confirmation":"preapproved"}, {"name":"Write","description":"Write local file","target":{"kind":"import","name":"files"},"method":"write","nestable":true,"result":"json","confirmation":"preapproved"}, - {"name":"WebFetch","description":"Fetch a URL","target":{"kind":"import","name":"web"},"method":"fetch","input":"web","nestable":true,"result":"json","confirmation":"preapproved"} + {"name":"WebFetch","description":"Fetch a URL","target":{"kind":"import","name":"web"},"method":"fetch","input":"web","nestable":true,"result":"json","confirmation":"preapproved"}, + {"name":"WebSearch","description":"Search the web","target":{"kind":"import","name":"web"},"method":"search","input":"web","nestable":true,"result":"json","confirmation":"preapproved"} ]); config["mcpServers"] = json!([{"name":"fixture","transport":{"type":"streamable_http","url":format!("{address}/mcp")},"preapproved":true}]); }).await?; @@ -458,9 +472,33 @@ async fn tool_matrix(code: bool) -> TestResult<()> { attach(&mut rig,&boot).await?; let client = rig.client.as_ref().unwrap(); let web = call(client,"web","snapshot",json!({})).await?; - assert_eq!(call(client,"web","save",json!({"revision":web["revision"],"settings":{"enabled":true,"source":"model"}})).await?["kind"],"updated"); + assert_eq!(call(client,"web","save",json!({"revision":web["revision"],"settings":{ + "enabled":true,"source":"model","backend":{"kind":"custom","endpoint":{ + "url":format!("{address}/search"),"method":"get","urlParameters":{},"body":{},"auth":null, + "query":{"location":"query","name":"q"},"limit":null, + "results":{"list":"/results","url":"/url","title":null,"snippet":null} + }} + }})).await?["kind"],"updated"); let model = model(client,&provider.url,code).await?; let preset = preset(client,&model,"Keep the reviewer role.").await?; + let session = create(client, &base, &model, None).await?; + let id = session["id"].as_str().unwrap(); + for requests in [true, false] { + let owner = rig.captured.lock().unwrap().clone().unwrap(); + let revision = owner.mutable_session(id).await?.revision; + let changed = call(client, "agent", "metadata", json!({ + "sessionId":id,"expectedRevision":revision,"title":"Sandboxed search","labels":[], + "permissions":{"settings":{"access":"workspace_write","requestPermissions":requests},"revokeGrants":true} + })).await?; + assert_eq!(changed["kind"], "committed", "{changed}"); + let output = tool_round(&rig, &mut provider, id, code, "WebSearch", json!({"query":"Rust","limit":1})).await?; + if requests { + assert!(output.contains("https://www.rust-lang.org/"), "{output}"); + } else { + assert!(output.contains("WebSearch network access was denied"), "{output}"); + } + assert_eq!(search_calls.load(Ordering::SeqCst), 1, "{output}"); + } for selected in [None,Some(&preset)] { std::fs::write(base.join("workspace/output.txt"),"unchanged-before-write")?; let session = create(client,&base,&model,selected).await?; diff --git a/plugins/agent/native/src/owner/imports/history_source.rs b/plugins/agent/native/src/owner/imports/history_source.rs index cdf7c9782d4..1694ec504a9 100644 --- a/plugins/agent/native/src/owner/imports/history_source.rs +++ b/plugins/agent/native/src/owner/imports/history_source.rs @@ -76,6 +76,7 @@ impl Owner { maka_sandbox::Approval::Never }, }, + network_approval_ceiling: None, backend: BackendSpec::Historical {}, collaboration: configuration.collaboration_mode, language: self.resolve_language(&settings), diff --git a/plugins/agent/native/src/owner/notifications.rs b/plugins/agent/native/src/owner/notifications.rs index 29da9e2f743..6fc92e63ba1 100644 --- a/plugins/agent/native/src/owner/notifications.rs +++ b/plugins/agent/native/src/owner/notifications.rs @@ -383,6 +383,10 @@ impl Owner { .sandbox .contains(&view.spec.permission_ceiling.sandbox) .map_err(|error| error.to_string())? + && request + .spec + .network_approval_ceiling() + .contains(view.spec.network_approval_ceiling()) && view .spec .permission_ceiling diff --git a/plugins/agent/native/src/owner/queue/controls.rs b/plugins/agent/native/src/owner/queue/controls.rs index 259da4f8c45..21baa36b04e 100644 --- a/plugins/agent/native/src/owner/queue/controls.rs +++ b/plugins/agent/native/src/owner/queue/controls.rs @@ -26,6 +26,7 @@ fn same_execution(a: &RunSpec, b: &RunSpec) -> bool { && a.workspace == b.workspace && a.permission_basis == b.permission_basis && a.permission_ceiling == b.permission_ceiling + && a.network_approval_ceiling() == b.network_approval_ceiling() && a.backend == b.backend && a.collaboration == b.collaboration && a.language == b.language diff --git a/plugins/agent/native/src/owner/steering.rs b/plugins/agent/native/src/owner/steering.rs index 992e2d0a880..506dcface6e 100644 --- a/plugins/agent/native/src/owner/steering.rs +++ b/plugins/agent/native/src/owner/steering.rs @@ -48,6 +48,10 @@ impl Owner { .sandbox_floor() .contains(&target.spec.permission_ceiling.sandbox) .map_err(|error| error.to_string())? + || !parent + .sandbox_floor() + .network() + .contains(target.spec.network_approval_ceiling()) { return Err("agent_steering_isolation_unavailable".into()); } diff --git a/plugins/agent/native/src/tools.rs b/plugins/agent/native/src/tools.rs index c354f4913bd..b5afd555e9e 100644 --- a/plugins/agent/native/src/tools.rs +++ b/plugins/agent/native/src/tools.rs @@ -842,11 +842,18 @@ impl Service { computer, ) .await?; - let floor = if computer { + let mut floor = if computer { access.workspace_sandbox_floor().await? } else { access.sandbox_floor(&context.call.origin).await? }; + if selection.input == ToolInput::Web + && let maka_sandbox::Sandbox::Managed { network, .. } = &mut floor + { + // Host HTTP still requires a destination grant. Its inherited ceiling + // is independent of the workspace process sandbox's base network. + *network = access.spec().network_approval_ceiling().clone(); + } let request = services .request_with_work_and_context( Identity::Agent { diff --git a/plugins/agent/state/src/fold/tests.rs b/plugins/agent/state/src/fold/tests.rs index 7d706db3052..cb3cce6908f 100644 --- a/plugins/agent/state/src/fold/tests.rs +++ b/plugins/agent/state/src/fold/tests.rs @@ -66,6 +66,7 @@ fn spec(backend: BackendSpec, reference: &ContentRef) -> RunSpec { sandbox: maka_sandbox::Sandbox::Disabled, approval: maka_sandbox::Approval::Never, }, + network_approval_ceiling: None, backend, collaboration: Default::default(), language: Default::default(), diff --git a/plugins/agent/state/src/store/stop_tests.rs b/plugins/agent/state/src/store/stop_tests.rs index 02ff6b3170f..3bc062b2ee3 100644 --- a/plugins/agent/state/src/store/stop_tests.rs +++ b/plugins/agent/state/src/store/stop_tests.rs @@ -91,6 +91,7 @@ fn spec() -> (RunSpec, Vec) { sandbox: maka_sandbox::Sandbox::Disabled, approval: maka_sandbox::Approval::Never, }, + network_approval_ceiling: None, backend: BackendSpec::Model { binding: ModelBinding { connection_id: "fixture".into(),