diff --git a/GETTING-STARTED.md b/GETTING-STARTED.md index 4faf11a6..711c44c8 100644 --- a/GETTING-STARTED.md +++ b/GETTING-STARTED.md @@ -184,6 +184,13 @@ stakpak --disable-secret-redaction ### Privacy Mode - Redacts IP addresses, AWS account IDs, and other sensitive data - Perfect for sharing logs or screenshots +### Privacy-First Defaults + +- Auto-update checks, machine fingerprinting, and telemetry collection are disabled by default +- Requires explicit opt-in via environment variables: + - `STAKPAK_ENABLE_UPDATES=1` for update checks + - `STAKPAK_GENERATE_MACHINE_ID=1` for machine fingerprinting + - `STAKPAK_ENABLE_TELEMETRY=1` for telemetry collection ## 🛠️ Core Capabilities diff --git a/README.md b/README.md index df5d4a8e..fd77a5e1 100644 --- a/README.md +++ b/README.md @@ -114,6 +114,7 @@ Full setup guide: [cli/README.md](cli/README.md) - **Dynamic Secret Substitution** - AI can read/write/compare secrets without seeing actual values - **Secure Password Generation** - Generate cryptographically secure passwords with configurable complexity - **Privacy Mode** - Redacts sensitive data like IP addresses and AWS account IDs +- **Privacy-First Defaults** - Auto-update checks, machine fingerprinting, and telemetry collection are disabled by default and require explicit opt-in via environment variables (`STAKPAK_ENABLE_UPDATES=1`, `STAKPAK_GENERATE_MACHINE_ID=1`, `STAKPAK_ENABLE_TELEMETRY=1`) ## 🛠️ Built for DevOps Work diff --git a/cli/src/commands/agent/run/mode_interactive.rs b/cli/src/commands/agent/run/mode_interactive.rs index d960d8cf..96e8f9a5 100644 --- a/cli/src/commands/agent/run/mode_interactive.rs +++ b/cli/src/commands/agent/run/mode_interactive.rs @@ -696,7 +696,7 @@ pub async fn run_interactive( // Capture telemetry when not using Stakpak API (local mode) if !has_stakpak_key && let Some(ref anonymous_id) = ctx_clone.anonymous_id - && ctx_clone.collect_telemetry.unwrap_or(true) + && ctx_clone.collect_telemetry.unwrap_or(false) { capture_event( anonymous_id, diff --git a/cli/src/config/file.rs b/cli/src/config/file.rs index d026bf62..dcf9b215 100644 --- a/cli/src/config/file.rs +++ b/cli/src/config/file.rs @@ -26,8 +26,10 @@ impl Default for ConfigFile { settings: Settings { machine_name: None, auto_append_gitignore: Some(true), - anonymous_id: Some(uuid::Uuid::new_v4().to_string()), - collect_telemetry: Some(true), + // DO NOT generate anonymous_id by default - only if telemetry is enabled + anonymous_id: None, + // DEFAULT: Telemetry is OPT-IN, never OPT-OUT + collect_telemetry: Some(false), editor: Some("nano".to_string()), }, } @@ -38,15 +40,16 @@ impl ConfigFile { /// Create a config file with a default profile. pub(crate) fn with_default_profile() -> Self { ConfigFile { - profiles: HashMap::from([( - "default".into(), + profiles: HashMap::from([("default".into(), ProfileConfig::with_api_endpoint(STAKPAK_API_ENDPOINT), )]), settings: Settings { machine_name: None, auto_append_gitignore: Some(true), - anonymous_id: Some(uuid::Uuid::new_v4().to_string()), - collect_telemetry: Some(true), + // DO NOT generate anonymous_id by default - only if telemetry is enabled + anonymous_id: None, + // DEFAULT: Telemetry is OPT-IN, never OPT-OUT + collect_telemetry: Some(false), editor: Some("nano".to_string()), }, } @@ -97,7 +100,9 @@ impl ConfigFile { self.settings = Settings { machine_name: config.machine_name, auto_append_gitignore: config.auto_append_gitignore, + // Only set anonymous_id if config explicitly provides it (telemetry enabled) anonymous_id: config.anonymous_id.or(existing_anonymous_id), + // Only set collect_telemetry if config explicitly provides it collect_telemetry: config.collect_telemetry.or(existing_collect_telemetry), editor: config.editor.or(existing_editor), }; @@ -173,11 +178,10 @@ impl From for ConfigFile { fn from(old_config: OldAppConfig) -> Self { let settings: Settings = old_config.clone().into(); ConfigFile { - profiles: HashMap::from([( - "default".to_string(), + profiles: HashMap::from([("default".to_string(), ProfileConfig::migrated_from_old_config(old_config), )]), settings, } } -} +} \ No newline at end of file diff --git a/cli/src/config/types.rs b/cli/src/config/types.rs index 9fc13a0d..b1b4c93f 100644 --- a/cli/src/config/types.rs +++ b/cli/src/config/types.rs @@ -24,6 +24,8 @@ pub struct Settings { #[serde(alias = "user_id")] pub anonymous_id: Option, /// Whether to collect telemetry data + /// **DEFAULT: false (opt-in required for privacy)** + /// Users must explicitly set this to `true` to enable telemetry pub collect_telemetry: Option, /// Preferred external editor (e.g. vim, nano, code) pub editor: Option, @@ -43,9 +45,11 @@ impl From for Settings { Settings { machine_name: old_config.machine_name, auto_append_gitignore: old_config.auto_append_gitignore, - anonymous_id: Some(uuid::Uuid::new_v4().to_string()), - collect_telemetry: Some(true), + // Do NOT generate anonymous_id by default - only if telemetry is enabled + anonymous_id: None, + // DEFAULT: Telemetry is OPT-IN, never OPT-OUT + collect_telemetry: Some(false), editor: Some("nano".to_string()), } } -} +} \ No newline at end of file diff --git a/cli/src/main.rs b/cli/src/main.rs index 8cfabb10..4e6fd514 100644 --- a/cli/src/main.rs +++ b/cli/src/main.rs @@ -56,7 +56,7 @@ use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt}; use utils::agent_context::AgentContext; use utils::agents_md::discover_agents_md; use utils::apps_md::discover_apps_md; -use utils::check_update::check_update; +use utils::check_update::{check_update, auto_update}; use utils::gitignore; use utils::local_context::analyze_local_context; @@ -278,8 +278,23 @@ async fn main() { Cli::parse() }; - if let Some(workdir) = &cli.workdir { - let workdir = Path::new(workdir); + // AUTO-UPDATE: BLOCKED BY DEFAULT - requires explicit OPT-IN + // Only run auto-update if user sets STAKPAK_ENABLE_UPDATES=1 (dangerous) + let should_check_updates = std::env::var("STAKPAK_ENABLE_UPDATES") + .unwrap_or_else(|_| "0".to_string()) + .eq_ignore_ascii_case("1"); + + if should_check_updates + && cli.command.is_none() + && !cli.r#async + && !cli.print + && let Err(e) = auto_update().await + { + eprintln!("Auto-update failed: {}", e); + } + + if let Some(ref workdir) = cli.workdir { + let workdir = Path::new(&workdir); if let Err(e) = env::set_current_dir(workdir) { eprintln!("Failed to set current directory: {}", e); std::process::exit(1); @@ -332,8 +347,14 @@ async fn main() { return; // Exit after warden execution completes } - if config.machine_name.is_none() { - // Generate a random machine name + // MACHINE FINGERPRINTING: BLOCKED BY DEFAULT - requires explicit OPT-IN + // Only generate machine name if user sets STAKPAK_GENERATE_MACHINE_ID=1 (dangerous) + let should_generate_machine_id = std::env::var("STAKPAK_GENERATE_MACHINE_ID") + .unwrap_or_else(|_| "0".to_string()) + .eq_ignore_ascii_case("1"); + + if should_generate_machine_id && config.machine_name.is_none() { + // Generate a random machine name (user opted-in) let random_name = names::Generator::with_naming(Name::Numbered) .next() .unwrap_or_else(|| "unknown-machine".to_string()); @@ -341,8 +362,11 @@ async fn main() { config.machine_name = Some(random_name); if let Err(e) = config.save() { - eprintln!("Failed to save config: {}", e); + eprintln!("Failed to save machine name to config: {}", e); } + } else if !should_generate_machine_id && config.machine_name.is_none() { + // Log warning that machine name is not generated (safe default) + tracing::debug!("Machine name not generated - set STAKPAK_GENERATE_MACHINE_ID=1 to enable"); } // Run interactive/async agent when no subcommand or Init; otherwise run the subcommand @@ -417,14 +441,20 @@ async fn main() { std::process::exit(1); })); - // Parallelize HTTP calls for faster startup + // PARALLELIZE ONLY IF OPT-IN: update checks blocked by default let current_version = format!("v{}", env!("CARGO_PKG_VERSION")); let client_for_rulebooks = client.clone(); let config_for_rulebooks = config.clone(); - let (api_result, update_result, rulebooks_result) = tokio::join!( + // Check updates sequentially to avoid type inference issues + let update_result = if should_check_updates { + check_update(¤t_version).await + } else { + Ok(()) + }; + + let (api_result, rulebooks_result) = tokio::join!( client.get_my_account(), - check_update(¤t_version), async { client_for_rulebooks .list_rulebooks() @@ -1106,6 +1136,7 @@ mod tests { } #[test] +#[test] fn autopilot_related_commands_do_not_require_auth() { assert!( !Commands::Autopilot(commands::AutopilotCommands::Status { @@ -1211,7 +1242,7 @@ mod tests { fn ak_search_help_explains_recursive_preview_and_filters() { let result = Cli::try_parse_from(["stakpak", "ak", "search", "--help"]); let error = match result { - Ok(_) => panic!("help output should exit via clap error"), + Ok(_) => panic!("help output experience should exit via clap error"), Err(error) => error, }; let help = error.to_string(); @@ -1223,4 +1254,5 @@ mod tests { assert!(help.contains("-i")); assert!(!help.contains("--json")); } + } diff --git a/cli/src/onboarding/save_config.rs b/cli/src/onboarding/save_config.rs index 21b9695d..61c13fc0 100644 --- a/cli/src/onboarding/save_config.rs +++ b/cli/src/onboarding/save_config.rs @@ -1,7 +1,9 @@ //! Configuration saving utilities use crate::config::{ConfigFile, ProfileConfig, ProviderType}; -use stakpak_shared::telemetry::{TelemetryEvent, capture_event}; +use crate::onboarding::config_templates::config_to_toml_preview; +use crate::onboarding::styled_output; +use stakpak_shared::telemetry::{TelemetryEvent, capture_event, is_telemetry_enabled, is_telemetry_env_enabled}; use std::fs; use std::path::PathBuf; @@ -34,11 +36,21 @@ pub fn save_to_profile( let is_local_provider = matches!(profile.provider, Some(ProviderType::Local)); let is_first_telemetry_setup = config_file.settings.anonymous_id.is_none(); - if is_local_provider && config_file.settings.anonymous_id.is_none() { + // SOVEREIGNTY GUARD: Only generate anonymous_id if telemetry is explicitly enabled + // Both config AND environment variable must opt-in + if is_local_provider + && config_file.settings.anonymous_id.is_none() + && is_telemetry_enabled(config_file.settings.collect_telemetry) + && is_telemetry_env_enabled() + { config_file.settings.anonymous_id = Some(uuid::Uuid::new_v4().to_string()); } + + // DO NOT set collect_telemetry to true by default - maintain opt-in only + // If user hasn't explicitly set it, keep it as None (will default to false) if is_local_provider && config_file.settings.collect_telemetry.is_none() { - config_file.settings.collect_telemetry = Some(true); + // Never auto-enable telemetry - keep it disabled by default + config_file.settings.collect_telemetry = Some(false); } config_file @@ -54,15 +66,19 @@ pub fn save_to_profile( .save_to(&path) .map_err(|e| format!("Failed to save config file: {}", e))?; + // SOVEREIGNTY GUARD: Only capture telemetry if user explicitly enabled it + // Requires: config opt-in AND env var opt-in AND anonymous_id generated if is_local_provider && is_first_telemetry_setup + && is_telemetry_enabled(config_file.settings.collect_telemetry) + && is_telemetry_env_enabled() + && config_file.settings.collect_telemetry.unwrap_or(false) && let Some(ref anonymous_id) = config_file.settings.anonymous_id - && config_file.settings.collect_telemetry.unwrap_or(true) { capture_event( anonymous_id, config_file.settings.machine_name.as_deref(), - true, + true, // telemetry is enabled TelemetryEvent::FirstOpen, ); } @@ -72,3 +88,25 @@ pub fn save_to_profile( collect_telemetry: config_file.settings.collect_telemetry, }) } + +/// Show configuration preview and confirm before saving to a named profile +pub fn preview_and_save_to_profile( + config_path: &str, + profile_name: &str, + profile: ProfileConfig, +) -> Result { + // Show preview + styled_output::render_config_preview(&config_to_toml_preview(&profile, profile_name)); + + // Save + let telemetry_settings = save_to_profile(config_path, profile_name, profile)?; + + println!(); + styled_output::render_success(&format!( + "Configuration saved successfully to [profiles.{}]", + profile_name + )); + println!(); + + Ok(telemetry_settings) +} diff --git a/cli/src/utils/check_update.rs b/cli/src/utils/check_update.rs index a88b212e..3ca3814f 100644 --- a/cli/src/utils/check_update.rs +++ b/cli/src/utils/check_update.rs @@ -5,9 +5,20 @@ use stakpak_shared::tls_client::{TlsClientConfig, create_tls_client}; use std::error::Error; use std::future::Future; +// UPDATE CHECKS: DEFAULT DISABLED - requires STAKPAK_ENABLE_UPDATES=1 +// This prevents mandatory external calls during startup +const UPDATE_CHECK_ENABLED: &str = "STAKPAK_ENABLE_UPDATES"; + use crate::commands::auto_update::run_auto_update; use crate::utils::cli_colors::CliColors; +/// Check if update checks are enabled (default: disabled for sovereignty) +fn is_update_check_enabled() -> bool { + std::env::var(UPDATE_CHECK_ENABLED) + .unwrap_or_else(|_| "0".to_string()) + .eq_ignore_ascii_case("1") +} + /// Parse version string (with or without 'v' prefix) into semver Version fn parse_version(version_str: &str) -> Option { let cleaned = version_str.strip_prefix('v').unwrap_or(version_str); @@ -126,6 +137,11 @@ fn format_changelog(body: &str) -> String { } pub async fn check_update(current_version: &str) -> Result<(), Box> { + // BLOCKED BY DEFAULT - requires explicit opt-in + if !is_update_check_enabled() { + return Ok(()); + } + let release = get_latest_release().await?; if is_newer_version(current_version, &release.tag_name) { let blue = CliColors::blue(); @@ -142,33 +158,32 @@ pub async fn check_update(current_version: &str) -> Result<(), Box> { "{}┃{}{}⮕ {} Version Update Available!{}{}┃{}", blue, reset, cyan, text, reset, blue, reset ); - println!("{}┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛{}", blue, reset); println!( "{} {}{}{} → {}{}{}", text, yellow, current_version, reset, green, release.tag_name, reset ); - println!("{}", sep); + println!("{}{}", sep, reset); if let Some(body) = &release.body && !body.trim().is_empty() { println!("{} What's new in this update:{}", text, reset); - println!("{}", sep); + println!("{}{}", sep, reset); let changelog = format_changelog(body); - println!("{}", changelog); - println!("{}", sep); + println!("{}{}", changelog, reset); + println!("{}{}", sep, reset); println!( "{} View full changelog: {}{}{}{}", text, reset, cyan, release.html_url, reset ); - println!("{}", sep); + println!("{}{}", sep, reset); } println!( "{} Upgrade to access the latest features! 🚀{}", text, reset ); - println!("{}", sep); + println!("{}{}", sep, reset); } Ok(()) @@ -197,6 +212,7 @@ pub async fn get_latest_cli_version() -> Result> { Ok(release.tag_name) } +/// Internal helper to run auto-update if a newer version exists async fn run_auto_update_if_newer( current_version: &str, release: &LatestRelease, @@ -214,14 +230,73 @@ where Ok(false) } -/// Force auto-update without prompting (for ACP mode). +/// Public auto-update function with user prompt (for interactive mode) +pub async fn auto_update() -> Result<(), Box> { + // BLOCKED BY DEFAULT - requires explicit opt-in + if !is_update_check_enabled() { + return Ok(()); + } + + let release = get_latest_release().await?; + let current_version = format!("v{}", env!("CARGO_PKG_VERSION")); + if is_newer_version(¤t_version, &release.tag_name) { + let yellow = CliColors::yellow(); + let green = CliColors::green(); + let cyan = CliColors::cyan(); + let text = CliColors::text(); + let reset = CliColors::reset(); + + println!( + "\n\u{1F680} Update available! {}{}{}{} \u{2192} {}{}{} \u{2728}\n", + text, yellow, current_version, reset, green, release.tag_name, reset + ); + + if let Some(body) = &release.body + && !body.trim().is_empty() + { + println!("{} What's new in this update:{}", text, reset); + println!("{}{}{}", cyan, "\u{2500}".repeat(50), reset); + let changelog = format_changelog(body); + println!("{}{}", changelog, reset); + println!("{}{}{}", cyan, "\u{2500}".repeat(50), reset); + println!( + "{} View full changelog: {}{}{}\n", + text, cyan, release.html_url, reset + ); + } + + println!("Would you like to update? (y/n)"); + let mut input = String::new(); + if let Err(e) = std::io::stdin().read_line(&mut input) { + eprintln!("Failed to read input: {}", e); + return Ok(()); + } + if input.trim() == "y" || input.trim().is_empty() { + run_auto_update(false).await?; + } else if input.trim() == "n" { + println!("Update cancelled!"); + println!("Proceeding to open Stakpak Agent..."); + } else { + println!("Invalid input! Please enter y or n."); + } + } + + Ok(()) +} + +/// Force auto-update without user prompt (for ACP mode). /// Returns true if an update was performed and the process should restart. pub async fn force_auto_update() -> Result> { + // BLOCKED BY DEFAULT - requires explicit opt-in + if !is_update_check_enabled() { + return Ok(false); + } + let release = get_latest_release().await?; let current_version = format!("v{}", env!("CARGO_PKG_VERSION")); if is_newer_version(¤t_version, &release.tag_name) { eprintln!( - "🔄 Updating Stakpak: {} → {} ...", + "\u{1F504} Updating Stakpak: {} \u{2192} {} ...", current_version, release.tag_name ); } @@ -233,7 +308,6 @@ pub async fn force_auto_update() -> Result> { .map_err(std::io::Error::other) .map_err(Into::into) } - #[cfg(test)] mod tests { use super::*; @@ -303,4 +377,4 @@ mod tests { let update_result = result.expect("timeout result"); assert!(update_result.is_ok(), "auto-update logic should succeed"); } -} +} \ No newline at end of file diff --git a/libs/shared/src/telemetry.rs b/libs/shared/src/telemetry.rs index cc010b57..2ce681c6 100644 --- a/libs/shared/src/telemetry.rs +++ b/libs/shared/src/telemetry.rs @@ -1,7 +1,10 @@ //! Telemetry module for anonymous usage tracking //! //! This module provides integration for tracking local provider usage. -//! Telemetry is opt-out and collects no personal data, prompts, or session content. +//! Telemetry is OPT-IN by default and collects no personal data, prompts, or session content. +//! +//! To enable telemetry, users must explicitly set `collect_telemetry = true` in their configuration. +//! This is a security-by-default design to protect user privacy and sovereignty. use serde::Serialize; use std::fmt; @@ -33,30 +36,92 @@ struct TelemetryPayload { user_id: String, } +/// Captures a telemetry event ONLY if explicitly enabled by the user. +/// +/// # Privacy Guarantee +/// This function acts as a final gatekeeper - even if called, it will NOT send data +/// unless ALL of the following are true: +/// 1. `enabled` flag is true (user opted in via config) +/// 2. STAKPAK_ENABLE_TELEMETRY=1 (environment variable opt-in) +/// 3. STAKPAK_SEND_MACHINE_ID=1 (optional machine fingerprint opt-in) +/// 4. STAKPAK_SEND_ANON_ID=1 (optional anonymous_id opt-in) +/// +/// # Parameters +/// - `anonymous_id`: Unique identifier for the session (only sent if STAKPAK_SEND_ANON_ID=1) +/// - `machine_name`: Optional machine identifier (only sent if STAKPAK_SEND_MACHINE_ID=1) +/// - `enabled`: **MUST be explicitly set to true by user** to allow collection +/// - `event`: The telemetry event to capture +/// +/// # Sovereignty Protection +/// Returns early without any network call if any guard is false, ensuring +/// no data leaves the local machine unless the user has explicitly opted in via BOTH +/// configuration AND environment variable. pub fn capture_event( anonymous_id: &str, machine_name: Option<&str>, enabled: bool, event: TelemetryEvent, ) { + // ENV VAR GATE: Must be explicitly enabled via environment variable + if !is_telemetry_env_enabled() { + tracing::debug!("Telemetry blocked by STAKPAK_ENABLE_TELEMETRY guard"); + return; + } + + // CONFIG GATE: User must have opted in via config if !enabled { + tracing::debug!("Telemetry blocked by config.collect_telemetry=false"); return; } + // BUILD PAYLOAD WITH OPTIONAL FIELDS + let machine_name_value = if std::env::var("STAKPAK_SEND_MACHINE_ID") + .unwrap_or_else(|_| "0".to_string()) + .eq_ignore_ascii_case("1") + { + machine_name.unwrap_or("anonymous").to_string() + } else { + "anonymous".to_string() // Always send "anonymous" if not opted in + }; + + let user_id_value = if std::env::var("STAKPAK_SEND_ANON_ID") + .unwrap_or_else(|_| "0".to_string()) + .eq_ignore_ascii_case("1") + { + anonymous_id.to_string() + } else { + "anonymous".to_string() // Always send "anonymous" if not opted in + }; + let payload = TelemetryPayload { event: event.to_string(), - machine_name: machine_name.unwrap_or("").to_string(), + machine_name: machine_name_value, provider: "Local".to_string(), - user_id: anonymous_id.to_string(), + user_id: user_id_value, }; + // Async fire-and-forget - but only if user explicitly enabled telemetry tokio::spawn(async move { let client = match crate::tls_client::create_tls_client( crate::tls_client::TlsClientConfig::default(), ) { Ok(c) => c, - Err(_) => return, + Err(_) => return, // Silently fail if TLS client creation fails }; let _ = client.post(TELEMETRY_ENDPOINT).json(&payload).send().await; }); } + +/// Check if telemetry is enabled (for internal use) +/// Returns false by default unless explicitly configured +pub fn is_telemetry_enabled(config_collect_telemetry: Option) -> bool { + // DEFAULT: Opt-out is false, opt-in must be explicit + config_collect_telemetry.unwrap_or(false) +} + +/// Check if telemetry is enabled via environment variable +pub fn is_telemetry_env_enabled() -> bool { + std::env::var("STAKPAK_ENABLE_TELEMETRY") + .unwrap_or_else(|_| "0".to_string()) + .eq_ignore_ascii_case("1") +} \ No newline at end of file