mirror of
https://github.com/block/buzz.git
synced 2026-10-28 13:01:10 +00:00
feat(runtime): accept launcher TLS trust snapshots
Signed-off-by: Alex Rosenzweig <arosenzweig@squareup.com>
This commit is contained in:
11
Cargo.lock
generated
11
Cargo.lock
generated
@@ -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"
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -13,6 +13,7 @@ mod llm;
|
||||
mod mcp;
|
||||
pub mod model_capabilities;
|
||||
mod permission;
|
||||
pub mod sandbox_runtime;
|
||||
pub mod types;
|
||||
mod wire;
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
103
crates/buzz-agent/src/sandbox_runtime.rs
Normal file
103
crates/buzz-agent/src/sandbox_runtime.rs
Normal 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();
|
||||
}
|
||||
}
|
||||
@@ -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"] }
|
||||
|
||||
|
||||
@@ -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())
|
||||
|
||||
11
crates/buzz-runtime-support/Cargo.toml
Normal file
11
crates/buzz-runtime-support/Cargo.toml
Normal 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
|
||||
3
crates/buzz-runtime-support/src/lib.rs
Normal file
3
crates/buzz-runtime-support/src/lib.rs
Normal file
@@ -0,0 +1,3 @@
|
||||
#![deny(unsafe_code)]
|
||||
//! Generic trusted-launcher support for confined clients.
|
||||
pub mod tls;
|
||||
33
crates/buzz-runtime-support/src/tls.rs
Normal file
33
crates/buzz-runtime-support/src/tls.rs
Normal 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))
|
||||
}
|
||||
10
desktop/src-tauri/Cargo.lock
generated
10
desktop/src-tauri/Cargo.lock
generated
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user