diff --git a/crates/alien-azure-clients/src/azure/common.rs b/crates/alien-azure-clients/src/azure/common.rs index 96dde1db1..a0347a784 100644 --- a/crates/alien-azure-clients/src/azure/common.rs +++ b/crates/alien-azure-clients/src/azure/common.rs @@ -122,7 +122,7 @@ impl AzureClientBase { pub async fn sign_request( &self, - mut req: http::Request, + mut req: http::Request>, bearer_token: &str, ) -> Result { // Inject mandatory headers if absent. @@ -184,6 +184,55 @@ impl AzureClientBase { // ------------- Low-level executor ------------- + /// Sends a request exactly once, with no retry. + /// + /// A repeat PUT to a collection with a server-minted id mints a second resource, and a + /// repeat exec may re-run a command that already started. Neither carries an idempotency key. + pub async fn execute_request_once( + &self, + req: reqwest::Request, + op: &str, + res_name: &str, + ) -> Result { + Self::send_once(&self.client, req, op, res_name).await + } + + async fn send_once( + client: &reqwest::Client, + req: reqwest::Request, + op: &str, + res_name: &str, + ) -> Result { + // Captured before execution consumes the request. + let request_url = req.url().to_string(); + let request_body = req.body().and_then(|b| b.as_bytes()).map(|b| { + String::from_utf8_lossy(&b[..b.len().min(MAX_ECHOED_REQUEST_BODY)]).to_string() + }); + + let resp = client + .execute(req) + .await + .into_alien_error() + .context(ErrorData::HttpRequestFailed { + message: format!("Azure {}: HTTP error for {}", op, res_name), + })?; + let status = resp.status(); + if status.is_success() || status == StatusCode::CREATED || status == StatusCode::ACCEPTED { + return Ok(resp); + } + + let body = resp.text().await.unwrap_or_default(); + Err(create_azure_http_error_with_context( + status, + op, + "Resource", + res_name, + &body, + &request_url, + request_body, + )) + } + /// Executes an HTTP request with retry logic and returns the response if successful. #[cfg(target_arch = "wasm32")] pub async fn execute_request( @@ -207,36 +256,7 @@ impl AzureClientBase { }) })?; - // Capture request details before execution consumes the request - let request_url = req_clone.url().to_string(); - let request_body = req_clone - .body() - .and_then(|b| b.as_bytes()) - .map(|b| String::from_utf8_lossy(b).to_string()); - - let resp = client.execute(req_clone).await.into_alien_error().context( - ErrorData::HttpRequestFailed { - message: format!("Azure {}: HTTP error for {}", op, res_name), - }, - )?; - let status = resp.status(); - if status.is_success() - || status == StatusCode::CREATED - || status == StatusCode::ACCEPTED - { - Ok(resp) - } else { - let body = resp.text().await.unwrap_or_default(); - Err(create_azure_http_error_with_context( - status, - &op, - "Resource", - &res_name, - &body, - &request_url, - request_body, - )) - } + Self::send_once(&client, req_clone, &op, &res_name).await } }; self.with_retry(retryable).await @@ -265,36 +285,7 @@ impl AzureClientBase { }) })?; - // Capture request details before execution consumes the request - let request_url = req_clone.url().to_string(); - let request_body = req_clone - .body() - .and_then(|b| b.as_bytes()) - .map(|b| String::from_utf8_lossy(b).to_string()); - - let resp = client.execute(req_clone).await.into_alien_error().context( - ErrorData::HttpRequestFailed { - message: format!("Azure {}: HTTP error for {}", op, res_name), - }, - )?; - let status = resp.status(); - if status.is_success() - || status == StatusCode::CREATED - || status == StatusCode::ACCEPTED - { - Ok(resp) - } else { - let body = resp.text().await.unwrap_or_default(); - Err(create_azure_http_error_with_context( - status, - &op, - "Resource", - &res_name, - &body, - &request_url, - request_body, - )) - } + Self::send_once(&client, req_clone, &op, &res_name).await } }; self.with_retry(retryable).await @@ -338,7 +329,7 @@ impl AzureClientBase { let request_body = req_clone .body() .and_then(|b| b.as_bytes()) - .map(|b| String::from_utf8_lossy(b).to_string()); + .map(|b| String::from_utf8_lossy(&b[..b.len().min(MAX_ECHOED_REQUEST_BODY)]).to_string()); let resp = client.execute(req_clone).await.into_alien_error().context( ErrorData::HttpRequestFailed { @@ -489,7 +480,7 @@ impl AzureClientBase { let request_body = req_clone .body() .and_then(|b| b.as_bytes()) - .map(|b| String::from_utf8_lossy(b).to_string()); + .map(|b| String::from_utf8_lossy(&b[..b.len().min(MAX_ECHOED_REQUEST_BODY)]).to_string()); let resp = client.execute(req_clone).await.into_alien_error().context( ErrorData::HttpRequestFailed { @@ -606,11 +597,18 @@ impl AzureClientBase { // Light request-builder (service-agnostic) // ----------------------------------------------------------------------------- +/// How much of a request body is echoed back in an error. +/// +/// A failed call quotes the request it sent, and a file upload's body would otherwise become a +/// multi-megabyte error message on its way into a log. Truncated rather than dropped, so a large +/// JSON body still shows the part that usually carries the mistake. +const MAX_ECHOED_REQUEST_BODY: usize = 4096; + pub struct AzureRequestBuilder { method: Method, uri: String, headers: Vec<(String, String)>, - body: String, + body: Vec, } impl AzureRequestBuilder { @@ -619,7 +617,7 @@ impl AzureRequestBuilder { method, uri, headers: vec![], - body: String::new(), + body: Vec::new(), } } pub fn header(mut self, name: &str, val: &str) -> Self { @@ -639,10 +637,15 @@ impl AzureRequestBuilder { self.header("content-length", &body.len().to_string()) } pub fn body(mut self, body: String) -> Self { + self.body = body.into_bytes(); + self + } + /// A body that is not text: a file's contents travel as bytes, not as UTF-8. + pub fn body_bytes(mut self, body: Vec) -> Self { self.body = body; self } - pub fn build(self) -> Result> { + pub fn build(self) -> Result>> { let mut b = http::Request::builder().method(self.method).uri(&self.uri); for (k, v) in self.headers { b = b.header(&k, &v); diff --git a/crates/alien-azure-clients/src/azure/sandbox_data_plane.rs b/crates/alien-azure-clients/src/azure/sandbox_data_plane.rs index d39516abd..6932191af 100644 --- a/crates/alien-azure-clients/src/azure/sandbox_data_plane.rs +++ b/crates/alien-azure-clients/src/azure/sandbox_data_plane.rs @@ -2,7 +2,7 @@ //! //! A **second endpoint** from ARM, at `management..azuredevcompute.io`, gated by the //! `Container Apps SandboxGroup Data Owner` role. Subscription Owner returns 403 here, so -//! management permissions alone provision a group cleanly and then fail at first exec. +//! management permissions alone provision a group without error and then fail at first exec. //! //! Microsoft's published data-plane REST reference covers `sessionPools` only, so the contract //! below was read out of the `azure-containerapps-sandbox` PyPI package (0.1.0b4) rather than @@ -13,6 +13,7 @@ use crate::azure::common::{AzureClientBase, AzureRequestBuilder}; use crate::azure::token_cache::AzureTokenCache; use alien_client_core::{ErrorData, Result}; use alien_error::{Context, IntoAlienError}; +use std::collections::BTreeMap; use async_trait::async_trait; use reqwest::Method; use serde::{Deserialize, Serialize}; @@ -23,9 +24,177 @@ use mockall::automock; /// Data-plane API version, from the SDK's `ApiVersion.V2026_02_01_PREVIEW`. pub const API_VERSION: &str = "2026-02-01-preview"; -/// Scope the data plane is signed for. Distinct from ARM's, which is why a token minted for -/// `management.azure.com` fails here in a way that looks like a permissions problem. -const ADC_SCOPE: &str = "https://management.azuredevcompute.io/.default"; +/// Scope the data plane is signed for, from the SDK's `DATA_PLANE_SCOPE` in `_helpers.py`. +/// +/// It is neither ARM's scope nor the endpoint's own host: the sandbox data plane sits on the +/// dynamic-sessions audience while answering at `azuredevcompute.io`. A token minted for either +/// host fails here as a 401 that reads like a missing role assignment. +const ADC_SCOPE: &str = "https://dynamicsessions.io/.default"; + +/// Service key an endpoint override is looked up under, which is how a test points the client at +/// a server it controls instead of a region's real data plane. +const SERVICE_NAME: &str = "sandboxDataPlane"; + +/// Largest file that moves in or out of a sandbox in one call. +/// +/// The package carries no size constant, so this is the number the agent-backed backends already +/// enforce (`alien-sandbox-agent/src/files.rs`) rather than a measured server limit: one bound +/// callers can rely on everywhere, and a body that never grows past it here. +const MAX_FILE_BYTES: usize = 32 * 1024 * 1024; + +/// An egress policy as the data plane takes and reports it. +/// +/// Only the fields a sandbox needs: the audit log, header transforms and URL rewrites are part of +/// the same object and none of them are policy Alien can express. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct EgressPolicy { + /// `Allow` or `Deny`, applied to anything no rule matches. The data plane's own default is + /// `Allow`, so a policy that omits it is an open sandbox. + pub default_action: String, + /// Host patterns and what to do with them. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub host_rules: Vec, + /// Match-and-act rules, which this client never sends and has to read: a rule here can permit + /// what the host patterns denied, and a policy field nobody models is one nobody checks. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub rules: Vec, + /// `Full`, `Partial`, `Legacy` or `None`. Only `Full` blocks non-HTTP traffic, so only `Full` + /// makes a `Deny` default mean no outbound access. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub traffic_inspection: Option, + /// Anything else the policy carries. + /// + /// Kept rather than dropped because this is a preview API whose surface Microsoft says may + /// change: a field that permits traffic and deserializes into nothing is one no containment + /// check can weigh, and silence is the wrong answer for a policy nobody can read whole. + #[serde(flatten)] + pub unmodelled: BTreeMap, +} + +/// A match-and-act rule, in the two parts containment turns on: what it matches, and what it does. +/// +/// The wire object also carries header transforms and URL rewrites. Neither is policy Alien can +/// express, and modelling them would only add fields to keep in step. +/// Every field the SDK's own model reads, and nothing beyond it. +/// +/// `deny_unknown_fields` rather than a catch-all: an exception list or a second host on a rule +/// this client reads as a plain deny is reach the declaration never named, and a field that +/// deserializes into nothing is one no containment check can weigh. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct EgressRule { + /// Rule name, which carries no policy. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub name: Option, + /// What the rule matches. Absent means the data plane sent a rule this client cannot read, + /// which is treated as unknown rather than as matching nothing. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub r#match: Option, + /// `Allow`, `Deny`, `Transform` or `Rewrite`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub action: Option, +} + +/// What a rule matches on. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct EgressRuleMatch { + /// Host pattern the rule applies to. + #[serde(default)] + pub host: String, + /// Path prefix the rule narrows to. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub path: Option, + /// HTTP methods the rule narrows to. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub methods: Option>, +} + +/// What a rule does when it matches. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct EgressRuleAction { + /// `Allow`, `Deny`, `Transform` or `Rewrite`. + #[serde(rename = "type", default)] + pub action_type: String, + /// Host a `Rewrite` sends the request to instead. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub host: Option, + /// Path a `Rewrite` sends the request to instead. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub path: Option, + /// Scheme a `Rewrite` sends the request over instead. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub scheme: Option, + /// Headers a `Transform` sets, inserts or removes. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub headers: Option>, +} + +/// One host pattern and the action it carries. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct EgressHostRule { + /// Host pattern, such as `api.example.com`. + pub pattern: String, + /// `Allow` or `Deny`. + pub action: String, +} + +/// What a sandbox is created from. +/// +/// A struct rather than a parameter list because the data plane keeps adding create-time fields +/// that decide what the sandbox can do, and each one added positionally is one a caller can pass +/// in the wrong slot. +#[derive(Debug, Clone, Default)] +pub struct CreateSandbox { + /// Public catalog disk image name, such as `ubuntu`. + pub disk_image: String, + /// CPU in the data plane's units, such as `1000m`. + pub cpu: String, + /// Memory in the data plane's units, such as `2048Mi`. + pub memory: String, + /// Variables placed in the sandbox. It inherits nothing, so a variable exists only if it is + /// sent here. + pub environment: BTreeMap, + /// Outbound policy, applied from the moment the sandbox starts. Absent leaves the data + /// plane's own default, which is open. + pub egress: Option, + /// Idle seconds after which the sandbox suspends itself. Absent leaves the data plane's own + /// policy rather than asserting one. + pub idle_suspend_seconds: Option, +} + +/// The create body. +/// +/// `sourcesRef` is required unless a preset sandbox type is named, and resources are nested rather +/// than top level. A flat {disk, cpu, memory} is rejected with "'sourcesRef' is required when not +/// using a preset sandbox type". +fn create_body(request: &CreateSandbox) -> serde_json::Value { + let mut body = serde_json::json!({ + "sourcesRef": { "diskImage": { "name": request.disk_image, "isPublic": true } }, + "resources": { "cpu": request.cpu, "memory": request.memory }, + }); + + if !request.environment.is_empty() { + body["environment"] = serde_json::json!(request.environment); + } + + if let Some(egress) = &request.egress { + body["egressPolicy"] = serde_json::json!(egress); + } + + // `Memory` is the SDK's own default for `auto_suspend_mode`, and the mode a session wants: + // what `Disk` does differently is not documented, so the default stands rather than a guess. + if let Some(seconds) = request.idle_suspend_seconds { + body["lifecycle"] = serde_json::json!({ + "autoSuspendPolicy": { "enabled": true, "interval": seconds, "mode": "Memory" } + }); + } + + body +} /// A sandbox as the data plane reports it. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] @@ -33,9 +202,17 @@ const ADC_SCOPE: &str = "https://management.azuredevcompute.io/.default"; pub struct Sandbox { /// Sandbox id within its group pub id: String, - /// `Running` or `Stopped` + /// The policy the sandbox is actually running under, which is the only way to tell that the + /// one that was asked for took effect. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub egress_policy: Option, + /// `Creating`, `Running`, `Stopping`, `Stopped`, `Suspended`, `Resuming` or `Deleting`. + /// + /// Optional because the name is only as good as the SDK it was read from: a field name that + /// does not match the wire deserializes to `None`, and the provider turns that into an error + /// rather than into a sandbox it assumes is healthy. #[serde(skip_serializing_if = "Option::is_none")] - pub status: Option, + pub state: Option, } /// Result of a shell command. @@ -57,8 +234,7 @@ pub struct ExecResult { #[async_trait] pub trait SandboxDataPlaneApi: Send + Sync + std::fmt::Debug { /// Creates a sandbox from a disk image. - async fn create_sandbox(&self, group: &str, disk: &str, cpu: &str, memory: &str) - -> Result; + async fn create_sandbox(&self, group: &str, request: CreateSandbox) -> Result; /// Reads a sandbox. A 404 is how deletion is confirmed. async fn get_sandbox(&self, group: &str, sandbox_id: &str) -> Result; @@ -77,6 +253,27 @@ pub trait SandboxDataPlaneApi: Send + Sync + std::fmt::Debug { command: &str, working_directory: Option, ) -> Result; + + /// Reads a file out of a sandbox. + async fn read_file(&self, group: &str, sandbox_id: &str, path: &str) -> Result>; + + /// Writes one file into a sandbox. + async fn write_file( + &self, + group: &str, + sandbox_id: &str, + path: &str, + contents: Vec, + ) -> Result<()>; + + /// Creates a directory inside a sandbox. Idempotent, like `mkdir -p`. + async fn mkdir(&self, group: &str, sandbox_id: &str, path: &str) -> Result<()>; + + /// Stops a sandbox, saving its state. Returns once accepted, not once stopped. + async fn stop_sandbox(&self, group: &str, sandbox_id: &str) -> Result<()>; + + /// Resumes a stopped sandbox. Returns once accepted, not once running. + async fn resume_sandbox(&self, group: &str, sandbox_id: &str) -> Result<()>; } /// The `executeShellCommand` body, which is `command` plus an optional `workingDirectory` and @@ -105,7 +302,10 @@ impl AzureSandboxDataPlaneClient { resource_group: &str, token_cache: AzureTokenCache, ) -> Self { - let endpoint = format!("https://management.{region}.azuredevcompute.io"); + let endpoint = token_cache + .get_service_endpoint(SERVICE_NAME) + .map(str::to_string) + .unwrap_or_else(|| format!("https://management.{region}.azuredevcompute.io")); Self { base: AzureClientBase::with_client_config( @@ -133,6 +333,32 @@ impl AzureSandboxDataPlaneClient { format!("{}/sandboxes/{sandbox_id}", self.group_path(group)) } + /// A bodyless POST that moves a sandbox between states. + /// + /// Sent once. A transition that took effect and lost its response would be repeated, and the + /// repeat refused for the state the first one produced — reporting a failure for work that + /// succeeded. The wait above this re-issues a resume itself, with the state in front of it. + async fn lifecycle_action( + &self, + group: &str, + sandbox_id: &str, + verb: &str, + operation: &str, + ) -> Result<()> { + let token = self.token_cache.get_bearer_token_with_scope(ADC_SCOPE).await?; + let url = self.base.build_url( + &format!("{}/{verb}", self.sandbox_path(group, sandbox_id)), + Some(vec![("api-version", API_VERSION.into())]), + ); + + let request = AzureRequestBuilder::new(Method::POST, url).build()?; + let signed = self.base.sign_request(request, &token).await?; + self.base + .execute_request_once(signed, operation, sandbox_id) + .await?; + Ok(()) + } + async fn parse( response: reqwest::Response, operation: &str, @@ -171,27 +397,14 @@ impl AzureSandboxDataPlaneClient { #[async_trait] impl SandboxDataPlaneApi for AzureSandboxDataPlaneClient { - async fn create_sandbox( - &self, - group: &str, - disk: &str, - cpu: &str, - memory: &str, - ) -> Result { + async fn create_sandbox(&self, group: &str, request: CreateSandbox) -> Result { let token = self.token_cache.get_bearer_token_with_scope(ADC_SCOPE).await?; let url = self.base.build_url( &format!("{}/sandboxes", self.group_path(group)), Some(vec![("api-version", API_VERSION.into())]), ); - // `sourcesRef` is required unless a preset sandbox type is named, and resources are - // nested rather than top level. A flat {disk, cpu, memory} is rejected with - // "'sourcesRef' is required when not using a preset sandbox type". - let body = serde_json::json!({ - "sourcesRef": { "diskImage": { "name": disk, "isPublic": true } }, - "resources": { "cpu": cpu, "memory": memory }, - }) - .to_string(); + let body = create_body(&request).to_string(); let request = AzureRequestBuilder::new(Method::PUT, url) .content_type_json() .content_length(&body) @@ -199,10 +412,14 @@ impl SandboxDataPlaneApi for AzureSandboxDataPlaneClient { .build()?; let signed = self.base.sign_request(request, &token).await?; - let response = self - .base - .execute_request(signed, "CreateSandbox", group) - .await?; + // The create body carries the caller's environment variables, and a failure echoes the + // request into the error chain, which is serialized into durable state. Sent once: the + // id is server-minted, so a re-send mints an orphan sandbox nothing can find or reap. + let response = alien_client_core::redact_request_body( + self.base + .execute_request_once(signed, "CreateSandbox", group) + .await, + )?; Self::parse(response, "CreateSandbox").await } @@ -265,24 +482,242 @@ impl SandboxDataPlaneApi for AzureSandboxDataPlaneClient { .build()?; let signed = self.base.sign_request(request, &token).await?; - let response = self - .base - .execute_request(signed, "ExecuteShellCommand", sandbox_id) - .await?; + // The body is the command, which is where a caller puts a token it wants the session to + // have. Sent once: a response that never arrives does not mean the command did not + // start, so a re-send would risk running untrusted code twice. + let response = alien_client_core::redact_request_body( + self.base + .execute_request_once(signed, "ExecuteShellCommand", sandbox_id) + .await, + )?; Self::parse(response, "ExecuteShellCommand").await } + + async fn read_file(&self, group: &str, sandbox_id: &str, path: &str) -> Result> { + let token = self.token_cache.get_bearer_token_with_scope(ADC_SCOPE).await?; + let url = self.base.build_url( + &format!("{}/files", self.sandbox_path(group, sandbox_id)), + Some(vec![ + ("api-version", API_VERSION.into()), + ("path", path.to_string()), + ]), + ); + + let request = AzureRequestBuilder::new(Method::GET, url).build()?; + let signed = self.base.sign_request(request, &token).await?; + let response = self.base.execute_request(signed, "ReadFile", sandbox_id).await?; + + // Bytes, not JSON: the body is the file, and `parse` would try to read an image or a + // tarball as a document. Collected chunk by chunk so the ceiling is enforced against + // what has arrived rather than after the whole file is already in memory. + let mut response = response; + let mut contents: Vec = Vec::new(); + while let Some(chunk) = response + .chunk() + .await + .into_alien_error() + .context(ErrorData::GenericError { + message: "Azure ADC ReadFile: the response body ended early".to_string(), + })? + { + contents.extend_from_slice(&chunk); + if contents.len() > MAX_FILE_BYTES { + return Err(alien_error::AlienError::new(ErrorData::InvalidInput { + message: format!( + "'{path}' is larger than the {MAX_FILE_BYTES}-byte transfer ceiling" + ), + field_name: Some("path".to_string()), + })); + } + } + + Ok(contents) + } + + async fn write_file( + &self, + group: &str, + sandbox_id: &str, + path: &str, + contents: Vec, + ) -> Result<()> { + if contents.len() > MAX_FILE_BYTES { + return Err(alien_error::AlienError::new(ErrorData::InvalidInput { + message: format!( + "'{path}' is {} bytes, over the {MAX_FILE_BYTES}-byte transfer ceiling", + contents.len() + ), + field_name: Some("path".to_string()), + })); + } + + let token = self.token_cache.get_bearer_token_with_scope(ADC_SCOPE).await?; + // `createDirs` is what makes a write create its parents, which is the cross-backend + // contract. The SDK also takes a `mode`, deliberately not sent: its accepted format is + // undocumented, and a wrong one would fail every write. + let url = self.base.build_url( + &format!("{}/files", self.sandbox_path(group, sandbox_id)), + Some(vec![ + ("api-version", API_VERSION.into()), + ("path", path.to_string()), + ("createDirs", "true".to_string()), + ]), + ); + + let request = AzureRequestBuilder::new(Method::PUT, url) + .header("Content-Type", "application/octet-stream") + .body_bytes(contents) + .build()?; + let signed = self.base.sign_request(request, &token).await?; + // The body is the file the caller asked to write. + alien_client_core::redact_request_body( + self.base + .execute_request(signed, "WriteFile", sandbox_id) + .await, + )?; + Ok(()) + } + + async fn stop_sandbox(&self, group: &str, sandbox_id: &str) -> Result<()> { + self.lifecycle_action(group, sandbox_id, "stop", "StopSandbox") + .await + } + + async fn resume_sandbox(&self, group: &str, sandbox_id: &str) -> Result<()> { + self.lifecycle_action(group, sandbox_id, "resume", "ResumeSandbox") + .await + } + + async fn mkdir(&self, group: &str, sandbox_id: &str, path: &str) -> Result<()> { + let token = self.token_cache.get_bearer_token_with_scope(ADC_SCOPE).await?; + let url = self.base.build_url( + &format!("{}/files/mkdir", self.sandbox_path(group, sandbox_id)), + Some(vec![("api-version", API_VERSION.into())]), + ); + + let body = serde_json::json!({ "path": path }).to_string(); + let request = AzureRequestBuilder::new(Method::POST, url) + .content_type_json() + .content_length(&body) + .body(body) + .build()?; + let signed = self.base.sign_request(request, &token).await?; + // The body is a caller-supplied path, redacted like the other bodied calls. + alien_client_core::redact_request_body( + self.base.execute_request(signed, "Mkdir", sandbox_id).await, + )?; + Ok(()) + } } #[cfg(test)] mod tests { use super::*; + use crate::azure::{AzureClientConfig, AzureClientConfigExt, ServiceOverrides}; + use httpmock::MockServer; + + /// A file that is not text. Every invalid UTF-8 shape in four bytes: a lone continuation, a + /// truncated sequence, and an embedded NUL. + const BINARY: [u8; 4] = [0xff, 0xfe, 0x00, 0x80]; + + /// `matches` takes a function pointer, so the expected bytes are a constant rather than a + /// captured value. + fn carries_binary(request: &httpmock::prelude::HttpMockRequest) -> bool { + request.body.clone().unwrap_or_default() == BINARY + } + + /// A client that talks to a server this test controls, through the endpoint override the + /// constructor honours. + fn client_against(server: &MockServer) -> AzureSandboxDataPlaneClient { + let config = AzureClientConfig::mock().with_service_overrides(ServiceOverrides { + endpoints: std::collections::HashMap::from([( + SERVICE_NAME.to_string(), + server.base_url(), + )]), + }); + + AzureSandboxDataPlaneClient::new( + reqwest::Client::new(), + "eastus", + "rg", + AzureTokenCache::new(config), + ) + } + + /// A create is delivered once, however the data plane answers. + /// + /// A second delivery mints an orphan sandbox no enumeration verb can find. Reads keep their + /// retry — repeating one is free. + #[tokio::test] + async fn a_create_is_never_re_sent_where_a_read_is() { + let server = MockServer::start_async().await; + let unavailable = server.mock(|when, then| { + when.method(httpmock::Method::PUT); + then.status(503).body("{}"); + }); + let client = client_against(&server); + + client + .create_sandbox( + "grp", + CreateSandbox { + disk_image: "ubuntu".to_string(), + cpu: "1".to_string(), + memory: "2Gi".to_string(), + environment: Default::default(), + egress: None, + idle_suspend_seconds: None, + }, + ) + .await + .expect_err("an unavailable data plane fails the create"); + + assert_eq!( + unavailable.hits(), + 1, + "a create that may already have minted a sandbox must not be sent twice" + ); + + let read = server.mock(|when, then| { + when.method(httpmock::Method::GET); + then.status(503).body("{}"); + }); + client + .get_sandbox("grp", "s1") + .await + .expect_err("an unavailable data plane fails the read"); + + assert!( + read.hits() > 1, + "a read is safe to repeat and must keep its retry: {} attempt(s)", + read.hits() + ); + + // A state transition is not safe to repeat either. If the stop takes effect and its + // response is lost, the repeat is refused for the state the first one produced — and the + // caller is told a session it did suspend is still awake. + let stop = server.mock(|when, then| { + when.method(httpmock::Method::POST).path_contains("/stop"); + then.status(503).body("{}"); + }); + client + .stop_sandbox("grp", "s1") + .await + .expect_err("an unavailable data plane fails the stop"); + + assert_eq!( + stop.hits(), + 1, + "a transition that may already have happened must not be sent twice" + ); + } /// Pinned because the contract came from a preview SDK Microsoft says may change. If these /// drift, the client must be re-read against the package rather than patched by guess. #[test] fn the_pinned_wire_contract_matches_what_the_sdk_ships() { assert_eq!(API_VERSION, "2026-02-01-preview"); - assert_eq!(ADC_SCOPE, "https://management.azuredevcompute.io/.default"); + assert_eq!(ADC_SCOPE, "https://dynamicsessions.io/.default"); } /// The data-plane path has no `providers/Microsoft.App` segment; borrowing ARM's shape here @@ -330,4 +765,283 @@ mod tests { assert_eq!(result.exit_code, None); } + + /// The three file calls, checked against the wire the SDK documents. + /// + /// Verb, path, query and body are each a way to be wrong without an error: the data plane + /// answers a mistyped query parameter with a success and a different effect. `createDirs` is + /// the one that carries the cross-backend rule that a write creates its parents. + #[tokio::test] + async fn the_file_calls_match_the_wire_the_sdk_documents() { + let server = MockServer::start_async().await; + let client = client_against(&server); + // The subscription is the mock config's; the rest is the path shape the SDK builds. + let sandbox = format!( + "/subscriptions/{}/resourceGroups/rg/sandboxGroups/grp/sandboxes/s1", + AzureClientConfig::mock().subscription_id + ); + + let read = server + .mock_async(|when, then| { + when.method(httpmock::Method::GET) + .path(format!("{sandbox}/files")) + .query_param("path", "src/app.py") + .query_param("api-version", API_VERSION); + then.status(200).body(b"print(1)\n"); + }) + .await; + let contents = client + .read_file("grp", "s1", "src/app.py") + .await + .expect("the read should succeed"); + assert_eq!(contents, b"print(1)\n"); + read.assert_async().await; + + let write = server + .mock_async(|when, then| { + when.method(httpmock::Method::PUT) + .path(format!("{sandbox}/files")) + .query_param("path", "src/app.py") + .query_param("createDirs", "true") + .header("content-type", "application/octet-stream") + .matches(carries_binary); + then.status(200); + }) + .await; + client + .write_file("grp", "s1", "src/app.py", BINARY.to_vec()) + .await + .expect("the write should succeed"); + write.assert_async().await; + + let mkdir = server + .mock_async(|when, then| { + when.method(httpmock::Method::POST) + .path(format!("{sandbox}/files/mkdir")) + .json_body(serde_json::json!({ "path": "src" })); + then.status(200); + }) + .await; + client.mkdir("grp", "s1", "src").await.expect("the mkdir should succeed"); + mkdir.assert_async().await; + } + + /// A file is bytes, not text: a transport that encoded it as UTF-8 would replace every + /// invalid sequence and hand back a different file than the sandbox holds. + #[tokio::test] + async fn a_file_that_is_not_text_survives_both_directions() { + let bytes = BINARY.to_vec(); + + let server = MockServer::start_async().await; + let client = client_against(&server); + let written = server + .mock_async(|when, then| { + when.method(httpmock::Method::PUT).matches(carries_binary); + then.status(200); + }) + .await; + client + .write_file("grp", "s1", "image.png", bytes.clone()) + .await + .expect("the write should succeed"); + written.assert_async().await; + + let server = MockServer::start_async().await; + let client = client_against(&server); + server + .mock_async(|when, then| { + when.method(httpmock::Method::GET); + then.status(200).body(bytes.clone()); + }) + .await; + assert_eq!( + client.read_file("grp", "s1", "image.png").await.expect("reads"), + bytes + ); + } + + /// The ceiling is refused here rather than accepted and truncated, and refused before the + /// body is sent — an oversized upload that fails at the far end has already been transferred. + #[tokio::test] + async fn a_transfer_over_the_ceiling_is_refused_before_it_is_sent() { + let server = MockServer::start_async().await; + let client = client_against(&server); + let refused = server + .mock_async(|when, then| { + when.method(httpmock::Method::PUT); + then.status(200); + }) + .await; + + let error = client + .write_file("grp", "s1", "big.bin", vec![0u8; MAX_FILE_BYTES + 1]) + .await + .expect_err("a body over the ceiling must be refused"); + + assert_eq!(error.code, "INVALID_INPUT", "{error}"); + refused.assert_hits_async(0).await; + } + + /// A read is bounded by the same number, against a data plane that says a file is small and + /// then sends more than it said. + #[tokio::test] + async fn a_read_stops_at_the_ceiling_rather_than_filling_memory() { + let server = MockServer::start_async().await; + let client = client_against(&server); + server + .mock_async(|when, then| { + when.method(httpmock::Method::GET); + then.status(200).body(vec![0u8; MAX_FILE_BYTES + 1]); + }) + .await; + + let error = client + .read_file("grp", "s1", "big.bin") + .await + .expect_err("a body over the ceiling must be refused"); + + assert_eq!(error.code, "INVALID_INPUT", "{error}"); + } + + /// A sandbox inherits nothing, so a variable the caller asked for exists only if the create + /// body carries it — and the data plane accepts a body without it, so nothing else would say. + #[test] + fn the_create_body_carries_the_variables_the_caller_asked_for() { + let body = create_body(&CreateSandbox { + disk_image: "ubuntu".to_string(), + cpu: "1000m".to_string(), + memory: "2048Mi".to_string(), + environment: BTreeMap::from([("TOKEN".to_string(), "t".to_string())]), + egress: Some(EgressPolicy { + default_action: "Deny".to_string(), + unmodelled: Default::default(), + host_rules: vec![EgressHostRule { + pattern: "api.example.com".to_string(), + action: "Allow".to_string(), + }], + rules: Vec::new(), + traffic_inspection: Some("Full".to_string()), + }), + idle_suspend_seconds: None, + }); + + assert_eq!(body["environment"]["TOKEN"], "t"); + assert_eq!(body["sourcesRef"]["diskImage"]["name"], "ubuntu"); + assert_eq!(body["resources"]["cpu"], "1000m"); + // camelCase, because the data plane ignores a field it cannot name and creates an open + // sandbox instead of refusing the body. + assert_eq!(body["egressPolicy"]["defaultAction"], "Deny"); + assert_eq!(body["egressPolicy"]["trafficInspection"], "Full"); + assert_eq!(body["egressPolicy"]["hostRules"][0]["pattern"], "api.example.com"); + + let bare = create_body(&CreateSandbox::default()); + assert!( + bare.get("environment").is_none(), + "an empty map is no variables, not an empty object: {bare}" + ); + assert!( + bare.get("lifecycle").is_none(), + "an undeclared idle policy leaves the service's own rather than asserting one: {bare}" + ); + } + + /// A declared idle suspend has to arrive as the nested policy the data plane reads, under + /// the mode that keeps the process state a session exists for. + #[test] + fn the_create_body_nests_the_idle_suspend_policy() { + let body = create_body(&CreateSandbox { + idle_suspend_seconds: Some(900), + ..CreateSandbox::default() + }); + + assert_eq!(body["lifecycle"]["autoSuspendPolicy"]["interval"], 900); + assert_eq!(body["lifecycle"]["autoSuspendPolicy"]["enabled"], true); + assert_eq!(body["lifecycle"]["autoSuspendPolicy"]["mode"], "Memory"); + } + + /// The response field is `state`. Reading `status` leaves every sandbox deserializing to + /// `None`, which the provider cannot tell apart from a healthy one. + #[test] + fn a_sandbox_deserializes_its_state() { + let sandbox: Sandbox = + serde_json::from_str(r#"{"id":"s1","state":"Stopped"}"#).expect("deserializes"); + + assert_eq!(sandbox.state.as_deref(), Some("Stopped")); + } + + /// A rule this client does not send still has to be read back: an `Allow` here permits what + /// the host patterns denied, and a field nobody models is a field nobody checks. + #[test] + fn an_effective_policy_carries_the_rules_it_was_not_sent() { + let policy: EgressPolicy = serde_json::from_str( + r#"{"defaultAction":"Deny","trafficInspection":"Full", + "rules":[{"match":{"host":"*"},"action":{"type":"Allow"}}]}"#, + ) + .expect("deserializes"); + + assert_eq!(policy.rules.len(), 1); + assert_eq!( + policy.rules[0].action.as_ref().map(|action| action.action_type.as_str()), + Some("Allow") + ); + } + + /// The two lifecycle verbs, on the paths the SDK documents. + /// + /// Both are bodyless POSTs to sibling paths, so a swapped verb is a call that succeeds and + /// does the opposite of what was asked. + #[tokio::test] + async fn the_lifecycle_verbs_post_to_their_own_paths() { + let server = MockServer::start_async().await; + let client = client_against(&server); + let sandbox = format!( + "/subscriptions/{}/resourceGroups/rg/sandboxGroups/grp/sandboxes/s1", + AzureClientConfig::mock().subscription_id + ); + + let stop = server + .mock_async(|when, then| { + when.method(httpmock::Method::POST) + .path(format!("{sandbox}/stop")) + .query_param("api-version", API_VERSION); + then.status(202); + }) + .await; + client.stop_sandbox("grp", "s1").await.expect("stop is accepted"); + stop.assert_async().await; + + let resume = server + .mock_async(|when, then| { + when.method(httpmock::Method::POST) + .path(format!("{sandbox}/resume")); + then.status(202); + }) + .await; + client.resume_sandbox("grp", "s1").await.expect("resume is accepted"); + resume.assert_async().await; + } + + /// A key this client cannot read on a *rule* fails the parse, as it does on the policy. + /// + /// An exception list on a rule that otherwise reads as a plain deny is reach the declaration + /// never named, and a field that deserializes into nothing is one no check can weigh. + #[test] + fn an_unreadable_key_on_a_rule_fails_the_parse() { + for policy in [ + r#"{"defaultAction":"Deny","hostRules":[{"pattern":"*","action":"Deny","exceptions":["x"]}]}"#, + r#"{"defaultAction":"Deny","rules":[{"action":{"type":"Deny","exceptHosts":["x"]}}]}"#, + r#"{"defaultAction":"Deny","rules":[{"match":{"host":"*","exceptPorts":[443]}}]}"#, + ] { + serde_json::from_str::(policy) + .expect_err("a rule carrying an unreadable key must not parse"); + } + + // The documented surface still parses, so the rule above refuses additions rather than + // everything. + serde_json::from_str::( + r#"{"defaultAction":"Deny","rules":[{"name":"r","match":{"host":"*","path":"/","methods":["GET"]}, + "action":{"type":"Rewrite","host":"h","path":"/p","scheme":"https","headers":[]}}]}"#, + ) + .expect("every field the SDK models must still parse"); + } } diff --git a/crates/alien-bindings/src/error.rs b/crates/alien-bindings/src/error.rs index ef4179686..a5115aab5 100644 --- a/crates/alien-bindings/src/error.rs +++ b/crates/alien-bindings/src/error.rs @@ -301,11 +301,15 @@ pub enum ErrorData { }, /// A command run inside a sandbox did not complete. + /// + /// Visibility is inherited for the reason `SandboxUnreachable` gives below: what this wraps is + /// often a cloud client's error carrying the response text of the call that failed, and + /// `into_external` reads only the outermost flag — so a fixed `false` here would publish it. #[error( code = "SANDBOX_COMMAND_FAILED", message = "Sandbox command failed ({failure}): {reason}", retryable = "false", - internal = "false", + internal = "inherit", http_status_code = 400 )] SandboxCommandFailed { @@ -315,6 +319,28 @@ pub enum ErrorData { reason: String, }, + /// A session came up without a restriction its declaration asked for. + /// + /// Distinct from a refused call: the data plane accepted the request and answered, and what + /// it built is not what was asked for. The session id is carried because the caller never + /// receives one — this is the failure where an operator has to be able to find what was left + /// behind if deleting it also failed. + #[error( + code = "SANDBOX_NOT_AS_DECLARED", + message = "Sandbox session '{session_id}' does not carry its declared {restriction}, so it cannot be used; create a new session. {reason}", + retryable = "false", + internal = "false", + http_status_code = 502 + )] + SandboxNotAsDeclared { + /// Provider-scoped id of the session that was built + session_id: String, + /// What the declaration asked for, such as `egress policy` + restriction: String, + /// What the session came up with instead + reason: String, + }, + /// The sandbox agent could not be reached, or the connection dropped mid-response. /// /// Visibility is inherited rather than declared public: what this wraps is often the cloud diff --git a/crates/alien-bindings/src/provider.rs b/crates/alien-bindings/src/provider.rs index 1b1f7eb60..1099a3ee3 100644 --- a/crates/alien-bindings/src/provider.rs +++ b/crates/alien-bindings/src/provider.rs @@ -107,6 +107,11 @@ impl std::fmt::Debug for CredentialResolver { } } +/// The ADC service defaults, named so a reader can tell a deliberate default from a magic number. +/// One core and 2 GiB — what `begin_create_sandbox` uses when a caller passes neither. +const DEFAULT_AZURE_CPU: &str = "1000m"; +const DEFAULT_AZURE_MEMORY: &str = "2048Mi"; + impl BindingsProvider { /// Creates a new BindingsProvider with explicit credentials and bindings. /// @@ -1891,14 +1896,22 @@ impl BindingsProviderApi for BindingsProvider { AzureTokenCache::new(azure_config.clone()), ); - // Session ceilings come from the resource, not the caller — an application must - // not be able to raise its own by asking. + let disk_image = azure_binding + .disk_image + .into_value(binding_name, "diskImage") + .map_err(|_| invalid("diskImage"))?; + + // Ceilings stay the service defaults: `.limits()` is refused on Azure at plan + // time, so nothing declares them and there is no value to carry. They move into + // the binding when `enforcedLimits` flips, not before. let sandbox: Arc = Arc::new(AzureSandbox::new( Arc::new(client), group, - "ubuntu".to_string(), - "1000m".to_string(), - "2048Mi".to_string(), + disk_image, + azure_binding.egress, + azure_binding.idle_suspend_seconds, + DEFAULT_AZURE_CPU.to_string(), + DEFAULT_AZURE_MEMORY.to_string(), )); Ok(sandbox) } @@ -2216,6 +2229,54 @@ mod tests { ); } + /// The image in the binding has to be the image the provider uses. + /// + /// Asserted here because the failure is silent: a sandbox built from the wrong image still + /// starts, so nothing else catches a declared image that never reached the create call. + #[cfg(feature = "azure")] + #[tokio::test] + async fn an_azure_sandbox_binding_carries_its_disk_image_to_the_provider() { + let env = HashMap::from([ + ( + ENV_ALIEN_DEPLOYMENT_TYPE.to_string(), + Platform::Azure.as_str().to_string(), + ), + ("AZURE_SUBSCRIPTION_ID".to_string(), "sub".to_string()), + ("AZURE_TENANT_ID".to_string(), "ten".to_string()), + ("AZURE_CLIENT_ID".to_string(), "cli".to_string()), + ("AZURE_CLIENT_SECRET".to_string(), "sec".to_string()), + ( + "ALIEN_BOX_BINDING".to_string(), + r#"{"service":"sandbox-azure", + "sandboxGroup":"grp", + "dataPlaneEndpoint":"https://management.swedencentral.azuredevcompute.io", + "region":"swedencentral", + "resourceGroup":"rg", + "diskImage":"my-toolchain", + "egress":{"mode":"deny"}}"# + .to_string(), + ), + ]); + let provider = BindingsProvider::from_env(env) + .await + .expect("provider construction validates only that the binding JSON parses"); + + let sandbox = provider + .load_sandbox("box") + .await + .expect("an Azure sandbox binding loads"); + + let azure = sandbox + .as_any() + .downcast_ref::() + .expect("an Azure binding builds an Azure provider"); + assert_eq!( + azure.disk_image(), + "my-toolchain", + "the declared image must reach the provider, not a literal chosen at construction" + ); + } + /// A MicroVM with no egress connector reaches the internet, so the binding's two egress /// fields have to agree: an empty list is how `allow` travels, and it is a fail-open default /// unless `allowEgress` says so. Both disagreements are refused, and `deny` still loads. diff --git a/crates/alien-bindings/src/providers/sandbox/azure.rs b/crates/alien-bindings/src/providers/sandbox/azure.rs index 2bc0746ca..12705845f 100644 --- a/crates/alien-bindings/src/providers/sandbox/azure.rs +++ b/crates/alien-bindings/src/providers/sandbox/azure.rs @@ -15,18 +15,25 @@ use crate::traits::{ Binding, CommandOutput, CreateSessionRequest, PreviewCapability, RunCommandRequest, Sandbox, SandboxSession, SandboxSessionState, }; -use alien_azure_clients::azure::sandbox_data_plane::SandboxDataPlaneApi; +use alien_azure_clients::azure::sandbox_data_plane::{ + CreateSandbox, EgressHostRule, EgressPolicy, SandboxDataPlaneApi, +}; use alien_client_core::ErrorData as ClientErrorData; -use alien_core::{Platform, SandboxCapabilities}; -use alien_error::AlienError; +use alien_core::{Platform, SandboxCapabilities, SandboxEgress}; +use alien_error::{AlienError, ContextError}; +use tracing::warn; /// A Sandbox backed by the Azure ADC data plane. #[derive(Debug)] pub struct AzureSandbox { client: std::sync::Arc, sandbox_group: String, - /// Disk image every session is created from. - disk: String, + /// Catalog disk image every session is created from, from the declaration. + disk_image: String, + /// Outbound policy every session is created with, from the declaration. + egress: SandboxEgress, + /// Idle seconds after which a session suspends itself, if the declaration asked for one. + idle_suspend_seconds: Option, /// Session ceilings, in the data plane's own units. cpu: String, memory: String, @@ -37,19 +44,56 @@ impl AzureSandbox { pub fn new( client: std::sync::Arc, sandbox_group: String, - disk: String, + disk_image: String, + egress: SandboxEgress, + idle_suspend_seconds: Option, cpu: String, memory: String, ) -> Self { Self { client, sandbox_group, - disk, + disk_image, + egress, + idle_suspend_seconds, cpu, memory, } } + /// The catalog image sessions are created from. Exists so a test can prove the declaration + /// reached the provider — the failure it guards is silent, so nothing else would show it. + #[cfg(test)] + pub(crate) fn disk_image(&self) -> &str { + &self.disk_image + } + + /// A session id that stays one path segment. + /// + /// The id is interpolated into the data-plane URL, and `Url::parse` resolves `..` — so an id + /// carrying one addresses a different sandbox group, which a stack-scoped management identity + /// can reach. Azure mints ids itself; this bounds the ones a caller hands back. + fn checked_session_id(operation: &str, session_id: &str) -> Result<()> { + let usable = !session_id.is_empty() + && session_id.len() <= MAX_SESSION_ID + && session_id + .chars() + .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_'); + + if usable { + return Ok(()); + } + + Err(AlienError::new(ErrorData::InvalidInput { + operation_context: operation.to_string(), + details: format!( + "session id '{session_id}' must hold only letters, digits, '-' and '_', at most \ + {MAX_SESSION_ID} characters" + ), + field_name: Some("sessionId".to_string()), + })) + } + fn unsupported(&self, capability: &str) -> AlienError { AlienError::new(ErrorData::OperationNotSupported { operation: capability.to_string(), @@ -57,10 +101,34 @@ impl AzureSandbox { }) } - fn failed(operation: &str, error: impl std::fmt::Display) -> AlienError { - AlienError::new(ErrorData::OperationNotSupported { + /// Sorts a data-plane failure into the two buckets every other backend uses. + /// + /// A refusal is a request the data plane understood and rejected, so repeating it repeats the + /// refusal. Anything else left the outcome unknown: for the idempotent file operations that is + /// worth another attempt, but `run_command` may already have started the command and must not + /// carry the retry signal. The cause stays on the source chain rather than in `reason`, which + /// is what keeps a raw response body out of an externally visible message. + fn failed(operation: &str, error: AlienError) -> AlienError { + if is_refusal(&error) { + return error.context(ErrorData::SandboxCommandFailed { + failure: "dataPlaneRefused".to_string(), + reason: format!("{operation} was refused; the cause carries which side refused"), + }); + } + + if operation == RUN_COMMAND || operation == CREATE { + return error.context(ErrorData::SandboxCommandFailed { + failure: "outcomeUnknown".to_string(), + reason: format!( + "{operation} did not complete against the Azure sandbox data plane, so \ + whether it took effect is unknown" + ), + }); + } + + error.context(ErrorData::SandboxUnreachable { operation: operation.to_string(), - reason: format!("the Azure sandbox data plane refused the call: {error}"), + reason: "the Azure sandbox data plane did not complete the call".to_string(), }) } } @@ -74,48 +142,117 @@ impl Sandbox for AzureSandbox { } async fn create(&self, request: CreateSessionRequest) -> Result { + checked_session_env(CREATE, &request.env)?; + + let asked = egress_policy(&self.egress); let sandbox = self .client - .create_sandbox(&self.sandbox_group, &self.disk, &self.cpu, &self.memory) + .create_sandbox( + &self.sandbox_group, + CreateSandbox { + disk_image: self.disk_image.clone(), + cpu: self.cpu.clone(), + memory: self.memory.clone(), + environment: request.env, + egress: asked.clone(), + idle_suspend_seconds: self.idle_suspend_seconds, + }, + ) .await - .map_err(|error| Self::failed("sandbox.create", error))?; + .map_err(|error| Self::failed(CREATE, error))?; // The caller's requested id is not authoritative: Azure allocates the id, and returning - // the requested one would hand back a handle that addresses nothing. + // the requested one would hand back a handle that addresses nothing. Checked because + // every later verb addresses the sandbox by it, and one this client cannot send is one + // nothing can reach or reap. let _ = request.session_id; + if Self::checked_session_id(CREATE, &sandbox.id).is_err() { + let unreadable = AlienError::new(ErrorData::UnexpectedResponseFormat { + provider: "azure".to_string(), + binding_name: CREATE.to_string(), + field: "id".to_string(), + response_json: format!("{:?}", sandbox.id), + }); + + // Reaped unless the id is itself what makes the delete unsafe: a path separator or an + // escape would send that delete into another group. Everything else this check + // refuses — an over-long id, an unusual character — is still safe to address once, + // and refusing to reap it leaves a running sandbox no id-holder can find. + // An allowlist, because the hazard is anything the URL parser reads differently: + // `abc?x` starts a query string, so the delete would land on the sandbox named `abc`. + let addressable = !sandbox.id.is_empty() + && sandbox + .id + .chars() + .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_'); + + return Err(if !addressable { + warn!( + session = %sandbox.id, + "the data plane minted an id this client will not send; the sandbox is \ + running and cannot be deleted through this binding" + ); + unreadable + } else { + self.discard(&sandbox.id, unreadable).await + }); + } - Ok(SandboxSession { - session_id: sandbox.id, - state: SandboxSessionState::Running, - generation: 1, - }) + // Everything past this point owns a sandbox the caller has no id for, so every failure + // deletes it. Azure allocates the id, so the one in this response was minted by this call. + match self.settle(&sandbox).await { + Ok(session) => Ok(session), + Err(error) => Err(self.discard(&sandbox.id, error).await), + } } async fn get(&self, session_id: &str) -> Result> { - match self - .client - .get_sandbox(&self.sandbox_group, session_id) - .await - { - Ok(sandbox) => Ok(Some(SandboxSession { - session_id: sandbox.id, - state: match sandbox.status.as_deref() { - Some("Stopped") => SandboxSessionState::Suspended, - _ => SandboxSessionState::Running, - }, - generation: 1, - })), - // A 404 is "gone", which is a valid answer. Anything else is a real failure and must - // not be flattened into None, or a throttle would read as an expired session. - Err(error) if is_not_found(&error) => Ok(None), - Err(error) => Err(Self::failed("sandbox.get", error)), - } + Self::checked_session_id("sandbox.get", session_id)?; + // A 404 is "gone", which is a valid answer. Anything else is a real failure and must not + // be flattened into None, or a throttle would read as an expired session. + let Some(sandbox) = self.read_session("sandbox.get", session_id).await? else { + return Ok(None); + }; + + let state = session_state("sandbox.get", sandbox.state.as_deref())?; + + // This is the path a reconnect takes: a session outlives the declaration it was created + // under, so a caller holding its id would otherwise be handed whatever containment it was + // built with. Only the two ends of the lifecycle carry no policy, and that is not a + // mismatch. + self.judge_if_judgeable(&sandbox)?; + + Ok(Some(SandboxSession { + session_id: sandbox.id, + state, + generation: 1, + })) } async fn get_or_create(&self, request: CreateSessionRequest) -> Result { if let Some(id) = request.session_id.as_deref() { - if let Some(existing) = self.get(id).await? { - return Ok(existing); + // `create` returns a session that can take work, and reaching one someone else + // started has to mean the same thing — so the same gate every other verb uses: bring + // it up, judge it there, and refuse it if it does not match. + match self.reconnect(id).await { + Ok(session) => return Ok(session), + // The two ways an id can fail to serve — gone, or running a policy the + // declaration no longer matches — mean the same thing to a caller asking for a + // session, and are answered the same way: a fresh one. A session refused for its + // policy is left as it was found — asleep again if this call woke it — because it + // may be another revision's, and this caller is served by the replacement rather + // than by taking theirs. + // + // Narrow on purpose: a readiness timeout says the data plane is slow, and + // answering that by creating a second sandbox makes it slower. + Err(error) + if error.code == "SANDBOX_NOT_AS_DECLARED" + || matches!( + &error.error, + Some(ErrorData::SandboxCommandFailed { failure, .. }) + if failure == "sessionGone" || failure == "sessionTerminated" + ) => {} + Err(error) => return Err(error), } } @@ -131,6 +268,7 @@ impl Sandbox for AzureSandbox { session_id: &str, request: RunCommandRequest, ) -> Result>> { + Self::checked_session_id(RUN_COMMAND, session_id)?; if request.deadline.is_zero() { return Err(AlienError::new(ErrorData::OperationNotSupported { operation: "sandbox.runCommand".to_string(), @@ -138,6 +276,12 @@ impl Sandbox for AzureSandbox { })); } + // The only verb that starts untrusted code, so it is the one that re-reads the policy: a + // session id outlives a declaration change, and nothing else stands between an id a + // caller kept and the egress it was built with. One extra read against a data plane the + // command itself is about to cross. + self.judged_session(RUN_COMMAND, session_id).await?; + // The deadline bounds the untrusted code, not the caller's patience. Read out of the // preview SDK rather than assumed: `executeShellCommand` sends `command` and an optional // `workingDirectory` and nothing else, so there is no server-side timeout to ask for. The @@ -146,7 +290,35 @@ impl Sandbox for AzureSandbox { // agent-supervised backends give. The client-side guard is the backstop for a data plane // that never answers at all; there the only lever left is ending the session, and that // call returns once the session is confirmed gone rather than claim containment early. - let shell = bounded_shell(&request.command, request.deadline); + // The data plane's exec takes a command and a working directory and nothing else, so a + // per-command variable travels through `env` in the argv — which keeps it off the shell + // that bounds the command. Names are checked so `env` will take them as variables. + if request.command.is_empty() { + return Err(AlienError::new(ErrorData::InvalidInput { + operation_context: RUN_COMMAND.to_string(), + details: "a command must name a program to run".to_string(), + field_name: Some("command".to_string()), + })); + } + + for name in request.env.keys() { + checked_env_name(RUN_COMMAND, name)?; + } + // `env` takes operands as assignments until one is not, so a program whose own name + // carries `=` would be read as a variable and the next argument run in its place. + if !request.env.is_empty() { + if let Some(program) = request.command.first().filter(|first| first.contains('=')) { + return Err(AlienError::new(ErrorData::InvalidInput { + operation_context: RUN_COMMAND.to_string(), + details: format!( + "command '{program}' cannot carry '=' in its name while the call also \ + declares environment variables" + ), + field_name: Some("command".to_string()), + })); + } + } + let shell = bounded_shell(&request.command, &request.env, request.deadline); let result = self.execute_within(session_id, &shell, &request).await?; // The session's own report, removed from what the caller sees. @@ -200,32 +372,101 @@ impl Sandbox for AzureSandbox { Ok(Box::pin(stream::iter(frames))) } - async fn read_file(&self, _session_id: &str, _path: &str) -> Result> { - Err(self.unsupported("readFile")) + /// Ungated on purpose, as is `mkdir`: reading existing content and creating an empty + /// directory add nothing to a sandbox, so neither can turn a stale session into a way to run + /// something under egress the declaration has since removed. + async fn read_file(&self, session_id: &str, path: &str) -> Result> { + Self::checked_session_id("sandbox.readFile", session_id)?; + let path = &checked_path("sandbox.readFile", path)?; + + self.client + .read_file(&self.sandbox_group, session_id, path) + .await + .map_err(|error| Self::failed("sandbox.readFile", error)) } - async fn write_files( - &self, - _session_id: &str, - _files: BTreeMap>, - ) -> Result<()> { - Err(self.unsupported("writeFiles")) + async fn write_files(&self, session_id: &str, files: BTreeMap>) -> Result<()> { + Self::checked_session_id("sandbox.writeFiles", session_id)?; + // Checked before anything is written, and before anything is read: partial application is + // the contract for a data plane that refuses midway, not for a path this process could + // have rejected without a round trip. + let files = files + .into_iter() + .map(|(path, contents)| Ok((checked_path("sandbox.writeFiles", &path)?, contents))) + .collect::>>()?; + + // The one file operation that moves the caller's own content in. A write-then-run against + // an id kept across a tightened declaration would land the payload in a sandbox with the + // egress the declaration just removed, and the refusal would arrive a beat later. + self.judged_session("sandbox.writeFiles", session_id).await?; + + // One request per path, stopping at the first failure: the same partial application every + // other backend performs, so a caller sees one contract rather than five. + for (path, contents) in files { + self.client + .write_file(&self.sandbox_group, session_id, &path, contents) + .await + .map_err(|error| Self::failed("sandbox.writeFiles", error))?; + } + + Ok(()) } - async fn mkdir(&self, _session_id: &str, _path: &str) -> Result<()> { - Err(self.unsupported("mkdir")) + async fn mkdir(&self, session_id: &str, path: &str) -> Result<()> { + Self::checked_session_id("sandbox.mkdir", session_id)?; + let path = &checked_path("sandbox.mkdir", path)?; + + self.client + .mkdir(&self.sandbox_group, session_id, path) + .await + .map_err(|error| Self::failed("sandbox.mkdir", error)) } async fn preview(&self, _session_id: &str, _port: u16) -> Result { Err(self.unsupported("preview")) } - async fn suspend(&self, _session_id: &str) -> Result<()> { - Err(self.unsupported("suspendResume")) + async fn suspend(&self, session_id: &str) -> Result<()> { + Self::checked_session_id("sandbox.suspend", session_id)?; + // Accepted, not completed — the same contract the AWS backend follows. `get` reports + // `Suspended` from the moment the stop is under way, so it answers "cannot take work", + // not "has stopped"; only `terminate` confirms a session is actually gone. + self.client + .stop_sandbox(&self.sandbox_group, session_id) + .await + .map_err(|error| Self::failed("sandbox.suspend", error)) } - async fn resume(&self, _session_id: &str) -> Result<()> { - Err(self.unsupported("suspendResume")) + async fn resume(&self, session_id: &str) -> Result<()> { + Self::checked_session_id("sandbox.resume", session_id)?; + const OPERATION: &str = "sandbox.resume"; + + let Some(found) = self.read_session(OPERATION, session_id).await? else { + return Err(AlienError::new(ErrorData::SandboxCommandFailed { + failure: "sessionGone".to_string(), + reason: format!("{OPERATION}: session '{session_id}' does not exist"), + })); + }; + + // Refused from the record already in hand where that record answers it, so a session + // whose stored policy is plainly wrong is never put back on the network for a boot. + self.judge_if_judgeable(&found)?; + + // Judged again after the wake: the stopped record is not the one the work runs under, and + // a policy set on the group can change while a session sleeps. + let mut resumed_here = false; + let woken = self + .await_running(OPERATION, session_id, &mut resumed_here) + .await; + + let refusal = match woken { + Err(error) => error, + Ok(running) => match self.policy_must_hold(&running) { + Ok(()) => return Ok(()), + Err(error) => error, + }, + }; + Err(self.put_back(session_id, resumed_here, refusal).await) } async fn snapshot(&self, _session_id: &str) -> Result { @@ -233,14 +474,29 @@ impl Sandbox for AzureSandbox { } async fn terminate(&self, session_id: &str) -> Result<()> { + Self::checked_session_id("sandbox.terminate", session_id)?; self.accept_delete(session_id).await?; // The delete is accepted, not completed: the client's own contract is "returns before it // is gone; confirm by polling to 404". Returning here would report containment while the // code is still running, which is the whole point of terminate. + // The client rather than `get`: teardown needs the 404 and nothing else, and reading a + // state it cannot parse would abort the poll for a session that is already going away — + // replacing a `deadlineExceeded` finding with a deserialization error on the one path + // where untrusted code is known to be running past its deadline. for _ in 0..TERMINATE_POLL_ATTEMPTS { - if self.get(session_id).await?.is_none() { - return Ok(()); + // A read that fails is not a session that is gone, and it is not a reason to stop + // looking either: the attempt budget decides, so one throttled response cannot end + // the poll that turns an accepted delete into a confirmed one. + if let Err(error) = self + .client + .get_sandbox(&self.sandbox_group, session_id) + .await + { + if is_not_found(&error) { + return Ok(()); + } + warn!(session = %session_id, %error, "could not confirm a sandbox is gone"); } tokio::time::sleep(TERMINATE_POLL_INTERVAL).await; } @@ -260,17 +516,376 @@ impl Sandbox for AzureSandbox { } impl AzureSandbox { - /// Runs one shell string under the client-side guard. - /// - /// The guard is the deadline plus the grace the in-session `timeout` needs to report back. - /// When it fires the session itself did not end the command, so the session is ended, and - /// the call returns once that is confirmed — the same rule the agent-supervised backends - /// follow, where the agent waits for its kill before reporting: `deadlineExceeded` means the - /// command has stopped, never that a stop was requested. This is the one path where untrusted - /// code is known to be running past its deadline, so it is bounded rather than early: the - /// deadline, the grace, and the delete's confirmation window, and it is reached only by a - /// session that could not run `timeout` — every other overrun is ended in place, at the - /// deadline. + /// Brings a session the caller named back into service, or says why it cannot be. + /// + /// The one path that replaces rather than only refusing: `get_or_create` asked for a usable + /// session, so an id that cannot serve becomes a fresh session rather than an error the + /// caller has no way to act on. Only a `Failed` sandbox is deleted here — one refused for its + /// policy is left alone, because the group is shared and it may be in use. + async fn reconnect(&self, session_id: &str) -> Result { + let gone = || { + AlienError::new(ErrorData::SandboxCommandFailed { + failure: "sessionGone".to_string(), + reason: format!("{GET_OR_CREATE}: session '{session_id}' cannot take work"), + }) + }; + + let found = match self.read_session(GET_OR_CREATE, session_id).await? { + // A failed sandbox is not going away on its own, and the caller asked for a session + // rather than for this one, so it is reaped rather than left beside its replacement. + Some(sandbox) if sandbox.state.as_deref() == Some("Failed") => { + return Err(self.discard(session_id, gone()).await) + } + Some(sandbox) if sandbox.state.as_deref() != Some("Deleting") => sandbox, + _ => return Err(gone()), + }; + + // Judged asleep first: waking one that already fails puts its workload back on the network + // for a boot. Refused rather than deleted, here and after the wake: the policy mismatch + // may belong to another revision, mid-command in the shared group. + self.judge_if_judgeable(&found)?; + + // Judged again once it is up: only the woken record covers a session that was still coming + // up, or a policy set on the group while it slept. + let mut resumed_here = false; + let running = match self + .await_running(GET_OR_CREATE, session_id, &mut resumed_here) + .await + { + Ok(running) => running, + Err(error) => return Err(self.put_back(session_id, resumed_here, error).await), + }; + if let Err(error) = self.policy_must_hold(&running) { + return Err(self.put_back(session_id, resumed_here, error).await); + } + + Ok(SandboxSession { + session_id: running.id, + state: SandboxSessionState::Running, + generation: 1, + }) + } + + /// Reads a session that is fit to be used, refusing one that is not. + /// + /// Refuses rather than repairs: a session this binding did not create and the caller did not + /// ask to replace is not this call's to destroy. Two revisions of a stack share a sandbox + /// group, so a tightened one reaping a session the other is mid-command on would be an + /// outage caused by a read. + /// + /// Requires the session to be running, because that is the only state carrying a policy + /// worth judging — and waking one to write into it would undo the idle suspend the + /// declaration asked for. + async fn judged_session(&self, operation: &str, session_id: &str) -> Result<()> { + let refuse = |failure: &str, why: &str| { + Err(AlienError::new(ErrorData::SandboxCommandFailed { + failure: failure.to_string(), + reason: format!("{operation}: session '{session_id}' {why}"), + })) + }; + + let Some(sandbox) = self.read_session(operation, session_id).await? else { + return refuse("sessionGone", "does not exist"); + }; + + match sandbox.state.as_deref() { + Some("Running") => {} + Some("Creating" | "Resuming") => { + return refuse("sessionNotReady", "is still starting; wait for it to run") + } + Some("Deleting") => return refuse("sessionGone", "is being deleted"), + Some("Failed") => return refuse("sessionGone", "has failed"), + Some("Stopping") => return refuse("sessionSuspended", "is stopping; wait for it"), + Some("Stopped" | "Suspended" | "Idle") => { + return refuse("sessionSuspended", "is suspended; resume it first") + } + // Unreadable rather than suspended, which would send a caller to `resume` for an + // answer it cannot give. The refusal below is reached only if the two state lists + // drift apart, and refusing is the safe side of that. + other => { + session_state(operation, other)?; + return refuse("sessionNotReady", "is in a state this client cannot read"); + } + } + + self.policy_must_hold(&sandbox) + } + + /// Reads a session, or `None` when it is gone, without judging its policy. + async fn read_session( + &self, + operation: &str, + session_id: &str, + ) -> Result> { + match self + .client + .get_sandbox(&self.sandbox_group, session_id) + .await + { + Ok(sandbox) => Ok(Some(sandbox)), + Err(error) if is_not_found(&error) => Ok(None), + Err(error) => Err(Self::failed(operation, error)), + } + } + + /// Wakes a session without judging it, for the wait that has nothing to judge yet. + async fn resume_unchecked(&self, session_id: &str) -> Result<()> { + self.client + .resume_sandbox(&self.sandbox_group, session_id) + .await + .map_err(|error| Self::failed("sandbox.resume", error)) + } + + /// Refuses a sandbox that is not running the policy the declaration asked for. + /// + /// The effective policy can change under a live session — a group-scoped policy is set + /// somewhere this binding never writes — so every path that hands one back checks, not just + /// the one that created it. + fn policy_must_hold( + &self, + sandbox: &alien_azure_clients::azure::sandbox_data_plane::Sandbox, + ) -> Result<()> { + let Some(asked) = egress_policy(&self.egress) else { + return Ok(()); + }; + if policy_holds(&asked, sandbox.egress_policy.as_ref()) { + return Ok(()); + } + + Err(AlienError::new(ErrorData::SandboxNotAsDeclared { + session_id: sandbox.id.clone(), + restriction: "egress policy".to_string(), + reason: format!( + "it is running {} where the declaration asks for {}", + describe(sandbox.egress_policy.as_ref()), + describe(Some(&asked)) + ), + })) + } + + /// Turns a freshly created sandbox into a session, or says why it is not one. + /// + /// Every check that can fail after the sandbox exists lives here, so `create` has one place + /// to delete from rather than a delete beside each `?`. + async fn settle( + &self, + sandbox: &alien_azure_clients::azure::sandbox_data_plane::Sandbox, + ) -> Result { + // The running sandbox is what gets judged, not the accept: a create response sent while + // the sandbox is still coming up need not carry the policy yet, and reading its absence + // as "the restriction did not take" would delete every sandbox that answered early. + let mut resumed_here = false; + let running = self + .await_running(CREATE, &sandbox.id, &mut resumed_here) + .await?; + + // A restriction that did not take effect is worse than one that was never asked for: the + // caller believes the sandbox is contained. + self.policy_must_hold(&running)?; + + Ok(SandboxSession { + session_id: running.id, + state: SandboxSessionState::Running, + generation: 1, + }) + } + + /// Waits for a session to be able to take work. + /// + /// The operation is the caller's, not this function's: a reconnect that waits is still a + /// reconnect, and reporting it as a create would mark a repeatable read unrepeatable. + /// + /// A suspended session is resumed rather than waited on — on the create path an idle policy + /// can stop a sandbox before its first command, and on the reconnect path a stopped sandbox + /// is the ordinary resting state. Nothing else brings one up, so waiting alone would spend + /// the whole deadline and then delete it. + async fn await_running( + &self, + operation: &str, + session_id: &str, + resumed_here: &mut bool, + ) -> Result { + let deadline = std::time::Instant::now() + SESSION_READY_TIMEOUT; + let mut refusal: Option = None; + + loop { + let Some(sandbox) = self.read_session(operation, session_id).await? else { + return Err(AlienError::new(ErrorData::SandboxCommandFailed { + failure: "sessionGone".to_string(), + reason: format!("{operation}: session '{session_id}' disappeared while it was being waited for"), + })); + }; + + // The raw state, because the four the trait publishes cannot separate a sandbox on + // its way up from one on its way down, and this loop needs that difference. + match sandbox.state.as_deref() { + Some("Running") => return Ok(sandbox), + Some("Creating" | "Resuming") => {} + // Still going down. Resume is refused in this state — the SDK's own resumable + // set excludes it — so the wait is for `Stopped`, not for the call to work. + Some("Stopping") => {} + // Re-issued on every poll, because the attempt most likely to be refused is the + // first one: remembering only that an attempt was made would spend the whole + // budget watching a sandbox nothing is bringing up. + Some("Stopped" | "Suspended" | "Idle") => { + match self.resume_unchecked(session_id).await { + Ok(()) => { + refusal = None; + *resumed_here = true; + } + Err(error) => { + let failure = match &error.error { + Some(ErrorData::SandboxCommandFailed { failure, .. }) => { + failure.clone() + } + _ => error.code.clone(), + }; + // A refusal is the one answer that proves the session did not wake. + // Anything else — a 5xx, a timeout, a dropped connection — leaves the + // outcome unknown, and an unknown wake is one this call owns. + if failure != "dataPlaneRefused" { + *resumed_here = true; + } + warn!(session = %session_id, %error, "resume was refused; still waiting"); + refusal = Some(failure); + } + } + } + // A terminated session never becomes runnable, and folding it into the timeout + // would report it a minute late as a slow boot. + other => { + let state = session_state(operation, other)?; + return Err(AlienError::new(ErrorData::SandboxCommandFailed { + failure: "sessionTerminated".to_string(), + reason: format!( + "session '{session_id}' reached {state:?} and will not run again" + ), + })); + } + } + + if std::time::Instant::now() >= deadline { + // The last refusal, because "not running after 120s" sends a reader looking for a + // slow data plane when the answer is that every resume was rejected. + return Err(AlienError::new(ErrorData::SandboxCommandFailed { + failure: "sessionNotReady".to_string(), + reason: match refusal { + Some(code) => format!( + "session '{session_id}' was still not running after {}s; the last \ + resume was refused with {code}", + SESSION_READY_TIMEOUT.as_secs() + ), + None => format!( + "session '{session_id}' was still not running after {}s", + SESSION_READY_TIMEOUT.as_secs() + ), + }, + })); + } + tokio::time::sleep(SESSION_READY_INTERVAL).await; + } + } + + /// Whether a record carries a policy this client can hold it to. + /// + /// A running session always reports its effective policy, so an absent one there is a + /// mismatch. Off that state the data plane's behaviour is unverified, and reading absence as + /// a mismatch would refuse every idle-suspended session; the read taken after the wake is + /// authoritative either way. + fn judgeable(sandbox: &alien_azure_clients::azure::sandbox_data_plane::Sandbox) -> bool { + match sandbox.state.as_deref() { + Some("Running") => true, + Some("Stopping" | "Stopped" | "Suspended" | "Idle") => sandbox.egress_policy.is_some(), + // The two ends of the lifecycle and anything unread: one has no policy yet, the other + // has dropped it, and a state this client cannot name is refused before it gets here. + _ => false, + } + } + + fn judge_if_judgeable( + &self, + sandbox: &alien_azure_clients::azure::sandbox_data_plane::Sandbox, + ) -> Result<()> { + if Self::judgeable(sandbox) { + self.policy_must_hold(sandbox)?; + } + Ok(()) + } + + /// Re-suspends a session this call woke, keeping the reason it is being refused. + /// + /// Only a session this call woke: another revision of the same stack shares the sandbox + /// group, and stopping one that was already up ends a command that revision is mid-way + /// through. A stop that fails is named rather than logged — a sandbox this call put back on + /// the network under a policy the declaration does not allow is not "nothing happened". + async fn put_back( + &self, + session_id: &str, + resumed_here: bool, + reason: AlienError, + ) -> AlienError { + if !resumed_here { + return reason; + } + let Err(failed) = self.client.stop_sandbox(&self.sandbox_group, session_id).await else { + return reason; + }; + // A session that is already gone is the state this was trying to reach, and reporting it + // as left awake sends an operator looking for a sandbox that does not exist. + if is_not_found(&failed) { + return reason; + } + + warn!(session = %session_id, error = %failed, "could not re-suspend a session this call woke"); + reason.context(ErrorData::SandboxCommandFailed { + failure: "sandboxLeftAwake".to_string(), + reason: format!( + "session '{session_id}' was woken by this call, could not be handed back, and \ + could not be put to sleep again" + ), + }) + } + + /// Deletes a sandbox the caller will never receive, keeping the reason it is being discarded. + /// + /// The delete's own failure must not replace that reason — it is the finding that matters — + /// but it must not vanish either: the session id is in the error, and a failed delete leaves + /// a sandbox only that id can find. + async fn discard( + &self, + session_id: &str, + reason: AlienError, + ) -> AlienError { + let Err(error) = self.accept_delete(session_id).await else { + return reason; + }; + + warn!( + session = %session_id, + %error, + "could not delete a sandbox that was never handed to its caller" + ); + // Names the leak rather than the reason for it: a timeout and a policy mismatch both + // reach here, and reporting either as the other sends the reader somewhere false. The + // original reason stays on the chain. The clause is fixed text, because the delete's own + // error is the cloud client's and this variant is externally visible. + reason.context(ErrorData::SandboxCommandFailed { + failure: "sandboxLeftBehind".to_string(), + reason: format!( + "session '{session_id}' was not handed to its caller and could not be deleted, \ + so it is still running" + ), + }) + } + + /// Runs one shell string under the client-side guard, which is the deadline plus the grace + /// the in-session `timeout` needs to report back. See `run_command` for why the deadline is + /// enforced inside the session. + /// + /// Reached only by a session that could not run `timeout`, so it is the one path where + /// untrusted code is known to be overrunning: the session is ended and the call returns once + /// that is confirmed, because `deadlineExceeded` has to mean the command stopped rather than + /// that a stop was asked for. async fn execute_within( &self, session_id: &str, @@ -288,7 +903,7 @@ impl AzureSandbox { ) .await { - Ok(inner) => inner.map_err(|error| Self::failed("sandbox.runCommand", error)), + Ok(inner) => inner.map_err(|error| Self::failed(RUN_COMMAND, error)), Err(_) => { self.terminate(session_id).await?; Err(AlienError::new(ErrorData::SandboxCommandFailed { @@ -333,9 +948,24 @@ const TERMINATE_POLL_INTERVAL: std::time::Duration = std::time::Duration::from_s /// The data plane takes one shell string, so the command is passed to `sh` as arguments rather /// than pasted into the program text: `"$@"` cannot re-parse what it holds, so an argument /// carrying a space or an operator stays one argument. -fn bounded_shell(command: &[String], deadline: std::time::Duration) -> String { +fn bounded_shell( + command: &[String], + env: &BTreeMap, + deadline: std::time::Duration, +) -> String { let escape = |value: &str| value.replace('\'', "'\\''"); - let arguments = command + + // Through `env`, so the variables reach the caller's command and not the wrapper that bounds + // it: an assignment in front of the wrapper would put a caller-chosen `PATH` on the shell + // that resolves `setsid`, `sleep` and `kill`, and the deadline is only as real as those. + let mut argv = Vec::with_capacity(command.len() + env.len() + 1); + if !env.is_empty() { + argv.push("env".to_string()); + argv.extend(env.iter().map(|(name, value)| format!("{name}={value}"))); + } + argv.extend(command.iter().cloned()); + + let arguments = argv .iter() .map(|argument| format!(" '{}'", escape(argument))) .collect::(); @@ -345,6 +975,288 @@ fn bounded_shell(command: &[String], deadline: std::time::Duration) -> String { ) } +/// Refuses an environment a session must not carry. +/// +/// The wrapper that holds a command to its deadline runs inside the session and inherits its +/// environment, so a name that changes how a shell resolves, splits, or loads hands the command a +/// deadline it can forge. `PATH` chooses which `od` draws the nonce; `IFS` changes how the wrapper +/// reads its own pids; every `LD_*` runs attacker code inside `od` itself; `SHELLOPTS` turns on +/// tracing in a `sh` that is really bash. Refused as families where they are one, because a list +/// of names is a list of the ones somebody remembered — and `DeadlineReport::read` finds its +/// announcement by shape for the same reason, so a name missed here is noise rather than failure. +/// The same names per command are safe — those travel through `env` and reach only the command. +fn checked_session_env(operation: &str, env: &BTreeMap) -> Result<()> { + for name in env.keys() { + checked_env_name(operation, name)?; + if matches!(name.as_str(), "PATH" | "IFS" | "SHELLOPTS" | "BASHOPTS") + || name.starts_with("LD_") + { + return Err(AlienError::new(ErrorData::InvalidInput { + operation_context: operation.to_string(), + details: format!( + "'{name}' cannot be set for the whole session, because the wrapper that holds \ + a command to its deadline inherits it; declare it on the command instead" + ), + field_name: Some("env".to_string()), + })); + } + } + Ok(()) +} + +/// Refuses a variable name `env` would not take as one. +/// +/// Kept even though the whole `NAME=value` pair is one quoted argument: a name outside this set +/// either fails the exec or silently becomes something else, and the other backends bound it the +/// same way. +fn checked_env_name(operation: &str, name: &str) -> Result<()> { + let usable = !name.is_empty() + && !name.starts_with(|c: char| c.is_ascii_digit()) + && name + .chars() + .all(|c| c.is_ascii_alphanumeric() || c == '_'); + if usable { + return Ok(()); + } + Err(AlienError::new(ErrorData::InvalidInput { + operation_context: operation.to_string(), + details: format!( + "environment variable name '{name}' is not a shell name: letters, digits and \ + underscores only, and not starting with a digit" + ), + field_name: Some("env".to_string()), + })) +} + +/// Refuses a caller's path before it reaches the data plane, and returns what to send. +/// +/// This refuses traversal syntax; it establishes no root. Whether the data plane bounds a path is +/// undocumented and unmeasured, so no rule here can promise confinement — what it promises is +/// that a path cannot name a parent. A leading slash is trimmed rather than refused because it +/// means "under the session's own root" on every other backend, and refusing it would make the +/// one shape portable code writes the one shape this backend rejects. +fn checked_path(operation: &str, path: &str) -> Result { + let refused = |details: &str| { + Err(AlienError::new(ErrorData::InvalidInput { + operation_context: operation.to_string(), + details: format!("path '{path}' {details}"), + field_name: Some("path".to_string()), + })) + }; + + // Checked before anything is trimmed, which would make "a/b/" and the file "a/b" the same + // request. + if path.ends_with('/') { + return refused("must not end in '/'"); + } + // A leading slash means "under the session's own root" on every other backend, so it means + // that here too: the alternative is that the one path shape portable code writes is the one + // shape the newest `files` backend refuses. + let relative = path.trim_start_matches('/'); + if relative.is_empty() { + return refused("is empty"); + } + if relative.contains('\0') { + return refused("contains a null byte"); + } + if relative.split('/').any(|part| part == ".." || part.is_empty()) { + return refused("must not traverse"); + } + + Ok(relative.to_string()) +} + +/// The policy a declared mode is created with. +/// +/// `Full` inspection is what makes a `Deny` default mean no outbound access: under `Partial`, +/// `Legacy` and `None`, non-HTTP traffic is allowed through whatever the default action says, so +/// the sandbox would carry a `deny` label and a live network. `allow` sends no policy at all — +/// the data plane's default is already open, and `Full` there would block the non-HTTP traffic +/// `allow` promises. +fn egress_policy(egress: &SandboxEgress) -> Option { + let bounded = |host_rules| { + Some(EgressPolicy { + default_action: DENY.to_string(), + unmodelled: Default::default(), + rules: Vec::new(), + host_rules, + traffic_inspection: Some(FULL_INSPECTION.to_string()), + }) + }; + + match egress { + SandboxEgress::Allow => None, + // Written as a rule as well as a default, because Microsoft documents `Partial` + // inspection as evaluating only traffic a rule matches and never states that `Full` + // differs. A policy holding no rule at all is the one shape where "deny" could mean + // nothing, and this is one rule to be out of it. + SandboxEgress::Deny => bounded(vec![EgressHostRule { + pattern: EVERY_HOST.to_string(), + action: DENY.to_string(), + }]), + SandboxEgress::AllowDomains { domains } => bounded( + domains + .iter() + .map(|domain| EgressHostRule { + pattern: domain.clone(), + action: ALLOW.to_string(), + }) + .collect(), + ), + } +} + +/// Whether the sandbox is running the policy it was created with. +/// +/// Not equality — the data plane may return the policy normalised, and failing every create over a +/// reordered list would push whoever hits it into removing the check. Not a subset either, which +/// is the same mistake pointing outward: a permission the sandbox holds and the declaration never +/// asked for is exactly what this is looking for. So both directions, on the two things that can +/// permit traffic: nothing may allow a host the declaration did not name, in either list. +/// +/// A group-scoped policy can add an entry nobody sent here, which is why the rules list is read at +/// all — it is never written. +fn policy_holds(asked: &EgressPolicy, effective: Option<&EgressPolicy>) -> bool { + let Some(effective) = effective else { + return false; + }; + + let asked_for = |host: &str| { + asked + .host_rules + .iter() + .any(|rule| rule.action.eq_ignore_ascii_case(ALLOW) && rule.pattern == host) + }; + + effective.default_action.eq_ignore_ascii_case(&asked.default_action) + && effective + .traffic_inspection + .as_deref() + .is_some_and(|mode| mode.eq_ignore_ascii_case(FULL_INSPECTION)) + && asked.host_rules.iter().all(|asked_rule| { + effective.host_rules.iter().any(|rule| { + rule.pattern == asked_rule.pattern + && rule.action.eq_ignore_ascii_case(&asked_rule.action) + }) + }) + // A whitelist, not a blacklist: an action this client does not recognise is one it cannot + // weigh, and `Transform` and `Rewrite` reach a host by rewriting the request rather than + // by naming it. Only a plain deny, or an allow the declaration asked for, passes. + && effective.host_rules.iter().all(|rule| { + rule.action.eq_ignore_ascii_case(DENY) + || (rule.action.eq_ignore_ascii_case(ALLOW) && asked_for(&rule.pattern)) + }) + // This client never writes `rules`, so anything here came from elsewhere — a group-scoped + // policy, or an API that moved — and only an outright deny is readable as harmless. + && effective.rules.iter().all(|rule| { + rule.action + .as_ref() + .is_some_and(|action| action.action_type.eq_ignore_ascii_case(DENY)) + }) + // A field this client cannot read is a permission it cannot rule out. + && effective.unmodelled.is_empty() +} + +/// The effective policy, short enough to read in an error. +fn describe(effective: Option<&EgressPolicy>) -> String { + match effective { + None => "no policy at all".to_string(), + Some(policy) if !policy.unmodelled.is_empty() => format!( + "a policy carrying {}, which this client cannot weigh", + policy + .unmodelled + .keys() + .map(String::as_str) + .collect::>() + .join(", ") + ), + Some(policy) => format!( + "default action '{}' under {} inspection, {} host rules and {} match rules", + policy.default_action, + policy.traffic_inspection.as_deref().unwrap_or("unstated"), + policy.host_rules.len(), + policy.rules.len() + ), + } +} + +/// The data plane's own lifecycle vocabulary, in ours. +/// +/// An unrecognised state is an error rather than a default, because every default here is a lie +/// a caller acts on: `Running` sends commands to a sandbox that cannot answer them, and anything +/// else hides one that can. +fn session_state(operation: &str, state: Option<&str>) -> Result { + match state { + Some("Running") => Ok(SandboxSessionState::Running), + Some("Creating" | "Resuming") => Ok(SandboxSessionState::Starting), + // `Idle` is where the SDK contradicts itself: it declares `Idle` as a reason a sandbox + // stopped, and then waits for a *state* of `Idle` after a stop. Accepted as suspended + // either way — the alternative is that the state auto-suspend produces is the one state + // this refuses to read. + // A sandbox on its way down is not one to send work to, and the four states the trait + // publishes have no word for "stopping" — so it reads as unusable. Anything that has to + // tell "going down" from "already down" reads the raw state instead. + Some("Stopping" | "Stopped" | "Suspended" | "Idle") => Ok(SandboxSessionState::Suspended), + Some("Deleting" | "Failed") => Ok(SandboxSessionState::Terminated), + other => Err(AlienError::new(ErrorData::UnexpectedResponseFormat { + provider: "azure".to_string(), + binding_name: operation.to_string(), + field: "state".to_string(), + response_json: other + .map_or_else(|| "absent".to_string(), |state| format!("\"{state}\"")), + })), + } +} + +/// The data plane's own words for the two actions and the one inspection mode that blocks +/// non-HTTP traffic. +const DENY: &str = "Deny"; +const ALLOW: &str = "Allow"; +const FULL_INSPECTION: &str = "Full"; + +/// The host pattern that matches everything, so `deny` is a rule rather than only a default. +const EVERY_HOST: &str = "*"; + +/// Longest session id this client will put in a data-plane URL. +/// +/// A bound on what a caller hands back rather than on what Azure mints: the ids seen in practice +/// are far shorter, and the point is that an id reaching the URL is one this client chose to send. +const MAX_SESSION_ID: usize = 63; + +/// The two operations a repeat could perform twice. +/// +/// `create` is a PUT to a collection with a server-minted id, so a second attempt makes a second +/// sandbox — and with no enumeration verb, the first one has no id-holder and nothing to reap it. +const RUN_COMMAND: &str = "sandbox.runCommand"; +const CREATE: &str = "sandbox.create"; +const GET_OR_CREATE: &str = "sandbox.getOrCreate"; + +/// How long a session has to become able to take work, and how often that is checked. +const SESSION_READY_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(120); +const SESSION_READY_INTERVAL: std::time::Duration = std::time::Duration::from_secs(2); + +/// Whether the data plane understood the request and rejected it. +/// +/// Reads the classified variant the client attaches rather than the status on its source: the +/// wrapper is what survives `create_azure_http_error_with_context`, and it already carries the +/// 4xx-versus-everything-else split this needs. +fn is_refusal(error: &AlienError) -> bool { + // `RemoteResourceConflict` is deliberately absent: the client also uses it for the 400s Azure + // marks as propagation delays, and calling those refusals would tell a caller never to retry + // the one failure Azure says to retry. + matches!( + &error.error, + Some( + ClientErrorData::RemoteResourceNotFound { .. } + | ClientErrorData::RemoteAccessDenied { .. } + | ClientErrorData::InvalidInput { .. } + ) + ) || matches!( + &error.error, + Some(ClientErrorData::HttpResponseError { http_status, .. }) if (400..500).contains(http_status) + ) +} + /// Whether an Azure data-plane failure means the session is already gone. /// /// Reads the status the client carries rather than the rendered message: `AlienError`'s `Display` @@ -369,6 +1281,9 @@ mod tests { use super::*; use alien_azure_clients::azure::sandbox_data_plane::ExecResult; use alien_azure_clients::azure::sandbox_data_plane::MockSandboxDataPlaneApi; + use alien_azure_clients::azure::sandbox_data_plane::{ + EgressRule, EgressRuleAction, EgressRuleMatch, + }; use futures::StreamExt; fn http_error(status: u16, body: &str) -> AlienError { @@ -381,16 +1296,62 @@ mod tests { }) } + /// Answers the readiness read every create makes, with the policy the sandbox came up under. + fn settles_running(client: &mut MockSandboxDataPlaneApi, egress: Option) { + client + .expect_get_sandbox() + .returning(move |_, id| Ok(running(id, egress.clone()))); + } + fn sandbox_with(client: MockSandboxDataPlaneApi) -> AzureSandbox { AzureSandbox::new( std::sync::Arc::new(client), "grp".to_string(), "ubuntu".to_string(), + SandboxEgress::Allow, + None, "1000m".to_string(), "2048Mi".to_string(), ) } + /// The declared image has to reach the create call, not a default chosen here. + /// + /// Asserted on the argument the client receives, because the failure this pins is silent: + /// a sandbox started from the wrong image returns a healthy session and only diverges once + /// the caller's code is missing from it. + #[tokio::test] + async fn the_declared_image_reaches_the_create_call() { + let mut client = MockSandboxDataPlaneApi::new(); + client + .expect_create_sandbox() + .withf(|_, request| request.disk_image == "my-toolchain") + .times(1) + .returning(|_, _| { + Ok(alien_azure_clients::azure::sandbox_data_plane::Sandbox { + id: "s1".to_string(), + egress_policy: None, + state: Some("Running".to_string()), + }) + }); + settles_running(&mut client, None); + + let sandbox = AzureSandbox::new( + std::sync::Arc::new(client), + "grp".to_string(), + "my-toolchain".to_string(), + SandboxEgress::Allow, + None, + "1000m".to_string(), + "2048Mi".to_string(), + ); + + sandbox + .create(CreateSessionRequest::default()) + .await + .expect("create succeeds"); + } + /// Azure accepts a delete and completes it later, so returning on the accepted call would /// report that untrusted code had stopped while it was still running. Time is paused, so the /// poll runs to its bound instantly. @@ -401,7 +1362,8 @@ mod tests { client.expect_get_sandbox().returning(|_, id| { Ok(alien_azure_clients::azure::sandbox_data_plane::Sandbox { id: id.to_string(), - status: Some("Running".to_string()), + egress_policy: None, + state: Some("Running".to_string()), }) }); @@ -431,15 +1393,15 @@ mod tests { } /// The discriminating case. A throttle whose body mentions 404 — a trace id, an inner code, a - /// path — used to read as "the session is gone", which starts a second sandbox while the - /// first keeps running and reports a live session as terminated. + /// path — must not read as "the session is gone": that starts a second sandbox while the + /// first keeps running, reporting a live session as terminated. #[test] fn only_the_status_decides_whether_a_session_is_gone() { assert!(is_not_found(&http_error(404, "SandboxNotFound"))); // The shape the client actually produces: a 404 is returned as // `http_error.context(RemoteResourceNotFound)`, so the outer variant is the classified - // one. Matching only `HttpResponseError` made every real 404 read as a live session. + // one. Matching only `HttpResponseError` would read every real 404 as a live session. assert!( is_not_found(&AlienError::new(ClientErrorData::RemoteResourceNotFound { resource_type: "Sandbox".to_string(), @@ -500,29 +1462,72 @@ mod tests { #[async_trait] impl SandboxDataPlaneApi for ScriptedExec { - async fn create_sandbox( + async fn stop_sandbox( &self, _group: &str, - _disk: &str, - _cpu: &str, - _memory: &str, - ) -> alien_client_core::Result - { - unreachable!("the command paths never create") + _sandbox_id: &str, + ) -> alien_client_core::Result<()> { + unreachable!("the command paths never suspend") } - async fn get_sandbox( + async fn resume_sandbox( &self, _group: &str, - sandbox_id: &str, - ) -> alien_client_core::Result - { - if self.deleted.load(std::sync::atomic::Ordering::SeqCst) { - return Err(http_error(404, "SandboxNotFound")); - } + _sandbox_id: &str, + ) -> alien_client_core::Result<()> { + unreachable!("the command paths never resume") + } + + async fn read_file( + &self, + _group: &str, + _sandbox_id: &str, + _path: &str, + ) -> alien_client_core::Result> { + unreachable!("the command paths never read files") + } + + async fn write_file( + &self, + _group: &str, + _sandbox_id: &str, + _path: &str, + _contents: Vec, + ) -> alien_client_core::Result<()> { + unreachable!("the command paths never write files") + } + + async fn mkdir( + &self, + _group: &str, + _sandbox_id: &str, + _path: &str, + ) -> alien_client_core::Result<()> { + unreachable!("the command paths never create directories") + } + + async fn create_sandbox( + &self, + _group: &str, + _request: CreateSandbox, + ) -> alien_client_core::Result + { + unreachable!("the command paths never create") + } + + async fn get_sandbox( + &self, + _group: &str, + sandbox_id: &str, + ) -> alien_client_core::Result + { + if self.deleted.load(std::sync::atomic::Ordering::SeqCst) { + return Err(http_error(404, "SandboxNotFound")); + } Ok(alien_azure_clients::azure::sandbox_data_plane::Sandbox { id: sandbox_id.to_string(), - status: Some("Running".to_string()), + egress_policy: None, + state: Some("Running".to_string()), }) } @@ -566,7 +1571,10 @@ mod tests { const DEADLINE_PLACEHOLDER: &str = ""; /// The nonce a session would draw. Announced on the first line of stderr, and repeated by /// the killer, exactly as the wrapper does. - const SESSION_NONCE: &str = "a1b2c3d4"; + /// The width the wrapper draws — `od -N16` is 16 bytes, so 32 hex digits. Short of that is + /// not an announcement, and a fixture that used a short one pinned a weaker rule than the + /// session's. + const SESSION_NONCE: &str = "a1b2c3d4a1b2c3d4a1b2c3d4a1b2c3d4"; /// Wraps a scripted stderr the way a bounded session would return it. fn as_session_stderr(stderr: &str) -> String { @@ -581,6 +1589,8 @@ mod tests { client, "grp".to_string(), "ubuntu".to_string(), + SandboxEgress::Allow, + None, "1000m".to_string(), "2048Mi".to_string(), ) @@ -700,6 +1710,7 @@ mod tests { "&&".to_string(), "sleep 5".to_string(), ], + &BTreeMap::new(), std::time::Duration::from_millis(1500), ); assert!(wrapped.contains("sleep 1.500"), "{wrapped}"); @@ -708,4 +1719,2199 @@ mod tests { "{wrapped}" ); } + + /// A per-command variable reaches the command, and its value stays data. + /// + /// The exec endpoint takes no environment, so the assignment travels in the shell string — + /// which is exactly where an unquoted value would stop being a value. + #[test] + fn the_bounded_shell_carries_variables_as_data() { + let wrapped = bounded_shell( + &["printenv".to_string(), "TOKEN".to_string()], + &BTreeMap::from([("TOKEN".to_string(), "a'; rm -rf /".to_string())]), + std::time::Duration::from_millis(1500), + ); + + assert!( + wrapped.ends_with("' sh 'env' 'TOKEN=a'\\''; rm -rf /' 'printenv' 'TOKEN'"), + "the value has to survive as one argument to env: {wrapped}" + ); + } + + /// A caller's `PATH` reaches the command and not the wrapper that bounds it. + /// + /// The wrapper resolves `setsid`, `od`, `sleep` and `kill` through `PATH`. A caller able to + /// set it on the wrapper's own shell could hand it no-ops, and the deadline that keeps + /// untrusted code bounded would never fire. + #[test] + fn a_caller_cannot_repoint_the_wrappers_own_path() { + let wrapped = bounded_shell( + &["sleep".to_string(), "forever".to_string()], + &BTreeMap::from([("PATH".to_string(), "/tmp/attacker".to_string())]), + std::time::Duration::from_millis(1500), + ); + + let (wrapper, argv) = wrapped + .split_once("' sh ") + .expect("the wrapper's program ends where its arguments begin"); + assert!( + !wrapper.contains("PATH"), + "the wrapper has to resolve its own tools: {wrapper}" + ); + assert_eq!( + argv, "'env' 'PATH=/tmp/attacker' 'sleep' 'forever'", + "the variable belongs to the command, not to the shell that bounds it" + ); + } + + /// The wrapper the provider builds actually runs, with the variable set. + /// + /// The other tests here assert the shape of the string. This one runs it, because the shape + /// can be exactly what was intended and still not execute: `env` reads operands as + /// assignments until one is not, so a separator in the wrong place becomes the program name. + /// + /// A stand-in `setsid` is supplied because macOS ships none, and it only `exec`s — it starts + /// no session. So this pins that the command runs and the variable arrives; it says nothing + /// about the kill, which needs a real `setsid` and a real process group. + #[test] + #[cfg(unix)] + fn the_wrapper_this_builds_runs_with_the_variable_set() { + use std::os::unix::fs::PermissionsExt; + + let bin = std::env::temp_dir().join(format!("alien-azure-shell-{}", std::process::id())); + std::fs::create_dir_all(&bin).expect("a directory for the stand-in"); + let setsid = bin.join("setsid"); + std::fs::write(&setsid, "#!/bin/sh\nexec \"$@\"\n").expect("the stand-in is written"); + std::fs::set_permissions(&setsid, std::fs::Permissions::from_mode(0o755)) + .expect("the stand-in is executable"); + let path = format!( + "{}:{}", + bin.display(), + std::env::var("PATH").unwrap_or_default() + ); + + // Addressed absolutely, so the command itself does not depend on the `PATH` under test. + let command = [ + "/bin/sh".to_string(), + "-c".to_string(), + "printf %s \"$TOKEN\"".to_string(), + ]; + + let run = |env: BTreeMap| { + let shell = bounded_shell(&command, &env, std::time::Duration::from_secs(5)); + std::process::Command::new("/bin/sh") + .arg("-c") + .arg(shell) + .env("PATH", &path) + .output() + .expect("a shell runs") + }; + + let plain = run(BTreeMap::from([("TOKEN".to_string(), "reached".to_string())])); + assert_eq!( + String::from_utf8_lossy(&plain.stdout), + "reached", + "the variable has to reach the command; stderr {:?}", + String::from_utf8_lossy(&plain.stderr) + ); + + // The wrapper resolves its own tools before the caller's environment applies, so a `PATH` + // that points nowhere reaches the command and leaves the deadline intact. + let repointed = run(BTreeMap::from([ + ("TOKEN".to_string(), "reached".to_string()), + ("PATH".to_string(), "/nonexistent".to_string()), + ])); + assert_eq!( + String::from_utf8_lossy(&repointed.stdout), + "reached", + "a caller's PATH must not break the wrapper; stderr {:?}", + String::from_utf8_lossy(&repointed.stderr) + ); + + std::fs::remove_dir_all(&bin).ok(); + } + + /// A session cannot set the variables the deadline wrapper reads. + /// + /// The wrapper runs inside the session and inherits its environment, so a session-level + /// `PATH` picks which `od` draws the deadline nonce and an `IFS` changes how the wrapper + /// reads its own pids — either lets the command claim a deadline nothing enforced. The same + /// names on a command are fine, because those reach only the command. + #[tokio::test] + async fn a_session_cannot_set_what_the_deadline_wrapper_reads() { + // `LD_AUDIT` is the one that proves the family has to go as a family: it runs attacker + // code inside `od`, which is what draws the nonce the deadline report rests on. + for name in [ + "PATH", + "IFS", + "LD_PRELOAD", + "LD_LIBRARY_PATH", + "LD_AUDIT", + "LD_DEBUG", + "LD_BIND_NOW", + "SHELLOPTS", + "BASHOPTS", + ] { + let mut client = MockSandboxDataPlaneApi::new(); + client.expect_create_sandbox().never(); + + let error = sandbox_with(client) + .create(CreateSessionRequest { + session_id: None, + tenant_key: None, + env: BTreeMap::from([(name.to_string(), "/tmp/attacker".to_string())]), + }) + .await + .expect_err("a session that could forge its own deadline must not be created"); + + assert_eq!(error.code, "INVALID_INPUT", "{name}: {error}"); + } + + // The ordinary case still reaches the create body. + let mut client = MockSandboxDataPlaneApi::new(); + client + .expect_create_sandbox() + .times(1) + .withf(|_, request| request.environment.get("TOKEN").map(String::as_str) == Some("t")) + .returning(|_, _| Ok(running("s1", None))); + client + .expect_get_sandbox() + .returning(|_, id| Ok(running(id, None))); + + sandbox_with(client) + .create(CreateSessionRequest { + session_id: None, + tenant_key: None, + env: BTreeMap::from([("TOKEN".to_string(), "t".to_string())]), + }) + .await + .expect("an ordinary variable is still carried"); + } + + /// A command with no program is refused rather than run. + /// + /// `env` with assignments and no operand prints the environment it was given and exits 0, so + /// an empty command would hand the caller the session's own variables and read as a command + /// that succeeded. + #[tokio::test] + async fn a_command_naming_no_program_is_refused() { + let mut client = MockSandboxDataPlaneApi::new(); + client + .expect_get_sandbox() + .returning(|_, id| Ok(running(id, None))); + client.expect_execute_shell_command().never(); + + let mut request = command(5); + request.command = Vec::new(); + request.env = BTreeMap::from([("SECRET".to_string(), "hunter2".to_string())]); + + let error = match sandbox_with(client).run_command("s1", request).await { + Ok(_) => panic!("a command with no program must not run"), + Err(error) => error, + }; + + assert_eq!(error.code, "INVALID_INPUT", "{error}"); + } + + /// A program whose own name carries `=` is refused when the call also declares variables. + /// + /// `env` reads operands as assignments until one is not, so such a name would be taken as a + /// variable and the next argument run in its place — the command silently replaced rather + /// than refused. + #[tokio::test] + async fn a_program_name_env_would_swallow_is_refused() { + let mut client = MockSandboxDataPlaneApi::new(); + client + .expect_get_sandbox() + .returning(|_, id| Ok(running(id, None))); + client.expect_execute_shell_command().never(); + + let mut request = command(5); + request.command = vec!["FOO=bar".to_string(), "printenv".to_string()]; + request.env = BTreeMap::from([("TOKEN".to_string(), "t".to_string())]); + + let error = match sandbox_with(client).run_command("s1", request).await { + Ok(_) => panic!("a command env would swallow must not be sent"), + Err(error) => error, + }; + + assert_eq!(error.code, "INVALID_INPUT", "{error}"); + } + + /// A name the shell would read as a second command never reaches the shell string. + #[test] + fn a_variable_name_that_is_not_a_name_is_refused() { + for name in ["", "A B", "A;rm", "1A", "A=B", "A-B"] { + let error = checked_env_name("sandbox.runCommand", name) + .expect_err("a name the shell would not read as a name must be refused"); + assert_eq!(error.code, "INVALID_INPUT", "name '{name}': {error}"); + } + for name in ["A", "_a", "TOKEN_1"] { + checked_env_name("sandbox.runCommand", name) + .unwrap_or_else(|error| panic!("name '{name}' is a shell name: {error}")); + } + } + + /// A path that could leave the caller's own directory is refused before anything is sent. + /// + /// Asserted on the client never being called, not on the error: the data plane's own path + /// handling is undocumented, so a request that leaves this process is already outside what + /// this backend can promise. + #[tokio::test] + async fn a_path_that_could_escape_never_reaches_the_data_plane() { + let mut client = MockSandboxDataPlaneApi::new(); + client.expect_read_file().never(); + client.expect_write_file().never(); + client.expect_mkdir().never(); + let sandbox = sandbox_with(client); + + for path in [ + "../etc/shadow", + "", + "/", + "work/", + "a//b", + "a/../../b", + "/../escape", + ] { + let error = sandbox + .read_file("s1", path) + .await + .expect_err(&format!("'{path}' must be refused")); + assert_eq!(error.code, "INVALID_INPUT", "{path}: {error}"); + + sandbox + .write_files("s1", BTreeMap::from([(path.to_string(), vec![1u8])])) + .await + .expect_err(&format!("'{path}' must be refused on write too")); + sandbox + .mkdir("s1", path) + .await + .expect_err(&format!("'{path}' must be refused on mkdir too")); + } + + // The same shapes, accepted: a rule that refuses everything would pass the loop above. + // An absolute path is one of them — it means "under the session's own root" on every + // other backend, and arrives at the data plane with the leading slash trimmed. + let mut client = MockSandboxDataPlaneApi::new(); + client + .expect_read_file() + .withf(|_, _, path| !path.starts_with('/')) + .times(3) + .returning(|_, _, _| Ok(Vec::new())); + let sandbox = sandbox_with(client); + for path in ["app.py", "src/app.py", "/work/app.py"] { + sandbox + .read_file("s1", path) + .await + .unwrap_or_else(|error| panic!("'{path}' is a normal path: {error}")); + } + } + + /// The group, the session and the path each reach the call they belong to, and the bytes come + /// back unchanged. + #[tokio::test] + async fn a_read_carries_the_session_and_path_to_the_data_plane() { + let mut client = MockSandboxDataPlaneApi::new(); + client + .expect_read_file() + .withf(|group, session_id, path| { + group == "grp" && session_id == "s1" && path == "src/app.py" + }) + .times(1) + .returning(|_, _, _| Ok(b"print(1)\n".to_vec())); + + let contents = sandbox_with(client) + .read_file("s1", "src/app.py") + .await + .expect("the read should succeed"); + + assert_eq!(contents, b"print(1)\n"); + } + + /// One bad path fails the batch before anything is written. + /// + /// Partial application is the contract for a data plane that refuses midway — not for a path + /// this process could have refused before the first request. + #[tokio::test] + async fn a_batch_with_an_unusable_path_writes_nothing() { + let mut client = MockSandboxDataPlaneApi::new(); + client.expect_write_file().never(); + + let error = sandbox_with(client) + .write_files( + "s1", + BTreeMap::from([ + ("a.txt".to_string(), vec![1u8]), + ("b/../../escape".to_string(), vec![2u8]), + ]), + ) + .await + .expect_err("a path that could escape must fail the batch"); + + assert_eq!(error.code, "INVALID_INPUT", "{error}"); + } + + /// Writing stops at the first failure rather than pressing on, which is what makes a partial + /// write observable to the caller instead of a success with a hole in it. + #[tokio::test] + async fn a_failed_write_stops_the_ones_behind_it() { + let mut client = MockSandboxDataPlaneApi::new(); + settles_running(&mut client, None); + client + .expect_write_file() + .times(1) + .returning(|_, _, path, _| { + assert_eq!(path, "a.txt", "the first path in order is the one attempted"); + Err(AlienError::new(ClientErrorData::RemoteAccessDenied { + resource_type: "sandbox".to_string(), + resource_name: "s1".to_string(), + })) + }); + + let error = sandbox_with(client) + .write_files( + "s1", + BTreeMap::from([ + ("a.txt".to_string(), vec![1u8]), + ("b.txt".to_string(), vec![2u8]), + ]), + ) + .await + .expect_err("a refused write must fail the call"); + + assert_eq!(error.code, "SANDBOX_COMMAND_FAILED", "{error}"); + } + + /// The two buckets a caller retries on, and the one it must not. + /// + /// A refusal repeated is refused again, and a file operation whose outcome is unknown is safe + /// to repeat — but a command may already be running, and a retry there runs it twice. + #[tokio::test] + async fn only_the_operations_that_are_safe_to_repeat_are_marked_retryable() { + let mut client = MockSandboxDataPlaneApi::new(); + client.expect_read_file().times(1).returning(|_, _, _| { + Err(AlienError::new(ClientErrorData::RemoteResourceNotFound { + resource_type: "file".to_string(), + resource_name: "missing.txt".to_string(), + })) + }); + let refused = sandbox_with(client) + .read_file("s1", "missing.txt") + .await + .expect_err("a missing file is an error"); + assert_eq!(refused.code, "SANDBOX_COMMAND_FAILED", "{refused}"); + assert!(!refused.retryable, "repeating a refusal repeats it: {refused}"); + + let mut client = MockSandboxDataPlaneApi::new(); + client.expect_read_file().times(1).returning(|_, _, _| { + Err(AlienError::new(ClientErrorData::RemoteServiceUnavailable { + message: "the data plane is unavailable".to_string(), + })) + }); + let unreachable = sandbox_with(client) + .read_file("s1", "app.py") + .await + .expect_err("an unavailable data plane is an error"); + assert_eq!(unreachable.code, "SANDBOX_UNREACHABLE", "{unreachable}"); + assert!(unreachable.retryable, "a read is safe to repeat: {unreachable}"); + + let mut client = MockSandboxDataPlaneApi::new(); + settles_running(&mut client, None); + client + .expect_execute_shell_command() + .times(1) + .returning(|_, _, _, _| { + Err(AlienError::new(ClientErrorData::RemoteServiceUnavailable { + message: "the data plane is unavailable".to_string(), + })) + }); + let command = match sandbox_with(client).run_command("s1", command(5)).await { + Ok(_) => panic!("an unavailable data plane is an error"), + Err(error) => error, + }; + assert_eq!(command.code, "SANDBOX_COMMAND_FAILED", "{command}"); + assert!( + !command.retryable, + "the command may already be running, so a retry would run it twice: {command}" + ); + } + + /// A session's state is the data plane's, not a default. + /// + /// The four states that are not `Running` each mean a command sent now does not run, so + /// reporting `Running` for any of them tells a caller to use a session that cannot answer. + #[tokio::test] + async fn a_session_reports_the_state_the_data_plane_gave_it() { + for (reported, expected) in [ + ("Running", SandboxSessionState::Running), + ("Creating", SandboxSessionState::Starting), + ("Resuming", SandboxSessionState::Starting), + // On its way down, and the four states the trait publishes have no word for it. + ("Stopping", SandboxSessionState::Suspended), + ("Stopped", SandboxSessionState::Suspended), + ("Suspended", SandboxSessionState::Suspended), + ("Idle", SandboxSessionState::Suspended), + ("Deleting", SandboxSessionState::Terminated), + ] { + let mut client = MockSandboxDataPlaneApi::new(); + let state = reported.to_string(); + client.expect_get_sandbox().times(1).returning(move |_, _| { + Ok(alien_azure_clients::azure::sandbox_data_plane::Sandbox { + id: "s1".to_string(), + egress_policy: None, + state: Some(state.clone()), + }) + }); + + let session = sandbox_with(client) + .get("s1") + .await + .unwrap_or_else(|error| panic!("{reported}: {error}")) + .unwrap_or_else(|| panic!("{reported}: the session exists")); + + assert_eq!(session.state, expected, "state {reported}"); + } + } + + /// A state this client does not know is a preview API that moved, and guessing which of the + /// four it maps to is how a caller ends up talking to a sandbox that is going away. + #[tokio::test] + async fn an_unknown_state_is_an_error_rather_than_a_guess() { + for reported in [Some("Hibernated"), None] { + let mut client = MockSandboxDataPlaneApi::new(); + let state = reported.map(str::to_string); + client.expect_get_sandbox().times(1).returning(move |_, _| { + Ok(alien_azure_clients::azure::sandbox_data_plane::Sandbox { + id: "s1".to_string(), + egress_policy: None, + state: state.clone(), + }) + }); + + let error = sandbox_with(client) + .get("s1") + .await + .expect_err("an unreadable state must not become a session"); + + assert_eq!(error.code, "UNEXPECTED_RESPONSE_FORMAT", "{error}"); + } + } + + /// The variables the caller declared have to reach the create body: a sandbox inherits none + /// of them, and the data plane accepts a create that omits them. + #[tokio::test] + async fn the_declared_variables_reach_the_create_call() { + let mut client = MockSandboxDataPlaneApi::new(); + client + .expect_create_sandbox() + .withf(|_, request| request.environment.get("TOKEN").map(String::as_str) == Some("t")) + .times(1) + .returning(|_, _| { + Ok(alien_azure_clients::azure::sandbox_data_plane::Sandbox { + id: "s1".to_string(), + egress_policy: None, + state: Some("Creating".to_string()), + }) + }); + + // Created as `Creating`, so the create waits: the trait owes the caller a session that + // can already take work, and returning one that cannot pushes the readiness poll into + // every caller. + client + .expect_get_sandbox() + .times(1) + .returning(|_, _| Ok(running("s1", None))); + + let session = sandbox_with(client) + .create(CreateSessionRequest { + session_id: None, + tenant_key: None, + env: BTreeMap::from([("TOKEN".to_string(), "t".to_string())]), + }) + .await + .expect("the create should succeed"); + + assert_eq!(session.state, SandboxSessionState::Running); + } + + fn running( + id: &str, + egress: Option, + ) -> alien_azure_clients::azure::sandbox_data_plane::Sandbox { + alien_azure_clients::azure::sandbox_data_plane::Sandbox { + id: id.to_string(), + egress_policy: egress, + state: Some("Running".to_string()), + } + } + + fn sandbox_denying(client: MockSandboxDataPlaneApi, egress: SandboxEgress) -> AzureSandbox { + AzureSandbox::new( + std::sync::Arc::new(client), + "grp".to_string(), + "ubuntu".to_string(), + egress, + None, + "1000m".to_string(), + "2048Mi".to_string(), + ) + } + + /// What each declared mode is created with. + /// + /// The inspection mode is the half that is easy to leave out and impossible to notice: under + /// anything but `Full` a `Deny` default still lets every non-HTTP protocol out, so a sandbox + /// would carry the label and none of the containment. `allow` must send no policy, because + /// `Full` would block the traffic `allow` promises. + #[tokio::test] + async fn each_declared_mode_is_created_with_the_policy_that_realises_it() { + let mut client = MockSandboxDataPlaneApi::new(); + client + .expect_create_sandbox() + .times(1) + .returning(|_, request| { + let policy = request.egress.expect("deny must send a policy"); + assert_eq!(policy.default_action, "Deny"); + assert_eq!( + policy.traffic_inspection.as_deref(), + Some("Full"), + "only Full inspection blocks non-HTTP traffic" + ); + assert_eq!( + policy.host_rules, + vec![EgressHostRule { + pattern: "*".to_string(), + action: "Deny".to_string(), + }], + "deny is written as a rule too, so it does not rest on how the proxy treats a \ + policy with no rules" + ); + Ok(running("s1", Some(policy))) + }); + settles_running( + &mut client, + Some(EgressPolicy { + default_action: "Deny".to_string(), + host_rules: vec![EgressHostRule { + pattern: "*".to_string(), + action: "Deny".to_string(), + }], + rules: Vec::new(), + unmodelled: Default::default(), + traffic_inspection: Some("Full".to_string()), + }), + ); + sandbox_denying(client, SandboxEgress::Deny) + .create(CreateSessionRequest::default()) + .await + .expect("deny should create"); + + let mut client = MockSandboxDataPlaneApi::new(); + client + .expect_create_sandbox() + .times(1) + .returning(|_, request| { + let policy = request.egress.expect("allowDomains must send a policy"); + assert_eq!(policy.default_action, "Deny", "anything unlisted is denied"); + assert_eq!(policy.traffic_inspection.as_deref(), Some("Full")); + assert_eq!( + policy.host_rules, + vec![EgressHostRule { + pattern: "api.example.com".to_string(), + action: "Allow".to_string(), + }] + ); + Ok(running("s1", Some(policy))) + }); + settles_running( + &mut client, + Some(EgressPolicy { + default_action: "Deny".to_string(), + host_rules: vec![EgressHostRule { + pattern: "api.example.com".to_string(), + action: "Allow".to_string(), + }], + rules: Vec::new(), + unmodelled: Default::default(), + traffic_inspection: Some("Full".to_string()), + }), + ); + sandbox_denying( + client, + SandboxEgress::AllowDomains { + domains: vec!["api.example.com".to_string()], + }, + ) + .create(CreateSessionRequest::default()) + .await + .expect("allowDomains should create"); + + let mut client = MockSandboxDataPlaneApi::new(); + client + .expect_create_sandbox() + .times(1) + .returning(|_, request| { + assert!( + request.egress.is_none(), + "an open sandbox sends no policy: Full inspection would block non-HTTP traffic" + ); + Ok(running("s1", None)) + }); + settles_running(&mut client, None); + sandbox_denying(client, SandboxEgress::Allow) + .create(CreateSessionRequest::default()) + .await + .expect("allow should create"); + } + + /// A restriction that did not take effect is the failure this whole path exists to prevent, + /// so the sandbox is deleted rather than returned with a `deny` label and a live network. + #[tokio::test] + async fn a_sandbox_that_came_up_without_its_policy_is_deleted_rather_than_handed_back() { + for came_up_with in [ + None, + // The default action alone: every non-HTTP protocol still leaves. + Some(EgressPolicy { + default_action: "Deny".to_string(), + unmodelled: Default::default(), + rules: Vec::new(), + host_rules: Vec::new(), + traffic_inspection: Some("Partial".to_string()), + }), + // Inspected, and open. + Some(EgressPolicy { + default_action: "Allow".to_string(), + unmodelled: Default::default(), + rules: Vec::new(), + host_rules: Vec::new(), + traffic_inspection: Some("Full".to_string()), + }), + ] { + let mut client = MockSandboxDataPlaneApi::new(); + let effective = came_up_with.clone(); + client + .expect_create_sandbox() + .times(1) + .returning(move |_, _| Ok(running("s1", effective.clone()))); + settles_running(&mut client, came_up_with.clone()); + client + .expect_delete_sandbox() + .withf(|_, id| id == "s1") + .times(1) + .returning(|_, _| Ok(())); + + let error = sandbox_denying(client, SandboxEgress::Deny) + .create(CreateSessionRequest::default()) + .await + .expect_err("a sandbox without its policy must not be handed back"); + + assert_eq!(error.code, "SANDBOX_NOT_AS_DECLARED", "{error}"); + } + } + + /// A host the declaration named that the sandbox is not running is the same failure as a + /// missing policy: the caller believes traffic to it is allowed and it is not, or worse, the + /// list came back holding something else. + #[tokio::test] + async fn a_missing_host_rule_fails_the_create() { + let mut client = MockSandboxDataPlaneApi::new(); + let elsewhere = EgressPolicy { + default_action: "Deny".to_string(), + unmodelled: Default::default(), + rules: Vec::new(), + host_rules: vec![EgressHostRule { + pattern: "elsewhere.example.com".to_string(), + action: "Allow".to_string(), + }], + traffic_inspection: Some("Full".to_string()), + }; + let echoed = elsewhere.clone(); + client + .expect_create_sandbox() + .times(1) + .returning(move |_, _| Ok(running("s1", Some(echoed.clone())))); + settles_running(&mut client, Some(elsewhere)); + client.expect_delete_sandbox().times(1).returning(|_, _| Ok(())); + + let error = sandbox_denying( + client, + SandboxEgress::AllowDomains { + domains: vec!["api.example.com".to_string()], + }, + ) + .create(CreateSessionRequest::default()) + .await + .expect_err("a host the declaration named must be in the effective policy"); + + assert_eq!(error.code, "SANDBOX_NOT_AS_DECLARED", "{error}"); + } + + /// A session that is going away is not one to reconnect to. + /// + /// `get_or_create` hands back whatever `get` finds, and the id of a deleting sandbox will not + /// run again — so the caller would receive a handle whose every command lands on nothing. + #[tokio::test] + async fn a_terminated_session_is_replaced_rather_than_reconnected_to() { + let mut client = MockSandboxDataPlaneApi::new(); + client.expect_get_sandbox().times(1).returning(|_, id| { + Ok(running(id, None)).map( + |mut sandbox: alien_azure_clients::azure::sandbox_data_plane::Sandbox| { + sandbox.state = Some("Deleting".to_string()); + sandbox + }, + ) + }); + client + .expect_create_sandbox() + .times(1) + .returning(|_, request| Ok(running("fresh", request.egress))); + settles_running( + &mut client, + Some(EgressPolicy { + default_action: "Deny".to_string(), + host_rules: vec![EgressHostRule { + pattern: "*".to_string(), + action: "Deny".to_string(), + }], + rules: Vec::new(), + unmodelled: Default::default(), + traffic_inspection: Some("Full".to_string()), + }), + ); + + // Declared `deny`, because a terminated session carries no policy — judging it before + // reading the state reported a disappearing sandbox as an uncontained one. + let session = sandbox_denying(client, SandboxEgress::Deny) + .get_or_create(CreateSessionRequest { + session_id: Some("going-away".to_string()), + tenant_key: None, + env: BTreeMap::new(), + }) + .await + .expect("a new session should be created"); + + assert_eq!(session.session_id, "fresh"); + } + + /// A permission the declaration never asked for fails the create as surely as a missing one. + /// + /// The check looks outward as well as inward: an `Allow` the sandbox holds and the caller did + /// not name is the whole failure this path exists to catch, and a group-scoped policy is a + /// documented way for one to appear. + #[tokio::test] + async fn a_permission_nobody_asked_for_fails_the_create() { + let asked_for = || SandboxEgress::AllowDomains { + domains: vec!["api.example.com".to_string()], + }; + let declared = EgressHostRule { + pattern: "api.example.com".to_string(), + action: "Allow".to_string(), + }; + + for came_up_with in [ + // A second host, allowed. + EgressPolicy { + default_action: "Deny".to_string(), + unmodelled: Default::default(), + host_rules: vec![ + declared.clone(), + EgressHostRule { + pattern: "exfil.example.com".to_string(), + action: "Allow".to_string(), + }, + ], + rules: Vec::new(), + traffic_inspection: Some("Full".to_string()), + }, + // Everything, through the list this client never writes. + EgressPolicy { + default_action: "Deny".to_string(), + unmodelled: Default::default(), + host_rules: vec![declared.clone()], + rules: vec![EgressRule { + name: None, + r#match: Some(EgressRuleMatch { + host: "*".to_string(), + path: None, + methods: None, + }), + action: Some(EgressRuleAction { + action_type: "Allow".to_string(), + host: None, + path: None, + scheme: None, + headers: None, + }), + }], + traffic_inspection: Some("Full".to_string()), + }, + ] { + let mut client = MockSandboxDataPlaneApi::new(); + let effective = came_up_with.clone(); + client + .expect_create_sandbox() + .times(1) + .returning(move |_, _| Ok(running("s1", Some(effective.clone())))); + settles_running(&mut client, Some(came_up_with.clone())); + client.expect_delete_sandbox().times(1).returning(|_, _| Ok(())); + + let error = sandbox_denying(client, asked_for()) + .create(CreateSessionRequest::default()) + .await + .expect_err("a permission nobody asked for must fail the create"); + + assert_eq!(error.code, "SANDBOX_NOT_AS_DECLARED", "{error}"); + } + + // The same policy without the extra permission creates normally, so the rule above is + // refusing the addition rather than refusing everything. + let mut client = MockSandboxDataPlaneApi::new(); + client.expect_create_sandbox().times(1).returning(move |_, _| { + Ok(running( + "s1", + Some(EgressPolicy { + default_action: "Deny".to_string(), + unmodelled: Default::default(), + host_rules: vec![EgressHostRule { + pattern: "api.example.com".to_string(), + action: "Allow".to_string(), + }], + rules: Vec::new(), + traffic_inspection: Some("Full".to_string()), + }), + )) + }); + settles_running( + &mut client, + Some(EgressPolicy { + default_action: "Deny".to_string(), + unmodelled: Default::default(), + host_rules: vec![EgressHostRule { + pattern: "api.example.com".to_string(), + action: "Allow".to_string(), + }], + rules: Vec::new(), + traffic_inspection: Some("Full".to_string()), + }), + ); + sandbox_denying(client, asked_for()) + .create(CreateSessionRequest::default()) + .await + .expect("the policy that was asked for should create"); + } + + /// Suspend and resume are one call each, and each has to reach the verb it names. + /// + /// Returning on acceptance rather than on the state change is the same contract AWS follows, + /// so a caller that needs the session stopped polls `get` — the alternative is a call that + /// blocks for a resume Microsoft describes as sub-second and a stop that is not. + #[tokio::test] + async fn suspend_and_resume_reach_their_own_verbs() { + let mut client = MockSandboxDataPlaneApi::new(); + client + .expect_stop_sandbox() + .withf(|group, id| group == "grp" && id == "s1") + .times(1) + .returning(|_, _| Ok(())); + client.expect_resume_sandbox().never(); + sandbox_with(client) + .suspend("s1") + .await + .expect("suspend should be accepted"); + + // Found asleep, so the verb is actually sent — a mock that answers `Running` on the + // first read would let this pass with `resume_sandbox` never called at all. + let mut client = MockSandboxDataPlaneApi::new(); + let mut reads = 0; + client.expect_get_sandbox().returning(move |_, id| { + reads += 1; + let mut sandbox = running(id, None); + if reads < 3 { + sandbox.state = Some("Stopped".to_string()); + } + Ok(sandbox) + }); + client + .expect_resume_sandbox() + .withf(|group, id| group == "grp" && id == "s1") + .times(1) + .returning(|_, _| Ok(())); + client.expect_stop_sandbox().never(); + sandbox_with(client) + .resume("s1") + .await + .expect("resume should reach a running session"); + } + + /// A declared idle-suspend policy has to reach the create body. + /// + /// The data plane takes it at create and nowhere else, and accepts a body without it — so a + /// declaration that stops at the binding leaves the sandbox on whatever the service defaults + /// to, with nothing anywhere saying the number was ignored. + #[tokio::test] + async fn a_declared_idle_suspend_reaches_the_create_call() { + let mut client = MockSandboxDataPlaneApi::new(); + client + .expect_create_sandbox() + .withf(|_, request| request.idle_suspend_seconds == Some(900)) + .times(1) + .returning(|_, _| Ok(running("s1", None))); + settles_running(&mut client, None); + + AzureSandbox::new( + std::sync::Arc::new(client), + "grp".to_string(), + "ubuntu".to_string(), + SandboxEgress::Allow, + Some(900), + "1000m".to_string(), + "2048Mi".to_string(), + ) + .create(CreateSessionRequest::default()) + .await + .expect("the create should succeed"); + } + + /// Reconnect is the path a stale policy survives on. + /// + /// Azure has no session ceiling and an idle sandbox only suspends, so one created under an + /// older declaration outlives the change. Checking only at create hands the caller a session + /// whose containment is whatever it was built with, under the label it has now. + #[tokio::test] + async fn a_reconnect_to_a_session_built_under_another_policy_is_refused() { + let mut client = MockSandboxDataPlaneApi::new(); + client.expect_get_sandbox().times(1).returning(|_, id| { + // What an `allow` declaration built, before it was changed to `deny`. + Ok(running(id, None)) + }); + + let error = sandbox_denying(client, SandboxEgress::Deny) + .get("built-under-allow") + .await + .expect_err("a session without the declared policy must not be handed back"); + + assert_eq!(error.code, "SANDBOX_NOT_AS_DECLARED", "{error}"); + } + + /// A create whose response cannot be read owns a sandbox the caller has no id for. + /// + /// Azure allocates the id and has no enumeration verb, so an abandoned sandbox has no + /// id-holder and nothing to reap it — it runs until someone finds it by hand. + #[tokio::test] + async fn a_create_that_cannot_be_read_deletes_what_it_made() { + let mut client = MockSandboxDataPlaneApi::new(); + let unreadable = || { + Ok(alien_azure_clients::azure::sandbox_data_plane::Sandbox { + id: "orphan".to_string(), + egress_policy: None, + state: Some("Hibernated".to_string()), + }) + }; + client.expect_create_sandbox().times(1).returning(move |_, _| unreadable()); + client.expect_get_sandbox().returning(move |_, _| unreadable()); + client + .expect_delete_sandbox() + .withf(|_, id| id == "orphan") + .times(1) + .returning(|_, _| Ok(())); + + let error = sandbox_with(client) + .create(CreateSessionRequest::default()) + .await + .expect_err("an unreadable state must fail the create"); + + assert_eq!(error.code, "UNEXPECTED_RESPONSE_FORMAT", "{error}"); + } + + /// The three shapes a permitting policy can arrive in that a looser check would pass. + #[tokio::test] + async fn a_policy_this_client_cannot_read_whole_fails_the_create() { + let declared = || SandboxEgress::Deny; + let catch_all = EgressHostRule { + pattern: "*".to_string(), + action: "Deny".to_string(), + }; + + for came_up_with in [ + // A host rule carrying an action this client cannot weigh: `Transform` reaches a host + // by rewriting the request rather than by naming it. + EgressPolicy { + default_action: "Deny".to_string(), + host_rules: vec![ + catch_all.clone(), + EgressHostRule { + pattern: "api.example.com".to_string(), + action: "Transform".to_string(), + }, + ], + rules: Vec::new(), + unmodelled: Default::default(), + traffic_inspection: Some("Full".to_string()), + }, + // A field this client does not model at all. + EgressPolicy { + default_action: "Deny".to_string(), + host_rules: vec![catch_all.clone()], + rules: Vec::new(), + unmodelled: BTreeMap::from([( + "bypassList".to_string(), + serde_json::json!(["exfil.example.com"]), + )]), + traffic_inspection: Some("Full".to_string()), + }, + ] { + let mut client = MockSandboxDataPlaneApi::new(); + let effective = came_up_with.clone(); + client + .expect_create_sandbox() + .times(1) + .returning(move |_, _| Ok(running("s1", Some(effective.clone())))); + settles_running(&mut client, Some(came_up_with.clone())); + client.expect_delete_sandbox().times(1).returning(|_, _| Ok(())); + + let error = sandbox_denying(client, declared()) + .create(CreateSessionRequest::default()) + .await + .expect_err("a policy this client cannot read whole must fail the create"); + + assert_eq!(error.code, "SANDBOX_NOT_AS_DECLARED", "{error}"); + } + + // Case is the data plane's to choose: the same policy, normalised, still creates. + let mut client = MockSandboxDataPlaneApi::new(); + client.expect_create_sandbox().times(1).returning(|_, _| { + Ok(running( + "s1", + Some(EgressPolicy { + default_action: "deny".to_string(), + host_rules: vec![EgressHostRule { + pattern: "*".to_string(), + action: "deny".to_string(), + }], + rules: Vec::new(), + unmodelled: Default::default(), + traffic_inspection: Some("full".to_string()), + }), + )) + }); + settles_running( + &mut client, + Some(EgressPolicy { + default_action: "deny".to_string(), + host_rules: vec![EgressHostRule { + pattern: "*".to_string(), + action: "deny".to_string(), + }], + rules: Vec::new(), + unmodelled: Default::default(), + traffic_inspection: Some("full".to_string()), + }), + ); + sandbox_denying(client, declared()) + .create(CreateSessionRequest::default()) + .await + .expect("a normalised echo of the same policy is the same policy"); + } + + /// A session the declaration no longer matches is replaced, not a permanent error. + /// + /// `get_or_create` owes the caller a usable session, and a stale-policy sandbox is as + /// unusable as a terminated one. The old sandbox is left running: another revision of the + /// same stack may share this group, and the replacement is what this caller asked for. + #[tokio::test] + async fn a_stale_policy_session_is_replaced_rather_than_refused_forever() { + let mut client = MockSandboxDataPlaneApi::new(); + // The stale session is running under no policy at all; the replacement carries the one + // the declaration asks for. + client.expect_get_sandbox().returning(move |_, id| { + if id == "built-under-allow" { + return Ok(running(id, None)); + } + Ok(running( + id, + Some(EgressPolicy { + default_action: "Deny".to_string(), + host_rules: vec![EgressHostRule { + pattern: "*".to_string(), + action: "Deny".to_string(), + }], + rules: Vec::new(), + unmodelled: Default::default(), + traffic_inspection: Some("Full".to_string()), + }), + )) + }); + client.expect_delete_sandbox().never(); + client + .expect_create_sandbox() + .times(1) + .returning(|_, request| Ok(running("fresh", request.egress))); + + let session = sandbox_denying(client, SandboxEgress::Deny) + .get_or_create(CreateSessionRequest { + session_id: Some("built-under-allow".to_string()), + tenant_key: None, + env: BTreeMap::new(), + }) + .await + .expect("a stale session is replaced"); + + assert_eq!(session.session_id, "fresh"); + } + + /// A session id is one path segment, because it is interpolated into the data-plane URL and + /// `..` in a URL resolves — reaching a sandbox group this binding was never scoped to. + #[tokio::test] + async fn a_traversing_session_id_never_reaches_the_data_plane() { + let mut client = MockSandboxDataPlaneApi::new(); + client.expect_get_sandbox().never(); + client.expect_delete_sandbox().never(); + client.expect_execute_shell_command().never(); + let sandbox = sandbox_with(client); + + for id in ["../../other-group/sandboxes/theirs", "a/b", "", "has space"] { + assert_eq!( + sandbox + .get(id) + .await + .expect_err(&format!("'{id}' must be refused")) + .code, + "INVALID_INPUT" + ); + sandbox + .terminate(id) + .await + .expect_err(&format!("'{id}' must be refused on every verb")); + } + } + + /// A stale session cannot run code, which is the one verb where it matters most. + /// + /// An id outlives a declaration change and the SDK hands `runCommand` an arbitrary string, so + /// without this the containment check is one a caller can walk around by keeping an id. + #[tokio::test] + async fn a_stale_policy_session_cannot_run_a_command() { + let mut client = MockSandboxDataPlaneApi::new(); + client + .expect_get_sandbox() + .times(1) + .returning(|_, id| Ok(running(id, None))); + // Refused, not reaped: this call did not create the session and was not asked to replace + // it, and two revisions of a stack share a sandbox group. + client.expect_delete_sandbox().never(); + client.expect_execute_shell_command().never(); + + let error = match sandbox_denying(client, SandboxEgress::Deny) + .run_command("built-under-allow", command(5)) + .await + { + Ok(_) => panic!("a session without the declared policy must not run code"), + Err(error) => error, + }; + + assert_eq!(error.code, "SANDBOX_NOT_AS_DECLARED", "{error}"); + } + + /// A policy that changed while a session was suspended is caught on the way back. + /// + /// The effective policy can be set on the group, somewhere this binding never writes, so the + /// read that finds a stopped sandbox is not the read that decides whether it is contained — + /// the one taken after it comes up is. + #[tokio::test] + async fn a_policy_that_changed_during_suspension_is_caught_on_reconnect() { + let declared = EgressPolicy { + default_action: "Deny".to_string(), + host_rules: vec![EgressHostRule { + pattern: "*".to_string(), + action: "Deny".to_string(), + }], + rules: Vec::new(), + unmodelled: Default::default(), + traffic_inspection: Some("Full".to_string()), + }; + + let mut client = MockSandboxDataPlaneApi::new(); + let mut reads = 0; + let stopped = declared.clone(); + client.expect_get_sandbox().returning(move |_, id| { + // The replacement is compliant; only the session that was asleep woke up wider. + if id != "was-suspended" { + return Ok(running(id, Some(stopped.clone()))); + } + reads += 1; + Ok(match reads { + // Suspended and compliant for the reconnect's read and the wait's first poll, so + // the reconnect proceeds and the wait is what wakes it. + 1 | 2 => { + let mut sandbox = running(id, Some(stopped.clone())); + sandbox.state = Some("Stopped".to_string()); + sandbox + } + // Awake, and the group gained a host nobody here asked for. + _ => running( + id, + Some(EgressPolicy { + host_rules: vec![ + EgressHostRule { + pattern: "*".to_string(), + action: "Deny".to_string(), + }, + EgressHostRule { + pattern: "exfil.example.com".to_string(), + action: "Allow".to_string(), + }, + ], + ..stopped.clone() + }), + ), + }) + }); + // Woken here, so this call owes the put-back: it is returned to the state it was found + // in rather than destroyed, because another revision may hold the same id. + client.expect_resume_sandbox().returning(|_, _| Ok(())); + client + .expect_stop_sandbox() + .withf(|_, id| id == "was-suspended") + .times(1) + .returning(|_, _| Ok(())); + client.expect_delete_sandbox().never(); + client + .expect_create_sandbox() + .times(1) + .returning(|_, request| Ok(running("fresh", request.egress))); + + let session = sandbox_denying(client, SandboxEgress::Deny) + .get_or_create(CreateSessionRequest { + session_id: Some("was-suspended".to_string()), + tenant_key: None, + env: BTreeMap::new(), + }) + .await + .expect("a caller asking for a session gets a usable one"); + + // Answered the same way as a terminated id: the caller gets a fresh session. The one + // that woke up wider is put back to sleep, not deleted — the id may be another + // revision's. + assert_eq!(session.session_id, "fresh"); + } + + /// A sandbox left behind must not publish the cloud's own response text. + /// + /// `discard` wraps the reason so the leak is named, and the wrapper inherits visibility: the + /// error it wraps is the cloud client's, which carries the request and response of the call + /// that failed, and the flag `into_external` reads is the outermost one. + #[tokio::test] + async fn a_sandbox_left_behind_does_not_publish_the_response_body() { + const SECRET: &str = "tenant-only-detail"; + + let mut client = MockSandboxDataPlaneApi::new(); + client + .expect_create_sandbox() + .times(1) + .returning(|_, _| Ok(running("s1", None))); + // The readiness read and the delete both fail, which is one failure in practice: a + // missing data-plane role refuses every verb. + client + .expect_get_sandbox() + .returning(|_, _| Err(http_error(403, SECRET))); + client + .expect_delete_sandbox() + .returning(|_, _| Err(http_error(403, SECRET))); + + let error = sandbox_with(client) + .create(CreateSessionRequest::default()) + .await + .expect_err("a create that cannot be confirmed must fail"); + + assert_eq!(error.code, "SANDBOX_COMMAND_FAILED", "{error}"); + assert!( + error.internal, + "the wrapper must inherit the cloud error's visibility: {error}" + ); + } + + /// Waking a session puts what it was running back on the network, so it is gated like + /// `run_command`: a caller holding an id from an older declaration must not be able to + /// resume its way around the check. + #[tokio::test] + async fn a_stale_policy_session_cannot_be_resumed() { + let mut client = MockSandboxDataPlaneApi::new(); + // Found asleep, so this call is what wakes it — and therefore what must put it back. + let mut reads = 0; + client.expect_get_sandbox().returning(move |_, id| { + reads += 1; + let mut sandbox = running(id, None); + // Asleep for the resume's own read and the wait's first poll, so the wait is what + // wakes it — and therefore what owes the put-back. + if reads <= 2 { + sandbox.state = Some("Stopped".to_string()); + } + Ok(sandbox) + }); + client.expect_resume_sandbox().returning(|_, _| Ok(())); + // Refused, not reaped: the caller asked to wake a session, not to lose it. Put back, + // because this call is what woke it. + client.expect_delete_sandbox().never(); + client + .expect_stop_sandbox() + .times(1) + .returning(|_, _| Ok(())); + + let error = sandbox_denying(client, SandboxEgress::Deny) + .resume("built-under-allow") + .await + .expect_err("a session without the declared policy must not be woken"); + + assert_eq!(error.code, "SANDBOX_NOT_AS_DECLARED", "{error}"); + } + + /// A resume that finds the session already awake refuses without touching it. + /// + /// Two revisions of a stack share a sandbox group, so stopping a session this call did not + /// wake ends whatever command the other revision is running. Refusing is this call's to do; + /// suspending someone else's work is not. + #[tokio::test] + async fn a_session_this_call_did_not_wake_is_left_running() { + let mut client = MockSandboxDataPlaneApi::new(); + client + .expect_get_sandbox() + .returning(|_, id| Ok(running(id, None))); + client.expect_resume_sandbox().never(); + client.expect_stop_sandbox().never(); + client.expect_delete_sandbox().never(); + + let error = sandbox_denying(client, SandboxEgress::Deny) + .resume("someone-elses-session") + .await + .expect_err("a session without the declared policy must not be handed back"); + + assert_eq!(error.code, "SANDBOX_NOT_AS_DECLARED", "{error}"); + } + + /// A session that came up on its own is not this call's to suspend. + /// + /// A read taken before the wait sees `Creating` and calls that asleep, but nothing here woke + /// it — another revision created it a moment earlier. Stopping it on a policy mismatch ends + /// that revision's session; only refusing is this call's to do. + #[tokio::test] + async fn a_session_that_came_up_on_its_own_is_not_suspended() { + let mut client = MockSandboxDataPlaneApi::new(); + let mut reads = 0; + client.expect_get_sandbox().returning(move |_, id| { + reads += 1; + let mut sandbox = running(id, None); + if reads <= 2 { + sandbox.state = Some("Creating".to_string()); + } + Ok(sandbox) + }); + client.expect_resume_sandbox().never(); + client.expect_stop_sandbox().never(); + client.expect_delete_sandbox().never(); + + let error = sandbox_denying(client, SandboxEgress::Deny) + .resume("created-by-another-revision") + .await + .expect_err("a session without the declared policy must not be handed back"); + + assert_eq!(error.code, "SANDBOX_NOT_AS_DECLARED", "{error}"); + } + + /// A suspended session that reports no policy reads as suspended, not as a mismatch. + /// + /// Whether the data plane reports `egressPolicy` off `Running` is unverified; judging it + /// here would turn every idle-suspended session into a containment failure. + #[tokio::test] + async fn a_suspended_session_reporting_no_policy_is_not_a_mismatch() { + let mut client = MockSandboxDataPlaneApi::new(); + client.expect_get_sandbox().times(1).returning(|_, id| { + let mut sandbox = running(id, None); + sandbox.state = Some("Stopped".to_string()); + Ok(sandbox) + }); + + let session = sandbox_denying(client, SandboxEgress::Deny) + .get("asleep") + .await + .expect("a sleeping session must still be readable") + .expect("the session exists"); + + assert_eq!(session.state, SandboxSessionState::Suspended); + } + + /// A sleeping session whose own record is plainly wrong is refused before anything wakes it. + /// + /// Waking it to reach the same verdict puts its workload back on the network for the length of + /// a boot, which is the window this check exists to close. + #[tokio::test] + async fn a_sleeping_session_with_a_wrong_policy_is_never_woken() { + let mut client = MockSandboxDataPlaneApi::new(); + client.expect_get_sandbox().times(1).returning(|_, id| { + let mut sandbox = running( + id, + Some(EgressPolicy { + default_action: "Allow".to_string(), + host_rules: Vec::new(), + rules: Vec::new(), + unmodelled: Default::default(), + traffic_inspection: Some("Full".to_string()), + }), + ); + sandbox.state = Some("Stopped".to_string()); + Ok(sandbox) + }); + client.expect_resume_sandbox().never(); + client.expect_stop_sandbox().never(); + client.expect_delete_sandbox().never(); + + let error = sandbox_denying(client, SandboxEgress::Deny) + .resume("built-under-allow") + .await + .expect_err("a stored policy that already fails must not be woken"); + + assert_eq!(error.code, "SANDBOX_NOT_AS_DECLARED", "{error}"); + } + + /// A wait that woke a session and then failed still puts it back. + /// + /// The wait can fail after issuing the resume, and a session left awake by a call that + /// returned an error is exactly the one nothing else will come back for. + #[tokio::test] + async fn a_session_woken_by_a_wait_that_then_failed_is_put_back() { + let mut client = MockSandboxDataPlaneApi::new(); + let mut reads = 0; + client.expect_get_sandbox().returning(move |_, id| { + reads += 1; + let mut sandbox = running(id, None); + // Asleep for the resume's read and the wait's first poll, then unreadable. + sandbox.state = Some(if reads <= 2 { "Stopped" } else { "Hibernated" }.to_string()); + Ok(sandbox) + }); + client + .expect_resume_sandbox() + .times(1) + .returning(|_, _| Ok(())); + client + .expect_stop_sandbox() + .times(1) + .returning(|_, _| Ok(())); + + let error = sandbox_denying(client, SandboxEgress::Deny) + .resume("wakes-then-breaks") + .await + .expect_err("a wait that cannot finish must not report a resumed session"); + + assert_eq!(error.code, "UNEXPECTED_RESPONSE_FORMAT", "{error}"); + } + + /// A reconnect that woke a session and then could not use it puts back what it woke. + /// + /// The refusal travels either way; what must not survive it is a live sandbox this call put + /// on the network and then walked away from. Returned to sleep rather than deleted, because + /// the id may be another revision's. + #[tokio::test] + async fn a_session_woken_by_a_failed_reconnect_is_put_back() { + let mut client = MockSandboxDataPlaneApi::new(); + let mut reads = 0; + client.expect_get_sandbox().returning(move |_, id| { + reads += 1; + let mut sandbox = running(id, None); + sandbox.state = Some(if reads <= 2 { "Stopped" } else { "Hibernated" }.to_string()); + Ok(sandbox) + }); + client + .expect_resume_sandbox() + .times(1) + .returning(|_, _| Ok(())); + client + .expect_stop_sandbox() + .withf(|_, id| id == "woken-then-unreadable") + .times(1) + .returning(|_, _| Ok(())); + client.expect_delete_sandbox().never(); + + let error = sandbox_with(client) + .get_or_create(CreateSessionRequest { + session_id: Some("woken-then-unreadable".to_string()), + tenant_key: None, + env: BTreeMap::new(), + }) + .await + .expect_err("a state this client cannot read is not a session"); + + assert_eq!(error.code, "UNEXPECTED_RESPONSE_FORMAT", "{error}"); + } + + /// The variables a command declares reach the command. + /// + /// Every other backend honours `RunCommandRequest.env`; dropping it here would answer a + /// documented field with nothing, and the failure would surface inside the sandbox. + #[tokio::test] + async fn a_declared_variable_reaches_the_command() { + let mut client = MockSandboxDataPlaneApi::new(); + client + .expect_get_sandbox() + .returning(|_, id| Ok(running(id, None))); + client + .expect_execute_shell_command() + .times(1) + .withf(|_, _, shell, _| shell.ends_with("' sh 'env' 'TOKEN=t' 'sleep' 'forever'")) + .returning(|_, _, _, _| { + Ok(alien_azure_clients::azure::sandbox_data_plane::ExecResult { + exit_code: Some(0), + stdout: String::new(), + // The wrapper announces its nonce before starting the command. + stderr: "a1b2c3d4a1b2c3d4a1b2c3d4a1b2c3d4\n".to_string(), + }) + }); + + let mut request = command(5); + request.env = BTreeMap::from([("TOKEN".to_string(), "t".to_string())]); + + let frames: Vec> = sandbox_with(client) + .run_command("s1", request) + .await + .expect("a command declaring a variable must run") + .collect() + .await; + + assert!( + matches!(frames.last(), Some(Ok(CommandOutput::Exit { code, .. })) if *code == 0), + "the command has to reach its exit: {frames:?}" + ); + } + + /// A variable name that is not a name never reaches the shell string. + /// + /// The name sits left of the `=`, where quoting cannot reach it, so an unchecked one is a + /// second command running inside the sandbox rather than a variable in it. + #[tokio::test] + async fn a_command_carrying_an_unusable_variable_name_runs_nothing() { + let mut client = MockSandboxDataPlaneApi::new(); + client + .expect_get_sandbox() + .returning(|_, id| Ok(running(id, None))); + client.expect_execute_shell_command().never(); + + let mut request = command(5); + request.env = BTreeMap::from([("X; curl evil".to_string(), "1".to_string())]); + + let error = match sandbox_with(client).run_command("s1", request).await { + Ok(_) => panic!("a name the shell would run must not reach the shell"), + Err(error) => error, + }; + + assert_eq!(error.code, "INVALID_INPUT", "{error}"); + } + + /// A resume whose outcome is unknown is one this call owns. + /// + /// A 5xx or a dropped connection does not mean the POST failed to land: the session can wake + /// anyway. Treating that as "did not wake" leaves a sandbox this call put back on the network + /// under a policy the declaration forbids, with nothing coming back for it. + #[tokio::test] + async fn a_resume_that_may_have_landed_is_owned() { + let mut client = MockSandboxDataPlaneApi::new(); + let mut reads = 0; + client.expect_get_sandbox().returning(move |_, id| { + reads += 1; + let mut sandbox = running(id, None); + if reads <= 2 { + sandbox.state = Some("Stopped".to_string()); + } + Ok(sandbox) + }); + // The answer never arrived; the data plane may still have taken it. + client + .expect_resume_sandbox() + .returning(|_, _| Err(http_error(503, "GatewayTimeout"))); + client + .expect_stop_sandbox() + .times(1) + .returning(|_, _| Ok(())); + + let error = sandbox_denying(client, SandboxEgress::Deny) + .resume("woke-or-did-not") + .await + .expect_err("a session that came up uncontained is not a resumed session"); + + assert_eq!(error.code, "SANDBOX_NOT_AS_DECLARED", "{error}"); + } + + /// A resume the data plane refused is not one this call woke. + /// + /// The other side of the same rule: a 4xx is an answer, so the session stayed asleep and + /// whatever woke it afterwards was someone else. Stopping it would end their work. + #[tokio::test] + async fn a_refused_resume_leaves_someone_elses_session_alone() { + let mut client = MockSandboxDataPlaneApi::new(); + let mut reads = 0; + client.expect_get_sandbox().returning(move |_, id| { + reads += 1; + let mut sandbox = running(id, None); + if reads <= 2 { + sandbox.state = Some("Stopped".to_string()); + } + Ok(sandbox) + }); + // Refused, so this call did not wake it — another revision did, between the polls. + client + .expect_resume_sandbox() + .returning(|_, _| Err(http_error(409, "SandboxNotStopped"))); + client.expect_stop_sandbox().never(); + client.expect_delete_sandbox().never(); + + let error = sandbox_denying(client, SandboxEgress::Deny) + .resume("someone-elses-session") + .await + .expect_err("a session without the declared policy must not be handed back"); + + assert_eq!(error.code, "SANDBOX_NOT_AS_DECLARED", "{error}"); + } + + /// A session that vanished while it was being put back is not "left awake". + /// + /// The put-back exists to name a sandbox this call left running. One the data plane says is + /// gone has reached that state by another route, and reporting it sends an operator looking + /// for something that does not exist. + #[tokio::test] + async fn a_session_that_vanished_is_not_reported_as_left_awake() { + let mut client = MockSandboxDataPlaneApi::new(); + let mut reads = 0; + client.expect_get_sandbox().returning(move |_, id| { + reads += 1; + let mut sandbox = running(id, None); + if reads <= 2 { + sandbox.state = Some("Stopped".to_string()); + } + Ok(sandbox) + }); + client.expect_resume_sandbox().returning(|_, _| Ok(())); + client + .expect_stop_sandbox() + .times(1) + .returning(|_, _| Err(http_error(404, "SandboxNotFound"))); + + let error = sandbox_denying(client, SandboxEgress::Deny) + .resume("gone-by-then") + .await + .expect_err("the refusal still travels"); + + assert!( + !error.to_string().contains("sandboxLeftAwake"), + "a sandbox the data plane says is gone was not left awake: {error}" + ); + assert_eq!(error.code, "SANDBOX_NOT_AS_DECLARED", "{error}"); + } + + /// A session being deleted is still running, so it must not take new work. + /// + /// `get` skips the policy check for one — a sandbox on its way out carries no policy to + /// judge — so a gate that only asks "does it exist" would run untrusted code on a live + /// sandbox under whatever egress it was built with. Azure accepts a delete rather than + /// completing it, which is why `terminate` polls to a 404 instead of trusting the accept. + #[tokio::test] + async fn a_session_being_deleted_takes_no_new_work() { + for outcome in ["Deleting", "gone"] { + let mut client = MockSandboxDataPlaneApi::new(); + let deleting = outcome == "Deleting"; + client.expect_get_sandbox().returning(move |_, id| { + if deleting { + let mut sandbox = running(id, None); + sandbox.state = Some("Deleting".to_string()); + Ok(sandbox) + } else { + Err(http_error(404, "SandboxNotFound")) + } + }); + client.expect_execute_shell_command().never(); + client.expect_resume_sandbox().never(); + let sandbox = sandbox_denying(client, SandboxEgress::Deny); + + let ran = match sandbox.run_command("on-its-way-out", command(5)).await { + Ok(_) => panic!("{outcome}: a session that cannot take work must not run code"), + Err(error) => error, + }; + assert_eq!(ran.code, "SANDBOX_COMMAND_FAILED", "{outcome}: {ran}"); + + let woken = sandbox + .resume("on-its-way-out") + .await + .expect_err("a session that cannot take work must not be resumed"); + assert_eq!(woken.code, "SANDBOX_COMMAND_FAILED", "{outcome}: {woken}"); + } + } + + /// A create whose id this client will not send is reaped unless the id is why. + /// + /// An over-long or oddly-spelled id is still one path segment, so the sandbox can be deleted + /// once and must be — nothing else can find it. An id carrying a separator or an escape is + /// the one case where the delete itself would travel somewhere else. + #[tokio::test] + async fn an_unaddressable_minted_id_is_reaped_unless_the_id_is_the_hazard() { + let minted = |id: &'static str| { + let mut client = MockSandboxDataPlaneApi::new(); + client + .expect_create_sandbox() + .times(1) + .returning(move |_, _| Ok(running(id, None))); + client + }; + + // Safe to address once: reaped. + let mut client = minted("x".repeat(80).leak()); + client.expect_delete_sandbox().times(1).returning(|_, _| Ok(())); + let error = sandbox_with(client) + .create(CreateSessionRequest::default()) + .await + .expect_err("an id this client will not send must fail the create"); + assert_eq!(error.code, "UNEXPECTED_RESPONSE_FORMAT", "{error}"); + + // The id is the hazard: the delete would travel into another group, so it is not sent. + let mut client = minted("../../other-group/sandboxes/theirs"); + client.expect_delete_sandbox().never(); + let error = sandbox_with(client) + .create(CreateSessionRequest::default()) + .await + .expect_err("a traversing id must fail the create"); + assert_eq!(error.code, "UNEXPECTED_RESPONSE_FORMAT", "{error}"); + } + + /// A sandbox that is still coming up has no policy yet, and that is not a mismatch. + /// + /// `policy_holds` reads an absent policy as a failure, so judging a `Creating` session would + /// report a booting sandbox as an uncontained one — and `get_or_create` acts on that by + /// deleting it and creating another. + #[tokio::test] + async fn a_session_that_is_still_coming_up_is_not_a_policy_mismatch() { + let mut client = MockSandboxDataPlaneApi::new(); + client.expect_get_sandbox().times(1).returning(|_, id| { + let mut sandbox = running(id, None); + sandbox.state = Some("Creating".to_string()); + Ok(sandbox) + }); + client.expect_delete_sandbox().never(); + + let session = sandbox_denying(client, SandboxEgress::Deny) + .get("still-booting") + .await + .expect("a booting session is not a contained-ness failure") + .expect("the session exists"); + + assert_eq!(session.state, SandboxSessionState::Starting); + } + + /// Writing into a stale session is refused before the bytes land. + /// + /// `write_files` is the one file operation that moves the caller's own content in, so a + /// write-then-run against an id kept across a tightened declaration would put the payload + /// inside a sandbox with the egress the declaration just removed. + #[tokio::test] + async fn a_stale_policy_session_takes_no_written_files() { + let mut client = MockSandboxDataPlaneApi::new(); + client + .expect_get_sandbox() + .times(1) + .returning(|_, id| Ok(running(id, None))); + client.expect_delete_sandbox().never(); + client.expect_write_file().never(); + + let error = sandbox_denying(client, SandboxEgress::Deny) + .write_files( + "built-under-allow", + BTreeMap::from([("app.py".to_string(), vec![1u8])]), + ) + .await + .expect_err("a session without the declared policy must take no content"); + + assert_eq!(error.code, "SANDBOX_NOT_AS_DECLARED", "{error}"); + } + + /// A resume the data plane refuses once is retried, not abandoned for the whole wait. + /// + /// The first attempt is the one most likely to be refused — a resume racing a sandbox that is + /// still stopping answers 409 — so remembering only that an attempt was made would spend the + /// budget watching a session nothing is bringing up. + #[tokio::test] + async fn a_refused_resume_is_tried_again() { + let mut client = MockSandboxDataPlaneApi::new(); + let mut reads = 0; + client.expect_get_sandbox().returning(move |_, id| { + reads += 1; + let mut sandbox = running(id, None); + // Stopping, then stopped, then up — the shape a suspend-then-resume race produces. + sandbox.state = Some( + match reads { + 1 => "Stopping", + 2 | 3 => "Stopped", + _ => "Running", + } + .to_string(), + ); + Ok(sandbox) + }); + + let mut attempts = 0; + client.expect_resume_sandbox().times(2).returning(move |_, _| { + attempts += 1; + if attempts == 1 { + // The 409 a sandbox still stopping answers. + Err(http_error(409, "SandboxNotStopped")) + } else { + Ok(()) + } + }); + + sandbox_with(client) + .resume("racing-the-idle-policy") + .await + .expect("a refused first resume must not doom the wait"); + } + + /// A session that is not running takes no work and no content, and is not woken to take it. + /// + /// Waking one to write into it would undo the idle suspend the declaration asked for, and a + /// stopped sandbox's policy record is not the one the work would run under. + #[tokio::test] + async fn a_suspended_session_is_refused_rather_than_woken() { + let mut client = MockSandboxDataPlaneApi::new(); + client.expect_get_sandbox().returning(|_, id| { + let mut sandbox = running(id, None); + sandbox.state = Some("Stopped".to_string()); + Ok(sandbox) + }); + client.expect_resume_sandbox().never(); + client.expect_write_file().never(); + client.expect_execute_shell_command().never(); + let sandbox = sandbox_denying(client, SandboxEgress::Deny); + + let wrote = sandbox + .write_files( + "asleep", + BTreeMap::from([("app.py".to_string(), vec![1u8])]), + ) + .await + .expect_err("a suspended session takes no content"); + assert_eq!(wrote.code, "SANDBOX_COMMAND_FAILED", "{wrote}"); + + let ran = match sandbox.run_command("asleep", command(5)).await { + Ok(_) => panic!("a suspended session runs no code"), + Err(error) => error, + }; + assert_eq!(ran.code, "SANDBOX_COMMAND_FAILED", "{ran}"); + } + + /// A stopped session that no longer matches is refused before anything wakes it. + /// + /// The stopped record carries the policy it stopped under, so it is judgeable — and waking a + /// sandbox to find out would put its workload back on the network for the length of a boot + /// before this call could refuse it. + #[tokio::test] + async fn a_stopped_session_is_judged_before_it_is_woken() { + let mut client = MockSandboxDataPlaneApi::new(); + let declared = EgressPolicy { + default_action: "Deny".to_string(), + host_rules: vec![EgressHostRule { + pattern: "*".to_string(), + action: "Deny".to_string(), + }], + rules: Vec::new(), + unmodelled: Default::default(), + traffic_inspection: Some("Full".to_string()), + }; + client.expect_get_sandbox().returning(move |_, id| { + if id == "fresh" { + return Ok(running(id, Some(declared.clone()))); + } + // Asleep, and the record it stopped under is present and open. + let mut sandbox = running( + id, + Some(EgressPolicy { + default_action: "Allow".to_string(), + host_rules: Vec::new(), + rules: Vec::new(), + unmodelled: Default::default(), + traffic_inspection: Some("Full".to_string()), + }), + ); + sandbox.state = Some("Stopped".to_string()); + Ok(sandbox) + }); + client.expect_resume_sandbox().never(); + // Nothing woke it and nothing owns it here, so it is left exactly as found. + client.expect_delete_sandbox().never(); + client.expect_stop_sandbox().never(); + client + .expect_create_sandbox() + .times(1) + .returning(|_, request| Ok(running("fresh", request.egress))); + + let session = sandbox_denying(client, SandboxEgress::Deny) + .get_or_create(CreateSessionRequest { + session_id: Some("asleep-under-allow".to_string()), + tenant_key: None, + env: BTreeMap::new(), + }) + .await + .expect("a caller asking for a session gets a usable one"); + + assert_eq!(session.session_id, "fresh"); + } + + /// A session the data plane reports as `Failed` is replaced, not carried forever. + /// + /// It is a documented terminal state, and one this client did not know: an unmapped state + /// becomes an unexpected-response error, which nothing heals, so the id would be permanently + /// unusable through `get_or_create`. + #[tokio::test] + async fn a_failed_session_is_replaced() { + let mut client = MockSandboxDataPlaneApi::new(); + client.expect_get_sandbox().returning(|_, id| { + if id == "fresh" { + return Ok(running(id, None)); + } + let mut sandbox = running(id, None); + sandbox.state = Some("Failed".to_string()); + Ok(sandbox) + }); + // A failed sandbox is not going away on its own, so it is reaped rather than left beside + // its replacement. + client + .expect_delete_sandbox() + .withf(|_, id| id == "broken") + .times(1) + .returning(|_, _| Ok(())); + client + .expect_create_sandbox() + .times(1) + .returning(|_, _| Ok(running("fresh", None))); + + let session = sandbox_with(client) + .get_or_create(CreateSessionRequest { + session_id: Some("broken".to_string()), + tenant_key: None, + env: BTreeMap::new(), + }) + .await + .expect("a failed session is replaced rather than returned"); + + assert_eq!(session.session_id, "fresh"); + } + + /// `Failed` is a state the data plane reports and this client has to know. + /// + /// An unmapped state becomes an unexpected-response error, and nothing heals that — so the id + /// of a failed sandbox would be permanently unusable rather than replaced. + #[tokio::test] + async fn a_failed_session_reads_as_terminated() { + let mut client = MockSandboxDataPlaneApi::new(); + client.expect_get_sandbox().times(1).returning(|_, id| { + let mut sandbox = running(id, None); + sandbox.state = Some("Failed".to_string()); + Ok(sandbox) + }); + + let session = sandbox_denying(client, SandboxEgress::Deny) + .get("broken") + .await + .expect("a failed session is a state, not an unreadable response") + .expect("the session exists"); + + assert_eq!(session.state, SandboxSessionState::Terminated); + } + + /// A session that dies while it is being waited for is replaced, like one already dead. + /// + /// The same condition one read earlier heals as `sessionGone`; answering it differently + /// depending on which read observed it is the inconsistency this path exists to avoid. + #[tokio::test] + async fn a_session_that_dies_during_the_wait_is_replaced() { + let mut client = MockSandboxDataPlaneApi::new(); + let mut reads = 0; + client.expect_get_sandbox().returning(move |_, id| { + if id == "fresh" { + return Ok(running(id, None)); + } + reads += 1; + let mut sandbox = running(id, None); + // Asleep when it is found, being deleted by the time the wait looks. + sandbox.state = Some(if reads == 1 { "Stopped" } else { "Deleting" }.to_string()); + Ok(sandbox) + }); + client + .expect_create_sandbox() + .times(1) + .returning(|_, _| Ok(running("fresh", None))); + + let session = sandbox_with(client) + .get_or_create(CreateSessionRequest { + session_id: Some("dying".to_string()), + tenant_key: None, + env: BTreeMap::new(), + }) + .await + .expect("a session that died mid-wait is replaced"); + + assert_eq!(session.session_id, "fresh"); + } + + /// A sleeping session that still matches is reconnected, not replaced. + /// + /// The discriminating case for judging a stopped record: if the data plane does report the + /// policy for a suspended sandbox, a compliant one has to survive the reconnect — otherwise + /// every idle-suspended session would be silently churned on each attach. + #[tokio::test] + async fn a_sleeping_session_that_still_matches_is_kept() { + let declared = EgressPolicy { + default_action: "Deny".to_string(), + host_rules: vec![EgressHostRule { + pattern: "*".to_string(), + action: "Deny".to_string(), + }], + rules: Vec::new(), + unmodelled: Default::default(), + traffic_inspection: Some("Full".to_string()), + }; + + let mut client = MockSandboxDataPlaneApi::new(); + let mut reads = 0; + let carried = declared.clone(); + client.expect_get_sandbox().returning(move |_, id| { + reads += 1; + let mut sandbox = running(id, Some(carried.clone())); + // Asleep for the first two reads — the reconnect's own, and the wait's first poll — + // so the resume is actually issued. + if reads <= 2 { + sandbox.state = Some("Stopped".to_string()); + } + Ok(sandbox) + }); + client + .expect_resume_sandbox() + .times(1) + .returning(|_, _| Ok(())); + client.expect_delete_sandbox().never(); + client.expect_create_sandbox().never(); + + let session = sandbox_denying(client, SandboxEgress::Deny) + .get_or_create(CreateSessionRequest { + session_id: Some("asleep-and-fine".to_string()), + tenant_key: None, + env: BTreeMap::new(), + }) + .await + .expect("a compliant sleeping session is woken and returned"); + + assert_eq!(session.session_id, "asleep-and-fine"); + } + + /// A sleeping session with no policy on its record is woken before it is judged. + /// + /// Whether the data plane reports `egressPolicy` for a sandbox that is not running is + /// unverified. If it does not, judging the sleeping record would delete every compliant + /// idle-suspended session on every reconnect, so the absence is left for the post-wake read. + #[tokio::test] + async fn a_sleeping_session_with_no_policy_is_woken_before_it_is_judged() { + let declared = EgressPolicy { + default_action: "Deny".to_string(), + host_rules: vec![EgressHostRule { + pattern: "*".to_string(), + action: "Deny".to_string(), + }], + rules: Vec::new(), + unmodelled: Default::default(), + traffic_inspection: Some("Full".to_string()), + }; + + let mut client = MockSandboxDataPlaneApi::new(); + let mut reads = 0; + let carried = declared.clone(); + client.expect_get_sandbox().returning(move |_, id| { + reads += 1; + if reads <= 2 { + let mut asleep = running(id, None); + asleep.state = Some("Stopped".to_string()); + return Ok(asleep); + } + Ok(running(id, Some(carried.clone()))) + }); + client + .expect_resume_sandbox() + .times(1) + .returning(|_, _| Ok(())); + client.expect_delete_sandbox().never(); + client.expect_create_sandbox().never(); + + let session = sandbox_denying(client, SandboxEgress::Deny) + .get_or_create(CreateSessionRequest { + session_id: Some("asleep-without-a-record".to_string()), + tenant_key: None, + env: BTreeMap::new(), + }) + .await + .expect("an absent policy on a sleeping record is unknown, not a mismatch"); + + assert_eq!(session.session_id, "asleep-without-a-record"); + } + + /// A session woken to be judged, found uncontained, and left awake says so. + /// + /// The refusal alone would read as "nothing happened", when what happened is a sandbox this + /// call put back on the network under a policy the declaration does not allow. + #[tokio::test] + async fn a_session_that_cannot_be_put_back_is_reported_as_left_awake() { + let mut client = MockSandboxDataPlaneApi::new(); + let mut reads = 0; + client.expect_get_sandbox().returning(move |_, id| { + reads += 1; + let mut sandbox = running(id, None); + // Asleep for the resume's own read and the wait's first poll, so the wait is what + // wakes it — and therefore what owes the put-back. + if reads <= 2 { + sandbox.state = Some("Stopped".to_string()); + } + Ok(sandbox) + }); + client.expect_resume_sandbox().returning(|_, _| Ok(())); + client + .expect_stop_sandbox() + .times(1) + .returning(|_, _| Err(http_error(500, "SuspendFailed"))); + + let error = sandbox_denying(client, SandboxEgress::Deny) + .resume("built-under-allow") + .await + .expect_err("a session that woke up uncontained must not be reported as resumed"); + + assert!( + error.to_string().contains("sandboxLeftAwake"), + "a sandbox left awake has to be named, not folded into the refusal: {error}" + ); + } + + /// A state this client cannot read takes no work, and is not called suspended. + /// + /// Reporting it as suspended sends the caller to `resume`, which answers the same thing — + /// a loop that ends in a timeout instead of the unreadable state that caused it. + #[tokio::test] + async fn an_unreadable_state_takes_no_work_and_is_not_called_suspended() { + let mut client = MockSandboxDataPlaneApi::new(); + client.expect_get_sandbox().times(1).returning(|_, _| { + Ok(alien_azure_clients::azure::sandbox_data_plane::Sandbox { + id: "s1".to_string(), + egress_policy: None, + state: Some("Hibernated".to_string()), + }) + }); + client.expect_execute_shell_command().never(); + client.expect_resume_sandbox().never(); + + let error = match sandbox_with(client).run_command("s1", command(5)).await { + Ok(_) => panic!("an unreadable state must not take work"), + Err(error) => error, + }; + + assert_eq!(error.code, "UNEXPECTED_RESPONSE_FORMAT", "{error}"); + } } diff --git a/crates/alien-bindings/src/providers/sandbox/fixtures/gcp-sandbox-cli-help.txt b/crates/alien-bindings/src/providers/sandbox/fixtures/gcp-sandbox-cli-help.txt new file mode 100644 index 000000000..66838fd0e --- /dev/null +++ b/crates/alien-bindings/src/providers/sandbox/fixtures/gcp-sandbox-cli-help.txt @@ -0,0 +1,207 @@ +# Captured from a live Cloud Run service with sandboxLauncher enabled, 2026-08-21. +# +# The reference at docs.cloud.google.com/run/docs/reference/sandbox-cli lists six verbs; this +# build has eight (completion, help are undocumented). That page says to run `sandbox -h` for +# the complete list, and it is right to. +# +# Kept so that "the launcher has no X verb" in gcp.rs is a citation rather than an assertion. +# Re-capture by running `sandbox -h`, then `sandbox -h` for each verb, inside a Cloud +# Run container deployed with --sandbox-launcher. +# --------------------------------------------------------------------------------------------- + + +===== ENVIRONMENT ===== +launcher path: /usr/local/gcp/bin/sandbox +RESULT launcher_present=yes +nproc=5 mem=4010112kB + +===== sandbox -h ===== +Serverless sandboxing CLI, providing compartmentalized execution for commands. + +Usage: + sandbox [command] + +Available Commands: + completion Generate the autocompletion script for the specified shell + delete Delete a sandbox + do Execute the specified command in a sandbox + exec Execute a command in an existing sandbox session + fork Fork a running sandbox to a new one. + help Help about any command + run Start a new sandbox. + tar Export a tarfile of the writable overlay (rootfs-upper) of a running sandbox + +Flags: + -h, --help help for sandbox + +Use "sandbox [command] --help" for more information about a command. + +===== sandbox do -h ===== +The do command provides support for executing a command in a sandbox without having to think about sandbox lifecycle management. A new sandbox will be created and destroyed for each execution, optionally persisting the state of the filesystem to a persistence directory between executions. This command blocks until the command and sandbox lifecycle completes. + +Usage: + sandbox do [flags] [command-to-execute] + +Flags: + --allow-egress Allow egress for this sandbox + -e, --env string Environment variables to set in the sandbox + --export-tar string The tarball to export rootfs-upper to on exit + -h, --help help for do + --import-tar string The tarball to import rootfs-upper from + --mount string Mounts for the sandbox + -p, --publish string Ports to expose from the sandbox + --rootfs string Run the command using the root of the executing container as the root directory of the sandbox. By default, this mount is read-only (default "/") + --sandbox-name string The ID to use for the sandbox; if not specified, a random ID will be generated + --stderr Wire the stderr pipe of the sandbox command to the stderr of the process executing in the sandbox (default true) + --stdin Wire the stdin pipe of the sandbox command to the stdin of the process executing in the sandbox (default true) + --stdout Wire the stdout pipe of the sandbox command to the stdout of the process executing in the sandbox (default true) + --sync-tar string The tarball to use for keeping the filesystem in sync (import if exists, export on exit) + --template-var string Template variables to set in the sandbox (format: KEY=VALUE) + -w, --workdir string The working directory to execute the command in + --write Allow filesystems that have been mounted to be writable by this sandbox + +===== sandbox run -h ===== +The run command creates and starts a sandbox. If no command is specified, an empty sandbox will be started. The command blocks until the container has started. + +Usage: + sandbox run [command-to-execute] [flags] + +Flags: + --allow-egress Allow egress for this sandbox. + --detach Detach the sandbox from the console + -e, --env string Environment variables to set in the sandbox + -h, --help help for run + --import-tar string The tarball to import rootfs-upper from + --mount string Mounts for the sandbox + -p, --publish string Ports to expose from the sandbox + --rootfs string Run the command using the root of the executing container as the root directory of the sandbox. (default "/") + --stderr Wire the stderr pipe of the sandbox command to the stderr of the process executing in the sandbox (default true) + --stdin Wire the stdin pipe of the sandbox command to the stdin of the process executing in the sandbox (default true) + --stdout Wire the stdout pipe of the sandbox command to the stdout of the process executing in the sandbox (default true) + --template-var string Template variables to set in the sandbox (format: KEY=VALUE) + -w, --workdir string The working directory to execute the command in. + --write Allow filesystems that have been mounted to be writable by this sandbox + +===== sandbox exec -h ===== +The exec command allows you to execute a command in a running sandbox. The sandbox must be running already, or the command will fail. + +Usage: + sandbox exec [args...] [flags] + +Flags: + -e, --env string Environment variables to set in the sandbox + -h, --help help for exec + --stderr Wire the stderr pipe of the sandbox command to the stderr of the process executing in the sandbox (default true) + --stdin Wire the stdin pipe of the sandbox command to the stdin of the process executing in the sandbox (default true) + --stdout Wire the stdout pipe of the sandbox command to the stdout of the process executing in the sandbox (default true) + -w, --workdir string The working directory to execute the command in + +===== sandbox fork -h ===== +Fork creates a new sandbox using the state and command line of a running source sandbox. + +Usage: + sandbox fork [flags] + +Flags: + --allow-egress Allow egress for this sandbox + --detach Detach the new sandbox from the console + -h, --help help for fork + -p, --publish string Ports to expose from the sandbox + --stderr Wire the stderr pipe of the sandbox command to the stderr of the process executing in the sandbox (default true) + --stdin Wire the stdin pipe of the sandbox command to the stdin of the process executing in the sandbox (default true) + --stdout Wire the stdout pipe of the sandbox command to the stdout of the process executing in the sandbox (default true) + --tar string The tarball from the source sandbox state with which the target sandbox was started + +===== sandbox tar -h ===== +The tar command creates a tarball of the writable overlay (rootfs-upper) of a sandbox container, containing all changes made in the sandbox. The tarball will capture all files and directories that differ from the rootfs. + +Usage: + sandbox tar [flags] + +Flags: + --file string The file to write the tarball to + -h, --help help for tar + --stderr Wire the stderr pipe of the sandbox command to the stderr of the process executing in the sandbox (default true) + --stdin Wire the stdin pipe of the sandbox command to the stdin of the process executing in the sandbox (default true) + --stdout Wire the stdout pipe of the sandbox command to the stdout of the process executing in the sandbox (default true) + +===== sandbox delete -h ===== +The delete command removes a sandbox and cleans up its resources. In the case of a running sandbox, the sandbox can be deleted by adding --force. + +Usage: + sandbox delete [flags] + +Flags: + --force Force delete the sandbox, even if it is running + -h, --help help for delete + --stderr Wire the stderr pipe of the sandbox command to the stderr of the process executing in the sandbox (default true) + --stdin Wire the stdin pipe of the sandbox command to the stdin of the process executing in the sandbox (default true) + --stdout Wire the stdout pipe of the sandbox command to the stdout of the process executing in the sandbox (default true) + +===== verbs this backend reports as absent ===== + suspend: absent +RESULT verb_suspend=absent + resume: absent +RESULT verb_resume=absent + list: absent +RESULT verb_list=absent + ps: absent +RESULT verb_ps=absent + snapshot: absent +RESULT verb_snapshot=absent + checkpoint: absent +RESULT verb_checkpoint=absent + restore: absent +RESULT verb_restore=absent + +===== create argv: --id versus the documented positional id ===== +--- ours: run --id poc-ours-14 --detach --- +Error: unknown flag: --id + +RESULT ours_argv_rc=0 +--- documented: run poc-doc-14 --detach --- +Running in detached mode: stdin, stdout and stderr arguments are ignored. +RESULT doc_argv_rc=0 +--- can each id be reached by exec? --- + 'poc-ours-14': not reachable +RESULT reachable_poc-ours-=no + 'poc-doc-14': REACHABLE +RESULT reachable_poc-doc-=yes + '--id': not reachable +RESULT reachable_--id=no + +===== does run without --detach block? ===== + rc=124 after 20s (rc=124 means it blocked until the timeout) +RESULT detach_needed=yes +RESULT nodetach_elapsed=20 + +===== does --env work? ===== + run --env then exec: [hello] +RESULT env_on_run=works + exec --env: [world] +RESULT env_on_exec=works + does a sandbox inherit the container's env? (Google says no) + [] +RESULT env_inherited=no + +===== tar export / import round trip ===== +Serializing rootfs upper layer into a tar archive for container: poc-tar-14, sandbox: poc-tar-14 + tar produced 2560 bytes +RESULT tar_export=yes + restored marker: Error: sandbox poc-restore-14 is not running +RESULT tar_import=no + +===== does a sandbox see the instance's CPU and memory? ===== + host: cpu=5 mem=4010112kB + sandbox: 5 4010112 +RESULT host_cpu=5 +RESULT sandbox_cpu_mem=5 4010112 + +===== CLEANUP ===== + deleted poc-ours-14 + deleted poc-doc-14 + deleted poc-nodet-14 + deleted poc-env-14 + deleted poc-tar-14 +PROBE-COMPLETE +PROBE-DONE diff --git a/crates/alien-bindings/src/providers/sandbox/gcp.rs b/crates/alien-bindings/src/providers/sandbox/gcp.rs index 50c7166b2..4959feeb2 100644 --- a/crates/alien-bindings/src/providers/sandbox/gcp.rs +++ b/crates/alien-bindings/src/providers/sandbox/gcp.rs @@ -27,6 +27,9 @@ use alien_core::sandbox_process::{self, ProcessFrame, ProcessStream, FRAME_CHANN use alien_core::{Platform, SandboxCapabilities}; use alien_error::AlienError; +/// Longest session id the launcher is asked to take, which is also a container name. +const MAX_SESSION_ID: usize = 63; + /// How much of one command's output is kept before the terminal frame reports truncation. const OUTPUT_CAP: usize = 8 * 1024 * 1024; @@ -147,6 +150,33 @@ impl GcpSandbox { } /// Builds `sandbox exec -- `. + /// A session id the launcher cannot read as one of its own options. + /// + /// The id is positional and `--allow-egress` is a flag on the same verb, so an id shaped like + /// a flag is an application asking to widen the egress its binding decided — and the argv is + /// built here, where a shell is not involved and quoting would not help. + fn checked_session_id(operation: &str, session_id: &str) -> Result<()> { + let usable = !session_id.is_empty() + && session_id.len() <= MAX_SESSION_ID + && session_id + .chars() + .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_') + && session_id.starts_with(|c: char| c.is_ascii_alphanumeric()); + + if usable { + return Ok(()); + } + + Err(AlienError::new(ErrorData::InvalidInput { + operation_context: operation.to_string(), + details: format!( + "session id '{session_id}' must start with a letter or digit and hold only \ + letters, digits, '-' and '_', at most {MAX_SESSION_ID} characters" + ), + field_name: Some("sessionId".to_string()), + })) + } + fn exec_arguments(&self, session_id: &str, command: &[String]) -> Vec { let mut arguments = vec!["exec".to_string(), session_id.to_string(), "--".to_string()]; arguments.extend(command.iter().cloned()); @@ -168,22 +198,30 @@ impl Sandbox for GcpSandbox { /// Starts a sandbox with a caller-chosen id. /// + /// The launcher's real verb and flag list is captured in + /// `fixtures/gcp-sandbox-cli-help.txt`, so the "no X verb" refusals below cite it. + /// /// Egress comes from the binding rather than the request: the launcher decides it at create /// time and an application must not be able to widen its own. async fn create(&self, request: CreateSessionRequest) -> Result { - if !request.env.is_empty() { - return Err(self.failed( - "sandbox.create", - "the Cloud Run sandbox launcher takes no session environment; bake it into the \ - image or pass it in each command", - )); - } - let session_id = request .session_id .unwrap_or_else(|| uuid::Uuid::new_v4().simple().to_string()); - - let mut arguments = vec!["run".to_string(), "--id".to_string(), session_id.clone()]; + Self::checked_session_id("sandbox.create", &session_id)?; + + // The id is positional and `--detach` is what makes this return: without it the launcher + // stays attached and `control` waits out its deadline instead of handing back a session. + let mut arguments = vec![ + "run".to_string(), + session_id.clone(), + "--detach".to_string(), + ]; + // A sandbox inherits nothing from the container, so a variable the caller asked for only + // exists if it is passed here. + for (key, value) in &request.env { + arguments.push("--env".to_string()); + arguments.push(format!("{key}={value}")); + } if self.allow_egress { arguments.push("--allow-egress".to_string()); } @@ -225,6 +263,7 @@ impl Sandbox for GcpSandbox { session_id: &str, request: RunCommandRequest, ) -> Result>> { + Self::checked_session_id("sandbox.runCommand", session_id)?; if request.command.is_empty() { return Err(self.failed("sandbox.runCommand", "command is empty")); } @@ -236,23 +275,17 @@ impl Sandbox for GcpSandbox { )); } - // The launcher takes no environment, and dropping what a caller asked for is the silent - // no-op the capability contract forbids: a command reading a variable it was promised - // would see nothing and fail somewhere far from here. - if !request.env.is_empty() { - return Err(self.failed( - "sandbox.runCommand", - "the Cloud Run sandbox launcher takes no per-command environment; bake it into \ - the image or pass it in the command", - )); - } - let mut arguments = self.exec_arguments(session_id, &request.command); + // Prepended rather than appended: everything after `--` is the caller's command, so + // anything meant for the launcher has to land before it. if let Some(directory) = &request.working_directory { - // Prepended rather than appended: everything after `--` is the caller's command. arguments.insert(2, directory.clone()); arguments.insert(2, "--workdir".to_string()); } + for (key, value) in &request.env { + arguments.insert(2, format!("{key}={value}")); + arguments.insert(2, "--env".to_string()); + } let child = sandbox_process::spawn(&self.launcher_path, &arguments) .and_then(|mut command| command.spawn()) @@ -292,6 +325,7 @@ impl Sandbox for GcpSandbox { } async fn read_file(&self, session_id: &str, path: &str) -> Result> { + Self::checked_session_id("sandbox.readFile", session_id)?; let path = self.checked_path(path, "sandbox.readFile")?; let command = vec!["/bin/cat".to_string(), path]; self.control( @@ -307,6 +341,7 @@ impl Sandbox for GcpSandbox { /// The cost is `ARG_MAX`: a file larger than roughly a megabyte needs a different transport, /// and fails loudly here rather than being silently truncated. async fn write_files(&self, session_id: &str, files: BTreeMap>) -> Result<()> { + Self::checked_session_id("sandbox.writeFiles", session_id)?; for (path, contents) in files { let path = self.checked_path(&path, "sandbox.writeFiles")?; let encoded = BASE64.encode(&contents); @@ -334,6 +369,7 @@ impl Sandbox for GcpSandbox { } async fn mkdir(&self, session_id: &str, path: &str) -> Result<()> { + Self::checked_session_id("sandbox.mkdir", session_id)?; let path = self.checked_path(path, "sandbox.mkdir")?; let command = vec!["/bin/mkdir".to_string(), "-p".to_string(), path]; self.control("sandbox.mkdir", &self.exec_arguments(session_id, &command)) @@ -364,6 +400,7 @@ impl Sandbox for GcpSandbox { } async fn terminate(&self, session_id: &str) -> Result<()> { + Self::checked_session_id("sandbox.terminate", session_id)?; self.control( "sandbox.terminate", &["delete".to_string(), session_id.to_string()], @@ -399,17 +436,45 @@ impl From for CommandOutput { #[cfg(test)] mod tests { use super::*; + use futures::StreamExt; use alien_core::bindings::BindingValue; - /// A fake launcher: it records the argv it was given and answers like the real one. + /// A fake launcher that rejects argv the real one rejects. /// /// Testing against a script rather than a mock is deliberate. What this provider gets wrong /// is argument construction, and a mock of the launcher would be built from the same /// misunderstanding as the code. + /// + /// `body` runs only after the argv passes `STRICT_PRELUDE`'s checks. A fake that accepts + /// anything is worse than none: it produced green tests for a `create` that sent + /// `run --id `, which the real launcher answers with `unknown flag: --id`. fn launcher(body: &str) -> (tempfile::TempDir, GcpSandbox) { + launcher_with_prelude(STRICT_PRELUDE, body) + } + + /// Verbs and flags taken from a live `sandbox -h`, not from the reference page — the page + /// lists six verbs where the launcher has eight. + const STRICT_PRELUDE: &str = r#" +case "$1" in + run|exec|do|fork|tar|delete|completion|help) ;; + *) echo "Error: unknown command: $1" >&2; exit 1 ;; +esac +# The real launcher exits 0 on an unknown flag, which is how a broken create looked healthy. +# This one exits 2, so the same mistake fails a test instead of passing one. "$@" is left +# intact so the body sees exactly what the provider sent, verb included. +for a in "$@"; do + case "$a" in + --) break ;; + --detach|--allow-egress|--write|--env|--workdir|--import-tar|--mount|--rootfs|--file|--force|--tar|--sandbox-name|-e|-w) ;; + --*) echo "Error: unknown flag: $a" >&2; exit 2 ;; + esac +done +"#; + + fn launcher_with_prelude(prelude: &str, body: &str) -> (tempfile::TempDir, GcpSandbox) { let directory = tempfile::tempdir().expect("temp dir"); let path = directory.path().join("sandbox"); - std::fs::write(&path, format!("#!/bin/sh\n{body}\n")).expect("write launcher"); + std::fs::write(&path, format!("#!/bin/sh\n{prelude}\n{body}\n")).expect("write launcher"); #[cfg(unix)] { @@ -549,27 +614,28 @@ mod tests { ); } - /// The launcher carries no environment, so a caller that asks for one has to hear about it. - /// Accepting the request and running the command without those variables is the silent no-op - /// the capability contract exists to prevent — the failure would surface inside the sandbox, - /// far from the call that caused it. + /// A sandbox inherits nothing from the container, so a variable a caller asks for reaches the + /// command only if it is passed on the argv. Asserted on the recorded argv rather than on a + /// success code: the launcher exits 0 even when it rejects a flag, so a green call proves + /// nothing about what it was actually given. #[tokio::test] - async fn an_environment_the_launcher_cannot_carry_is_refused() { - let (_dir, sandbox) = launcher("exit 0"); - let env = BTreeMap::from([("TOKEN".to_string(), "secret".to_string())]); + async fn an_environment_reaches_the_launcher_on_create_and_on_exec() { + let directory = tempfile::tempdir().expect("temp dir"); + let record = directory.path().join("argv"); + let (_dir, sandbox) = launcher(&format!(r#"echo "$@" >> {}"#, record.display())); - let on_create = sandbox + let env = BTreeMap::from([("TOKEN".to_string(), "secret".to_string())]); + sandbox .create(CreateSessionRequest { session_id: Some("s1".to_string()), tenant_key: None, env: env.clone(), }) .await - .expect_err("a session environment cannot be honoured here"); - assert_eq!(on_create.code, "OPERATION_NOT_SUPPORTED"); + .expect("a session environment is carried, not refused"); - // `let else` rather than `expect_err`: the Ok side is a stream and carries no Debug. - let Err(on_command) = sandbox + // The stream has to be drained: dropping it undrained kills the child before it runs. + let mut frames = sandbox .run_command( "s1", RunCommandRequest { @@ -580,25 +646,55 @@ mod tests { }, ) .await - else { - panic!("a command environment cannot be honoured here"); - }; - assert_eq!(on_command.code, "OPERATION_NOT_SUPPORTED"); + .unwrap_or_else(|error| panic!("a command with variables is accepted: {error}")); + while frames.next().await.is_some() {} + + let argv = std::fs::read_to_string(&record).expect("launcher ran"); + let lines: Vec<&str> = argv.lines().collect(); + assert!( + lines[0].contains("--env TOKEN=secret"), + "create must pass the variable: {}", + lines[0] + ); + assert!( + lines[1].contains("--env TOKEN=secret"), + "exec must pass the variable: {}", + lines[1] + ); + // Before the command, or the launcher reads it as an argument to the command itself. + let exec = lines[1]; + assert!( + exec.find("--env").unwrap() < exec.find(" -- ").unwrap(), + "--env must precede the `--` separator: {exec}" + ); + } + + /// The create argv, pinned. `--id` does not exist on `run`; the id is positional, and without + /// `--detach` the launcher stays attached until the control deadline kills it. + #[tokio::test] + async fn create_passes_the_id_positionally_and_detaches() { + let directory = tempfile::tempdir().expect("temp dir"); + let record = directory.path().join("argv"); + let (_dir, sandbox) = launcher(&format!(r#"echo "$@" > {}"#, record.display())); - // The control: the same calls without an environment are accepted, so the assertions - // above cannot pass against a provider that refuses everything. sandbox .create(CreateSessionRequest { - session_id: Some("s2".to_string()), + session_id: Some("s1".to_string()), tenant_key: None, env: BTreeMap::new(), }) .await - .expect("a session with no environment is fine"); + .expect("create succeeds"); + + let argv = std::fs::read_to_string(&record).expect("launcher ran"); + let argv = argv.trim(); + assert!(argv.starts_with("run s1"), "id is positional: {argv}"); + assert!(argv.contains("--detach"), "must detach: {argv}"); + assert!(!argv.contains("--id"), "--id is not a flag on run: {argv}"); } /// A command with no deadline is a hang waiting for a slow day, in a sandbox running code the - /// caller does not control. Every other backend refuses it; this one did not. + /// caller does not control, so it is refused here as on every other backend. #[tokio::test] async fn a_command_without_a_deadline_is_refused() { let (_dir, sandbox) = launcher("exit 0"); @@ -678,4 +774,49 @@ mod tests { .await .expect_err("a traversing path must be refused on write too"); } + + /// A session id shaped like a launcher option never reaches the launcher. + /// + /// The id is positional and `--allow-egress` is a flag on the same verb, so an application + /// passing one as its session id would be asking for the egress its binding refused it — the + /// one setting the binding decides rather than the caller. + #[tokio::test] + async fn an_option_shaped_session_id_is_refused_before_the_launcher_runs() { + let (_dir, sandbox) = launcher("exit 0"); + + for id in [ + "--allow-egress", + "-e", + "--env", + "", + "has space", + "semi;colon", + "-leading-dash", + ] { + let error = sandbox + .create(CreateSessionRequest { + session_id: Some(id.to_string()), + tenant_key: None, + env: BTreeMap::new(), + }) + .await + .expect_err(&format!("'{id}' must never reach the argv")); + assert_eq!(error.code, "INVALID_INPUT", "'{id}': {error}"); + + sandbox + .terminate(id) + .await + .expect_err(&format!("'{id}' must be refused on every verb that takes it")); + } + + // The shape the launcher is actually given, and the one this binding generates. + sandbox + .create(CreateSessionRequest { + session_id: Some("sbx-7f3a_01".to_string()), + tenant_key: None, + env: BTreeMap::new(), + }) + .await + .expect("an ordinary id is not refused"); + } } diff --git a/crates/alien-bindings/src/providers/sandbox/local.rs b/crates/alien-bindings/src/providers/sandbox/local.rs index 9620e11ed..b0e8ffbb4 100644 --- a/crates/alien-bindings/src/providers/sandbox/local.rs +++ b/crates/alien-bindings/src/providers/sandbox/local.rs @@ -510,7 +510,10 @@ mod tests { const DEADLINE_PLACEHOLDER: &str = ""; /// The nonce a session would draw. Announced on the first line of stderr, and repeated by /// the killer, exactly as the wrapper does. - const SESSION_NONCE: &str = "a1b2c3d4"; + /// The width the wrapper draws — `od -N16` is 16 bytes, so 32 hex digits. Short of that is + /// not an announcement, and a fixture that used a short one pinned a weaker rule than the + /// session's. + const SESSION_NONCE: &str = "a1b2c3d4a1b2c3d4a1b2c3d4a1b2c3d4"; /// Wraps a scripted stderr the way a bounded session would return it. fn as_session_stderr(stderr: &str) -> String { diff --git a/crates/alien-bindings/src/providers/sandbox/mod.rs b/crates/alien-bindings/src/providers/sandbox/mod.rs index 272b2c7d7..45aad507f 100644 --- a/crates/alien-bindings/src/providers/sandbox/mod.rs +++ b/crates/alien-bindings/src/providers/sandbox/mod.rs @@ -51,12 +51,21 @@ pub(crate) const DEADLINE_GRACE: std::time::Duration = std::time::Duration::from /// command can read its parent's `/proc//cmdline` and `environ`: a nonce that travelled in /// either could be echoed back, and untrusted code would be able to claim its own deadline. A /// shell variable is in neither, and the command cannot read what has already been written to -/// the stream it inherits. +/// the stream it inherits. `unset` first, because an inherited *exported* variable of the same +/// name keeps its export attribute across re-assignment and would carry the nonce straight back +/// into the command's own environment. /// /// Nothing but `sh` and `/dev/urandom` is required, which every session image has. #[cfg(any(feature = "azure", feature = "local"))] pub(crate) struct DeadlineReport; +/// Hex digits in the nonce the wrapper draws: `od -N16` reads 16 bytes. +/// +/// Checked exactly, so a single stray hex character on a line of its own cannot be read as an +/// announcement and turn the rest of the stream into its own repeat. +#[cfg(any(feature = "azure", feature = "local"))] +const NONCE_HEXITS: usize = 32; + #[cfg(any(feature = "azure", feature = "local"))] impl DeadlineReport { /// The shell program that runs a command under this deadline. @@ -64,8 +73,10 @@ impl DeadlineReport { /// The command arrives as `"$@"`, so nothing re-parses its text. It is started in a session /// of its own so the kill reaches its process group rather than one pid: a command that /// spawned children would otherwise leave them running while the caller is told the deadline - /// contained it, which is the claim this path exists to make good on. An image that cannot do - /// that runs nothing — a deadline that cannot be enforced is refused, not approximated. + /// contained it. A child that starts a session of its own leaves that group and outlives the + /// kill — measured — so this covers what the command left behind, not what it moved away. An + /// image that cannot start a session runs nothing: a deadline that cannot be enforced at all + /// is refused rather than approximated. /// /// The killer repeats the nonce when its signal was delivered, which the status has to confirm: /// a command already exited and awaiting reaping takes the signal too. Once the command is @@ -77,11 +88,13 @@ impl DeadlineReport { /// argv, which the command could read. pub(crate) fn bounded_program(deadline: std::time::Duration) -> String { format!( - "command -v setsid >/dev/null 2>&1 || exit {unboundable}; \ + "unset nonce command_pid killer_pid sleeper status; \ + command -v setsid >/dev/null 2>&1 || exit {unboundable}; \ nonce=$(od -An -N16 -tx1 /dev/urandom | tr -d ' \\n') || exit {unboundable}; \ printf '%s\\n' \"$nonce\" >&2; \ setsid \"$@\" & command_pid=$!; \ - ( sleep {deadline} & sleeper=$!; trap 'kill $sleeper 2>/dev/null; exit' TERM; wait $sleeper; \ + ( sleep {deadline} & sleeper=$!; trap 'kill \"$sleeper\" 2>/dev/null; exit' TERM; \ + wait \"$sleeper\"; \ trap '' TERM; kill -KILL -\"$command_pid\" 2>/dev/null && printf %s \"$nonce\" >&2 ) & killer_pid=$!; \ wait \"$command_pid\"; status=$?; \ kill \"$killer_pid\" 2>/dev/null; wait \"$killer_pid\"; \ @@ -106,11 +119,28 @@ impl DeadlineReport { /// because only the session knows the value. Whether that signal ended the command is the /// status's to say. pub(crate) fn read(exit_code: Option, stderr: &str) -> Bounded { - let announced = stderr - .split_once('\n') - .filter(|(nonce, _)| !nonce.is_empty() && nonce.chars().all(|c| c.is_ascii_hexdigit())); - - let Some((nonce, rest)) = announced else { + // The first line that is a nonce and nothing else, rather than the first line: a shell + // asked to trace itself writes its own lines before this one, and they displace an + // announcement that has to be found for the report to mean anything. Unforgeable either + // way — the session writes it before the command starts, and a traced line carries the + // shell's prefix, so nothing the command chose can be read as the announcement. + let announced = stderr.split('\n').enumerate().find_map(|(index, line)| { + // A carriage return would make the announcement 33 bytes and invisible, and the first + // line the command chose would be adopted in its place. No transport here delivers + // one today; the cost of not depending on that is one trim. + let line = line.strip_suffix('\r').unwrap_or(line); + let is_nonce = line.len() == NONCE_HEXITS && line.chars().all(|c| c.is_ascii_hexdigit()); + is_nonce.then(|| { + let after = stderr + .split('\n') + .skip(index + 1) + .collect::>() + .join("\n"); + (line, after) + }) + }); + + let Some((nonce, rest)) = announced.as_ref().map(|(n, r)| (*n, r.as_str())) else { // No announcement means the wrapper exited before starting anything, so nothing of // the caller's ran and nothing about a deadline can be claimed. return Bounded::NotRun { @@ -220,7 +250,7 @@ mod tests { #[test] fn only_the_session_can_report_a_deadline() { // The shell writes its own notice after the signal, so the repeat is not always last. - let killed = match DeadlineReport::read(Some(137), "abc123\nboom\nabc123Killed\n") { + let killed = match DeadlineReport::read(Some(137), "a1b2c3d4a1b2c3d4a1b2c3d4a1b2c3d4\nboom\na1b2c3d4a1b2c3d4a1b2c3d4a1b2c3d4Killed\n") { Bounded::Ran { killed, stderr } => { assert_eq!(stderr, "boom\nKilled\n"); killed @@ -231,7 +261,7 @@ mod tests { // A command echoing something nonce-shaped repeats nothing the session announced. assert!(matches!( - DeadlineReport::read(Some(0), "abc123\nboom\ndeadbeef\n"), + DeadlineReport::read(Some(0), "a1b2c3d4a1b2c3d4a1b2c3d4a1b2c3d4\nboom\ndeadbeef\n"), Bounded::Ran { killed: false, .. } )); } @@ -252,12 +282,54 @@ mod tests { assert!(reason.contains("could not start"), "{reason}"); } + /// The announcement survives a shell that writes before it, and a short hex line is not one. + /// + /// A `sh` that is really bash turns on tracing from `SHELLOPTS` in its environment and writes + /// its own lines first. Reading only line 1 lost the announcement there and reported every + /// command — including the ones that succeeded — as never bounded. The width is checked + /// exactly, so a stray hex fragment on a line of its own cannot stand in for it. + #[test] + fn the_announcement_is_found_by_shape_rather_than_by_position() { + let traced = format!("+ unset nonce command_pid\n+ printf\na1b2c3d4a1b2c3d4a1b2c3d4a1b2c3d4\nboom\na1b2c3d4a1b2c3d4a1b2c3d4a1b2c3d4Killed\n"); + let Bounded::Ran { killed, stderr } = DeadlineReport::read(Some(137), &traced) else { + panic!("the trace must not hide the announcement"); + }; + assert!(killed, "the killer's repeat still reports the kill"); + assert_eq!( + stderr, "boom\nKilled\n", + "what precedes the announcement was written before the command started, so it is the \ + session's own noise rather than the command's — and one of those lines is the trace \ + of the announcement itself" + ); + + // A carriage return does not hide the announcement, which would otherwise let the first + // line the command chose stand in for it. + let crlf = format!("a1b2c3d4a1b2c3d4a1b2c3d4a1b2c3d4\r\nboom\r\na1b2c3d4a1b2c3d4a1b2c3d4a1b2c3d4Killed\r\n"); + assert!( + matches!( + DeadlineReport::read(Some(137), &crlf), + Bounded::Ran { killed: true, .. } + ), + "a carriage return is not part of the nonce" + ); + + // Short of the width the session draws, so not an announcement — and the rest of the + // stream is not its repeat. + assert!( + matches!( + DeadlineReport::read(Some(137), "ab\nboom\nabc\n"), + Bounded::NotRun { .. } + ), + "a hex fragment is not a nonce" + ); + } + /// A command that finished as the killer fired keeps its own result. `kill` succeeds on a /// process that has exited and is not yet reaped, so the repeat alone would turn a command /// that beat its deadline into a deadline failure and throw away what it returned. #[test] fn a_command_that_finished_as_the_killer_fired_keeps_its_result() { - let Bounded::Ran { killed, stderr } = DeadlineReport::read(Some(0), "abc123\nboom\nabc123") + let Bounded::Ran { killed, stderr } = DeadlineReport::read(Some(0), "a1b2c3d4a1b2c3d4a1b2c3d4a1b2c3d4\nboom\na1b2c3d4a1b2c3d4a1b2c3d4a1b2c3d4") else { panic!("the command ran"); }; @@ -293,11 +365,13 @@ mod tests { "the killer is stopped and then awaited, whatever the command's exit: {program}" ); assert!( - program.contains("wait $sleeper; trap '' TERM; kill -KILL"), - "past its sleep the killer ignores the stop, so its report is never cut off: {program}" + program.contains(r#"wait "$sleeper"; trap '' TERM; kill -KILL"#), + "past its sleep the killer ignores the stop, so its report is never cut off, and the \ + pid is quoted so an inherited IFS cannot split it into words that are not children: \ + {program}" ); assert!( - program.contains("trap 'kill $sleeper 2>/dev/null; exit' TERM"), + program.contains(r#"trap 'kill "$sleeper" 2>/dev/null; exit' TERM"#), "a stopped killer reaps its own sleeper, so none outlives the command: {program}" ); assert!( @@ -307,6 +381,56 @@ mod tests { ); } + /// An inherited variable of the wrapper's own name never reaches the command. + /// + /// Run against a real `sh`: an exported variable keeps its export attribute across + /// re-assignment, so `nonce=$(…)` would hand the command the session's own nonce. A + /// stand-in `setsid` is supplied because macOS ships none. + #[test] + #[cfg(unix)] + fn the_wrapper_never_hands_its_nonce_to_the_command() { + use std::os::unix::fs::PermissionsExt; + + let bin = std::env::temp_dir().join(format!("alien-sandbox-{}", std::process::id())); + std::fs::create_dir_all(&bin).expect("a directory for the stand-in"); + let setsid = bin.join("setsid"); + std::fs::write(&setsid, "#!/bin/sh\nexec \"$@\"\n").expect("the stand-in is written"); + std::fs::set_permissions(&setsid, std::fs::Permissions::from_mode(0o755)) + .expect("the stand-in is executable"); + + let path = format!( + "{}:{}", + bin.display(), + std::env::var("PATH").unwrap_or_default() + ); + let run = std::process::Command::new("/bin/sh") + .arg("-c") + .arg(DeadlineReport::bounded_program(std::time::Duration::from_secs(5))) + .arg("sh") + .arg("printenv") + .arg("nonce") + .env("PATH", path) + .env("nonce", "inherited-from-the-session") + .output() + .expect("a shell runs"); + std::fs::remove_dir_all(&bin).ok(); + + let announced = String::from_utf8_lossy(&run.stderr); + let announced = announced.lines().next().unwrap_or_default().to_string(); + assert!( + announced.len() == 32 && announced.chars().all(|c| c.is_ascii_hexdigit()), + "the session has to reach the point of drawing a nonce, or this proves nothing: \ + stderr {:?}", + String::from_utf8_lossy(&run.stderr) + ); + + let seen = String::from_utf8_lossy(&run.stdout); + assert!( + seen.trim().is_empty(), + "the command must inherit no `nonce` at all, and it saw {seen:?}" + ); + } + /// A deadline neither end can honour is refused, not stretched or waited on. #[tokio::test] async fn a_deadline_outside_what_the_backends_can_honour_is_refused() { diff --git a/crates/alien-cloudformation/src/emitters/aws/sandbox.rs b/crates/alien-cloudformation/src/emitters/aws/sandbox.rs index b2ffdfa70..c7b92e781 100644 --- a/crates/alien-cloudformation/src/emitters/aws/sandbox.rs +++ b/crates/alien-cloudformation/src/emitters/aws/sandbox.rs @@ -572,8 +572,9 @@ fn egress_connector_arns(sandbox: &Sandbox, image_id: &str) -> CfExpression { /// Refuses an egress mode the emitted template cannot deliver. /// /// `deny` is built from a connector whose security group permits nothing outbound. Outbound -/// allowances are not: `allow` would depend on the network's NAT topology, and AWS has no -/// domain-filtering primitive at the connector, so `allowDomains` has nothing to render into. +/// allowances are not: AWS has no domain-filtering primitive at the connector, so `allowDomains` +/// has nothing to render into. `allow` is accepted and emits no connector at all — a MicroVM +/// without one reaches the internet. /// A template that silently ignores a declared egress policy is worse than one that refuses it. fn refuse_unsupported_egress(sandbox: &Sandbox) -> Result<()> { let refuse = |mode: &str| { @@ -582,8 +583,8 @@ fn refuse_unsupported_egress(sandbox: &Sandbox) -> Result<()> { reason: format!( "AWS sandboxes reach the network through a VPC egress connector, which this \ template builds to deny outbound traffic; egress '{mode}' has no connector \ - configuration to render into. Declare egress: deny, or use a platform that \ - supports it" + configuration to render into. Declare egress: deny for a connector that reaches \ + nothing, or egress: allow for no connector at all" ), })) }; diff --git a/crates/alien-core/src/bindings/sandbox.rs b/crates/alien-core/src/bindings/sandbox.rs index dc21e0ac2..209cfd69b 100644 --- a/crates/alien-core/src/bindings/sandbox.rs +++ b/crates/alien-core/src/bindings/sandbox.rs @@ -5,6 +5,7 @@ //! record, so a binding describes the parent only. use super::BindingValue; +use crate::SandboxEgress; use serde::{Deserialize, Serialize}; /// Represents a sandbox binding for creating and reaching sandbox sessions. @@ -91,6 +92,24 @@ pub struct AzureSandboxBinding { /// Resource group the sandbox group sits in. The data-plane path is scoped by it, and the /// Azure client config does not carry one. pub resource_group: BindingValue, + /// Outbound policy every session is created with, as declared. + /// + /// Carried whole rather than as a flag: the data plane's default action is `Allow`, so a + /// session created without a policy is an open one, and a hostname list has no boolean to + /// travel in. + pub egress: SandboxEgress, + /// Idle seconds after which a session suspends, if the declaration asked for one. + /// + /// Carried because the data plane takes it at create and nowhere else: a policy that does not + /// travel with the create body is a declaration the sandbox never hears about. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub idle_suspend_seconds: Option, + /// Catalog disk image every session is created from, taken from the declaration's `code`. + /// + /// Carried rather than hardcoded in the provider because the declaration is the only place + /// that knows it, and a sandbox running an image its author did not choose is the one Azure + /// gap that fails without an error. + pub disk_image: BindingValue, } /// GCP sandbox binding configuration. @@ -168,12 +187,18 @@ impl SandboxBinding { data_plane_endpoint: impl Into>, region: impl Into>, resource_group: impl Into>, + disk_image: impl Into>, + egress: SandboxEgress, + idle_suspend_seconds: Option, ) -> Self { Self::Azure(AzureSandboxBinding { sandbox_group: sandbox_group.into(), data_plane_endpoint: data_plane_endpoint.into(), region: region.into(), resource_group: resource_group.into(), + egress, + idle_suspend_seconds, + disk_image: disk_image.into(), }) } @@ -239,6 +264,9 @@ mod tests { "https://management.swedencentral.azuredevcompute.io", "swedencentral", "rg", + "ubuntu", + SandboxEgress::Deny, + None, ), SandboxBinding::gcp("/usr/local/gcp/bin/sandbox", false), SandboxBinding::kubernetes( @@ -267,7 +295,7 @@ mod tests { fn service_tags_are_prefixed_and_distinct() { let tags: Vec = vec![ SandboxBinding::aws("a", "1", "r"), - SandboxBinding::azure("g", "e", "r", "rg"), + SandboxBinding::azure("g", "e", "r", "rg", "ubuntu", SandboxEgress::Deny, None), SandboxBinding::gcp("p", true), SandboxBinding::kubernetes("n", "gvisor", "s", "http://op:8080", "k", "/t"), SandboxBinding::local("u", "k", "t"), diff --git a/crates/alien-core/src/resources/sandbox.rs b/crates/alien-core/src/resources/sandbox.rs index 3f693c2d7..fbd4ce004 100644 --- a/crates/alien-core/src/resources/sandbox.rs +++ b/crates/alien-core/src/resources/sandbox.rs @@ -25,7 +25,10 @@ pub enum SandboxCode { /// A prebuilt container image used as the sandbox root filesystem. #[serde(rename_all = "camelCase")] Image { - /// Image reference (e.g. `ubuntu:24.04`, `ghcr.io/myorg/sandbox:latest`) + /// Image reference (e.g. `ubuntu:24.04`, `ghcr.io/myorg/sandbox:latest`). + /// + /// Two backends narrow it in opposite directions: AWS wants an `s3://` bundle, Azure a + /// bare catalog name such as `ubuntu`. Each refuses the other's shape while planning. image: String, }, /// Source built into a sandbox image at deploy time. @@ -130,9 +133,14 @@ pub enum SandboxEgress { /// Unrestricted outbound access to the public internet, and none to private ranges or the /// deployment's own network. /// - /// Link-local carries the same exception as `Deny`. + /// Link-local carries the same exception as `Deny`. AWS and Kubernetes deliver both halves. + /// Azure and GCP deliver the first only: one matches host patterns and the other is a single + /// switch, so neither can name an address range to exclude. Allow, - /// Outbound access only to the listed hostnames. No backend expresses this yet. + /// Outbound access only to the listed hostnames. + /// + /// Azure alone expresses it: its egress proxy matches on host pattern. The others filter by + /// CIDR or carry a single switch, and both would approximate the list rather than keep it. #[serde(rename_all = "camelCase")] AllowDomains { /// Hostnames the sandbox may reach @@ -168,8 +176,6 @@ pub struct SandboxSessionPolicy { #[serde(rename_all = "camelCase", deny_unknown_fields)] pub struct SandboxCapabilities { /// Files can be moved in and out of a session - /// - /// Every backend but Azure, whose binding implements no transfer. pub files: bool, /// A later call can reach a session created by an earlier one pub reconnect: bool, @@ -225,20 +231,28 @@ impl SandboxCapabilities { // it cannot create a namespace. No backend offers this today. supervisor_pid_namespace: false, }), - // Azure the platform has all three — a per-port URL closed to anonymous traffic, a - // 0.54s resume, and a full-VM snapshot — and the binding provider implements none of - // them. The capability set describes what a caller can reach, not what the cloud - // could do, so these stay false until the provider catches up. Platform::Azure => Ok(Self { - files: false, + files: true, reconnect: true, + // A sandbox port carries a URL and an auth config, and the auth config offers two + // things: anonymous, or Entra ID with an allowlist of human email addresses. + // Neither is a credential scoped to a port for a fixed time, which is what a + // preview capability is. Returning the anonymous URL would publish the port. preview: false, - suspend_resume: false, + suspend_resume: true, + // The one cloud of the five that could offer this, and the blocker is ours: + // `snapshot()` returns an id and `CreateSessionRequest` has no field to consume + // one, so no backend can complete the round trip. Nothing in the resource model + // owns such an artifact either, and Microsoft states snapshots are not garbage + // collected — an id with no owner is a bill that grows. snapshot: false, - domain_egress_rules: false, - egress_deny: false, + domain_egress_rules: true, + egress_deny: true, enforced_limits: false, process_limit: false, + // Auto-suspend and auto-delete exist; a wall-clock ceiling does not. Accepting + // `maxLifetimeSeconds` here would be the silent no-op the capability set exists + // to prevent, so this is a decision rather than a gap. session_lifetime: false, // No Alien process inside an Azure sandbox, so there is no supervisor to isolate. supervisor_pid_namespace: false, @@ -459,9 +473,9 @@ impl Sandbox { pub fn validate_for_platform(&self, platform: Platform) -> Result<()> { let capabilities = SandboxCapabilities::for_platform(platform)?; - // No backend builds a sandbox image from source. Kubernetes turned this into an empty - // image string and a pod that could never schedule, which is the silent no-op the - // capability contract forbids — the failure has to land here instead. + // No backend builds a sandbox image from source: an empty image string schedules a pod + // that can never run, the silent no-op the capability contract forbids — the failure + // has to land here instead. if let SandboxCode::Source { .. } = &self.code { return Err(AlienError::new(ErrorData::SandboxLimitInvalid { resource_id: self.id.clone(), @@ -473,6 +487,11 @@ impl Sandbox { })); } + // Read before the limits, because the image is declared whether or not any are. + if platform == Platform::Azure { + self.azure_catalog_image()?; + } + let Some(limits) = self.limits.as_ref() else { // Nothing declared, so nothing to enforce and nothing to reject. return self.validate_capabilities(&capabilities, platform); @@ -525,6 +544,47 @@ impl Sandbox { self.validate_capabilities(&capabilities, platform) } + /// The catalog disk image Azure creates a session from. + /// + /// Azure names a public catalog entry rather than pulling a reference, so a registry path, + /// tag or digest has nowhere to go. An allowlist, because the answer to "what else could be + /// in there" is a name the data plane rejects at the first session, long after the apply. + pub fn azure_catalog_image(&self) -> Result<&str> { + let refused = |value: &str, reason: &str| { + AlienError::new(ErrorData::SandboxLimitInvalid { + resource_id: self.id.clone(), + field: "code.image".to_string(), + value: value.to_string(), + reason: reason.to_string(), + }) + }; + + let SandboxCode::Image { image } = &self.code else { + return Err(AlienError::new(ErrorData::SandboxLimitInvalid { + resource_id: self.id.clone(), + field: "code".to_string(), + value: "source".to_string(), + reason: "no sandbox backend builds an image from source yet".to_string(), + })); + }; + + let image = image.trim(); + if image.is_empty() { + return Err(refused(image, "a sandbox has to name an image")); + } + if !image + .chars() + .all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-')) + { + return Err(refused( + image, + "Azure creates a session from a public catalog disk image, so code.image must be \ + a bare catalog name such as 'ubuntu'", + )); + } + Ok(image) + } + /// The MicroVM size that keeps every declared ceiling, or why none does. /// /// AWS sizes are discrete and a running MicroVM bursts to four times its baseline, so the @@ -619,6 +679,21 @@ impl Sandbox { // `allow` asks for no restriction, so a backend that ignores it fails loudly on the first // blocked connection. `deny` asks for one, and a backend that ignores it puts untrusted // code on the internet with nothing to notice — so only this direction is gated. + // An empty list is not a restriction anyone wrote down: it renders as a deny-all wearing + // an allowlist's label, which reads at a glance as the opposite of what it does. + if let SandboxEgress::AllowDomains { domains } = &self.egress { + if domains.is_empty() { + return Err(AlienError::new(ErrorData::SandboxLimitInvalid { + resource_id: self.id.clone(), + field: "egress.domains".to_string(), + value: "[]".to_string(), + reason: "an allowlist naming no domain denies everything; declare \ + egress: deny if that is what was meant" + .to_string(), + })); + } + } + if matches!(self.egress, SandboxEgress::Deny) { capabilities.require(SandboxCapability::EgressDeny, platform)?; } @@ -829,7 +904,7 @@ mod tests { fn sandbox_with(egress: SandboxEgress, preview_ports: Vec) -> Sandbox { Sandbox::new("agent-sbx".to_string()) .code(SandboxCode::Image { - image: "ubuntu:24.04".to_string(), + image: "ubuntu".to_string(), }) .limits(SandboxLimits { cpu: "1".to_string(), @@ -862,18 +937,21 @@ mod tests { assert!(!gcp.enforced_limits); let azure = SandboxCapabilities::for_platform(Platform::Azure).expect("azure is supported"); - assert!(!azure.files, "the Azure binding implements no file transfer"); - assert!(gcp.files, "every other backend moves files"); - // The Azure binding renders neither an egress policy nor a ceiling, so a declaration of - // either is refused rather than accepted and dropped. - assert!(!azure.domain_egress_rules); - assert!(!azure.egress_deny); + assert!(azure.files, "every backend moves files"); + assert!(gcp.files); + // Azure is the only backend whose egress policy matches on host pattern, and the only + // one where `deny` and a hostname list are the same object. + assert!(azure.domain_egress_rules); + assert!(azure.egress_deny); + // The data plane takes no ceiling, so a declaration of one is refused rather than + // accepted and dropped. assert!(!azure.enforced_limits); - // Azure the cloud has snapshot, preview and resume; the binding provider returns - // unsupported for all three. What a caller can reach is what the set describes. + assert!(azure.suspend_resume); + // Both stay false for reasons that are not "unbuilt": a snapshot id has nothing to + // consume it on any backend, and an Azure port's auth is anonymous or a human allowlist, + // neither of which is a port-scoped credential. assert!(!azure.snapshot); assert!(!azure.preview); - assert!(!azure.suspend_resume); let aws = SandboxCapabilities::for_platform(Platform::Aws).expect("aws is supported"); assert!(!aws.snapshot, "AWS has no user-callable session snapshot"); @@ -910,11 +988,11 @@ mod tests { assert!(rendered.contains("gcp"), "names the platform: {rendered}"); } - /// No backend expresses a hostname allowlist: AWS and Kubernetes match CIDRs, and the Azure - /// binding renders no egress policy at all. Accepting one anywhere would leave a stack + /// Azure matches on hostname; AWS and Kubernetes match CIDRs, and Local and GCP have a + /// switch rather than a filter. Accepting a hostname list on those four would leave a stack /// reading as restricted while the sandbox reaches the whole internet. #[test] - fn a_hostname_allowlist_is_refused_on_every_backend() { + fn a_hostname_allowlist_is_refused_everywhere_it_would_be_approximated() { let sandbox = sandbox_with( SandboxEgress::AllowDomains { domains: vec!["example.com".to_string()], @@ -924,19 +1002,25 @@ mod tests { for platform in [ Platform::Aws, - Platform::Azure, Platform::Gcp, Platform::Kubernetes, Platform::Local, ] { let error = sandbox .validate_for_platform(platform) - .expect_err("no backend expresses a hostname allowlist"); + .expect_err("only Azure expresses a hostname allowlist"); assert_eq!( error.code, "SANDBOX_CAPABILITY_UNSUPPORTED", "on {platform:?}" ); } + + assert!( + SandboxCapabilities::for_platform(Platform::Azure) + .expect("supported") + .domain_egress_rules, + "Azure's egress policy matches on host pattern" + ); } /// `deny` is the declaration that carries a security promise, so a backend that cannot keep @@ -959,10 +1043,10 @@ mod tests { .expect("deny is enforced here"); } - // Declares no ceilings, so the only thing left for Azure to refuse is the egress mode. + // Declares no ceilings, which Azure refuses for its own reason, so this isolates egress. let egress_only = Sandbox::new("sbx".to_string()) .code(SandboxCode::Image { - image: "alpine:3.20".to_string(), + image: "alpine".to_string(), }) .egress(SandboxEgress::Deny) .session(SandboxSessionPolicy { @@ -971,15 +1055,9 @@ mod tests { }) .build(); - let error = egress_only + egress_only .validate_for_platform(Platform::Azure) - .expect_err("the Azure binding renders no egress policy, so deny cannot be kept"); - assert_eq!(error.code, "SANDBOX_CAPABILITY_UNSUPPORTED"); - assert!( - error.message.contains("egressDeny"), - "names the capability: {}", - error.message - ); + .expect("Azure creates the sandbox under a Deny policy with full inspection"); } /// Ceilings are rejected per-platform where unsupported — rejected when *declared*. With @@ -994,7 +1072,7 @@ mod tests { let undeclared = Sandbox::new("sbx".to_string()) .code(SandboxCode::Image { - image: "alpine:3.20".to_string(), + image: "alpine".to_string(), }) .egress(SandboxEgress::Deny) .session(SandboxSessionPolicy { @@ -1131,6 +1209,61 @@ mod tests { .expect("the ceiling itself is allowed"); } + /// An image reference Azure cannot honour is refused while planning, not at the first session. + /// + /// `code.image`'s own documentation gives a tag and a registry path as examples — exactly + /// what Azure cannot take, so this is the shape a customer is most likely to declare. + #[test] + fn an_image_azure_cannot_pull_is_refused_while_planning() { + let mut sandbox = sandbox_with(SandboxEgress::Deny, vec![]); + // Azure enforces no declared ceiling, so a sandbox carrying limits is refused before the + // image is ever read. + sandbox.limits = None; + + for image in [ + "ubuntu:24.04", + "ghcr.io/myorg/sandbox:latest", + "ubuntu@sha256:abc", + "", + " ", + "ubuntu latest", + "ubuntu?x", + ] { + sandbox.code = SandboxCode::Image { + image: image.to_string(), + }; + let error = sandbox + .validate_for_platform(Platform::Azure) + .expect_err("an image Azure has nowhere to put is refused"); + assert_eq!(error.code, "SANDBOX_LIMIT_INVALID", "image '{image}'"); + + // The same declaration is ordinary everywhere that pulls a reference. + sandbox + .validate_for_platform(Platform::Kubernetes) + .expect("a registry reference is what every other backend takes"); + } + + for image in ["ubuntu", "ubuntu-22.04", "debian_slim"] { + sandbox.code = SandboxCode::Image { + image: image.to_string(), + }; + sandbox + .validate_for_platform(Platform::Azure) + .unwrap_or_else(|error| panic!("'{image}' is a catalog name: {error}")); + } + + // Surrounding space is trimmed rather than carried into the create body. + sandbox.code = SandboxCode::Image { + image: " ubuntu ".to_string(), + }; + assert_eq!( + sandbox + .azure_catalog_image() + .expect("a padded name is still a name"), + "ubuntu" + ); + } + /// A deadline is accepted only where the platform itself terminates on it — the kubelet's /// `activeDeadlineSeconds` and Lambda's `maximumDurationInSeconds`. Everywhere else it would /// need a reaper that does not exist, so it is refused rather than accepted and dropped. @@ -1228,9 +1361,9 @@ mod tests { ); } - /// `Source` is a public part of the type that no backend builds. Kubernetes used to turn it - /// into an empty image string, producing a pod that could never schedule — the refusal has to - /// happen at plan time and on every platform, not in one emitter. + /// `Source` is a public part of the type that no backend builds: an empty image string + /// schedules a pod that can never run, so the refusal has to happen at plan time and on + /// every platform, not in one emitter. #[test] fn source_code_is_refused_everywhere_rather_than_producing_a_broken_manifest() { let sandbox = Sandbox::new("agent".to_string()) @@ -1312,7 +1445,7 @@ mod tests { let original = sandbox_with(SandboxEgress::Deny, vec![]); let renamed = Sandbox::new("other".to_string()) .code(SandboxCode::Image { - image: "ubuntu:24.04".to_string(), + image: "ubuntu".to_string(), }) .limits( original @@ -1334,4 +1467,69 @@ mod tests { .validate_update(&renamed) .expect_err("renaming a sandbox is not an update"); } + + /// Azure declares an idle-suspend policy but not a wall-clock ceiling. + /// + /// The two travel together in `SandboxSessionPolicy` and are gated separately on purpose: + /// Azure suspends on idle and has no maximum lifetime, so accepting one and refusing the + /// other is the honest split rather than an inconsistency. + #[test] + fn azure_takes_an_idle_policy_and_still_refuses_a_lifetime_ceiling() { + let with_policy = |session: SandboxSessionPolicy| { + Sandbox::new("sbx".to_string()) + .code(SandboxCode::Image { + image: "ubuntu".to_string(), + }) + .egress(SandboxEgress::Allow) + .session(session) + .build() + .validate_for_platform(Platform::Azure) + }; + + with_policy(SandboxSessionPolicy { + max_lifetime_seconds: None, + idle_suspend_seconds: Some(900), + }) + .expect("Azure suspends a session on idle"); + + let error = with_policy(SandboxSessionPolicy { + max_lifetime_seconds: Some(3600), + idle_suspend_seconds: None, + }) + .expect_err("Azure has no wall-clock ceiling to enforce one with"); + assert_eq!(error.code, "SANDBOX_CAPABILITY_UNSUPPORTED"); + assert!( + error.message.contains("sessionLifetime"), + "names the capability: {}", + error.message + ); + } + + /// An allowlist naming nothing is a deny-all wearing an allowlist's label. + /// + /// It renders as a `Deny` default with no rules — the shape the Azure provider adds a + /// catch-all to avoid — and a reader scanning the declaration sees "allowDomains" and reads + /// the opposite of what it does. + #[test] + fn an_allowlist_with_no_domains_is_refused() { + let declared = |domains: Vec| { + Sandbox::new("sbx".to_string()) + .code(SandboxCode::Image { + image: "ubuntu".to_string(), + }) + .egress(SandboxEgress::AllowDomains { domains }) + .session(SandboxSessionPolicy { + max_lifetime_seconds: None, + idle_suspend_seconds: None, + }) + .build() + .validate_for_platform(Platform::Azure) + }; + + let error = declared(vec![]).expect_err("an empty allowlist must be refused"); + assert_eq!(error.code, "SANDBOX_LIMIT_INVALID"); + + declared(vec!["api.example.com".to_string()]) + .expect("a named domain is what an allowlist is for"); + } } diff --git a/crates/alien-helm/src/emitters/sandbox.rs b/crates/alien-helm/src/emitters/sandbox.rs index edd1a690c..fc7b82f76 100644 --- a/crates/alien-helm/src/emitters/sandbox.rs +++ b/crates/alien-helm/src/emitters/sandbox.rs @@ -61,7 +61,7 @@ impl HelmEmitter for SandboxEmitter { let mut fragment = HelmFragment::empty(); fragment.extra_templates.insert( format!("sandbox-{}-networkpolicy.yaml", sandbox.id()), - network_policy(sandbox), + network_policy(sandbox, ctx.resource_id)?, ); fragment .extra_templates @@ -78,12 +78,21 @@ impl HelmEmitter for SandboxEmitter { /// because that needs a gateway validating a session-and-port capability and none exists. Under /// `deny`, `Egress` is listed with no rules — a listed policy type with no rule is how /// NetworkPolicy spells "none", where omitting the type would mean "unrestricted". -fn network_policy(sandbox: &Sandbox) -> String { +fn network_policy(sandbox: &Sandbox, resource_id: &str) -> Result { let egress = match sandbox.egress { SandboxEgress::Deny => String::new(), - // A hostname allowlist is not expressible here — NetworkPolicy matches CIDRs — which is - // why Kubernetes publishes `domainEgressRules: false` rather than approximating one. - SandboxEgress::Allow | SandboxEgress::AllowDomains { .. } => { + // Refused here rather than upstream, so the function that would render the permissive + // rule is the one that declines: a hostname list rendered as `allow` opens every address + // it was written to exclude. + SandboxEgress::AllowDomains { .. } => { + return Err(AlienError::new(ErrorData::OperationNotSupported { + operation: format!("generate the Helm chart for sandbox '{resource_id}'"), + reason: "a Kubernetes NetworkPolicy matches addresses, not names, so a hostname \ + list has nothing to render into. Declare egress: deny or egress: allow" + .to_string(), + })); + } + SandboxEgress::Allow => { let excepts: String = ALWAYS_DENIED_CIDRS .iter() .map(|cidr| format!(" - {cidr}\n")) @@ -99,7 +108,7 @@ fn network_policy(sandbox: &Sandbox) -> String { } }; - format!( + Ok(format!( r#"apiVersion: networking.k8s.io/v1 kind: NetworkPolicy metadata: @@ -127,7 +136,7 @@ spec: id = sandbox.id(), label = LABEL_SANDBOX, agent_port = AGENT_PORT, - ) + )) } /// Cluster-scoped RBAC for the session broker. diff --git a/crates/alien-helm/tests/generator/helpers.rs b/crates/alien-helm/tests/generator/helpers.rs index d6ca4bfff..01e372b9b 100644 --- a/crates/alien-helm/tests/generator/helpers.rs +++ b/crates/alien-helm/tests/generator/helpers.rs @@ -8,6 +8,11 @@ use super::test_utils; /// Render `stack` into a chart through the built-in registry. pub fn render(stack: &Stack, settings: StackSettings) -> HelmChart { + try_render(stack, settings).expect("chart should render") +} + +/// Render `stack`, keeping the error for a case that is meant to be refused. +pub fn try_render(stack: &Stack, settings: StackSettings) -> alien_core::Result { let registry = HelmRegistry::built_in(); generate_helm_chart( stack, @@ -17,7 +22,6 @@ pub fn render(stack: &Stack, settings: StackSettings) -> HelmChart { chart_name: stack.id().to_string(), }, ) - .expect("chart should render") } /// Snapshot the entire chart as a single string with `=== ===` diff --git a/crates/alien-helm/tests/generator/resource_layer_tests.rs b/crates/alien-helm/tests/generator/resource_layer_tests.rs index 3fdbdf688..b860524b0 100644 --- a/crates/alien-helm/tests/generator/resource_layer_tests.rs +++ b/crates/alien-helm/tests/generator/resource_layer_tests.rs @@ -2,7 +2,7 @@ //! artifact-registry contributions land under //! `infrastructure.` in the chart's `values.yaml`. -use super::helpers::{assert_helm_valid, render, snapshot_chart}; +use super::helpers::{assert_helm_valid, render, snapshot_chart, try_render}; use alien_core::{ ArtifactRegistry, Kv, Queue, ResourceLifecycle, Sandbox, SandboxCode, SandboxEgress, SandboxSessionPolicy, Stack, StackSettings, Storage, Vault, @@ -37,12 +37,10 @@ fn data_layer_emits_infrastructure_bindings() { assert_helm_valid(&chart, "data_layer"); } -/// The Kubernetes Frozen parent, which nothing emitted before this. -/// -/// Two things the chart owns and the operator does not: the NetworkPolicy that makes the declared -/// egress real, and the cluster-scoped RBAC the broker's `TokenReview` needs. Rendering is not -/// enough on its own — `assert_helm_valid` runs `helm lint`, `helm template` and `kubeconform`, so -/// a policy the API server would reject fails here rather than at install. +/// The Kubernetes Frozen parent owns two things the operator does not: the NetworkPolicy that +/// makes the declared egress real, and the cluster-scoped RBAC the broker's `TokenReview` needs. +/// Rendering is not enough on its own — `assert_helm_valid` runs `helm lint`, `helm template` and +/// `kubeconform`, so a policy the API server would reject fails here rather than at install. #[test] fn a_sandbox_emits_its_network_policy_and_the_brokers_rbac() { let stack = Stack::new("sandbox-chart".to_string()) @@ -166,11 +164,14 @@ fn a_sandbox_allowing_egress_still_denies_the_metadata_endpoint() { assert_helm_valid(&chart, "sandbox_layer_allow"); } -/// NetworkPolicy matches addresses, not names, so a hostname allowlist cannot be honoured here. -/// It degrades to `allow` rather than being approximated, and the capability set declares -/// `domainEgressRules: false` so a caller learns that at plan time instead of believing it held. +/// NetworkPolicy matches addresses, not names, so a hostname allowlist has nothing to render +/// into. It is refused: rendering it as `allow` would open every address the list excluded, and +/// the chart would look like the policy applied. +/// +/// The second gate, not the first — a customer meets `domainEgressRules` at plan time. This one +/// covers the paths that render without planning. #[test] -fn a_hostname_allowlist_is_not_silently_approximated() { +fn a_hostname_allowlist_is_refused_rather_than_widened() { let stack = Stack::new("sandbox-domains-chart".to_string()) .add( Sandbox::new("agent".to_string()) @@ -188,14 +189,12 @@ fn a_hostname_allowlist_is_not_silently_approximated() { ResourceLifecycle::Frozen, ) .build(); - let chart = render(&stack, StackSettings::default()); + let error = try_render(&stack, StackSettings::default()) + .expect_err("a hostname list must be refused rather than approximated"); - let policy = chart - .files - .get("templates/sandbox-agent-networkpolicy.yaml") - .expect("the sandbox NetworkPolicy must render"); + assert_eq!(error.code, "OPERATION_NOT_SUPPORTED", "{error}"); assert!( - policy.contains("cidr: 0.0.0.0/0") && !policy.contains("example.com"), - "domains are not expressible and must not appear as though they were:\n{policy}" + error.to_string().contains("agent"), + "the refusal must name the sandbox it is about: {error}" ); } diff --git a/crates/alien-infra/src/sandbox/local.rs b/crates/alien-infra/src/sandbox/local.rs index 68d1ea82d..3d7cd72a4 100644 --- a/crates/alien-infra/src/sandbox/local.rs +++ b/crates/alien-infra/src/sandbox/local.rs @@ -292,7 +292,8 @@ fn session_template(sandbox: &Sandbox) -> Result alien_local::SandboxEgressMode::Allow, SandboxEgress::AllowDomains { .. } => { return Err(AlienError::new(ErrorData::CloudPlatformError { - message: "no sandbox backend restricts egress to a hostname list" + message: "a local sandbox has one network switch and no filter, so a hostname \ + list has nothing to render into; Azure matches on host pattern" .to_string(), resource_id: Some(sandbox.id.clone()), })) diff --git a/crates/alien-preflights/src/compile_time/sandbox_platform_support.rs b/crates/alien-preflights/src/compile_time/sandbox_platform_support.rs index a4c190788..7597ffa36 100644 --- a/crates/alien-preflights/src/compile_time/sandbox_platform_support.rs +++ b/crates/alien-preflights/src/compile_time/sandbox_platform_support.rs @@ -80,7 +80,9 @@ mod tests { fn sandbox(id: &str, limits: Option, egress: SandboxEgress) -> Sandbox { let builder = Sandbox::new(id.to_string()) .code(SandboxCode::Image { - image: "ubuntu:24.04".to_string(), + // A bare name, because Azure takes a catalog entry rather than a reference and + // these cases are about egress and limits rather than about the image. + image: "ubuntu".to_string(), }) .egress(egress) .session(SandboxSessionPolicy { @@ -136,8 +138,8 @@ mod tests { } } - /// No backend expresses a hostname allowlist, so the declaration is refused everywhere - /// rather than accepted and dropped. + /// Azure's egress proxy matches on host pattern; the other four filter by address or carry a + /// single switch, so the declaration is refused there rather than accepted and dropped. #[tokio::test] async fn domain_egress_rules_are_refused_where_they_cannot_be_expressed() { let stack = stack_with(sandbox( @@ -150,9 +152,9 @@ mod tests { for platform in [ Platform::Aws, - Platform::Azure, Platform::Gcp, Platform::Kubernetes, + Platform::Local, ] { let result = SandboxPlatformSupportCheck .check(&stack, platform) @@ -163,6 +165,16 @@ mod tests { "{platform} has no hostname allowlist and must refuse the declaration" ); } + + let azure = SandboxPlatformSupportCheck + .check(&stack, Platform::Azure) + .await + .expect("check runs"); + assert!( + azure.success, + "Azure creates the sandbox under host rules: {:?}", + azure.errors + ); } #[tokio::test] diff --git a/crates/alien-terraform/src/emitters/aws/sandbox.rs b/crates/alien-terraform/src/emitters/aws/sandbox.rs index 7baa61488..50dd17385 100644 --- a/crates/alien-terraform/src/emitters/aws/sandbox.rs +++ b/crates/alien-terraform/src/emitters/aws/sandbox.rs @@ -620,8 +620,9 @@ fn egress_connector_arns(sandbox: &Sandbox, label: &str) -> Expression { /// Refuses an egress mode the emitted artifact cannot deliver. /// /// `deny` is built from a connector whose security group carries no egress rule. Outbound -/// allowances are not: `allow` would depend on the network's NAT topology, and AWS has no -/// domain-filtering primitive at the connector, so `allowDomains` has nothing to render into. +/// allowances are not: AWS has no domain-filtering primitive at the connector, so `allowDomains` +/// has nothing to render into. `allow` is accepted and emits no connector at all — a MicroVM +/// without one reaches the internet. /// Emitting a template that silently ignores a declared egress policy is worse than refusing it — /// the customer would believe outbound access was configured. fn refuse_unsupported_egress(sandbox: &Sandbox) -> Result<()> { @@ -631,8 +632,8 @@ fn refuse_unsupported_egress(sandbox: &Sandbox) -> Result<()> { reason: format!( "AWS sandboxes reach the network through a VPC egress connector, which this \ module builds to deny outbound traffic; egress '{mode}' has no connector \ - configuration to render into. Declare egress: deny, or use a platform that \ - supports it" + configuration to render into. Declare egress: deny for a connector that reaches \ + nothing, or egress: allow for no connector at all" ), })) }; diff --git a/crates/alien-terraform/src/emitters/azure/sandbox.rs b/crates/alien-terraform/src/emitters/azure/sandbox.rs index 47227ac8e..a5e516bf7 100644 --- a/crates/alien-terraform/src/emitters/azure/sandbox.rs +++ b/crates/alien-terraform/src/emitters/azure/sandbox.rs @@ -1,24 +1,27 @@ //! Azure Sandbox — a named group, and nothing built at setup. //! -//! The ACA sandbox group is created by the runtime controller, idempotently by name, because a -//! group is cheap to create and pointless to hold open while no session wants one. So setup emits -//! no Azure resource here; what it owes the runtime is the three names the data plane is addressed -//! by, which the Azure client config does not carry: the group, the region that selects the -//! per-region endpoint, and the resource group the data-plane path is scoped by. +//! No sandbox controller is registered for Azure, and `create_or_update_sandbox_group` has no +//! caller, so nothing here creates the group a session lives in — it has to exist already. Setup +//! emits no Azure resource for the same reason it would not be useful to: a group is cheap to +//! create by name and pointless to hold open while no session wants one. +//! +//! What this emitter contributes is the three names the data plane is addressed by, which the +//! Azure client config does not carry: the group, the region that selects the per-region endpoint, +//! and the resource group the data-plane path is scoped by. use crate::{ emitter::{TfEmitter, TfFragment}, emitters::azure::helpers::{downcast, required_label, resource_prefix_template}, expr, }; -use alien_core::{import::EmitContext, Result, Sandbox}; +use alien_core::{import::EmitContext, Result, Sandbox, SandboxEgress}; use hcl::expr::Expression; /// Emits the Azure sandbox group's identity for the runtime to address. #[derive(Debug, Clone, Copy, Default)] pub struct AzureSandboxEmitter; -/// The group name the runtime controller creates and the data plane addresses. +/// The group name the data plane is addressed by. /// /// Derived rather than emitted as a resource: both sides compute it from the same prefix and id, /// so there is nothing to look up and nothing to keep in step. The prefix must be the resolved @@ -29,10 +32,33 @@ fn sandbox_group(ctx: &EmitContext<'_>) -> Expression { resource_prefix_template(&ctx.resource_id) } +/// The declared outbound policy, in the shape the binding carries. +/// +/// The sandbox is created with it rather than a setup resource enforcing it — Azure's proxy takes +/// the policy at create — so the declaration has to survive as far as the binding intact. +fn egress(sandbox: &Sandbox) -> Expression { + match &sandbox.egress { + SandboxEgress::Deny => expr::object([("mode", Expression::String("deny".to_string()))]), + SandboxEgress::Allow => expr::object([("mode", Expression::String("allow".to_string()))]), + SandboxEgress::AllowDomains { domains } => expr::object([ + ("mode", Expression::String("allowDomains".to_string())), + ( + "domains", + Expression::from( + domains + .iter() + .map(|domain| Expression::String(domain.clone())) + .collect::>(), + ), + ), + ]), + } +} + impl TfEmitter for AzureSandboxEmitter { fn emit(&self, _ctx: &EmitContext<'_>) -> Result { - // Deliberately empty: see the module note. A group created here would sit idle until a - // session asked for one, and the controller would have to reconcile against it anyway. + // Deliberately empty: see the module note. A group emitted here would sit idle until a + // session asked for one, and it is addressed by name rather than by reference. Ok(TfFragment::default()) } @@ -47,9 +73,10 @@ impl TfEmitter for AzureSandboxEmitter { } fn emit_binding_ref(&self, ctx: &EmitContext<'_>) -> Result> { - let _ = downcast::(ctx, Sandbox::RESOURCE_TYPE)?; + let sandbox = downcast::(ctx, Sandbox::RESOURCE_TYPE)?; let _ = required_label(ctx)?; - Ok(Some(expr::object([ + let disk_image = sandbox.azure_catalog_image()?.to_string(); + let mut fields = vec![ ("service", Expression::String("sandbox-azure".to_string())), ("sandboxGroup", sandbox_group(ctx)), // The data plane is a per-region host, so the region is what selects it rather than a @@ -60,6 +87,140 @@ impl TfEmitter for AzureSandboxEmitter { ), ("region", expr::raw("var.azure_location")), ("resourceGroup", expr::raw("var.azure_resource_group_name")), - ]))) + ("diskImage", Expression::String(disk_image)), + ("egress", egress(sandbox)), + ]; + + if let Some(seconds) = sandbox.session.idle_suspend_seconds { + fields.push(( + "idleSuspendSeconds", + Expression::Number(i64::from(seconds).into()), + )); + } + + Ok(Some(expr::object(fields))) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use alien_core::bindings::{AzureSandboxBinding, BindingValue}; + use alien_core::SandboxCode; + use alien_core::{ResourceLifecycle, SandboxSessionPolicy, Stack, StackSettings}; + use indexmap::IndexMap; + + fn binding_for(egress: SandboxEgress) -> String { + binding_with(egress, None) + } + + fn binding_with(egress: SandboxEgress, idle_suspend_seconds: Option) -> String { + let stack = Stack::new("acme".to_string()) + .add( + Sandbox::new("agents".to_string()) + .code(SandboxCode::Image { + image: "ubuntu".to_string(), + }) + .egress(egress) + .session(SandboxSessionPolicy { + max_lifetime_seconds: None, + idle_suspend_seconds, + }) + .build(), + ResourceLifecycle::Frozen, + ) + .build(); + let resource = stack.resources.get("agents").expect("the sandbox is in the stack"); + let names = IndexMap::from([("agents".to_string(), "agents".to_string())]); + let settings = StackSettings::default(); + let ctx = EmitContext { + stack: &stack, + resource, + resource_id: "agents", + platform: alien_core::Platform::Azure, + targets_kubernetes: false, + stack_settings: &settings, + names: &names, + }; + + AzureSandboxEmitter + .emit_binding_ref(&ctx) + .expect("the binding renders") + .expect("an Azure sandbox has a binding") + .to_string() + } + + /// The declared mode has to reach the binding, whole. + /// + /// Azure applies the policy at create rather than through a setup resource, so the binding is + /// the only carrier: a mode that stops here leaves every session created under the data + /// plane's own default, which is open. A hostname list fails twice over — the mode without the + /// domains denies everything, and the domains without the mode are ignored. + #[test] + fn the_binding_carries_the_declared_egress() { + // The key names are asserted, not just the values: `AzureSandboxBinding.egress` has no + // serde default, so a misspelled key here is a deserialization failure on the customer's + // cluster rather than a failure at emit. + let denied = binding_for(SandboxEgress::Deny); + assert!(denied.contains("egress = {"), "{denied}"); + assert!(denied.contains(r#"mode = "deny""#), "{denied}"); + + let listed = binding_for(SandboxEgress::AllowDomains { + domains: vec!["api.example.com".to_string()], + }); + assert!(listed.contains(r#"mode = "allowDomains""#), "{listed}"); + assert!(listed.contains("domains = ["), "{listed}"); + assert!(listed.contains(r#""api.example.com""#), "{listed}"); + + let open = binding_for(SandboxEgress::Allow); + assert!(open.contains(r#"mode = "allow""#), "{open}"); + } + + /// Every key the binding deserializes is a key the emitter writes. + /// + /// The emitter types the names by hand while the provider reads them through serde, so a + /// rename on either side would otherwise surface as a deserialization failure at runtime. + #[test] + fn the_emitted_keys_are_the_ones_the_binding_deserializes() { + let rendered = binding_with( + SandboxEgress::AllowDomains { + domains: vec!["api.example.com".to_string()], + }, + Some(900), + ); + + let binding = AzureSandboxBinding { + sandbox_group: BindingValue::Value("sbg".to_string()), + data_plane_endpoint: BindingValue::Value("https://example.invalid".to_string()), + region: BindingValue::Value("eastus".to_string()), + resource_group: BindingValue::Value("rg".to_string()), + egress: SandboxEgress::Allow, + idle_suspend_seconds: Some(900), + disk_image: BindingValue::Value("ubuntu".to_string()), + }; + let keys = serde_json::to_value(&binding).expect("the binding serializes"); + + for key in keys.as_object().expect("an object").keys() { + assert!( + rendered.contains(&format!("{key} = ")), + "the emitter never writes '{key}': {rendered}" + ); + } + } + + /// The idle-suspend policy travels the same way, and only when it was declared. + /// + /// Azure takes it at create, so a number that stops at the emitter leaves the session on the + /// service default — and an emitted zero would be a policy nobody asked for. + #[test] + fn the_binding_carries_a_declared_idle_suspend_and_nothing_otherwise() { + let declared = binding_with(SandboxEgress::Allow, Some(900)); + assert!(declared.contains("idleSuspendSeconds = 900"), "{declared}"); + + let undeclared = binding_with(SandboxEgress::Allow, None); + assert!( + !undeclared.contains("idleSuspendSeconds"), + "{undeclared}" + ); } } diff --git a/crates/alien-terraform/src/emitters/gcp/sandbox.rs b/crates/alien-terraform/src/emitters/gcp/sandbox.rs index 6ea186935..4c31601bb 100644 --- a/crates/alien-terraform/src/emitters/gcp/sandbox.rs +++ b/crates/alien-terraform/src/emitters/gcp/sandbox.rs @@ -11,9 +11,27 @@ use crate::{ emitters::gcp::helpers::{downcast, required_label}, expr, }; -use alien_core::{import::EmitContext, Result, Sandbox, SandboxEgress}; +use alien_core::{import::EmitContext, ErrorData, Result, Sandbox, SandboxEgress}; +use alien_error::AlienError; use hcl::expr::Expression; +/// Refuses an egress mode the launcher cannot deliver. +/// +/// `--allow-egress` is a switch, so a hostname list has nowhere to go and would otherwise be +/// carried as its nearest boolean — denying everything the declaration asked to permit, with +/// nothing anywhere saying so. +fn refuse_unsupported_egress(sandbox: &Sandbox) -> Result<()> { + match &sandbox.egress { + SandboxEgress::Deny | SandboxEgress::Allow => Ok(()), + SandboxEgress::AllowDomains { .. } => Err(AlienError::new(ErrorData::OperationNotSupported { + operation: format!("terraform emit sandbox '{}'", sandbox.id()), + reason: "the Cloud Run sandbox launcher takes a single egress switch, so a hostname \ + list has nothing to render into. Declare egress: deny or egress: allow" + .to_string(), + })), + } +} + /// Where Cloud Run mounts the sandbox CLI inside a launcher-enabled container. const LAUNCHER_PATH: &str = "/usr/local/gcp/bin/sandbox"; @@ -30,6 +48,7 @@ impl TfEmitter for GcpSandboxEmitter { fn emit_import_ref(&self, ctx: &EmitContext<'_>) -> Result { let _ = required_label(ctx)?; let sandbox = downcast::(ctx, Sandbox::RESOURCE_TYPE)?; + refuse_unsupported_egress(sandbox)?; Ok(expr::object([ ( "launcherPath", @@ -45,6 +64,7 @@ impl TfEmitter for GcpSandboxEmitter { fn emit_binding_ref(&self, ctx: &EmitContext<'_>) -> Result> { let sandbox = downcast::(ctx, Sandbox::RESOURCE_TYPE)?; let _ = required_label(ctx)?; + refuse_unsupported_egress(sandbox)?; Ok(Some(expr::object([ ("service", Expression::String("sandbox-gcp".to_string())), ( @@ -61,3 +81,48 @@ impl TfEmitter for GcpSandboxEmitter { ]))) } } + +#[cfg(test)] +mod tests { + use super::*; + use alien_core::{SandboxCode, SandboxSessionPolicy}; + + fn sandbox_with(egress: SandboxEgress) -> Sandbox { + Sandbox::new("agents".to_string()) + .code(SandboxCode::Image { + image: "ubuntu".to_string(), + }) + .egress(egress) + .session(SandboxSessionPolicy { + max_lifetime_seconds: None, + idle_suspend_seconds: None, + }) + .build() + } + + /// A hostname list is refused rather than carried as its nearest boolean. + /// + /// `--allow-egress` is a switch: rendering the list as `true` or `false` opens or denies + /// addresses the declaration did not say to. Neither is the declaration, so neither is emitted. + /// + /// The second gate, not the first — a customer meets `domainEgressRules` at plan time. This + /// one covers the paths that render without planning. + #[test] + fn a_hostname_allowlist_is_refused_rather_than_approximated() { + let error = refuse_unsupported_egress(&sandbox_with(SandboxEgress::AllowDomains { + domains: vec!["api.example.com".to_string()], + })) + .expect_err("a hostname list has nothing to render into on Cloud Run"); + + assert_eq!(error.code, "OPERATION_NOT_SUPPORTED", "{error}"); + assert!( + error.to_string().contains("agents"), + "the refusal has to name the sandbox: {error}" + ); + + for accepted in [SandboxEgress::Deny, SandboxEgress::Allow] { + refuse_unsupported_egress(&sandbox_with(accepted.clone())) + .unwrap_or_else(|error| panic!("{accepted:?} is a switch position: {error}")); + } + } +} diff --git a/packages/core/src/generated/schemas/sandbox.json b/packages/core/src/generated/schemas/sandbox.json index c9dacc58e..daa730a5c 100644 --- a/packages/core/src/generated/schemas/sandbox.json +++ b/packages/core/src/generated/schemas/sandbox.json @@ -1 +1 @@ -{"type":"object","description":"An isolated environment for running untrusted code, created per session at runtime.","required":["id","code","egress","session"],"properties":{"code":{"description":"Where the sandbox's root filesystem comes from","oneOf":[{"type":"object","description":"A prebuilt container image used as the sandbox root filesystem.","required":["image","type"],"properties":{"image":{"type":"string","description":"Image reference (e.g. `ubuntu:24.04`, `ghcr.io/myorg/sandbox:latest`)"},"type":{"type":"string","enum":["image"]}}},{"type":"object","description":"Source built into a sandbox image at deploy time.","required":["src","toolchain","type"],"properties":{"src":{"type":"string","description":"The source directory to build from"},"toolchain":{"description":"Toolchain configuration with type-safe options","oneOf":[{"type":"object","description":"Rust with Cargo build system","required":["binaryName","type"],"properties":{"binaryName":{"type":"string","description":"Name of the binary to build and run"},"type":{"type":"string","enum":["rust"]}}},{"type":"object","description":"TypeScript/JavaScript compiled to single executable with Bun","required":["type"],"properties":{"binaryName":{"type":["string","null"],"description":"Name of the compiled binary (defaults to package.json name if not specified)"},"type":{"type":"string","enum":["typescript"]}}},{"type":"object","description":"Docker build from Dockerfile","required":["type"],"properties":{"buildArgs":{"type":["object","null"],"description":"Build arguments for docker build","additionalProperties":{"type":"string"},"propertyNames":{"type":"string"}},"dockerfile":{"type":["string","null"],"description":"Dockerfile path relative to src (default: \"Dockerfile\")"},"target":{"type":["string","null"],"description":"Multi-stage build target"},"type":{"type":"string","enum":["docker"]}}}],"x-readme-ref-name":"ToolchainConfig"},"type":{"type":"string","enum":["source"]}}}],"x-readme-ref-name":"SandboxCode"},"egress":{"description":"Outbound network policy","oneOf":[{"type":"object","description":"No outbound network access.\n\nRouted traffic only. Link-local is not outbound and no backend's egress control reaches\nit, so this is not a boundary against instance metadata.","required":["mode"],"properties":{"mode":{"type":"string","enum":["deny"]}}},{"type":"object","description":"Unrestricted outbound access to the public internet, and none to private ranges or the\ndeployment's own network.\n\nLink-local carries the same exception as `Deny`.","required":["mode"],"properties":{"mode":{"type":"string","enum":["allow"]}}},{"type":"object","description":"Outbound access only to the listed hostnames. No backend expresses this yet.","required":["domains","mode"],"properties":{"domains":{"type":"array","items":{"type":"string"},"description":"Hostnames the sandbox may reach"},"mode":{"type":"string","enum":["allowDomains"]}}}],"x-readme-ref-name":"SandboxEgress"},"id":{"type":"string","description":"Identifier for the sandbox. Must contain only alphanumeric characters, hyphens, and\nunderscores ([A-Za-z0-9-_]). Maximum 64 characters."},"limits":{"oneOf":[{"type":"null"},{"description":"Enforced resource ceilings.\n\nOptional because not every platform can enforce them, and a declaration that names none\ntakes the platform's own defaults. Naming them on a platform that cannot enforce them is\nrejected at plan time rather than silently ignored.","type":"object","required":["cpu","memory","disk"],"properties":{"cpu":{"type":"string","description":"CPU ceiling in cores or millicores (e.g. `\"1\"`, `\"500m\"`)"},"disk":{"type":"string","description":"Disk ceiling (e.g. `\"20Gi\"`)"},"maxProcesses":{"type":["integer","null"],"format":"int32","description":"Maximum number of processes, which bounds fork bombs.\n\nOptional because only a container runtime has the primitive: Kubernetes sets a pid ceiling\nper node, not per pod, and neither AWS MicroVMs nor Azure sandboxes expose one. Declaring\nit on a platform that cannot apply it is refused at plan time.","minimum":0},"memory":{"type":"string","description":"Memory ceiling (e.g. `\"2Gi\"`, `\"512Mi\"`)"}},"additionalProperties":false,"x-readme-ref-name":"SandboxLimits"}]},"previewPorts":{"type":"array","items":{"type":"integer","format":"int32","minimum":0},"description":"Ports eligible for a preview capability. A port not listed here can never be exposed,\nso an application cannot widen its own ingress at runtime."},"session":{"description":"Session lifetime and idle behaviour","type":"object","properties":{"idleSuspendSeconds":{"type":["integer","null"],"format":"int32","description":"Idle period after which the session is suspended, where the platform supports it","minimum":0},"maxLifetimeSeconds":{"type":["integer","null"],"format":"int32","description":"Wall-clock ceiling on a single session, after which the platform terminates it.\n\nOptional because not every backend has the primitive: Kubernetes has\n`activeDeadlineSeconds` and AWS `maximumDurationInSeconds`, while neither Azure nor Local\nexpose one, so declaring a ceiling there is refused at plan time rather than accepted and\nnever applied. AWS caps it at 8 hours.","minimum":0}},"additionalProperties":false,"x-readme-ref-name":"SandboxSessionPolicy"}},"additionalProperties":false,"x-readme-ref-name":"Sandbox"} \ No newline at end of file +{"type":"object","description":"An isolated environment for running untrusted code, created per session at runtime.","required":["id","code","egress","session"],"properties":{"code":{"description":"Where the sandbox's root filesystem comes from","oneOf":[{"type":"object","description":"A prebuilt container image used as the sandbox root filesystem.","required":["image","type"],"properties":{"image":{"type":"string","description":"Image reference (e.g. `ubuntu:24.04`, `ghcr.io/myorg/sandbox:latest`).\n\nTwo backends narrow it in opposite directions: AWS wants an `s3://` bundle, Azure a\nbare catalog name such as `ubuntu`. Each refuses the other's shape while planning."},"type":{"type":"string","enum":["image"]}}},{"type":"object","description":"Source built into a sandbox image at deploy time.","required":["src","toolchain","type"],"properties":{"src":{"type":"string","description":"The source directory to build from"},"toolchain":{"description":"Toolchain configuration with type-safe options","oneOf":[{"type":"object","description":"Rust with Cargo build system","required":["binaryName","type"],"properties":{"binaryName":{"type":"string","description":"Name of the binary to build and run"},"type":{"type":"string","enum":["rust"]}}},{"type":"object","description":"TypeScript/JavaScript compiled to single executable with Bun","required":["type"],"properties":{"binaryName":{"type":["string","null"],"description":"Name of the compiled binary (defaults to package.json name if not specified)"},"type":{"type":"string","enum":["typescript"]}}},{"type":"object","description":"Docker build from Dockerfile","required":["type"],"properties":{"buildArgs":{"type":["object","null"],"description":"Build arguments for docker build","additionalProperties":{"type":"string"},"propertyNames":{"type":"string"}},"dockerfile":{"type":["string","null"],"description":"Dockerfile path relative to src (default: \"Dockerfile\")"},"target":{"type":["string","null"],"description":"Multi-stage build target"},"type":{"type":"string","enum":["docker"]}}}],"x-readme-ref-name":"ToolchainConfig"},"type":{"type":"string","enum":["source"]}}}],"x-readme-ref-name":"SandboxCode"},"egress":{"description":"Outbound network policy","oneOf":[{"type":"object","description":"No outbound network access.\n\nRouted traffic only. Link-local is not outbound and no backend's egress control reaches\nit, so this is not a boundary against instance metadata.","required":["mode"],"properties":{"mode":{"type":"string","enum":["deny"]}}},{"type":"object","description":"Unrestricted outbound access to the public internet, and none to private ranges or the\ndeployment's own network.\n\nLink-local carries the same exception as `Deny`. AWS and Kubernetes deliver both halves.\nAzure and GCP deliver the first only: one matches host patterns and the other is a single\nswitch, so neither can name an address range to exclude.","required":["mode"],"properties":{"mode":{"type":"string","enum":["allow"]}}},{"type":"object","description":"Outbound access only to the listed hostnames.\n\nAzure alone expresses it: its egress proxy matches on host pattern. The others filter by\nCIDR or carry a single switch, and both would approximate the list rather than keep it.","required":["domains","mode"],"properties":{"domains":{"type":"array","items":{"type":"string"},"description":"Hostnames the sandbox may reach"},"mode":{"type":"string","enum":["allowDomains"]}}}],"x-readme-ref-name":"SandboxEgress"},"id":{"type":"string","description":"Identifier for the sandbox. Must contain only alphanumeric characters, hyphens, and\nunderscores ([A-Za-z0-9-_]). Maximum 64 characters."},"limits":{"oneOf":[{"type":"null"},{"description":"Enforced resource ceilings.\n\nOptional because not every platform can enforce them, and a declaration that names none\ntakes the platform's own defaults. Naming them on a platform that cannot enforce them is\nrejected at plan time rather than silently ignored.","type":"object","required":["cpu","memory","disk"],"properties":{"cpu":{"type":"string","description":"CPU ceiling in cores or millicores (e.g. `\"1\"`, `\"500m\"`)"},"disk":{"type":"string","description":"Disk ceiling (e.g. `\"20Gi\"`)"},"maxProcesses":{"type":["integer","null"],"format":"int32","description":"Maximum number of processes, which bounds fork bombs.\n\nOptional because only a container runtime has the primitive: Kubernetes sets a pid ceiling\nper node, not per pod, and neither AWS MicroVMs nor Azure sandboxes expose one. Declaring\nit on a platform that cannot apply it is refused at plan time.","minimum":0},"memory":{"type":"string","description":"Memory ceiling (e.g. `\"2Gi\"`, `\"512Mi\"`)"}},"additionalProperties":false,"x-readme-ref-name":"SandboxLimits"}]},"previewPorts":{"type":"array","items":{"type":"integer","format":"int32","minimum":0},"description":"Ports eligible for a preview capability. A port not listed here can never be exposed,\nso an application cannot widen its own ingress at runtime."},"session":{"description":"Session lifetime and idle behaviour","type":"object","properties":{"idleSuspendSeconds":{"type":["integer","null"],"format":"int32","description":"Idle period after which the session is suspended, where the platform supports it","minimum":0},"maxLifetimeSeconds":{"type":["integer","null"],"format":"int32","description":"Wall-clock ceiling on a single session, after which the platform terminates it.\n\nOptional because not every backend has the primitive: Kubernetes has\n`activeDeadlineSeconds` and AWS `maximumDurationInSeconds`, while neither Azure nor Local\nexpose one, so declaring a ceiling there is refused at plan time rather than accepted and\nnever applied. AWS caps it at 8 hours.","minimum":0}},"additionalProperties":false,"x-readme-ref-name":"SandboxSessionPolicy"}},"additionalProperties":false,"x-readme-ref-name":"Sandbox"} \ No newline at end of file diff --git a/packages/core/src/generated/schemas/sandboxCapabilities.json b/packages/core/src/generated/schemas/sandboxCapabilities.json index 98a055f00..9411a97c4 100644 --- a/packages/core/src/generated/schemas/sandboxCapabilities.json +++ b/packages/core/src/generated/schemas/sandboxCapabilities.json @@ -1 +1 @@ -{"type":"object","description":"What a platform's sandbox backend can actually do.\n\nPublished so portable code can branch before calling rather than discovering a gap through\nan error. Every field here corresponds to a capability that at least one platform lacks;\ncreate, exec and terminate are the guaranteed floor and are therefore not listed.","required":["files","reconnect","preview","suspendResume","snapshot","domainEgressRules","egressDeny","enforcedLimits","processLimit","sessionLifetime","supervisorPidNamespace"],"properties":{"domainEgressRules":{"type":"boolean","description":"Egress can be restricted to a hostname allowlist"},"egressDeny":{"type":"boolean","description":"Whether a declared `deny` is actually enforced, rather than accepted and dropped"},"enforcedLimits":{"type":"boolean","description":"The platform enforces the declared cpu, memory and disk ceilings"},"files":{"type":"boolean","description":"Files can be moved in and out of a session\n\nEvery backend but Azure, whose binding implements no transfer."},"preview":{"type":"boolean","description":"An authenticated, port-scoped capability to reach a service inside the sandbox"},"processLimit":{"type":"boolean","description":"The platform can cap how many processes a session runs"},"reconnect":{"type":"boolean","description":"A later call can reach a session created by an earlier one"},"sessionLifetime":{"type":"boolean","description":"The platform terminates a session at a declared wall-clock deadline"},"snapshot":{"type":"boolean","description":"A session's full state can be captured and used to create another"},"supervisorPidNamespace":{"type":"boolean","description":"A command runs in its own PID namespace and cannot see or signal the agent's processes.\n\nOnly where an agent runs as root. Creating the namespace needs `CAP_SYS_ADMIN`, and the\nKubernetes sandbox pod drops every capability — which is also what denies `ptrace` by\nconstruction, so granting it there would remove a lock to add one."},"suspendResume":{"type":"boolean","description":"Session state can be suspended and resumed"}},"additionalProperties":false,"x-readme-ref-name":"SandboxCapabilities"} \ No newline at end of file +{"type":"object","description":"What a platform's sandbox backend can actually do.\n\nPublished so portable code can branch before calling rather than discovering a gap through\nan error. Every field here corresponds to a capability that at least one platform lacks;\ncreate, exec and terminate are the guaranteed floor and are therefore not listed.","required":["files","reconnect","preview","suspendResume","snapshot","domainEgressRules","egressDeny","enforcedLimits","processLimit","sessionLifetime","supervisorPidNamespace"],"properties":{"domainEgressRules":{"type":"boolean","description":"Egress can be restricted to a hostname allowlist"},"egressDeny":{"type":"boolean","description":"Whether a declared `deny` is actually enforced, rather than accepted and dropped"},"enforcedLimits":{"type":"boolean","description":"The platform enforces the declared cpu, memory and disk ceilings"},"files":{"type":"boolean","description":"Files can be moved in and out of a session"},"preview":{"type":"boolean","description":"An authenticated, port-scoped capability to reach a service inside the sandbox"},"processLimit":{"type":"boolean","description":"The platform can cap how many processes a session runs"},"reconnect":{"type":"boolean","description":"A later call can reach a session created by an earlier one"},"sessionLifetime":{"type":"boolean","description":"The platform terminates a session at a declared wall-clock deadline"},"snapshot":{"type":"boolean","description":"A session's full state can be captured and used to create another"},"supervisorPidNamespace":{"type":"boolean","description":"A command runs in its own PID namespace and cannot see or signal the agent's processes.\n\nOnly where an agent runs as root. Creating the namespace needs `CAP_SYS_ADMIN`, and the\nKubernetes sandbox pod drops every capability — which is also what denies `ptrace` by\nconstruction, so granting it there would remove a lock to add one."},"suspendResume":{"type":"boolean","description":"Session state can be suspended and resumed"}},"additionalProperties":false,"x-readme-ref-name":"SandboxCapabilities"} \ No newline at end of file diff --git a/packages/core/src/generated/schemas/sandboxCode.json b/packages/core/src/generated/schemas/sandboxCode.json index a91988b5c..28f575af4 100644 --- a/packages/core/src/generated/schemas/sandboxCode.json +++ b/packages/core/src/generated/schemas/sandboxCode.json @@ -1 +1 @@ -{"oneOf":[{"type":"object","description":"A prebuilt container image used as the sandbox root filesystem.","required":["image","type"],"properties":{"image":{"type":"string","description":"Image reference (e.g. `ubuntu:24.04`, `ghcr.io/myorg/sandbox:latest`)"},"type":{"type":"string","enum":["image"]}}},{"type":"object","description":"Source built into a sandbox image at deploy time.","required":["src","toolchain","type"],"properties":{"src":{"type":"string","description":"The source directory to build from"},"toolchain":{"description":"Toolchain configuration with type-safe options","oneOf":[{"type":"object","description":"Rust with Cargo build system","required":["binaryName","type"],"properties":{"binaryName":{"type":"string","description":"Name of the binary to build and run"},"type":{"type":"string","enum":["rust"]}}},{"type":"object","description":"TypeScript/JavaScript compiled to single executable with Bun","required":["type"],"properties":{"binaryName":{"type":["string","null"],"description":"Name of the compiled binary (defaults to package.json name if not specified)"},"type":{"type":"string","enum":["typescript"]}}},{"type":"object","description":"Docker build from Dockerfile","required":["type"],"properties":{"buildArgs":{"type":["object","null"],"description":"Build arguments for docker build","additionalProperties":{"type":"string"},"propertyNames":{"type":"string"}},"dockerfile":{"type":["string","null"],"description":"Dockerfile path relative to src (default: \"Dockerfile\")"},"target":{"type":["string","null"],"description":"Multi-stage build target"},"type":{"type":"string","enum":["docker"]}}}],"x-readme-ref-name":"ToolchainConfig"},"type":{"type":"string","enum":["source"]}}}],"description":"Specifies where the sandbox's root filesystem comes from.","x-readme-ref-name":"SandboxCode"} \ No newline at end of file +{"oneOf":[{"type":"object","description":"A prebuilt container image used as the sandbox root filesystem.","required":["image","type"],"properties":{"image":{"type":"string","description":"Image reference (e.g. `ubuntu:24.04`, `ghcr.io/myorg/sandbox:latest`).\n\nTwo backends narrow it in opposite directions: AWS wants an `s3://` bundle, Azure a\nbare catalog name such as `ubuntu`. Each refuses the other's shape while planning."},"type":{"type":"string","enum":["image"]}}},{"type":"object","description":"Source built into a sandbox image at deploy time.","required":["src","toolchain","type"],"properties":{"src":{"type":"string","description":"The source directory to build from"},"toolchain":{"description":"Toolchain configuration with type-safe options","oneOf":[{"type":"object","description":"Rust with Cargo build system","required":["binaryName","type"],"properties":{"binaryName":{"type":"string","description":"Name of the binary to build and run"},"type":{"type":"string","enum":["rust"]}}},{"type":"object","description":"TypeScript/JavaScript compiled to single executable with Bun","required":["type"],"properties":{"binaryName":{"type":["string","null"],"description":"Name of the compiled binary (defaults to package.json name if not specified)"},"type":{"type":"string","enum":["typescript"]}}},{"type":"object","description":"Docker build from Dockerfile","required":["type"],"properties":{"buildArgs":{"type":["object","null"],"description":"Build arguments for docker build","additionalProperties":{"type":"string"},"propertyNames":{"type":"string"}},"dockerfile":{"type":["string","null"],"description":"Dockerfile path relative to src (default: \"Dockerfile\")"},"target":{"type":["string","null"],"description":"Multi-stage build target"},"type":{"type":"string","enum":["docker"]}}}],"x-readme-ref-name":"ToolchainConfig"},"type":{"type":"string","enum":["source"]}}}],"description":"Specifies where the sandbox's root filesystem comes from.","x-readme-ref-name":"SandboxCode"} \ No newline at end of file diff --git a/packages/core/src/generated/schemas/sandboxEgress.json b/packages/core/src/generated/schemas/sandboxEgress.json index 8beabf0ec..00361f928 100644 --- a/packages/core/src/generated/schemas/sandboxEgress.json +++ b/packages/core/src/generated/schemas/sandboxEgress.json @@ -1 +1 @@ -{"oneOf":[{"type":"object","description":"No outbound network access.\n\nRouted traffic only. Link-local is not outbound and no backend's egress control reaches\nit, so this is not a boundary against instance metadata.","required":["mode"],"properties":{"mode":{"type":"string","enum":["deny"]}}},{"type":"object","description":"Unrestricted outbound access to the public internet, and none to private ranges or the\ndeployment's own network.\n\nLink-local carries the same exception as `Deny`.","required":["mode"],"properties":{"mode":{"type":"string","enum":["allow"]}}},{"type":"object","description":"Outbound access only to the listed hostnames. No backend expresses this yet.","required":["domains","mode"],"properties":{"domains":{"type":"array","items":{"type":"string"},"description":"Hostnames the sandbox may reach"},"mode":{"type":"string","enum":["allowDomains"]}}}],"description":"Outbound network policy for a sandbox.","x-readme-ref-name":"SandboxEgress"} \ No newline at end of file +{"oneOf":[{"type":"object","description":"No outbound network access.\n\nRouted traffic only. Link-local is not outbound and no backend's egress control reaches\nit, so this is not a boundary against instance metadata.","required":["mode"],"properties":{"mode":{"type":"string","enum":["deny"]}}},{"type":"object","description":"Unrestricted outbound access to the public internet, and none to private ranges or the\ndeployment's own network.\n\nLink-local carries the same exception as `Deny`. AWS and Kubernetes deliver both halves.\nAzure and GCP deliver the first only: one matches host patterns and the other is a single\nswitch, so neither can name an address range to exclude.","required":["mode"],"properties":{"mode":{"type":"string","enum":["allow"]}}},{"type":"object","description":"Outbound access only to the listed hostnames.\n\nAzure alone expresses it: its egress proxy matches on host pattern. The others filter by\nCIDR or carry a single switch, and both would approximate the list rather than keep it.","required":["domains","mode"],"properties":{"domains":{"type":"array","items":{"type":"string"},"description":"Hostnames the sandbox may reach"},"mode":{"type":"string","enum":["allowDomains"]}}}],"description":"Outbound network policy for a sandbox.","x-readme-ref-name":"SandboxEgress"} \ No newline at end of file diff --git a/packages/core/src/generated/zod/sandbox-capabilities-schema.ts b/packages/core/src/generated/zod/sandbox-capabilities-schema.ts index fec2da979..35778965d 100644 --- a/packages/core/src/generated/zod/sandbox-capabilities-schema.ts +++ b/packages/core/src/generated/zod/sandbox-capabilities-schema.ts @@ -12,7 +12,7 @@ export const SandboxCapabilitiesSchema = z.object({ "domainEgressRules": z.boolean().describe("Egress can be restricted to a hostname allowlist"), "egressDeny": z.boolean().describe("Whether a declared `deny` is actually enforced, rather than accepted and dropped"), "enforcedLimits": z.boolean().describe("The platform enforces the declared cpu, memory and disk ceilings"), -"files": z.boolean().describe("Files can be moved in and out of a session\n\nEvery backend but Azure, whose binding implements no transfer."), +"files": z.boolean().describe("Files can be moved in and out of a session"), "preview": z.boolean().describe("An authenticated, port-scoped capability to reach a service inside the sandbox"), "processLimit": z.boolean().describe("The platform can cap how many processes a session runs"), "reconnect": z.boolean().describe("A later call can reach a session created by an earlier one"), diff --git a/packages/core/src/generated/zod/sandbox-code-schema.ts b/packages/core/src/generated/zod/sandbox-code-schema.ts index 5a9743b23..e24fef1be 100644 --- a/packages/core/src/generated/zod/sandbox-code-schema.ts +++ b/packages/core/src/generated/zod/sandbox-code-schema.ts @@ -10,7 +10,7 @@ import { ToolchainConfigSchema } from "./toolchain-config-schema.js"; * @description Specifies where the sandbox\'s root filesystem comes from. */ export const SandboxCodeSchema = z.union([z.object({ - "image": z.string().describe("Image reference (e.g. `ubuntu:24.04`, `ghcr.io/myorg/sandbox:latest`)"), + "image": z.string().describe("Image reference (e.g. `ubuntu:24.04`, `ghcr.io/myorg/sandbox:latest`).\n\nTwo backends narrow it in opposite directions: AWS wants an `s3://` bundle, Azure a\nbare catalog name such as `ubuntu`. Each refuses the other's shape while planning."), "type": z.enum(["image"]) }), z.object({ "src": z.string().describe("The source directory to build from"), diff --git a/packages/core/src/sandbox.ts b/packages/core/src/sandbox.ts index 05a2945a9..848045031 100644 --- a/packages/core/src/sandbox.ts +++ b/packages/core/src/sandbox.ts @@ -33,8 +33,8 @@ export { SandboxSchema as SandboxConfigSchema } from "./generated/index.js" * * Capabilities are not uniform. Call `capabilities()` on the binding and branch, or handle the * typed error — an unsupported capability never silently succeeds. Notably GCP cannot - * reconnect to a session (its session id is scoped to one Cloud Run instance), the Azure - * binding implements no file transfer, and no binding renders a hostname egress allowlist. + * reconnect to a session (its session id is scoped to one Cloud Run instance), only Azure + * restricts egress to a hostname allowlist, and no platform can snapshot a session. * * Limits are enforced ceilings, not scheduling hints, and are validated when the stack is * planned. A platform that cannot enforce them rejects the sandbox rather than ignoring them.