diff --git a/assets/sbx-vault-mixins/aws_secrets_manager/spec.yaml b/assets/sbx-vault-mixins/aws_secrets_manager/spec.yaml new file mode 100644 index 0000000..7643316 --- /dev/null +++ b/assets/sbx-vault-mixins/aws_secrets_manager/spec.yaml @@ -0,0 +1,33 @@ +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 new file mode 100644 index 0000000..5d6afd1 --- /dev/null +++ b/assets/sbx-vault-mixins/azure_key_vault/spec.yaml @@ -0,0 +1,24 @@ +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 new file mode 100644 index 0000000..4de2149 --- /dev/null +++ b/assets/sbx-vault-mixins/gcp_secret_manager/spec.yaml @@ -0,0 +1,34 @@ +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 new file mode 100644 index 0000000..8e12e08 --- /dev/null +++ b/assets/sbx-vault-mixins/gopass/spec.yaml @@ -0,0 +1,30 @@ +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 new file mode 100644 index 0000000..72fdc11 --- /dev/null +++ b/assets/sbx-vault-mixins/one_password/spec.yaml @@ -0,0 +1,31 @@ +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/config/mod.rs b/src/config/mod.rs index 1999fad..33a4ba3 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -146,6 +146,7 @@ const WORKSPACE_MEMORY_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"; const GIT_DIR_NAME: &str = ".git"; const GITIGNORE_FILE_NAME: &str = ".gitignore"; const DEFAULT_VISIBLE_TOOLS: [&str; 18] = [ diff --git a/src/config/paths.rs b/src/config/paths.rs index df49cf2..1578225 100644 --- a/src/config/paths.rs +++ b/src/config/paths.rs @@ -4,7 +4,8 @@ use super::{ ENV_FILE_NAME, FUNCTIONS_BIN_DIR_NAME, FUNCTIONS_DIR_NAME, GLOBAL_TOOLS_DIR_NAME, GLOBAL_TOOLS_UTILS_DIR_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, SKILLS_DIR_NAME, WORKSPACE_MEMORY_DIR_NAME, + SBX_KIT_HASH_FILE, SBX_MIXIN_FILE_NAME, SBX_VAULT_MIXINS_DIR_NAME, SKILLS_DIR_NAME, + WORKSPACE_MEMORY_DIR_NAME, }; use crate::client::ProviderModels; use crate::utils::{get_env_name, list_file_names, normalize_env_name}; @@ -81,6 +82,14 @@ pub fn sbx_kit_hash_file() -> PathBuf { sbx_kit_dir().join(SBX_KIT_HASH_FILE) } +pub fn sbx_vault_mixins_dir() -> PathBuf { + cache_path().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 config_file() -> PathBuf { match env::var(get_env_name("config_file")) { Ok(value) => PathBuf::from(value), diff --git a/src/sandbox/mod.rs b/src/sandbox/mod.rs index fc4a95e..751ec3e 100644 --- a/src/sandbox/mod.rs +++ b/src/sandbox/mod.rs @@ -9,6 +9,8 @@ use which::which; mod mixins; +use gman::providers::SupportedProvider; + use crate::config::paths; use crate::sandbox::mixins::DiscoveredMixin; use crate::utils::run_command_with_output; @@ -22,6 +24,10 @@ 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<()> { ensure_sbx_installed()?; bail_if_nested()?; @@ -32,7 +38,13 @@ pub fn launch(name: Option, fresh: bool, no_mixins: bool) -> Result<()> let discovered = if no_mixins { Vec::new() } else { - mixins::discover()? + 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 }; if sandbox_exists(&name)? { @@ -195,6 +207,99 @@ 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", + }; + + let cache_root = extract_vault_mixins_cache()?; + let provider_root = cache_root.join(provider_dir); + let spec_path = provider_root.join("spec.yaml"); + + if !spec_path.exists() { + bail!( + "Embedded vault mixin for '{provider_dir}' is missing spec.yaml at {}", + spec_path.display() + ); + } + + let label = format!(""); + let (install_count, domain_count) = mixins::summarize(&spec_path)?; + + Ok(Some(DiscoveredMixin { + path: provider_root, + label, + install_count, + domain_count, + })) +} + +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); + } + + if cache_root.exists() { + fs::remove_dir_all(&cache_root).with_context(|| { + format!( + "Failed to clear stale vault mixins at {}", + cache_root.display() + ) + })?; + } + 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()))?; + } + + 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) +} + +fn compute_vault_mixins_hash() -> Result { + let mut hasher = Sha256::new(); + let mut entries: Vec<_> = EmbeddedVaultMixins::iter().collect(); + entries.sort(); + + 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); + } + + Ok(format!("{:x}", hasher.finalize())) +} + fn sandbox_exists(name: &str) -> Result { let (success, stdout, stderr) = run_command_with_output(SBX_BINARY, &["ls"], None).context("Failed to run `sbx ls`")?; @@ -487,4 +592,104 @@ mod tests { ] ); } + + mod vault_mixins { + use super::*; + 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; + + #[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 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 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 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 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 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); + } + } }