diff --git a/crates/machine/src/machine_bus.rs b/crates/machine/src/machine_bus.rs index fb5e102..45c682d 100644 --- a/crates/machine/src/machine_bus.rs +++ b/crates/machine/src/machine_bus.rs @@ -11,6 +11,7 @@ use crate::virtio::blk::VirtioBlk; use crate::virtio::console::VirtioConsole; use crate::virtio::fs::{Mount, VirtioFs}; use crate::virtio::net::VirtioNet; +use crate::virtio::secrets::SecretBinding; use crate::virtio::slirp::SlirpBackend; use crate::{ @@ -73,7 +74,11 @@ impl MachineBus { } pub fn attach_net(&mut self) { - let backend = SlirpBackend::new(GUEST_MAC); + self.attach_net_with_secrets(Vec::new()); + } + + pub fn attach_net_with_secrets(&mut self, secrets: Vec) { + let backend = SlirpBackend::with_secrets(GUEST_MAC, secrets); self.net = Some(VirtioNet::new(backend, GUEST_MAC)); } diff --git a/crates/machine/src/virtio/https_gateway.rs b/crates/machine/src/virtio/https_gateway.rs index 0344321..b7c9095 100644 --- a/crates/machine/src/virtio/https_gateway.rs +++ b/crates/machine/src/virtio/https_gateway.rs @@ -5,6 +5,7 @@ use std::collections::VecDeque; use rustls::ClientConfig; use std::sync::Arc; +use super::secrets::{SecretBinding, Substitution}; use super::tls_proxy::{Timing, TlsContext, TlsProxy}; use super::upstream::{PREAMBLE_PREFIX, Upstream, UpstreamMode, UpstreamStatus}; use crate::trace::{HttpObserver, Tracer}; @@ -25,6 +26,7 @@ pub struct HttpsGateway { dst_ip: [u8; 4], timing: Option, tracer: Option, + secrets: Vec, } impl HttpsGateway { @@ -36,9 +38,14 @@ impl HttpsGateway { dst_ip, timing: Timing::new(), tracer: None, + secrets: Vec::new(), } } + pub fn carry_secrets(&mut self, secrets: Vec) { + self.secrets = secrets; + } + pub fn observe_http(&mut self, tracer: Tracer) { self.tracer = Some(tracer); } @@ -60,6 +67,7 @@ impl HttpsGateway { dst_ip, timing: None, tracer: None, + secrets: Vec::new(), } } @@ -134,6 +142,8 @@ impl HttpsGateway { if let Some(tracer) = self.tracer.take() { proxy.observe_http(HttpObserver::new(tracer, "https", self.dst_ip, 443)); } + + proxy.carry_secrets(std::mem::take(&mut self.secrets)); proxy.push_from_guest(&buffered); self.state = GatewayState::Tls(Box::new(proxy)); } @@ -179,6 +189,7 @@ impl HttpsGateway { port, &host, self.timing.take(), + &self.secrets, ) { Ok(mut bridge) => { if let Some(tracer) = self.tracer.take() { @@ -223,6 +234,7 @@ struct PlainBridge { upstream_hs_done: bool, marked_upstream_hs: bool, first_reply: bool, + substitution: Option, } impl PlainBridge { @@ -233,6 +245,7 @@ impl PlainBridge { port: u16, host: &str, timing: Option, + secrets: &[SecretBinding], ) -> Result { let upstream = Upstream::connect(mode, client_config, dst_ip, port, host)?; @@ -247,6 +260,7 @@ impl PlainBridge { upstream_hs_done: false, marked_upstream_hs: false, first_reply: false, + substitution: Substitution::for_host(secrets, host), }; if let Some(t) = &bridge.timing { t.mark("upstream TCP connected"); @@ -259,7 +273,27 @@ impl PlainBridge { http.observe(bytes); } - if self.upstream.send_plaintext(bytes).is_err() { + let outbound = match &mut self.substitution { + None => bytes.to_vec(), + Some(substitution) => match substitution.feed(bytes) { + Ok(bytes) => bytes, + Err(refused) => { + log::warn!( + "secrets: refusing a credential for {}, which is not on its allowlist", + refused.host + ); + self.failed = true; + return; + } + }, + }; + + if outbound.is_empty() { + self.pump(); + return; + } + + if self.upstream.send_plaintext(&outbound).is_err() { self.failed = true; return; } @@ -332,8 +366,8 @@ impl PlainBridge { #[cfg(test)] mod tests { use super::super::tls_proxy::{ - ca_cert_pem, client_config_trusting, spawn_plaintext_host, spawn_test_upstream, - spawn_test_upstream_rst, spawn_test_upstream_streaming, + ca_cert_pem, client_config_trusting, spawn_capturing_upstream, spawn_plaintext_host, + spawn_test_upstream, spawn_test_upstream_rst, spawn_test_upstream_streaming, }; use super::*; use std::time::Duration; @@ -347,6 +381,79 @@ mod tests { } } + fn secret_for(hosts: &[&str]) -> Vec { + vec![SecretBinding { + placeholder: "vpod-secret-key-a1b2c3d4".to_string(), + value: "sk-ant-the-real-thing".to_string(), + hosts: hosts.iter().map(|host| host.to_string()).collect(), + }] + } + + #[test] + fn the_preamble_path_swaps_a_credential_too() { + let (port, up_ca, seen, up) = spawn_capturing_upstream(UPSTREAM_REPLY); + let ctx = TlsContext::new().unwrap(); + let mut gateway = + HttpsGateway::new_test(&ctx, [127, 0, 0, 1], client_config_trusting(&up_ca)); + gateway.carry_secrets(secret_for(&["localhost"])); + + let wire = format!( + "VPOD-CONNECT localhost {port}\nGET / HTTP/1.1\r\nHost: localhost\r\n\ + x-api-key: vpod-secret-key-a1b2c3d4\r\nContent-Length: 0\r\n\r\n" + ); + gateway.push_from_guest(wire.as_bytes()); + + let mut got = Vec::new(); + for _ in 0..2000 { + drain(&mut gateway, &mut got); + if gateway.eof() { + break; + } + std::thread::sleep(Duration::from_millis(1)); + } + let _ = up.join(); + + let received = + String::from_utf8_lossy(&seen.recv().expect("upstream saw no request")).into_owned(); + + assert!( + received.contains("sk-ant-the-real-thing"), + "the preamble path sent the stand-in: {received}" + ); + assert!(!received.contains("vpod-secret-key-a1b2c3d4")); + } + + #[test] + fn the_preamble_path_refuses_a_credential_bound_elsewhere() { + let (port, up_ca, seen, up) = spawn_capturing_upstream(UPSTREAM_REPLY); + let ctx = TlsContext::new().unwrap(); + let mut gateway = + HttpsGateway::new_test(&ctx, [127, 0, 0, 1], client_config_trusting(&up_ca)); + gateway.carry_secrets(secret_for(&["api.anthropic.com"])); + + let wire = format!( + "VPOD-CONNECT localhost {port}\nGET / HTTP/1.1\r\nHost: api.anthropic.com\r\n\ + x-api-key: vpod-secret-key-a1b2c3d4\r\nContent-Length: 0\r\n\r\n" + ); + gateway.push_from_guest(wire.as_bytes()); + + let mut got = Vec::new(); + for _ in 0..200 { + drain(&mut gateway, &mut got); + if gateway.failed() { + break; + } + std::thread::sleep(Duration::from_millis(1)); + } + drop(up); + + assert!(gateway.failed(), "the connection survived the refusal"); + assert!( + seen.try_recv().is_err(), + "a request reached a host outside the allowlist" + ); + } + #[test] fn preamble_bridges_plaintext_to_real_tls_upstream() { let (port, up_ca, up) = spawn_test_upstream(UPSTREAM_REPLY); diff --git a/crates/machine/src/virtio/mod.rs b/crates/machine/src/virtio/mod.rs index e7c4438..fa462dc 100644 --- a/crates/machine/src/virtio/mod.rs +++ b/crates/machine/src/virtio/mod.rs @@ -3,6 +3,7 @@ pub mod console; pub mod fs; pub mod https_gateway; pub mod net; +pub mod secrets; pub mod slirp; pub mod tls_proxy; pub mod upstream; diff --git a/crates/machine/src/virtio/secrets.rs b/crates/machine/src/virtio/secrets.rs new file mode 100644 index 0000000..f8fd9b3 --- /dev/null +++ b/crates/machine/src/virtio/secrets.rs @@ -0,0 +1,417 @@ +#[derive(Clone, Debug)] +pub struct SecretBinding { + pub placeholder: String, + pub value: String, + pub hosts: Vec, +} + +const MAX_HEAD_BYTES: usize = 64 * 1024; + +#[derive(Debug, PartialEq, Eq)] +pub struct Refused { + pub placeholder: String, + pub host: String, +} + +#[derive(Debug, PartialEq, Eq)] +enum Phase { + Head(Vec), + Body(u64), + Opaque, +} + +pub struct Substitution { + allowed: Vec, + forbidden: Vec, + host: String, + phase: Phase, +} + +impl Substitution { + pub fn for_host(bindings: &[SecretBinding], host: &str) -> Option { + if bindings.is_empty() { + return None; + } + + let (allowed, rejected): (Vec<_>, Vec<_>) = bindings + .iter() + .cloned() + .partition(|binding| binding.hosts.iter().any(|name| host_matches(name, host))); + + Some(Self { + allowed, + forbidden: rejected + .into_iter() + .map(|binding| binding.placeholder) + .collect(), + host: host.to_string(), + phase: Phase::Head(Vec::new()), + }) + } + + pub fn feed(&mut self, bytes: &[u8]) -> Result, Refused> { + let mut pending = bytes.to_vec(); + let mut out = Vec::with_capacity(bytes.len()); + + loop { + match &mut self.phase { + Phase::Opaque => { + self.refuse_forbidden(&pending)?; + out.extend_from_slice(&pending); + return Ok(out); + } + + Phase::Body(remaining) => { + let take = (*remaining).min(pending.len() as u64) as usize; + out.extend_from_slice(&pending[..take]); + *remaining -= take as u64; + pending.drain(..take); + + if *remaining > 0 { + return Ok(out); + } + self.phase = Phase::Head(Vec::new()); + if pending.is_empty() { + return Ok(out); + } + } + + Phase::Head(head) => { + head.extend_from_slice(&pending); + pending.clear(); + + let Some(end) = find_head_end(head) else { + if head.len() > MAX_HEAD_BYTES { + return Err(Refused { + placeholder: String::new(), + host: self.host.clone(), + }); + } + + return Ok(out); + }; + + let rest = head.split_off(end); + let head = std::mem::take(head); + + self.refuse_forbidden(&head)?; + let (rewritten, body) = self.rewrite_head(head); + out.extend_from_slice(&rewritten); + + self.phase = match body { + Some(length) => Phase::Body(length), + None => Phase::Opaque, + }; + pending = rest; + + if pending.is_empty() && self.phase != Phase::Opaque { + return Ok(out); + } + } + } + } + } + + fn refuse_forbidden(&self, bytes: &[u8]) -> Result<(), Refused> { + for placeholder in &self.forbidden { + if contains(bytes, placeholder.as_bytes()) { + return Err(Refused { + placeholder: placeholder.clone(), + host: self.host.clone(), + }); + } + } + + Ok(()) + } + + /// Returns the head to send and how long the body is, or `None` when the + /// body has no length the sender declared and parsing has to stop. + fn rewrite_head(&self, head: Vec) -> (Vec, Option) { + let Ok(text) = std::str::from_utf8(&head) else { + return (head, None); + }; + + let mut rewritten = text.to_string(); + for binding in &self.allowed { + if rewritten.contains(&binding.placeholder) { + rewritten = rewritten.replace(&binding.placeholder, &binding.value); + } + } + + (rewritten.into_bytes(), body_length(text)) + } +} + +fn host_matches(pattern: &str, host: &str) -> bool { + match pattern.strip_prefix("*.") { + Some(suffix) => host + .split_once('.') + .is_some_and(|(_, rest)| rest.eq_ignore_ascii_case(suffix)), + None => pattern.eq_ignore_ascii_case(host), + } +} + +fn find_head_end(bytes: &[u8]) -> Option { + bytes + .windows(4) + .position(|window| window == b"\r\n\r\n") + .map(|start| start + 4) +} + +fn contains(haystack: &[u8], needle: &[u8]) -> bool { + !needle.is_empty() + && haystack.len() >= needle.len() + && haystack + .windows(needle.len()) + .any(|window| window == needle) +} + +fn body_length(head: &str) -> Option { + let mut lines = head.split("\r\n"); + + let request_line = lines.next()?; + let mut parts = request_line.split(' '); + let method = parts.next()?; + let _target = parts.next()?; + let version = parts.next()?; + + if parts.next().is_some() + || !version.starts_with("HTTP/1.") + || method.is_empty() + || !method.bytes().all(|byte| byte.is_ascii_uppercase()) + { + return None; + } + + let mut length = 0; + for line in lines { + let Some((name, value)) = line.split_once(':') else { + continue; + }; + + if name.eq_ignore_ascii_case("transfer-encoding") { + return None; + } + if name.eq_ignore_ascii_case("content-length") { + length = value.trim().parse().ok()?; + } + } + + Some(length) +} + +#[cfg(test)] +mod tests { + use super::*; + + const PLACEHOLDER: &str = "vpod-secret-key-a1b2c3d4"; + const VALUE: &str = "sk-ant-the-real-thing"; + + fn binding(hosts: &[&str]) -> SecretBinding { + SecretBinding { + placeholder: PLACEHOLDER.to_string(), + value: VALUE.to_string(), + hosts: hosts.iter().map(|host| host.to_string()).collect(), + } + } + + fn request(header_value: &str, body: &str) -> String { + format!( + "POST /v1/messages HTTP/1.1\r\nHost: api.anthropic.com\r\n\ + x-api-key: {header_value}\r\nContent-Length: {}\r\n\r\n{body}", + body.len() + ) + } + + fn feed_all(substitution: &mut Substitution, chunks: &[&str]) -> Result { + let mut out = Vec::new(); + for chunk in chunks { + out.extend(substitution.feed(chunk.as_bytes())?); + } + + Ok(String::from_utf8(out).expect("output was not utf8")) + } + + #[test] + fn a_placeholder_bound_for_an_allowed_host_is_swapped() { + let mut sub = + Substitution::for_host(&[binding(&["api.anthropic.com"])], "api.anthropic.com") + .expect("bindings were given"); + + let out = feed_all(&mut sub, &[&request(PLACEHOLDER, "{}")]).expect("allowed"); + + assert!(out.contains(VALUE), "the real value never went out"); + assert!(!out.contains(PLACEHOLDER), "the stand-in went out too"); + } + + #[test] + fn a_placeholder_bound_for_another_host_refuses_the_connection() { + let mut sub = + Substitution::for_host(&[binding(&["api.anthropic.com"])], "evil.example.com") + .expect("bindings were given"); + + let refused = feed_all(&mut sub, &[&request(PLACEHOLDER, "{}")]).expect_err("refused"); + + assert_eq!(refused.placeholder, PLACEHOLDER); + assert_eq!(refused.host, "evil.example.com"); + } + + #[test] + fn a_request_to_an_unlisted_host_without_the_placeholder_is_untouched() { + let mut sub = + Substitution::for_host(&[binding(&["api.anthropic.com"])], "example.com").unwrap(); + + let wire = request("nothing-secret", "{}"); + let out = feed_all(&mut sub, &[&wire]).expect("no placeholder, no refusal"); + + assert_eq!(out, wire); + } + + #[test] + fn a_host_header_naming_an_allowed_host_does_not_earn_the_swap() { + // The connection is to evil.example.com; only the header claims otherwise. + let mut sub = + Substitution::for_host(&[binding(&["api.anthropic.com"])], "evil.example.com").unwrap(); + + let wire = format!( + "GET / HTTP/1.1\r\nHost: api.anthropic.com\r\nx-api-key: {PLACEHOLDER}\r\n\ + Content-Length: 0\r\n\r\n" + ); + + assert!( + feed_all(&mut sub, &[&wire]).is_err(), + "a header bought authority" + ); + } + + #[test] + fn a_placeholder_split_across_two_reads_is_still_swapped() { + let mut sub = + Substitution::for_host(&[binding(&["api.anthropic.com"])], "api.anthropic.com") + .unwrap(); + + let wire = request(PLACEHOLDER, "{}"); + let (first, second) = wire.split_at(wire.find(PLACEHOLDER).unwrap() + 8); + + let out = feed_all(&mut sub, &[first, second]).expect("allowed"); + + assert!(out.contains(VALUE)); + assert!(!out.contains(PLACEHOLDER)); + } + + #[test] + fn a_split_placeholder_bound_elsewhere_is_still_caught() { + let mut sub = + Substitution::for_host(&[binding(&["api.anthropic.com"])], "evil.example.com").unwrap(); + + let wire = request(PLACEHOLDER, "{}"); + let (first, second) = wire.split_at(wire.find(PLACEHOLDER).unwrap() + 8); + + assert!( + feed_all(&mut sub, &[first, second]).is_err(), + "the split hid it" + ); + } + + #[test] + fn a_second_request_on_the_same_connection_is_swapped_too() { + let mut sub = + Substitution::for_host(&[binding(&["api.anthropic.com"])], "api.anthropic.com") + .unwrap(); + + let wire = format!( + "{}{}", + request(PLACEHOLDER, "{}"), + request(PLACEHOLDER, "{}") + ); + let out = feed_all(&mut sub, &[&wire]).expect("allowed"); + + assert_eq!( + out.matches(VALUE).count(), + 2, + "keep-alive lost the second one" + ); + assert!(!out.contains(PLACEHOLDER)); + } + + #[test] + fn a_body_that_looks_like_a_head_is_not_parsed_as_one() { + let mut sub = + Substitution::for_host(&[binding(&["api.anthropic.com"])], "api.anthropic.com") + .unwrap(); + + let body = "GET /nested HTTP/1.1\r\nx-api-key: nothing\r\n\r\n"; + let out = feed_all(&mut sub, &[&request(PLACEHOLDER, body)]).expect("allowed"); + + assert!(out.ends_with(body), "the body was rewritten as a head"); + } + + #[test] + fn two_secrets_do_not_cross() { + let anthropic = SecretBinding { + placeholder: "vpod-secret-a".to_string(), + value: "value-a".to_string(), + hosts: vec!["api.anthropic.com".to_string()], + }; + let github = SecretBinding { + placeholder: "vpod-secret-b".to_string(), + value: "value-b".to_string(), + hosts: vec!["api.github.com".to_string()], + }; + + let mut sub = Substitution::for_host(&[anthropic, github], "api.anthropic.com").unwrap(); + + let out = feed_all(&mut sub, &[&request("vpod-secret-a", "{}")]).expect("allowed"); + assert!(out.contains("value-a")); + + let mut sub = + Substitution::for_host(&[binding(&["api.anthropic.com"])], "api.anthropic.com") + .unwrap(); + let github_key = feed_all(&mut sub, &[&request("vpod-secret-b", "{}")]); + assert!( + github_key.is_ok(), + "an unrelated stand-in is not ours to refuse" + ); + } + + #[test] + fn a_wildcard_matches_one_label_and_not_the_apex() { + assert!(host_matches("*.example.com", "api.example.com")); + assert!(!host_matches("*.example.com", "example.com")); + assert!(!host_matches("*.example.com", "a.b.example.com")); + assert!(host_matches("API.Example.com", "api.example.com")); + } + + #[test] + fn a_head_that_never_ends_is_refused_rather_than_buffered_forever() { + let mut sub = + Substitution::for_host(&[binding(&["api.anthropic.com"])], "api.anthropic.com") + .unwrap(); + + let flood = "x".repeat(MAX_HEAD_BYTES + 1); + + assert!( + feed_all(&mut sub, &[&flood]).is_err(), + "buffered without bound" + ); + } + + #[test] + fn traffic_that_is_not_http_still_refuses_a_forbidden_placeholder() { + let mut sub = + Substitution::for_host(&[binding(&["api.anthropic.com"])], "evil.example.com").unwrap(); + + let wire = format!("\x16\x03\x01 binary junk {PLACEHOLDER} more junk\r\n\r\n"); + + assert!( + feed_all(&mut sub, &[&wire]).is_err(), + "opaque bytes smuggled it out" + ); + } + + #[test] + fn nothing_bound_means_nothing_to_do() { + assert!(Substitution::for_host(&[], "api.anthropic.com").is_none()); + } +} diff --git a/crates/machine/src/virtio/slirp/mod.rs b/crates/machine/src/virtio/slirp/mod.rs index ecede45..fcde91e 100644 --- a/crates/machine/src/virtio/slirp/mod.rs +++ b/crates/machine/src/virtio/slirp/mod.rs @@ -17,6 +17,7 @@ use tcp::{TcpConn, TcpKey, TcpState}; use udp::{UdpConn, UdpKey}; use super::net::NetworkBackend; +use super::secrets::SecretBinding; use super::tls_proxy::TlsContext; use crate::trace::Tracer; @@ -29,10 +30,15 @@ pub struct SlirpBackend { dhcp_xid: u32, tls: Option, tracer: Option, + secrets: Vec, } impl SlirpBackend { pub fn new(guest_mac: [u8; 6]) -> Self { + Self::with_secrets(guest_mac, Vec::new()) + } + + pub fn with_secrets(guest_mac: [u8; 6], secrets: Vec) -> Self { let tls = match TlsContext::new() { Ok(ctx) => Some(ctx), Err(e) => { @@ -50,6 +56,7 @@ impl SlirpBackend { dhcp_xid: 0, tls, tracer: None, + secrets, } } diff --git a/crates/machine/src/virtio/slirp/tcp.rs b/crates/machine/src/virtio/slirp/tcp.rs index 3ccb15a..d957dee 100644 --- a/crates/machine/src/virtio/slirp/tcp.rs +++ b/crates/machine/src/virtio/slirp/tcp.rs @@ -552,6 +552,7 @@ impl SlirpBackend { if let Some(ctx) = self.tls.as_ref().filter(|_| host_port == HTTPS_PORT) { let mut gateway = HttpsGateway::new(ctx, host_ip); + gateway.carry_secrets(self.secrets.clone()); if let Some(tracer) = self .tracer .as_ref() diff --git a/crates/machine/src/virtio/tls_proxy/mod.rs b/crates/machine/src/virtio/tls_proxy/mod.rs index 2bbd048..217ad47 100644 --- a/crates/machine/src/virtio/tls_proxy/mod.rs +++ b/crates/machine/src/virtio/tls_proxy/mod.rs @@ -27,6 +27,7 @@ use x509_cert::serial_number::SerialNumber; use x509_cert::spki::SubjectPublicKeyInfoOwned; use x509_cert::time::{Time, Validity}; +use super::secrets::{SecretBinding, Substitution}; use super::upstream::{Upstream, UpstreamMode, UpstreamStatus}; use crate::trace::HttpObserver; @@ -38,8 +39,8 @@ mod tests; #[cfg(test)] pub(crate) use testutil::{ - client_config_trusting, spawn_plaintext_host, spawn_test_upstream, spawn_test_upstream_rst, - spawn_test_upstream_streaming, + client_config_trusting, spawn_capturing_upstream, spawn_plaintext_host, spawn_test_upstream, + spawn_test_upstream_rst, spawn_test_upstream_streaming, }; const CA_KEY_PEM: &str = include_str!("../../../assets/tls/vpod-ca-key.pem"); @@ -240,6 +241,8 @@ pub struct TlsProxy { close_notified: bool, timing: Option, http: Option, + secrets: Vec, + substitution: Option, } pub(crate) struct Timing { @@ -311,9 +314,15 @@ impl TlsProxy { close_notified: false, timing, http: None, + secrets: Vec::new(), + substitution: None, }) } + pub(crate) fn carry_secrets(&mut self, secrets: Vec) { + self.secrets = secrets; + } + pub(crate) fn observe_http(&mut self, http: HttpObserver) { self.http = Some(http); } @@ -389,6 +398,9 @@ impl TlsProxy { if let Some(http) = &mut self.http { http.set_default_host(&sni); } + // The upstream handshake validates a real certificate for this name, + // so a guest that lies about it cannot reach the server it named. + self.substitution = Substitution::for_host(&self.secrets, &sni); self.connect_upstream(&sni); if let (Some(t), true) = (&mut self.timing, self.upstream.is_some()) @@ -410,7 +422,34 @@ impl TlsProxy { http.observe(&buf[..n]); } - if upstream.send_plaintext(&buf[..n]).is_err() { + // After the observer, never before: swapping first would + // record the real credential in the trace of any API that + // takes its key in a query string. + let outbound = match &mut self.substitution { + None => buf[..n].to_vec(), + Some(substitution) => match substitution.feed(&buf[..n]) { + Ok(bytes) => bytes, + Err(refused) => { + log::warn!( + "secrets: refusing {} for {}, which is not on its allowlist", + if refused.placeholder.is_empty() { + "an oversized request head" + } else { + "a credential" + }, + refused.host + ); + self.failed = true; + return; + } + }, + }; + + if outbound.is_empty() { + continue; + } + + if upstream.send_plaintext(&outbound).is_err() { self.failed = true; return; } @@ -567,6 +606,8 @@ impl TlsProxy { close_notified: false, timing: None, http: None, + secrets: Vec::new(), + substitution: None, } } } diff --git a/crates/machine/src/virtio/tls_proxy/tests.rs b/crates/machine/src/virtio/tls_proxy/tests.rs index cb7a805..26e8d10 100644 --- a/crates/machine/src/virtio/tls_proxy/tests.rs +++ b/crates/machine/src/virtio/tls_proxy/tests.rs @@ -890,3 +890,153 @@ fn large_response_delivered_in_full_without_truncation() { .map(|i| got.len() - (i + 4)); assert_eq!(body, Some(BODY), "large body truncated: {body:?} of {BODY}"); } + +/// Drive a real guest TLS client through the proxy and return what the upstream +/// received, or `None` when the proxy refused the connection. +fn request_through_proxy( + proxy: &mut TlsProxy, + up_ca: &str, + seen: &std::sync::mpsc::Receiver>, + request: &str, +) -> Option { + let _ = up_ca; + let mut guest_roots = RootCertStore::empty(); + let vpod_ca_der = Certificate::from_pem(CA_CERT_PEM) + .unwrap() + .to_der() + .unwrap(); + guest_roots.add(CertificateDer::from(vpod_ca_der)).unwrap(); + + let guest_cfg = Arc::new( + ClientConfig::builder_with_provider(Arc::new(rustls_rustcrypto::provider())) + .with_safe_default_protocol_versions() + .unwrap() + .with_root_certificates(guest_roots) + .with_no_client_auth(), + ); + let mut guest = + ClientConnection::new(guest_cfg, ServerName::try_from("localhost").unwrap()).unwrap(); + + let mut sent = false; + for _ in 0..2000 { + let mut out = Vec::new(); + while guest.wants_write() { + guest.write_tls(&mut out).unwrap(); + } + if !out.is_empty() { + proxy.push_from_guest(&out); + } + + let mut buf = [0u8; 16384]; + while let Some(n) = proxy.pull_to_guest(&mut buf) { + let mut slice = &buf[..n]; + while !slice.is_empty() { + guest.read_tls(&mut slice).unwrap(); + } + guest.process_new_packets().unwrap(); + } + + if !sent && !guest.is_handshaking() { + guest.writer().write_all(request.as_bytes()).unwrap(); + sent = true; + } + + if let Ok(received) = seen.try_recv() { + return Some(String::from_utf8_lossy(&received).into_owned()); + } + if proxy.failed() { + return None; + } + + thread::sleep(Duration::from_millis(1)); + } + + seen.try_recv() + .ok() + .map(|received| String::from_utf8_lossy(&received).into_owned()) +} + +fn proxy_carrying(ctx: &TlsContext, up_ca: &str, port: u16, hosts: &[&str]) -> TlsProxy { + let mut proxy = TlsProxy::new_test( + ctx.server_config.clone(), + client_config_trusting(up_ca), + [127, 0, 0, 1], + port, + ); + proxy.carry_secrets(vec![SecretBinding { + placeholder: "vpod-secret-key-a1b2c3d4".to_string(), + value: "sk-ant-the-real-thing".to_string(), + hosts: hosts.iter().map(|host| host.to_string()).collect(), + }]); + + proxy +} + +#[test] +fn a_credential_the_guest_never_held_reaches_the_upstream() { + let (port, up_ca, seen, host) = spawn_capturing_upstream(UPSTREAM_REPLY); + let ctx = TlsContext::new().unwrap(); + let mut proxy = proxy_carrying(&ctx, &up_ca, port, &["localhost"]); + + let received = request_through_proxy( + &mut proxy, + &up_ca, + &seen, + "GET / HTTP/1.1\r\nHost: localhost\r\n\ + x-api-key: vpod-secret-key-a1b2c3d4\r\nContent-Length: 0\r\n\r\n", + ) + .expect("the upstream never saw a request"); + + let _ = host.join(); + + assert!( + received.contains("sk-ant-the-real-thing"), + "the real credential never went out: {received}" + ); + assert!( + !received.contains("vpod-secret-key-a1b2c3d4"), + "the stand-in went out as well: {received}" + ); +} + +#[test] +fn a_credential_bound_elsewhere_never_leaves_the_machine() { + let (port, up_ca, seen, host) = spawn_capturing_upstream(UPSTREAM_REPLY); + let ctx = TlsContext::new().unwrap(); + // The allowlist names a host this connection is not going to. + let mut proxy = proxy_carrying(&ctx, &up_ca, port, &["api.anthropic.com"]); + + let received = request_through_proxy( + &mut proxy, + &up_ca, + &seen, + "GET / HTTP/1.1\r\nHost: api.anthropic.com\r\n\ + x-api-key: vpod-secret-key-a1b2c3d4\r\nContent-Length: 0\r\n\r\n", + ); + + drop(host); + + assert!( + received.is_none(), + "the request was forwarded to a host outside the allowlist: {received:?}" + ); + assert!( + proxy.failed(), + "the connection was left open after refusing" + ); +} + +#[test] +fn a_request_without_a_credential_is_unchanged_by_the_substitution() { + let (port, up_ca, seen, host) = spawn_capturing_upstream(UPSTREAM_REPLY); + let ctx = TlsContext::new().unwrap(); + let mut proxy = proxy_carrying(&ctx, &up_ca, port, &["localhost"]); + + let wire = "GET /plain HTTP/1.1\r\nHost: localhost\r\nContent-Length: 0\r\n\r\n"; + let received = request_through_proxy(&mut proxy, &up_ca, &seen, wire) + .expect("the upstream never saw a request"); + + let _ = host.join(); + + assert_eq!(received, wire); +} diff --git a/crates/machine/src/virtio/tls_proxy/testutil.rs b/crates/machine/src/virtio/tls_proxy/testutil.rs index a619370..db30c61 100644 --- a/crates/machine/src/virtio/tls_proxy/testutil.rs +++ b/crates/machine/src/virtio/tls_proxy/testutil.rs @@ -36,6 +36,29 @@ pub(crate) fn generate_ca_pems() -> (String, String) { pub(crate) fn spawn_test_upstream( reply: &'static [u8], +) -> (u16, String, std::thread::JoinHandle<()>) { + spawn_test_upstream_reporting(reply, None) +} + +/// Like `spawn_test_upstream`, but hands back the request the upstream actually +/// received, which is the only way to prove what left the machine. +pub(crate) fn spawn_capturing_upstream( + reply: &'static [u8], +) -> ( + u16, + String, + std::sync::mpsc::Receiver>, + std::thread::JoinHandle<()>, +) { + let (sender, receiver) = std::sync::mpsc::channel(); + let (port, ca, handle) = spawn_test_upstream_reporting(reply, Some(sender)); + + (port, ca, receiver, handle) +} + +fn spawn_test_upstream_reporting( + reply: &'static [u8], + seen: Option>>, ) -> (u16, String, std::thread::JoinHandle<()>) { use std::net::TcpListener; @@ -91,6 +114,10 @@ pub(crate) fn spawn_test_upstream( conn.read_tls(&mut sock).unwrap(); conn.process_new_packets().unwrap(); } + if let Some(seen) = seen { + let _ = seen.send(req.clone()); + } + conn.writer().write_all(reply).unwrap(); while conn.wants_write() { conn.write_tls(&mut sock).unwrap(); diff --git a/crates/wasi-component/src/api/executor.rs b/crates/wasi-component/src/api/executor.rs index a7f2967..5e55702 100644 --- a/crates/wasi-component/src/api/executor.rs +++ b/crates/wasi-component/src/api/executor.rs @@ -1,8 +1,60 @@ use crate::api::session::SESSION_MANAGER; use crate::exports::vpod::sandbox::executor::{ - ExecMode, ExecutionResult, Guest, MountEntry, SliceOutput, TraceOptions, + EnvVar, ExecMode, ExecutionResult, Guest, MountEntry, SecretBinding as WitSecret, SliceOutput, + TraceOptions, }; use crate::vm; +use machine::virtio::secrets::SecretBinding; + +fn env_pairs(env: Vec) -> Result, String> { + env.into_iter() + .map(|entry| { + let valid = !entry.name.is_empty() + && !entry.name.starts_with(|c: char| c.is_ascii_digit()) + && entry + .name + .chars() + .all(|c| c.is_ascii_alphanumeric() || c == '_'); + + if !valid { + return Err(format!( + "environment variable name {:?} is not a plain identifier", + entry.name + )); + } + + Ok((entry.name, entry.value)) + }) + .collect() +} + +type SplitSecrets = (Vec<(String, String)>, Vec); + +fn split_secrets(secrets: Vec) -> Result { + let mut guest_env = Vec::new(); + let mut bindings = Vec::new(); + + for secret in secrets { + if secret.placeholder.is_empty() { + return Err(format!("secret {:?} has an empty placeholder", secret.name)); + } + if secret.hosts.is_empty() { + return Err(format!( + "secret {:?} names no hosts, so it could never be used", + secret.name + )); + } + + guest_env.push((secret.name, secret.placeholder.clone())); + bindings.push(SecretBinding { + placeholder: secret.placeholder, + value: secret.value, + hosts: secret.hosts, + }); + } + + Ok((guest_env, bindings)) +} pub struct Executor; @@ -12,6 +64,8 @@ impl Guest for Executor { command: String, prompt: String, mounts: Vec, + env: Vec, + secrets: Vec, ) -> Result { let mount_args: Vec = mounts .into_iter() @@ -22,7 +76,11 @@ impl Guest for Executor { }) .collect(); - SESSION_MANAGER.start_session(snapshot_path, command, prompt, mount_args) + let (placeholders, bindings) = split_secrets(secrets)?; + let mut env = env_pairs(env)?; + env.extend(placeholders); + + SESSION_MANAGER.start_session(snapshot_path, command, prompt, mount_args, env, bindings) } fn session_exec( @@ -69,6 +127,8 @@ impl Guest for Executor { command: String, prompt: String, mounts: Vec, + env: Vec, + secrets: Vec, ) -> Result { let delta = std::fs::read(&delta_path) .map_err(|e| format!("failed to read delta from {delta_path}: {e}"))?; @@ -82,7 +142,19 @@ impl Guest for Executor { }) .collect(); - SESSION_MANAGER.resume_session(snapshot_path, delta, command, prompt, mount_args) + let (placeholders, bindings) = split_secrets(secrets)?; + let mut env = env_pairs(env)?; + env.extend(placeholders); + + SESSION_MANAGER.resume_session( + snapshot_path, + delta, + command, + prompt, + mount_args, + env, + bindings, + ) } fn session_trace_start(handle: u64, options: TraceOptions) -> Result<(), String> { diff --git a/crates/wasi-component/src/api/session.rs b/crates/wasi-component/src/api/session.rs index e3958c4..d75fc6e 100644 --- a/crates/wasi-component/src/api/session.rs +++ b/crates/wasi-component/src/api/session.rs @@ -11,6 +11,7 @@ use crate::vm; use machine::machine_bus::MachineBus; use machine::trace::{DEFAULT_BUFFER_BYTES, TraceOptions}; +use machine::virtio::secrets::SecretBinding; use riscv_core::Hart; const PYRUNNER_SENTINEL: &str = "---VPOD_DONE---"; @@ -36,6 +37,97 @@ const TRACE_START_COMMAND: &str = "echo 2 > /proc/sys/kernel/io_uring_disabled 2 const AOT_MISMATCH_PROBE_THRESHOLD: u64 = 64; +const ENV_VALUE_CHUNK: usize = 400; //for secret/env api + +fn shell_quote(value: &str) -> String { + format!("'{}'", value.replace('\'', "'\\''")) +} + +fn env_statements(env: &[(String, String)]) -> Vec { + let mut statements = Vec::new(); + + for (name, value) in env { + let mut chunks = value + .as_bytes() + .chunks(ENV_VALUE_CHUNK) + .map(|chunk| String::from_utf8_lossy(chunk).into_owned()); + + let first = chunks.next().unwrap_or_default(); + statements.push(format!("export {}={}", name, shell_quote(&first))); + + for chunk in chunks { + statements.push(format!("{0}=\"${0}\"{1}", name, shell_quote(&chunk))); + } + } + + statements +} + +fn apply_env_to_shell( + bus: &mut MachineBus, + hart: &mut Hart, + prompt: &[u8], + env: &[(String, String)], +) { + let mut line = String::new(); + + for statement in env_statements(env) { + if !line.is_empty() && line.len() + statement.len() + 2 > MAX_INLINE_EXEC { + run_shell_line(bus, hart, prompt, &line); + line.clear(); + } + + if !line.is_empty() { + line.push_str("; "); + } + line.push_str(&statement); + } + + if !line.is_empty() { + run_shell_line(bus, hart, prompt, &line); + } +} + +fn run_shell_line(bus: &mut MachineBus, hart: &mut Hart, prompt: &[u8], line: &str) { + for byte in line.bytes() { + bus.uart.push_rx(byte); + } + bus.uart.push_rx(b'\n'); + + repl::wait_for_prompt(bus, hart, prompt); + bus.uart.drain_tx(); +} + +fn apply_env_to_pyrunner(bus: &mut MachineBus, hart: &mut Hart, env: &[(String, String)]) { + let encode = |text: &str| base64::engine::general_purpose::STANDARD.encode(text.as_bytes()); + + let pairs: Vec = env + .iter() + .map(|(name, value)| { + format!( + "_d(\"{}\").decode(): _d(\"{}\").decode()", + encode(name), + encode(value) + ) + }) + .collect(); + + let source = format!( + "import os\nfrom base64 import b64decode as _d\nos.environ.update({{{}}})\ndel _d\n", + pairs.join(", ") + ); + + let encoded = base64::engine::general_purpose::STANDARD.encode(source.as_bytes()); + for byte in encoded.bytes() { + bus.uart_data.push_rx(byte); + } + bus.uart_data.push_rx(b'\n'); + + repl::capture_output(bus, hart, b"", 30, false, Some(PYRUNNER_SENTINEL), true); + bus.uart_stderr.drain_tx(); + bus.uart_ctrl.drain_tx(); +} + fn warn_if_aot_mismatch(hart: &Hart) { static WARNED: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false); @@ -67,6 +159,7 @@ pub struct Session { pub shell_lost: bool, pub exec: Option, pub staged_stdin: Vec, + pub env: Vec<(String, String)>, } fn recover_shell(session: &mut Session) { @@ -430,6 +523,8 @@ impl SessionManager { command: String, prompt: String, mount_args: Vec, + env: Vec<(String, String)>, + secrets: Vec, ) -> Result { self.ensure_base(&snapshot_path)?; @@ -438,7 +533,8 @@ impl SessionManager { let flags = cached.flags; let ram_size = cached.base.ram_size(); - let (mut bus, mut hart) = vm::_bus_from_base(&cached.base, ram_size, &mount_args, true); + let (mut bus, mut hart) = + vm::_bus_from_base(&cached.base, ram_size, &mount_args, true, secrets); machine::snapshot::restore_devices( &mut bus, @@ -514,6 +610,16 @@ impl SessionManager { bus.uart.drain_tx(); } + if !env.is_empty() { + if is_shell || use_pyrunner { + apply_env_to_shell(&mut bus, &mut hart, &prompt_bytes, &env); + } + + if python_ready { + apply_env_to_pyrunner(&mut bus, &mut hart, &env); + } + } + if is_shell { install_prompt_sentinel(&mut bus, &mut hart, &mut prompt_bytes); } @@ -536,6 +642,7 @@ impl SessionManager { shell_lost: false, exec: None, staged_stdin: Vec::new(), + env, }, ); @@ -582,6 +689,11 @@ impl SessionManager { if session.pyrunner_dirty { restart_pyrunner(session); session.pyrunner_dirty = false; + + if !session.env.is_empty() { + let env = session.env.clone(); + apply_env_to_pyrunner(&mut session.bus, &mut session.hart, &env); + } } if !session.pyrunner_reseeded { @@ -788,6 +900,7 @@ impl SessionManager { Ok(buf) } + #[allow(clippy::too_many_arguments)] pub fn resume_session( &self, snapshot_path: String, @@ -795,13 +908,16 @@ impl SessionManager { _command: String, _prompt: String, mount_args: Vec, + env: Vec<(String, String)>, + secrets: Vec, ) -> Result { self.ensure_base(&snapshot_path)?; let cache = self.base_cache.borrow(); let cached = cache.as_ref().unwrap(); let ram_size = cached.base.ram_size(); - let (mut bus, mut hart) = vm::_bus_from_base(&cached.base, ram_size, &mount_args, true); + let (mut bus, mut hart) = + vm::_bus_from_base(&cached.base, ram_size, &mount_args, true, secrets); drop(cache); let meta_len_offset = delta.len() - 4; @@ -843,6 +959,14 @@ impl SessionManager { (true, false, false, false, b"# ".to_vec()) }; + if !env.is_empty() { + apply_env_to_shell(&mut bus, &mut hart, &prompt, &env); + + if has_pyrunner { + apply_env_to_pyrunner(&mut bus, &mut hart, &env); + } + } + if is_shell { install_prompt_sentinel(&mut bus, &mut hart, &mut prompt); } @@ -865,6 +989,7 @@ impl SessionManager { shell_lost: false, exec: None, staged_stdin: Vec::new(), + env, }, ); @@ -1008,3 +1133,146 @@ fn restart_pyrunner(session: &mut Session) { session.pyrunner_reseeded = true; } + +#[cfg(test)] +mod env_tests { + use super::{ENV_VALUE_CHUNK, MAX_INLINE_EXEC, env_statements, shell_quote}; + + fn pairs(entries: &[(&str, &str)]) -> Vec<(String, String)> { + entries + .iter() + .map(|(name, value)| (name.to_string(), value.to_string())) + .collect() + } + + #[test] + fn a_quote_is_closed_escaped_and_reopened() { + assert_eq!(shell_quote("a'b"), r#"'a'\''b'"#); + assert_eq!(shell_quote("plain"), "'plain'"); + assert_eq!(shell_quote(""), "''"); + } + + #[test] + fn a_plain_value_becomes_one_quoted_assignment() { + assert_eq!( + env_statements(&pairs(&[("TZ", "UTC")])), + vec!["export TZ='UTC'"] + ); + } + + fn value_a_shell_sees(name: &str, value: &str) -> String { + let script = format!( + "{}\nprintf %s \"${}\"", + env_statements(&pairs(&[(name, value)])).join("\n"), + name + ); + + let output = std::process::Command::new("/bin/sh") + .arg("-c") + .arg(&script) + .output() + .expect("could not run /bin/sh"); + + assert!( + output.status.success(), + "the shell rejected the export: {}", + String::from_utf8_lossy(&output.stderr) + ); + + String::from_utf8_lossy(&output.stdout).into_owned() + } + + #[test] + fn a_value_carrying_shell_syntax_arrives_literally() { + for hostile in [ + "'; rm -rf /; echo '", + "$HOME", + "`id`", + "$(id)", + "a'b", + "''", + "\\", + "line one\nline two", + "trailing space ", + "* ? [a-z]", + ] { + assert_eq!( + value_a_shell_sees("K", hostile), + hostile, + "the shell did not see {hostile:?} literally" + ); + } + } + + #[test] + fn a_long_value_survives_being_appended_in_pieces() { + let value: String = std::iter::repeat_n("abc'def", 400).collect(); + + assert_eq!(value_a_shell_sees("BIG", &value), value); + } + + #[test] + fn every_quote_in_a_value_is_escaped() { + let statements = env_statements(&pairs(&[("K", "a'b'c")])); + let assignment = &statements[0]; + + assert_eq!(assignment, r#"export K='a'\''b'\''c'"#); + assert_eq!( + assignment.matches(r"'\''").count(), + 2, + "one escape per quote in the value" + ); + } + + #[test] + fn a_dollar_sign_is_not_expanded() { + assert_eq!( + env_statements(&pairs(&[("K", "$HOME `id` $(id)")])), + vec!["export K='$HOME `id` $(id)'"] + ); + } + + #[test] + fn a_long_value_is_appended_across_statements_that_each_fit() { + let value = "x".repeat(ENV_VALUE_CHUNK * 3 + 7); + let statements = env_statements(&pairs(&[("BIG", &value)])); + + assert_eq!(statements.len(), 4, "one assignment plus three appends"); + assert!(statements[0].starts_with("export BIG='")); + assert!(statements[1].starts_with(r#"BIG="$BIG"'"#)); + + for statement in &statements { + assert!( + statement.len() < MAX_INLINE_EXEC, + "a statement outran the shell's input limit at {} bytes", + statement.len() + ); + } + } + + #[test] + fn a_long_value_of_nothing_but_quotes_still_fits() { + let value = "'".repeat(ENV_VALUE_CHUNK); + let statements = env_statements(&pairs(&[("Q", &value)])); + + for statement in &statements { + assert!( + statement.len() < MAX_INLINE_EXEC, + "escaping pushed a statement to {} bytes", + statement.len() + ); + } + } + + #[test] + fn an_empty_value_is_still_exported() { + assert_eq!(env_statements(&pairs(&[("K", "")])), vec!["export K=''"]); + } + + #[test] + fn variables_keep_the_order_they_were_given() { + let statements = env_statements(&pairs(&[("A", "1"), ("B", "2")])); + + assert_eq!(statements, vec!["export A='1'", "export B='2'"]); + } +} diff --git a/crates/wasi-component/src/vm.rs b/crates/wasi-component/src/vm.rs index f3bf772..4667940 100644 --- a/crates/wasi-component/src/vm.rs +++ b/crates/wasi-component/src/vm.rs @@ -3,6 +3,7 @@ use machine::cow_ram::CowRam; use machine::machine_bus::MachineBus; use machine::snapshot; use machine::virtio::fs::Mount; +use machine::virtio::secrets::SecretBinding; use riscv_core::Hart; use std::io::{BufReader, Read}; use std::path::{Path, PathBuf}; @@ -125,9 +126,10 @@ pub fn _bus_from_base( ram_size: u64, mounts: &[MountArg], capture_tx: bool, + secrets: Vec, ) -> (MachineBus, Hart) { let mut bus = MachineBus::new(ram_size, base.clone_shared()); - bus.attach_net(); + bus.attach_net_with_secrets(secrets); bus.attach_fs(vec![]); let mut hart = Hart::new(0x1000); diff --git a/crates/wasi-component/vpod.wit b/crates/wasi-component/vpod.wit index a358916..4faf523 100644 --- a/crates/wasi-component/vpod.wit +++ b/crates/wasi-component/vpod.wit @@ -23,6 +23,18 @@ interface executor { writable: bool, } + record env-var { + name: string, + value: string, + } + + record secret-binding { + name: string, + placeholder: string, + value: string, + hosts: list, + } + enum exec-mode { closed, piped, terminal } record trace-options { @@ -33,7 +45,7 @@ interface executor { buffer-bytes: u32, } - session-start: func(snapshot-path: string, command: string, prompt: string, mounts: list) -> result; + session-start: func(snapshot-path: string, command: string, prompt: string, mounts: list, env: list, secrets: list) -> result; session-exec: func(handle: u64, code: string, timeout: option) -> result; session-close: func(handle: u64); @@ -43,7 +55,7 @@ interface executor { session-stdin: func(handle: u64, data: list) -> result<_, string>; session-suspend: func(handle: u64, delta-path: string) -> result; - session-resume: func(snapshot-path: string, delta-path: string, command: string, prompt: string, mounts: list) -> result; + session-resume: func(snapshot-path: string, delta-path: string, command: string, prompt: string, mounts: list, env: list, secrets: list) -> result; session-trace-start: func(handle: u64, options: trace-options) -> result<_, string>; session-trace-drain: func(handle: u64, max-bytes: u32) -> result; diff --git a/sdks/python/README.md b/sdks/python/README.md index f3165ae..27e73ee 100644 --- a/sdks/python/README.md +++ b/sdks/python/README.md @@ -57,7 +57,8 @@ with Sandbox.create() as sbx: ### Advanced Configuration -You can mount local directories into the sandbox and specify which snapshot to use. +You can mount local directories into the sandbox, set environment variables, and +specify which snapshot to use. ```python from vpod import Sandbox @@ -70,6 +71,13 @@ with Sandbox.create(snapshot="vsnap-data", mounts=mounts) as sbx: sbx.code.run("print('Pandas is ready!')") ``` +Environment variables are set at creation and reach both commands and `code.run`: + +```python +with Sandbox.create(env={"TZ": "UTC"}) as sbx: + sbx.commands.run("date") +``` + ### Suspend & Resume Pause a running sandbox and resume it later — no daemon, no background process. Only dirty memory pages are saved, making it fast and storage-efficient. diff --git a/sdks/python/tests/conftest.py b/sdks/python/tests/conftest.py index 15f3326..a3501fe 100644 --- a/sdks/python/tests/conftest.py +++ b/sdks/python/tests/conftest.py @@ -39,10 +39,18 @@ def fake_execute(snapshot_path, command): **{"exit-code": result.returncode}, ) - def fake_session_start(snapshot_path, command, prompt, mounts=None): + def fake_session_start( + snapshot_path, command, prompt, mounts=None, env=None, secrets=None + ): session_counter["id"] += 1 sid = session_counter["id"] - sessions[sid] = {"env": {}, "type": command} + sessions[sid] = { + "env": { + **{entry.name: entry.value for entry in (env or [])}, + **{entry.name: entry.placeholder for entry in (secrets or [])}, + }, + "type": command, + } return sid def fake_session_exec(sid, command): diff --git a/sdks/python/tests/test_integration.py b/sdks/python/tests/test_integration.py index 614c230..4bbd62f 100644 --- a/sdks/python/tests/test_integration.py +++ b/sdks/python/tests/test_integration.py @@ -1168,3 +1168,111 @@ def test_stdin_is_delivered_byte_for_byte(): result = sbx.commands.run("base64", stdin=raw, timeout=120) assert result.exit_code == 0, result assert _b64.b64decode(result.stdout.replace("\n", "")) == raw + + +def test_env_reaches_a_command(): + with Sandbox.create(env={"VPOD_GREETING": "hello"}) as sbx: + result = sbx.commands.run("echo $VPOD_GREETING") + + assert result.success + assert result.stdout.strip() == "hello" + + +def test_env_value_with_shell_syntax_arrives_literally(): + hostile = "'; echo pwned; x='$HOME `id`" + + with Sandbox.create(env={"VPOD_HOSTILE": hostile}) as sbx: + result = sbx.commands.run('printf %s "$VPOD_HOSTILE"') + + assert result.success + assert result.stdout == hostile + + +def test_env_reaches_a_child_process(): + with Sandbox.create(env={"VPOD_GREETING": "hello"}) as sbx: + result = sbx.commands.run("sh -c 'echo $VPOD_GREETING'") + + assert result.stdout.strip() == "hello" + + +def test_env_reaches_code_run(): + with Sandbox.create(env={"VPOD_GREETING": "hello"}) as sbx: + result = sbx.code.run("import os; print(os.environ['VPOD_GREETING'])") + + assert result.success, result.error + assert result.text.strip() == "hello" + + +def test_a_long_env_value_survives(): + value = "abc'def" * 400 + + with Sandbox.create(env={"VPOD_BIG": value}) as sbx: + result = sbx.commands.run('printf %s "$VPOD_BIG" | wc -c') + + assert result.stdout.strip() == str(len(value)) + + +def test_env_name_that_is_not_an_identifier_is_refused(): + with pytest.raises(ValueError, match="plain identifier"): + Sandbox.create(env={"NOT AN IDENT": "x"}) + + +def test_env_survives_suspend_and_resume(): + sbx = Sandbox.create(env={"VPOD_GREETING": "hello"}) + try: + instance_id = sbx.suspend() + finally: + pass + + resumed = Sandbox.resume(instance_id) + try: + assert resumed.commands.run("echo $VPOD_GREETING").stdout.strip() == "hello" + + code = resumed.code.run("import os; print(os.environ['VPOD_GREETING'])") + assert code.success, code.error + assert code.text.strip() == "hello" + finally: + resumed.close() + + +def test_the_guest_sees_a_placeholder_and_never_the_value(): + real = "sk-ant-the-real-thing-do-not-leak" + + with Sandbox.create( + secrets={"ANTHROPIC_API_KEY": {"value": real, "hosts": ["api.anthropic.com"]}} + ) as sbx: + shown = sbx.commands.run("printf %s \"$ANTHROPIC_API_KEY\"").stdout + + assert shown.startswith("vpod-secret-anthropic_api_key-"), shown + assert real not in shown + + assert real not in sbx.commands.run("env").stdout + assert real not in sbx.commands.run("cat /proc/self/environ").stdout + + seen = sbx.code.run("import os; print(os.environ['ANTHROPIC_API_KEY'])") + assert seen.success, seen.error + assert real not in seen.text + assert seen.text.strip().startswith("vpod-secret-") + + +def test_a_chosen_placeholder_is_what_the_guest_gets(): + with Sandbox.create( + secrets={ + "API_KEY": { + "value": "real-value", + "hosts": ["api.example.com"], + "placeholder": "sk-live-stand-in", + } + } + ) as sbx: + assert sbx.commands.run("printf %s \"$API_KEY\"").stdout == "sk-live-stand-in" + + +def test_a_secret_without_a_host_is_refused(): + with pytest.raises(ValueError, match="at least one host"): + Sandbox.create(secrets={"K": {"value": "x", "hosts": []}}) + + +def test_a_secret_needs_a_value(): + with pytest.raises(ValueError, match="non-empty string value"): + Sandbox.create(secrets={"K": {"value": "", "hosts": ["api.example.com"]}}) diff --git a/sdks/python/vpod/sandbox.py b/sdks/python/vpod/sandbox.py index e2c855e..7c4d42e 100644 --- a/sdks/python/vpod/sandbox.py +++ b/sdks/python/vpod/sandbox.py @@ -1,7 +1,9 @@ import json import os +import re import uuid from os.path import abspath +from secrets import token_hex from pathlib import Path from typing import Optional @@ -23,6 +25,88 @@ INSTANCES_DIR = Path.home() / ".vpod" / "instances" +def _parse_secrets(secrets: dict[str, dict]) -> list[dict]: + """A credential the sandbox can spend but never read. + + The guest is given a stand-in through its environment. The value itself goes + only to the network gateway, which swaps it back in on the way out, and only + for a host on the secret's list. + """ + parsed = [] + + for name, spec in secrets.items(): + if not _ENV_NAME.fullmatch(name): + raise ValueError(f"secret name {name!r} is not a plain identifier") + if not isinstance(spec, dict): + raise TypeError( + f"secret {name!r} must be a dict with 'value' and 'hosts', got {spec!r}" + ) + + value = spec.get("value") + if not isinstance(value, str) or not value: + raise ValueError(f"secret {name!r} needs a non-empty string value") + + hosts = spec.get("hosts") + if isinstance(hosts, str): + hosts = [hosts] + if not hosts or not all(isinstance(host, str) and host for host in hosts): + raise ValueError(f"secret {name!r} needs at least one host it may be sent to") + + placeholder = spec.get("placeholder") or f"vpod-secret-{name.lower()}-{token_hex(4)}" + if not isinstance(placeholder, str) or not placeholder: + raise ValueError(f"secret {name!r} has an empty placeholder") + + parsed.append({ + "name": name, + "placeholder": placeholder, + "value": value, + "hosts": list(hosts), + }) + + return parsed + + +def _secret_entries(secrets: list[dict]) -> list: + entries = [] + + for secret in secrets: + entry = object.__new__(type("SecretBinding", (), {})) + object.__setattr__(entry, "name", secret["name"]) + object.__setattr__(entry, "placeholder", secret["placeholder"]) + object.__setattr__(entry, "value", secret["value"]) + object.__setattr__(entry, "hosts", secret["hosts"]) + entries.append(entry) + + return entries + + +def _parse_env(env: dict[str, str]) -> dict[str, str]: + """The guest receives these as a shell `export`, so a name that is not a + plain identifier could carry arbitrary shell along with it.""" + parsed = {} + + for name, value in env.items(): + if not _ENV_NAME.fullmatch(name): + raise ValueError( + f"environment variable name {name!r} is not a plain identifier" + ) + parsed[name] = str(value) + + return parsed + + +def _env_entries(env: dict[str, str]) -> list: + entries = [] + + for name, value in env.items(): + entry = object.__new__(type("EnvVar", (), {})) + object.__setattr__(entry, "name", name) + object.__setattr__(entry, "value", value) + entries.append(entry) + + return entries + + def _parse_mounts(mounts: dict[str, str]) -> list[dict]: result = [] @@ -42,6 +126,8 @@ def _parse_mounts(mounts: dict[str, str]) -> list[dict]: return result +_ENV_NAME = re.compile(r"[A-Za-z_][A-Za-z0-9_]*") + _DEFAULT_SHELL = "/bin/sh" _DEFAULT_PROMPT = "# " @@ -52,6 +138,8 @@ def __init__( self, snapshot: str = "alpine:latest", mounts: dict[str, str] | None = None, + env: dict[str, str] | None = None, + secrets: dict[str, dict] | None = None, registry_url: str | None = None, api_key: str | None = None, engine: str = "auto", @@ -69,6 +157,8 @@ def __init__( self._snapshot_path = "snap/" + snapshot_path.name self._snapshot_file = snapshot_path self._mounts = _parse_mounts(mounts) if mounts else [] + self._env = _parse_env(env) if env else {} + self._secrets = _parse_secrets(secrets) if secrets else [] self._shell_session_id: Optional[int] = None self._in_context = False self._migrating = False @@ -129,6 +219,8 @@ def create( cls, snapshot: str = "vsnap-base:latest", mounts: dict[str, str] | None = None, + env: dict[str, str] | None = None, + secrets: dict[str, dict] | None = None, registry_url: str | None = None, api_key: str | None = None, engine: str = "auto", @@ -137,6 +229,8 @@ def create( return cls( snapshot, mounts=mounts, + env=env, + secrets=secrets, registry_url=registry_url, api_key=api_key, engine=engine, @@ -158,11 +252,22 @@ def _mount_entries(self) -> list: mount_entries.append(entry) return mount_entries + def _env_entries(self) -> list: + return _env_entries(self._env) + + def _secret_entries(self) -> list: + return _secret_entries(self._secrets) + def _get_shell_session_id(self) -> int: self._maybe_upgrade_engine() if self._shell_session_id is None: result = self._exports["session-start"]( - self._snapshot_path, _DEFAULT_SHELL, _DEFAULT_PROMPT, self._mount_entries() + self._snapshot_path, + _DEFAULT_SHELL, + _DEFAULT_PROMPT, + self._mount_entries(), + self._env_entries(), + self._secret_entries(), ) self._shell_session_id = int(_unwrap_result(result)) self.trace._start(self._exports, self._shell_session_id) @@ -199,7 +304,8 @@ def _maybe_upgrade_engine(self) -> None: def _resume(exports) -> None: result = exports["session-resume"]( self._snapshot_path, delta_rel, _DEFAULT_SHELL, - _DEFAULT_PROMPT, self._mount_entries(), + _DEFAULT_PROMPT, self._mount_entries(), self._env_entries(), + self._secret_entries(), ) self._shell_session_id = int(_unwrap_result(result)) self.trace._start(exports, self._shell_session_id) @@ -275,8 +381,15 @@ def suspend(self) -> str: @classmethod def resume( - cls, instance_id: str, mounts: dict[str, str] | None = None, trace=None + cls, + instance_id: str, + mounts: dict[str, str] | None = None, + env: dict[str, str] | None = None, + secrets: dict[str, dict] | None = None, + trace=None, ) -> "Sandbox": + resumed_env = _parse_env(env) if env else {} + resumed_secrets = _parse_secrets(secrets) if secrets else [] options = trace_options(trace) instance_dir = INSTANCES_DIR / instance_id meta = json.loads((instance_dir / "meta.json").read_text()) @@ -340,10 +453,18 @@ def resume( snap_rel = "snap/" + snapshot_path.name result = exports["session-resume"]( - snap_rel, delta_rel, _DEFAULT_SHELL, _DEFAULT_PROMPT, mount_entries + snap_rel, + delta_rel, + _DEFAULT_SHELL, + _DEFAULT_PROMPT, + mount_entries, + _env_entries(resumed_env), + _secret_entries(resumed_secrets), ) session_id = int(_unwrap_result(result)) + instance._env = resumed_env + instance._secrets = resumed_secrets instance._snapshot_path = snap_rel instance._snapshot_file = snapshot_path instance._tier = tier diff --git a/sdks/typescript/src/env.ts b/sdks/typescript/src/env.ts new file mode 100644 index 0000000..942ed94 --- /dev/null +++ b/sdks/typescript/src/env.ts @@ -0,0 +1,38 @@ +export type EnvSpec = Record; + +export interface EnvVar { + name: string; + value: string; +} + +const PLAIN_IDENTIFIER = /^[A-Za-z_][A-Za-z0-9_]*$/; + +/** + * The guest receives these as a shell `export`, so a name that is not a plain + * identifier could carry arbitrary shell along with it. + */ +export function envVars(env: EnvSpec | undefined): EnvVar[] { + if (env === undefined) { + return []; + } + if (typeof env !== "object" || env === null || Array.isArray(env)) { + throw new Error( + `vpod: env must be an object of name to value, got ${JSON.stringify(env)}`, + ); + } + + return Object.entries(env).map(([name, value]) => { + if (!PLAIN_IDENTIFIER.test(name)) { + throw new Error( + `vpod: environment variable name ${JSON.stringify(name)} is not a plain identifier`, + ); + } + if (typeof value !== "string") { + throw new Error( + `vpod: environment variable ${JSON.stringify(name)} needs a string value`, + ); + } + + return { name, value }; + }); +} diff --git a/sdks/typescript/src/index.ts b/sdks/typescript/src/index.ts index 7d89dc8..4fcdf7b 100644 --- a/sdks/typescript/src/index.ts +++ b/sdks/typescript/src/index.ts @@ -13,6 +13,8 @@ export { normalizeLineEndings, } from "./execution.js"; +export type { EnvSpec, EnvVar } from "./env.js"; +export type { SecretBinding, SecretSpec, SecretsSpec } from "./secrets.js"; export type { MountEntry, MountSpec } from "./mounts.js"; export { Trace, TraceRecorder } from "./trace.js"; diff --git a/sdks/typescript/src/node/transport.ts b/sdks/typescript/src/node/transport.ts index f3261dd..50b8894 100644 --- a/sdks/typescript/src/node/transport.ts +++ b/sdks/typescript/src/node/transport.ts @@ -12,6 +12,8 @@ import { componentImports, loadCoreModule } from "./component-imports.js"; import { FileSnapshotStore } from "./store.js"; import type { ComponentModule } from "../worker/component-imports.js"; import type { ExecutorTransport } from "../transport/types.js"; +import type { EnvVar } from "../env.js"; +import type { SecretBinding } from "../secrets.js"; import type { MountEntry } from "../mounts.js"; import type { ExecutionResult, WorkerCall } from "../worker/protocol.js"; import type { WireTraceOptions } from "../trace.js"; @@ -40,6 +42,8 @@ interface Executor { command: string, prompt: string, mounts: MountEntry[], + env: EnvVar[], + secrets: SecretBinding[], ): bigint; sessionExec(handle: bigint, code: string, timeout: bigint | undefined): ExecutionResult; sessionExecSlice( @@ -59,6 +63,8 @@ interface Executor { command: string, prompt: string, mounts: MountEntry[], + env: EnvVar[], + secrets: SecretBinding[], ): bigint; sessionTraceStart?(handle: bigint, options: WireTraceOptions): void; sessionTraceDrain?(handle: bigint, maxBytes: number): string; @@ -153,6 +159,8 @@ export class NodeDispatcher { call.command, call.prompt, hostMounts(call.mounts), + call.env, + call.secrets, ); case "session-exec": @@ -210,6 +218,8 @@ export class NodeDispatcher { call.command, call.prompt, hostMounts(call.mounts), + call.env, + call.secrets, ); } finally { await rm(path, { force: true }); diff --git a/sdks/typescript/src/runtime.ts b/sdks/typescript/src/runtime.ts index e0aa719..cd50c48 100644 --- a/sdks/typescript/src/runtime.ts +++ b/sdks/typescript/src/runtime.ts @@ -7,6 +7,8 @@ import { import { capabilitiesOf } from "./net/capabilities.js"; import type { NetworkBackendName, NetworkCapabilities } from "./net/capabilities.js"; import type { ExecutorTransport } from "./transport/types.js"; +import type { EnvVar } from "./env.js"; +import type { SecretBinding } from "./secrets.js"; import type { MountEntry } from "./mounts.js"; import type { WireTraceOptions } from "./trace.js"; import type { @@ -92,6 +94,8 @@ export class SandboxRuntime { command = "/bin/sh", prompt = "# ", mounts: MountEntry[] = [], + env: EnvVar[] = [], + secrets: SecretBinding[] = [], ): Promise { return this.#transport.call({ kind: "session-start", @@ -99,6 +103,8 @@ export class SandboxRuntime { command, prompt, mounts, + env, + secrets, }); } @@ -154,9 +160,20 @@ export class SandboxRuntime { command = "/bin/sh", prompt = "# ", mounts: MountEntry[] = [], + env: EnvVar[] = [], + secrets: SecretBinding[] = [], ): Promise { return this.#transport.call( - { kind: "session-resume", snapshotPath, deltaBytes, command, prompt, mounts }, + { + kind: "session-resume", + snapshotPath, + deltaBytes, + command, + prompt, + mounts, + env, + secrets, + }, [deltaBytes], ); } diff --git a/sdks/typescript/src/sandbox.ts b/sdks/typescript/src/sandbox.ts index 58b4fad..6d0fcb1 100644 --- a/sdks/typescript/src/sandbox.ts +++ b/sdks/typescript/src/sandbox.ts @@ -22,6 +22,8 @@ import { } from "./snapshots/engine.js"; import { defaultStore } from "./snapshots/index.js"; import { resolveRegistryUrl } from "./snapshots/registry.js"; +import { envVars, type EnvSpec, type EnvVar } from "./env.js"; +import { secretBindings, type SecretBinding, type SecretsSpec } from "./secrets.js"; import { mountEntries, type MountEntry, type MountSpec } from "./mounts.js"; import { TRACE_NOT_SUPPORTED, @@ -65,6 +67,10 @@ export interface SandboxOptions extends SandboxRuntimeOptions { trace?: TraceSetting; /** Host directories to mount, as guest path per host path. Append ":rw" for write access. Node only. */ mounts?: MountSpec; + /** Environment variables the guest starts with. */ + env?: EnvSpec; + /** Credentials the guest can use but never read. */ + secrets?: SecretsSpec; } interface ImageEngine { @@ -447,6 +453,8 @@ export class Sandbox { readonly #snapshotId: string; readonly #imageEngineSha256: string | null; readonly #mounts: MountEntry[]; + readonly #env: EnvVar[]; + readonly #secrets: SecretBinding[]; #sessionHandle: bigint | null = null; private constructor( @@ -456,12 +464,16 @@ export class Sandbox { imageEngineSha256: string | null, trace: WireTraceOptions | null, mounts: MountEntry[] = [], + env: EnvVar[] = [], + secrets: SecretBinding[] = [], ) { this.#runtime = runtime; this.#snapshotPath = snapshotPath; this.#snapshotId = snapshotId; this.#imageEngineSha256 = imageEngineSha256; this.#mounts = mounts; + this.#env = env; + this.#secrets = secrets; this.commands = new Commands(this); this.code = new Code(this); this.trace = new TraceRecorder(trace, async (maxBytes) => @@ -637,6 +649,8 @@ export class Sandbox { const trace = traceOptions(options.trace); const mounts = mountEntries(options.mounts); + const env = envVars(options.env); + const secrets = secretBindings(options.secrets); const snapshot = options.snapshot ?? DEFAULT_SNAPSHOT; const cachedEngine = typeof snapshot === "string" ? await Sandbox.#cachedImageEngine(options, snapshot) : null; @@ -658,6 +672,8 @@ export class Sandbox { imageEngine?.sha256 ?? null, trace, mounts, + env, + secrets, ); } @@ -763,6 +779,8 @@ export class Sandbox { DEFAULT_SHELL, DEFAULT_PROMPT, this.#mounts, + this.#env, + this.#secrets, ); await this.#startTrace(this.#sessionHandle); } @@ -801,6 +819,8 @@ export class Sandbox { const trace = traceOptions(options.trace); const mounts = mountEntries(options.mounts); + const env = envVars(options.env); + const secrets = secretBindings(options.secrets); const snapshot = options.snapshot ?? resolved.snapshotId; let wanted: ImageEngine | null; if (resolved.engineSha256 !== undefined) { @@ -837,6 +857,8 @@ export class Sandbox { imageEngine?.sha256 ?? null, trace, mounts, + env, + secrets, ); const delta = resolved.delta.slice(); sandbox.#sessionHandle = await runtime.sessionResume( @@ -845,6 +867,8 @@ export class Sandbox { DEFAULT_SHELL, DEFAULT_PROMPT, mounts, + env, + secrets, ); await sandbox.#startTrace(sandbox.#sessionHandle); diff --git a/sdks/typescript/src/secrets.ts b/sdks/typescript/src/secrets.ts new file mode 100644 index 0000000..88533fd --- /dev/null +++ b/sdks/typescript/src/secrets.ts @@ -0,0 +1,82 @@ +export interface SecretSpec { + /** The credential itself. It never enters the sandbox. */ + value: string; + /** Hosts this credential may be sent to. `*.example.com` matches one label. */ + hosts: string | string[]; + /** What the guest sees instead. Generated when left out. */ + placeholder?: string; +} + +export type SecretsSpec = Record; + +export interface SecretBinding { + name: string; + placeholder: string; + value: string; + hosts: string[]; +} + +const PLAIN_IDENTIFIER = /^[A-Za-z_][A-Za-z0-9_]*$/; + +function randomSuffix(): string { + const bytes = new Uint8Array(4); + crypto.getRandomValues(bytes); + + return Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join(""); +} + +/** + * A credential the sandbox can spend but never read. + * + * The guest is given a stand-in through its environment. The value itself goes + * only to the network gateway, which swaps it back in on the way out, and only + * for a host on the secret's list. + */ +export function secretBindings(secrets: SecretsSpec | undefined): SecretBinding[] { + if (secrets === undefined) { + return []; + } + if (typeof secrets !== "object" || secrets === null || Array.isArray(secrets)) { + throw new Error( + `vpod: secrets must be an object of name to { value, hosts }, got ${JSON.stringify(secrets)}`, + ); + } + + return Object.entries(secrets).map(([name, spec]) => { + if (!PLAIN_IDENTIFIER.test(name)) { + throw new Error(`vpod: secret name ${JSON.stringify(name)} is not a plain identifier`); + } + if (typeof spec !== "object" || spec === null) { + throw new Error( + `vpod: secret ${JSON.stringify(name)} must be an object with value and hosts`, + ); + } + + const { value, hosts, placeholder } = spec; + if (typeof value !== "string" || value.length === 0) { + throw new Error(`vpod: secret ${JSON.stringify(name)} needs a non-empty string value`); + } + + const list = typeof hosts === "string" ? [hosts] : hosts; + if ( + !Array.isArray(list) || + list.length === 0 || + !list.every((host) => typeof host === "string" && host.length > 0) + ) { + throw new Error( + `vpod: secret ${JSON.stringify(name)} needs at least one host it may be sent to`, + ); + } + + if (placeholder !== undefined && (typeof placeholder !== "string" || !placeholder)) { + throw new Error(`vpod: secret ${JSON.stringify(name)} has an empty placeholder`); + } + + return { + name, + placeholder: placeholder ?? `vpod-secret-${name.toLowerCase()}-${randomSuffix()}`, + value, + hosts: [...list], + }; + }); +} diff --git a/sdks/typescript/src/worker/dispatch.ts b/sdks/typescript/src/worker/dispatch.ts index 6f9849d..753e3c3 100644 --- a/sdks/typescript/src/worker/dispatch.ts +++ b/sdks/typescript/src/worker/dispatch.ts @@ -14,6 +14,8 @@ import { componentImports } from "./component-imports.js"; import type { ComponentModule, CoreModuleLoader } from "./component-imports.js"; import type { DriverCommand } from "../net/driver-protocol.js"; import type { ExecutionResult, WorkerCall } from "./protocol.js"; +import type { EnvVar } from "../env.js"; +import type { SecretBinding } from "../secrets.js"; import { MOUNTS_NEED_A_HOST, type MountEntry } from "../mounts.js"; import type { WireTraceOptions } from "../trace.js"; @@ -36,6 +38,8 @@ export interface Executor { command: string, prompt: string, mounts: never[], + env: EnvVar[], + secrets: SecretBinding[], ): bigint; sessionExec( handle: bigint, @@ -59,6 +63,8 @@ export interface Executor { command: string, prompt: string, mounts: never[], + env: EnvVar[], + secrets: SecretBinding[], ): bigint; sessionTraceStart?(handle: bigint, options: WireTraceOptions): void; sessionTraceDrain?(handle: bigint, maxBytes: number): string; @@ -200,6 +206,8 @@ export class Dispatcher { call.command, call.prompt, [], + call.env, + call.secrets, ); } catch (thrown: unknown) { throw await this.#explainRejectedSnapshot(call.snapshotPath, thrown); @@ -259,6 +267,8 @@ export class Dispatcher { call.command, call.prompt, [], + call.env, + call.secrets, ); } finally { removeGuestFile(path); diff --git a/sdks/typescript/src/worker/protocol.ts b/sdks/typescript/src/worker/protocol.ts index 2c393ca..ac1147a 100644 --- a/sdks/typescript/src/worker/protocol.ts +++ b/sdks/typescript/src/worker/protocol.ts @@ -1,6 +1,8 @@ import type { ExecMode } from "../sandbox.js"; import type { CoreModuleBytes } from "./component-imports.js"; +import type { EnvVar } from "../env.js"; +import type { SecretBinding } from "../secrets.js"; import type { MountEntry } from "../mounts.js"; import type { WireTraceOptions } from "../trace.js"; @@ -60,6 +62,8 @@ export type WorkerCall = command: string; prompt: string; mounts: MountEntry[]; + env: EnvVar[]; + secrets: SecretBinding[]; } | { kind: "session-exec"; @@ -86,6 +90,8 @@ export type WorkerCall = command: string; prompt: string; mounts: MountEntry[]; + env: EnvVar[]; + secrets: SecretBinding[]; } | { kind: "trace-supported" } | { kind: "session-trace-start"; handle: bigint; options: WireTraceOptions } diff --git a/sdks/typescript/tests/integration/env.test.mjs b/sdks/typescript/tests/integration/env.test.mjs new file mode 100644 index 0000000..06916ee --- /dev/null +++ b/sdks/typescript/tests/integration/env.test.mjs @@ -0,0 +1,69 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; + +import { createTestSandbox, skipReason } from "../helpers.mjs"; + +async function withEnv(env, body) { + const sandbox = await createTestSandbox({ env }); + + try { + return await body(sandbox); + } finally { + await sandbox.close(); + } +} + +describe("env", { skip: skipReason() ?? false }, () => { + it("reaches a command", async () => { + await withEnv({ VPOD_GREETING: "hello" }, async (sandbox) => { + const result = await sandbox.commands.run("echo $VPOD_GREETING"); + + assert.equal(result.stdout.trim(), "hello"); + }); + }); + + it("keeps a value with shell syntax literal", async () => { + const hostile = "'; echo pwned; x='$HOME `id`"; + + await withEnv({ VPOD_HOSTILE: hostile }, async (sandbox) => { + const result = await sandbox.commands.run('printf %s "$VPOD_HOSTILE"'); + assert.equal(result.stdout, hostile); + }); + }); + + it("reaches a child process", async () => { + await withEnv({ VPOD_GREETING: "hello" }, async (sandbox) => { + const result = await sandbox.commands.run("sh -c 'echo $VPOD_GREETING'"); + + assert.equal(result.stdout.trim(), "hello"); + }); + }); + + it("reaches code.run", async () => { + await withEnv({ VPOD_GREETING: "hello" }, async (sandbox) => { + const result = await sandbox.code.run( + "import os; print(os.environ['VPOD_GREETING'])", + ); + + assert.equal(result.text.trim(), "hello"); + }); + }); + + it("survives a value longer than one shell line", async () => { + const value = "abc'def".repeat(400); + + await withEnv({ VPOD_BIG: value }, async (sandbox) => { + const result = await sandbox.commands.run('printf %s "$VPOD_BIG" | wc -c'); + + assert.equal(result.stdout.trim(), String(value.length)); + }); + }); + + it("leaves a sandbox without env untouched", async () => { + await withEnv(undefined, async (sandbox) => { + const result = await sandbox.commands.run("echo ${VPOD_GREETING:-unset}"); + + assert.equal(result.stdout.trim(), "unset"); + }); + }); +}); diff --git a/sdks/typescript/tests/integration/secrets.test.mjs b/sdks/typescript/tests/integration/secrets.test.mjs new file mode 100644 index 0000000..3866bc2 --- /dev/null +++ b/sdks/typescript/tests/integration/secrets.test.mjs @@ -0,0 +1,65 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; + +import { createTestSandbox, skipReason } from "../helpers.mjs"; + +const REAL = "sk-ant-the-real-thing-do-not-leak"; + +async function withSecret(secrets, body) { + const sandbox = await createTestSandbox({ secrets }); + + try { + return await body(sandbox); + } finally { + await sandbox.close(); + } +} + +describe("secrets", { skip: skipReason() ?? false }, () => { + it("gives the guest a stand-in and never the value", async () => { + await withSecret( + { ANTHROPIC_API_KEY: { value: REAL, hosts: ["api.anthropic.com"] } }, + async (sandbox) => { + const shown = await sandbox.commands.run('printf %s "$ANTHROPIC_API_KEY"'); + + assert.match(shown.stdout, /^vpod-secret-anthropic_api_key-[0-9a-f]{8}$/); + assert.ok(!shown.stdout.includes(REAL)); + }, + ); + }); + + it("keeps the value out of everywhere the guest can look", async () => { + await withSecret( + { ANTHROPIC_API_KEY: { value: REAL, hosts: ["api.anthropic.com"] } }, + async (sandbox) => { + const env = await sandbox.commands.run("env"); + const environ = await sandbox.commands.run("cat /proc/self/environ"); + const code = await sandbox.code.run( + "import os; print(os.environ['ANTHROPIC_API_KEY'])", + ); + + assert.ok(!env.stdout.includes(REAL), "the value was in env"); + assert.ok(!environ.stdout.includes(REAL), "the value was in /proc"); + assert.ok(!code.text.includes(REAL), "the value was in the interpreter"); + assert.match(code.text.trim(), /^vpod-secret-/); + }, + ); + }); + + it("uses a placeholder the caller chose", async () => { + await withSecret( + { + API_KEY: { + value: REAL, + hosts: ["api.example.com"], + placeholder: "sk-live-stand-in", + }, + }, + async (sandbox) => { + const shown = await sandbox.commands.run('printf %s "$API_KEY"'); + + assert.equal(shown.stdout, "sk-live-stand-in"); + }, + ); + }); +}); diff --git a/sdks/typescript/tests/unit/env.test.mjs b/sdks/typescript/tests/unit/env.test.mjs new file mode 100644 index 0000000..229ed75 --- /dev/null +++ b/sdks/typescript/tests/unit/env.test.mjs @@ -0,0 +1,85 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; + +import { distPath } from "../helpers.mjs"; + +const { Sandbox } = await import(distPath("index.js")); + +function recordingTransport() { + const calls = []; + + return { + calls, + ready: async () => 0, + terminate() {}, + async call(call) { + calls.push(call); + + switch (call.kind) { + case "mount-snapshot": + return { snapshotPath: "snap/test.snap", byteLength: 0 }; + case "session-start": + return 1n; + default: + return undefined; + } + }, + }; +} + +async function sessionStartFor(env) { + const transport = recordingTransport(); + const sandbox = await Sandbox.create({ + transport, + network: false, + snapshot: { bytes: new Uint8Array(4), name: "test-256mb.snap" }, + env, + }); + + await sandbox.commands.run("true").catch(() => {}); + return transport.calls.find((call) => call.kind === "session-start"); +} + +describe("env", () => { + it("carries a variable to the engine", async () => { + const started = await sessionStartFor({ TZ: "UTC" }); + + assert.deepEqual(started.env, [{ name: "TZ", value: "UTC" }]); + }); + + it("keeps values with shell syntax exactly as given", async () => { + const hostile = "'; echo pwned; x='$HOME"; + const started = await sessionStartFor({ K: hostile }); + + assert.deepEqual(started.env, [{ name: "K", value: hostile }]); + }); + + it("carries every variable it was given, in order", async () => { + const started = await sessionStartFor({ A: "1", B: "2" }); + + assert.deepEqual( + started.env.map((entry) => entry.name), + ["A", "B"], + ); + }); + + it("sends an empty list when nothing is set", async () => { + const started = await sessionStartFor(undefined); + + assert.deepEqual(started.env, []); + }); + + it("refuses a name that is not a plain identifier", async () => { + await assert.rejects(sessionStartFor({ "NOT AN IDENT": "x" }), /plain identifier/); + await assert.rejects(sessionStartFor({ "1LEADING": "x" }), /plain identifier/); + await assert.rejects(sessionStartFor({ "SEMI;COLON": "x" }), /plain identifier/); + }); + + it("refuses a value that is not a string", async () => { + await assert.rejects(sessionStartFor({ PORT: 8080 }), /needs a string value/); + }); + + it("refuses a list where an object of names belongs", async () => { + await assert.rejects(sessionStartFor(["TZ=UTC"]), /must be an object/); + }); +}); diff --git a/sdks/typescript/tests/unit/secrets.test.mjs b/sdks/typescript/tests/unit/secrets.test.mjs new file mode 100644 index 0000000..dd8f9bd --- /dev/null +++ b/sdks/typescript/tests/unit/secrets.test.mjs @@ -0,0 +1,125 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; + +import { distPath } from "../helpers.mjs"; + +const { Sandbox } = await import(distPath("index.js")); + +function recordingTransport() { + const calls = []; + + return { + calls, + ready: async () => 0, + terminate() {}, + async call(call) { + calls.push(call); + + switch (call.kind) { + case "mount-snapshot": + return { snapshotPath: "snap/test.snap", byteLength: 0 }; + case "session-start": + return 1n; + default: + return undefined; + } + }, + }; +} + +async function sessionStartFor(secrets) { + const transport = recordingTransport(); + const sandbox = await Sandbox.create({ + transport, + network: false, + snapshot: { bytes: new Uint8Array(4), name: "test-256mb.snap" }, + secrets, + }); + + await sandbox.commands.run("true").catch(() => {}); + return transport.calls.find((call) => call.kind === "session-start"); +} + +const KEY = "sk-ant-the-real-thing"; + +describe("secrets", () => { + it("carries the value and its hosts to the engine", async () => { + const started = await sessionStartFor({ + ANTHROPIC_API_KEY: { value: KEY, hosts: ["api.anthropic.com"] }, + }); + + assert.equal(started.secrets.length, 1); + assert.equal(started.secrets[0].name, "ANTHROPIC_API_KEY"); + assert.equal(started.secrets[0].value, KEY); + assert.deepEqual(started.secrets[0].hosts, ["api.anthropic.com"]); + }); + + it("generates a placeholder that is not the value", async () => { + const started = await sessionStartFor({ + ANTHROPIC_API_KEY: { value: KEY, hosts: ["api.anthropic.com"] }, + }); + const { placeholder } = started.secrets[0]; + + assert.match(placeholder, /^vpod-secret-anthropic_api_key-[0-9a-f]{8}$/); + assert.notEqual(placeholder, KEY); + }); + + it("gives two sandboxes different placeholders for the same secret", async () => { + const spec = { K: { value: KEY, hosts: ["api.anthropic.com"] } }; + const [first, second] = await Promise.all([ + sessionStartFor(spec), + sessionStartFor(spec), + ]); + + assert.notEqual(first.secrets[0].placeholder, second.secrets[0].placeholder); + }); + + it("keeps a placeholder the caller chose, for clients that check key shape", async () => { + const started = await sessionStartFor({ + K: { value: KEY, hosts: ["api.anthropic.com"], placeholder: "sk-ant-stand-in" }, + }); + + assert.equal(started.secrets[0].placeholder, "sk-ant-stand-in"); + }); + + it("takes a single host without a list", async () => { + const started = await sessionStartFor({ + K: { value: KEY, hosts: "api.anthropic.com" }, + }); + + assert.deepEqual(started.secrets[0].hosts, ["api.anthropic.com"]); + }); + + it("never sends the value as an ordinary environment variable", async () => { + const started = await sessionStartFor({ + ANTHROPIC_API_KEY: { value: KEY, hosts: ["api.anthropic.com"] }, + }); + + assert.deepEqual(started.env, [], "the value rode along as plain env"); + }); + + it("sends an empty list when nothing is bound", async () => { + const started = await sessionStartFor(undefined); + + assert.deepEqual(started.secrets, []); + }); + + it("refuses a secret with no host, which could never be spent", async () => { + await assert.rejects(sessionStartFor({ K: { value: KEY, hosts: [] } }), /at least one host/); + await assert.rejects(sessionStartFor({ K: { value: KEY } }), /at least one host/); + }); + + it("refuses an empty value", async () => { + await assert.rejects( + sessionStartFor({ K: { value: "", hosts: ["api.anthropic.com"] } }), + /non-empty string value/, + ); + }); + + it("refuses a name that is not a plain identifier", async () => { + await assert.rejects( + sessionStartFor({ "NOT AN IDENT": { value: KEY, hosts: ["h"] } }), + /plain identifier/, + ); + }); +});