From 32e4dfaab482e61726b34254a8195477e2f64be4 Mon Sep 17 00:00:00 2001 From: Paul Thurlow Date: Tue, 12 May 2026 14:05:28 -0700 Subject: [PATCH 1/3] move to sandbox jwt auth in cli --- src/api.rs | 73 +++++++-- src/auth.rs | 106 ++++++++---- src/main.rs | 33 ++++ src/sandbox.rs | 192 ++++++++++++++++++---- src/sandbox_session.rs | 361 +++++++++++++++++++++++++++++++++++++++++ src/util.rs | 31 +++- 6 files changed, 709 insertions(+), 87 deletions(-) create mode 100644 src/sandbox_session.rs diff --git a/src/api.rs b/src/api.rs index 50271c0..4436ab3 100644 --- a/src/api.rs +++ b/src/api.rs @@ -47,24 +47,61 @@ impl ApiClient { } }; - let api_key_fallback = profile_config - .api_key - .as_deref() - .filter(|k| !k.is_empty() && *k != "PLACEHOLDER"); - - // Pre-flight: return the cached JWT if valid, refresh it if - // close to expiry, or mint a new one from the API key. The - // returned string is a JWT — that's what we send on the wire. - let access_token = match crate::jwt::ensure_access_token(&profile_config, api_key_fallback) - { - Ok(t) => t, - Err(e) => { - eprintln!("{}", format!("error: {e}").red()); - eprintln!( - "Run {} to log in, or pass --api-key.", - "hotdata auth".cyan() - ); - std::process::exit(1); + // Auth source precedence: + // + // 1. `HOTDATA_SANDBOX_TOKEN` env var — a `sandbox run` child + // is executing with the parent's credentials scrubbed. + // Refresh in-memory via `HOTDATA_SANDBOX_REFRESH_TOKEN` if + // the JWT is close to expiry; never write to disk (the + // child's FS may not be writable). + // 2. `~/.hotdata/sandbox_session.json` — the user ran + // `hotdata sandbox set ` (or `sandbox new` / `sandbox + // run` in the parent shell). The sandbox JWT is the active + // bearer for *every* command until `sandbox set` (with no + // id) clears the file. + // 3. `~/.hotdata/session.json` + optional api_key fallback — + // normal user-scoped CLI session. + let api_url = profile_config.api_url.to_string(); + let access_token = if std::env::var("HOTDATA_SANDBOX_TOKEN").is_ok() { + match crate::sandbox_session::refresh_from_env(&api_url) { + Some(t) => t, + None => { + eprintln!("{}", "error: HOTDATA_SANDBOX_TOKEN is empty".red()); + std::process::exit(1); + } + } + } else if crate::sandbox_session::load().is_some() { + match crate::sandbox_session::ensure_access_token(&api_url) { + Some(t) => t, + None => { + eprintln!("{}", "error: sandbox session expired".red()); + eprintln!( + "Run {} to clear it, or {} to re-mint.", + "hotdata sandbox set".cyan(), + "hotdata sandbox set ".cyan(), + ); + std::process::exit(1); + } + } + } else { + let api_key_fallback = profile_config + .api_key + .as_deref() + .filter(|k| !k.is_empty() && *k != "PLACEHOLDER"); + + // Pre-flight: return the cached JWT if valid, refresh it if + // close to expiry, or mint a new one from the API key. The + // returned string is a JWT — that's what we send on the wire. + match crate::jwt::ensure_access_token(&profile_config, api_key_fallback) { + Ok(t) => t, + Err(e) => { + eprintln!("{}", format!("error: {e}").red()); + eprintln!( + "Run {} to log in, or pass --api-key.", + "hotdata auth".cyan() + ); + std::process::exit(1); + } } }; diff --git a/src/auth.rs b/src/auth.rs index 928d732..b688b73 100644 --- a/src/auth.rs +++ b/src/auth.rs @@ -26,21 +26,37 @@ pub enum AuthStatus { } pub fn check_status(profile_config: &config::ProfileConfig) -> AuthStatus { - let api_key_fallback = profile_config - .api_key - .as_deref() - .filter(|k| !k.is_empty() && *k != "PLACEHOLDER"); - - // PKCE-origin sessions don't write an api_key, so absence of a key - // alone isn't "not configured" — only true if there's also no - // cached JWT session to validate. - if api_key_fallback.is_none() && crate::jwt::load_session().is_none() { - return AuthStatus::NotConfigured; - } + // Same precedence as `ApiClient::new`: + // 1. `sandbox run` child via env var + // 2. on-disk sandbox session (sandbox set ) + // 3. user-scoped CLI session / api_key fallback + let api_url = profile_config.api_url.to_string(); + let access_token = if let Some((sandbox_jwt, _)) = + crate::sandbox_session::sandbox_token_in_use() + { + sandbox_jwt + } else if crate::sandbox_session::load().is_some() { + match crate::sandbox_session::ensure_access_token(&api_url) { + Some(t) => t, + None => return AuthStatus::Invalid(401), + } + } else { + let api_key_fallback = profile_config + .api_key + .as_deref() + .filter(|k| !k.is_empty() && *k != "PLACEHOLDER"); + + // PKCE-origin sessions don't write an api_key, so absence of a key + // alone isn't "not configured" — only true if there's also no + // cached JWT session to validate. + if api_key_fallback.is_none() && crate::jwt::load_session().is_none() { + return AuthStatus::NotConfigured; + } - let access_token = match crate::jwt::ensure_access_token(profile_config, api_key_fallback) { - Ok(t) => t, - Err(_) => return AuthStatus::Invalid(401), + match crate::jwt::ensure_access_token(profile_config, api_key_fallback) { + Ok(t) => t, + Err(_) => return AuthStatus::Invalid(401), + } }; let url = format!("{}/workspaces", profile_config.api_url); @@ -64,26 +80,50 @@ pub fn status(profile: &str) { } }; - // The credential the CLI is *about to use*. Note: even when an - // override is set, the wire credential is still a JWT (minted on - // demand from the override) — but we report the user-visible source. - let method_label = match profile_config.api_key_source { - ApiKeySource::Flag => "API Key flag", - ApiKeySource::Env => "API Key env", - ApiKeySource::Config => "CLI Session", + // The credential the CLI is *about to use*. Precedence matches + // `ApiClient::new`: env-var sandbox token (sandbox run child) > + // on-disk sandbox session (sandbox set ) > user CLI session. + let env_sandbox = crate::sandbox_session::sandbox_token_in_use(); + let disk_sandbox = if env_sandbox.is_none() { + crate::sandbox_session::load() + } else { + None }; - - // For Flag/Env we mask the api_key the user supplied. For the - // CLI session path we mask the refresh_token — it's stable across - // commands (unlike the 5-min access_token), so the tail stays - // recognizable between runs. - let credential_tail = match profile_config.api_key_source { - ApiKeySource::Flag | ApiKeySource::Env => profile_config - .api_key - .as_deref() - .map(crate::util::mask_credential), - ApiKeySource::Config => crate::jwt::load_session() - .map(|s| crate::util::mask_credential(&s.refresh_token)), + let (method_label, credential_tail) = if let Some((token, sandbox_id)) = &env_sandbox { + let label = match sandbox_id { + Some(id) => format!("Sandbox {id}"), + None => "Sandbox Session".to_string(), + }; + (label, Some(crate::util::mask_credential(token))) + } else if let Some(s) = &disk_sandbox { + // Use the refresh token for the displayed tail — it's stable + // across refreshes (the access token rotates every 3 days), so + // the tail stays recognizable between runs. + let label = if s.sandbox_id.is_empty() { + "Sandbox Session".to_string() + } else { + format!("Sandbox {}", s.sandbox_id) + }; + (label, Some(crate::util::mask_credential(&s.refresh_token))) + } else { + let label = match profile_config.api_key_source { + ApiKeySource::Flag => "API Key flag", + ApiKeySource::Env => "API Key env", + ApiKeySource::Config => "CLI Session", + }; + // For Flag/Env we mask the api_key the user supplied. For + // the CLI session path we mask the refresh_token — it's + // stable across commands (unlike the 5-min access_token), + // so the tail stays recognizable between runs. + let tail = match profile_config.api_key_source { + ApiKeySource::Flag | ApiKeySource::Env => profile_config + .api_key + .as_deref() + .map(crate::util::mask_credential), + ApiKeySource::Config => crate::jwt::load_session() + .map(|s| crate::util::mask_credential(&s.refresh_token)), + }; + (label.to_string(), tail) }; let method_suffix = match credential_tail { Some(tail) => format!(" - {method_label} [{tail}]"), diff --git a/src/main.rs b/src/main.rs index cabf63b..3a713cf 100644 --- a/src/main.rs +++ b/src/main.rs @@ -14,6 +14,7 @@ mod queries; mod query; mod results; mod sandbox; +mod sandbox_session; mod skill; mod table; mod tables; @@ -81,7 +82,39 @@ fn resolve_workspace(provided: Option) -> String { } } +// libc::atexit (no extra crate needed — the symbol is linked by default). +// Callbacks registered here fire even when subcommands call +// `std::process::exit`, which Rust's `Drop` would otherwise miss. +unsafe extern "C" { + fn atexit(callback: extern "C" fn()) -> i32; +} + +/// Runs once at process exit. If the CLI was authenticating through a +/// sandbox session (env var from `sandbox run`, or on-disk session +/// from `sandbox set `), emit a footer line on stderr so the user +/// can tell at a glance which sandbox served the call. Stderr keeps +/// stdout clean for callers that parse JSON/YAML output. +extern "C" fn print_sandbox_footer() { + use crossterm::style::Stylize; + + let sandbox_id = sandbox_session::sandbox_token_in_use() + .and_then(|(_, sid)| sid) + .or_else(|| { + sandbox_session::load() + .map(|s| s.sandbox_id) + .filter(|s| !s.is_empty()) + }); + if let Some(sid) = sandbox_id { + eprintln!("{}", format!("sandbox: {sid}").dark_grey()); + } +} + fn main() { + // Register before `Cli::parse`, since `--help` / `--version` exit + // from inside the parser. Safety: `atexit` is async-signal-safe; + // the callback only reads env vars / files and writes to stderr. + unsafe { atexit(print_sandbox_footer) }; + dotenvy::dotenv().ok(); let cli = Cli::parse(); diff --git a/src/sandbox.rs b/src/sandbox.rs index 468c989..aa83b87 100644 --- a/src/sandbox.rs +++ b/src/sandbox.rs @@ -1,7 +1,9 @@ use crate::api::ApiClient; use crate::config; +use crate::sandbox_session::{self, SandboxSession}; use crossterm::style::Stylize; use serde::{Deserialize, Serialize}; +use std::time::{SystemTime, UNIX_EPOCH}; #[derive(Deserialize, Serialize)] struct Sandbox { @@ -22,6 +24,38 @@ struct DetailResponse { sandbox: Sandbox, } +/// Response shape of `/v1/auth/sandbox` and `/v1/auth/sandbox/`. +#[derive(Deserialize)] +struct SandboxTokenResponse { + token: String, + refresh_token: String, + sandbox_id: String, + expires_in: u64, + refresh_expires_in: u64, +} + +fn now_unix() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0) +} + +fn persist_sandbox_session(resp: SandboxTokenResponse, workspace_id: &str) { + let now = now_unix(); + let session = SandboxSession { + access_token: resp.token, + refresh_token: resp.refresh_token, + sandbox_id: resp.sandbox_id, + workspace_id: workspace_id.to_string(), + access_expires_at: now + resp.expires_in, + refresh_expires_at: now + resp.refresh_expires_in, + }; + if let Err(e) = sandbox_session::save(&session) { + eprintln!("warning: could not persist sandbox session: {e}"); + } +} + pub fn list(workspace_id: &str, format: &str) { let api = ApiClient::new(Some(workspace_id)); let body: ListResponse = api.get("/sandboxes"); @@ -151,22 +185,27 @@ pub fn new(workspace_id: &str, name: Option<&str>, format: &str) { body["name"] = serde_json::json!(n); } - let resp: DetailResponse = api.post("/sandboxes", &body); - let s = &resp.sandbox; + // POST /auth/sandbox creates the sandbox AND mints a sandbox-scoped + // JWT (+ refresh token) in one round-trip. + let resp: SandboxTokenResponse = api.post("/auth/sandbox", &body); + let sandbox_id = resp.sandbox_id.clone(); + persist_sandbox_session(resp, workspace_id); - // Set as the active sandbox in config - if let Err(e) = config::save_sandbox("default", &s.public_id) { + if let Err(e) = config::save_sandbox("default", &sandbox_id) { eprintln!("warning: could not save sandbox to config: {e}"); } println!("{}", "Sandbox created".green()); match format { - "json" => println!("{}", serde_json::to_string_pretty(s).unwrap()), - "yaml" => print!("{}", serde_yaml::to_string(s).unwrap()), + "json" => println!("{}", serde_json::json!({"public_id": sandbox_id})), + "yaml" => print!( + "{}", + serde_yaml::to_string(&serde_json::json!({"public_id": sandbox_id})).unwrap() + ), "table" => { - println!("id: {}", s.public_id); - if !s.name.is_empty() { - println!("name: {}", s.name); + println!("id: {}", sandbox_id); + if let Some(n) = name { + println!("name: {}", n); } } _ => unreachable!(), @@ -219,36 +258,96 @@ pub fn update( pub fn run(sandbox_id: Option<&str>, workspace_id: &str, name: Option<&str>, cmd: &[String]) { check_sandbox_lock(); - let sid = match sandbox_id { + let api = ApiClient::new(Some(workspace_id)); + + // Mint (or re-mint, for an existing sandbox) a sandbox-scoped JWT + // by hitting the auth endpoint. The same call creates a sandbox + // when no id is provided. Either way we end up with a fresh + // bundle persisted to sandbox_session.json before we spawn. + let resp: SandboxTokenResponse = match sandbox_id { Some(id) => { - // Verify the sandbox exists - let api = ApiClient::new(Some(workspace_id)); - let path = format!("/sandboxes/{id}"); - let _: DetailResponse = api.get(&path); - id.to_string() + let path = format!("/auth/sandbox/{id}"); + api.post(&path, &serde_json::json!({})) } None => { - // Create a new sandbox - let api = ApiClient::new(Some(workspace_id)); let mut body = serde_json::json!({}); if let Some(n) = name { body["name"] = serde_json::json!(n); } - let resp: DetailResponse = api.post("/sandboxes", &body); - resp.sandbox.public_id + api.post("/auth/sandbox", &body) } }; + let sid = resp.sandbox_id.clone(); + let sandbox_jwt = resp.token.clone(); + let sandbox_refresh = resp.refresh_token.clone(); + persist_sandbox_session(resp, workspace_id); + eprintln!("{} {}", "sandbox:".dark_grey(), sid); eprintln!("{} {}", "workspace:".dark_grey(), workspace_id); - let status = std::process::Command::new(&cmd[0]) - .args(&cmd[1..]) - .env("HOTDATA_SANDBOX", &sid) - .env("HOTDATA_WORKSPACE", workspace_id) - .status(); + spawn_child_with_sandbox_env(&sid, workspace_id, &sandbox_jwt, &sandbox_refresh, &api.api_url, cmd); +} + +/// Allow-list of parent environment variables to forward to a +/// `sandbox run` child. Anything outside this set is dropped, so the +/// child can't accidentally read the user's API key, AWS creds, or any +/// other secret the parent shell happens to expose. +/// +/// Public so the unit test can assert against the exact set. +pub(crate) const SANDBOX_ENV_ALLOWLIST: &[&str] = &[ + "PATH", + "HOME", + "USER", + "LOGNAME", + "SHELL", + "TERM", + "LANG", + "LC_ALL", + "LC_CTYPE", + "TZ", + "TMPDIR", +]; + +/// Names of the auth env vars injected into the child. The CLI reads +/// `HOTDATA_SANDBOX_TOKEN` and treats it as the only valid bearer when +/// set (see `api::ApiClient::new`). Kept as a constant alongside the +/// allow-list so a future audit can compare both at a glance. +#[allow(dead_code)] // Referenced from the audit test below. +pub(crate) const SANDBOX_AUTH_ENV: &[&str] = &[ + "HOTDATA_SANDBOX", + "HOTDATA_WORKSPACE", + "HOTDATA_API_URL", + "HOTDATA_SANDBOX_TOKEN", + "HOTDATA_SANDBOX_REFRESH_TOKEN", +]; + +fn spawn_child_with_sandbox_env( + sandbox_id: &str, + workspace_id: &str, + sandbox_jwt: &str, + sandbox_refresh: &str, + api_url: &str, + cmd: &[String], +) { + let mut command = std::process::Command::new(&cmd[0]); + command.args(&cmd[1..]); + + // Scrub: start from a clean environment and explicitly re-add + // only what the child legitimately needs. + command.env_clear(); + for key in SANDBOX_ENV_ALLOWLIST { + if let Ok(val) = std::env::var(key) { + command.env(key, val); + } + } + command.env("HOTDATA_SANDBOX", sandbox_id); + command.env("HOTDATA_WORKSPACE", workspace_id); + command.env("HOTDATA_API_URL", api_url); + command.env("HOTDATA_SANDBOX_TOKEN", sandbox_jwt); + command.env("HOTDATA_SANDBOX_REFRESH_TOKEN", sandbox_refresh); - match status { + match command.status() { Ok(s) => std::process::exit(s.code().unwrap_or(1)), Err(e) => { eprintln!("error: failed to execute '{}': {e}", cmd[0]); @@ -261,10 +360,13 @@ pub fn set(sandbox_id: Option<&str>, workspace_id: &str) { check_sandbox_lock(); match sandbox_id { Some(id) => { - // Verify the sandbox exists by fetching it + // Mint a sandbox-scoped JWT against this existing id. The + // call doubles as an existence + access check (404/403 if + // the user can't reach it). let api = ApiClient::new(Some(workspace_id)); - let path = format!("/sandboxes/{id}"); - let _: DetailResponse = api.get(&path); + let path = format!("/auth/sandbox/{id}"); + let resp: SandboxTokenResponse = api.post(&path, &serde_json::json!({})); + persist_sandbox_session(resp, workspace_id); if let Err(e) = config::save_sandbox("default", id) { eprintln!("error saving config: {e}"); @@ -274,7 +376,8 @@ pub fn set(sandbox_id: Option<&str>, workspace_id: &str) { println!("id: {}", id); } None => { - // Clear the active sandbox + // Clear the active sandbox + its cached session. + sandbox_session::clear(); if let Err(e) = config::clear_sandbox("default") { eprintln!("error saving config: {e}"); std::process::exit(1); @@ -302,4 +405,35 @@ mod tests { find_sandbox_run_ancestor_inner() ); } + + #[test] + fn sandbox_env_allowlist_excludes_sensitive_parent_state() { + // The allowlist must not leak the parent's auth credentials or + // the parent's active-sandbox state into a child that's + // supposed to be running under the freshly-minted sandbox JWT. + let forbidden = [ + "HOTDATA_API_KEY", + "AWS_ACCESS_KEY_ID", + "AWS_SECRET_ACCESS_KEY", + "OPENAI_API_KEY", + "GH_TOKEN", + "GITHUB_TOKEN", + ]; + for k in forbidden { + assert!( + !SANDBOX_ENV_ALLOWLIST.contains(&k), + "{k} must not be forwarded to sandbox child" + ); + } + } + + #[test] + fn sandbox_auth_env_set_is_self_consistent() { + // Anything the parent injects must also be the set the child's + // ApiClient knows to read (HOTDATA_SANDBOX_TOKEN in particular). + assert!(SANDBOX_AUTH_ENV.contains(&"HOTDATA_SANDBOX_TOKEN")); + assert!(SANDBOX_AUTH_ENV.contains(&"HOTDATA_SANDBOX_REFRESH_TOKEN")); + assert!(SANDBOX_AUTH_ENV.contains(&"HOTDATA_SANDBOX")); + assert!(SANDBOX_AUTH_ENV.contains(&"HOTDATA_WORKSPACE")); + } } diff --git a/src/sandbox_session.rs b/src/sandbox_session.rs new file mode 100644 index 0000000..bffad20 --- /dev/null +++ b/src/sandbox_session.rs @@ -0,0 +1,361 @@ +//! Persisted sandbox-scoped JWT session. +//! +//! Distinct from the user-scoped session in [`crate::jwt`]: +//! +//! * Minted by `/v1/auth/sandbox` (or `/v1/auth/sandbox/`), not +//! `/o/token/`. +//! * Bound to a single sandbox + workspace; the JWT carries only +//! workspace-read + sandbox-read/write scope. +//! * Refreshed via `/v1/auth/sandbox/refresh`, which rotates the +//! refresh token (single-use). The user's own credentials are never +//! involved — possession of the sandbox refresh token is enough. +//! +//! Stored at `~/.hotdata/sandbox_session.json` (mode 0600). + +use crate::config; +use crate::util; +use serde::{Deserialize, Serialize}; +use std::fs; +use std::io::Write; +use std::path::PathBuf; +use std::time::{SystemTime, UNIX_EPOCH}; + +/// Refresh ahead of expiry to avoid racing it. +const REFRESH_LEEWAY_SECONDS: u64 = 60; + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct SandboxSession { + pub access_token: String, + pub refresh_token: String, + pub sandbox_id: String, + pub workspace_id: String, + pub access_expires_at: u64, + pub refresh_expires_at: u64, +} + +pub fn session_path() -> Option { + config::config_dir().ok().map(|d| d.join("sandbox_session.json")) +} + +#[allow(dead_code)] // Reserved for parent-side flows that resurrect a session. +pub fn load() -> Option { + let path = session_path()?; + let raw = fs::read_to_string(&path).ok()?; + serde_json::from_str(&raw).ok() +} + +pub fn save(session: &SandboxSession) -> Result<(), String> { + let path = session_path().ok_or_else(|| "no sandbox session path available".to_string())?; + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).map_err(|e| format!("mkdir failed: {e}"))?; + } + let json = serde_json::to_string_pretty(session) + .map_err(|e| format!("serialize failed: {e}"))?; + + use std::os::unix::fs::OpenOptionsExt; + let mut f = fs::OpenOptions::new() + .create(true) + .write(true) + .truncate(true) + .mode(0o600) + .open(&path) + .map_err(|e| format!("open failed: {e}"))?; + f.write_all(json.as_bytes()) + .map_err(|e| format!("write failed: {e}"))?; + Ok(()) +} + +pub fn clear() { + if let Some(path) = session_path() { + let _ = fs::remove_file(path); + } +} + +fn now_unix() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0) +} + +#[derive(Deserialize)] +pub(crate) struct MintResponse { + token: String, + refresh_token: String, + sandbox_id: String, + expires_in: u64, + refresh_expires_in: u64, +} + +fn redact(s: &str) -> String { + util::mask_credential(s) +} + +/// Trade a refresh token for a fresh sandbox JWT (and a new refresh +/// token). The server rotates: the old refresh token is dead after a +/// successful call, so the new value must be persisted before the +/// caller can recover from a crash. +pub fn refresh(api_url: &str, refresh_token: &str) -> Result { + let url = format!("{}/auth/sandbox/refresh", api_url.trim_end_matches('/')); + let body = serde_json::json!({"refresh_token": refresh_token}); + let body_log = serde_json::json!({"refresh_token": redact(refresh_token)}); + + let client = reqwest::blocking::Client::new(); + let req = client.post(&url).json(&body); + let (status, body_text) = util::send_debug_with_redaction( + &client, + req, + Some(&body_log), + &["token", "refresh_token"], + ) + .map_err(|e| format!("connection error: {e}"))?; + if !status.is_success() { + return Err(format!("sandbox refresh failed: HTTP {status}: {body_text}")); + } + let resp: MintResponse = serde_json::from_str(&body_text) + .map_err(|e| format!("malformed refresh response: {e}"))?; + Ok(session_from_response(resp, /*workspace_id*/ String::new())) +} + +/// Build a [`SandboxSession`] from a mint/refresh response. The mint +/// response itself doesn't include the workspace public_id, so the +/// caller passes it in (the workspace the sandbox was created against +/// is what the JWT's `workspaces` claim restricts the bearer to). For +/// refresh, workspace_id is left blank — the caller fills it in from +/// the prior session, since the sandbox-id ↔ workspace mapping is +/// invariant across refreshes. +pub(crate) fn session_from_response(resp: MintResponse, workspace_id: String) -> SandboxSession { + let now = now_unix(); + SandboxSession { + access_token: resp.token, + refresh_token: resp.refresh_token, + sandbox_id: resp.sandbox_id, + workspace_id, + access_expires_at: now + resp.expires_in, + refresh_expires_at: now + resp.refresh_expires_in, + } +} + +/// Decode a JWT's payload (without verifying the signature) and pull +/// out the named string claim. Returns `None` if the token is +/// unparseable or the claim is missing. +fn jwt_string_claim(token: &str, claim: &str) -> Option { + use base64::Engine; + let parts: Vec<&str> = token.split('.').collect(); + if parts.len() < 2 { + return None; + } + let payload = base64::engine::general_purpose::URL_SAFE_NO_PAD + .decode(parts[1].as_bytes()) + .ok()?; + let value: serde_json::Value = serde_json::from_slice(&payload).ok()?; + value.get(claim).and_then(|v| v.as_str()).map(String::from) +} + +/// Decode the `exp` claim out of a JWT without verifying the signature. +/// Returns `None` if the token is unparseable; in that case the caller +/// should treat it as expired (force-refresh or fail). +fn jwt_exp(token: &str) -> Option { + use base64::Engine; + let parts: Vec<&str> = token.split('.').collect(); + if parts.len() < 2 { + return None; + } + let payload = base64::engine::general_purpose::URL_SAFE_NO_PAD + .decode(parts[1].as_bytes()) + .ok()?; + let value: serde_json::Value = serde_json::from_slice(&payload).ok()?; + value.get("exp").and_then(|v| v.as_u64()) +} + +/// If `HOTDATA_SANDBOX_TOKEN` is set in the environment, return +/// `(token, sandbox_public_id)` — the sandbox public_id read from the +/// JWT's `sandbox` claim. Returns `None` if no env var is set, or if +/// the token isn't a parseable JWT (in which case we can still use it +/// as a bearer but can't identify the sandbox). +pub fn sandbox_token_in_use() -> Option<(String, Option)> { + let token = std::env::var("HOTDATA_SANDBOX_TOKEN").ok()?; + if token.is_empty() { + return None; + } + let sandbox_id = jwt_string_claim(&token, "sandbox"); + Some((token, sandbox_id)) +} + +/// In-child equivalent of [`ensure_access_token`] that operates on env +/// vars only — used by [`crate::api::ApiClient`] when the parent +/// `sandbox run` already passed in `HOTDATA_SANDBOX_TOKEN` and +/// `HOTDATA_SANDBOX_REFRESH_TOKEN`. The new tokens are *not* persisted +/// to disk: the child may not have write access to the parent's +/// config dir (sandboxed FS), and re-doing the refresh on the next +/// invocation costs one HTTP call. +/// +/// Falls back to the current `HOTDATA_SANDBOX_TOKEN` value if a +/// refresh isn't needed or fails. +pub fn refresh_from_env(api_url: &str) -> Option { + let current = std::env::var("HOTDATA_SANDBOX_TOKEN").ok()?; + let needs_refresh = match jwt_exp(¤t) { + Some(exp) => exp.saturating_sub(REFRESH_LEEWAY_SECONDS) <= now_unix(), + None => true, + }; + if !needs_refresh { + return Some(current); + } + let rt = std::env::var("HOTDATA_SANDBOX_REFRESH_TOKEN").ok()?; + if rt.is_empty() { + return Some(current); + } + match refresh(api_url, &rt) { + Ok(new_session) => Some(new_session.access_token), + Err(_) => Some(current), + } +} + +/// Return the cached sandbox session's access token, refreshing if +/// it's about to expire. Returns `None` if no session is cached, the +/// refresh token is past its TTL, or the refresh call failed. +#[allow(dead_code)] // Reserved for parent-side flows that re-use a cached session. +pub fn ensure_access_token(api_url: &str) -> Option { + let session = load()?; + let now = now_unix(); + + if !session.access_token.is_empty() && now + REFRESH_LEEWAY_SECONDS < session.access_expires_at { + return Some(session.access_token); + } + + if session.refresh_token.is_empty() || now >= session.refresh_expires_at { + return None; + } + + match refresh(api_url, &session.refresh_token) { + Ok(mut new_session) => { + // Carry workspace_id over (refresh response omits it). + new_session.workspace_id = session.workspace_id.clone(); + let tok = new_session.access_token.clone(); + let _ = save(&new_session); + Some(tok) + } + Err(_) => { + clear(); + None + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::test_helpers::with_temp_config_dir; + + fn mk_session(access_offset: i64, refresh_offset: i64) -> SandboxSession { + let now = now_unix() as i64; + SandboxSession { + access_token: "cached".into(), + refresh_token: "cached-refresh".into(), + sandbox_id: "s_abc12345".into(), + workspace_id: "work_xyz".into(), + access_expires_at: (now + access_offset).max(0) as u64, + refresh_expires_at: (now + refresh_offset).max(0) as u64, + } + } + + #[test] + fn round_trip() { + let (_tmp, _guard) = with_temp_config_dir(); + let s = mk_session(3600, 86400); + save(&s).unwrap(); + let loaded = load().unwrap(); + assert_eq!(loaded.access_token, "cached"); + assert_eq!(loaded.sandbox_id, "s_abc12345"); + assert_eq!(loaded.workspace_id, "work_xyz"); + } + + #[test] + fn file_is_mode_0600() { + use std::os::unix::fs::PermissionsExt; + let (_tmp, _guard) = with_temp_config_dir(); + save(&mk_session(60, 60)).unwrap(); + let mode = fs::metadata(session_path().unwrap()).unwrap().permissions().mode() & 0o777; + assert_eq!(mode, 0o600); + } + + #[test] + fn ensure_returns_cached_when_fresh() { + let (_tmp, _guard) = with_temp_config_dir(); + save(&mk_session(3600, 86400)).unwrap(); + // Unreachable URL — if the code reached the network we'd see an error here. + let tok = ensure_access_token("http://127.0.0.1:1"); + assert_eq!(tok.as_deref(), Some("cached")); + } + + #[test] + fn ensure_returns_none_when_no_session() { + let (_tmp, _guard) = with_temp_config_dir(); + assert!(ensure_access_token("http://127.0.0.1:1").is_none()); + } + + #[test] + fn ensure_returns_none_when_refresh_dead() { + let (_tmp, _guard) = with_temp_config_dir(); + // Access and refresh both expired. + save(&mk_session(-10, -10)).unwrap(); + assert!(ensure_access_token("http://127.0.0.1:1").is_none()); + } + + #[test] + fn refresh_success_rotates_tokens() { + let mut server = mockito::Server::new(); + let m = server + .mock("POST", "/auth/sandbox/refresh") + .with_status(200) + .with_header("content-type", "application/json") + .with_body( + r#"{"ok":true,"token":"new-jwt","refresh_token":"new-refresh","sandbox_id":"s_abc12345","expires_in":259200,"refresh_expires_in":2592000}"#, + ) + .create(); + + let s = refresh(&server.url(), "old-refresh").unwrap(); + m.assert(); + assert_eq!(s.access_token, "new-jwt"); + assert_eq!(s.refresh_token, "new-refresh"); + assert_eq!(s.sandbox_id, "s_abc12345"); + } + + #[test] + fn refresh_http_error() { + let mut server = mockito::Server::new(); + let m = server + .mock("POST", "/auth/sandbox/refresh") + .with_status(401) + .create(); + let err = refresh(&server.url(), "x").unwrap_err(); + m.assert(); + assert!(err.contains("401")); + } + + #[test] + fn ensure_refreshes_and_persists() { + let (_tmp, _guard) = with_temp_config_dir(); + // Access expired but refresh still good. + let mut existing = mk_session(-10, 86400); + existing.workspace_id = "work_xyz".into(); + save(&existing).unwrap(); + + let mut server = mockito::Server::new(); + let m = server + .mock("POST", "/auth/sandbox/refresh") + .with_status(200) + .with_header("content-type", "application/json") + .with_body( + r#"{"ok":true,"token":"refreshed","refresh_token":"rotated","sandbox_id":"s_abc12345","expires_in":259200,"refresh_expires_in":2592000}"#, + ) + .create(); + let tok = ensure_access_token(&server.url()); + m.assert(); + assert_eq!(tok.as_deref(), Some("refreshed")); + let after = load().unwrap(); + assert_eq!(after.access_token, "refreshed"); + assert_eq!(after.refresh_token, "rotated"); + assert_eq!(after.workspace_id, "work_xyz"); + } +} diff --git a/src/util.rs b/src/util.rs index 99dce67..0c65338 100644 --- a/src/util.rs +++ b/src/util.rs @@ -74,10 +74,16 @@ pub fn debug_response_redacted( (status, body) } -/// Mask a credential to its first 4 characters (`XXXX...`), or `***` -/// if the value is too short to safely reveal a head. +/// Mask a credential to its first + last 4 characters +/// (`XXXX...YYYY`), or `***` if it's too short to reveal anything +/// safely. The tail makes it easy to distinguish which token is on +/// the wire (e.g. user JWT vs sandbox-scoped JWT vs opaque API token). pub fn mask_credential(s: &str) -> String { - if s.len() > 4 { + if s.len() >= 12 { + format!("{}...{}", &s[..4], &s[s.len() - 4..]) + } else if s.len() > 4 { + // Short-ish — still better to show head than nothing, but + // don't double up on bytes by showing a tail. format!("{}...", &s[..4]) } else { "***".into() @@ -296,7 +302,16 @@ mod tests { use serde_json::json; #[test] - fn mask_credential_long() { + fn mask_credential_long_shows_prefix_and_suffix() { + // 12+ chars: show both ends so the user can tell which token + // is on the wire (sandbox JWT vs user JWT vs opaque API token). + assert_eq!(mask_credential("abcdefghijkl"), "abcd...ijkl"); + assert_eq!(mask_credential("eyJhMIDDLEYwxyz"), "eyJh...wxyz"); + } + + #[test] + fn mask_credential_medium_falls_back_to_head_only() { + // Between 5 and 11 chars: showing both ends would overlap. assert_eq!(mask_credential("abcdefgh"), "abcd..."); } @@ -314,8 +329,8 @@ mod tests { "refresh_token": "another-secret" }); redact_json_fields(&mut v, &["access_token", "refresh_token"]); - assert_eq!(v["access_token"], "long..."); - assert_eq!(v["refresh_token"], "anot..."); + assert_eq!(v["access_token"], "long...oken"); + assert_eq!(v["refresh_token"], "anot...cret"); // Non-redacted keys untouched. assert_eq!(v["expires_in"], 300); } @@ -331,8 +346,10 @@ mod tests { } }); redact_json_fields(&mut v, &["access_token"]); + // "secret-1234" is 11 chars — falls into the head-only branch. assert_eq!(v["data"]["access_token"], "secr..."); - assert_eq!(v["data"]["items"][0]["access_token"], "nest..."); + // "nested-secret" is 13 chars — head + tail. + assert_eq!(v["data"]["items"][0]["access_token"], "nest...cret"); } #[test] From fb41ae3ec08c42e68c5a9d7c481ae715f166c324 Mon Sep 17 00:00:00 2001 From: Paul Thurlow Date: Tue, 12 May 2026 14:54:12 -0700 Subject: [PATCH 2/3] handle sandbox sessions better --- src/main.rs | 38 +++++++++++++++--------- src/sandbox.rs | 37 +++++++++++++----------- src/sandbox_session.rs | 61 +++++++++++++++++++++++++-------------- src/util.rs | 65 +++++++++++++++++++++++++++++++++++------- 4 files changed, 140 insertions(+), 61 deletions(-) diff --git a/src/main.rs b/src/main.rs index 3a713cf..0c6e172 100644 --- a/src/main.rs +++ b/src/main.rs @@ -89,24 +89,34 @@ unsafe extern "C" { fn atexit(callback: extern "C" fn()) -> i32; } -/// Runs once at process exit. If the CLI was authenticating through a -/// sandbox session (env var from `sandbox run`, or on-disk session -/// from `sandbox set `), emit a footer line on stderr so the user -/// can tell at a glance which sandbox served the call. Stderr keeps -/// stdout clean for callers that parse JSON/YAML output. +/// Runs once at process exit. Prints a sandbox footer on stderr when +/// the CLI is running under an on-disk sandbox session (i.e. the user +/// ran `hotdata sandbox set ` to enter it from this shell). Stays +/// silent when the sandbox comes from `HOTDATA_SANDBOX_TOKEN` in the +/// environment: that means we're inside a `sandbox run` child, and +/// the parent already announced the sandbox once at spawn time. +/// Stderr keeps stdout clean for callers parsing JSON/YAML output. extern "C" fn print_sandbox_footer() { use crossterm::style::Stylize; - let sandbox_id = sandbox_session::sandbox_token_in_use() - .and_then(|(_, sid)| sid) - .or_else(|| { - sandbox_session::load() - .map(|s| s.sandbox_id) - .filter(|s| !s.is_empty()) - }); - if let Some(sid) = sandbox_id { - eprintln!("{}", format!("sandbox: {sid}").dark_grey()); + // Inside a `sandbox run` child — parent printed the banner already. + if sandbox_session::sandbox_token_in_use().is_some() { + return; } + let Some(session) = sandbox_session::load() else { + return; + }; + if session.sandbox_id.is_empty() { + return; + } + eprintln!( + "{}", + format!( + "current sandbox: {} use 'hotdata sandbox set' to change", + session.sandbox_id + ) + .dark_grey(), + ); } fn main() { diff --git a/src/sandbox.rs b/src/sandbox.rs index aa83b87..20ba98f 100644 --- a/src/sandbox.rs +++ b/src/sandbox.rs @@ -261,22 +261,23 @@ pub fn run(sandbox_id: Option<&str>, workspace_id: &str, name: Option<&str>, cmd let api = ApiClient::new(Some(workspace_id)); // Mint (or re-mint, for an existing sandbox) a sandbox-scoped JWT - // by hitting the auth endpoint. The same call creates a sandbox - // when no id is provided. Either way we end up with a fresh - // bundle persisted to sandbox_session.json before we spawn. - let resp: SandboxTokenResponse = match sandbox_id { - Some(id) => { - let path = format!("/auth/sandbox/{id}"); - api.post(&path, &serde_json::json!({})) - } + // by dispatching on grant_type at /auth/sandbox. Either way we + // end up with a fresh bundle persisted to sandbox_session.json + // before we spawn. + let body = match sandbox_id { + Some(id) => serde_json::json!({ + "grant_type": "existing_sandbox", + "sandbox_id": id, + }), None => { - let mut body = serde_json::json!({}); + let mut b = serde_json::json!({}); if let Some(n) = name { - body["name"] = serde_json::json!(n); + b["name"] = serde_json::json!(n); } - api.post("/auth/sandbox", &body) + b } }; + let resp: SandboxTokenResponse = api.post("/auth/sandbox", &body); let sid = resp.sandbox_id.clone(); let sandbox_jwt = resp.token.clone(); @@ -360,12 +361,16 @@ pub fn set(sandbox_id: Option<&str>, workspace_id: &str) { check_sandbox_lock(); match sandbox_id { Some(id) => { - // Mint a sandbox-scoped JWT against this existing id. The - // call doubles as an existence + access check (404/403 if - // the user can't reach it). + // Mint a sandbox-scoped JWT against this existing id via + // the grant_type=existing_sandbox dispatch. The call + // doubles as an existence + access check (404/403 if the + // user can't reach it). let api = ApiClient::new(Some(workspace_id)); - let path = format!("/auth/sandbox/{id}"); - let resp: SandboxTokenResponse = api.post(&path, &serde_json::json!({})); + let body = serde_json::json!({ + "grant_type": "existing_sandbox", + "sandbox_id": id, + }); + let resp: SandboxTokenResponse = api.post("/auth/sandbox", &body); persist_sandbox_session(resp, workspace_id); if let Err(e) = config::save_sandbox("default", id) { diff --git a/src/sandbox_session.rs b/src/sandbox_session.rs index bffad20..262f40f 100644 --- a/src/sandbox_session.rs +++ b/src/sandbox_session.rs @@ -2,13 +2,16 @@ //! //! Distinct from the user-scoped session in [`crate::jwt`]: //! -//! * Minted by `/v1/auth/sandbox` (or `/v1/auth/sandbox/`), not -//! `/o/token/`. +//! * Minted by `POST /v1/auth/sandbox` (with no body, or +//! `grant_type=existing_sandbox` + `sandbox_id`), not `/o/token/`. //! * Bound to a single sandbox + workspace; the JWT carries only //! workspace-read + sandbox-read/write scope. -//! * Refreshed via `/v1/auth/sandbox/refresh`, which rotates the -//! refresh token (single-use). The user's own credentials are never -//! involved — possession of the sandbox refresh token is enough. +//! * Refreshed via `POST /v1/auth/sandbox` with +//! `grant_type=refresh_token` — same endpoint as the new-mint path, +//! dispatched by body field (mirrors `POST /o/token/`). The server +//! does **not** rotate the refresh token. The user's own credentials +//! are never involved — possession of the sandbox refresh token is +//! enough. //! //! Stored at `~/.hotdata/sandbox_session.json` (mode 0600). @@ -91,14 +94,22 @@ fn redact(s: &str) -> String { util::mask_credential(s) } -/// Trade a refresh token for a fresh sandbox JWT (and a new refresh -/// token). The server rotates: the old refresh token is dead after a -/// successful call, so the new value must be persisted before the -/// caller can recover from a crash. +/// Trade a refresh token for a fresh sandbox JWT. The server does +/// **not** rotate the refresh token (matches DOT's +/// ``ROTATE_REFRESH_TOKEN=False``), so the same value is returned on +/// every call. Same endpoint as the new-mint path — +/// ``POST /v1/auth/sandbox`` with ``grant_type=refresh_token`` in the +/// body, mirroring ``POST /o/token/``. pub fn refresh(api_url: &str, refresh_token: &str) -> Result { - let url = format!("{}/auth/sandbox/refresh", api_url.trim_end_matches('/')); - let body = serde_json::json!({"refresh_token": refresh_token}); - let body_log = serde_json::json!({"refresh_token": redact(refresh_token)}); + let url = format!("{}/auth/sandbox", api_url.trim_end_matches('/')); + let body = serde_json::json!({ + "grant_type": "refresh_token", + "refresh_token": refresh_token, + }); + let body_log = serde_json::json!({ + "grant_type": "refresh_token", + "refresh_token": redact(refresh_token), + }); let client = reqwest::blocking::Client::new(); let req = client.post(&url).json(&body); @@ -303,21 +314,28 @@ mod tests { } #[test] - fn refresh_success_rotates_tokens() { + fn refresh_posts_grant_type_to_sandbox_endpoint() { let mut server = mockito::Server::new(); let m = server - .mock("POST", "/auth/sandbox/refresh") + .mock("POST", "/auth/sandbox") + .match_body(mockito::Matcher::AllOf(vec![ + mockito::Matcher::JsonString( + r#"{"grant_type":"refresh_token","refresh_token":"stable-refresh"}"# + .to_string(), + ), + ])) .with_status(200) .with_header("content-type", "application/json") .with_body( - r#"{"ok":true,"token":"new-jwt","refresh_token":"new-refresh","sandbox_id":"s_abc12345","expires_in":259200,"refresh_expires_in":2592000}"#, + // Server does not rotate — same refresh_token comes back. + r#"{"ok":true,"token":"new-jwt","refresh_token":"stable-refresh","sandbox_id":"s_abc12345","expires_in":300,"refresh_expires_in":259200}"#, ) .create(); - let s = refresh(&server.url(), "old-refresh").unwrap(); + let s = refresh(&server.url(), "stable-refresh").unwrap(); m.assert(); assert_eq!(s.access_token, "new-jwt"); - assert_eq!(s.refresh_token, "new-refresh"); + assert_eq!(s.refresh_token, "stable-refresh"); assert_eq!(s.sandbox_id, "s_abc12345"); } @@ -325,7 +343,7 @@ mod tests { fn refresh_http_error() { let mut server = mockito::Server::new(); let m = server - .mock("POST", "/auth/sandbox/refresh") + .mock("POST", "/auth/sandbox") .with_status(401) .create(); let err = refresh(&server.url(), "x").unwrap_err(); @@ -343,11 +361,11 @@ mod tests { let mut server = mockito::Server::new(); let m = server - .mock("POST", "/auth/sandbox/refresh") + .mock("POST", "/auth/sandbox") .with_status(200) .with_header("content-type", "application/json") .with_body( - r#"{"ok":true,"token":"refreshed","refresh_token":"rotated","sandbox_id":"s_abc12345","expires_in":259200,"refresh_expires_in":2592000}"#, + r#"{"ok":true,"token":"refreshed","refresh_token":"cached-refresh","sandbox_id":"s_abc12345","expires_in":300,"refresh_expires_in":259200}"#, ) .create(); let tok = ensure_access_token(&server.url()); @@ -355,7 +373,8 @@ mod tests { assert_eq!(tok.as_deref(), Some("refreshed")); let after = load().unwrap(); assert_eq!(after.access_token, "refreshed"); - assert_eq!(after.refresh_token, "rotated"); + // No rotation — same refresh_token as before. + assert_eq!(after.refresh_token, "cached-refresh"); assert_eq!(after.workspace_id, "work_xyz"); } } diff --git a/src/util.rs b/src/util.rs index 0c65338..39a7462 100644 --- a/src/util.rs +++ b/src/util.rs @@ -284,16 +284,34 @@ pub fn format_date(s: &str) -> String { } pub fn api_error(body: String) -> String { - serde_json::from_str::(&body) - .ok() - .and_then(|v| v["error"]["message"].as_str().map(str::to_string)) - .unwrap_or_else(|| { - if body.trim_start().starts_with('<') { - "unexpected server error".to_string() - } else { - body - } - }) + if let Ok(v) = serde_json::from_str::(&body) { + // Two shapes in the wild: + // {"error": {"message": "..."}} — RuntimeDB-style + // {"error": "snake_case_code"} — Django-style (e.g. sandbox endpoints) + if let Some(m) = v["error"]["message"].as_str() { + return m.to_string(); + } + if let Some(code) = v["error"].as_str() { + return humanize_error_code(code); + } + } + if body.trim_start().starts_with('<') { + return "unexpected server error".to_string(); + } + body +} + +/// Turn a snake_case error code into a human-friendly sentence: +/// ``sandbox_not_found`` → ``Sandbox not found``. Cheap heuristic — if +/// a code reads badly after this, the server should be the one to fix +/// it by returning a real message. +fn humanize_error_code(code: &str) -> String { + let spaced = code.replace('_', " "); + let mut chars = spaced.chars(); + match chars.next() { + Some(c) => c.to_uppercase().chain(chars).collect(), + None => String::new(), + } } #[cfg(test)] @@ -321,6 +339,33 @@ mod tests { assert_eq!(mask_credential(""), "***"); } + #[test] + fn api_error_humanizes_snake_case_code() { + // Django-style flat shape — `sandbox_not_found` should render + // as a readable sentence, not a raw JSON blob. + let body = r#"{"error": "sandbox_not_found"}"#.to_string(); + assert_eq!(api_error(body), "Sandbox not found"); + } + + #[test] + fn api_error_prefers_nested_message_over_code() { + // RuntimeDB-style nested shape — use the human message verbatim. + let body = r#"{"error": {"message": "Query qrun_x not found"}}"#.to_string(); + assert_eq!(api_error(body), "Query qrun_x not found"); + } + + #[test] + fn api_error_falls_through_for_plain_body() { + let body = "raw text body".to_string(); + assert_eq!(api_error(body), "raw text body"); + } + + #[test] + fn api_error_handles_html_body() { + let body = "500".to_string(); + assert_eq!(api_error(body), "unexpected server error"); + } + #[test] fn redact_json_fields_top_level() { let mut v = json!({ From ccd876d74e73b9b3e5886168e37467023f63ddcd Mon Sep 17 00:00:00 2001 From: Paul Thurlow Date: Tue, 12 May 2026 15:13:18 -0700 Subject: [PATCH 3/3] remove extra env clearing code --- src/sandbox.rs | 102 +++++-------------------------------------------- 1 file changed, 10 insertions(+), 92 deletions(-) diff --git a/src/sandbox.rs b/src/sandbox.rs index 20ba98f..d0313af 100644 --- a/src/sandbox.rs +++ b/src/sandbox.rs @@ -287,68 +287,16 @@ pub fn run(sandbox_id: Option<&str>, workspace_id: &str, name: Option<&str>, cmd eprintln!("{} {}", "sandbox:".dark_grey(), sid); eprintln!("{} {}", "workspace:".dark_grey(), workspace_id); - spawn_child_with_sandbox_env(&sid, workspace_id, &sandbox_jwt, &sandbox_refresh, &api.api_url, cmd); -} - -/// Allow-list of parent environment variables to forward to a -/// `sandbox run` child. Anything outside this set is dropped, so the -/// child can't accidentally read the user's API key, AWS creds, or any -/// other secret the parent shell happens to expose. -/// -/// Public so the unit test can assert against the exact set. -pub(crate) const SANDBOX_ENV_ALLOWLIST: &[&str] = &[ - "PATH", - "HOME", - "USER", - "LOGNAME", - "SHELL", - "TERM", - "LANG", - "LC_ALL", - "LC_CTYPE", - "TZ", - "TMPDIR", -]; - -/// Names of the auth env vars injected into the child. The CLI reads -/// `HOTDATA_SANDBOX_TOKEN` and treats it as the only valid bearer when -/// set (see `api::ApiClient::new`). Kept as a constant alongside the -/// allow-list so a future audit can compare both at a glance. -#[allow(dead_code)] // Referenced from the audit test below. -pub(crate) const SANDBOX_AUTH_ENV: &[&str] = &[ - "HOTDATA_SANDBOX", - "HOTDATA_WORKSPACE", - "HOTDATA_API_URL", - "HOTDATA_SANDBOX_TOKEN", - "HOTDATA_SANDBOX_REFRESH_TOKEN", -]; - -fn spawn_child_with_sandbox_env( - sandbox_id: &str, - workspace_id: &str, - sandbox_jwt: &str, - sandbox_refresh: &str, - api_url: &str, - cmd: &[String], -) { - let mut command = std::process::Command::new(&cmd[0]); - command.args(&cmd[1..]); - - // Scrub: start from a clean environment and explicitly re-add - // only what the child legitimately needs. - command.env_clear(); - for key in SANDBOX_ENV_ALLOWLIST { - if let Ok(val) = std::env::var(key) { - command.env(key, val); - } - } - command.env("HOTDATA_SANDBOX", sandbox_id); - command.env("HOTDATA_WORKSPACE", workspace_id); - command.env("HOTDATA_API_URL", api_url); - command.env("HOTDATA_SANDBOX_TOKEN", sandbox_jwt); - command.env("HOTDATA_SANDBOX_REFRESH_TOKEN", sandbox_refresh); - - match command.status() { + let status = std::process::Command::new(&cmd[0]) + .args(&cmd[1..]) + .env("HOTDATA_SANDBOX", &sid) + .env("HOTDATA_WORKSPACE", workspace_id) + .env("HOTDATA_API_URL", &api.api_url) + .env("HOTDATA_SANDBOX_TOKEN", &sandbox_jwt) + .env("HOTDATA_SANDBOX_REFRESH_TOKEN", &sandbox_refresh) + .status(); + + match status { Ok(s) => std::process::exit(s.code().unwrap_or(1)), Err(e) => { eprintln!("error: failed to execute '{}': {e}", cmd[0]); @@ -411,34 +359,4 @@ mod tests { ); } - #[test] - fn sandbox_env_allowlist_excludes_sensitive_parent_state() { - // The allowlist must not leak the parent's auth credentials or - // the parent's active-sandbox state into a child that's - // supposed to be running under the freshly-minted sandbox JWT. - let forbidden = [ - "HOTDATA_API_KEY", - "AWS_ACCESS_KEY_ID", - "AWS_SECRET_ACCESS_KEY", - "OPENAI_API_KEY", - "GH_TOKEN", - "GITHUB_TOKEN", - ]; - for k in forbidden { - assert!( - !SANDBOX_ENV_ALLOWLIST.contains(&k), - "{k} must not be forwarded to sandbox child" - ); - } - } - - #[test] - fn sandbox_auth_env_set_is_self_consistent() { - // Anything the parent injects must also be the set the child's - // ApiClient knows to read (HOTDATA_SANDBOX_TOKEN in particular). - assert!(SANDBOX_AUTH_ENV.contains(&"HOTDATA_SANDBOX_TOKEN")); - assert!(SANDBOX_AUTH_ENV.contains(&"HOTDATA_SANDBOX_REFRESH_TOKEN")); - assert!(SANDBOX_AUTH_ENV.contains(&"HOTDATA_SANDBOX")); - assert!(SANDBOX_AUTH_ENV.contains(&"HOTDATA_WORKSPACE")); - } }