feat(runtime): accept launcher TLS trust snapshots

Signed-off-by: Alex Rosenzweig <arosenzweig@squareup.com>
This commit is contained in:
Alex Rosenzweig
2026-09-28 09:44:20 -05:00
parent 68c005869b
commit 1815161d22
14 changed files with 188 additions and 5 deletions

11
Cargo.lock generated
View File

@@ -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"

View File

@@ -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",

View File

@@ -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 }

View File

@@ -158,7 +158,10 @@ async fn discover_databricks_models_with_token_source(
cfg: &Config,
token_source: Arc<dyn TokenSource>,
) -> Result<Vec<ModelEntry>, 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(

View File

@@ -13,6 +13,7 @@ mod llm;
mod mcp;
pub mod model_capabilities;
mod permission;
pub mod sandbox_runtime;
pub mod types;
mod wire;

View File

@@ -61,7 +61,7 @@ const LLM_CONNECT_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(
impl Llm {
pub fn new(cfg: &Config) -> Result<Self, AgentError> {
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

View File

@@ -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",

View File

@@ -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<reqwest::ClientBuilder, AgentError> {
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<reqwest::ClientBuilder, AgentError> {
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::<reqwest::Certificate>::new())
.build()
.unwrap();
assert!(untrusted
.get(format!("https://localhost:{port}"))
.send()
.await
.is_err());
task.abort();
}
}

View File

@@ -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"] }

View File

@@ -539,7 +539,8 @@ impl BuzzClient {
auth_tag: Option<Tag>,
auth_tag_json: Option<String>,
) -> Result<Self, CliError> {
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<bytes::Bytes, CliError> {
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())

View File

@@ -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

View File

@@ -0,0 +1,3 @@
#![deny(unsafe_code)]
//! Generic trusted-launcher support for confined clients.
pub mod tls;

View File

@@ -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<reqwest::ClientBuilder, anyhow::Error> {
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<reqwest::ClientBuilder, anyhow::Error> {
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<Vec<u8>> = 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::<Result<Vec<_>, _>>()
.map_err(|_| fail())?;
Ok(builder.tls_certs_only(certs))
}

View File

@@ -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"