From 9c41b0eecd8e3031e72c67966b486634f6ca5466 Mon Sep 17 00:00:00 2001 From: Itamar Zand <133867530+ItamarZand88@users.noreply.github.com> Date: Fri, 21 Aug 2026 23:02:40 +0300 Subject: [PATCH 01/29] fix(sandbox): send the argv the Cloud Run launcher actually accepts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `create` sent `run --id `. There is no `--id` flag on `run` — the id is positional — and without `--detach` the launcher stays attached until the control deadline kills it. Measured against a live launcher: it answers `unknown flag: --id`, exits 0, and leaves a session nothing can reach. So the call reported success and handed back an id that addressed nothing. Both env refusals go too. `--env` is accepted on `run` and on `exec`, and a sandbox inherits nothing from the container, so refusing it denied the only way to get a variable in. The test fake accepted any argv, which is how this passed review: three existing tests were green against the broken form. It now rejects unknown verbs and flags, and reverting the argv fails five tests. `fixtures/gcp-sandbox-cli-help.txt` records the launcher's real surface — eight verbs where the published reference lists six. --- .../sandbox/fixtures/gcp-sandbox-cli-help.txt | 207 ++++++++++++++++++ .../src/providers/sandbox/gcp.rs | 148 +++++++++---- 2 files changed, 312 insertions(+), 43 deletions(-) create mode 100644 crates/alien-bindings/src/providers/sandbox/fixtures/gcp-sandbox-cli-help.txt 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..4ade2ab3f 100644 --- a/crates/alien-bindings/src/providers/sandbox/gcp.rs +++ b/crates/alien-bindings/src/providers/sandbox/gcp.rs @@ -168,22 +168,29 @@ 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()]; + // 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()); } @@ -236,23 +243,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()) @@ -401,15 +402,42 @@ mod tests { use super::*; 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_launcher`'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 +577,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. + if let Ok(mut frames) = sandbox .run_command( "s1", RunCommandRequest { @@ -580,21 +609,54 @@ mod tests { }, ) .await - else { - panic!("a command environment cannot be honoured here"); - }; - assert_eq!(on_command.code, "OPERATION_NOT_SUPPORTED"); + { + use futures::StreamExt; + 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. Both were + /// wrong here, and neither could be caught by a fake that accepted any argv. + #[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 From b901fa48dde420d9f47dc097c1fb0cdc3c0dba86 Mon Sep 17 00:00:00 2001 From: Itamar Zand <133867530+ItamarZand88@users.noreply.github.com> Date: Fri, 21 Aug 2026 23:16:22 +0300 Subject: [PATCH 02/29] fix(sandbox): send Azure the image the stack declared MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `.code({image})` was accepted on Azure and then dropped: the provider was constructed with a literal `"ubuntu"` and the binding had no field to carry anything else. Every session ran a stock image whatever the declaration said, and nothing failed, because a sandbox on the wrong image still starts. It is the one Azure gap with no typed error and no capability bit behind it. The binding now carries `diskImage` and the emitter fills it from `code`. A registry reference is refused at plan time rather than reinterpreted, matching the AWS emitter: the create body names a public catalog image, so `ghcr.io/org/x:tag` has nowhere to go and saying so beats substituting. Ceilings stay the service defaults, now named rather than inline. `.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. Reverting the plumbing fails the new provider-level test — the seam that was wrong is the one under assertion, not just the provider it feeds. --- crates/alien-bindings/src/provider.rs | 69 +++++++++++++++++-- .../src/providers/sandbox/azure.rs | 49 +++++++++++-- crates/alien-core/src/bindings/sandbox.rs | 11 ++- .../src/emitters/azure/sandbox.rs | 35 +++++++++- 4 files changed, 151 insertions(+), 13 deletions(-) diff --git a/crates/alien-bindings/src/provider.rs b/crates/alien-bindings/src/provider.rs index 1b1f7eb60..a894f8df3 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,20 @@ 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, + DEFAULT_AZURE_CPU.to_string(), + DEFAULT_AZURE_MEMORY.to_string(), )); Ok(sandbox) } @@ -2216,6 +2227,54 @@ mod tests { ); } + /// The image in the binding has to be the image the provider uses. + /// + /// This asserts the seam the previous code got wrong: the value was read from nowhere and a + /// literal was passed instead, so every session ran a stock image whatever the stack declared + /// — and nothing failed, because a sandbox on the wrong image still starts. + #[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"}"# + .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..5e64ce50f 100644 --- a/crates/alien-bindings/src/providers/sandbox/azure.rs +++ b/crates/alien-bindings/src/providers/sandbox/azure.rs @@ -25,8 +25,8 @@ use alien_error::AlienError; 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, /// Session ceilings, in the data plane's own units. cpu: String, memory: String, @@ -37,19 +37,25 @@ impl AzureSandbox { pub fn new( client: std::sync::Arc, sandbox_group: String, - disk: String, + disk_image: String, cpu: String, memory: String, ) -> Self { Self { client, sandbox_group, - disk, + disk_image, 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. + pub(crate) fn disk_image(&self) -> &str { + &self.disk_image + } + fn unsupported(&self, capability: &str) -> AlienError { AlienError::new(ErrorData::OperationNotSupported { operation: capability.to_string(), @@ -76,7 +82,7 @@ impl Sandbox for AzureSandbox { async fn create(&self, request: CreateSessionRequest) -> Result { let sandbox = self .client - .create_sandbox(&self.sandbox_group, &self.disk, &self.cpu, &self.memory) + .create_sandbox(&self.sandbox_group, &self.disk_image, &self.cpu, &self.memory) .await .map_err(|error| Self::failed("sandbox.create", error))?; @@ -391,6 +397,39 @@ mod tests { ) } + /// 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(|_, disk_image, _, _| disk_image == "my-toolchain") + .times(1) + .returning(|_, _, _, _| { + Ok(alien_azure_clients::azure::sandbox_data_plane::Sandbox { + id: "s1".to_string(), + status: Some("Running".to_string()), + }) + }); + + let sandbox = AzureSandbox::new( + std::sync::Arc::new(client), + "grp".to_string(), + "my-toolchain".to_string(), + "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. diff --git a/crates/alien-core/src/bindings/sandbox.rs b/crates/alien-core/src/bindings/sandbox.rs index dc21e0ac2..6d76a51cd 100644 --- a/crates/alien-core/src/bindings/sandbox.rs +++ b/crates/alien-core/src/bindings/sandbox.rs @@ -91,6 +91,12 @@ 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, + /// 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 +174,14 @@ impl SandboxBinding { data_plane_endpoint: impl Into>, region: impl Into>, resource_group: impl Into>, + disk_image: impl Into>, ) -> Self { Self::Azure(AzureSandboxBinding { sandbox_group: sandbox_group.into(), data_plane_endpoint: data_plane_endpoint.into(), region: region.into(), resource_group: resource_group.into(), + disk_image: disk_image.into(), }) } @@ -239,6 +247,7 @@ mod tests { "https://management.swedencentral.azuredevcompute.io", "swedencentral", "rg", + "ubuntu", ), SandboxBinding::gcp("/usr/local/gcp/bin/sandbox", false), SandboxBinding::kubernetes( @@ -267,7 +276,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"), SandboxBinding::gcp("p", true), SandboxBinding::kubernetes("n", "gvisor", "s", "http://op:8080", "k", "/t"), SandboxBinding::local("u", "k", "t"), diff --git a/crates/alien-terraform/src/emitters/azure/sandbox.rs b/crates/alien-terraform/src/emitters/azure/sandbox.rs index 47227ac8e..90b43988c 100644 --- a/crates/alien-terraform/src/emitters/azure/sandbox.rs +++ b/crates/alien-terraform/src/emitters/azure/sandbox.rs @@ -11,7 +11,8 @@ use crate::{ emitters::azure::helpers::{downcast, required_label, resource_prefix_template}, expr, }; -use alien_core::{import::EmitContext, Result, Sandbox}; +use alien_core::{import::EmitContext, ErrorData, Result, Sandbox, SandboxCode}; +use alien_error::AlienError; use hcl::expr::Expression; /// Emits the Azure sandbox group's identity for the runtime to address. @@ -29,6 +30,34 @@ fn sandbox_group(ctx: &EmitContext<'_>) -> Expression { resource_prefix_template(&ctx.resource_id) } +/// The catalog image name a declaration asks for, or a refusal. +/// +/// The create body names a public catalog image, so a registry reference has nowhere to go. +/// Refusing at plan time follows the AWS emitter: a reference the backend cannot honour is +/// rejected rather than quietly replaced, which is what happened before this existed — every +/// Azure session ran a stock image whatever the declaration said, with no error anywhere. +fn catalog_disk_image(sandbox: &Sandbox) -> Result { + let unsupported = |reason: String| { + AlienError::new(ErrorData::OperationNotSupported { + operation: format!("terraform emit sandbox '{}'", sandbox.id()), + reason, + }) + }; + + match &sandbox.code { + SandboxCode::Image { image } if image.contains('/') => Err(unsupported(format!( + "Azure creates a sandbox from a public catalog disk image, so code.image must be a \ + catalog name such as 'ubuntu', not the registry reference '{image}'" + ))), + SandboxCode::Image { image } => Ok(image.clone()), + SandboxCode::Source { .. } => Err(unsupported( + "Azure creates a sandbox from a prebuilt catalog disk image and cannot build one \ + from source" + .to_string(), + )), + } +} + 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 @@ -47,8 +76,9 @@ 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)?; + let disk_image = catalog_disk_image(sandbox)?; Ok(Some(expr::object([ ("service", Expression::String("sandbox-azure".to_string())), ("sandboxGroup", sandbox_group(ctx)), @@ -60,6 +90,7 @@ impl TfEmitter for AzureSandboxEmitter { ), ("region", expr::raw("var.azure_location")), ("resourceGroup", expr::raw("var.azure_resource_group_name")), + ("diskImage", Expression::String(disk_image)), ]))) } } From 91c75650aab8acfa395a8f3a18c506ffb2da9662 Mon Sep 17 00:00:00 2001 From: Itamar Zand <133867530+ItamarZand88@users.noreply.github.com> Date: Fri, 21 Aug 2026 23:50:31 +0300 Subject: [PATCH 03/29] fix(sandbox): take the data-plane audience from the package, not the host MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The scope was `management.azuredevcompute.io/.default` — the endpoint's own host, which is the natural guess and had no citation. The SDK this module's header already names as the source of its contract pins the audience for this endpoint and api-version as `dynamicsessions.io/.default`. Both audiences mint a token against our tenant, so a wrong one fails at the data plane rather than at the token endpoint, where it looks like a missing `SandboxGroup Data Owner` assignment. Which of the two the data plane accepts cannot be settled without a provisioned sandbox group: the endpoint answers 404 to an unauthenticated request in our region, so nothing short of a real group distinguishes them. The test that pinned the old value was written from the same guess rather than from the package, so it pinned the guess. --- .../src/azure/sandbox_data_plane.rs | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) 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..f92c3b2a6 100644 --- a/crates/alien-azure-clients/src/azure/sandbox_data_plane.rs +++ b/crates/alien-azure-clients/src/azure/sandbox_data_plane.rs @@ -23,9 +23,12 @@ 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"; /// A sandbox as the data plane reports it. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] @@ -282,7 +285,7 @@ mod tests { #[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 From c4f53c8d34046f17270dec7a0434485ef0a0e503 Mon Sep 17 00:00:00 2001 From: Itamar Zand <133867530+ItamarZand88@users.noreply.github.com> Date: Sat, 22 Aug 2026 00:06:23 +0300 Subject: [PATCH 04/29] feat(sandbox): move files in and out of an Azure sandbox MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Azure was the one backend that refused `readFile`, `writeFiles` and `mkdir`, so portable file code had to branch on the capability. The data plane has had the verbs the whole time — `GET`/`PUT {sandbox}/files` and `POST {sandbox}/files/mkdir` — and `write` takes `createDirs`, which is what gives Azure the cross-backend rule that a write creates its parents. Three things had to come first. The Azure request builder carried a `String` body, which cannot hold a file; it now carries bytes. Every failure was one `OperationNotSupported`, so a missing file and an unreachable data plane were indistinguishable; they now split into a refusal, which is not retryable, and an unknown outcome, which is retryable for a file operation and never for a command. And a body is now truncated before it is echoed into an error, so a 32 MiB upload cannot become a 32 MiB log line. Paths are checked before they leave the process: relative only, no `..`, no empty components, no trailing slash, on all three operations. Whether the server confines a path is undocumented, so the code says this is our rule and not a guarantee. Transfers are capped at 32 MiB in both directions, the number the agent-backed backends already enforce, with the read bounded as it arrives. `files: true` came last, after the three methods worked. The wire tests run against a server the test controls and each one fails against a plausible wrong version: drop `createDirs`, move the mkdir path into the query, drop either ceiling, or drop the path check, and a test goes red. Azure's propagation-delay 400s arrive as `RemoteResourceConflict`, which the client marks transient, so that variant stays out of the refusal set — a refusal tells the caller never to retry. --- .../alien-azure-clients/src/azure/common.rs | 28 +- .../src/azure/sandbox_data_plane.rs | 284 +++++++++++++++- .../src/providers/sandbox/azure.rs | 310 +++++++++++++++++- crates/alien-core/src/resources/sandbox.rs | 8 +- 4 files changed, 601 insertions(+), 29 deletions(-) diff --git a/crates/alien-azure-clients/src/azure/common.rs b/crates/alien-azure-clients/src/azure/common.rs index 96dde1db1..bf8e7e07c 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. @@ -212,7 +212,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 { @@ -270,7 +270,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 { @@ -338,7 +338,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 +489,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 +606,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 +626,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 +646,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 f92c3b2a6..90307617c 100644 --- a/crates/alien-azure-clients/src/azure/sandbox_data_plane.rs +++ b/crates/alien-azure-clients/src/azure/sandbox_data_plane.rs @@ -30,6 +30,17 @@ pub const API_VERSION: &str = "2026-02-01-preview"; /// 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; + /// A sandbox as the data plane reports it. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] @@ -80,6 +91,21 @@ 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<()>; } /// The `executeShellCommand` body, which is `command` plus an optional `workingDirectory` and @@ -108,7 +134,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( @@ -274,11 +303,139 @@ impl SandboxDataPlaneApi for AzureSandboxDataPlaneClient { .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?; + self.base.execute_request(signed, "WriteFile", sandbox_id).await?; + Ok(()) + } + + 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?; + 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), + ) + } /// 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. @@ -333,4 +490,129 @@ 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 server = MockServer::start_async().await; + let client = client_against(&server); + let bytes = BINARY.to_vec(); + + 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}"); + } } diff --git a/crates/alien-bindings/src/providers/sandbox/azure.rs b/crates/alien-bindings/src/providers/sandbox/azure.rs index 5e64ce50f..09f1488aa 100644 --- a/crates/alien-bindings/src/providers/sandbox/azure.rs +++ b/crates/alien-bindings/src/providers/sandbox/azure.rs @@ -18,7 +18,7 @@ use crate::traits::{ use alien_azure_clients::azure::sandbox_data_plane::SandboxDataPlaneApi; use alien_client_core::ErrorData as ClientErrorData; use alien_core::{Platform, SandboxCapabilities}; -use alien_error::AlienError; +use alien_error::{AlienError, ContextError}; /// A Sandbox backed by the Azure ADC data plane. #[derive(Debug)] @@ -52,6 +52,7 @@ impl AzureSandbox { /// 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 } @@ -63,10 +64,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 { + return error.context(ErrorData::SandboxCommandFailed { + failure: "outcomeUnknown".to_string(), + reason: format!( + "{operation} did not complete against the Azure sandbox data plane, so \ + whether the command ran 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(), }) } } @@ -206,20 +231,37 @@ 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")) + async fn read_file(&self, session_id: &str, path: &str) -> Result> { + 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<()> { + // 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 { + checked_path("sandbox.writeFiles", &path)?; + + 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<()> { + 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 { @@ -294,7 +336,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 { @@ -351,6 +393,67 @@ fn bounded_shell(command: &[String], deadline: std::time::Duration) -> String { ) } +/// Refuses a caller's path before it reaches the data plane. +/// +/// Whether the server bounds a path to a root is undocumented and unmeasured, so this is the only +/// confinement there is, and it is a client-side rule rather than a guarantee. Relative only: +/// Azure exposes no session root to rewrite an absolute path against, so accepting one would hand +/// the caller the sandbox's whole filesystem instead of its own directory. +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 '/'"); + } + if path.is_empty() { + return refused("is empty"); + } + if path.starts_with('/') { + return refused("must be relative to the sandbox's own directory"); + } + if path.contains('\0') { + return refused("contains a null byte"); + } + if path.split('/').any(|part| part == ".." || part.is_empty()) { + return refused("must not traverse"); + } + + Ok(()) +} + +/// The one operation a repeat could run twice. +const RUN_COMMAND: &str = "sandbox.runCommand"; + +/// 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` @@ -539,6 +642,34 @@ mod tests { #[async_trait] impl SandboxDataPlaneApi for ScriptedExec { + 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, @@ -747,4 +878,153 @@ mod tests { "{wrapped}" ); } + + /// 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", "/etc/shadow", "", "work/", "a//b", "a/../../b"] { + let error = sandbox + .read_file("s1", path) + .await + .expect_err("'{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("'{path}' must be refused on write too"); + sandbox + .mkdir("s1", path) + .await + .expect_err("'{path}' must be refused on mkdir too"); + } + + // The same shapes, accepted: a rule that refuses everything would pass the loop above. + let mut client = MockSandboxDataPlaneApi::new(); + client + .expect_read_file() + .times(2) + .returning(|_, _, _| Ok(Vec::new())); + let sandbox = sandbox_with(client); + for path in ["app.py", "src/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"); + } + + /// 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(); + 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(); + 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}" + ); + } } diff --git a/crates/alien-core/src/resources/sandbox.rs b/crates/alien-core/src/resources/sandbox.rs index 3f693c2d7..0850deb15 100644 --- a/crates/alien-core/src/resources/sandbox.rs +++ b/crates/alien-core/src/resources/sandbox.rs @@ -168,8 +168,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, @@ -230,7 +228,7 @@ impl SandboxCapabilities { // 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, preview: false, suspend_resume: false, @@ -862,8 +860,8 @@ 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"); + assert!(azure.files, "every backend moves files"); + assert!(gcp.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); From 95b7e358bded4f4804b574109cf401a80b7d20b6 Mon Sep 17 00:00:00 2001 From: Itamar Zand <133867530+ItamarZand88@users.noreply.github.com> Date: Sat, 22 Aug 2026 00:21:20 +0300 Subject: [PATCH 05/29] fix(sandbox): report an Azure session's real state, and send its variables MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two silent failures in the same create path. The data plane names the lifecycle field `state`; this client read `status`, so every response deserialized to nothing and the provider's fallback arm called that `Running`. A sandbox still being created, stopping, or being deleted all came back as ready to run commands. The mapping now covers the seven states the data plane reports and refuses one it does not recognise — every default here is a lie a caller acts on. The create body never carried `environment`, and a sandbox inherits nothing from its group, so a declared variable simply did not exist inside the session. The data plane accepts the body either way, which is why nothing failed. `create_sandbox` takes a struct now: the create body keeps gaining fields that decide what the sandbox can do, and each one added positionally is one a caller can pass in the wrong slot. --- .../src/azure/sandbox_data_plane.rs | 97 +++++++++--- .../src/providers/sandbox/azure.rs | 143 ++++++++++++++++-- 2 files changed, 206 insertions(+), 34 deletions(-) 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 90307617c..739c97800 100644 --- a/crates/alien-azure-clients/src/azure/sandbox_data_plane.rs +++ b/crates/alien-azure-clients/src/azure/sandbox_data_plane.rs @@ -12,6 +12,7 @@ use crate::azure::common::{AzureClientBase, AzureRequestBuilder}; use crate::azure::token_cache::AzureTokenCache; use alien_client_core::{ErrorData, Result}; +use std::collections::BTreeMap; use alien_error::{Context, IntoAlienError}; use async_trait::async_trait; use reqwest::Method; @@ -41,15 +42,55 @@ const SERVICE_NAME: &str = "sandboxDataPlane"; /// callers can rely on everywhere, and a body that never grows past it here. const MAX_FILE_BYTES: usize = 32 * 1024 * 1024; +/// 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, +} + +/// 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); + } + + body +} + /// A sandbox as the data plane reports it. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct Sandbox { /// Sandbox id within its group pub id: String, - /// `Running` or `Stopped` + /// `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. @@ -71,8 +112,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; @@ -203,27 +243,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) @@ -615,4 +642,36 @@ mod tests { 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())]), + }); + + assert_eq!(body["environment"]["TOKEN"], "t"); + assert_eq!(body["sourcesRef"]["diskImage"]["name"], "ubuntu"); + assert_eq!(body["resources"]["cpu"], "1000m"); + + let bare = create_body(&CreateSandbox::default()); + assert!( + bare.get("environment").is_none(), + "an empty map is no variables, not an empty object: {bare}" + ); + } + + /// 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")); + } } diff --git a/crates/alien-bindings/src/providers/sandbox/azure.rs b/crates/alien-bindings/src/providers/sandbox/azure.rs index 09f1488aa..24aa804ac 100644 --- a/crates/alien-bindings/src/providers/sandbox/azure.rs +++ b/crates/alien-bindings/src/providers/sandbox/azure.rs @@ -15,7 +15,7 @@ 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, SandboxDataPlaneApi}; use alien_client_core::ErrorData as ClientErrorData; use alien_core::{Platform, SandboxCapabilities}; use alien_error::{AlienError, ContextError}; @@ -107,7 +107,15 @@ impl Sandbox for AzureSandbox { async fn create(&self, request: CreateSessionRequest) -> Result { let sandbox = self .client - .create_sandbox(&self.sandbox_group, &self.disk_image, &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, + }, + ) .await .map_err(|error| Self::failed("sandbox.create", error))?; @@ -117,7 +125,7 @@ impl Sandbox for AzureSandbox { Ok(SandboxSession { session_id: sandbox.id, - state: SandboxSessionState::Running, + state: session_state("sandbox.create", sandbox.state.as_deref())?, generation: 1, }) } @@ -130,10 +138,7 @@ impl Sandbox for AzureSandbox { { Ok(sandbox) => Ok(Some(SandboxSession { session_id: sandbox.id, - state: match sandbox.status.as_deref() { - Some("Stopped") => SandboxSessionState::Suspended, - _ => SandboxSessionState::Running, - }, + state: session_state("sandbox.get", sandbox.state.as_deref())?, generation: 1, })), // A 404 is "gone", which is a valid answer. Anything else is a real failure and must @@ -429,6 +434,27 @@ fn checked_path(operation: &str, path: &str) -> Result<()> { Ok(()) } +/// 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), + Some("Stopping" | "Stopped" | "Suspended") => Ok(SandboxSessionState::Suspended), + Some("Deleting") => 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 one operation a repeat could run twice. const RUN_COMMAND: &str = "sandbox.runCommand"; @@ -510,12 +536,12 @@ mod tests { let mut client = MockSandboxDataPlaneApi::new(); client .expect_create_sandbox() - .withf(|_, disk_image, _, _| disk_image == "my-toolchain") + .withf(|_, request| request.disk_image == "my-toolchain") .times(1) - .returning(|_, _, _, _| { + .returning(|_, _| { Ok(alien_azure_clients::azure::sandbox_data_plane::Sandbox { id: "s1".to_string(), - status: Some("Running".to_string()), + state: Some("Running".to_string()), }) }); @@ -543,7 +569,7 @@ 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()), + state: Some("Running".to_string()), }) }); @@ -673,9 +699,7 @@ mod tests { async fn create_sandbox( &self, _group: &str, - _disk: &str, - _cpu: &str, - _memory: &str, + _request: CreateSandbox, ) -> alien_client_core::Result { unreachable!("the command paths never create") @@ -692,7 +716,7 @@ mod tests { } Ok(alien_azure_clients::azure::sandbox_data_plane::Sandbox { id: sandbox_id.to_string(), - status: Some("Running".to_string()), + state: Some("Running".to_string()), }) } @@ -1027,4 +1051,93 @@ mod tests { "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), + ("Stopping", SandboxSessionState::Suspended), + ("Stopped", SandboxSessionState::Suspended), + ("Suspended", 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(), + 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(), + 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(), + state: Some("Creating".to_string()), + }) + }); + + 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::Starting, + "a sandbox still being created is not one a command can reach" + ); + } } From fd8cd6ebf3472ae7119034fc8609f21e68f18201 Mon Sep 17 00:00:00 2001 From: Itamar Zand <133867530+ItamarZand88@users.noreply.github.com> Date: Sat, 22 Aug 2026 01:11:03 +0300 Subject: [PATCH 06/29] feat(sandbox): create Azure sandboxes under the declared egress policy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Azure's data plane takes an egress policy at create, and this backend sent none — so `deny` and a hostname list were both refused at plan time while the cloud underneath could express either. The declaration now travels in the binding and becomes the policy the sandbox is created with. `deny` is a `Deny` default under `Full` traffic inspection, plus a catch-all deny rule. Each part is load-bearing. Only `Full` blocks non-HTTP traffic — under any other mode a `Deny` default is a label on a live network. The rule is there because Microsoft documents `Partial` as evaluating only traffic a rule matches and never says `Full` differs, so a policy holding no rules at all is the one shape where "deny" could mean nothing. `allowDomains` becomes host rules over the same `Deny` default. `allow` sends no policy: the data plane is already open, and `Full` there would block the traffic `allow` promises. Then it is checked. The create response carries the policy the sandbox is actually running under, so a sandbox that came up without the one that was asked for is deleted rather than handed back — a restriction that did not take effect is worse than one nobody asked for, because the caller believes it held. The check compares the default action, the inspection mode, and every host the declaration named, not the whole object, so a normalised response does not fail every create. `egressDeny` and `domainEgressRules` flip last. Azure is the first backend to express a hostname allowlist at all: the others match CIDRs or carry a single switch. The binding's `egress` field is required, so a binding JSON without one no longer parses. Nothing is deployed, so there is nothing to migrate. What a live sandbox still has to settle: whether traffic is actually blocked. The response proves the policy is configured, never that a packet was dropped — the same evidence every other backend flips its flag on. --- .../src/azure/sandbox_data_plane.rs | 53 +++ crates/alien-bindings/src/provider.rs | 4 +- .../src/providers/sandbox/azure.rs | 338 +++++++++++++++++- crates/alien-core/src/bindings/sandbox.rs | 12 +- crates/alien-core/src/resources/sandbox.rs | 47 +-- .../compile_time/sandbox_platform_support.rs | 16 +- .../src/emitters/azure/sandbox.rs | 90 ++++- 7 files changed, 529 insertions(+), 31 deletions(-) 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 739c97800..45316fa71 100644 --- a/crates/alien-azure-clients/src/azure/sandbox_data_plane.rs +++ b/crates/alien-azure-clients/src/azure/sandbox_data_plane.rs @@ -42,6 +42,35 @@ const SERVICE_NAME: &str = "sandboxDataPlane"; /// 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, + /// `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, +} + +/// One host pattern and the action it carries. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +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 @@ -58,6 +87,9 @@ pub struct CreateSandbox { /// 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, } /// The create body. @@ -75,6 +107,10 @@ fn create_body(request: &CreateSandbox) -> serde_json::Value { body["environment"] = serde_json::json!(request.environment); } + if let Some(egress) = &request.egress { + body["egressPolicy"] = serde_json::json!(egress); + } + body } @@ -84,6 +120,10 @@ fn create_body(request: &CreateSandbox) -> serde_json::Value { pub struct Sandbox { /// Sandbox id within its group pub id: String, + /// 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 @@ -652,11 +692,24 @@ mod tests { 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(), + host_rules: vec![EgressHostRule { + pattern: "api.example.com".to_string(), + action: "Allow".to_string(), + }], + traffic_inspection: Some("Full".to_string()), + }), }); 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!( diff --git a/crates/alien-bindings/src/provider.rs b/crates/alien-bindings/src/provider.rs index a894f8df3..8d537f143 100644 --- a/crates/alien-bindings/src/provider.rs +++ b/crates/alien-bindings/src/provider.rs @@ -1908,6 +1908,7 @@ impl BindingsProviderApi for BindingsProvider { Arc::new(client), group, disk_image, + azure_binding.egress, DEFAULT_AZURE_CPU.to_string(), DEFAULT_AZURE_MEMORY.to_string(), )); @@ -2251,7 +2252,8 @@ mod tests { "dataPlaneEndpoint":"https://management.swedencentral.azuredevcompute.io", "region":"swedencentral", "resourceGroup":"rg", - "diskImage":"my-toolchain"}"# + "diskImage":"my-toolchain", + "egress":{"mode":"deny"}}"# .to_string(), ), ]); diff --git a/crates/alien-bindings/src/providers/sandbox/azure.rs b/crates/alien-bindings/src/providers/sandbox/azure.rs index 24aa804ac..ada3f9a61 100644 --- a/crates/alien-bindings/src/providers/sandbox/azure.rs +++ b/crates/alien-bindings/src/providers/sandbox/azure.rs @@ -15,9 +15,11 @@ use crate::traits::{ Binding, CommandOutput, CreateSessionRequest, PreviewCapability, RunCommandRequest, Sandbox, SandboxSession, SandboxSessionState, }; -use alien_azure_clients::azure::sandbox_data_plane::{CreateSandbox, 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_core::{Platform, SandboxCapabilities, SandboxEgress}; use alien_error::{AlienError, ContextError}; /// A Sandbox backed by the Azure ADC data plane. @@ -27,6 +29,8 @@ pub struct AzureSandbox { sandbox_group: 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, /// Session ceilings, in the data plane's own units. cpu: String, memory: String, @@ -38,6 +42,7 @@ impl AzureSandbox { client: std::sync::Arc, sandbox_group: String, disk_image: String, + egress: SandboxEgress, cpu: String, memory: String, ) -> Self { @@ -45,6 +50,7 @@ impl AzureSandbox { client, sandbox_group, disk_image, + egress, cpu, memory, } @@ -105,6 +111,7 @@ impl Sandbox for AzureSandbox { } async fn create(&self, request: CreateSessionRequest) -> Result { + let asked = egress_policy(&self.egress); let sandbox = self .client .create_sandbox( @@ -114,6 +121,7 @@ impl Sandbox for AzureSandbox { cpu: self.cpu.clone(), memory: self.memory.clone(), environment: request.env, + egress: asked.clone(), }, ) .await @@ -123,6 +131,31 @@ impl Sandbox for AzureSandbox { // the requested one would hand back a handle that addresses nothing. let _ = request.session_id; + // A restriction that did not take effect is worse than one that was never asked for: the + // caller believes the sandbox is contained. The response says what the sandbox is running + // under, so this is checked rather than assumed, and a sandbox that came up without the + // policy is deleted rather than handed back. + if let Some(asked) = &asked { + if !policy_holds(asked, sandbox.egress_policy.as_ref()) { + // The delete's own failure is carried rather than returned: it would replace the + // finding that matters — that the sandbox is not contained — with a delete error. + let deleted = match self.accept_delete(&sandbox.id).await { + Ok(()) => "it was deleted".to_string(), + Err(error) => format!("deleting it also failed: {error}"), + }; + return Err(AlienError::new(ErrorData::BindingConfigInvalid { + binding_name: "sandbox".to_string(), + env_var: "ALIEN_BINDING_SANDBOX".to_string(), + reason: format!( + "the sandbox was created asking for {} but came up with {}, so {deleted} \ + rather than handed back", + describe(Some(asked)), + describe(sandbox.egress_policy.as_ref()) + ), + })); + } + } + Ok(SandboxSession { session_id: sandbox.id, state: session_state("sandbox.create", sandbox.state.as_deref())?, @@ -150,8 +183,13 @@ impl Sandbox for AzureSandbox { 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); + // A session on its way out is not one to reconnect to: the id will not run again, and + // handing it back trades an error now for a command that never lands. + match self.get(id).await? { + Some(existing) if existing.state != SandboxSessionState::Terminated => { + return Ok(existing) + } + _ => {} } } @@ -434,6 +472,76 @@ fn checked_path(operation: &str, path: &str) -> Result<()> { Ok(()) } +/// 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(), + 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. +/// +/// A subset check rather than equality: the data plane may return the policy normalised or carry +/// fields this client does not model, and failing every create over a reordered list would push +/// whoever hits it into removing the check. What is compared is what containment rests on — the +/// default action, the inspection mode, and every host the declaration named. +fn policy_holds(asked: &EgressPolicy, effective: Option<&EgressPolicy>) -> bool { + let Some(effective) = effective else { + return false; + }; + + effective.default_action == asked.default_action + && effective.traffic_inspection.as_deref() == Some(FULL_INSPECTION) + && asked + .host_rules + .iter() + .all(|rule| effective.host_rules.contains(rule)) +} + +/// 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) => format!( + "default action '{}' under {} inspection with {} host rules", + policy.default_action, + policy.traffic_inspection.as_deref().unwrap_or("unstated"), + policy.host_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 @@ -455,6 +563,15 @@ fn session_state(operation: &str, state: Option<&str>) -> Result) -> 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, + "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))) + }); + 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))) + }); + 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)) + }); + 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(), + host_rules: Vec::new(), + traffic_inspection: Some("Partial".to_string()), + }), + // Inspected, and open. + Some(EgressPolicy { + default_action: "Allow".to_string(), + 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()))); + 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, "BINDING_CONFIG_INVALID", "{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(); + client.expect_create_sandbox().times(1).returning(|_, _| { + Ok(running( + "s1", + Some(EgressPolicy { + default_action: "Deny".to_string(), + host_rules: vec![EgressHostRule { + pattern: "elsewhere.example.com".to_string(), + action: "Allow".to_string(), + }], + traffic_inspection: Some("Full".to_string()), + }), + )) + }); + 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, "BINDING_CONFIG_INVALID", "{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(|_, _| Ok(running("fresh", None))); + + let session = sandbox_with(client) + .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"); + } } diff --git a/crates/alien-core/src/bindings/sandbox.rs b/crates/alien-core/src/bindings/sandbox.rs index 6d76a51cd..bf1ac1a28 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,12 @@ 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, /// 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 @@ -175,12 +182,14 @@ impl SandboxBinding { region: impl Into>, resource_group: impl Into>, disk_image: impl Into>, + egress: SandboxEgress, ) -> 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, disk_image: disk_image.into(), }) } @@ -248,6 +257,7 @@ mod tests { "swedencentral", "rg", "ubuntu", + SandboxEgress::Deny, ), SandboxBinding::gcp("/usr/local/gcp/bin/sandbox", false), SandboxBinding::kubernetes( @@ -276,7 +286,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", "ubuntu"), + SandboxBinding::azure("g", "e", "r", "rg", "ubuntu", SandboxEgress::Deny), 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 0850deb15..2cddb3f1f 100644 --- a/crates/alien-core/src/resources/sandbox.rs +++ b/crates/alien-core/src/resources/sandbox.rs @@ -132,7 +132,10 @@ pub enum SandboxEgress { /// /// Link-local carries the same exception as `Deny`. 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 @@ -233,8 +236,8 @@ impl SandboxCapabilities { preview: false, suspend_resume: false, snapshot: false, - domain_egress_rules: false, - egress_deny: false, + domain_egress_rules: true, + egress_deny: true, enforced_limits: false, process_limit: false, session_lifetime: false, @@ -862,10 +865,12 @@ mod tests { let azure = SandboxCapabilities::for_platform(Platform::Azure).expect("azure is supported"); assert!(azure.files, "every backend moves files"); assert!(gcp.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); + // 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. @@ -908,11 +913,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()], @@ -922,19 +927,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 @@ -957,7 +968,7 @@ 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(), @@ -969,15 +980,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 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..4dda41d44 100644 --- a/crates/alien-preflights/src/compile_time/sandbox_platform_support.rs +++ b/crates/alien-preflights/src/compile_time/sandbox_platform_support.rs @@ -136,8 +136,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 +150,9 @@ mod tests { for platform in [ Platform::Aws, - Platform::Azure, Platform::Gcp, Platform::Kubernetes, + Platform::Local, ] { let result = SandboxPlatformSupportCheck .check(&stack, platform) @@ -163,6 +163,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/azure/sandbox.rs b/crates/alien-terraform/src/emitters/azure/sandbox.rs index 90b43988c..138ca98b7 100644 --- a/crates/alien-terraform/src/emitters/azure/sandbox.rs +++ b/crates/alien-terraform/src/emitters/azure/sandbox.rs @@ -11,7 +11,7 @@ use crate::{ emitters::azure::helpers::{downcast, required_label, resource_prefix_template}, expr, }; -use alien_core::{import::EmitContext, ErrorData, Result, Sandbox, SandboxCode}; +use alien_core::{import::EmitContext, ErrorData, Result, Sandbox, SandboxCode, SandboxEgress}; use alien_error::AlienError; use hcl::expr::Expression; @@ -30,6 +30,29 @@ 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::>(), + ), + ), + ]), + } +} + /// The catalog image name a declaration asks for, or a refusal. /// /// The create body names a public catalog image, so a registry reference has nowhere to go. @@ -91,6 +114,71 @@ 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)), ]))) } } + +#[cfg(test)] +mod tests { + use super::*; + use alien_core::{ResourceLifecycle, SandboxSessionPolicy, Stack, StackSettings}; + use indexmap::IndexMap; + + fn binding_for(egress: SandboxEgress) -> 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: None, + }) + .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() { + let denied = binding_for(SandboxEgress::Deny); + assert!(denied.contains(r#""deny""#), "{denied}"); + + let listed = binding_for(SandboxEgress::AllowDomains { + domains: vec!["api.example.com".to_string()], + }); + assert!(listed.contains(r#""allowDomains""#), "{listed}"); + assert!(listed.contains("api.example.com"), "{listed}"); + + let open = binding_for(SandboxEgress::Allow); + assert!(open.contains(r#""allow""#), "{open}"); + } +} From 02b48662578937ebea8a19d83be654ad8377c3bf Mon Sep 17 00:00:00 2001 From: Itamar Zand <133867530+ItamarZand88@users.noreply.github.com> Date: Sat, 22 Aug 2026 01:11:12 +0300 Subject: [PATCH 07/29] fix(sandbox): refuse a hostname allowlist instead of approximating it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three backends turned `allowDomains` into their nearest expressible thing, and only the core capability check stood between that and a rendered artifact. Render a chart or a GCP module directly and the declaration changed meaning with nothing anywhere saying so. Helm folded it into the `allow` arm and emitted `0.0.0.0/0` — every address the list existed to exclude. Its test asserted the widened policy, pinning the behaviour rather than preventing it. GCP collapsed it to `allowEgress: false`, denying everything the declaration asked to permit. Both now refuse, as AWS already did. The AWS message ends "or use a platform that supports it", which was advice to nowhere while every backend reported `domainEgressRules: false`; all four messages now name Azure, whose egress proxy matches on host pattern. --- .../src/emitters/aws/sandbox.rs | 4 ++-- crates/alien-helm/src/emitters/sandbox.rs | 17 ++++++++++++-- crates/alien-helm/tests/generator/helpers.rs | 6 ++++- .../tests/generator/resource_layer_tests.rs | 21 ++++++++--------- crates/alien-infra/src/sandbox/local.rs | 3 ++- .../src/emitters/aws/sandbox.rs | 4 ++-- .../src/emitters/gcp/sandbox.rs | 23 ++++++++++++++++++- 7 files changed, 57 insertions(+), 21 deletions(-) diff --git a/crates/alien-cloudformation/src/emitters/aws/sandbox.rs b/crates/alien-cloudformation/src/emitters/aws/sandbox.rs index b2ffdfa70..01481113e 100644 --- a/crates/alien-cloudformation/src/emitters/aws/sandbox.rs +++ b/crates/alien-cloudformation/src/emitters/aws/sandbox.rs @@ -582,8 +582,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, or deploy to Azure, whose \ + egress proxy matches on host pattern" ), })) }; diff --git a/crates/alien-helm/src/emitters/sandbox.rs b/crates/alien-helm/src/emitters/sandbox.rs index edd1a690c..c37b7462a 100644 --- a/crates/alien-helm/src/emitters/sandbox.rs +++ b/crates/alien-helm/src/emitters/sandbox.rs @@ -58,6 +58,19 @@ impl HelmEmitter for SandboxEmitter { }) })?; + // A hostname list has no NetworkPolicy to render into — it matches CIDRs — so it is + // refused rather than widened to the `allow` rule, which would open every address the + // declaration meant to exclude. + if let SandboxEgress::AllowDomains { .. } = sandbox.egress { + return Err(AlienError::new(ErrorData::OperationNotSupported { + operation: format!("helm emit sandbox '{}'", ctx.resource_id), + reason: "a Kubernetes NetworkPolicy matches addresses, not names, so a hostname \ + list has nothing to render into. Declare egress: deny, or deploy to \ + Azure, whose egress proxy matches on host pattern" + .to_string(), + })); + } + let mut fragment = HelmFragment::empty(); fragment.extra_templates.insert( format!("sandbox-{}-networkpolicy.yaml", sandbox.id()), @@ -81,8 +94,8 @@ impl HelmEmitter for SandboxEmitter { fn network_policy(sandbox: &Sandbox) -> String { 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. + // `AllowDomains` never reaches here: the emitter refuses it rather than render it as the + // `allow` rule below, which permits every address the list meant to exclude. SandboxEgress::Allow | SandboxEgress::AllowDomains { .. } => { let excepts: String = ALWAYS_DENIED_CIDRS .iter() 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..5c552a15a 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, @@ -166,11 +166,11 @@ 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. #[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 +188,11 @@ 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!( - 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("matches addresses, not names"), + "the refusal must name why: {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-terraform/src/emitters/aws/sandbox.rs b/crates/alien-terraform/src/emitters/aws/sandbox.rs index 7baa61488..f8d3cffb7 100644 --- a/crates/alien-terraform/src/emitters/aws/sandbox.rs +++ b/crates/alien-terraform/src/emitters/aws/sandbox.rs @@ -631,8 +631,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, or deploy to Azure, whose \ + egress proxy matches on host pattern" ), })) }; diff --git a/crates/alien-terraform/src/emitters/gcp/sandbox.rs b/crates/alien-terraform/src/emitters/gcp/sandbox.rs index 6ea186935..498887e30 100644 --- a/crates/alien-terraform/src/emitters/gcp/sandbox.rs +++ b/crates/alien-terraform/src/emitters/gcp/sandbox.rs @@ -11,9 +11,28 @@ 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 deploy to Azure, \ + whose egress proxy matches on host pattern" + .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 +49,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 +65,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())), ( From 874f5a7271e2b0ff55c8da399e8637d777ab67ba Mon Sep 17 00:00:00 2001 From: Itamar Zand <133867530+ItamarZand88@users.noreply.github.com> Date: Sat, 22 Aug 2026 01:21:05 +0300 Subject: [PATCH 08/29] fix(sandbox): fail an Azure create on a permission nobody asked for MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The containment check compared the effective policy inward only — every host the declaration named had to be present — which is the right test pointed the wrong way. A sandbox that came up allowing a host nobody asked for passed it, and so did one whose `rules` list allowed everything, because this client never writes that list and so did not read it either. A group-scoped policy is a documented way for an entry nobody sent to appear. Both directions now: nothing may allow a host the declaration did not name, in either list, and an advanced rule that is not a `Deny` fails the create outright. Extra denials stay harmless, so a normalised response still cannot fail a create that was honoured. --- .../src/azure/sandbox_data_plane.rs | 56 ++++++++ .../src/providers/sandbox/azure.rs | 128 +++++++++++++++++- 2 files changed, 177 insertions(+), 7 deletions(-) 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 45316fa71..6e5eec3ea 100644 --- a/crates/alien-azure-clients/src/azure/sandbox_data_plane.rs +++ b/crates/alien-azure-clients/src/azure/sandbox_data_plane.rs @@ -55,12 +55,50 @@ pub struct EgressPolicy { /// 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, } +/// 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. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct EgressRule { + /// 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, +} + +/// The host a rule matches. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct EgressRuleMatch { + /// Host pattern the rule applies to. + #[serde(default)] + pub host: String, +} + +/// What a rule does when it matches. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct EgressRuleAction { + /// `Allow`, `Deny`, `Transform` or `Rewrite`. + #[serde(rename = "type", default)] + pub action_type: String, +} + /// One host pattern and the action it carries. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] @@ -698,6 +736,7 @@ mod tests { pattern: "api.example.com".to_string(), action: "Allow".to_string(), }], + rules: Vec::new(), traffic_inspection: Some("Full".to_string()), }), }); @@ -727,4 +766,21 @@ mod tests { 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") + ); + } } diff --git a/crates/alien-bindings/src/providers/sandbox/azure.rs b/crates/alien-bindings/src/providers/sandbox/azure.rs index ada3f9a61..bf89a3d20 100644 --- a/crates/alien-bindings/src/providers/sandbox/azure.rs +++ b/crates/alien-bindings/src/providers/sandbox/azure.rs @@ -16,7 +16,8 @@ use crate::traits::{ SandboxSession, SandboxSessionState, }; use alien_azure_clients::azure::sandbox_data_plane::{ - CreateSandbox, EgressHostRule, EgressPolicy, SandboxDataPlaneApi, + CreateSandbox, EgressHostRule, EgressPolicy, EgressRule, EgressRuleAction, EgressRuleMatch, + SandboxDataPlaneApi, }; use alien_client_core::ErrorData as ClientErrorData; use alien_core::{Platform, SandboxCapabilities, SandboxEgress}; @@ -137,6 +138,8 @@ impl Sandbox for AzureSandbox { // policy is deleted rather than handed back. if let Some(asked) = &asked { if !policy_holds(asked, sandbox.egress_policy.as_ref()) { + // Deleting is safe to do unconditionally here: Azure allocates the id, so the + // one in this response was minted by this call and belongs to no other caller. // The delete's own failure is carried rather than returned: it would replace the // finding that matters — that the sandbox is not contained — with a delete error. let deleted = match self.accept_delete(&sandbox.id).await { @@ -483,6 +486,7 @@ fn egress_policy(egress: &SandboxEgress) -> Option { let bounded = |host_rules| { Some(EgressPolicy { default_action: DENY.to_string(), + rules: Vec::new(), host_rules, traffic_inspection: Some(FULL_INSPECTION.to_string()), }) @@ -512,21 +516,44 @@ fn egress_policy(egress: &SandboxEgress) -> Option { /// Whether the sandbox is running the policy it was created with. /// -/// A subset check rather than equality: the data plane may return the policy normalised or carry -/// fields this client does not model, and failing every create over a reordered list would push -/// whoever hits it into removing the check. What is compared is what containment rests on — the -/// default action, the inspection mode, and every host the declaration named. +/// 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 allowed = |host: &str| { + asked + .host_rules + .iter() + .any(|rule| rule.action == ALLOW && rule.pattern == host) + }; + effective.default_action == asked.default_action && effective.traffic_inspection.as_deref() == Some(FULL_INSPECTION) && asked .host_rules .iter() .all(|rule| effective.host_rules.contains(rule)) + && effective + .host_rules + .iter() + .all(|rule| rule.action != ALLOW || allowed(&rule.pattern)) + // An advanced rule is refused outright rather than matched host by host: this client + // never sends one, so an `Allow` here came from somewhere else, and `Transform` and + // `Rewrite` reach a host by rewriting the request rather than by naming it. + && effective.rules.iter().all(|rule| { + rule.action + .as_ref() + .is_some_and(|action| action.action_type == DENY) + }) } /// The effective policy, short enough to read in an error. @@ -534,10 +561,11 @@ fn describe(effective: Option<&EgressPolicy>) -> String { match effective { None => "no policy at all".to_string(), Some(policy) => format!( - "default action '{}' under {} inspection with {} host rules", + "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.host_rules.len(), + policy.rules.len() ), } } @@ -1375,12 +1403,14 @@ mod tests { // The default action alone: every non-HTTP protocol still leaves. Some(EgressPolicy { default_action: "Deny".to_string(), + rules: Vec::new(), host_rules: Vec::new(), traffic_inspection: Some("Partial".to_string()), }), // Inspected, and open. Some(EgressPolicy { default_action: "Allow".to_string(), + rules: Vec::new(), host_rules: Vec::new(), traffic_inspection: Some("Full".to_string()), }), @@ -1417,6 +1447,7 @@ mod tests { "s1", Some(EgressPolicy { default_action: "Deny".to_string(), + rules: Vec::new(), host_rules: vec![EgressHostRule { pattern: "elsewhere.example.com".to_string(), action: "Allow".to_string(), @@ -1470,4 +1501,87 @@ mod tests { 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(), + 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(), + host_rules: vec![declared.clone()], + rules: vec![EgressRule { + r#match: Some(EgressRuleMatch { + host: "*".to_string(), + }), + action: Some(EgressRuleAction { + action_type: "Allow".to_string(), + }), + }], + 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())))); + 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, "BINDING_CONFIG_INVALID", "{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(), + 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"); + } } From f5c403f3a9c1d60179315a8eb775d046d23374b2 Mon Sep 17 00:00:00 2001 From: Itamar Zand <133867530+ItamarZand88@users.noreply.github.com> Date: Sat, 22 Aug 2026 01:49:08 +0300 Subject: [PATCH 09/29] feat(sandbox): suspend, resume and auto-suspend an Azure sandbox MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `suspendResume` was false because the two verbs were unimplemented, not because Azure lacks them: `POST {sandbox}/stop` saves the state and `POST {sandbox}/resume` brings it back, both returning on acceptance. That is the contract the trait gets — a caller that needs the session stopped polls `get`, the same rule AWS follows. Flipping the flag drags a second thing with it. `idleSuspendSeconds` is gated on `suspendResume`, so the declaration becomes legal on Azure the moment the flag flips — and the create body never carried a lifecycle policy, so the number would have been accepted and dropped. It now travels in the binding and arrives as `lifecycle.autoSuspendPolicy`, suspending to memory, which is what makes the resume fast enough to be worth having. Three capabilities stay false, and each is now a recorded decision rather than an unbuilt feature: - `preview`: a sandbox port's auth is anonymous or Entra ID with an allowlist of human email addresses. Neither is a credential scoped to a port for a fixed time, and returning the anonymous URL would publish the port. - `snapshot`: the blocker is ours. `snapshot()` returns an id and `CreateSessionRequest` has nothing to consume one, so no backend can complete the round trip — and nothing in the resource model owns the artifact, which Microsoft says is never garbage collected. - `sessionLifetime`: Azure suspends on idle and deletes after a stop, but has no wall-clock ceiling. Accepting `maxLifetimeSeconds` would be the silent no-op the capability set exists to prevent. The comment those three replace claimed a per-port URL closed to anonymous traffic and a 0.54s resume. Neither has a source, and the first is the opposite of what the port model says. --- .../src/azure/sandbox_data_plane.rs | 103 ++++++++++++++++++ crates/alien-bindings/src/provider.rs | 1 + .../src/providers/sandbox/azure.rs | 97 ++++++++++++++++- crates/alien-core/src/bindings/sandbox.rs | 11 +- crates/alien-core/src/resources/sandbox.rs | 62 +++++++++-- .../src/emitters/azure/sandbox.rs | 48 ++++++-- 6 files changed, 301 insertions(+), 21 deletions(-) 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 6e5eec3ea..f0d26a8df 100644 --- a/crates/alien-azure-clients/src/azure/sandbox_data_plane.rs +++ b/crates/alien-azure-clients/src/azure/sandbox_data_plane.rs @@ -128,6 +128,9 @@ pub struct CreateSandbox { /// 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. @@ -149,6 +152,14 @@ fn create_body(request: &CreateSandbox) -> serde_json::Value { body["egressPolicy"] = serde_json::json!(egress); } + // `Memory` rather than `Disk`: a memory suspend is what makes resume fast, and a sandbox that + // suspended to disk loses the process state a session exists to keep. + if let Some(seconds) = request.idle_suspend_seconds { + body["lifecycle"] = serde_json::json!({ + "autoSuspendPolicy": { "enabled": true, "interval": seconds, "mode": "Memory" } + }); + } + body } @@ -224,6 +235,12 @@ pub trait SandboxDataPlaneApi: Send + Sync + std::fmt::Debug { /// 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 @@ -283,6 +300,28 @@ impl AzureSandboxDataPlaneClient { format!("{}/sandboxes/{sandbox_id}", self.group_path(group)) } + /// A bodyless POST that moves a sandbox between states. + 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(signed, operation, sandbox_id) + .await?; + Ok(()) + } + async fn parse( response: reqwest::Response, operation: &str, @@ -489,6 +528,16 @@ impl SandboxDataPlaneApi for AzureSandboxDataPlaneClient { 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( @@ -739,6 +788,7 @@ mod tests { rules: Vec::new(), traffic_inspection: Some("Full".to_string()), }), + idle_suspend_seconds: None, }); assert_eq!(body["environment"]["TOKEN"], "t"); @@ -755,6 +805,24 @@ mod tests { 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 @@ -783,4 +851,39 @@ mod tests { 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; + } } diff --git a/crates/alien-bindings/src/provider.rs b/crates/alien-bindings/src/provider.rs index 8d537f143..be9277302 100644 --- a/crates/alien-bindings/src/provider.rs +++ b/crates/alien-bindings/src/provider.rs @@ -1909,6 +1909,7 @@ impl BindingsProviderApi for BindingsProvider { group, disk_image, azure_binding.egress, + azure_binding.idle_suspend_seconds, DEFAULT_AZURE_CPU.to_string(), DEFAULT_AZURE_MEMORY.to_string(), )); diff --git a/crates/alien-bindings/src/providers/sandbox/azure.rs b/crates/alien-bindings/src/providers/sandbox/azure.rs index bf89a3d20..d176bd6bb 100644 --- a/crates/alien-bindings/src/providers/sandbox/azure.rs +++ b/crates/alien-bindings/src/providers/sandbox/azure.rs @@ -32,6 +32,8 @@ pub struct AzureSandbox { 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, @@ -44,6 +46,7 @@ impl AzureSandbox { sandbox_group: String, disk_image: String, egress: SandboxEgress, + idle_suspend_seconds: Option, cpu: String, memory: String, ) -> Self { @@ -52,6 +55,7 @@ impl AzureSandbox { sandbox_group, disk_image, egress, + idle_suspend_seconds, cpu, memory, } @@ -123,6 +127,7 @@ impl Sandbox for AzureSandbox { memory: self.memory.clone(), environment: request.env, egress: asked.clone(), + idle_suspend_seconds: self.idle_suspend_seconds, }, ) .await @@ -314,12 +319,20 @@ impl Sandbox for AzureSandbox { Err(self.unsupported("preview")) } - async fn suspend(&self, _session_id: &str) -> Result<()> { - Err(self.unsupported("suspendResume")) + async fn suspend(&self, session_id: &str) -> Result<()> { + // Accepted, not completed — the same contract the AWS backend follows. A caller that + // needs the session to have stopped polls `get` for `Suspended`. + 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.client + .resume_sandbox(&self.sandbox_group, session_id) + .await + .map_err(|error| Self::failed("sandbox.resume", error)) } async fn snapshot(&self, _session_id: &str) -> Result { @@ -667,6 +680,7 @@ mod tests { "grp".to_string(), "ubuntu".to_string(), SandboxEgress::Allow, + None, "1000m".to_string(), "2048Mi".to_string(), ) @@ -697,6 +711,7 @@ mod tests { "grp".to_string(), "my-toolchain".to_string(), SandboxEgress::Allow, + None, "1000m".to_string(), "2048Mi".to_string(), ); @@ -817,6 +832,18 @@ mod tests { #[async_trait] impl SandboxDataPlaneApi for ScriptedExec { + async fn stop_sandbox(&self, _group: &str, _sandbox_id: &str) -> alien_client_core::Result<()> { + unreachable!("the command paths never suspend") + } + + async fn resume_sandbox( + &self, + _group: &str, + _sandbox_id: &str, + ) -> alien_client_core::Result<()> { + unreachable!("the command paths never resume") + } + async fn read_file( &self, _group: &str, @@ -926,6 +953,7 @@ mod tests { "grp".to_string(), "ubuntu".to_string(), SandboxEgress::Allow, + None, "1000m".to_string(), "2048Mi".to_string(), ) @@ -1309,6 +1337,7 @@ mod tests { "grp".to_string(), "ubuntu".to_string(), egress, + None, "1000m".to_string(), "2048Mi".to_string(), ) @@ -1584,4 +1613,64 @@ mod tests { .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"); + + let mut client = MockSandboxDataPlaneApi::new(); + 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 be accepted"); + } + + /// 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))); + + 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"); + } } diff --git a/crates/alien-core/src/bindings/sandbox.rs b/crates/alien-core/src/bindings/sandbox.rs index bf1ac1a28..209cfd69b 100644 --- a/crates/alien-core/src/bindings/sandbox.rs +++ b/crates/alien-core/src/bindings/sandbox.rs @@ -98,6 +98,12 @@ pub struct AzureSandboxBinding { /// 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 @@ -183,6 +189,7 @@ impl SandboxBinding { resource_group: impl Into>, disk_image: impl Into>, egress: SandboxEgress, + idle_suspend_seconds: Option, ) -> Self { Self::Azure(AzureSandboxBinding { sandbox_group: sandbox_group.into(), @@ -190,6 +197,7 @@ impl SandboxBinding { region: region.into(), resource_group: resource_group.into(), egress, + idle_suspend_seconds, disk_image: disk_image.into(), }) } @@ -258,6 +266,7 @@ mod tests { "rg", "ubuntu", SandboxEgress::Deny, + None, ), SandboxBinding::gcp("/usr/local/gcp/bin/sandbox", false), SandboxBinding::kubernetes( @@ -286,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", "ubuntu", SandboxEgress::Deny), + 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 2cddb3f1f..137c8170b 100644 --- a/crates/alien-core/src/resources/sandbox.rs +++ b/crates/alien-core/src/resources/sandbox.rs @@ -226,20 +226,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: 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: 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, @@ -872,11 +880,12 @@ mod tests { // 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"); @@ -1337,4 +1346,41 @@ mod tests { .validate_update(&renamed) .expect_err("renaming a sandbox is not an update"); } + + /// An idle-suspend policy is now declarable on Azure, and a wall-clock ceiling still is not. + /// + /// 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 + ); + } } diff --git a/crates/alien-terraform/src/emitters/azure/sandbox.rs b/crates/alien-terraform/src/emitters/azure/sandbox.rs index 138ca98b7..7e98a9500 100644 --- a/crates/alien-terraform/src/emitters/azure/sandbox.rs +++ b/crates/alien-terraform/src/emitters/azure/sandbox.rs @@ -1,10 +1,13 @@ //! 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. +//! The ACA sandbox group is created at runtime, 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. +//! +//! Nothing in this repository creates that group: `create_or_update_sandbox_group` has no caller, +//! and the controller registry holds only the Local and Kubernetes sandbox controllers. use crate::{ emitter::{TfEmitter, TfFragment}, @@ -102,7 +105,7 @@ impl TfEmitter for AzureSandboxEmitter { let sandbox = downcast::(ctx, Sandbox::RESOURCE_TYPE)?; let _ = required_label(ctx)?; let disk_image = catalog_disk_image(sandbox)?; - Ok(Some(expr::object([ + 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 @@ -115,7 +118,16 @@ impl TfEmitter for AzureSandboxEmitter { ("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))) } } @@ -126,6 +138,10 @@ mod tests { 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()) @@ -135,7 +151,7 @@ mod tests { .egress(egress) .session(SandboxSessionPolicy { max_lifetime_seconds: None, - idle_suspend_seconds: None, + idle_suspend_seconds, }) .build(), ResourceLifecycle::Frozen, @@ -181,4 +197,20 @@ mod tests { let open = binding_for(SandboxEgress::Allow); assert!(open.contains(r#""allow""#), "{open}"); } + + /// 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("900"), "{declared}"); + + let undeclared = binding_with(SandboxEgress::Allow, None); + assert!( + !undeclared.contains("idleSuspendSeconds"), + "{undeclared}" + ); + } } From 07254cd0b749ecfae4c08bffe56200d0aae3e195 Mon Sep 17 00:00:00 2001 From: Itamar Zand <133867530+ItamarZand88@users.noreply.github.com> Date: Sat, 22 Aug 2026 01:54:03 +0300 Subject: [PATCH 10/29] fix(sandbox): read the state Azure's own auto-suspend produces MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The state mapping refused `Idle`, and `Idle` is what the SDK's stop poller waits for. The package contradicts itself — it declares `Idle` as a reason a sandbox stopped and then waits for a *state* of `Idle` — and an unrecognised state is an error here on purpose, so the one state the idle policy is most likely to produce would have failed every `get` on the sessions that policy governs. It reads as suspended, which is true under either reading. Also: `autoSuspendPolicy` mode is the SDK's own default rather than a claim about what `Disk` does, which is documented nowhere. --- .../src/azure/sandbox_data_plane.rs | 4 ++-- crates/alien-bindings/src/providers/sandbox/azure.rs | 11 +++++++++-- 2 files changed, 11 insertions(+), 4 deletions(-) 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 f0d26a8df..de1b69e57 100644 --- a/crates/alien-azure-clients/src/azure/sandbox_data_plane.rs +++ b/crates/alien-azure-clients/src/azure/sandbox_data_plane.rs @@ -152,8 +152,8 @@ fn create_body(request: &CreateSandbox) -> serde_json::Value { body["egressPolicy"] = serde_json::json!(egress); } - // `Memory` rather than `Disk`: a memory suspend is what makes resume fast, and a sandbox that - // suspended to disk loses the process state a session exists to keep. + // `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" } diff --git a/crates/alien-bindings/src/providers/sandbox/azure.rs b/crates/alien-bindings/src/providers/sandbox/azure.rs index d176bd6bb..bc959fbe4 100644 --- a/crates/alien-bindings/src/providers/sandbox/azure.rs +++ b/crates/alien-bindings/src/providers/sandbox/azure.rs @@ -321,7 +321,9 @@ impl Sandbox for AzureSandbox { async fn suspend(&self, session_id: &str) -> Result<()> { // Accepted, not completed — the same contract the AWS backend follows. A caller that - // needs the session to have stopped polls `get` for `Suspended`. + // needs the session to have stopped polls `get` for `Suspended`. Suspending an + // already-stopped session, which a caller racing the idle policy cannot avoid, answers + // 409 and is reported retryable rather than as a refusal. self.client .stop_sandbox(&self.sandbox_group, session_id) .await @@ -592,7 +594,11 @@ fn session_state(operation: &str, state: Option<&str>) -> Result Ok(SandboxSessionState::Running), Some("Creating" | "Resuming") => Ok(SandboxSessionState::Starting), - Some("Stopping" | "Stopped" | "Suspended") => Ok(SandboxSessionState::Suspended), + // `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. + Some("Stopping" | "Stopped" | "Suspended" | "Idle") => Ok(SandboxSessionState::Suspended), Some("Deleting") => Ok(SandboxSessionState::Terminated), other => Err(AlienError::new(ErrorData::UnexpectedResponseFormat { provider: "azure".to_string(), @@ -1244,6 +1250,7 @@ mod tests { ("Stopping", SandboxSessionState::Suspended), ("Stopped", SandboxSessionState::Suspended), ("Suspended", SandboxSessionState::Suspended), + ("Idle", SandboxSessionState::Suspended), ("Deleting", SandboxSessionState::Terminated), ] { let mut client = MockSandboxDataPlaneApi::new(); From f98f1b60a36094d9082031086848b81ce32028ae Mon Sep 17 00:00:00 2001 From: Itamar Zand <133867530+ItamarZand88@users.noreply.github.com> Date: Sat, 22 Aug 2026 03:05:22 +0300 Subject: [PATCH 11/29] fix(sandbox): close what the pre-push review found MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Six things this branch got wrong, and the review caught each one. The create body carries the caller's environment variables and a write carries file bytes, and a failure echoes the request into an error chain that is serialized into durable state. Both now go through `redact_request_body`, which every other create-with-a-secret already used. `create` owes the caller a session that can take work — the trait says a backend whose start API returns early waits here — and it was handing back one that could not. It waits now. Every failure after the sandbox exists deletes it through one path: a `?` on an unreadable state was abandoning a running sandbox that nothing could find, since Azure mints the id, has no enumeration verb and sets no auto-delete. The egress check guarded `create` alone, so a reconnect returned whatever a session was built with. Azure has no session ceiling and an idle sandbox only suspends, so one created under an older declaration outlives the change and was being handed back under the label the stack has now. `get` checks too. `policy_holds` had three holes: it compared case-sensitively where the data plane normalises, it passed any host action that was not exactly `Allow` — `Transform` and `Rewrite` reach a host by rewriting the request — and it could not see a policy field this client does not model. It is now a whitelist in both directions, and an unreadable policy fails the create. The refusal named an env var that does not exist and flattened the delete's own error through an `internal = false` boundary, publishing raw service text. It is a typed `SandboxNotAsDeclared` carrying the session id — the one thing an operator needs when the delete also failed. `catalog_disk_image` rejected a registry path but not a tag, so `ubuntu:24.04` rendered into a customer's module, planned, applied, and failed at the first session. Smaller, same review: `Stopping` is not stopped, so it no longer answers the poll `suspend` documents; `terminate` polls the client directly rather than dying on a state it cannot parse; an absolute path means "under the session root" here as everywhere else; `create` is not idempotent and no longer claims to be; an `allowDomains` naming no domain is refused at plan time; Helm refuses in the function that renders rather than one upstream; and the four egress refusals stopped pointing customers at a platform whose sandbox group nothing in this repository creates. The captured launcher fixture carries the launcher's own output, and a recipe for re-capturing it that needs nothing but the container it came from. The generated schemas and the TypeScript doc still described Azure as having no file transfer and no backend as matching hostnames. Regenerated with `pnpm -C packages/core run generate`, and the hand-written paragraph corrected. --- .../src/azure/sandbox_data_plane.rs | 40 +- crates/alien-bindings/src/error.rs | 22 + .../src/providers/sandbox/azure.rs | 488 ++++++++++++++---- .../src/providers/sandbox/gcp.rs | 11 +- .../src/emitters/aws/sandbox.rs | 4 +- crates/alien-core/src/resources/sandbox.rs | 43 ++ crates/alien-helm/src/emitters/sandbox.rs | 35 +- .../tests/generator/resource_layer_tests.rs | 5 +- .../src/emitters/aws/sandbox.rs | 4 +- .../src/emitters/azure/sandbox.rs | 46 +- .../src/emitters/gcp/sandbox.rs | 3 +- .../core/src/generated/schemas/sandbox.json | 2 +- .../schemas/sandboxCapabilities.json | 2 +- .../src/generated/schemas/sandboxEgress.json | 2 +- .../zod/sandbox-capabilities-schema.ts | 2 +- packages/core/src/sandbox.ts | 4 +- 16 files changed, 555 insertions(+), 158 deletions(-) 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 de1b69e57..565a3fa37 100644 --- a/crates/alien-azure-clients/src/azure/sandbox_data_plane.rs +++ b/crates/alien-azure-clients/src/azure/sandbox_data_plane.rs @@ -12,8 +12,8 @@ use crate::azure::common::{AzureClientBase, AzureRequestBuilder}; use crate::azure::token_cache::AzureTokenCache; use alien_client_core::{ErrorData, Result}; -use std::collections::BTreeMap; use alien_error::{Context, IntoAlienError}; +use std::collections::BTreeMap; use async_trait::async_trait; use reqwest::Method; use serde::{Deserialize, Serialize}; @@ -63,6 +63,13 @@ pub struct EgressPolicy { /// 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. @@ -375,10 +382,11 @@ 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. + let response = alien_client_core::redact_request_body( + self.base.execute_request(signed, "CreateSandbox", group).await, + )?; Self::parse(response, "CreateSandbox").await } @@ -524,7 +532,12 @@ impl SandboxDataPlaneApi for AzureSandboxDataPlaneClient { .body_bytes(contents) .build()?; let signed = self.base.sign_request(request, &token).await?; - self.base.execute_request(signed, "WriteFile", sandbox_id).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(()) } @@ -709,9 +722,21 @@ mod tests { /// 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 bytes = BINARY.to_vec(); + 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); @@ -781,6 +806,7 @@ mod tests { 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(), diff --git a/crates/alien-bindings/src/error.rs b/crates/alien-bindings/src/error.rs index ef4179686..b66cf65c2 100644 --- a/crates/alien-bindings/src/error.rs +++ b/crates/alien-bindings/src/error.rs @@ -315,6 +315,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}' came up without its declared {restriction}: {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/providers/sandbox/azure.rs b/crates/alien-bindings/src/providers/sandbox/azure.rs index bc959fbe4..0b12b5bb0 100644 --- a/crates/alien-bindings/src/providers/sandbox/azure.rs +++ b/crates/alien-bindings/src/providers/sandbox/azure.rs @@ -22,6 +22,7 @@ use alien_azure_clients::azure::sandbox_data_plane::{ use alien_client_core::ErrorData as ClientErrorData; 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)] @@ -90,12 +91,12 @@ impl AzureSandbox { }); } - if operation == RUN_COMMAND { + 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 the command ran is unknown" + whether it took effect is unknown" ), }); } @@ -131,73 +132,75 @@ impl Sandbox for AzureSandbox { }, ) .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. let _ = request.session_id; - // A restriction that did not take effect is worse than one that was never asked for: the - // caller believes the sandbox is contained. The response says what the sandbox is running - // under, so this is checked rather than assumed, and a sandbox that came up without the - // policy is deleted rather than handed back. - if let Some(asked) = &asked { - if !policy_holds(asked, sandbox.egress_policy.as_ref()) { - // Deleting is safe to do unconditionally here: Azure allocates the id, so the - // one in this response was minted by this call and belongs to no other caller. - // The delete's own failure is carried rather than returned: it would replace the - // finding that matters — that the sandbox is not contained — with a delete error. - let deleted = match self.accept_delete(&sandbox.id).await { - Ok(()) => "it was deleted".to_string(), - Err(error) => format!("deleting it also failed: {error}"), - }; - return Err(AlienError::new(ErrorData::BindingConfigInvalid { - binding_name: "sandbox".to_string(), - env_var: "ALIEN_BINDING_SANDBOX".to_string(), - reason: format!( - "the sandbox was created asking for {} but came up with {}, so {deleted} \ - rather than handed back", - describe(Some(asked)), - describe(sandbox.egress_policy.as_ref()) - ), - })); - } + // 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, asked.as_ref()).await { + Ok(session) => Ok(session), + Err(error) => Err(self.discard(&sandbox.id, error).await), } - - Ok(SandboxSession { - session_id: sandbox.id, - state: session_state("sandbox.create", sandbox.state.as_deref())?, - generation: 1, - }) } async fn get(&self, session_id: &str) -> Result> { - match self + let sandbox = match self .client .get_sandbox(&self.sandbox_group, session_id) .await { - Ok(sandbox) => Ok(Some(SandboxSession { - session_id: sandbox.id, - state: session_state("sandbox.get", sandbox.state.as_deref())?, - generation: 1, - })), + Ok(sandbox) => sandbox, // 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)), + Err(error) if is_not_found(&error) => return Ok(None), + Err(error) => return Err(Self::failed("sandbox.get", error)), + }; + + // Checked here as well as at create, because this is the path a reconnect takes: a + // session created under an older declaration outlives the change — Azure has no session + // ceiling, and an idle sandbox only suspends — so a caller holding its id would otherwise + // be handed a sandbox whose containment is whatever it was built with. + if let Some(asked) = egress_policy(&self.egress) { + if !policy_holds(&asked, sandbox.egress_policy.as_ref()) { + return Err(AlienError::new(ErrorData::SandboxNotAsDeclared { + session_id: sandbox.id, + restriction: "egress policy".to_string(), + reason: format!( + "it is running {} where the declaration asks for {}", + describe(sandbox.egress_policy.as_ref()), + describe(Some(&asked)) + ), + })); + } } + + Ok(Some(SandboxSession { + session_id: sandbox.id, + state: session_state("sandbox.get", sandbox.state.as_deref())?, + generation: 1, + })) } async fn get_or_create(&self, request: CreateSessionRequest) -> Result { if let Some(id) = request.session_id.as_deref() { // A session on its way out is not one to reconnect to: the id will not run again, and // handing it back trades an error now for a command that never lands. - match self.get(id).await? { - Some(existing) if existing.state != SandboxSessionState::Terminated => { - return Ok(existing) + if let Some(existing) = self.get(id).await? { + match existing.state { + SandboxSessionState::Terminated => {} + // `create` returns a session that can take work, and reaching one someone + // else started has to mean the same thing — an idle sandbox suspends itself, + // so this is the ordinary resting state rather than an edge. + SandboxSessionState::Suspended => { + self.resume(id).await?; + return self.await_running(id).await; + } + SandboxSessionState::Starting => return self.await_running(id).await, + SandboxSessionState::Running => return Ok(existing), } - _ => {} } } @@ -283,7 +286,7 @@ impl Sandbox for AzureSandbox { } async fn read_file(&self, session_id: &str, path: &str) -> Result> { - checked_path("sandbox.readFile", path)?; + let path = &checked_path("sandbox.readFile", path)?; self.client .read_file(&self.sandbox_group, session_id, path) @@ -292,10 +295,16 @@ impl Sandbox for AzureSandbox { } async fn write_files(&self, session_id: &str, files: BTreeMap>) -> Result<()> { + // Checked before anything is written: partial application is the contract for a data + // plane that refuses midway, not for a path this process could have rejected first. + let files = files + .into_iter() + .map(|(path, contents)| Ok((checked_path("sandbox.writeFiles", &path)?, contents))) + .collect::>>()?; + // 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 { - checked_path("sandbox.writeFiles", &path)?; self.client .write_file(&self.sandbox_group, session_id, &path, contents) @@ -307,7 +316,7 @@ impl Sandbox for AzureSandbox { } async fn mkdir(&self, session_id: &str, path: &str) -> Result<()> { - checked_path("sandbox.mkdir", path)?; + let path = &checked_path("sandbox.mkdir", path)?; self.client .mkdir(&self.sandbox_group, session_id, path) @@ -321,9 +330,7 @@ impl Sandbox for AzureSandbox { async fn suspend(&self, session_id: &str) -> Result<()> { // Accepted, not completed — the same contract the AWS backend follows. A caller that - // needs the session to have stopped polls `get` for `Suspended`. Suspending an - // already-stopped session, which a caller racing the idle policy cannot avoid, answers - // 409 and is reported retryable rather than as a refusal. + // needs the session to have stopped polls `get` for `Suspended`. self.client .stop_sandbox(&self.sandbox_group, session_id) .await @@ -347,9 +354,19 @@ impl Sandbox for AzureSandbox { // 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(()); + match self + .client + .get_sandbox(&self.sandbox_group, session_id) + .await + { + Err(error) if is_not_found(&error) => return Ok(()), + Err(error) => return Err(Self::failed("sandbox.terminate", error)), + Ok(_) => {} } tokio::time::sleep(TERMINATE_POLL_INTERVAL).await; } @@ -369,6 +386,104 @@ impl Sandbox for AzureSandbox { } impl AzureSandbox { + /// 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, + asked: Option<&EgressPolicy>, + ) -> Result { + // A restriction that did not take effect is worse than one that was never asked for: the + // caller believes the sandbox is contained. The response says what the sandbox is running + // under, so this is checked rather than assumed. + if let Some(asked) = asked { + if !policy_holds(asked, sandbox.egress_policy.as_ref()) { + return Err(AlienError::new(ErrorData::SandboxNotAsDeclared { + session_id: sandbox.id.clone(), + restriction: "egress policy".to_string(), + reason: format!( + "it came up with {} where the declaration asks for {}", + describe(sandbox.egress_policy.as_ref()), + describe(Some(asked)) + ), + })); + } + } + + match session_state(CREATE, sandbox.state.as_deref())? { + SandboxSessionState::Running => Ok(SandboxSession { + session_id: sandbox.id.clone(), + state: SandboxSessionState::Running, + generation: 1, + }), + // `create` owes the caller a session that can already take work, so the wait happens + // here rather than in every caller. + _ => self.await_running(&sandbox.id).await, + } + } + + /// Waits for a session to be able to take work. + async fn await_running(&self, session_id: &str) -> Result { + let deadline = std::time::Instant::now() + SESSION_READY_TIMEOUT; + + loop { + let sandbox = self + .client + .get_sandbox(&self.sandbox_group, session_id) + .await + .map_err(|error| Self::failed("sandbox.create", error))?; + + match session_state("sandbox.create", sandbox.state.as_deref())? { + SandboxSessionState::Running => { + return Ok(SandboxSession { + session_id: sandbox.id, + state: SandboxSessionState::Running, + generation: 1, + }) + } + // Only a session on its way up is worth waiting for. A terminated one never + // becomes runnable, and folding it into the timeout would report it a minute late + // as a slow boot. + SandboxSessionState::Starting | SandboxSessionState::Suspended => {} + SandboxSessionState::Terminated => { + return Err(AlienError::new(ErrorData::SandboxCommandFailed { + failure: "sessionTerminated".to_string(), + reason: format!("session '{session_id}' is being deleted"), + })) + } + } + + if std::time::Instant::now() >= deadline { + return Err(AlienError::new(ErrorData::SandboxCommandFailed { + failure: "sessionNotReady".to_string(), + reason: format!( + "session '{session_id}' was still not running after {}s", + SESSION_READY_TIMEOUT.as_secs() + ), + })); + } + tokio::time::sleep(SESSION_READY_INTERVAL).await; + } + } + + /// 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 { + if let Err(error) = self.accept_delete(session_id).await { + warn!( + session = %session_id, + %error, + "could not delete a sandbox that was never handed to its caller" + ); + } + reason + } + /// 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. @@ -460,7 +575,7 @@ fn bounded_shell(command: &[String], deadline: std::time::Duration) -> String { /// confinement there is, and it is a client-side rule rather than a guarantee. Relative only: /// Azure exposes no session root to rewrite an absolute path against, so accepting one would hand /// the caller the sandbox's whole filesystem instead of its own directory. -fn checked_path(operation: &str, path: &str) -> Result<()> { +fn checked_path(operation: &str, path: &str) -> Result { let refused = |details: &str| { Err(AlienError::new(ErrorData::InvalidInput { operation_context: operation.to_string(), @@ -474,20 +589,21 @@ fn checked_path(operation: &str, path: &str) -> Result<()> { if path.ends_with('/') { return refused("must not end in '/'"); } - if path.is_empty() { + // 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 path.starts_with('/') { - return refused("must be relative to the sandbox's own directory"); - } - if path.contains('\0') { + if relative.contains('\0') { return refused("contains a null byte"); } - if path.split('/').any(|part| part == ".." || part.is_empty()) { + if relative.split('/').any(|part| part == ".." || part.is_empty()) { return refused("must not traverse"); } - Ok(()) + Ok(relative.to_string()) } /// The policy a declared mode is created with. @@ -501,6 +617,7 @@ 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()), @@ -544,31 +661,40 @@ fn policy_holds(asked: &EgressPolicy, effective: Option<&EgressPolicy>) -> bool return false; }; - let allowed = |host: &str| { + let asked_for = |host: &str| { asked .host_rules .iter() - .any(|rule| rule.action == ALLOW && rule.pattern == host) + .any(|rule| rule.action.eq_ignore_ascii_case(ALLOW) && rule.pattern == host) }; - effective.default_action == asked.default_action - && effective.traffic_inspection.as_deref() == Some(FULL_INSPECTION) - && asked - .host_rules - .iter() - .all(|rule| effective.host_rules.contains(rule)) + effective.default_action.eq_ignore_ascii_case(&asked.default_action) && effective - .host_rules - .iter() - .all(|rule| rule.action != ALLOW || allowed(&rule.pattern)) - // An advanced rule is refused outright rather than matched host by host: this client - // never sends one, so an `Allow` here came from somewhere else, and `Transform` and - // `Rewrite` reach a host by rewriting the request rather than by naming it. + .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 == DENY) + .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. @@ -598,7 +724,11 @@ fn session_state(operation: &str, state: Option<&str>) -> Result Ok(SandboxSessionState::Suspended), + // `Stopping` is still running, and reporting it suspended would answer the poll + // `suspend` documents while the sandbox is still up — the same early "it is contained" + // that `terminate` refuses by polling to a 404 rather than trusting the accepted call. + Some("Stopping") => Ok(SandboxSessionState::Running), + Some("Stopped" | "Suspended" | "Idle") => Ok(SandboxSessionState::Suspended), Some("Deleting") => Ok(SandboxSessionState::Terminated), other => Err(AlienError::new(ErrorData::UnexpectedResponseFormat { provider: "azure".to_string(), @@ -619,8 +749,16 @@ 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 = "*"; -/// The one operation a repeat could run twice. +/// 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"; + +/// 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. /// @@ -1101,31 +1239,34 @@ mod tests { client.expect_mkdir().never(); let sandbox = sandbox_with(client); - for path in ["../etc/shadow", "/etc/shadow", "", "work/", "a//b", "a/../../b"] { + for path in ["../etc/shadow", "", "/", "work/", "a//b", "a/../../b", "/../escape"] { let error = sandbox .read_file("s1", path) .await - .expect_err("'{path}' must be refused"); + .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("'{path}' must be refused on write too"); + .expect_err(&format!("'{path}' must be refused on write too")); sandbox .mkdir("s1", path) .await - .expect_err("'{path}' must be refused on mkdir too"); + .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() - .times(2) + .withf(|_, _, path| !path.starts_with('/')) + .times(3) .returning(|_, _, _| Ok(Vec::new())); let sandbox = sandbox_with(client); - for path in ["app.py", "src/app.py"] { + for path in ["app.py", "src/app.py", "/work/app.py"] { sandbox .read_file("s1", path) .await @@ -1154,6 +1295,29 @@ mod tests { 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] @@ -1247,7 +1411,8 @@ mod tests { ("Running", SandboxSessionState::Running), ("Creating", SandboxSessionState::Starting), ("Resuming", SandboxSessionState::Starting), - ("Stopping", SandboxSessionState::Suspended), + // Still up: a sandbox that has been asked to stop has not stopped. + ("Stopping", SandboxSessionState::Running), ("Stopped", SandboxSessionState::Suspended), ("Suspended", SandboxSessionState::Suspended), ("Idle", SandboxSessionState::Suspended), @@ -1314,6 +1479,14 @@ mod tests { }) }); + // 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, @@ -1323,11 +1496,7 @@ mod tests { .await .expect("the create should succeed"); - assert_eq!( - session.state, - SandboxSessionState::Starting, - "a sandbox still being created is not one a command can reach" - ); + assert_eq!(session.state, SandboxSessionState::Running); } fn running(id: &str, egress: Option) -> alien_azure_clients::azure::sandbox_data_plane::Sandbox { @@ -1439,6 +1608,7 @@ mod tests { // 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()), @@ -1446,6 +1616,7 @@ mod tests { // 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()), @@ -1468,7 +1639,7 @@ mod tests { .await .expect_err("a sandbox without its policy must not be handed back"); - assert_eq!(error.code, "BINDING_CONFIG_INVALID", "{error}"); + assert_eq!(error.code, "SANDBOX_NOT_AS_DECLARED", "{error}"); } } @@ -1483,6 +1654,7 @@ mod tests { "s1", Some(EgressPolicy { default_action: "Deny".to_string(), + unmodelled: Default::default(), rules: Vec::new(), host_rules: vec![EgressHostRule { pattern: "elsewhere.example.com".to_string(), @@ -1504,7 +1676,7 @@ mod tests { .await .expect_err("a host the declaration named must be in the effective policy"); - assert_eq!(error.code, "BINDING_CONFIG_INVALID", "{error}"); + assert_eq!(error.code, "SANDBOX_NOT_AS_DECLARED", "{error}"); } /// A session that is going away is not one to reconnect to. @@ -1557,6 +1729,7 @@ mod tests { // A second host, allowed. EgressPolicy { default_action: "Deny".to_string(), + unmodelled: Default::default(), host_rules: vec![ declared.clone(), EgressHostRule { @@ -1570,6 +1743,7 @@ mod tests { // 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 { r#match: Some(EgressRuleMatch { @@ -1595,7 +1769,7 @@ mod tests { .await .expect_err("a permission nobody asked for must fail the create"); - assert_eq!(error.code, "BINDING_CONFIG_INVALID", "{error}"); + assert_eq!(error.code, "SANDBOX_NOT_AS_DECLARED", "{error}"); } // The same policy without the extra permission creates normally, so the rule above is @@ -1606,6 +1780,7 @@ mod tests { "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(), @@ -1680,4 +1855,129 @@ mod tests { .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(); + client.expect_create_sandbox().times(1).returning(|_, _| { + Ok(alien_azure_clients::azure::sandbox_data_plane::Sandbox { + id: "orphan".to_string(), + egress_policy: None, + state: Some("Hibernated".to_string()), + }) + }); + 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())))); + 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()), + }), + )) + }); + sandbox_denying(client, declared()) + .create(CreateSessionRequest::default()) + .await + .expect("a normalised echo of the same policy is the same policy"); + } } diff --git a/crates/alien-bindings/src/providers/sandbox/gcp.rs b/crates/alien-bindings/src/providers/sandbox/gcp.rs index 4ade2ab3f..b38e5d0ac 100644 --- a/crates/alien-bindings/src/providers/sandbox/gcp.rs +++ b/crates/alien-bindings/src/providers/sandbox/gcp.rs @@ -400,6 +400,7 @@ impl From for CommandOutput { #[cfg(test)] mod tests { use super::*; + use futures::StreamExt; use alien_core::bindings::BindingValue; /// A fake launcher that rejects argv the real one rejects. @@ -408,7 +409,7 @@ mod tests { /// 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_launcher`'s checks. A fake that accepts + /// `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) { @@ -598,7 +599,7 @@ done .expect("a session environment is carried, not refused"); // The stream has to be drained: dropping it undrained kills the child before it runs. - if let Ok(mut frames) = sandbox + let mut frames = sandbox .run_command( "s1", RunCommandRequest { @@ -609,10 +610,8 @@ done }, ) .await - { - use futures::StreamExt; - while frames.next().await.is_some() {} - } + .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(); diff --git a/crates/alien-cloudformation/src/emitters/aws/sandbox.rs b/crates/alien-cloudformation/src/emitters/aws/sandbox.rs index 01481113e..004d5c72e 100644 --- a/crates/alien-cloudformation/src/emitters/aws/sandbox.rs +++ b/crates/alien-cloudformation/src/emitters/aws/sandbox.rs @@ -582,8 +582,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 deploy to Azure, whose \ - egress proxy matches on host pattern" + 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/resources/sandbox.rs b/crates/alien-core/src/resources/sandbox.rs index 137c8170b..bb282b474 100644 --- a/crates/alien-core/src/resources/sandbox.rs +++ b/crates/alien-core/src/resources/sandbox.rs @@ -628,6 +628,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)?; } @@ -1383,4 +1398,32 @@ mod tests { 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 c37b7462a..76f3a3375 100644 --- a/crates/alien-helm/src/emitters/sandbox.rs +++ b/crates/alien-helm/src/emitters/sandbox.rs @@ -58,23 +58,11 @@ impl HelmEmitter for SandboxEmitter { }) })?; - // A hostname list has no NetworkPolicy to render into — it matches CIDRs — so it is - // refused rather than widened to the `allow` rule, which would open every address the - // declaration meant to exclude. - if let SandboxEgress::AllowDomains { .. } = sandbox.egress { - return Err(AlienError::new(ErrorData::OperationNotSupported { - operation: format!("helm emit sandbox '{}'", ctx.resource_id), - reason: "a Kubernetes NetworkPolicy matches addresses, not names, so a hostname \ - list has nothing to render into. Declare egress: deny, or deploy to \ - Azure, whose egress proxy matches on host pattern" - .to_string(), - })); - } 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 @@ -91,12 +79,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(), - // `AllowDomains` never reaches here: the emitter refuses it rather than render it as the - // `allow` rule below, which permits every address the list meant to exclude. - 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")) @@ -112,7 +109,7 @@ fn network_policy(sandbox: &Sandbox) -> String { } }; - format!( + Ok(format!( r#"apiVersion: networking.k8s.io/v1 kind: NetworkPolicy metadata: @@ -140,7 +137,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/resource_layer_tests.rs b/crates/alien-helm/tests/generator/resource_layer_tests.rs index 5c552a15a..51f519092 100644 --- a/crates/alien-helm/tests/generator/resource_layer_tests.rs +++ b/crates/alien-helm/tests/generator/resource_layer_tests.rs @@ -191,8 +191,9 @@ fn a_hostname_allowlist_is_refused_rather_than_widened() { let error = try_render(&stack, StackSettings::default()) .expect_err("a hostname list must be refused rather than approximated"); + assert_eq!(error.code, "OPERATION_NOT_SUPPORTED", "{error}"); assert!( - error.to_string().contains("matches addresses, not names"), - "the refusal must name why: {error}" + error.to_string().contains("agent"), + "the refusal must name the sandbox it is about: {error}" ); } diff --git a/crates/alien-terraform/src/emitters/aws/sandbox.rs b/crates/alien-terraform/src/emitters/aws/sandbox.rs index f8d3cffb7..c0edbb1f4 100644 --- a/crates/alien-terraform/src/emitters/aws/sandbox.rs +++ b/crates/alien-terraform/src/emitters/aws/sandbox.rs @@ -631,8 +631,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 deploy to Azure, whose \ - egress proxy matches on host pattern" + 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 7e98a9500..49303384f 100644 --- a/crates/alien-terraform/src/emitters/azure/sandbox.rs +++ b/crates/alien-terraform/src/emitters/azure/sandbox.rs @@ -1,13 +1,13 @@ //! Azure Sandbox — a named group, and nothing built at setup. //! -//! The ACA sandbox group is created at runtime, 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 +//! 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. -//! -//! Nothing in this repository creates that group: `create_or_update_sandbox_group` has no caller, -//! and the controller registry holds only the Local and Kubernetes sandbox controllers. use crate::{ emitter::{TfEmitter, TfFragment}, @@ -71,15 +71,20 @@ fn catalog_disk_image(sandbox: &Sandbox) -> Result { }; match &sandbox.code { - SandboxCode::Image { image } if image.contains('/') => Err(unsupported(format!( - "Azure creates a sandbox from a public catalog disk image, so code.image must be a \ - catalog name such as 'ubuntu', not the registry reference '{image}'" - ))), + // A tag is the shape that gets through unnoticed: `ubuntu:24.04` has no slash, renders + // into the customer's module, plans and applies, and fails at the first session. + SandboxCode::Image { image } + if image.contains('/') || image.contains(':') || image.contains('@') => + { + Err(unsupported(format!( + "Azure creates a sandbox from a public catalog disk image, so code.image must be \ + a bare catalog name such as 'ubuntu' — '{image}' carries a registry path, tag or \ + digest, which the data plane has nowhere to put" + ))) + } SandboxCode::Image { image } => Ok(image.clone()), SandboxCode::Source { .. } => Err(unsupported( - "Azure creates a sandbox from a prebuilt catalog disk image and cannot build one \ - from source" - .to_string(), + "no sandbox backend builds an image from source yet".to_string(), )), } } @@ -185,17 +190,22 @@ mod tests { /// 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(r#""deny""#), "{denied}"); + 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#""allowDomains""#), "{listed}"); - assert!(listed.contains("api.example.com"), "{listed}"); + 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#""allow""#), "{open}"); + assert!(open.contains(r#"mode = "allow""#), "{open}"); } /// The idle-suspend policy travels the same way, and only when it was declared. @@ -205,7 +215,7 @@ mod tests { #[test] fn the_binding_carries_a_declared_idle_suspend_and_nothing_otherwise() { let declared = binding_with(SandboxEgress::Allow, Some(900)); - assert!(declared.contains("900"), "{declared}"); + assert!(declared.contains("idleSuspendSeconds = 900"), "{declared}"); let undeclared = binding_with(SandboxEgress::Allow, None); assert!( diff --git a/crates/alien-terraform/src/emitters/gcp/sandbox.rs b/crates/alien-terraform/src/emitters/gcp/sandbox.rs index 498887e30..c3a4d8998 100644 --- a/crates/alien-terraform/src/emitters/gcp/sandbox.rs +++ b/crates/alien-terraform/src/emitters/gcp/sandbox.rs @@ -26,8 +26,7 @@ fn refuse_unsupported_egress(sandbox: &Sandbox) -> Result<()> { 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 deploy to Azure, \ - whose egress proxy matches on host pattern" + list has nothing to render into. Declare egress: deny or egress: allow" .to_string(), })), } diff --git a/packages/core/src/generated/schemas/sandbox.json b/packages/core/src/generated/schemas/sandbox.json index c9dacc58e..152b8b16b 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`)"},"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.\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/sandboxEgress.json b/packages/core/src/generated/schemas/sandboxEgress.json index 8beabf0ec..b88385e76 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`.","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/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. From 22e1767c8f5fe4017f32650abd684917850b6fe5 Mon Sep 17 00:00:00 2001 From: Itamar Zand <133867530+ItamarZand88@users.noreply.github.com> Date: Sat, 22 Aug 2026 03:19:51 +0300 Subject: [PATCH 12/29] docs(sandbox): state what the code does, not what it used to do MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The comment pass found eleven comments narrating the change rather than the code: a regression test explaining the bug it came from, three "used to" and "before this" asides, and a rationale stated twice — once at the orchestrating function and again at the call site. The deadline guard's reason now lives at `run_command`, and `execute_within` carries the local note plus a pointer. --- .../src/azure/sandbox_data_plane.rs | 2 +- crates/alien-bindings/src/provider.rs | 5 ++-- .../src/providers/sandbox/azure.rs | 29 +++++++++---------- .../src/providers/sandbox/gcp.rs | 5 ++-- crates/alien-core/src/resources/sandbox.rs | 14 ++++----- .../tests/generator/resource_layer_tests.rs | 10 +++---- .../src/emitters/azure/sandbox.rs | 4 +-- 7 files changed, 32 insertions(+), 37 deletions(-) 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 565a3fa37..9c6760033 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 diff --git a/crates/alien-bindings/src/provider.rs b/crates/alien-bindings/src/provider.rs index be9277302..1099a3ee3 100644 --- a/crates/alien-bindings/src/provider.rs +++ b/crates/alien-bindings/src/provider.rs @@ -2231,9 +2231,8 @@ mod tests { /// The image in the binding has to be the image the provider uses. /// - /// This asserts the seam the previous code got wrong: the value was read from nowhere and a - /// literal was passed instead, so every session ran a stock image whatever the stack declared - /// — and nothing failed, because a sandbox on the wrong image still starts. + /// 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() { diff --git a/crates/alien-bindings/src/providers/sandbox/azure.rs b/crates/alien-bindings/src/providers/sandbox/azure.rs index 0b12b5bb0..22e37921e 100644 --- a/crates/alien-bindings/src/providers/sandbox/azure.rs +++ b/crates/alien-bindings/src/providers/sandbox/azure.rs @@ -16,8 +16,7 @@ use crate::traits::{ SandboxSession, SandboxSessionState, }; use alien_azure_clients::azure::sandbox_data_plane::{ - CreateSandbox, EgressHostRule, EgressPolicy, EgressRule, EgressRuleAction, EgressRuleMatch, - SandboxDataPlaneApi, + CreateSandbox, EgressHostRule, EgressPolicy, SandboxDataPlaneApi, }; use alien_client_core::ErrorData as ClientErrorData; use alien_core::{Platform, SandboxCapabilities, SandboxEgress}; @@ -484,17 +483,14 @@ impl AzureSandbox { reason } - /// Runs one shell string under the client-side guard. + /// 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. /// - /// 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. + /// 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, @@ -804,6 +800,9 @@ fn is_not_found(error: &AlienError) -> bool { #[cfg(test)] mod tests { use super::*; + use alien_azure_clients::azure::sandbox_data_plane::{ + EgressRule, EgressRuleAction, EgressRuleMatch, + }; use alien_azure_clients::azure::sandbox_data_plane::ExecResult; use alien_azure_clients::azure::sandbox_data_plane::MockSandboxDataPlaneApi; use futures::StreamExt; @@ -907,15 +906,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(), diff --git a/crates/alien-bindings/src/providers/sandbox/gcp.rs b/crates/alien-bindings/src/providers/sandbox/gcp.rs index b38e5d0ac..0833f1141 100644 --- a/crates/alien-bindings/src/providers/sandbox/gcp.rs +++ b/crates/alien-bindings/src/providers/sandbox/gcp.rs @@ -634,8 +634,7 @@ done } /// 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. Both were - /// wrong here, and neither could be caught by a fake that accepted any argv. + /// `--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"); @@ -659,7 +658,7 @@ done } /// 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"); diff --git a/crates/alien-core/src/resources/sandbox.rs b/crates/alien-core/src/resources/sandbox.rs index bb282b474..b517b77a2 100644 --- a/crates/alien-core/src/resources/sandbox.rs +++ b/crates/alien-core/src/resources/sandbox.rs @@ -468,9 +468,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(), @@ -1255,9 +1255,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()) @@ -1362,7 +1362,7 @@ mod tests { .expect_err("renaming a sandbox is not an update"); } - /// An idle-suspend policy is now declarable on Azure, and a wall-clock ceiling still is not. + /// 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 diff --git a/crates/alien-helm/tests/generator/resource_layer_tests.rs b/crates/alien-helm/tests/generator/resource_layer_tests.rs index 51f519092..03872b559 100644 --- a/crates/alien-helm/tests/generator/resource_layer_tests.rs +++ b/crates/alien-helm/tests/generator/resource_layer_tests.rs @@ -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()) diff --git a/crates/alien-terraform/src/emitters/azure/sandbox.rs b/crates/alien-terraform/src/emitters/azure/sandbox.rs index 49303384f..ddfa479b7 100644 --- a/crates/alien-terraform/src/emitters/azure/sandbox.rs +++ b/crates/alien-terraform/src/emitters/azure/sandbox.rs @@ -60,8 +60,8 @@ fn egress(sandbox: &Sandbox) -> Expression { /// /// The create body names a public catalog image, so a registry reference has nowhere to go. /// Refusing at plan time follows the AWS emitter: a reference the backend cannot honour is -/// rejected rather than quietly replaced, which is what happened before this existed — every -/// Azure session ran a stock image whatever the declaration said, with no error anywhere. +/// rejected rather than quietly replaced — silently ignoring it would run a stock image +/// whatever the declaration said, with no error anywhere. fn catalog_disk_image(sandbox: &Sandbox) -> Result { let unsupported = |reason: String| { AlienError::new(ErrorData::OperationNotSupported { From 52896f30b427d8c45ea2378ca845aad085b8e176 Mon Sep 17 00:00:00 2001 From: Itamar Zand <133867530+ItamarZand88@users.noreply.github.com> Date: Sat, 22 Aug 2026 03:26:41 +0300 Subject: [PATCH 13/29] fix(sandbox): refuse a GCP session id the launcher would read as a flag MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `run` takes the sandbox id positionally and `--allow-egress` is one of its own flags, so an application passing `--allow-egress` as its session id was asking for the egress its binding had refused it — the one setting the binding decides rather than the caller. The doc comment three lines above says exactly that, and the argv defeated it. Reachable because create now works: the id used to sit behind a flag the launcher rejected outright. The id is checked wherever a caller supplies one — create, exec, the three file operations and terminate — rather than at the one verb that has `--allow-egress`, because every verb takes it positionally and each has its own flags. --- .../src/providers/sandbox/azure.rs | 17 ++-- .../src/providers/sandbox/gcp.rs | 81 +++++++++++++++++++ 2 files changed, 92 insertions(+), 6 deletions(-) diff --git a/crates/alien-bindings/src/providers/sandbox/azure.rs b/crates/alien-bindings/src/providers/sandbox/azure.rs index 22e37921e..685376b8d 100644 --- a/crates/alien-bindings/src/providers/sandbox/azure.rs +++ b/crates/alien-bindings/src/providers/sandbox/azure.rs @@ -195,9 +195,11 @@ impl Sandbox for AzureSandbox { // so this is the ordinary resting state rather than an edge. SandboxSessionState::Suspended => { self.resume(id).await?; - return self.await_running(id).await; + return self.await_running("sandbox.getOrCreate", id).await; + } + SandboxSessionState::Starting => { + return self.await_running("sandbox.getOrCreate", id).await } - SandboxSessionState::Starting => return self.await_running(id).await, SandboxSessionState::Running => return Ok(existing), } } @@ -419,12 +421,15 @@ impl AzureSandbox { }), // `create` owes the caller a session that can already take work, so the wait happens // here rather than in every caller. - _ => self.await_running(&sandbox.id).await, + _ => self.await_running(CREATE, &sandbox.id).await, } } /// Waits for a session to be able to take work. - async fn await_running(&self, session_id: &str) -> Result { + /// + /// 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. + async fn await_running(&self, operation: &str, session_id: &str) -> Result { let deadline = std::time::Instant::now() + SESSION_READY_TIMEOUT; loop { @@ -432,9 +437,9 @@ impl AzureSandbox { .client .get_sandbox(&self.sandbox_group, session_id) .await - .map_err(|error| Self::failed("sandbox.create", error))?; + .map_err(|error| Self::failed(operation, error))?; - match session_state("sandbox.create", sandbox.state.as_deref())? { + match session_state(operation, sandbox.state.as_deref())? { SandboxSessionState::Running => { return Ok(SandboxSession { session_id: sandbox.id, diff --git a/crates/alien-bindings/src/providers/sandbox/gcp.rs b/crates/alien-bindings/src/providers/sandbox/gcp.rs index 0833f1141..4f7c23559 100644 --- a/crates/alien-bindings/src/providers/sandbox/gcp.rs +++ b/crates/alien-bindings/src/providers/sandbox/gcp.rs @@ -25,6 +25,9 @@ use crate::traits::{ use alien_core::bindings::GcpSandboxBinding; use alien_core::sandbox_process::{self, ProcessFrame, ProcessStream, FRAME_CHANNEL_DEPTH}; use alien_core::{Platform, SandboxCapabilities}; + +/// Longest session id the launcher is asked to take, which is also a container name. +const MAX_SESSION_ID: usize = 63; use alien_error::AlienError; /// How much of one command's output is kept before the terminal frame reports truncation. @@ -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()); @@ -177,6 +207,7 @@ impl Sandbox for GcpSandbox { let session_id = request .session_id .unwrap_or_else(|| uuid::Uuid::new_v4().simple().to_string()); + 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. @@ -232,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")); } @@ -293,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( @@ -308,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); @@ -335,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)) @@ -365,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()], @@ -738,4 +774,49 @@ done .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"); + } } From 2fc9b99d8767ebd6e85bfa497021c616670d004a Mon Sep 17 00:00:00 2001 From: Itamar Zand <133867530+ItamarZand88@users.noreply.github.com> Date: Sat, 22 Aug 2026 04:36:24 +0300 Subject: [PATCH 14/29] fix(sandbox): close what the confirming review found MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The first fix round left three holes and opened one. `execute_shell_command` was the third body a caller controls in that file and the one that was missed: a shell command is where an app puts a token it wants the session to have, and a failure echoes the request into an error chain that reaches durable state. `settle` judged the egress policy on the create response. A create answered while the sandbox is still coming up need not carry the policy yet, and an absent policy reads as "the restriction did not take" — so every deny-declared create would have deleted the sandbox it just made. It waits for the sandbox to be running and judges what came up, which is also the read the containment check should have been making all along. `Stopping` mapped to `Running` so `suspend`'s completion poll would not answer early, and that routed a sandbox on its way down through the reconnect path as ready for work. The four states the trait publishes have no word for it, so it reads as unusable — the honest answer for everything that consumes the enum — and the wait and reconnect paths read the raw state, where the difference is the whole question. A session whose policy no longer matches the declaration was a permanent error from `get_or_create`, which owes the caller a usable session: it is now terminated and replaced, like a terminated one. `unmodelled` caught an unreadable field on the policy but not on its rules, so an exception list on a rule that otherwise reads as a plain deny still passed. The rule structs now model every field the SDK does and refuse anything past it. `SandboxCommandFailed` was fixed `internal = "false"` while wrapping cloud client errors that carry response text, and `into_external` reads only the outermost flag. It inherits, for the reason `SandboxUnreachable` already gives. An Azure session id is interpolated into the data-plane URL, where `..` resolves — reaching a sandbox group a stack-scoped identity can address but this binding was never scoped to. It is checked wherever a caller supplies one, as GCP's now is. And `run_command` re-reads the policy: the SDK hands it an arbitrary session id, so an id kept across a declaration change was the way around the check. --- .../src/azure/sandbox_data_plane.rs | 71 ++- crates/alien-bindings/src/error.rs | 8 +- .../src/providers/sandbox/azure.rs | 444 ++++++++++++++---- .../src/providers/sandbox/gcp.rs | 2 +- .../src/emitters/azure/sandbox.rs | 15 +- 5 files changed, 436 insertions(+), 104 deletions(-) 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 9c6760033..76b4b2f8a 100644 --- a/crates/alien-azure-clients/src/azure/sandbox_data_plane.rs +++ b/crates/alien-azure-clients/src/azure/sandbox_data_plane.rs @@ -76,9 +76,17 @@ pub struct EgressPolicy { /// /// 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")] +#[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")] @@ -88,27 +96,45 @@ pub struct EgressRule { pub action: Option, } -/// The host a rule matches. +/// What a rule matches on. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] +#[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")] +#[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")] +#[serde(rename_all = "camelCase", deny_unknown_fields)] pub struct EgressHostRule { /// Host pattern, such as `api.example.com`. pub pattern: String, @@ -449,10 +475,13 @@ 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. + let response = alien_client_core::redact_request_body( + self.base + .execute_request(signed, "ExecuteShellCommand", sandbox_id) + .await, + )?; Self::parse(response, "ExecuteShellCommand").await } @@ -912,4 +941,28 @@ mod tests { 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 b66cf65c2..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 { @@ -323,7 +327,7 @@ pub enum ErrorData { /// behind if deleting it also failed. #[error( code = "SANDBOX_NOT_AS_DECLARED", - message = "Sandbox session '{session_id}' came up without its declared {restriction}: {reason}", + 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 diff --git a/crates/alien-bindings/src/providers/sandbox/azure.rs b/crates/alien-bindings/src/providers/sandbox/azure.rs index 685376b8d..007c71685 100644 --- a/crates/alien-bindings/src/providers/sandbox/azure.rs +++ b/crates/alien-bindings/src/providers/sandbox/azure.rs @@ -68,6 +68,32 @@ impl AzureSandbox { &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(), @@ -146,6 +172,7 @@ impl Sandbox for AzureSandbox { } async fn get(&self, session_id: &str) -> Result> { + Self::checked_session_id("sandbox.get", session_id)?; let sandbox = match self .client .get_sandbox(&self.sandbox_group, session_id) @@ -185,23 +212,31 @@ impl Sandbox for AzureSandbox { async fn get_or_create(&self, request: CreateSessionRequest) -> Result { if let Some(id) = request.session_id.as_deref() { - // A session on its way out is not one to reconnect to: the id will not run again, and - // handing it back trades an error now for a command that never lands. - if let Some(existing) = self.get(id).await? { - match existing.state { - SandboxSessionState::Terminated => {} - // `create` returns a session that can take work, and reaching one someone - // else started has to mean the same thing — an idle sandbox suspends itself, - // so this is the ordinary resting state rather than an edge. - SandboxSessionState::Suspended => { - self.resume(id).await?; - return self.await_running("sandbox.getOrCreate", id).await; - } - SandboxSessionState::Starting => { - return self.await_running("sandbox.getOrCreate", id).await - } - SandboxSessionState::Running => return Ok(existing), + match self.get(id).await { + // `create` returns a session that can take work, and reaching one someone else + // started has to mean the same thing. A suspended sandbox is the ordinary resting + // state once an idle policy is set, so the wait resumes it. + Ok(Some(existing)) if existing.state == SandboxSessionState::Running => { + return Ok(existing) + } + Ok(Some(existing)) if existing.state != SandboxSessionState::Terminated => { + let running = self.await_running(GET_OR_CREATE, id).await?; + return Ok(SandboxSession { + session_id: running.id, + state: SandboxSessionState::Running, + generation: 1, + }); } + // Terminated, or gone: both mean this id cannot serve, so a fresh session is what + // "get or create" owes the caller. + Ok(_) => {} + // A session the declaration no longer matches is as unusable as a terminated one, + // and leaving it running bills for a sandbox nothing can reach through this + // binding. Replaced rather than returned as a permanent error. + Err(error) if error.code == "SANDBOX_NOT_AS_DECLARED" => { + self.terminate(id).await?; + } + Err(error) => return Err(error), } } @@ -217,6 +252,18 @@ impl Sandbox for AzureSandbox { session_id: &str, request: RunCommandRequest, ) -> Result>> { + Self::checked_session_id(RUN_COMMAND, session_id)?; + // 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.get(session_id).await?.ok_or_else(|| { + AlienError::new(ErrorData::SandboxCommandFailed { + failure: "sessionGone".to_string(), + reason: format!("session '{session_id}' does not exist"), + }) + })?; + if request.deadline.is_zero() { return Err(AlienError::new(ErrorData::OperationNotSupported { operation: "sandbox.runCommand".to_string(), @@ -287,6 +334,7 @@ impl Sandbox for AzureSandbox { } 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 @@ -296,6 +344,7 @@ impl Sandbox for AzureSandbox { } async fn write_files(&self, session_id: &str, files: BTreeMap>) -> Result<()> { + Self::checked_session_id("sandbox.writeFiles", session_id)?; // Checked before anything is written: partial application is the contract for a data // plane that refuses midway, not for a path this process could have rejected first. let files = files @@ -317,6 +366,7 @@ impl Sandbox for AzureSandbox { } 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 @@ -330,6 +380,7 @@ impl Sandbox for AzureSandbox { } 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. A caller that // needs the session to have stopped polls `get` for `Suspended`. self.client @@ -339,6 +390,7 @@ impl Sandbox for AzureSandbox { } async fn resume(&self, session_id: &str) -> Result<()> { + Self::checked_session_id("sandbox.resume", session_id)?; self.client .resume_sandbox(&self.sandbox_group, session_id) .await @@ -350,6 +402,7 @@ 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 @@ -360,14 +413,17 @@ impl Sandbox for AzureSandbox { // 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 { - match self + // 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 { - Err(error) if is_not_found(&error) => return Ok(()), - Err(error) => return Err(Self::failed("sandbox.terminate", error)), - Ok(_) => {} + if is_not_found(&error) { + return Ok(()); + } } tokio::time::sleep(TERMINATE_POLL_INTERVAL).await; } @@ -396,41 +452,50 @@ impl AzureSandbox { sandbox: &alien_azure_clients::azure::sandbox_data_plane::Sandbox, asked: Option<&EgressPolicy>, ) -> 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 running = self.await_running(CREATE, &sandbox.id).await?; + // A restriction that did not take effect is worse than one that was never asked for: the - // caller believes the sandbox is contained. The response says what the sandbox is running - // under, so this is checked rather than assumed. + // caller believes the sandbox is contained. if let Some(asked) = asked { - if !policy_holds(asked, sandbox.egress_policy.as_ref()) { + if !policy_holds(asked, running.egress_policy.as_ref()) { return Err(AlienError::new(ErrorData::SandboxNotAsDeclared { - session_id: sandbox.id.clone(), + session_id: running.id, restriction: "egress policy".to_string(), reason: format!( "it came up with {} where the declaration asks for {}", - describe(sandbox.egress_policy.as_ref()), + describe(running.egress_policy.as_ref()), describe(Some(asked)) ), })); } } - match session_state(CREATE, sandbox.state.as_deref())? { - SandboxSessionState::Running => Ok(SandboxSession { - session_id: sandbox.id.clone(), - state: SandboxSessionState::Running, - generation: 1, - }), - // `create` owes the caller a session that can already take work, so the wait happens - // here rather than in every caller. - _ => self.await_running(CREATE, &sandbox.id).await, - } + 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. - async fn await_running(&self, operation: &str, session_id: &str) -> Result { + /// + /// 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, + ) -> Result { let deadline = std::time::Instant::now() + SESSION_READY_TIMEOUT; + let mut resumed = false; loop { let sandbox = self @@ -439,23 +504,26 @@ impl AzureSandbox { .await .map_err(|error| Self::failed(operation, error))?; - match session_state(operation, sandbox.state.as_deref())? { - SandboxSessionState::Running => { - return Ok(SandboxSession { - session_id: sandbox.id, - state: SandboxSessionState::Running, - generation: 1, - }) + // 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") => {} + // Going down, or already down. Either way nothing is bringing it up. + Some("Stopping" | "Stopped" | "Suspended" | "Idle") => { + if !resumed { + self.resume(session_id).await?; + resumed = true; + } } - // Only a session on its way up is worth waiting for. A terminated one never - // becomes runnable, and folding it into the timeout would report it a minute late - // as a slow boot. - SandboxSessionState::Starting | SandboxSessionState::Suspended => {} - SandboxSessionState::Terminated => { + // A terminated session never becomes runnable, and folding it into the timeout + // would report it a minute late as a slow boot. + other => { + session_state(operation, other)?; return Err(AlienError::new(ErrorData::SandboxCommandFailed { failure: "sessionTerminated".to_string(), reason: format!("session '{session_id}' is being deleted"), - })) + })); } } @@ -478,14 +546,22 @@ impl AzureSandbox { /// 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 { - if let Err(error) = self.accept_delete(session_id).await { - warn!( - session = %session_id, - %error, - "could not delete a sandbox that was never handed to its caller" - ); - } - reason + 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" + ); + // A fixed clause rather than the delete's own error: that text is the cloud client's, and + // this variant is externally visible. + reason.context(ErrorData::SandboxNotAsDeclared { + session_id: session_id.to_string(), + restriction: "egress policy".to_string(), + reason: "it could not be deleted either, so it is still running".to_string(), + }) } /// Runs one shell string under the client-side guard, which is the deadline plus the grace @@ -570,12 +646,13 @@ fn bounded_shell(command: &[String], deadline: std::time::Duration) -> String { ) } -/// Refuses a caller's path before it reaches the data plane. +/// Refuses a caller's path before it reaches the data plane, and returns what to send. /// -/// Whether the server bounds a path to a root is undocumented and unmeasured, so this is the only -/// confinement there is, and it is a client-side rule rather than a guarantee. Relative only: -/// Azure exposes no session root to rewrite an absolute path against, so accepting one would hand -/// the caller the sandbox's whole filesystem instead of its own directory. +/// 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 { @@ -702,6 +779,15 @@ fn policy_holds(asked: &EgressPolicy, effective: Option<&EgressPolicy>) -> bool 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, @@ -725,11 +811,10 @@ fn session_state(operation: &str, state: Option<&str>) -> Result Ok(SandboxSessionState::Running), - Some("Stopped" | "Suspended" | "Idle") => Ok(SandboxSessionState::Suspended), + // 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") => Ok(SandboxSessionState::Terminated), other => Err(AlienError::new(ErrorData::UnexpectedResponseFormat { provider: "azure".to_string(), @@ -750,12 +835,16 @@ 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 the data plane is addressed with, matching the launcher-side bound. +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); @@ -822,6 +911,13 @@ 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), @@ -853,6 +949,7 @@ mod tests { state: Some("Running".to_string()), }) }); + settles_running(&mut client, None); let sandbox = AzureSandbox::new( std::sync::Arc::new(client), @@ -1386,6 +1483,7 @@ mod tests { 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) @@ -1415,8 +1513,8 @@ mod tests { ("Running", SandboxSessionState::Running), ("Creating", SandboxSessionState::Starting), ("Resuming", SandboxSessionState::Starting), - // Still up: a sandbox that has been asked to stop has not stopped. - ("Stopping", SandboxSessionState::Running), + // 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), @@ -1554,6 +1652,19 @@ mod tests { ); 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 @@ -1576,6 +1687,19 @@ mod tests { ); 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 { @@ -1597,6 +1721,7 @@ mod tests { ); Ok(running("s1", None)) }); + settles_running(&mut client, None); sandbox_denying(client, SandboxEgress::Allow) .create(CreateSessionRequest::default()) .await @@ -1632,6 +1757,7 @@ mod tests { .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") @@ -1653,21 +1779,22 @@ mod tests { #[tokio::test] async fn a_missing_host_rule_fails_the_create() { let mut client = MockSandboxDataPlaneApi::new(); - client.expect_create_sandbox().times(1).returning(|_, _| { - Ok(running( - "s1", - Some(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 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( @@ -1701,6 +1828,7 @@ mod tests { .expect_create_sandbox() .times(1) .returning(|_, _| Ok(running("fresh", None))); + settles_running(&mut client, None); let session = sandbox_with(client) .get_or_create(CreateSessionRequest { @@ -1750,11 +1878,18 @@ mod tests { 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()), @@ -1766,6 +1901,7 @@ mod tests { .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()) @@ -1794,6 +1930,19 @@ mod tests { }), )) }); + 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 @@ -1845,6 +1994,7 @@ mod tests { .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), @@ -1888,13 +2038,15 @@ mod tests { #[tokio::test] async fn a_create_that_cannot_be_read_deletes_what_it_made() { let mut client = MockSandboxDataPlaneApi::new(); - client.expect_create_sandbox().times(1).returning(|_, _| { + 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") @@ -1952,6 +2104,7 @@ mod tests { .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()) @@ -1979,9 +2132,128 @@ mod tests { }), )) }); + 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 — returning the refusal forever would leave the caller with + /// no way forward and the old sandbox still running. + #[tokio::test] + async fn a_stale_policy_session_is_replaced_rather_than_refused_forever() { + let mut client = MockSandboxDataPlaneApi::new(); + // The stale session answers once, is deleted, and is gone from then on; the fresh one + // answers its own readiness read. + let mut stale_reads = 0; + client.expect_get_sandbox().returning(move |_, id| { + if id != "built-under-allow" { + return 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()), + }), + )); + } + stale_reads += 1; + if stale_reads == 1 { + Ok(running(id, None)) + } else { + Err(http_error(404, "SandboxNotFound")) + } + }); + client + .expect_delete_sandbox() + .withf(|_, id| id == "built-under-allow") + .times(1) + .returning(|_, _| Ok(())); + 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))); + 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}"); + } } diff --git a/crates/alien-bindings/src/providers/sandbox/gcp.rs b/crates/alien-bindings/src/providers/sandbox/gcp.rs index 4f7c23559..4959feeb2 100644 --- a/crates/alien-bindings/src/providers/sandbox/gcp.rs +++ b/crates/alien-bindings/src/providers/sandbox/gcp.rs @@ -25,10 +25,10 @@ use crate::traits::{ use alien_core::bindings::GcpSandboxBinding; use alien_core::sandbox_process::{self, ProcessFrame, ProcessStream, FRAME_CHANNEL_DEPTH}; 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; -use alien_error::AlienError; /// How much of one command's output is kept before the terminal frame reports truncation. const OUTPUT_CAP: usize = 8 * 1024 * 1024; diff --git a/crates/alien-terraform/src/emitters/azure/sandbox.rs b/crates/alien-terraform/src/emitters/azure/sandbox.rs index ddfa479b7..46546e919 100644 --- a/crates/alien-terraform/src/emitters/azure/sandbox.rs +++ b/crates/alien-terraform/src/emitters/azure/sandbox.rs @@ -22,7 +22,7 @@ use hcl::expr::Expression; #[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 @@ -74,12 +74,15 @@ fn catalog_disk_image(sandbox: &Sandbox) -> Result { // A tag is the shape that gets through unnoticed: `ubuntu:24.04` has no slash, renders // into the customer's module, plans and applies, and fails at the first session. SandboxCode::Image { image } - if image.contains('/') || image.contains(':') || image.contains('@') => + if image.trim().is_empty() + || image.contains('/') + || image.contains(':') + || image.contains('@') => { Err(unsupported(format!( "Azure creates a sandbox from a public catalog disk image, so code.image must be \ - a bare catalog name such as 'ubuntu' — '{image}' carries a registry path, tag or \ - digest, which the data plane has nowhere to put" + a bare catalog name such as 'ubuntu'; '{image}' is empty or carries a registry \ + path, tag or digest, which the data plane has nowhere to put" ))) } SandboxCode::Image { image } => Ok(image.clone()), @@ -91,8 +94,8 @@ fn catalog_disk_image(sandbox: &Sandbox) -> Result { 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()) } From ca7f8165041482b19b8fe3ea45ca3b8c3ea02396 Mon Sep 17 00:00:00 2001 From: Itamar Zand <133867530+ItamarZand88@users.noreply.github.com> Date: Sat, 22 Aug 2026 05:02:13 +0300 Subject: [PATCH 15/29] fix(sandbox): judge a session's state before its policy, and gate resume MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `get` read the egress policy before the state, so a session being deleted — which carries no policy — was reported as one running the wrong one. Under a `deny` declaration that sent a disappearing sandbox down the replace path instead of the terminated one. The test that should have caught it declared `allow`, which skips the check entirely; it declares `deny` now. `resume` is the other verb that puts code back on the network, and it did no policy read, so an id kept across a declaration change could be resumed around the check that `run_command` performs. The wait resumes through an ungated path instead, because a sandbox mid-boot has no policy to judge yet — gating both would re-break the case `settle` was restructured to fix. Reconnecting now re-judges the policy on the sandbox that came up rather than the one that was found asleep: a group-scoped policy is set somewhere this binding never writes, so the read before the wait is not the read that decides. And `discard` names the sandbox it left behind rather than attributing every failure to the egress policy — a readiness timeout reached the caller as a containment failure, pointing at a restriction that was never the finding. --- .../src/providers/sandbox/azure.rs | 283 ++++++++++++++---- 1 file changed, 231 insertions(+), 52 deletions(-) diff --git a/crates/alien-bindings/src/providers/sandbox/azure.rs b/crates/alien-bindings/src/providers/sandbox/azure.rs index 007c71685..f0fce57a6 100644 --- a/crates/alien-bindings/src/providers/sandbox/azure.rs +++ b/crates/alien-bindings/src/providers/sandbox/azure.rs @@ -160,12 +160,17 @@ impl Sandbox for AzureSandbox { .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 let Err(error) = Self::checked_session_id(CREATE, &sandbox.id) { + return Err(self.discard(&sandbox.id, error).await); + } // 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, asked.as_ref()).await { + match self.settle(&sandbox).await { Ok(session) => Ok(session), Err(error) => Err(self.discard(&sandbox.id, error).await), } @@ -185,27 +190,23 @@ impl Sandbox for AzureSandbox { Err(error) => return Err(Self::failed("sandbox.get", error)), }; + let state = session_state("sandbox.get", sandbox.state.as_deref())?; + // Checked here as well as at create, because this is the path a reconnect takes: a // session created under an older declaration outlives the change — Azure has no session // ceiling, and an idle sandbox only suspends — so a caller holding its id would otherwise // be handed a sandbox whose containment is whatever it was built with. - if let Some(asked) = egress_policy(&self.egress) { - if !policy_holds(&asked, sandbox.egress_policy.as_ref()) { - return Err(AlienError::new(ErrorData::SandboxNotAsDeclared { - session_id: sandbox.id, - restriction: "egress policy".to_string(), - reason: format!( - "it is running {} where the declaration asks for {}", - describe(sandbox.egress_policy.as_ref()), - describe(Some(&asked)) - ), - })); - } + // + // Not a session on its way out: a sandbox being deleted carries no policy to judge, and + // reading that absence as a mismatch would report a disappearing session as an + // uncontained one. + if state != SandboxSessionState::Terminated { + self.policy_must_hold(&sandbox)?; } Ok(Some(SandboxSession { session_id: sandbox.id, - state: session_state("sandbox.get", sandbox.state.as_deref())?, + state, generation: 1, })) } @@ -220,7 +221,10 @@ impl Sandbox for AzureSandbox { return Ok(existing) } Ok(Some(existing)) if existing.state != SandboxSessionState::Terminated => { + // Judged again on the sandbox that came up: a policy set on the group can + // change while a session is suspended, and the read above saw a stopped one. let running = self.await_running(GET_OR_CREATE, id).await?; + self.policy_must_hold(&running)?; return Ok(SandboxSession { session_id: running.id, state: SandboxSessionState::Running, @@ -253,6 +257,13 @@ impl Sandbox for AzureSandbox { 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(), + reason: "a command must carry a non-zero deadline".to_string(), + })); + } + // 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 @@ -264,13 +275,6 @@ impl Sandbox for AzureSandbox { }) })?; - if request.deadline.is_zero() { - return Err(AlienError::new(ErrorData::OperationNotSupported { - operation: "sandbox.runCommand".to_string(), - reason: "a command must carry a non-zero deadline".to_string(), - })); - } - // 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 @@ -381,8 +385,9 @@ impl Sandbox for AzureSandbox { 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. A caller that - // needs the session to have stopped polls `get` for `Suspended`. + // 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 @@ -391,10 +396,17 @@ impl Sandbox for AzureSandbox { async fn resume(&self, session_id: &str) -> Result<()> { Self::checked_session_id("sandbox.resume", session_id)?; - self.client - .resume_sandbox(&self.sandbox_group, session_id) - .await - .map_err(|error| Self::failed("sandbox.resume", error)) + // Waking a session puts whatever it was running back on the network, so it is gated like + // `run_command` and unlike the file operations. `await_running` resumes without this, + // because a sandbox mid-boot has no policy to judge yet. + self.get(session_id).await?.ok_or_else(|| { + AlienError::new(ErrorData::SandboxCommandFailed { + failure: "sessionGone".to_string(), + reason: format!("session '{session_id}' does not exist"), + }) + })?; + + self.resume_unchecked(session_id).await } async fn snapshot(&self, _session_id: &str) -> Result { @@ -424,6 +436,7 @@ impl Sandbox for AzureSandbox { 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; } @@ -443,6 +456,41 @@ impl Sandbox for AzureSandbox { } impl AzureSandbox { + /// 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 @@ -450,7 +498,6 @@ impl AzureSandbox { async fn settle( &self, sandbox: &alien_azure_clients::azure::sandbox_data_plane::Sandbox, - asked: Option<&EgressPolicy>, ) -> 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 @@ -459,19 +506,7 @@ impl AzureSandbox { // A restriction that did not take effect is worse than one that was never asked for: the // caller believes the sandbox is contained. - if let Some(asked) = asked { - if !policy_holds(asked, running.egress_policy.as_ref()) { - return Err(AlienError::new(ErrorData::SandboxNotAsDeclared { - session_id: running.id, - restriction: "egress policy".to_string(), - reason: format!( - "it came up with {} where the declaration asks for {}", - describe(running.egress_policy.as_ref()), - describe(Some(asked)) - ), - })); - } - } + self.policy_must_hold(&running)?; Ok(SandboxSession { session_id: running.id, @@ -512,8 +547,12 @@ impl AzureSandbox { // Going down, or already down. Either way nothing is bringing it up. Some("Stopping" | "Stopped" | "Suspended" | "Idle") => { if !resumed { - self.resume(session_id).await?; resumed = true; + // A resume racing a sandbox that is still stopping answers 409, which is + // the wait's business rather than the caller's: the budget decides. + if let Err(error) = self.resume_unchecked(session_id).await { + warn!(session = %session_id, %error, "resume was refused; still waiting"); + } } } // A terminated session never becomes runnable, and folding it into the timeout @@ -555,12 +594,16 @@ impl AzureSandbox { %error, "could not delete a sandbox that was never handed to its caller" ); - // A fixed clause rather than the delete's own error: that text is the cloud client's, and - // this variant is externally visible. - reason.context(ErrorData::SandboxNotAsDeclared { - session_id: session_id.to_string(), - restriction: "egress policy".to_string(), - reason: "it could not be deleted either, so it is still running".to_string(), + // 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" + ), }) } @@ -1827,10 +1870,24 @@ mod tests { client .expect_create_sandbox() .times(1) - .returning(|_, _| Ok(running("fresh", None))); - settles_running(&mut client, None); + .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()), + }), + ); - let session = sandbox_with(client) + // 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, @@ -1969,6 +2026,7 @@ mod tests { .expect("suspend should be accepted"); let mut client = MockSandboxDataPlaneApi::new(); + settles_running(&mut client, None); client .expect_resume_sandbox() .withf(|group, id| group == "grp" && id == "s1") @@ -2256,4 +2314,125 @@ mod tests { 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| { + reads += 1; + Ok(match reads { + // Suspended and compliant, so the reconnect proceeds. + 1 => { + 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() + }), + ), + }) + }); + // However it wakes — resumed here or already coming up — the read after it is the one + // that decides. + client.expect_resume_sandbox().returning(|_, _| Ok(())); + client.expect_create_sandbox().never(); + + let error = sandbox_denying(client, SandboxEgress::Deny) + .get_or_create(CreateSessionRequest { + session_id: Some("was-suspended".to_string()), + tenant_key: None, + env: BTreeMap::new(), + }) + .await + .expect_err("a session that woke up with more reach must not be handed back"); + + assert_eq!(error.code, "SANDBOX_NOT_AS_DECLARED", "{error}"); + } + + /// 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(); + client + .expect_get_sandbox() + .times(1) + .returning(|_, id| Ok(running(id, None))); + client.expect_resume_sandbox().never(); + + 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}"); + } } From 76b8d0b873e8307e94cadff882fdd76444a45fc5 Mon Sep 17 00:00:00 2001 From: Itamar Zand <133867530+ItamarZand88@users.noreply.github.com> Date: Sat, 22 Aug 2026 10:26:18 +0300 Subject: [PATCH 16/29] fix(sandbox): refuse a session that is on its way out, and reap one this code woke MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two blockers, both found and reproduced by the review rather than read out of the diff. `get` skips the policy check for a session being deleted, because a sandbox on its way out carries no policy to judge. The two gates that stand between a kept session id and its egress then asked only whether the session exists — so a `Deleting` sandbox passed both. Azure accepts a delete rather than completing it, which is why `terminate` polls to a 404 instead of trusting the accept: the workload is still running through that window. A session built under a looser declaration, tightened since, with a delete in flight, would have run new code under the egress it was built with. Both gates now refuse a session that cannot take work, which is also the right answer for `resume`. The reconnect path judged the policy after `await_running` had already resumed the sandbox — putting whatever it was running back on the network — and then returned the error with no cleanup. Every retry repeated it, and the argument that retries converge does not hold: a stopped sandbox reporting the stale policy sends each attempt back down the same branch. It is discarded now, like every other sandbox this code creates or wakes and then refuses. And a minted id this client cannot address is no longer sent to the delete — the id it refuses to send is the id that delete would travel on. --- .../src/providers/sandbox/azure.rs | 102 +++++++++++++++--- 1 file changed, 86 insertions(+), 16 deletions(-) diff --git a/crates/alien-bindings/src/providers/sandbox/azure.rs b/crates/alien-bindings/src/providers/sandbox/azure.rs index f0fce57a6..5a68b7669 100644 --- a/crates/alien-bindings/src/providers/sandbox/azure.rs +++ b/crates/alien-bindings/src/providers/sandbox/azure.rs @@ -164,8 +164,20 @@ impl Sandbox for AzureSandbox { // 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 let Err(error) = Self::checked_session_id(CREATE, &sandbox.id) { - return Err(self.discard(&sandbox.id, error).await); + // Not reaped on failure: an id this client will not send is one it cannot send to the + // delete either, and a traversing id would make that delete reach another group. + if Self::checked_session_id(CREATE, &sandbox.id).is_err() { + warn!( + session = %sandbox.id, + "the data plane minted an id this client cannot address; the sandbox is running \ + and cannot be deleted through this binding" + ); + return Err(AlienError::new(ErrorData::UnexpectedResponseFormat { + provider: "azure".to_string(), + binding_name: CREATE.to_string(), + field: "id".to_string(), + response_json: format!("\"{}\"", sandbox.id), + })); } // Everything past this point owns a sandbox the caller has no id for, so every failure @@ -223,8 +235,12 @@ impl Sandbox for AzureSandbox { Ok(Some(existing)) if existing.state != SandboxSessionState::Terminated => { // Judged again on the sandbox that came up: a policy set on the group can // change while a session is suspended, and the read above saw a stopped one. + // Waking it is what makes the cleanup necessary — the wait put whatever it + // was running back on the network before anything could judge it. let running = self.await_running(GET_OR_CREATE, id).await?; - self.policy_must_hold(&running)?; + if let Err(error) = self.policy_must_hold(&running) { + return Err(self.discard(id, error).await); + } return Ok(SandboxSession { session_id: running.id, state: SandboxSessionState::Running, @@ -268,12 +284,7 @@ impl Sandbox for AzureSandbox { // 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.get(session_id).await?.ok_or_else(|| { - AlienError::new(ErrorData::SandboxCommandFailed { - failure: "sessionGone".to_string(), - reason: format!("session '{session_id}' does not exist"), - }) - })?; + self.usable_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 @@ -399,12 +410,7 @@ impl Sandbox for AzureSandbox { // Waking a session puts whatever it was running back on the network, so it is gated like // `run_command` and unlike the file operations. `await_running` resumes without this, // because a sandbox mid-boot has no policy to judge yet. - self.get(session_id).await?.ok_or_else(|| { - AlienError::new(ErrorData::SandboxCommandFailed { - failure: "sessionGone".to_string(), - reason: format!("session '{session_id}' does not exist"), - }) - })?; + self.usable_session("sandbox.resume", session_id).await?; self.resume_unchecked(session_id).await } @@ -456,6 +462,27 @@ impl Sandbox for AzureSandbox { } impl AzureSandbox { + /// Reads a session that is fit to be used, refusing one that is not. + /// + /// `get` skips the policy check for a session being deleted, because a sandbox on its way out + /// carries no policy to judge — so the callers that gate on the policy have to reject that + /// state themselves. A delete is accepted rather than completed, so a `Deleting` sandbox is + /// still running: passing one through would run new code on it under whatever egress it was + /// built with. + async fn usable_session(&self, operation: &str, session_id: &str) -> Result<()> { + let gone = || { + Err(AlienError::new(ErrorData::SandboxCommandFailed { + failure: "sessionGone".to_string(), + reason: format!("session '{session_id}' cannot take work"), + })) + }; + + match self.get(session_id).await? { + Some(session) if session.state != SandboxSessionState::Terminated => Ok(()), + _ => gone(), + } + } + /// 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 @@ -2365,8 +2392,13 @@ mod tests { }) }); // However it wakes — resumed here or already coming up — the read after it is the one - // that decides. + // that decides, and a sandbox this code woke and then refused must not be left running. client.expect_resume_sandbox().returning(|_, _| Ok(())); + client + .expect_delete_sandbox() + .withf(|_, id| id == "was-suspended") + .times(1) + .returning(|_, _| Ok(())); client.expect_create_sandbox().never(); let error = sandbox_denying(client, SandboxEgress::Deny) @@ -2435,4 +2467,42 @@ mod tests { 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}"); + } + } } From 3377b568ae43abd8836f1745413208b8c93be86b Mon Sep 17 00:00:00 2001 From: Itamar Zand <133867530+ItamarZand88@users.noreply.github.com> Date: Sat, 22 Aug 2026 10:42:03 +0300 Subject: [PATCH 17/29] fix(sandbox): judge a session in the state the work will run in MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The gate asked whether a session existed and whether it was not being deleted. Neither is the question. A sandbox still coming up carries no policy yet, so judging one reported a booting session as uncontained — and `get_or_create` acts on that by deleting it and creating another. A suspended one carries the record it stopped with, which is not what the work would run under. So the gate now brings a session up before it judges it, and every verb that starts code, wakes it, or moves the caller's own content into it goes through the same path: `run_command`, `resume`, `write_files` and `get_or_create`. `readFile` and `mkdir` stay ungated — a read returns to the caller who already holds the session, and a directory plants nothing. That also settles a question the two reconnect arms answered oppositely. A session that cannot serve is replaced, whether the reason is that it is gone, being deleted, or running a policy the declaration no longer matches. The narrow part is deliberate: a readiness timeout is not one of those reasons, because answering a slow data plane with a second sandbox makes it slower. Two smaller ones from the same review. A minted id this client will not send is still reaped unless the id is itself why the delete would be unsafe — an over-long id is one path segment and nothing else can find that sandbox, while a traversing one would send the delete into another group. And `write_files` validates its paths before it spends a round trip, not after. --- .../src/providers/sandbox/azure.rs | 263 +++++++++++++----- 1 file changed, 196 insertions(+), 67 deletions(-) diff --git a/crates/alien-bindings/src/providers/sandbox/azure.rs b/crates/alien-bindings/src/providers/sandbox/azure.rs index 5a68b7669..cc18d3ab2 100644 --- a/crates/alien-bindings/src/providers/sandbox/azure.rs +++ b/crates/alien-bindings/src/providers/sandbox/azure.rs @@ -164,20 +164,28 @@ impl Sandbox for AzureSandbox { // 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; - // Not reaped on failure: an id this client will not send is one it cannot send to the - // delete either, and a traversing id would make that delete reach another group. if Self::checked_session_id(CREATE, &sandbox.id).is_err() { - warn!( - session = %sandbox.id, - "the data plane minted an id this client cannot address; the sandbox is running \ - and cannot be deleted through this binding" - ); - return Err(AlienError::new(ErrorData::UnexpectedResponseFormat { + let unreadable = AlienError::new(ErrorData::UnexpectedResponseFormat { provider: "azure".to_string(), binding_name: CREATE.to_string(), field: "id".to_string(), - response_json: format!("\"{}\"", sandbox.id), - })); + 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. + return Err(if sandbox.id.contains(['/', '.', '%']) || sandbox.id.is_empty() { + 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 + }); } // Everything past this point owns a sandbox the caller has no id for, so every failure @@ -225,37 +233,25 @@ impl Sandbox for AzureSandbox { async fn get_or_create(&self, request: CreateSessionRequest) -> Result { if let Some(id) = request.session_id.as_deref() { - match self.get(id).await { - // `create` returns a session that can take work, and reaching one someone else - // started has to mean the same thing. A suspended sandbox is the ordinary resting - // state once an idle policy is set, so the wait resumes it. - Ok(Some(existing)) if existing.state == SandboxSessionState::Running => { - return Ok(existing) - } - Ok(Some(existing)) if existing.state != SandboxSessionState::Terminated => { - // Judged again on the sandbox that came up: a policy set on the group can - // change while a session is suspended, and the read above saw a stopped one. - // Waking it is what makes the cleanup necessary — the wait put whatever it - // was running back on the network before anything could judge it. - let running = self.await_running(GET_OR_CREATE, id).await?; - if let Err(error) = self.policy_must_hold(&running) { - return Err(self.discard(id, error).await); - } - return Ok(SandboxSession { - session_id: running.id, - state: SandboxSessionState::Running, - generation: 1, - }); - } - // Terminated, or gone: both mean this id cannot serve, so a fresh session is what - // "get or create" owes the caller. - Ok(_) => {} - // A session the declaration no longer matches is as unusable as a terminated one, - // and leaving it running bills for a sandbox nothing can reach through this - // binding. Replaced rather than returned as a permanent error. - Err(error) if error.code == "SANDBOX_NOT_AS_DECLARED" => { - self.terminate(id).await?; - } + // `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.usable_session(GET_OR_CREATE, 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. The gate has already + // discarded whatever it refused, so nothing is left running. + // + // 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" + ) => {} Err(error) => return Err(error), } } @@ -360,13 +356,19 @@ impl Sandbox for AzureSandbox { async fn write_files(&self, session_id: &str, files: BTreeMap>) -> Result<()> { Self::checked_session_id("sandbox.writeFiles", session_id)?; - // Checked before anything is written: partial application is the contract for a data - // plane that refuses midway, not for a path this process could have rejected first. + // 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.usable_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 { @@ -407,12 +409,12 @@ impl Sandbox for AzureSandbox { async fn resume(&self, session_id: &str) -> Result<()> { Self::checked_session_id("sandbox.resume", session_id)?; - // Waking a session puts whatever it was running back on the network, so it is gated like - // `run_command` and unlike the file operations. `await_running` resumes without this, - // because a sandbox mid-boot has no policy to judge yet. + // Waking a session puts whatever it was running back on the network, so the policy is + // judged after the wake rather than before it: the stopped record is not the one the work + // runs under. That makes `resume` complete rather than accepted, which the sub-second + // resume Microsoft documents makes affordable. self.usable_session("sandbox.resume", session_id).await?; - - self.resume_unchecked(session_id).await + Ok(()) } async fn snapshot(&self, _session_id: &str) -> Result { @@ -469,18 +471,40 @@ impl AzureSandbox { /// state themselves. A delete is accepted rather than completed, so a `Deleting` sandbox is /// still running: passing one through would run new code on it under whatever egress it was /// built with. - async fn usable_session(&self, operation: &str, session_id: &str) -> Result<()> { - let gone = || { - Err(AlienError::new(ErrorData::SandboxCommandFailed { - failure: "sessionGone".to_string(), - reason: format!("session '{session_id}' cannot take work"), - })) + async fn usable_session(&self, operation: &str, session_id: &str) -> Result { + let found = match self.get(session_id).await { + Ok(found) => found, + // The read itself judges a running session, and a refusal there leaves the same + // sandbox running that a refusal below would: one discard, wherever it is found. + Err(error) if error.code == "SANDBOX_NOT_AS_DECLARED" => { + return Err(self.discard(session_id, error).await) + } + Err(error) => return Err(error), + }; + + match found { + Some(session) if session.state != SandboxSessionState::Terminated => session, + _ => { + return Err(AlienError::new(ErrorData::SandboxCommandFailed { + failure: "sessionGone".to_string(), + reason: format!("{operation}: session '{session_id}' cannot take work"), + })) + } }; - match self.get(session_id).await? { - Some(session) if session.state != SandboxSessionState::Terminated => Ok(()), - _ => gone(), + // Brought up before it is judged, not after: a suspended or booting sandbox has no + // effective policy to read, so a gate that accepted one would be judging nothing. The + // wait resumes a stopped session, which is what makes this the state the work runs in. + let running = self.await_running(operation, session_id).await?; + if let Err(error) = self.policy_must_hold(&running) { + return Err(self.discard(session_id, error).await); } + + Ok(SandboxSession { + session_id: running.id, + state: SandboxSessionState::Running, + generation: 1, + }) } /// Wakes a session without judging it, for the wait that has nothing to judge yet. @@ -500,6 +524,12 @@ impl AzureSandbox { &self, sandbox: &alien_azure_clients::azure::sandbox_data_plane::Sandbox, ) -> Result<()> { + // Only a running sandbox carries a policy worth reading. One still coming up need not + // have it yet, and judging that absence reports a booting sandbox as an uncontained one — + // which `get_or_create` acts on by deleting it. + if sandbox.state.as_deref() != Some("Running") { + return Ok(()); + } let Some(asked) = egress_policy(&self.egress) else { return Ok(()); }; @@ -1494,6 +1524,7 @@ mod tests { #[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) @@ -2052,18 +2083,16 @@ mod tests { .await .expect("suspend should be accepted"); + // `resume` completes rather than accepts: it judges the woken sandbox, which means the + // data plane may already have it running by the time the wait looks. let mut client = MockSandboxDataPlaneApi::new(); settles_running(&mut client, None); - client - .expect_resume_sandbox() - .withf(|group, id| group == "grp" && id == "s1") - .times(1) - .returning(|_, _| Ok(())); + client.expect_resume_sandbox().returning(|_, _| Ok(())); client.expect_stop_sandbox().never(); sandbox_with(client) .resume("s1") .await - .expect("resume should be accepted"); + .expect("resume should reach a running session"); } /// A declared idle-suspend policy has to reach the create body. @@ -2329,6 +2358,9 @@ mod tests { .expect_get_sandbox() .times(1) .returning(|_, id| Ok(running(id, None))); + // Refusing it also reaps it: a session nothing can reach through this binding + // should not keep billing. + client.expect_delete_sandbox().times(1).returning(|_, _| Ok(())); client.expect_execute_shell_command().never(); let error = match sandbox_denying(client, SandboxEgress::Deny) @@ -2364,6 +2396,10 @@ mod tests { 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, so the reconnect proceeds. @@ -2399,18 +2435,23 @@ mod tests { .withf(|_, id| id == "was-suspended") .times(1) .returning(|_, _| Ok(())); - client.expect_create_sandbox().never(); + client + .expect_create_sandbox() + .times(1) + .returning(|_, request| Ok(running("fresh", request.egress))); - let error = sandbox_denying(client, SandboxEgress::Deny) + 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_err("a session that woke up with more reach must not be handed back"); + .expect("a caller asking for a session gets a usable one"); - assert_eq!(error.code, "SANDBOX_NOT_AS_DECLARED", "{error}"); + // Answered the same way as a terminated id: the one that woke up wider is discarded and + // replaced, rather than returned as an error the caller cannot act on. + assert_eq!(session.session_id, "fresh"); } /// A sandbox left behind must not publish the cloud's own response text. @@ -2458,6 +2499,9 @@ mod tests { .expect_get_sandbox() .times(1) .returning(|_, id| Ok(running(id, None))); + // Refusing it also reaps it: a session nothing can reach through this binding + // should not keep billing. + client.expect_delete_sandbox().times(1).returning(|_, _| Ok(())); client.expect_resume_sandbox().never(); let error = sandbox_denying(client, SandboxEgress::Deny) @@ -2505,4 +2549,89 @@ mod tests { 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().times(1).returning(|_, _| Ok(())); + 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}"); + } } From 7e3ab8304c34cdc12973504acb7d572b08547053 Mon Sep 17 00:00:00 2001 From: Itamar Zand <133867530+ItamarZand88@users.noreply.github.com> Date: Sat, 22 Aug 2026 10:59:44 +0300 Subject: [PATCH 18/29] fix(sandbox): judge a sleeping session before waking it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A stopped sandbox carries the policy it stopped under, so it can be judged where it lies — and the gate was exempting exactly that state. The cost was concrete: a session created under `allow`, a declaration since tightened to `deny`, and a reconnect would wake it, put its workload back on the network for the length of a boot, and only then refuse it. It is judged asleep now, and again once it is up, because a group-scoped policy can change while it sleeps. Only a session still coming up is exempt, which is the one state with nothing to judge. The resume in the readiness wait was latched on the attempt rather than the outcome, so a single refusal meant nothing ever woke the sandbox again: the wait spent its whole budget watching, returned `sessionNotReady`, and that does not heal — wedging the id permanently. It retries every poll now, and never fires at all while the sandbox is still `Stopping`, which is the state the data plane refuses a resume in and the state a suspend leaves behind. The timeout carries the last refusal, because "still not running after 120s" sends a reader looking for a slow data plane when every resume was rejected. `Failed` is a state the data plane reports and this client did not know, so it became an unreadable-response error that nothing heals. It is terminal, and a session found terminal mid-wait is now replaced like one found terminal at the start — the same condition answered the same way whichever read observes it. Two verbs stopped destroying what they refuse. `run_command`, `write_files` and `resume` did not create the session and were not asked to replace it, and two revisions of a stack share a sandbox group — so reaping there turns one revision's tightened declaration into the other's outage. They refuse; `resume` puts back a session it woke and then rejected. Only `create`, which owns what it made, and `get_or_create`, which was asked for a usable session, replace. And `suspend_and_resume_reach_their_own_verbs` proves the verb is sent again: its mock answered `Running` on the first read, so the wait returned before any resume, and the assertion that named the test passed with the call never made. --- .../src/providers/sandbox/azure.rs | 509 ++++++++++++++---- 1 file changed, 402 insertions(+), 107 deletions(-) diff --git a/crates/alien-bindings/src/providers/sandbox/azure.rs b/crates/alien-bindings/src/providers/sandbox/azure.rs index cc18d3ab2..fd7e0c2d1 100644 --- a/crates/alien-bindings/src/providers/sandbox/azure.rs +++ b/crates/alien-bindings/src/providers/sandbox/azure.rs @@ -176,7 +176,15 @@ impl Sandbox for AzureSandbox { // 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. - return Err(if sandbox.id.contains(['/', '.', '%']) || sandbox.id.is_empty() { + // 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 \ @@ -198,16 +206,10 @@ impl Sandbox for AzureSandbox { async fn get(&self, session_id: &str) -> Result> { Self::checked_session_id("sandbox.get", session_id)?; - let sandbox = match self - .client - .get_sandbox(&self.sandbox_group, session_id) - .await - { - Ok(sandbox) => sandbox, - // 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) => return Ok(None), - Err(error) => return Err(Self::failed("sandbox.get", error)), + // 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())?; @@ -217,10 +219,10 @@ impl Sandbox for AzureSandbox { // ceiling, and an idle sandbox only suspends — so a caller holding its id would otherwise // be handed a sandbox whose containment is whatever it was built with. // - // Not a session on its way out: a sandbox being deleted carries no policy to judge, and - // reading that absence as a mismatch would report a disappearing session as an - // uncontained one. - if state != SandboxSessionState::Terminated { + // A stopped sandbox still carries the policy it stopped under, so it is judged like a + // running one. Only a session still coming up has nothing to judge yet — reading that + // absence as a mismatch would report a healthy session as an uncontained one. + if !matches!(sandbox.state.as_deref(), Some("Creating" | "Resuming")) { self.policy_must_hold(&sandbox)?; } @@ -236,7 +238,7 @@ impl Sandbox for AzureSandbox { // `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.usable_session(GET_OR_CREATE, id).await { + 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 @@ -250,7 +252,7 @@ impl Sandbox for AzureSandbox { || matches!( &error.error, Some(ErrorData::SandboxCommandFailed { failure, .. }) - if failure == "sessionGone" + if failure == "sessionGone" || failure == "sessionTerminated" ) => {} Err(error) => return Err(error), } @@ -280,7 +282,7 @@ impl Sandbox for AzureSandbox { // 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.usable_session(RUN_COMMAND, session_id).await?; + 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 @@ -367,7 +369,7 @@ impl Sandbox for AzureSandbox { // 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.usable_session("sandbox.writeFiles", session_id).await?; + 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. @@ -409,11 +411,31 @@ impl Sandbox for AzureSandbox { async fn resume(&self, session_id: &str) -> Result<()> { Self::checked_session_id("sandbox.resume", session_id)?; + const OPERATION: &str = "sandbox.resume"; + + if self.read_session(OPERATION, session_id).await?.is_none() { + return Err(AlienError::new(ErrorData::SandboxCommandFailed { + failure: "sessionGone".to_string(), + reason: format!("{OPERATION}: session '{session_id}' does not exist"), + })); + } + // Waking a session puts whatever it was running back on the network, so the policy is // judged after the wake rather than before it: the stopped record is not the one the work // runs under. That makes `resume` complete rather than accepted, which the sub-second // resume Microsoft documents makes affordable. - self.usable_session("sandbox.resume", session_id).await?; + let running = self.await_running(OPERATION, session_id).await?; + + // Put back rather than destroyed: this call woke it, so undoing that returns the session + // to the state the caller found it in. Deleting a session the caller asked to resume + // takes a decision that is not this call's to take. + if let Err(error) = self.policy_must_hold(&running) { + if let Err(failed) = self.client.stop_sandbox(&self.sandbox_group, session_id).await { + warn!(session = %session_id, error = %failed, "could not re-suspend a session that woke up uncontained"); + } + return Err(error); + } + Ok(()) } @@ -464,38 +486,38 @@ impl Sandbox for AzureSandbox { } impl AzureSandbox { - /// Reads a session that is fit to be used, refusing one that is not. + /// Brings a session the caller named back into service, or says why it cannot be. /// - /// `get` skips the policy check for a session being deleted, because a sandbox on its way out - /// carries no policy to judge — so the callers that gate on the policy have to reject that - /// state themselves. A delete is accepted rather than completed, so a `Deleting` sandbox is - /// still running: passing one through would run new code on it under whatever egress it was - /// built with. - async fn usable_session(&self, operation: &str, session_id: &str) -> Result { - let found = match self.get(session_id).await { - Ok(found) => found, - // The read itself judges a running session, and a refusal there leaves the same - // sandbox running that a refusal below would: one discard, wherever it is found. - Err(error) if error.code == "SANDBOX_NOT_AS_DECLARED" => { - return Err(self.discard(session_id, error).await) - } - Err(error) => return Err(error), + /// The one path that repairs rather than refusing: `get_or_create` asked for a usable + /// session, so a session that cannot serve is discarded and replaced rather than returned as + /// an error the caller has no way to act on. + 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"), + }) }; - match found { - Some(session) if session.state != SandboxSessionState::Terminated => session, - _ => { - return Err(AlienError::new(ErrorData::SandboxCommandFailed { - failure: "sessionGone".to_string(), - reason: format!("{operation}: session '{session_id}' cannot take work"), - })) + let found = match self.read_session(GET_OR_CREATE, session_id).await? { + Some(sandbox) if !matches!(sandbox.state.as_deref(), Some("Deleting" | "Failed")) => { + sandbox } + _ => return Err(gone()), }; - // Brought up before it is judged, not after: a suspended or booting sandbox has no - // effective policy to read, so a gate that accepted one would be judging nothing. The - // wait resumes a stopped session, which is what makes this the state the work runs in. - let running = self.await_running(operation, session_id).await?; + // Judged asleep before anything wakes it: a stopped sandbox carries the policy it stopped + // under, and waking one that already fails would put its workload back on the network for + // the length of a boot before this call could refuse it. + if !matches!(found.state.as_deref(), Some("Creating" | "Resuming")) { + if let Err(error) = self.policy_must_hold(&found) { + return Err(self.discard(session_id, error).await); + } + } + + // Then judged again where the work will run: a policy set on the group can change while a + // session sleeps, and only the woken record shows that. + let running = self.await_running(GET_OR_CREATE, session_id).await?; if let Err(error) = self.policy_must_hold(&running) { return Err(self.discard(session_id, error).await); } @@ -507,6 +529,57 @@ impl AzureSandbox { }) } + /// 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"), + _ => return refuse("sessionSuspended", "is suspended; resume it first"), + } + + 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 @@ -524,12 +597,6 @@ impl AzureSandbox { &self, sandbox: &alien_azure_clients::azure::sandbox_data_plane::Sandbox, ) -> Result<()> { - // Only a running sandbox carries a policy worth reading. One still coming up need not - // have it yet, and judging that absence reports a booting sandbox as an uncontained one — - // which `get_or_create` acts on by deleting it. - if sandbox.state.as_deref() != Some("Running") { - return Ok(()); - } let Some(asked) = egress_policy(&self.egress) else { return Ok(()); }; @@ -587,14 +654,15 @@ impl AzureSandbox { session_id: &str, ) -> Result { let deadline = std::time::Instant::now() + SESSION_READY_TIMEOUT; - let mut resumed = false; + let mut refusal: Option = None; loop { - let sandbox = self - .client - .get_sandbox(&self.sandbox_group, session_id) - .await - .map_err(|error| Self::failed(operation, error))?; + 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. @@ -602,14 +670,16 @@ impl AzureSandbox { Some("Running") => return Ok(sandbox), Some("Creating" | "Resuming") => {} // Going down, or already down. Either way nothing is bringing it up. - Some("Stopping" | "Stopped" | "Suspended" | "Idle") => { - if !resumed { - resumed = true; - // A resume racing a sandbox that is still stopping answers 409, which is - // the wait's business rather than the caller's: the budget decides. - if let Err(error) = self.resume_unchecked(session_id).await { - warn!(session = %session_id, %error, "resume was refused; still waiting"); - } + // 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") => { + if let Err(error) = self.resume_unchecked(session_id).await { + warn!(session = %session_id, %error, "resume was refused; still waiting"); + refusal = Some(error.code.clone()); } } // A terminated session never becomes runnable, and folding it into the timeout @@ -624,12 +694,21 @@ impl AzureSandbox { } 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: format!( - "session '{session_id}' was still not running after {}s", - SESSION_READY_TIMEOUT.as_secs() - ), + 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; @@ -915,7 +994,7 @@ fn session_state(operation: &str, state: Option<&str>) -> Result Ok(SandboxSessionState::Suspended), - Some("Deleting") => Ok(SandboxSessionState::Terminated), + Some("Deleting" | "Failed") => Ok(SandboxSessionState::Terminated), other => Err(AlienError::new(ErrorData::UnexpectedResponseFormat { provider: "azure".to_string(), binding_name: operation.to_string(), @@ -2083,11 +2162,23 @@ mod tests { .await .expect("suspend should be accepted"); - // `resume` completes rather than accepts: it judges the woken sandbox, which means the - // data plane may already have it running by the time the wait looks. + // 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(); - settles_running(&mut client, None); - client.expect_resume_sandbox().returning(|_, _| Ok(())); + 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") @@ -2273,31 +2364,25 @@ mod tests { #[tokio::test] async fn a_stale_policy_session_is_replaced_rather_than_refused_forever() { let mut client = MockSandboxDataPlaneApi::new(); - // The stale session answers once, is deleted, and is gone from then on; the fresh one - // answers its own readiness read. - let mut stale_reads = 0; + // 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, - 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()), - }), - )); - } - stale_reads += 1; - if stale_reads == 1 { - Ok(running(id, None)) - } else { - Err(http_error(404, "SandboxNotFound")) + 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() @@ -2358,9 +2443,9 @@ mod tests { .expect_get_sandbox() .times(1) .returning(|_, id| Ok(running(id, None))); - // Refusing it also reaps it: a session nothing can reach through this binding - // should not keep billing. - client.expect_delete_sandbox().times(1).returning(|_, _| Ok(())); + // 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) @@ -2497,12 +2582,10 @@ mod tests { let mut client = MockSandboxDataPlaneApi::new(); client .expect_get_sandbox() - .times(1) .returning(|_, id| Ok(running(id, None))); - // Refusing it also reaps it: a session nothing can reach through this binding - // should not keep billing. - client.expect_delete_sandbox().times(1).returning(|_, _| Ok(())); - client.expect_resume_sandbox().never(); + // Refused, not reaped: the caller asked to wake a session, not to lose it. + client.expect_delete_sandbox().never(); + client.expect_stop_sandbox().returning(|_, _| Ok(())); let error = sandbox_denying(client, SandboxEgress::Deny) .resume("built-under-allow") @@ -2621,7 +2704,7 @@ mod tests { .expect_get_sandbox() .times(1) .returning(|_, id| Ok(running(id, None))); - client.expect_delete_sandbox().times(1).returning(|_, _| Ok(())); + client.expect_delete_sandbox().never(); client.expect_write_file().never(); let error = sandbox_denying(client, SandboxEgress::Deny) @@ -2634,4 +2717,216 @@ mod tests { 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()))); + } + // Built under `allow`, so it carries no policy at all. + let mut sandbox = running(id, None); + sandbox.state = Some("Stopped".to_string()); + Ok(sandbox) + }); + client.expect_resume_sandbox().never(); + client + .expect_delete_sandbox() + .times(1) + .returning(|_, _| Ok(())); + 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) + }); + 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_with(client) + .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_resume_sandbox().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("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"); + } } From 1555252bdbc71ecce3c7bcee1ac5e6a2ed00bde9 Mon Sep 17 00:00:00 2001 From: Itamar Zand <133867530+ItamarZand88@users.noreply.github.com> Date: Sat, 22 Aug 2026 11:45:45 +0300 Subject: [PATCH 19/29] fix(sandbox): suspend only the session this call woke MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A resume that found the session already running, or watched it come up on its own, stopped it on a policy mismatch — ending a command another revision of the same stack was running, since both share the sandbox group. Only the wait knows whether it issued the resume, so it reports that instead of a pre-read inferring it from the state. A failed session found on reconnect is now reaped rather than left beside its replacement, and a sleeping record with no policy at all is left for the post-wake judgement: whether the data plane reports egressPolicy for a stopped sandbox is unverified, and refusing would churn every idle-suspended session if it does not. --- .../src/providers/sandbox/azure.rs | 444 +++++++++++++++--- 1 file changed, 378 insertions(+), 66 deletions(-) diff --git a/crates/alien-bindings/src/providers/sandbox/azure.rs b/crates/alien-bindings/src/providers/sandbox/azure.rs index fd7e0c2d1..84dad37b2 100644 --- a/crates/alien-bindings/src/providers/sandbox/azure.rs +++ b/crates/alien-bindings/src/providers/sandbox/azure.rs @@ -39,6 +39,15 @@ pub struct AzureSandbox { memory: String, } +/// A session that reached `Running`, and whether this wait is what resumed it. +/// +/// Only the wait knows: a read taken before it cannot tell a session that came up on its own from +/// one this call woke, and suspending the wrong one ends another revision's command. +struct Ready { + sandbox: alien_azure_clients::azure::sandbox_data_plane::Sandbox, + resumed_here: bool, +} + impl AzureSandbox { /// Builds a provider bound to one sandbox group. pub fn new( @@ -214,15 +223,14 @@ impl Sandbox for AzureSandbox { let state = session_state("sandbox.get", sandbox.state.as_deref())?; - // Checked here as well as at create, because this is the path a reconnect takes: a - // session created under an older declaration outlives the change — Azure has no session - // ceiling, and an idle sandbox only suspends — so a caller holding its id would otherwise - // be handed a sandbox whose containment is whatever it was built with. - // - // A stopped sandbox still carries the policy it stopped under, so it is judged like a - // running one. Only a session still coming up has nothing to judge yet — reading that - // absence as a mismatch would report a healthy session as an uncontained one. - if !matches!(sandbox.state.as_deref(), Some("Creating" | "Resuming")) { + // 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. + if !matches!( + sandbox.state.as_deref(), + Some("Creating" | "Resuming" | "Deleting" | "Failed") + ) { self.policy_must_hold(&sandbox)?; } @@ -424,16 +432,31 @@ impl Sandbox for AzureSandbox { // judged after the wake rather than before it: the stopped record is not the one the work // runs under. That makes `resume` complete rather than accepted, which the sub-second // resume Microsoft documents makes affordable. - let running = self.await_running(OPERATION, session_id).await?; - - // Put back rather than destroyed: this call woke it, so undoing that returns the session - // to the state the caller found it in. Deleting a session the caller asked to resume - // takes a decision that is not this call's to take. - if let Err(error) = self.policy_must_hold(&running) { - if let Err(failed) = self.client.stop_sandbox(&self.sandbox_group, session_id).await { - warn!(session = %session_id, error = %failed, "could not re-suspend a session that woke up uncontained"); + let ready = self.await_running(OPERATION, session_id).await?; + + // Put back only if this call is what woke it. A session that was already up, or that came + // up on its own, is someone else's — another revision of the same stack shares this + // sandbox group — and stopping it would end a command that revision is mid-way through. + if let Err(error) = self.policy_must_hold(&ready.sandbox) { + if !ready.resumed_here { + return Err(error); } - return Err(error); + let Err(failed) = self + .client + .stop_sandbox(&self.sandbox_group, session_id) + .await + else { + return Err(error); + }; + + warn!(session = %session_id, error = %failed, "could not re-suspend a session that woke up uncontained"); + return Err(error.context(ErrorData::SandboxCommandFailed { + failure: "sandboxLeftAwake".to_string(), + reason: format!( + "session '{session_id}' was woken to be judged, does not carry the declared \ + policy, and could not be put back" + ), + })); } Ok(()) @@ -500,24 +523,28 @@ impl AzureSandbox { }; let found = match self.read_session(GET_OR_CREATE, session_id).await? { - Some(sandbox) if !matches!(sandbox.state.as_deref(), Some("Deleting" | "Failed")) => { - sandbox + // 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 before anything wakes it: a stopped sandbox carries the policy it stopped - // under, and waking one that already fails would put its workload back on the network for - // the length of a boot before this call could refuse it. - if !matches!(found.state.as_deref(), Some("Creating" | "Resuming")) { + // Judged asleep first: waking one that already fails puts its workload back on the network + // for a boot. An absent policy is unknown rather than wrong — whether the data plane + // reports one for a stopped sandbox is unverified, and refusing would churn every idle + // session if it does not. + if found.state.as_deref() != Some("Running") && found.egress_policy.is_some() { if let Err(error) = self.policy_must_hold(&found) { return Err(self.discard(session_id, error).await); } } - // Then judged again where the work will run: a policy set on the group can change while a - // session sleeps, and only the woken record shows that. - let running = self.await_running(GET_OR_CREATE, session_id).await?; + // 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 running = self.await_running(GET_OR_CREATE, session_id).await?.sandbox; if let Err(error) = self.policy_must_hold(&running) { return Err(self.discard(session_id, error).await); } @@ -557,7 +584,18 @@ impl AzureSandbox { return refuse("sessionNotReady", "is still starting; wait for it to run") } Some("Deleting") => return refuse("sessionGone", "is being deleted"), - _ => return refuse("sessionSuspended", "is suspended; resume it first"), + 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) @@ -626,7 +664,7 @@ impl AzureSandbox { // 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 running = self.await_running(CREATE, &sandbox.id).await?; + let running = self.await_running(CREATE, &sandbox.id).await?.sandbox; // A restriction that did not take effect is worse than one that was never asked for: the // caller believes the sandbox is contained. @@ -648,13 +686,10 @@ impl AzureSandbox { /// 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, - ) -> Result { + async fn await_running(&self, operation: &str, session_id: &str) -> Result { let deadline = std::time::Instant::now() + SESSION_READY_TIMEOUT; let mut refusal: Option = None; + let mut resumed_here = false; loop { let Some(sandbox) = self.read_session(operation, session_id).await? else { @@ -667,9 +702,13 @@ impl AzureSandbox { // 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("Running") => { + return Ok(Ready { + sandbox, + resumed_here, + }) + } Some("Creating" | "Resuming") => {} - // Going down, or already down. Either way nothing is bringing it up. // 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") => {} @@ -677,9 +716,20 @@ impl AzureSandbox { // 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") => { - if let Err(error) = self.resume_unchecked(session_id).await { - warn!(session = %session_id, %error, "resume was refused; still waiting"); - refusal = Some(error.code.clone()); + match self.resume_unchecked(session_id).await { + Ok(()) => { + refusal = None; + resumed_here = true; + } + Err(error) => { + warn!(session = %session_id, %error, "resume was refused; still waiting"); + refusal = Some(match &error.error { + Some(ErrorData::SandboxCommandFailed { failure, .. }) => { + failure.clone() + } + _ => error.code.clone(), + }); + } } } // A terminated session never becomes runnable, and folding it into the timeout @@ -720,7 +770,11 @@ impl AzureSandbox { /// 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 { + async fn discard( + &self, + session_id: &str, + reason: AlienError, + ) -> AlienError { let Err(error) = self.accept_delete(session_id).await else { return reason; }; @@ -1073,11 +1127,11 @@ fn is_not_found(error: &AlienError) -> bool { #[cfg(test)] 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 alien_azure_clients::azure::sandbox_data_plane::ExecResult; - use alien_azure_clients::azure::sandbox_data_plane::MockSandboxDataPlaneApi; use futures::StreamExt; fn http_error(status: u16, body: &str) -> AlienError { @@ -1256,7 +1310,11 @@ mod tests { #[async_trait] impl SandboxDataPlaneApi for ScriptedExec { - async fn stop_sandbox(&self, _group: &str, _sandbox_id: &str) -> alien_client_core::Result<()> { + async fn stop_sandbox( + &self, + _group: &str, + _sandbox_id: &str, + ) -> alien_client_core::Result<()> { unreachable!("the command paths never suspend") } @@ -1519,7 +1577,15 @@ mod tests { client.expect_mkdir().never(); let sandbox = sandbox_with(client); - for path in ["../etc/shadow", "", "/", "work/", "a//b", "a/../../b", "/../escape"] { + for path in [ + "../etc/shadow", + "", + "/", + "work/", + "a//b", + "a/../../b", + "/../escape", + ] { let error = sandbox .read_file("s1", path) .await @@ -1781,7 +1847,10 @@ mod tests { assert_eq!(session.state, SandboxSessionState::Running); } - fn running(id: &str, egress: Option) -> alien_azure_clients::azure::sandbox_data_plane::Sandbox { + 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, @@ -1997,13 +2066,14 @@ mod tests { #[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_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) @@ -2579,18 +2649,84 @@ mod tests { /// 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))); - // Refused, not reaped: the caller asked to wake a session, not to lose it. + client.expect_resume_sandbox().never(); + client.expect_stop_sandbox().never(); client.expect_delete_sandbox().never(); - client.expect_stop_sandbox().returning(|_, _| Ok(())); let error = sandbox_denying(client, SandboxEgress::Deny) - .resume("built-under-allow") + .resume("someone-elses-session") .await - .expect_err("a session without the declared policy must not be woken"); + .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}"); } @@ -2731,12 +2867,14 @@ mod tests { 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()); + sandbox.state = Some( + match reads { + 1 => "Stopping", + 2 | 3 => "Stopped", + _ => "Running", + } + .to_string(), + ); Ok(sandbox) }); @@ -2812,8 +2950,17 @@ mod tests { if id == "fresh" { return Ok(running(id, Some(declared.clone()))); } - // Built under `allow`, so it carries no policy at all. - let mut sandbox = running(id, None); + // 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) }); @@ -2855,6 +3002,13 @@ mod tests { 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) @@ -2885,7 +3039,7 @@ mod tests { Ok(sandbox) }); - let session = sandbox_with(client) + let session = sandbox_denying(client, SandboxEgress::Deny) .get("broken") .await .expect("a failed session is a state, not an unreadable response") @@ -2912,7 +3066,6 @@ mod tests { sandbox.state = Some(if reads == 1 { "Stopped" } else { "Deleting" }.to_string()); Ok(sandbox) }); - client.expect_resume_sandbox().returning(|_, _| Ok(())); client .expect_create_sandbox() .times(1) @@ -2929,4 +3082,163 @@ mod tests { 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}"); + } } From 6d4c2ad752033e2e4e68ebbbc8712555d2c2f2ba Mon Sep 17 00:00:00 2001 From: Itamar Zand <133867530+ItamarZand88@users.noreply.github.com> Date: Sat, 22 Aug 2026 12:47:53 +0300 Subject: [PATCH 20/29] fix(sandbox): send the verbs a repeat performs twice exactly once A create is a PUT to a collection with a service-minted id and no idempotency key, and the transport retried it three times: a response lost after the sandbox was minted made a second one, returned success, and left the first running with the caller's environment variables and no id-holder able to reap it. An exec answering late was re-sent the same way, so untrusted code could run four times. Both now take a single-attempt path; every other verb keeps its retry, which is what the new test asserts against. The rest of this change closes what the same review found. `get` and the reconnect path now share one predicate for "is there a policy here to judge", so they cannot disagree about a suspended session; `resume` judges the sleeping record before waking anything; and the fact that this call issued the resume now survives every exit from the wait, so a session woken by a wait that then failed is still put back or named. A command's own environment variables reached nothing: the exec endpoint takes no environment, so they travel as shell assignments in front of the command, with names checked because a name sits where quoting cannot reach it. Azure's catalog image rule moves to plan time beside the AWS one, so a sandbox no worker binds is still refused, and `code.image` says what Azure takes rather than offering two examples it rejects. --- .../alien-azure-clients/src/azure/common.rs | 114 +++-- .../src/azure/sandbox_data_plane.rs | 71 ++- .../src/providers/sandbox/azure.rs | 441 +++++++++++++++--- .../src/emitters/aws/sandbox.rs | 5 +- crates/alien-core/src/resources/sandbox.rs | 117 ++++- crates/alien-helm/src/emitters/sandbox.rs | 1 - .../src/emitters/aws/sandbox.rs | 5 +- .../src/emitters/azure/sandbox.rs | 77 ++- .../src/emitters/gcp/sandbox.rs | 43 ++ .../core/src/generated/schemas/sandbox.json | 2 +- .../src/generated/schemas/sandboxCode.json | 2 +- .../src/generated/schemas/sandboxEgress.json | 2 +- .../src/generated/zod/sandbox-code-schema.ts | 2 +- 13 files changed, 693 insertions(+), 189 deletions(-) diff --git a/crates/alien-azure-clients/src/azure/common.rs b/crates/alien-azure-clients/src/azure/common.rs index bf8e7e07c..126159e7a 100644 --- a/crates/alien-azure-clients/src/azure/common.rs +++ b/crates/alien-azure-clients/src/azure/common.rs @@ -184,6 +184,58 @@ impl AzureClientBase { // ------------- Low-level executor ------------- + /// Sends a request exactly once, with no retry. + /// + /// For the verbs a repeat performs twice: a PUT to a collection with a server-minted id makes + /// a second resource the caller has no id for, and an exec that answered late may already + /// have started the command. Neither carries an idempotency key, so the only safe number of + /// attempts is one. + 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 + } + + /// One attempt: send it, and turn a non-success status into an error carrying the context. + 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 +259,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[..b.len().min(MAX_ECHOED_REQUEST_BODY)]).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 +288,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[..b.len().min(MAX_ECHOED_REQUEST_BODY)]).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 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 76b4b2f8a..0b217336f 100644 --- a/crates/alien-azure-clients/src/azure/sandbox_data_plane.rs +++ b/crates/alien-azure-clients/src/azure/sandbox_data_plane.rs @@ -410,8 +410,14 @@ impl SandboxDataPlaneApi for AzureSandboxDataPlaneClient { let signed = self.base.sign_request(request, &token).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 minted by the service and this is a PUT to a collection, so a + // re-send mints a second sandbox — and with no enumeration verb, the first one has no + // id-holder and nothing to reap it. let response = alien_client_core::redact_request_body( - self.base.execute_request(signed, "CreateSandbox", group).await, + self.base + .execute_request_once(signed, "CreateSandbox", group) + .await, )?; Self::parse(response, "CreateSandbox").await } @@ -477,9 +483,12 @@ impl SandboxDataPlaneApi for AzureSandboxDataPlaneClient { let signed = self.base.sign_request(request, &token).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, and + // running untrusted code a second time is not a recovery. let response = alien_client_core::redact_request_body( self.base - .execute_request(signed, "ExecuteShellCommand", sandbox_id) + .execute_request_once(signed, "ExecuteShellCommand", sandbox_id) .await, )?; Self::parse(response, "ExecuteShellCommand").await @@ -594,7 +603,11 @@ impl SandboxDataPlaneApi for AzureSandboxDataPlaneClient { .body(body) .build()?; let signed = self.base.sign_request(request, &token).await?; - self.base.execute_request(signed, "Mkdir", sandbox_id).await?; + // The body is a caller-supplied path; wrapped like the other bodied calls so the next one + // added here inherits the redaction rather than the omission. + alien_client_core::redact_request_body( + self.base.execute_request(signed, "Mkdir", sandbox_id).await, + )?; Ok(()) } } @@ -633,6 +646,58 @@ mod tests { ) } + /// A create is delivered once, however the data plane answers. + /// + /// The id is minted by the service and the PUT names a collection, so a second delivery makes + /// a second sandbox that no id-holder can find and no enumeration verb can list — one this + /// call would never learn about even when it eventually succeeds. The read is the contrast: + /// repeating it is free, so it keeps the retry. + #[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() + ); + } + /// 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] diff --git a/crates/alien-bindings/src/providers/sandbox/azure.rs b/crates/alien-bindings/src/providers/sandbox/azure.rs index 84dad37b2..517e57841 100644 --- a/crates/alien-bindings/src/providers/sandbox/azure.rs +++ b/crates/alien-bindings/src/providers/sandbox/azure.rs @@ -39,15 +39,6 @@ pub struct AzureSandbox { memory: String, } -/// A session that reached `Running`, and whether this wait is what resumed it. -/// -/// Only the wait knows: a read taken before it cannot tell a session that came up on its own from -/// one this call woke, and suspending the wrong one ends another revision's command. -struct Ready { - sandbox: alien_azure_clients::azure::sandbox_data_plane::Sandbox, - resumed_here: bool, -} - impl AzureSandbox { /// Builds a provider bound to one sandbox group. pub fn new( @@ -227,12 +218,7 @@ impl Sandbox for AzureSandbox { // 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. - if !matches!( - sandbox.state.as_deref(), - Some("Creating" | "Resuming" | "Deleting" | "Failed") - ) { - self.policy_must_hold(&sandbox)?; - } + self.judge_if_judgeable(&sandbox)?; Ok(Some(SandboxSession { session_id: sandbox.id, @@ -300,7 +286,13 @@ 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 has to travel as a shell assignment in front of it. Names are + // checked first: an unchecked one is a second command, not a variable. + for name in request.env.keys() { + checked_env_name(RUN_COMMAND, name)?; + } + 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. @@ -354,6 +346,9 @@ impl Sandbox for AzureSandbox { Ok(Box::pin(stream::iter(frames))) } + /// 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)?; @@ -382,7 +377,6 @@ impl Sandbox for AzureSandbox { // 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 @@ -421,45 +415,32 @@ impl Sandbox for AzureSandbox { Self::checked_session_id("sandbox.resume", session_id)?; const OPERATION: &str = "sandbox.resume"; - if self.read_session(OPERATION, session_id).await?.is_none() { + 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"), })); - } + }; - // Waking a session puts whatever it was running back on the network, so the policy is - // judged after the wake rather than before it: the stopped record is not the one the work - // runs under. That makes `resume` complete rather than accepted, which the sub-second - // resume Microsoft documents makes affordable. - let ready = self.await_running(OPERATION, session_id).await?; - - // Put back only if this call is what woke it. A session that was already up, or that came - // up on its own, is someone else's — another revision of the same stack shares this - // sandbox group — and stopping it would end a command that revision is mid-way through. - if let Err(error) = self.policy_must_hold(&ready.sandbox) { - if !ready.resumed_here { - return Err(error); - } - let Err(failed) = self - .client - .stop_sandbox(&self.sandbox_group, session_id) - .await - else { - return Err(error); - }; + // 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)?; - warn!(session = %session_id, error = %failed, "could not re-suspend a session that woke up uncontained"); - return Err(error.context(ErrorData::SandboxCommandFailed { - failure: "sandboxLeftAwake".to_string(), - reason: format!( - "session '{session_id}' was woken to be judged, does not carry the declared \ - policy, and could not be put back" - ), - })); - } + // 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; - Ok(()) + 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 { @@ -533,18 +514,24 @@ impl AzureSandbox { }; // Judged asleep first: waking one that already fails puts its workload back on the network - // for a boot. An absent policy is unknown rather than wrong — whether the data plane - // reports one for a stopped sandbox is unverified, and refusing would churn every idle - // session if it does not. - if found.state.as_deref() != Some("Running") && found.egress_policy.is_some() { - if let Err(error) = self.policy_must_hold(&found) { - return Err(self.discard(session_id, error).await); - } + // for a boot. + if let Err(error) = self.judge_if_judgeable(&found) { + return Err(self.discard(session_id, error).await); } // 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 running = self.await_running(GET_OR_CREATE, session_id).await?.sandbox; + let mut resumed_here = false; + let running = match self + .await_running(GET_OR_CREATE, session_id, &mut resumed_here) + .await + { + Ok(running) => running, + // A wait that woke it and then failed leaves it awake, and this call is about to hand + // back a different session — so the one it woke is its own to reap. + Err(error) if resumed_here => return Err(self.discard(session_id, error).await), + Err(error) => return Err(error), + }; if let Err(error) = self.policy_must_hold(&running) { return Err(self.discard(session_id, error).await); } @@ -664,7 +651,10 @@ impl AzureSandbox { // 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 running = self.await_running(CREATE, &sandbox.id).await?.sandbox; + 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. @@ -686,10 +676,14 @@ impl AzureSandbox { /// 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) -> Result { + 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; - let mut resumed_here = false; loop { let Some(sandbox) = self.read_session(operation, session_id).await? else { @@ -702,12 +696,7 @@ impl AzureSandbox { // 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(Ready { - sandbox, - resumed_here, - }) - } + 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. @@ -719,7 +708,7 @@ impl AzureSandbox { match self.resume_unchecked(session_id).await { Ok(()) => { refusal = None; - resumed_here = true; + *resumed_here = true; } Err(error) => { warn!(session = %session_id, %error, "resume was refused; still waiting"); @@ -735,10 +724,12 @@ impl AzureSandbox { // A terminated session never becomes runnable, and folding it into the timeout // would report it a minute late as a slow boot. other => { - session_state(operation, other)?; + let state = session_state(operation, other)?; return Err(AlienError::new(ErrorData::SandboxCommandFailed { failure: "sessionTerminated".to_string(), - reason: format!("session '{session_id}' is being deleted"), + reason: format!( + "session '{session_id}' reached {state:?} and will not run again" + ), })); } } @@ -765,6 +756,60 @@ impl AzureSandbox { } } + /// 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(), + _ => false, + } + } + + /// Judges a record only where there is something to judge. + 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; + }; + + 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 — @@ -867,18 +912,51 @@ 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 .iter() .map(|argument| format!(" '{}'", escape(argument))) .collect::(); + // Assignments in front of a simple command are exported to it, so the wrapper and everything + // it runs see them. + let assignments = env + .iter() + .map(|(name, value)| format!("{name}='{}' ", escape(value))) + .collect::(); format!( - "sh -c '{}' sh{arguments}", + "{assignments}sh -c '{}' sh{arguments}", escape(&DeadlineReport::bounded_program(deadline)) ) } +/// Refuses a variable name the shell would read as anything other than a name. +/// +/// The name is not quotable — it sits left of the `=` — so a name carrying a space or a `;` is a +/// second command rather than a variable, and quoting the value alone would not stop it. +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 @@ -1068,7 +1146,10 @@ 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 the data plane is addressed with, matching the launcher-side bound. +/// 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. @@ -1555,6 +1636,7 @@ mod tests { "&&".to_string(), "sleep 5".to_string(), ], + &BTreeMap::new(), std::time::Duration::from_millis(1500), ); assert!(wrapped.contains("sleep 1.500"), "{wrapped}"); @@ -1564,6 +1646,38 @@ mod tests { ); } + /// 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.starts_with("TOKEN='a'\\''; rm -rf /' sh -c '"), + "the value has to survive as one word: {wrapped}" + ); + } + + /// 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 @@ -2731,6 +2845,189 @@ mod tests { assert_eq!(error.code, "SANDBOX_NOT_AS_DECLARED", "{error}"); } + /// A suspended session that reports no policy reads as suspended, not as a mismatch. + /// + /// `get` and the reconnect path have to answer this the same way. Whether the data plane + /// reports `egressPolicy` for a sandbox that is not running is unverified, so if it does not, + /// judging the record here would turn every idle-suspended session into a containment + /// failure — and `suspendResume` would advertise a state the caller cannot observe. + #[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 refusal is not the only way out of `resume`: 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 reaps the one it woke. + /// + /// The refusal travels either way; what must not survive it is a live sandbox this call put + /// back on the network and then walked away from. + #[tokio::test] + async fn a_session_woken_by_a_failed_reconnect_is_reaped() { + 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_delete_sandbox() + .withf(|_, id| id == "woken-then-unreadable") + .times(1) + .returning(|_, _| Ok(())); + + 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`; the exec endpoint here takes no + /// environment at all, so dropping it silently would make one backend answer a documented + /// field with nothing, and the failure would surface inside the sandbox rather than at the + /// call. + #[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.starts_with("TOKEN='t' sh -c '")) + .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: "beef\n".to_string(), + }) + }); + + let mut request = command(5); + request.env = BTreeMap::from([("TOKEN".to_string(), "t".to_string())]); + + sandbox_with(client) + .run_command("s1", request) + .await + .expect("a command declaring a variable must run"); + } + + /// 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 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 diff --git a/crates/alien-cloudformation/src/emitters/aws/sandbox.rs b/crates/alien-cloudformation/src/emitters/aws/sandbox.rs index 004d5c72e..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| { diff --git a/crates/alien-core/src/resources/sandbox.rs b/crates/alien-core/src/resources/sandbox.rs index b517b77a2..23a9bd5b8 100644 --- a/crates/alien-core/src/resources/sandbox.rs +++ b/crates/alien-core/src/resources/sandbox.rs @@ -25,7 +25,9 @@ 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`). Azure takes a + /// bare catalog name such as `ubuntu` — it creates a session from a public catalog disk + /// image, so a registry path, tag or digest has nowhere to go. image: String, }, /// Source built into a sandbox image at deploy time. @@ -130,7 +132,9 @@ 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`. Azure delivers the first half only: its + /// egress rules match host patterns, so a private range has nothing to render into and the + /// data plane's own default applies. Allow, /// Outbound access only to the listed hostnames. /// @@ -482,6 +486,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); @@ -540,6 +549,45 @@ impl Sandbox { /// only tier that honours a ceiling is one whose peak fits inside it. A declaration no tier /// satisfies is refused: shipping the nearest size would give the customer a sandbox that /// exceeds the bound they wrote down. + /// 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(refused( + "source", + "no sandbox backend builds an image from source yet", + )); + }; + + 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) + } + pub fn microvm_tier(&self) -> Result { let Some(limits) = self.limits.as_ref() else { // Nothing declared: AWS's own default baseline, which is also `default_limits`. @@ -853,7 +901,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(), @@ -995,7 +1043,7 @@ mod tests { // 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 { @@ -1021,7 +1069,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 { @@ -1158,6 +1206,63 @@ mod tests { .expect("the ceiling itself is allowed"); } + /// An image reference Azure cannot honour is refused while planning, not at the first session. + /// + /// The examples in `code.image`'s own documentation — a tag, a registry path — are exactly + /// what Azure cannot take, so this is the shape a customer is most likely to declare. Caught + /// at plan time it names the sandbox; caught nowhere, it renders into the module, plans, + /// applies, and fails when the first session is created. + #[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. @@ -1339,7 +1444,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 diff --git a/crates/alien-helm/src/emitters/sandbox.rs b/crates/alien-helm/src/emitters/sandbox.rs index 76f3a3375..fc7b82f76 100644 --- a/crates/alien-helm/src/emitters/sandbox.rs +++ b/crates/alien-helm/src/emitters/sandbox.rs @@ -58,7 +58,6 @@ impl HelmEmitter for SandboxEmitter { }) })?; - let mut fragment = HelmFragment::empty(); fragment.extra_templates.insert( format!("sandbox-{}-networkpolicy.yaml", sandbox.id()), diff --git a/crates/alien-terraform/src/emitters/aws/sandbox.rs b/crates/alien-terraform/src/emitters/aws/sandbox.rs index c0edbb1f4..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<()> { diff --git a/crates/alien-terraform/src/emitters/azure/sandbox.rs b/crates/alien-terraform/src/emitters/azure/sandbox.rs index 46546e919..34954e437 100644 --- a/crates/alien-terraform/src/emitters/azure/sandbox.rs +++ b/crates/alien-terraform/src/emitters/azure/sandbox.rs @@ -14,8 +14,7 @@ use crate::{ emitters::azure::helpers::{downcast, required_label, resource_prefix_template}, expr, }; -use alien_core::{import::EmitContext, ErrorData, Result, Sandbox, SandboxCode, SandboxEgress}; -use alien_error::AlienError; +use alien_core::{import::EmitContext, Result, Sandbox, SandboxEgress}; use hcl::expr::Expression; /// Emits the Azure sandbox group's identity for the runtime to address. @@ -56,42 +55,6 @@ fn egress(sandbox: &Sandbox) -> Expression { } } -/// The catalog image name a declaration asks for, or a refusal. -/// -/// The create body names a public catalog image, so a registry reference has nowhere to go. -/// Refusing at plan time follows the AWS emitter: a reference the backend cannot honour is -/// rejected rather than quietly replaced — silently ignoring it would run a stock image -/// whatever the declaration said, with no error anywhere. -fn catalog_disk_image(sandbox: &Sandbox) -> Result { - let unsupported = |reason: String| { - AlienError::new(ErrorData::OperationNotSupported { - operation: format!("terraform emit sandbox '{}'", sandbox.id()), - reason, - }) - }; - - match &sandbox.code { - // A tag is the shape that gets through unnoticed: `ubuntu:24.04` has no slash, renders - // into the customer's module, plans and applies, and fails at the first session. - SandboxCode::Image { image } - if image.trim().is_empty() - || image.contains('/') - || image.contains(':') - || image.contains('@') => - { - Err(unsupported(format!( - "Azure creates a sandbox from a public catalog disk image, so code.image must be \ - a bare catalog name such as 'ubuntu'; '{image}' is empty or carries a registry \ - path, tag or digest, which the data plane has nowhere to put" - ))) - } - SandboxCode::Image { image } => Ok(image.clone()), - SandboxCode::Source { .. } => Err(unsupported( - "no sandbox backend builds an image from source yet".to_string(), - )), - } -} - impl TfEmitter for AzureSandboxEmitter { fn emit(&self, _ctx: &EmitContext<'_>) -> Result { // Deliberately empty: see the module note. A group emitted here would sit idle until a @@ -112,7 +75,7 @@ impl TfEmitter for AzureSandboxEmitter { fn emit_binding_ref(&self, ctx: &EmitContext<'_>) -> Result> { let sandbox = downcast::(ctx, Sandbox::RESOURCE_TYPE)?; let _ = required_label(ctx)?; - let disk_image = catalog_disk_image(sandbox)?; + let disk_image = sandbox.azure_catalog_image()?.to_string(); let mut fields = vec![ ("service", Expression::String("sandbox-azure".to_string())), ("sandboxGroup", sandbox_group(ctx)), @@ -142,6 +105,8 @@ impl TfEmitter for AzureSandboxEmitter { #[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; @@ -211,6 +176,40 @@ mod tests { 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 lands on a customer's cluster as a deserialization failure at the + /// first session rather than as a failure at plan time. The names come from the type here, + /// not from a second hand-typed list. + #[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 diff --git a/crates/alien-terraform/src/emitters/gcp/sandbox.rs b/crates/alien-terraform/src/emitters/gcp/sandbox.rs index c3a4d8998..f7f23ed38 100644 --- a/crates/alien-terraform/src/emitters/gcp/sandbox.rs +++ b/crates/alien-terraform/src/emitters/gcp/sandbox.rs @@ -81,3 +81,46 @@ 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` opens every address it was + /// written to exclude, and rendering it as `false` denies every one it was written to permit. + /// Neither is the declaration, so neither is emitted. + #[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 152b8b16b..d483c1c71 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.\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 +{"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`). Azure takes a\nbare catalog name such as `ubuntu` — it creates a session from a public catalog disk\nimage, so a registry path, tag or digest has nowhere to go."},"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`. Azure delivers the first half only: its\negress rules match host patterns, so a private range has nothing to render into and the\ndata plane's own default applies.","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/sandboxCode.json b/packages/core/src/generated/schemas/sandboxCode.json index a91988b5c..d83575f97 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`). Azure takes a\nbare catalog name such as `ubuntu` — it creates a session from a public catalog disk\nimage, so a registry path, tag or digest has nowhere to go."},"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 b88385e76..25269f56a 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.\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 +{"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`. Azure delivers the first half only: its\negress rules match host patterns, so a private range has nothing to render into and the\ndata plane's own default applies.","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-code-schema.ts b/packages/core/src/generated/zod/sandbox-code-schema.ts index 5a9743b23..d81f72a7c 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`). Azure takes a\nbare catalog name such as `ubuntu` — it creates a session from a public catalog disk\nimage, so a registry path, tag or digest has nowhere to go."), "type": z.enum(["image"]) }), z.object({ "src": z.string().describe("The source directory to build from"), From 981b7686e5a522fb90cbc7f134cda8a281c3de73 Mon Sep 17 00:00:00 2001 From: Itamar Zand <133867530+ItamarZand88@users.noreply.github.com> Date: Sat, 22 Aug 2026 13:05:19 +0300 Subject: [PATCH 21/29] fix(sandbox): own a resume whose outcome the data plane never reported MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A refusal is the one answer that proves a session stayed asleep. A 5xx, a timeout or a dropped connection does not: the data plane can take the resume and answer nothing, and the session wakes. Counting that as "did not wake" left the put-back inert, so a session this call returned to the network under a policy the declaration forbids was abandoned there — the hole the previous commit closed, reached through the other door. A stop that answers 404 now ends the put-back quietly. The session has reached the state the stop was for, and naming it as left awake sends an operator looking for a sandbox that does not exist. --- .../src/providers/sandbox/azure.rs | 118 +++++++++++++++++- 1 file changed, 115 insertions(+), 3 deletions(-) diff --git a/crates/alien-bindings/src/providers/sandbox/azure.rs b/crates/alien-bindings/src/providers/sandbox/azure.rs index 517e57841..20dc6cbc1 100644 --- a/crates/alien-bindings/src/providers/sandbox/azure.rs +++ b/crates/alien-bindings/src/providers/sandbox/azure.rs @@ -711,13 +711,20 @@ impl AzureSandbox { *resumed_here = true; } Err(error) => { - warn!(session = %session_id, %error, "resume was refused; still waiting"); - refusal = Some(match &error.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); } } } @@ -799,6 +806,11 @@ impl AzureSandbox { 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 { @@ -3028,6 +3040,106 @@ mod tests { 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 From 51f27d43db019a616cb1dffa762e5b87fa807d29 Mon Sep 17 00:00:00 2001 From: Itamar Zand <133867530+ItamarZand88@users.noreply.github.com> Date: Sat, 22 Aug 2026 13:38:00 +0300 Subject: [PATCH 22/29] fix(sandbox): keep the caller's variables off the shell that bounds them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The exec endpoint takes no environment, so a command's variables travel in the shell string. In front of the wrapper they applied to it: a declared PATH could hand `setsid`, `sleep` and `kill` no-ops, and the deadline that bounds untrusted code would never fire. They go through `env` now, so they reach the command and nothing else. The wrapper also unsets the names it uses before assigning them. An inherited exported variable keeps its export attribute across re-assignment, so a session created with `nonce` set was handing the command the deadline token it uses to tell a kill from an exit. Alongside: a reconnect refuses a session it did not wake rather than deleting it, matching what resume already did — two revisions of a stack share a sandbox group, and the replacement `get_or_create` owes its caller does not require ending the other one's work. Azure's catalog image is read while planning, where a sandbox no worker binds is still seen, and `code.image` says that AWS and Azure narrow it in opposite directions. `Allow` no longer claims a private-range denial that a host-pattern matcher and a boolean switch cannot express. --- .../src/providers/sandbox/azure.rs | 109 ++++++++++++------ .../src/providers/sandbox/mod.rs | 59 +++++++++- crates/alien-core/src/resources/sandbox.rs | 37 +++--- .../tests/generator/resource_layer_tests.rs | 3 + .../compile_time/sandbox_platform_support.rs | 4 +- .../src/emitters/gcp/sandbox.rs | 3 + .../core/src/generated/schemas/sandbox.json | 2 +- .../src/generated/schemas/sandboxCode.json | 2 +- .../src/generated/schemas/sandboxEgress.json | 2 +- .../src/generated/zod/sandbox-code-schema.ts | 2 +- 10 files changed, 162 insertions(+), 61 deletions(-) diff --git a/crates/alien-bindings/src/providers/sandbox/azure.rs b/crates/alien-bindings/src/providers/sandbox/azure.rs index 20dc6cbc1..cc54f9ae4 100644 --- a/crates/alien-bindings/src/providers/sandbox/azure.rs +++ b/crates/alien-bindings/src/providers/sandbox/azure.rs @@ -515,9 +515,12 @@ impl AzureSandbox { // Judged asleep first: waking one that already fails puts its workload back on the network // for a boot. - if let Err(error) = self.judge_if_judgeable(&found) { - return Err(self.discard(session_id, error).await); - } + // + // Refused rather than deleted, here and after the wake. A session under a policy this + // declaration does not allow may be another revision's, mid-command, in the group both + // share — and `get_or_create` gets what it owes the caller from the replacement its own + // refusal triggers, without ending work it does not own. + 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. @@ -527,13 +530,10 @@ impl AzureSandbox { .await { Ok(running) => running, - // A wait that woke it and then failed leaves it awake, and this call is about to hand - // back a different session — so the one it woke is its own to reap. - Err(error) if resumed_here => return Err(self.discard(session_id, error).await), - Err(error) => return Err(error), + 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.discard(session_id, error).await); + return Err(self.put_back(session_id, resumed_here, error).await); } Ok(SandboxSession { @@ -930,18 +930,24 @@ fn bounded_shell( 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() + 2); + if !env.is_empty() { + argv.push("env".to_string()); + argv.extend(env.iter().map(|(name, value)| format!("{name}={value}"))); + argv.push("--".to_string()); + } + argv.extend(command.iter().cloned()); + + let arguments = argv .iter() .map(|argument| format!(" '{}'", escape(argument))) .collect::(); - // Assignments in front of a simple command are exported to it, so the wrapper and everything - // it runs see them. - let assignments = env - .iter() - .map(|(name, value)| format!("{name}='{}' ", escape(value))) - .collect::(); format!( - "{assignments}sh -c '{}' sh{arguments}", + "sh -c '{}' sh{arguments}", escape(&DeadlineReport::bounded_program(deadline)) ) } @@ -1671,8 +1677,34 @@ mod tests { ); assert!( - wrapped.starts_with("TOKEN='a'\\''; rm -rf /' sh -c '"), - "the value has to survive as one word: {wrapped}" + 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" ); } @@ -2556,7 +2588,9 @@ mod tests { /// /// `get_or_create` owes the caller a usable session, and a stale-policy sandbox is as /// unusable as a terminated one — returning the refusal forever would leave the caller with - /// no way forward and the old sandbox still running. + /// no way forward. The old sandbox is left where it is: another revision of the same stack + /// shares this group and may be running in it, 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(); @@ -2580,11 +2614,7 @@ mod tests { }), )) }); - client - .expect_delete_sandbox() - .withf(|_, id| id == "built-under-allow") - .times(1) - .returning(|_, _| Ok(())); + client.expect_delete_sandbox().never(); client .expect_create_sandbox() .times(1) @@ -2683,8 +2713,9 @@ mod tests { } reads += 1; Ok(match reads { - // Suspended and compliant, so the reconnect proceeds. - 1 => { + // 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 @@ -2708,14 +2739,15 @@ mod tests { ), }) }); - // However it wakes — resumed here or already coming up — the read after it is the one - // that decides, and a sandbox this code woke and then refused must not be left running. + // 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_delete_sandbox() + .expect_stop_sandbox() .withf(|_, id| id == "was-suspended") .times(1) .returning(|_, _| Ok(())); + client.expect_delete_sandbox().never(); client .expect_create_sandbox() .times(1) @@ -2947,12 +2979,13 @@ mod tests { assert_eq!(error.code, "UNEXPECTED_RESPONSE_FORMAT", "{error}"); } - /// A reconnect that woke a session and then could not use it reaps the one it woke. + /// 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 - /// back on the network and then walked away from. + /// 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_reaped() { + 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| { @@ -2966,10 +2999,11 @@ mod tests { .times(1) .returning(|_, _| Ok(())); client - .expect_delete_sandbox() + .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 { @@ -2998,7 +3032,7 @@ mod tests { client .expect_execute_shell_command() .times(1) - .withf(|_, _, shell, _| shell.starts_with("TOKEN='t' sh -c '")) + .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), @@ -3374,10 +3408,9 @@ mod tests { Ok(sandbox) }); client.expect_resume_sandbox().never(); - client - .expect_delete_sandbox() - .times(1) - .returning(|_, _| Ok(())); + // 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) diff --git a/crates/alien-bindings/src/providers/sandbox/mod.rs b/crates/alien-bindings/src/providers/sandbox/mod.rs index 272b2c7d7..8a298ba10 100644 --- a/crates/alien-bindings/src/providers/sandbox/mod.rs +++ b/crates/alien-bindings/src/providers/sandbox/mod.rs @@ -51,7 +51,9 @@ 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"))] @@ -77,7 +79,8 @@ 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=$!; \ @@ -307,6 +310,58 @@ mod tests { ); } + /// An inherited variable of the wrapper's own name never reaches the command. + /// + /// Run against a real `sh`, because the hazard is a shell rule rather than a string: an + /// exported variable keeps its export attribute across re-assignment, so `nonce=$(…)` would + /// write the session's own nonce into the slot the command inherits, and untrusted code could + /// then claim a deadline it was never given. A stand-in `setsid` is supplied because macOS + /// ships none, and without it the wrapper exits before reaching any of this. + #[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-core/src/resources/sandbox.rs b/crates/alien-core/src/resources/sandbox.rs index 23a9bd5b8..2fe190890 100644 --- a/crates/alien-core/src/resources/sandbox.rs +++ b/crates/alien-core/src/resources/sandbox.rs @@ -25,9 +25,12 @@ 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`). Azure takes a - /// bare catalog name such as `ubuntu` — it creates a session from a public catalog disk - /// image, so a registry path, tag or digest has nowhere to go. + /// Image reference (e.g. `ubuntu:24.04`, `ghcr.io/myorg/sandbox:latest`). + /// + /// Two backends narrow it, in opposite directions: AWS builds a MicroVM from an `s3://` + /// bundle, and Azure names an entry in a public catalog, so a bare `ubuntu`. One + /// declaration therefore cannot target both, and each refuses the other's shape while + /// planning. image: String, }, /// Source built into a sandbox image at deploy time. @@ -132,9 +135,9 @@ 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`. Azure delivers the first half only: its - /// egress rules match host patterns, so a private range has nothing to render into and the - /// data plane's own default applies. + /// 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. /// @@ -543,12 +546,6 @@ impl Sandbox { self.validate_capabilities(&capabilities, platform) } - /// 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 - /// only tier that honours a ceiling is one whose peak fits inside it. A declaration no tier - /// satisfies is refused: shipping the nearest size would give the customer a sandbox that - /// exceeds the bound they wrote down. /// The catalog disk image Azure creates a session from. /// /// Azure names a public catalog entry rather than pulling a reference, so a registry path, @@ -565,10 +562,12 @@ impl Sandbox { }; let SandboxCode::Image { image } = &self.code else { - return Err(refused( - "source", - "no sandbox backend builds an image from source yet", - )); + 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(); @@ -588,6 +587,12 @@ impl Sandbox { 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 + /// only tier that honours a ceiling is one whose peak fits inside it. A declaration no tier + /// satisfies is refused: shipping the nearest size would give the customer a sandbox that + /// exceeds the bound they wrote down. pub fn microvm_tier(&self) -> Result { let Some(limits) = self.limits.as_ref() else { // Nothing declared: AWS's own default baseline, which is also `default_limits`. diff --git a/crates/alien-helm/tests/generator/resource_layer_tests.rs b/crates/alien-helm/tests/generator/resource_layer_tests.rs index 03872b559..b860524b0 100644 --- a/crates/alien-helm/tests/generator/resource_layer_tests.rs +++ b/crates/alien-helm/tests/generator/resource_layer_tests.rs @@ -167,6 +167,9 @@ fn a_sandbox_allowing_egress_still_denies_the_metadata_endpoint() { /// 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_refused_rather_than_widened() { let stack = Stack::new("sandbox-domains-chart".to_string()) 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 4dda41d44..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 { diff --git a/crates/alien-terraform/src/emitters/gcp/sandbox.rs b/crates/alien-terraform/src/emitters/gcp/sandbox.rs index f7f23ed38..bd0e9da22 100644 --- a/crates/alien-terraform/src/emitters/gcp/sandbox.rs +++ b/crates/alien-terraform/src/emitters/gcp/sandbox.rs @@ -105,6 +105,9 @@ mod tests { /// `--allow-egress` is a switch: rendering the list as `true` opens every address it was /// written to exclude, and rendering it as `false` denies every one it was written to permit. /// 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 { diff --git a/packages/core/src/generated/schemas/sandbox.json b/packages/core/src/generated/schemas/sandbox.json index d483c1c71..6f3ebf134 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`). Azure takes a\nbare catalog name such as `ubuntu` — it creates a session from a public catalog disk\nimage, so a registry path, tag or digest has nowhere to go."},"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`. Azure delivers the first half only: its\negress rules match host patterns, so a private range has nothing to render into and the\ndata plane's own default applies.","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 +{"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 builds a MicroVM from an `s3://`\nbundle, and Azure names an entry in a public catalog, so a bare `ubuntu`. One\ndeclaration therefore cannot target both, and each refuses the other's shape while\nplanning."},"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/sandboxCode.json b/packages/core/src/generated/schemas/sandboxCode.json index d83575f97..f4ef713d4 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`). Azure takes a\nbare catalog name such as `ubuntu` — it creates a session from a public catalog disk\nimage, so a registry path, tag or digest has nowhere to go."},"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 builds a MicroVM from an `s3://`\nbundle, and Azure names an entry in a public catalog, so a bare `ubuntu`. One\ndeclaration therefore cannot target both, and each refuses the other's shape while\nplanning."},"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 25269f56a..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`. Azure delivers the first half only: its\negress rules match host patterns, so a private range has nothing to render into and the\ndata plane's own default applies.","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 +{"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-code-schema.ts b/packages/core/src/generated/zod/sandbox-code-schema.ts index d81f72a7c..097faa453 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`). Azure takes a\nbare catalog name such as `ubuntu` — it creates a session from a public catalog disk\nimage, so a registry path, tag or digest has nowhere to go."), + "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 builds a MicroVM from an `s3://`\nbundle, and Azure names an entry in a public catalog, so a bare `ubuntu`. One\ndeclaration therefore cannot target both, and each refuses the other's shape while\nplanning."), "type": z.enum(["image"]) }), z.object({ "src": z.string().describe("The source directory to build from"), From ff27c0153bccaa918123a4b6402539eb5cc8d95b Mon Sep 17 00:00:00 2001 From: Itamar Zand <133867530+ItamarZand88@users.noreply.github.com> Date: Sat, 22 Aug 2026 14:15:52 +0300 Subject: [PATCH 23/29] docs(sandbox): hold the new comments to the standard Fifteen comments and doc-strings cut to the bar: the ones that had grown to five or six lines saying what three could, one that restated the name of the function under it, and one field doc whose middle sentence had no verb. Two that were not about wording. `judgeable`'s catch-all arm covers the transitional and terminal states and said nothing about why they carry nothing to judge. And the test for a command's declared variables never polled the stream it was handed, so it proved the call returned rather than that the command ran; it drains the stream and reads the exit now. --- .../alien-azure-clients/src/azure/common.rs | 7 +-- .../src/azure/sandbox_data_plane.rs | 22 +++------ .../src/providers/sandbox/azure.rs | 45 +++++++++---------- .../src/providers/sandbox/mod.rs | 8 ++-- crates/alien-core/src/resources/sandbox.rs | 12 ++--- .../src/emitters/azure/sandbox.rs | 4 +- .../src/emitters/gcp/sandbox.rs | 5 +-- 7 files changed, 40 insertions(+), 63 deletions(-) diff --git a/crates/alien-azure-clients/src/azure/common.rs b/crates/alien-azure-clients/src/azure/common.rs index 126159e7a..a0347a784 100644 --- a/crates/alien-azure-clients/src/azure/common.rs +++ b/crates/alien-azure-clients/src/azure/common.rs @@ -186,10 +186,8 @@ impl AzureClientBase { /// Sends a request exactly once, with no retry. /// - /// For the verbs a repeat performs twice: a PUT to a collection with a server-minted id makes - /// a second resource the caller has no id for, and an exec that answered late may already - /// have started the command. Neither carries an idempotency key, so the only safe number of - /// attempts is one. + /// 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, @@ -199,7 +197,6 @@ impl AzureClientBase { Self::send_once(&self.client, req, op, res_name).await } - /// One attempt: send it, and turn a non-success status into an error carrying the context. async fn send_once( client: &reqwest::Client, req: reqwest::Request, 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 0b217336f..3c826b8a8 100644 --- a/crates/alien-azure-clients/src/azure/sandbox_data_plane.rs +++ b/crates/alien-azure-clients/src/azure/sandbox_data_plane.rs @@ -409,11 +409,8 @@ impl SandboxDataPlaneApi for AzureSandboxDataPlaneClient { let signed = self.base.sign_request(request, &token).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 minted by the service and this is a PUT to a collection, so a - // re-send mints a second sandbox — and with no enumeration verb, the first one has no - // id-holder and nothing to reap it. + // 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) @@ -482,10 +479,8 @@ impl SandboxDataPlaneApi for AzureSandboxDataPlaneClient { let signed = self.base.sign_request(request, &token).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, and - // running untrusted code a second time is not a recovery. + // 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) @@ -603,8 +598,7 @@ impl SandboxDataPlaneApi for AzureSandboxDataPlaneClient { .body(body) .build()?; let signed = self.base.sign_request(request, &token).await?; - // The body is a caller-supplied path; wrapped like the other bodied calls so the next one - // added here inherits the redaction rather than the omission. + // 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, )?; @@ -648,10 +642,8 @@ mod tests { /// A create is delivered once, however the data plane answers. /// - /// The id is minted by the service and the PUT names a collection, so a second delivery makes - /// a second sandbox that no id-holder can find and no enumeration verb can list — one this - /// call would never learn about even when it eventually succeeds. The read is the contrast: - /// repeating it is free, so it keeps the retry. + /// 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; diff --git a/crates/alien-bindings/src/providers/sandbox/azure.rs b/crates/alien-bindings/src/providers/sandbox/azure.rs index cc54f9ae4..4dc5ea893 100644 --- a/crates/alien-bindings/src/providers/sandbox/azure.rs +++ b/crates/alien-bindings/src/providers/sandbox/azure.rs @@ -514,12 +514,8 @@ impl AzureSandbox { }; // 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. A session under a policy this - // declaration does not allow may be another revision's, mid-command, in the group both - // share — and `get_or_create` gets what it owes the caller from the replacement its own - // refusal triggers, without ending work it does not own. + // 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 @@ -773,11 +769,12 @@ impl AzureSandbox { 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, } } - /// Judges a record only where there is something to judge. fn judge_if_judgeable( &self, sandbox: &alien_azure_clients::azure::sandbox_data_plane::Sandbox, @@ -2587,10 +2584,8 @@ mod tests { /// 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 — returning the refusal forever would leave the caller with - /// no way forward. The old sandbox is left where it is: another revision of the same stack - /// shares this group and may be running in it, and the replacement is what this caller asked - /// for. + /// 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(); @@ -2891,10 +2886,8 @@ mod tests { /// A suspended session that reports no policy reads as suspended, not as a mismatch. /// - /// `get` and the reconnect path have to answer this the same way. Whether the data plane - /// reports `egressPolicy` for a sandbox that is not running is unverified, so if it does not, - /// judging the record here would turn every idle-suspended session into a containment - /// failure — and `suspendResume` would advertise a state the caller cannot observe. + /// 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(); @@ -2948,9 +2941,8 @@ mod tests { /// A wait that woke a session and then failed still puts it back. /// - /// The refusal is not the only way out of `resume`: 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. + /// 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(); @@ -3019,10 +3011,8 @@ mod tests { /// The variables a command declares reach the command. /// - /// Every other backend honours `RunCommandRequest.env`; the exec endpoint here takes no - /// environment at all, so dropping it silently would make one backend answer a documented - /// field with nothing, and the failure would surface inside the sandbox rather than at the - /// call. + /// 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(); @@ -3045,10 +3035,17 @@ mod tests { let mut request = command(5); request.env = BTreeMap::from([("TOKEN".to_string(), "t".to_string())]); - sandbox_with(client) + let frames: Vec> = sandbox_with(client) .run_command("s1", request) .await - .expect("a command declaring a variable must run"); + .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. diff --git a/crates/alien-bindings/src/providers/sandbox/mod.rs b/crates/alien-bindings/src/providers/sandbox/mod.rs index 8a298ba10..dd308ad5e 100644 --- a/crates/alien-bindings/src/providers/sandbox/mod.rs +++ b/crates/alien-bindings/src/providers/sandbox/mod.rs @@ -312,11 +312,9 @@ mod tests { /// An inherited variable of the wrapper's own name never reaches the command. /// - /// Run against a real `sh`, because the hazard is a shell rule rather than a string: an - /// exported variable keeps its export attribute across re-assignment, so `nonce=$(…)` would - /// write the session's own nonce into the slot the command inherits, and untrusted code could - /// then claim a deadline it was never given. A stand-in `setsid` is supplied because macOS - /// ships none, and without it the wrapper exits before reaching any of this. + /// 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() { diff --git a/crates/alien-core/src/resources/sandbox.rs b/crates/alien-core/src/resources/sandbox.rs index 2fe190890..fbd4ce004 100644 --- a/crates/alien-core/src/resources/sandbox.rs +++ b/crates/alien-core/src/resources/sandbox.rs @@ -27,10 +27,8 @@ pub enum SandboxCode { Image { /// Image reference (e.g. `ubuntu:24.04`, `ghcr.io/myorg/sandbox:latest`). /// - /// Two backends narrow it, in opposite directions: AWS builds a MicroVM from an `s3://` - /// bundle, and Azure names an entry in a public catalog, so a bare `ubuntu`. One - /// declaration therefore cannot target both, and each refuses the other's shape while - /// planning. + /// 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. @@ -1213,10 +1211,8 @@ mod tests { /// An image reference Azure cannot honour is refused while planning, not at the first session. /// - /// The examples in `code.image`'s own documentation — a tag, a registry path — are exactly - /// what Azure cannot take, so this is the shape a customer is most likely to declare. Caught - /// at plan time it names the sandbox; caught nowhere, it renders into the module, plans, - /// applies, and fails when the first session is created. + /// `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![]); diff --git a/crates/alien-terraform/src/emitters/azure/sandbox.rs b/crates/alien-terraform/src/emitters/azure/sandbox.rs index 34954e437..a5e516bf7 100644 --- a/crates/alien-terraform/src/emitters/azure/sandbox.rs +++ b/crates/alien-terraform/src/emitters/azure/sandbox.rs @@ -179,9 +179,7 @@ mod tests { /// 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 lands on a customer's cluster as a deserialization failure at the - /// first session rather than as a failure at plan time. The names come from the type here, - /// not from a second hand-typed list. + /// 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( diff --git a/crates/alien-terraform/src/emitters/gcp/sandbox.rs b/crates/alien-terraform/src/emitters/gcp/sandbox.rs index bd0e9da22..4c31601bb 100644 --- a/crates/alien-terraform/src/emitters/gcp/sandbox.rs +++ b/crates/alien-terraform/src/emitters/gcp/sandbox.rs @@ -102,9 +102,8 @@ mod tests { /// A hostname list is refused rather than carried as its nearest boolean. /// - /// `--allow-egress` is a switch: rendering the list as `true` opens every address it was - /// written to exclude, and rendering it as `false` denies every one it was written to permit. - /// Neither is the declaration, so neither is emitted. + /// `--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. From 024b408d2a9eec9a88d7c82e8bede47bb1d45799 Mon Sep 17 00:00:00 2001 From: Itamar Zand <133867530+ItamarZand88@users.noreply.github.com> Date: Sat, 22 Aug 2026 14:46:12 +0300 Subject: [PATCH 24/29] fix(sandbox): make the wrapper this builds actually run MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `env` reads operands as assignments until one is not, then execs that one. The separator sat after the assignments, so it became the program name and every Azure command carrying variables died with 127 before it started. Dropped, and a test now runs the generated string through a real shell instead of asserting its shape — the shape was exactly what was intended, and what was intended was wrong. A program whose own name carries `=` is refused when the call also declares variables, because `env` would take it for a variable and run the next argument in its place. A session can no longer set `PATH`, `IFS`, `LD_PRELOAD` or `LD_LIBRARY_PATH`. The wrapper that holds a command to its deadline runs inside the session and inherits them: a session `PATH` chooses which `od` draws the nonce, which is the whole basis for telling a kill from an exit. The same names per command are fine — those reach the command and nothing else. The wrapper also quotes the last two expansions it had left bare, so an inherited `IFS` cannot split a pid into words that are not children. Comments that still described assignments in front of the wrapper, and three that said a refused session is deleted, now say what the code does. Schemas regenerated for a doc reworded after the last run. --- .../src/providers/sandbox/azure.rs | 212 ++++++++++++++++-- .../src/providers/sandbox/mod.rs | 11 +- .../core/src/generated/schemas/sandbox.json | 2 +- .../src/generated/schemas/sandboxCode.json | 2 +- .../src/generated/zod/sandbox-code-schema.ts | 2 +- 5 files changed, 205 insertions(+), 24 deletions(-) diff --git a/crates/alien-bindings/src/providers/sandbox/azure.rs b/crates/alien-bindings/src/providers/sandbox/azure.rs index 4dc5ea893..28d16ff46 100644 --- a/crates/alien-bindings/src/providers/sandbox/azure.rs +++ b/crates/alien-bindings/src/providers/sandbox/azure.rs @@ -142,6 +142,8 @@ 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 @@ -236,8 +238,9 @@ impl Sandbox for AzureSandbox { 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. The gate has already - // discarded whatever it refused, so nothing is left running. + // session, and are answered the same way: a fresh one. A session refused for its + // policy is left running: 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. @@ -287,11 +290,25 @@ impl Sandbox for AzureSandbox { // 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. // The data plane's exec takes a command and a working directory and nothing else, so a - // per-command variable has to travel as a shell assignment in front of it. Names are - // checked first: an unchecked one is a second command, not a variable. + // 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. 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?; @@ -492,9 +509,10 @@ impl Sandbox for AzureSandbox { impl AzureSandbox { /// Brings a session the caller named back into service, or says why it cannot be. /// - /// The one path that repairs rather than refusing: `get_or_create` asked for a usable - /// session, so a session that cannot serve is discarded and replaced rather than returned as - /// an error the caller has no way to act on. + /// 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 { @@ -931,11 +949,10 @@ fn bounded_shell( // 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() + 2); + 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.push("--".to_string()); } argv.extend(command.iter().cloned()); @@ -949,10 +966,36 @@ fn bounded_shell( ) } -/// Refuses a variable name the shell would read as anything other than a name. +/// Names a session may not set, because the shell that bounds a command inherits them. /// -/// The name is not quotable — it sits left of the `=` — so a name carrying a space or a `;` is a -/// second command rather than a variable, and quoting the value alone would not stop it. +/// A session-level `PATH` chooses which `od` draws the deadline nonce, and an `IFS` changes how +/// the wrapper reads its own pids back — either hands the command a deadline it can forge. The +/// same names are safe per command, where they travel through `env` and reach only the command. +const SESSION_ENV_REFUSED: [&str; 4] = ["PATH", "IFS", "LD_PRELOAD", "LD_LIBRARY_PATH"]; + +/// Refuses an environment a session must not carry. +fn checked_session_env(operation: &str, env: &BTreeMap) -> Result<()> { + for name in env.keys() { + checked_env_name(operation, name)?; + if SESSION_ENV_REFUSED.contains(&name.as_str()) { + 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()) @@ -1674,7 +1717,7 @@ mod tests { ); assert!( - wrapped.ends_with("' sh 'env' 'TOKEN=a'\\''; rm -rf /' '--' 'printenv' 'TOKEN'"), + wrapped.ends_with("' sh 'env' 'TOKEN=a'\\''; rm -rf /' 'printenv' 'TOKEN'"), "the value has to survive as one argument to env: {wrapped}" ); } @@ -1700,11 +1743,145 @@ mod tests { "the wrapper has to resolve its own tools: {wrapper}" ); assert_eq!( - argv, "'env' 'PATH=/tmp/attacker' '--' 'sleep' 'forever'", + 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. + #[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() { + for name in ["PATH", "IFS", "LD_PRELOAD", "LD_LIBRARY_PATH"] { + 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 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() { @@ -2757,8 +2934,9 @@ mod tests { .await .expect("a caller asking for a session gets a usable one"); - // Answered the same way as a terminated id: the one that woke up wider is discarded and - // replaced, rather than returned as an error the caller cannot act on. + // 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"); } @@ -3022,7 +3200,7 @@ mod tests { client .expect_execute_shell_command() .times(1) - .withf(|_, _, shell, _| shell.ends_with("' sh 'env' 'TOKEN=t' '--' 'sleep' 'forever'")) + .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), diff --git a/crates/alien-bindings/src/providers/sandbox/mod.rs b/crates/alien-bindings/src/providers/sandbox/mod.rs index dd308ad5e..415c977e0 100644 --- a/crates/alien-bindings/src/providers/sandbox/mod.rs +++ b/crates/alien-bindings/src/providers/sandbox/mod.rs @@ -84,7 +84,8 @@ impl DeadlineReport { 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\"; \ @@ -296,11 +297,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!( diff --git a/packages/core/src/generated/schemas/sandbox.json b/packages/core/src/generated/schemas/sandbox.json index 6f3ebf134..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`).\n\nTwo backends narrow it, in opposite directions: AWS builds a MicroVM from an `s3://`\nbundle, and Azure names an entry in a public catalog, so a bare `ubuntu`. One\ndeclaration therefore cannot target both, and each refuses the other's shape while\nplanning."},"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 +{"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/sandboxCode.json b/packages/core/src/generated/schemas/sandboxCode.json index f4ef713d4..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`).\n\nTwo backends narrow it, in opposite directions: AWS builds a MicroVM from an `s3://`\nbundle, and Azure names an entry in a public catalog, so a bare `ubuntu`. One\ndeclaration therefore cannot target both, and each refuses the other's shape while\nplanning."},"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/zod/sandbox-code-schema.ts b/packages/core/src/generated/zod/sandbox-code-schema.ts index 097faa453..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`).\n\nTwo backends narrow it, in opposite directions: AWS builds a MicroVM from an `s3://`\nbundle, and Azure names an entry in a public catalog, so a bare `ubuntu`. One\ndeclaration therefore cannot target both, and each refuses the other's shape while\nplanning."), + "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"), From e4a3de285cb7ab41d2e52c9a6fd8412d4f7cf023 Mon Sep 17 00:00:00 2001 From: Itamar Zand <133867530+ItamarZand88@users.noreply.github.com> Date: Sat, 22 Aug 2026 15:22:07 +0300 Subject: [PATCH 25/29] fix(sandbox): refuse the loader family, not the two names guessed first MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `LD_AUDIT` runs attacker code inside every process the wrapper starts — including `od`, which draws the nonce the deadline report rests on. Code in the session can then write a nonce it chose to stderr before the wrapper announces one, exit 137 itself, and be reported as killed at a deadline nothing enforced. Demonstrated end to end against a real glibc loader: a command that ran for no time at all claimed a 30-second kill. Refused as `LD_*` rather than by name. The list was two entries long because two were suggested, and the loader reads more than anyone maintaining that list would remember. A command naming no program is refused too. `env` with assignments and no operand prints the environment it was handed and exits 0, so an empty command returned the session's own variables to the caller as a command that succeeded. --- .../src/providers/sandbox/azure.rs | 66 ++++++++++++++++--- 1 file changed, 56 insertions(+), 10 deletions(-) diff --git a/crates/alien-bindings/src/providers/sandbox/azure.rs b/crates/alien-bindings/src/providers/sandbox/azure.rs index 28d16ff46..05f3ca60f 100644 --- a/crates/alien-bindings/src/providers/sandbox/azure.rs +++ b/crates/alien-bindings/src/providers/sandbox/azure.rs @@ -292,6 +292,14 @@ impl Sandbox for AzureSandbox { // 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)?; } @@ -966,18 +974,18 @@ fn bounded_shell( ) } -/// Names a session may not set, because the shell that bounds a command inherits them. -/// -/// A session-level `PATH` chooses which `od` draws the deadline nonce, and an `IFS` changes how -/// the wrapper reads its own pids back — either hands the command a deadline it can forge. The -/// same names are safe per command, where they travel through `env` and reach only the command. -const SESSION_ENV_REFUSED: [&str; 4] = ["PATH", "IFS", "LD_PRELOAD", "LD_LIBRARY_PATH"]; - /// 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. Refused as a family +/// rather than a list, because the loader's set is longer than anything kept here would be. 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 SESSION_ENV_REFUSED.contains(&name.as_str()) { + if matches!(name.as_str(), "PATH" | "IFS") || name.starts_with("LD_") { return Err(AlienError::new(ErrorData::InvalidInput { operation_context: operation.to_string(), details: format!( @@ -1753,7 +1761,10 @@ mod tests { /// 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. + /// + /// 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() { @@ -1820,7 +1831,17 @@ mod tests { /// 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() { - for name in ["PATH", "IFS", "LD_PRELOAD", "LD_LIBRARY_PATH"] { + // `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", + ] { let mut client = MockSandboxDataPlaneApi::new(); client.expect_create_sandbox().never(); @@ -1857,6 +1878,31 @@ mod tests { .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 From 1d0a1c6f97ea8a4197d2786787b5f11017fbbd18 Mon Sep 17 00:00:00 2001 From: Itamar Zand <133867530+ItamarZand88@users.noreply.github.com> Date: Sat, 22 Aug 2026 15:55:58 +0300 Subject: [PATCH 26/29] fix(sandbox): find the deadline report by its shape, not its position MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bash imports `SHELLOPTS` from the environment and applies what it lists, including tracing, even when it is called `sh`. The wrapper's own trace then occupies the first line of stderr, the announcement is not where the reader looked for it, and every command in that session comes back as one the session could not bound — the ones that succeeded included. The reader now takes the first line that is a nonce and nothing else. The trace cannot be mistaken for it: a traced line carries the shell's prefix, and bash does not take that prefix from the environment. What precedes the announcement is dropped rather than returned, because it was written before the command started and one of those lines is the trace of the announcement itself. The width is checked exactly. A single hex character on a line of its own was an announcement, which made most of the stream its own repeat. `SHELLOPTS` and `BASHOPTS` join the names a session may not set. The two answers are deliberate: one keeps a name nobody listed from breaking the report, the other keeps the ones we know about out of the session. --- .../src/providers/sandbox/azure.rs | 21 ++++-- .../src/providers/sandbox/local.rs | 5 +- .../src/providers/sandbox/mod.rs | 67 ++++++++++++++++--- 3 files changed, 78 insertions(+), 15 deletions(-) diff --git a/crates/alien-bindings/src/providers/sandbox/azure.rs b/crates/alien-bindings/src/providers/sandbox/azure.rs index 05f3ca60f..a4aac18e0 100644 --- a/crates/alien-bindings/src/providers/sandbox/azure.rs +++ b/crates/alien-bindings/src/providers/sandbox/azure.rs @@ -979,13 +979,17 @@ fn bounded_shell( /// 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. Refused as a family -/// rather than a list, because the loader's set is longer than anything kept here would be. The -/// same names per command are safe — those travel through `env` and reach only the command. +/// 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") || name.starts_with("LD_") { + 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!( @@ -1566,7 +1570,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 { @@ -1841,6 +1848,8 @@ mod tests { "LD_AUDIT", "LD_DEBUG", "LD_BIND_NOW", + "SHELLOPTS", + "BASHOPTS", ] { let mut client = MockSandboxDataPlaneApi::new(); client.expect_create_sandbox().never(); @@ -3252,7 +3261,7 @@ mod tests { exit_code: Some(0), stdout: String::new(), // The wrapper announces its nonce before starting the command. - stderr: "beef\n".to_string(), + stderr: "a1b2c3d4a1b2c3d4a1b2c3d4a1b2c3d4\n".to_string(), }) }); 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 415c977e0..8783fe2c9 100644 --- a/crates/alien-bindings/src/providers/sandbox/mod.rs +++ b/crates/alien-bindings/src/providers/sandbox/mod.rs @@ -59,6 +59,13 @@ pub(crate) const DEADLINE_GRACE: std::time::Duration = std::time::Duration::from #[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. @@ -110,11 +117,24 @@ 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)| { + 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 { @@ -224,7 +244,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 @@ -235,7 +255,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, .. } )); } @@ -256,12 +276,43 @@ 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" + ); + + // 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"); }; From da08622c121bc09e728de28a2575d3a657880ca5 Mon Sep 17 00:00:00 2001 From: Itamar Zand <133867530+ItamarZand88@users.noreply.github.com> Date: Sat, 22 Aug 2026 17:55:16 +0300 Subject: [PATCH 27/29] docs(sandbox): say what the deadline wrapper does not survive MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A session runs its command as root on a writable filesystem, so code in it can replace the `od` that draws the nonce and hand every later command a value it chose, or kill the killer — a sibling with the same uid — and outlast its deadline. Both measured against real shells. The doc claimed the nonce was unreachable and left the rest implied. So the wrapper bounds a command that is merely slow and reports honestly on one that is. It is not a boundary against a command working to escape one; the caller-side guard, which ends the session, is that. The process-group note is narrowed to match what it delivers: a child that starts a session of its own leaves the group and outlives the kill. The reader also trims a trailing carriage return. None of the transports here produce one, but a 33-byte announcement is invisible and the first line the command chose gets adopted in its place — an inversion resting on a property of today's transports rather than on anything checked. --- .../src/providers/sandbox/mod.rs | 21 +++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/crates/alien-bindings/src/providers/sandbox/mod.rs b/crates/alien-bindings/src/providers/sandbox/mod.rs index 8783fe2c9..45aad507f 100644 --- a/crates/alien-bindings/src/providers/sandbox/mod.rs +++ b/crates/alien-bindings/src/providers/sandbox/mod.rs @@ -73,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 @@ -123,6 +125,10 @@ impl DeadlineReport { // 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 @@ -296,6 +302,17 @@ mod tests { 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!( From a1e08abfe8be38ab1bcda223cfa312f31a14cfcd Mon Sep 17 00:00:00 2001 From: Itamar Zand <133867530+ItamarZand88@users.noreply.github.com> Date: Sat, 22 Aug 2026 18:14:49 +0300 Subject: [PATCH 28/29] docs(sandbox): a refused session is left as it was found MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The note in `get_or_create` said such a session is left running. It is left asleep when this call is what woke it, because `put_back` suspends what it woke — which is what `reconnect`'s own doc and the test beside it already said. Three statements of one rule, and this was the one that disagreed. --- crates/alien-bindings/src/providers/sandbox/azure.rs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/crates/alien-bindings/src/providers/sandbox/azure.rs b/crates/alien-bindings/src/providers/sandbox/azure.rs index a4aac18e0..12705845f 100644 --- a/crates/alien-bindings/src/providers/sandbox/azure.rs +++ b/crates/alien-bindings/src/providers/sandbox/azure.rs @@ -239,8 +239,9 @@ impl Sandbox for AzureSandbox { // 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 running: it may be another revision's, and this caller is served - // by the replacement rather than by taking theirs. + // 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. From b6179a7e8f2689a557da0fc7f01197753646bce7 Mon Sep 17 00:00:00 2001 From: Itamar Zand <133867530+ItamarZand88@users.noreply.github.com> Date: Sat, 22 Aug 2026 19:57:38 +0300 Subject: [PATCH 29/29] fix(sandbox): send a state transition once, like the verbs beside it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stop and resume move a sandbox between states, so a repeat is refused for the state the first attempt produced. A transition that took effect and lost its response was re-sent, the re-send answered 409, and the caller was told a session it had suspended was still awake — the false `sandboxLeftAwake` report the put-back exists to make trustworthy. They join create and exec on the single-attempt path. The wait above them already re-issues a resume itself, with the state in front of it, so the transport had no business guessing. The test that pins create now pins these too; it did not before, which is why they were classified as safe to repeat. --- .../src/azure/sandbox_data_plane.rs | 24 ++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) 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 3c826b8a8..6932191af 100644 --- a/crates/alien-azure-clients/src/azure/sandbox_data_plane.rs +++ b/crates/alien-azure-clients/src/azure/sandbox_data_plane.rs @@ -334,6 +334,10 @@ impl AzureSandboxDataPlaneClient { } /// 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, @@ -350,7 +354,7 @@ impl AzureSandboxDataPlaneClient { let request = AzureRequestBuilder::new(Method::POST, url).build()?; let signed = self.base.sign_request(request, &token).await?; self.base - .execute_request(signed, operation, sandbox_id) + .execute_request_once(signed, operation, sandbox_id) .await?; Ok(()) } @@ -688,6 +692,24 @@ mod tests { "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