From f510bb649b8738acb2195a818edc4ea311d0a31b Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Fri, 24 Jul 2026 16:26:35 -0600 Subject: [PATCH] feat: Improved credentials management for docker sandboxes --- .../aws_secrets_manager/spec.yaml | 33 - .../azure_key_vault/spec.yaml | 24 - .../gcp_secret_manager/spec.yaml | 34 - assets/sbx-vault-mixins/gopass/spec.yaml | 30 - .../sbx-vault-mixins/one_password/spec.yaml | 31 - src/cli/mod.rs | 46 +- src/config/mod.rs | 66 +- src/config/paths.rs | 10 +- src/main.rs | 2 +- src/sandbox/mod.rs | 581 ++++-------------- src/vault/mod.rs | 112 +++- 11 files changed, 267 insertions(+), 702 deletions(-) delete mode 100644 assets/sbx-vault-mixins/aws_secrets_manager/spec.yaml delete mode 100644 assets/sbx-vault-mixins/azure_key_vault/spec.yaml delete mode 100644 assets/sbx-vault-mixins/gcp_secret_manager/spec.yaml delete mode 100644 assets/sbx-vault-mixins/gopass/spec.yaml delete mode 100644 assets/sbx-vault-mixins/one_password/spec.yaml diff --git a/assets/sbx-vault-mixins/aws_secrets_manager/spec.yaml b/assets/sbx-vault-mixins/aws_secrets_manager/spec.yaml deleted file mode 100644 index 7643316..0000000 --- a/assets/sbx-vault-mixins/aws_secrets_manager/spec.yaml +++ /dev/null @@ -1,33 +0,0 @@ -schemaVersion: "1" -kind: mixin -name: vault-aws-secrets-manager -description: > - Installs the AWS CLI v2 so the Coyote vault can read secrets from AWS - Secrets Manager inside the sandbox. The AWS Rust SDK does not strictly - require the CLI, but most users authenticate via `aws sso login` or - `aws configure`, which need the CLI to be installed. After install, run - the appropriate auth command in the sandbox; cached credentials persist - for the lifetime of the sandbox. - -network: - allowedDomains: - - "awscli.amazonaws.com:443" - - "sts.amazonaws.com:443" - - "*.sts.amazonaws.com:443" - - "*.secretsmanager.amazonaws.com:443" - - "*.amazonaws.com:443" - - "*.awsapps.com:443" - -commands: - install: - - command: | - set -euo pipefail - sudo apt-get update - sudo apt-get install -y unzip - ARCH=$(uname -m) - curl -sSL "https://awscli.amazonaws.com/awscli-exe-linux-${ARCH}.zip" -o /tmp/awscliv2.zip - unzip -q /tmp/awscliv2.zip -d /tmp - sudo /tmp/aws/install - rm -rf /tmp/awscliv2.zip /tmp/aws - user: "1000" - description: Install AWS CLI v2 from the official installer diff --git a/assets/sbx-vault-mixins/azure_key_vault/spec.yaml b/assets/sbx-vault-mixins/azure_key_vault/spec.yaml deleted file mode 100644 index 5d6afd1..0000000 --- a/assets/sbx-vault-mixins/azure_key_vault/spec.yaml +++ /dev/null @@ -1,24 +0,0 @@ -schemaVersion: "1" -kind: mixin -name: vault-azure-key-vault -description: > - Installs the Azure CLI (`az`) so the Coyote vault can read secrets from - Azure Key Vault inside the sandbox. After install, run `az login` in the - sandbox to authenticate; the session token persists for the lifetime of - the sandbox. - -network: - allowedDomains: - - "aka.ms:443" - - "packages.microsoft.com:443" - - "azurecliprod.blob.core.windows.net:443" - - "login.microsoftonline.com:443" - - "graph.microsoft.com:443" - - "management.azure.com:443" - - "*.vault.azure.net:443" - -commands: - install: - - command: "curl -sL https://aka.ms/InstallAzureCLIDeb | sudo bash" - user: "1000" - description: Install Azure CLI via Microsoft's official install script diff --git a/assets/sbx-vault-mixins/gcp_secret_manager/spec.yaml b/assets/sbx-vault-mixins/gcp_secret_manager/spec.yaml deleted file mode 100644 index 4de2149..0000000 --- a/assets/sbx-vault-mixins/gcp_secret_manager/spec.yaml +++ /dev/null @@ -1,34 +0,0 @@ -schemaVersion: "1" -kind: mixin -name: vault-gcp-secret-manager -description: > - Installs the Google Cloud CLI (`gcloud`) so the Coyote vault can read - secrets from GCP Secret Manager inside the sandbox. The GCP Rust SDK does - not strictly require the CLI, but most users authenticate via - `gcloud auth application-default login`, which needs the CLI to be - installed. After install, run that command in the sandbox; the ADC file - persists for the lifetime of the sandbox. - -network: - allowedDomains: - - "packages.cloud.google.com:443" - - "accounts.google.com:443" - - "oauth2.googleapis.com:443" - - "secretmanager.googleapis.com:443" - - "cloudresourcemanager.googleapis.com:443" - - "*.googleapis.com:443" - -commands: - install: - - command: | - set -euo pipefail - sudo apt-get update - sudo apt-get install -y apt-transport-https ca-certificates gnupg - echo "deb [signed-by=/usr/share/keyrings/cloud.google.gpg] https://packages.cloud.google.com/apt cloud-sdk main" \ - | sudo tee /etc/apt/sources.list.d/google-cloud-sdk.list >/dev/null - curl -sSL https://packages.cloud.google.com/apt/doc/apt-key.gpg \ - | sudo gpg --dearmor -o /usr/share/keyrings/cloud.google.gpg - sudo apt-get update - sudo apt-get install -y google-cloud-cli - user: "1000" - description: Install gcloud CLI from Google's official apt repository diff --git a/assets/sbx-vault-mixins/gopass/spec.yaml b/assets/sbx-vault-mixins/gopass/spec.yaml deleted file mode 100644 index 8e12e08..0000000 --- a/assets/sbx-vault-mixins/gopass/spec.yaml +++ /dev/null @@ -1,30 +0,0 @@ -schemaVersion: "1" -kind: mixin -name: vault-gopass -description: > - Installs `gopass` and `gpg` so the Coyote vault can read secrets from a - gopass store inside the sandbox. The store must be cloned manually - (gopass walks a user-specific git remote, so v1 only allowlists github.com - and gitlab.com; add other hosts via a user mixin if needed). After install, - run `gopass setup` or `gopass clone ` in the sandbox. - -network: - allowedDomains: - - "github.com:443" - - "api.github.com:443" - - "objects.githubusercontent.com:443" - - "gitlab.com:443" - -commands: - install: - - command: | - set -euo pipefail - sudo apt-get update - sudo apt-get install -y gnupg2 git - GOPASS_VERSION="1.15.13" - ARCH=$(dpkg --print-architecture) - curl -sSL "https://github.com/gopasspw/gopass/releases/download/v${GOPASS_VERSION}/gopass_${GOPASS_VERSION}_linux_${ARCH}.deb" -o /tmp/gopass.deb - sudo dpkg -i /tmp/gopass.deb - rm -f /tmp/gopass.deb - user: "1000" - description: Install gnupg2, git, and gopass from the official .deb release diff --git a/assets/sbx-vault-mixins/one_password/spec.yaml b/assets/sbx-vault-mixins/one_password/spec.yaml deleted file mode 100644 index 72fdc11..0000000 --- a/assets/sbx-vault-mixins/one_password/spec.yaml +++ /dev/null @@ -1,31 +0,0 @@ -schemaVersion: "1" -kind: mixin -name: vault-one-password -description: > - Installs the 1Password CLI (`op`) so the Coyote vault can decrypt secrets - inside the sandbox. After install, run `op signin` in the sandbox to - authenticate; credentials persist for the lifetime of the sandbox. - -network: - allowedDomains: - - "downloads.1password.com:443" - - "cache.agilebits.com:443" - - "my.1password.com:443" - - "my.1password.eu:443" - - "my.1password.ca:443" - - "events.1password.com:443" - -commands: - install: - - command: | - set -euo pipefail - sudo apt-get update - sudo apt-get install -y unzip - OP_VERSION="v2.30.3" - ARCH=$(dpkg --print-architecture) - curl -sSL "https://cache.agilebits.com/dist/1P/op2/pkg/${OP_VERSION}/op_linux_${ARCH}_${OP_VERSION}.zip" -o /tmp/op.zip - sudo unzip -od /usr/local/bin /tmp/op.zip op - sudo chmod +x /usr/local/bin/op - rm -f /tmp/op.zip - user: "1000" - description: Install 1Password CLI from the official archive diff --git a/src/cli/mod.rs b/src/cli/mod.rs index 0a8ab3b..5d99710 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -30,7 +30,7 @@ use std::io::{Read, stdin}; ", group( ArgGroup::new("sbx-mode") - .args(["sandbox", "fresh", "no_mixins"]) + .args(["sandbox"]) .multiple(true) .conflicts_with_all([ "model", "prompt", "role", "session", "agent", "rag", "rebuild_rag", @@ -227,13 +227,6 @@ pub struct Cli { /// Launch Coyote inside a Docker sandbox (via `sbx`); name defaults to current directory basename #[arg(long, value_name = "NAME", help_heading = "Sandbox")] pub sandbox: Option>, - /// Create the sandbox without bootstrapping the host config or vault password file - #[arg(long, requires = "sandbox", help_heading = "Sandbox")] - pub fresh: bool, - /// Skip discovery and application of all sbx mixins (user and built-in) - #[arg(long, requires = "sandbox", help_heading = "Sandbox")] - pub no_mixins: bool, - /// Display information #[arg(long, help_heading = "Diagnostics & Tools")] pub info: bool, @@ -608,41 +601,4 @@ mod tests { assert!(Cli::try_parse_from(["coyote", "--sandbox", "--agent", "foo"]).is_err()); } - #[test] - fn parse_fresh_flag_requires_sandbox() { - assert!(Cli::try_parse_from(["coyote", "--fresh"]).is_err()); - } - - #[test] - fn parse_fresh_flag_with_sandbox() { - let cli = parse(&["--sandbox", "--fresh"]); - assert_eq!(cli.sandbox, Some(None)); - assert!(cli.fresh); - } - - #[test] - fn parse_fresh_flag_with_named_sandbox() { - let cli = parse(&["--sandbox", "foo", "--fresh"]); - assert_eq!(cli.sandbox, Some(Some("foo".to_string()))); - assert!(cli.fresh); - } - - #[test] - fn parse_no_mixins_requires_sandbox() { - assert!(Cli::try_parse_from(["coyote", "--no-mixins"]).is_err()); - } - - #[test] - fn parse_no_mixins_with_sandbox() { - let cli = parse(&["--sandbox", "--no-mixins"]); - assert!(cli.no_mixins); - } - - #[test] - fn parse_sandbox_with_fresh_and_no_mixins() { - let cli = parse(&["--sandbox", "foo", "--fresh", "--no-mixins"]); - assert_eq!(cli.sandbox, Some(Some("foo".to_string()))); - assert!(cli.fresh); - assert!(cli.no_mixins); - } } diff --git a/src/config/mod.rs b/src/config/mod.rs index bfe6d52..50a3448 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -49,6 +49,7 @@ use crate::client::{ }; use crate::function::{FunctionDeclaration, Functions}; use crate::rag::Rag; +use crate::sandbox::SANDBOX_ENV_FLAG; use crate::utils::*; pub use macros::macro_execute; @@ -149,7 +150,7 @@ const WORKSPACE_COYOTE_DIR_NAME: &str = ".coyote"; const SBX_KIT_DIR_NAME: &str = "sbx-kit"; const SBX_KIT_HASH_FILE: &str = "kit.sha256"; const SBX_MIXIN_FILE_NAME: &str = "sbx-mixin.yaml"; -const SBX_VAULT_MIXINS_DIR_NAME: &str = "sbx-vault-mixins"; +pub(crate) const VAULT_DATA_FILE_NAME: &str = "vault.yml"; const SBX_MIXIN_KITS_DIR_NAME: &str = "sbx-mixin-kits"; const GIT_DIR_NAME: &str = ".git"; const GITIGNORE_FILE_NAME: &str = ".gitignore"; @@ -521,6 +522,12 @@ pub async fn sync_models(url: &str, abort_signal: AbortSignal) -> Result<()> { impl Config { pub async fn load_with_interpolation(info_flag: bool) -> Result { let config_path = paths::config_file(); + + if env::var_os(SANDBOX_ENV_FLAG).is_some() { + let (config, _) = Self::load_from_file(&config_path)?; + return Ok(config); + } + let (mut config, content) = if !config_path.exists() { match env::var(get_env_name("provider")) .ok() @@ -754,9 +761,7 @@ pub async fn create_config_file(config_path: &Path) -> Result<()> { let provider_choice = prompt_provider_choice()?; let mut vault = match &provider_choice { None => Vault::default_local(), - Some(provider) => Vault { - provider: provider.clone(), - }, + Some(provider) => Vault::from_provider(provider.clone()), }; create_vault_password_file(&mut vault)?; if provider_choice.is_some() { @@ -1108,4 +1113,57 @@ clients: assert!(!state.assert(StateFlags::SESSION)); assert!(!state.assert(StateFlags::empty())); } + + #[tokio::test] + #[serial_test::serial] + async fn sandbox_config_load_no_interpolation() { + use std::fs; + use std::time; + + let unique = time::SystemTime::now() + .duration_since(time::UNIX_EPOCH) + .unwrap() + .as_nanos(); + let tmp_dir = std::env::temp_dir().join(format!("coyote-sandbox-cfg-{unique}")); + fs::create_dir_all(&tmp_dir).unwrap(); + let config_path = tmp_dir.join("config.yaml"); + + fs::write( + &config_path, + "model: claude:claude-3-5-haiku\nclients:\n - type: claude\n api_key: '{{ANTHROPIC_API_KEY}}'\n", + ) + .unwrap(); + + let config_env = get_env_name("config_file"); + let prev_config = std::env::var_os(&config_env); + let prev_sandbox = std::env::var_os(crate::sandbox::SANDBOX_ENV_FLAG); + + unsafe { + std::env::set_var(&config_env, &config_path); + std::env::set_var(crate::sandbox::SANDBOX_ENV_FLAG, "1"); + } + + let result = Config::load_with_interpolation(false).await; + let (_, raw) = Config::load_from_file(&config_path).unwrap(); + + unsafe { + match prev_config { + Some(v) => std::env::set_var(&config_env, v), + None => std::env::remove_var(&config_env), + } + match prev_sandbox { + Some(v) => std::env::set_var(crate::sandbox::SANDBOX_ENV_FLAG, v), + None => std::env::remove_var(crate::sandbox::SANDBOX_ENV_FLAG), + } + } + let _ = fs::remove_dir_all(&tmp_dir); + + result.expect( + "load_with_interpolation should succeed in sandbox mode with placeholder values", + ); + assert!( + raw.contains("{{ANTHROPIC_API_KEY}}"), + "placeholder should be preserved as a literal string in sandbox mode" + ); + } } diff --git a/src/config/paths.rs b/src/config/paths.rs index 543b140..49f2135 100644 --- a/src/config/paths.rs +++ b/src/config/paths.rs @@ -5,7 +5,7 @@ use super::{ GLOBAL_TOOLS_UTILS_DIR_NAME, HIDDEN_MCP_FILE_NAME, MACROS_DIR_NAME, MCP_FILE_NAME, MEMORY_DIR_NAME, MEMORY_INDEX_FILE_NAME, ModelsOverride, RAGS_DIR_NAME, ROLES_DIR_NAME, SBX_KIT_DIR_NAME, SBX_KIT_HASH_FILE, SBX_MIXIN_FILE_NAME, SBX_MIXIN_KITS_DIR_NAME, - SBX_VAULT_MIXINS_DIR_NAME, SKILLS_DIR_NAME, WORKSPACE_COYOTE_DIR_NAME, + SKILLS_DIR_NAME, WORKSPACE_COYOTE_DIR_NAME, }; use crate::client::ProviderModels; use crate::config::REPL_HISTORY_DIR_NAME; @@ -148,14 +148,6 @@ pub fn sbx_kit_hash_file() -> PathBuf { sbx_kit_dir().join(SBX_KIT_HASH_FILE) } -pub fn sbx_vault_mixins_dir() -> PathBuf { - cache_dir().join(SBX_VAULT_MIXINS_DIR_NAME) -} - -pub fn sbx_vault_mixins_hash_file() -> PathBuf { - sbx_vault_mixins_dir().join(SBX_KIT_HASH_FILE) -} - pub fn sbx_mixin_kits_dir() -> PathBuf { cache_dir().join(SBX_MIXIN_KITS_DIR_NAME) } diff --git a/src/main.rs b/src/main.rs index d2a2718..3df48e2 100644 --- a/src/main.rs +++ b/src/main.rs @@ -106,7 +106,7 @@ async fn main() -> Result<()> { } if let Some(name) = &cli.sandbox { - return sandbox::launch(name.clone(), cli.fresh, cli.no_mixins); + return sandbox::launch(name.clone()); } install_builtins()?; diff --git a/src/sandbox/mod.rs b/src/sandbox/mod.rs index 9c9c8f5..f54310c 100644 --- a/src/sandbox/mod.rs +++ b/src/sandbox/mod.rs @@ -1,19 +1,23 @@ use anyhow::{Context, Result, anyhow, bail}; use rust_embed::RustEmbed; +use serde_json::Value; use sha2::{Digest, Sha256}; use std::env; use std::fs; +use std::io::Write; use std::path::{Path, PathBuf}; use std::process::{Command, Stdio}; use which::which; mod mixins; -use gman::providers::SupportedProvider; - +use crate::config::AppConfig; +use crate::config::Config; +use crate::config::VAULT_DATA_FILE_NAME; use crate::config::paths; use crate::sandbox::mixins::DiscoveredMixin; use crate::utils::run_command_with_output; +use crate::vault::SECRET_RE; use crate::vault::Vault; const SBX_BINARY: &str = "sbx"; @@ -24,49 +28,35 @@ const SANDBOX_AGENT: &str = "coyote"; #[folder = "assets/sbx-kit/"] struct EmbeddedKit; -#[derive(RustEmbed)] -#[folder = "assets/sbx-vault-mixins/"] -struct EmbeddedVaultMixins; - -pub fn launch(name: Option, fresh: bool, no_mixins: bool) -> Result<()> { +pub fn launch(name: Option) -> Result<()> { ensure_sbx_installed()?; bail_if_nested()?; let name = resolve_name(name)?; let kit_path = resolve_kit_path()?; - let discovered = if no_mixins { - Vec::new() - } else { - let mut all = mixins::discover()?; - if let Ok(vault) = Vault::init_bare() - && let Some(vault_mixin) = extract_vault_mixin(&vault.provider)? - { - all.insert(0, vault_mixin); - } - all + let config_path = paths::config_file(); + if !config_path.exists() { + bail!("No Coyote config found. Run `coyote` on your host to complete setup first."); + } + let (config, config_content) = Config::load_from_file(&config_path)?; + let bootstrap = AppConfig { + vault_password_file: config.vault_password_file.clone(), + secrets_provider: config.secrets_provider.clone(), + ..AppConfig::default() }; + let vault = Vault::init(&bootstrap)?; + inject_llm_secret(&config_content, &vault)?; + inject_mcp_secrets(&vault)?; + + let discovered = mixins::discover()?; if sandbox_exists(&name)? { info!("Re-attaching to existing sandbox '{name}'"); - if fresh { - debug!("--fresh ignored: re-attaching to existing sandbox '{name}'"); - } - if no_mixins { - debug!("--no-mixins ignored: re-attaching to existing sandbox '{name}'"); - } } else { - mixins::log_discovery(&discovered, no_mixins); - - if fresh { - let msg = format!("Creating fresh sandbox '{name}' (no host config will be copied)"); - info!("{msg}"); - println!("{msg}"); - create_sandbox(&name, &kit_path, &discovered)?; - } else { - create_sandbox(&name, &kit_path, &discovered)?; - copy_host_files(&name)?; - } + mixins::log_discovery(&discovered, false); + create_sandbox(&name, &kit_path, &discovered)?; + copy_host_files(&name)?; } exec_run(&name, &kit_path) @@ -207,97 +197,118 @@ fn compute_kit_hash() -> Result { Ok(format!("{:x}", hasher.finalize())) } -fn extract_vault_mixin(provider: &SupportedProvider) -> Result> { - let provider_dir = match provider { - SupportedProvider::Local { .. } => return Ok(None), - SupportedProvider::AwsSecretsManager { .. } => "aws_secrets_manager", - SupportedProvider::GcpSecretManager { .. } => "gcp_secret_manager", - SupportedProvider::AzureKeyVault { .. } => "azure_key_vault", - SupportedProvider::Gopass { .. } => "gopass", - SupportedProvider::OnePassword { .. } => "one_password", +fn inject_llm_secret(config_content: &str, vault: &Vault) -> Result<()> { + let value: serde_yaml::Value = serde_yaml::from_str(config_content) + .context("Failed to parse config for LLM secret injection")?; + + let Some(clients) = value.get("clients").and_then(|v| v.as_sequence()) else { + return Ok(()); }; - let cache_root = extract_vault_mixins_cache()?; - let provider_root = cache_root.join(provider_dir); - let spec_path = provider_root.join("spec.yaml"); + for client in clients { + let Some(api_key) = client.get("api_key").and_then(|v| v.as_str()) else { + continue; + }; - if !spec_path.exists() { - bail!( - "Embedded vault mixin for '{provider_dir}' is missing spec.yaml at {}", - spec_path.display() - ); + let Some(caps) = SECRET_RE.captures(api_key)? else { + continue; + }; + let secret_name = caps[1].to_string(); + + let client_type = client.get("type").and_then(|v| v.as_str()).unwrap_or(""); + let client_name = client.get("name").and_then(|v| v.as_str()); + let service = provider_to_sbx_service(client_type, client_name); + + let secret_value = vault + .get_secret(&secret_name, false) + .with_context(|| format!("Failed to decrypt LLM api_key secret '{secret_name}'"))?; + + sbx_secret_set(&service, &secret_value)?; } - let label = format!(""); - let (install_count, domain_count) = mixins::summarize(&spec_path)?; - - Ok(Some(DiscoveredMixin { - path: provider_root, - label, - install_count, - domain_count, - })) + Ok(()) } -fn extract_vault_mixins_cache() -> Result { - let cache_root = paths::sbx_vault_mixins_dir(); - let new_hash = compute_vault_mixins_hash()?; - let hash_file = paths::sbx_vault_mixins_hash_file(); - if let Ok(existing) = fs::read_to_string(&hash_file) - && existing == new_hash - { - return Ok(cache_root); +fn find_secret_placeholder(value: &Value) -> Option { + match value { + Value::String(s) => SECRET_RE + .captures(s) + .ok() + .flatten() + .map(|caps| caps[1].to_string()), + Value::Object(map) => map.values().find_map(find_secret_placeholder), + Value::Array(arr) => arr.iter().find_map(find_secret_placeholder), + _ => None, + } +} + +fn inject_mcp_secrets(vault: &Vault) -> Result<()> { + let mcp_path = paths::mcp_config_file(); + if !mcp_path.exists() { + return Ok(()); } - if cache_root.exists() { - fs::remove_dir_all(&cache_root).with_context(|| { + let content = fs::read_to_string(&mcp_path) + .with_context(|| format!("Failed to read {}", mcp_path.display()))?; + let mcp: Value = serde_json::from_str(&content) + .with_context(|| format!("Failed to parse {}", mcp_path.display()))?; + + let Some(servers) = mcp.get("mcpServers").and_then(|v| v.as_object()) else { + return Ok(()); + }; + + for (server_name, server_config) in servers { + let Some(secret_name) = find_secret_placeholder(server_config) else { + continue; + }; + + let secret_value = vault.get_secret(&secret_name, false).with_context(|| { format!( - "Failed to clear stale vault mixins at {}", - cache_root.display() + "Secret '{secret_name}' referenced by MCP server '{server_name}' not found \ + in vault. Add it with: coyote --add-secret {secret_name}" ) })?; - } - fs::create_dir_all(&cache_root) - .with_context(|| format!("Failed to create {}", cache_root.display()))?; - for entry in EmbeddedVaultMixins::iter() { - let file = EmbeddedVaultMixins::get(&entry).ok_or_else(|| { - anyhow!("Embedded vault mixin file missing during extraction: {entry}") - })?; - let dest = cache_root.join(entry.as_ref()); - if let Some(parent) = dest.parent() { - fs::create_dir_all(parent) - .with_context(|| format!("Failed to create {}", parent.display()))?; - } - - fs::write(&dest, &file.data) - .with_context(|| format!("Failed to write {}", dest.display()))?; + sbx_secret_set(server_name, &secret_value)?; } - fs::write(&hash_file, &new_hash) - .with_context(|| format!("Failed to write {}", hash_file.display()))?; - debug!( - "Extracted embedded sbx-vault-mixins to {}", - cache_root.display() - ); - - Ok(cache_root) + Ok(()) } -fn compute_vault_mixins_hash() -> Result { - let mut hasher = Sha256::new(); - let mut entries: Vec<_> = EmbeddedVaultMixins::iter().collect(); - entries.sort(); +fn provider_to_sbx_service(provider_type: &str, client_name: Option<&str>) -> String { + match provider_type { + "claude" => "anthropic".to_string(), + "openai" => "openai".to_string(), + "gemini" | "vertexai" => "google".to_string(), + "openai-compatible" => client_name.unwrap_or("openai-compatible").to_string(), + other => client_name.unwrap_or(other).to_string(), + } +} - for entry in &entries { - let file = EmbeddedVaultMixins::get(entry) - .ok_or_else(|| anyhow!("Embedded vault mixin file missing during hash: {entry}"))?; - hasher.update(entry.as_bytes()); - hasher.update(b"\0"); - hasher.update(&file.data); +fn sbx_secret_set(service: &str, secret_value: &str) -> Result<()> { + let mut child = Command::new(SBX_BINARY) + .args(["secret", "set", "-g", service]) + .stdin(Stdio::piped()) + .stdout(Stdio::inherit()) + .stderr(Stdio::inherit()) + .spawn() + .context("Failed to spawn `sbx secret set -g`")?; + + if let Some(ref mut stdin) = child.stdin { + stdin + .write_all(secret_value.as_bytes()) + .context("Failed to write secret to `sbx secret set -g` stdin")?; } - Ok(format!("{:x}", hasher.finalize())) + let status = child + .wait() + .context("Failed to wait for `sbx secret set -g`")?; + + if !status.success() { + bail!("`sbx secret set -g {service}` exited with {status}"); + } + + Ok(()) } fn sandbox_exists(name: &str) -> Result { @@ -367,7 +378,6 @@ fn build_create_args( fn copy_host_files(name: &str) -> Result<()> { let config_dir = paths::config_dir(); - let home_dir = dirs::home_dir().context("Could not determine home directory")?; if config_dir.exists() { let sandbox_config_dir = "/home/agent/.config/coyote"; @@ -378,6 +388,9 @@ fn copy_host_files(name: &str) -> Result<()> { { let entry = entry?; let path = entry.path(); + if path.file_name().is_some_and(|n| n == VAULT_DATA_FILE_NAME) { + continue; + } sbx_cp(&path.display().to_string(), &dest)?; } chown_agent_recursive(name, sandbox_config_dir)?; @@ -409,86 +422,9 @@ fn copy_host_files(name: &str) -> Result<()> { ); } - match resolve_vault_password_file() { - Some(password_file) if password_file.exists() => { - let dest_path = host_to_sandbox_path(&password_file, &home_dir, cfg!(windows))?; - if let Some(parent) = sandbox_path_parent(&dest_path) - && !parent.is_empty() - { - ensure_sandbox_dir(name, parent)?; - } - let dest = format!("{name}:{dest_path}"); - sbx_cp(&password_file.display().to_string(), &dest)?; - chown_agent_recursive(name, &dest_path)?; - } - Some(password_file) => { - debug!( - "Skipping vault password copy: {} does not exist", - password_file.display() - ); - } - None => { - debug!("Skipping vault password copy: no local vault provider configured"); - } - } - Ok(()) } -fn host_to_sandbox_path( - host_path: &Path, - home_dir: &Path, - is_windows_host: bool, -) -> Result { - let host_str = host_path.to_str().context("Host path is not valid UTF-8")?; - let home_str = home_dir - .to_str() - .context("Home directory is not valid UTF-8")?; - - if let Some(rel) = strip_host_home(host_str, home_str) { - let unixified = rel.replace('\\', "/"); - return Ok(format!("/home/agent/{unixified}")); - } - - if is_windows_host { - bail!( - "Path '{host_str}' is outside your Windows user profile ({home_str}). \ - Sandbox mode cannot copy files from outside %USERPROFILE% into a Linux \ - sandbox. Move the file under your user profile and update your config \ - accordingly." - ); - } - - Ok(host_str.to_string()) -} - -fn strip_host_home(path: &str, home: &str) -> Option { - let path_norm: String = path - .chars() - .map(|c| if c == '\\' { '/' } else { c }) - .collect(); - let home_norm: String = home - .chars() - .map(|c| if c == '\\' { '/' } else { c }) - .collect(); - let home_norm = home_norm.trim_end_matches('/'); - - if home_norm.is_empty() || path_norm.len() <= home_norm.len() { - return None; - } - - let (head, tail) = path_norm.split_at(home_norm.len()); - if head != home_norm || !tail.starts_with('/') { - return None; - } - - Some(tail[1..].to_string()) -} - -fn sandbox_path_parent(linux_path: &str) -> Option<&str> { - linux_path.rsplit_once('/').map(|(parent, _)| parent) -} - fn ensure_sandbox_dir(sandbox: &str, dir: &str) -> Result<()> { let dir_q = shell_words::quote(dir); let cmd = format!("sudo mkdir -p {dir_q} && sudo chown agent:agent {dir_q}"); @@ -510,10 +446,6 @@ fn ensure_sandbox_dir(sandbox: &str, dir: &str) -> Result<()> { Ok(()) } -fn resolve_vault_password_file() -> Option { - Vault::init_bare().ok()?.local_password_file().ok() -} - fn sbx_cp(src: &str, dest: &str) -> Result<()> { debug!("sbx cp {src} {dest}"); let status = Command::new(SBX_BINARY) @@ -707,279 +639,4 @@ mod tests { ] ); } - - mod vault_mixins { - use super::*; - use crate::utils::get_env_name; - use gman::providers::aws_secrets_manager::AwsSecretsManagerProvider; - use gman::providers::azure_key_vault::AzureKeyVaultProvider; - use gman::providers::gcp_secret_manager::GcpSecretManagerProvider; - use gman::providers::gopass::GopassProvider; - use gman::providers::local::LocalProvider; - use gman::providers::one_password::OnePasswordProvider; - use serial_test::serial; - use std::time::{SystemTime, UNIX_EPOCH}; - - struct TestCacheDirGuard { - key: String, - previous: Option, - path: PathBuf, - } - - impl TestCacheDirGuard { - fn new() -> Self { - let key = get_env_name("cache_dir"); - let previous = env::var_os(&key); - let unique = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap() - .as_nanos(); - let path = env::temp_dir().join(format!("coyote-sandbox-vault-tests-{unique}")); - fs::create_dir_all(&path).unwrap(); - unsafe { - env::set_var(&key, &path); - } - Self { - key, - previous, - path, - } - } - } - - impl Drop for TestCacheDirGuard { - fn drop(&mut self) { - unsafe { - match &self.previous { - Some(v) => env::set_var(&self.key, v), - None => env::remove_var(&self.key), - } - } - let _ = fs::remove_dir_all(&self.path); - } - } - - #[test] - fn returns_none_for_local() { - let p = SupportedProvider::Local { - provider_def: LocalProvider::default(), - }; - assert!(extract_vault_mixin(&p).unwrap().is_none()); - } - - #[test] - #[serial] - fn returns_some_for_aws() { - let _guard = TestCacheDirGuard::new(); - let p = SupportedProvider::AwsSecretsManager { - provider_def: AwsSecretsManagerProvider { - aws_profile: None, - aws_region: None, - }, - }; - let m = extract_vault_mixin(&p) - .unwrap() - .expect("expected vault mixin"); - assert!(m.path.join("spec.yaml").exists()); - assert!(m.label.contains("aws_secrets_manager")); - } - - #[test] - #[serial] - fn returns_some_for_gcp() { - let _guard = TestCacheDirGuard::new(); - let p = SupportedProvider::GcpSecretManager { - provider_def: GcpSecretManagerProvider { - gcp_project_id: None, - }, - }; - let m = extract_vault_mixin(&p) - .unwrap() - .expect("expected vault mixin"); - assert!(m.path.join("spec.yaml").exists()); - assert!(m.label.contains("gcp_secret_manager")); - } - - #[test] - #[serial] - fn returns_some_for_one_password() { - let _guard = TestCacheDirGuard::new(); - let p = SupportedProvider::OnePassword { - provider_def: OnePasswordProvider { - vault: None, - account: None, - }, - }; - let m = extract_vault_mixin(&p) - .unwrap() - .expect("expected vault mixin"); - assert!(m.path.join("spec.yaml").exists()); - assert!(m.label.contains("one_password")); - } - - #[test] - #[serial] - fn returns_some_for_azure() { - let _guard = TestCacheDirGuard::new(); - let p = SupportedProvider::AzureKeyVault { - provider_def: AzureKeyVaultProvider { vault_name: None }, - }; - let m = extract_vault_mixin(&p) - .unwrap() - .expect("expected vault mixin"); - assert!(m.path.join("spec.yaml").exists()); - assert!(m.label.contains("azure_key_vault")); - } - - #[test] - #[serial] - fn returns_some_for_gopass() { - let _guard = TestCacheDirGuard::new(); - let p = SupportedProvider::Gopass { - provider_def: GopassProvider { store: None }, - }; - let m = extract_vault_mixin(&p) - .unwrap() - .expect("expected vault mixin"); - assert!(m.path.join("spec.yaml").exists()); - assert!(m.label.contains("gopass")); - } - - #[test] - fn hash_is_deterministic() { - let h1 = compute_vault_mixins_hash().unwrap(); - let h2 = compute_vault_mixins_hash().unwrap(); - assert_eq!(h1, h2); - assert_eq!(h1.len(), 64); - } - } - - mod host_to_sandbox_path_tests { - use super::*; - - #[test] - fn linux_under_home() { - let dest = host_to_sandbox_path( - Path::new("/home/atusa/.coyote_password"), - Path::new("/home/atusa"), - false, - ) - .unwrap(); - - assert_eq!(dest, "/home/agent/.coyote_password"); - } - - #[test] - fn linux_nested_under_home() { - let dest = host_to_sandbox_path( - Path::new("/home/atusa/.config/coyote/.password"), - Path::new("/home/atusa"), - false, - ) - .unwrap(); - - assert_eq!(dest, "/home/agent/.config/coyote/.password"); - } - - #[test] - fn linux_outside_home_returns_verbatim() { - let dest = host_to_sandbox_path( - Path::new("/etc/coyote/.password"), - Path::new("/home/atusa"), - false, - ) - .unwrap(); - - assert_eq!(dest, "/etc/coyote/.password"); - } - - #[test] - fn macos_under_home_with_spaces() { - let dest = host_to_sandbox_path( - Path::new("/Users/atusa/Library/Application Support/coyote/.password"), - Path::new("/Users/atusa"), - false, - ) - .unwrap(); - - assert_eq!( - dest, - "/home/agent/Library/Application Support/coyote/.password" - ); - } - - #[test] - fn windows_under_home_converts_backslashes() { - let dest = host_to_sandbox_path( - Path::new(r"C:\Users\atusa\.coyote_password"), - Path::new(r"C:\Users\atusa"), - true, - ) - .unwrap(); - - assert_eq!(dest, "/home/agent/.coyote_password"); - } - - #[test] - fn windows_nested_under_home() { - let dest = host_to_sandbox_path( - Path::new(r"C:\Users\atusa\Documents\my\vault.txt"), - Path::new(r"C:\Users\atusa"), - true, - ) - .unwrap(); - - assert_eq!(dest, "/home/agent/Documents/my/vault.txt"); - } - - #[test] - fn windows_outside_home_bails_with_clear_error() { - let err = host_to_sandbox_path( - Path::new(r"C:\Program Files\Coyote\vault.txt"), - Path::new(r"C:\Users\atusa"), - true, - ) - .unwrap_err(); - - let msg = err.to_string(); - assert!( - msg.contains("Program Files"), - "error should name the offending path: {msg}" - ); - assert!( - msg.contains("user profile"), - "error should explain the limitation: {msg}" - ); - } - - #[test] - fn windows_tolerates_trailing_slash_in_home() { - let dest = host_to_sandbox_path( - Path::new(r"C:\Users\atusa\foo"), - Path::new(r"C:\Users\atusa\"), - true, - ) - .unwrap(); - - assert_eq!(dest, "/home/agent/foo"); - } - - #[test] - fn sandbox_path_parent_extracts_parent_for_nested() { - assert_eq!( - sandbox_path_parent("/home/agent/.coyote_password"), - Some("/home/agent") - ); - assert_eq!( - sandbox_path_parent("/etc/coyote/.password"), - Some("/etc/coyote") - ); - } - - #[test] - fn sandbox_path_parent_handles_edge_cases() { - assert_eq!(sandbox_path_parent("/file"), Some("")); - assert_eq!(sandbox_path_parent("noparent"), None); - } - } } diff --git a/src/vault/mod.rs b/src/vault/mod.rs index 317f3d8..a16a208 100644 --- a/src/vault/mod.rs +++ b/src/vault/mod.rs @@ -1,9 +1,11 @@ mod utils; +use std::env; use std::fs::read_to_string; use std::path::PathBuf; use crate::config::paths; +use crate::sandbox::SANDBOX_ENV_FLAG; pub use utils::create_vault_password_file; pub use utils::interpolate_secrets; pub use utils::prompt_provider_choice; @@ -17,7 +19,7 @@ use gman::providers::SecretProvider; use gman::providers::SupportedProvider; use gman::providers::local::LocalProvider; use inquire::{Password, PasswordDisplayMode, required}; -use log::{info, warn}; +use log::warn; use serde_yaml::Value; use std::sync::{Arc, LazyLock}; use tokio::runtime::Handle; @@ -25,34 +27,10 @@ use uuid::Uuid; pub static SECRET_RE: LazyLock = LazyLock::new(|| Regex::new(r"\{\{([^{}]+)}}").unwrap()); -fn apply_sandboxed_home_translation(provider_def: &mut LocalProvider) { - let Some(ref pf) = provider_def.password_file else { - return; - }; - - if pf.exists() { - return; - } - - let Some(translated) = paths::translate_sandboxed_home_dir(pf) else { - return; - }; - - if !translated.exists() { - return; - } - - info!( - "vault password file '{}' not found; resolved to sandboxed path '{}'", - pf.display(), - translated.display() - ); - provider_def.password_file = Some(translated); -} - #[derive(Debug, Default, Clone)] pub struct Vault { pub(crate) provider: SupportedProvider, + sandbox_mode: bool, } pub type GlobalVault = Arc; @@ -90,7 +68,10 @@ impl Vault { } }; - Ok(Self { provider }) + Ok(Self { + provider, + sandbox_mode: false, + }) } pub fn default_local() -> Self { @@ -102,10 +83,25 @@ impl Vault { ..LocalProvider::default() }, }, + sandbox_mode: false, + } + } + + pub fn from_provider(provider: SupportedProvider) -> Self { + Self { + provider, + sandbox_mode: false, } } pub fn init(config: &AppConfig) -> Result { + if env::var_os(SANDBOX_ENV_FLAG).is_some() { + return Ok(Self { + sandbox_mode: true, + ..Self::default() + }); + } + let mut provider = match &config.secrets_provider { Some(p) => p.clone(), None => SupportedProvider::Local { @@ -117,11 +113,13 @@ impl Vault { }; if let SupportedProvider::Local { provider_def } = &mut provider { - apply_sandboxed_home_translation(provider_def); ensure_password_file_initialized(provider_def)?; } - Ok(Self { provider }) + Ok(Self { + provider, + sandbox_mode: false, + }) } pub fn local_password_file(&self) -> Result { @@ -148,6 +146,11 @@ impl Vault { } pub fn add_secret(&self, secret_name: &str) -> Result<()> { + if self.sandbox_mode { + bail!( + "Vault management is disabled in sandbox mode. Use `coyote --add-secret` on your host." + ); + } let secret_value = Password::new("Enter the secret value:") .with_validator(required!()) .with_display_mode(PasswordDisplayMode::Masked) @@ -164,6 +167,11 @@ impl Vault { } pub fn get_secret(&self, secret_name: &str, display_output: bool) -> Result { + if self.sandbox_mode { + bail!( + "Vault management is disabled in sandbox mode. Use `coyote --add-secret` on your host." + ); + } let h = Handle::current(); let secret = tokio::task::block_in_place(|| { h.block_on(self.provider_ref().get_secret(secret_name)) @@ -177,6 +185,11 @@ impl Vault { } pub fn update_secret(&self, secret_name: &str) -> Result<()> { + if self.sandbox_mode { + bail!( + "Vault management is disabled in sandbox mode. Use `coyote --add-secret` on your host." + ); + } let secret_value = Password::new("Enter the secret value:") .with_validator(required!()) .with_display_mode(PasswordDisplayMode::Masked) @@ -195,6 +208,11 @@ impl Vault { } pub fn delete_secret(&self, secret_name: &str) -> Result<()> { + if self.sandbox_mode { + bail!( + "Vault management is disabled in sandbox mode. Use `coyote --add-secret` on your host." + ); + } let h = Handle::current(); tokio::task::block_in_place(|| h.block_on(self.provider_ref().delete_secret(secret_name)))?; println!("✓ Secret '{secret_name}' deleted from the vault."); @@ -203,6 +221,11 @@ impl Vault { } pub fn list_secrets(&self, display_output: bool) -> Result> { + if self.sandbox_mode { + bail!( + "Vault management is disabled in sandbox mode. Use `coyote --add-secret` on your host." + ); + } let h = Handle::current(); let secrets = tokio::task::block_in_place(|| h.block_on(self.provider_ref().list_secrets()))?; @@ -346,4 +369,35 @@ mod tests { let vault = Vault::default(); assert!(vault.local_password_file().is_err()); } + + #[test] + fn vault_disabled_in_sandbox() { + let prev = std::env::var_os("IS_SANDBOX"); + unsafe { + std::env::set_var("IS_SANDBOX", "1"); + } + + let vault = Vault::init(&AppConfig::default()).unwrap(); + + let check = |err: anyhow::Error| { + let msg = err.to_string(); + assert!( + msg.contains("Vault management is disabled in sandbox mode"), + "expected vault-disabled error, got: {msg}" + ); + }; + + check(vault.add_secret("test").unwrap_err()); + check(vault.get_secret("test", false).unwrap_err()); + check(vault.update_secret("test").unwrap_err()); + check(vault.delete_secret("test").unwrap_err()); + check(vault.list_secrets(false).unwrap_err()); + + unsafe { + match prev { + Some(v) => std::env::set_var("IS_SANDBOX", v), + None => std::env::remove_var("IS_SANDBOX"), + } + } + } }