diff --git a/Cargo.lock b/Cargo.lock index e57532c25c..3fda7dcda2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1017,6 +1017,7 @@ dependencies = [ "async-trait", "axum", "base64 0.22.1", + "buzz-runtime-support", "dirs", "fs2", "getrandom 0.4.3", @@ -1110,6 +1111,7 @@ dependencies = [ "base64 0.22.1", "buzz-core", "buzz-persona", + "buzz-runtime-support", "buzz-sdk", "buzz-ws-client", "bytes", @@ -1507,6 +1509,15 @@ dependencies = [ "uuid", ] +[[package]] +name = "buzz-runtime-support" +version = "0.1.0" +dependencies = [ + "anyhow", + "reqwest 0.13.4", + "serde_json", +] + [[package]] name = "buzz-sdk" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index d27c63d234..05a70cc7d0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,6 +12,7 @@ members = [ "crates/buzz-audit", "crates/buzz-acp", "crates/buzz-agent", + "crates/buzz-runtime-support", "crates/sprig", "crates/buzz-test-client", "crates/buzz-ws-client", diff --git a/crates/buzz-agent/Cargo.toml b/crates/buzz-agent/Cargo.toml index 91a024a38b..7c50471644 100644 --- a/crates/buzz-agent/Cargo.toml +++ b/crates/buzz-agent/Cargo.toml @@ -43,6 +43,7 @@ name = "auth-worker" path = "tests/bin/auth_worker.rs" [dependencies] +buzz-runtime-support = { path = "../buzz-runtime-support" } tokio = { workspace = true, features = ["rt-multi-thread", "macros", "io-std", "io-util", "sync", "process", "time", "net"] } serde = { workspace = true } serde_json = { workspace = true } diff --git a/crates/buzz-agent/src/catalog.rs b/crates/buzz-agent/src/catalog.rs index 38152b3f90..1386f7dcd7 100644 --- a/crates/buzz-agent/src/catalog.rs +++ b/crates/buzz-agent/src/catalog.rs @@ -158,7 +158,10 @@ async fn discover_databricks_models_with_token_source( cfg: &Config, token_source: Arc, ) -> Result, AgentError> { - discover_databricks_models_with_client(cfg, token_source, &Client::new()).await + let client = crate::sandbox_runtime::http_builder()? + .build() + .map_err(|e| AgentError::Llm(format!("catalog http: {e}")))?; + discover_databricks_models_with_client(cfg, token_source, &client).await } pub(crate) async fn discover_databricks_models_with_client( diff --git a/crates/buzz-agent/src/lib.rs b/crates/buzz-agent/src/lib.rs index 2c9c0f2036..8e03d63f10 100644 --- a/crates/buzz-agent/src/lib.rs +++ b/crates/buzz-agent/src/lib.rs @@ -13,6 +13,7 @@ mod llm; mod mcp; pub mod model_capabilities; mod permission; +pub mod sandbox_runtime; pub mod types; mod wire; diff --git a/crates/buzz-agent/src/llm.rs b/crates/buzz-agent/src/llm.rs index afe8b87ede..1bf35bdf23 100644 --- a/crates/buzz-agent/src/llm.rs +++ b/crates/buzz-agent/src/llm.rs @@ -61,7 +61,7 @@ const LLM_CONNECT_TIMEOUT: std::time::Duration = std::time::Duration::from_secs( impl Llm { pub fn new(cfg: &Config) -> Result { - let http = Client::builder() + let http = crate::sandbox_runtime::http_builder()? .connect_timeout(LLM_CONNECT_TIMEOUT) // No client-level read_timeout: we apply a per-request total // timeout via RequestBuilder::timeout() so that escalated budgets diff --git a/crates/buzz-agent/src/mcp.rs b/crates/buzz-agent/src/mcp.rs index c9f1c92488..fad4dd0330 100644 --- a/crates/buzz-agent/src/mcp.rs +++ b/crates/buzz-agent/src/mcp.rs @@ -48,6 +48,8 @@ const PASSTHROUGH_ENV: &[&str] = &[ // Explicit Buzz-owned OAuth root for named demo builds. The agent may spawn // auth-capable child tools after clearing its ambient environment. "BUZZ_AGENT_CONFIG_DIR", + // Read-only launcher trust snapshot; do not forward the auth-broker capability. + "BUZZ_SANDBOX_TLS_ROOTS", // SSH — required for git clone/push over SSH (git@github.com:...) "SSH_AUTH_SOCK", "SSH_AGENT_PID", diff --git a/crates/buzz-agent/src/sandbox_runtime.rs b/crates/buzz-agent/src/sandbox_runtime.rs new file mode 100644 index 0000000000..712c9e2f50 --- /dev/null +++ b/crates/buzz-agent/src/sandbox_runtime.rs @@ -0,0 +1,103 @@ +//! Local TLS verification for agents started by trusted external launchers. +use crate::AgentError; +#[cfg(test)] +use std::{path::Path, sync::Arc}; + +/// Path to the immutable certificate snapshot supplied by the launcher. +pub const ROOTS_ENV: &str = "BUZZ_SANDBOX_TLS_ROOTS"; + +/// Native trust is captured before confinement. Verification remains enabled, +/// using rustls locally instead of macOS trustd (which can bypass network policy). +pub fn http_builder() -> Result { + buzz_runtime_support::tls::http_builder().map_err(|e| AgentError::Llm(e.to_string())) +} +#[cfg(test)] +fn builder_with_roots( + builder: reqwest::ClientBuilder, + path: &Path, +) -> Result { + buzz_runtime_support::tls::builder_with_roots(builder, path) + .map_err(|e| AgentError::Llm(e.to_string())) +} + +#[cfg(test)] +mod tests { + use super::*; + use reqwest::StatusCode; + #[test] + fn missing_trust_snapshot_fails_closed() { + assert!(builder_with_roots( + reqwest::Client::builder(), + Path::new("/nonexistent/sandbox-certs.json") + ) + .is_err()); + } + #[tokio::test] + async fn snapshot_verifier_accepts_trusted_cert_and_rejects_wrong_hostname() { + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + let cert = rcgen::generate_simple_self_signed(vec!["localhost".into()]).unwrap(); + let der = cert.cert.der().clone(); + let key = tokio_rustls::rustls::pki_types::PrivatePkcs8KeyDer::from( + cert.signing_key.serialize_der(), + ); + let config = tokio_rustls::rustls::ServerConfig::builder_with_provider(Arc::new( + tokio_rustls::rustls::crypto::ring::default_provider(), + )) + .with_safe_default_protocol_versions() + .unwrap() + .with_no_client_auth() + .with_single_cert(vec![der.clone()], key.into()) + .unwrap(); + let acceptor = tokio_rustls::TlsAcceptor::from(Arc::new(config)); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let port = listener.local_addr().unwrap().port(); + let task = tokio::spawn(async move { + loop { + let (socket, _) = listener.accept().await.unwrap(); + let acceptor = acceptor.clone(); + tokio::spawn(async move { + if let Ok(mut stream) = acceptor.accept(socket).await { + let mut buffer = [0; 4096]; + let _ = stream.read(&mut buffer).await; + let _ = stream.write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\nConnection: close\r\n\r\nok").await; + } + }); + } + }); + let file = tempfile::NamedTempFile::new().unwrap(); + std::fs::write( + file.path(), + serde_json::to_vec(&vec![der.as_ref().to_vec()]).unwrap(), + ) + .unwrap(); + let client = builder_with_roots(reqwest::Client::builder().no_proxy(), file.path()) + .unwrap() + .build() + .unwrap(); + assert_eq!( + client + .get(format!("https://localhost:{port}")) + .send() + .await + .unwrap() + .status(), + StatusCode::OK + ); + assert!(client + .get(format!("https://127.0.0.1:{port}")) + .send() + .await + .is_err()); + let untrusted = reqwest::Client::builder() + .no_proxy() + .tls_certs_only(Vec::::new()) + .build() + .unwrap(); + assert!(untrusted + .get(format!("https://localhost:{port}")) + .send() + .await + .is_err()); + task.abort(); + } +} diff --git a/crates/buzz-cli/Cargo.toml b/crates/buzz-cli/Cargo.toml index 59d1bb2cee..4df54bab49 100644 --- a/crates/buzz-cli/Cargo.toml +++ b/crates/buzz-cli/Cargo.toml @@ -16,6 +16,7 @@ name = "buzz" path = "src/main.rs" [dependencies] +buzz-runtime-support = { path = "../buzz-runtime-support" } # CLI argument parsing — derive macros + env var support (BUZZ_API_TOKEN auto-wired) clap = { version = "4", features = ["derive", "env"] } diff --git a/crates/buzz-cli/src/client.rs b/crates/buzz-cli/src/client.rs index 54441f55cc..bab7d5d47c 100644 --- a/crates/buzz-cli/src/client.rs +++ b/crates/buzz-cli/src/client.rs @@ -539,7 +539,8 @@ impl BuzzClient { auth_tag: Option, auth_tag_json: Option, ) -> Result { - let http = reqwest::Client::builder() + let http = buzz_runtime_support::tls::http_builder() + .map_err(|e| CliError::Other(e.to_string()))? .timeout(env_duration_secs("BUZZ_TIMEOUT_SECS", 30)) .connect_timeout(env_duration_secs("BUZZ_CONNECT_TIMEOUT_SECS", 15)) .build() @@ -917,7 +918,8 @@ impl BuzzClient { let body = serde_json::to_vec(body).map_err(|e| CliError::Other(e.to_string()))?; let auth = sign_nip98(&self.keys, "POST", &url, Some(&body))?; let unknown = |detail: String| CliError::DeliveryUnknown(detail); - let http = reqwest::Client::builder() + let http = buzz_runtime_support::tls::http_builder() + .map_err(|e| CliError::Other(e.to_string()))? .redirect(reqwest::redirect::Policy::none()) .timeout(env_duration_secs("BUZZ_TIMEOUT_SECS", 30)) .connect_timeout(env_duration_secs("BUZZ_CONNECT_TIMEOUT_SECS", 15)) @@ -1336,7 +1338,8 @@ impl BuzzClient { pub async fn download_media(&self, input: &str) -> Result { let url = media_url_from_input(&self.relay_url, input)?; // Use a dedicated client: 120 s timeout, no redirect forwarding. - let client = reqwest::Client::builder() + let client = buzz_runtime_support::tls::http_builder() + .map_err(|e| CliError::Other(e.to_string()))? .timeout(Duration::from_secs(120)) // Do not forward Authorization or x-auth-tag to redirect targets. .redirect(reqwest::redirect::Policy::none()) diff --git a/crates/buzz-runtime-support/Cargo.toml b/crates/buzz-runtime-support/Cargo.toml new file mode 100644 index 0000000000..c0f5b81d0d --- /dev/null +++ b/crates/buzz-runtime-support/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "buzz-runtime-support" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true + +[dependencies] +anyhow.workspace = true +serde_json.workspace = true +reqwest.workspace = true diff --git a/crates/buzz-runtime-support/src/lib.rs b/crates/buzz-runtime-support/src/lib.rs new file mode 100644 index 0000000000..b1c7dbea7f --- /dev/null +++ b/crates/buzz-runtime-support/src/lib.rs @@ -0,0 +1,3 @@ +#![deny(unsafe_code)] +//! Generic trusted-launcher support for confined clients. +pub mod tls; diff --git a/crates/buzz-runtime-support/src/tls.rs b/crates/buzz-runtime-support/src/tls.rs new file mode 100644 index 0000000000..8cf1dd1aeb --- /dev/null +++ b/crates/buzz-runtime-support/src/tls.rs @@ -0,0 +1,33 @@ +//! Local certificate verification for protected agents and their CLI helpers. +use std::path::Path; +/// Optional launcher-owned certificate snapshot path. +pub const ROOTS_ENV: &str = "BUZZ_SANDBOX_TLS_ROOTS"; +/// Use ordinary platform trust unless an explicit launcher snapshot is configured. +pub fn http_builder() -> Result { + let builder = reqwest::Client::builder(); + match std::env::var_os(ROOTS_ENV) { + None => Ok(builder), + Some(path) => builder_with_roots(builder, Path::new(&path)), + } +} +/// Restrict verification to a nonempty DER certificate snapshot; invalid input fails closed. +pub fn builder_with_roots( + builder: reqwest::ClientBuilder, + path: &Path, +) -> Result { + let fail = || anyhow::anyhow!("invalid sandbox TLS trust snapshot"); + let bytes = std::fs::read(path).map_err(|_| fail())?; + if bytes.len() > 8 * 1024 * 1024 { + return Err(fail()); + } + let roots: Vec> = serde_json::from_slice(&bytes).map_err(|_| fail())?; + if roots.is_empty() { + return Err(fail()); + } + let certs = roots + .iter() + .map(|der| reqwest::Certificate::from_der(der)) + .collect::, _>>() + .map_err(|_| fail())?; + Ok(builder.tls_certs_only(certs)) +} diff --git a/desktop/src-tauri/Cargo.lock b/desktop/src-tauri/Cargo.lock index 9568b6083e..6d1a6e953d 100644 --- a/desktop/src-tauri/Cargo.lock +++ b/desktop/src-tauri/Cargo.lock @@ -1076,6 +1076,7 @@ dependencies = [ "async-trait", "axum", "base64 0.22.1", + "buzz-runtime-support", "dirs", "fs2", "getrandom 0.4.3", @@ -1252,6 +1253,15 @@ dependencies = [ "thiserror 2.0.18", ] +[[package]] +name = "buzz-runtime-support" +version = "0.1.0" +dependencies = [ + "anyhow", + "reqwest 0.13.4", + "serde_json", +] + [[package]] name = "buzz-sdk" version = "0.1.0"