From 993c16a1c9f28c1330e37f1818d83e3a7032e070 Mon Sep 17 00:00:00 2001 From: veeso Date: Thu, 9 Jul 2026 16:07:17 +0200 Subject: [PATCH 1/4] feat(vscode): add profile config foundation --- crates/firma-config-loader/Cargo.toml | 3 +- crates/firma-config-loader/src/lib.rs | 6 + crates/firma-config-loader/src/profile.rs | 33 +++- crates/firma-config-loader/src/resolver.rs | 15 +- .../seccomp/vscode-local-command-v1.toml | 13 ++ crates/firma-run/src/config.rs | 114 ++++++++++-- crates/firma-run/src/profile.rs | 48 ++++- crates/firma/src/args/config.rs | 68 ++++++- crates/firma/src/services/config.rs | 108 ++++++++++- crates/firma/src/services/policy.rs | 2 +- crates/firma/templates/mappings/vscode.toml | 168 ++++++++++++++++++ .../firma/tests/run_implicit_init_prompt.rs | 26 ++- 12 files changed, 544 insertions(+), 60 deletions(-) create mode 100644 crates/firma-run/seccomp/vscode-local-command-v1.toml create mode 100644 crates/firma/templates/mappings/vscode.toml diff --git a/crates/firma-config-loader/Cargo.toml b/crates/firma-config-loader/Cargo.toml index f2acafb50..85ffd1d10 100644 --- a/crates/firma-config-loader/Cargo.toml +++ b/crates/firma-config-loader/Cargo.toml @@ -11,7 +11,6 @@ workspace = true [features] clap = ["dep:clap"] -test-utils = [] [dependencies] anyhow = { workspace = true } @@ -22,6 +21,6 @@ thiserror = { workspace = true } toml = { workspace = true } [dev-dependencies] -firma-config-loader = { path = ".", features = ["test-utils"] } +firma-config-loader = { path = "." } pretty_assertions = { workspace = true } tempfile = { workspace = true } diff --git a/crates/firma-config-loader/src/lib.rs b/crates/firma-config-loader/src/lib.rs index a3aa50821..9fce3c6ce 100644 --- a/crates/firma-config-loader/src/lib.rs +++ b/crates/firma-config-loader/src/lib.rs @@ -3,6 +3,12 @@ //! Resolves a single `firma.toml` from a fixed precedence list and //! reports which directory won so callers can re-base unset resource //! paths. Fail-closed: no silent fallback to an empty config. +//! +//! # Feature flags +//! +//! | name | description | default | +//! |--------|----------------------------------|---------| +//! | `clap` | Enable command-line value enums. | | mod profile; mod resolver; diff --git a/crates/firma-config-loader/src/profile.rs b/crates/firma-config-loader/src/profile.rs index 31e556d59..e83ad2a8a 100644 --- a/crates/firma-config-loader/src/profile.rs +++ b/crates/firma-config-loader/src/profile.rs @@ -4,16 +4,19 @@ #[derive(Debug, Clone, Copy, PartialEq, Eq)] #[cfg_attr(feature = "clap", derive(clap::ValueEnum))] pub enum AgentProfile { - /// General-purpose sandbox, no agent-specific defaults. - Generic, - /// `OpenAI` Codex CLI. - Codex, /// Anthropic Claude Code CLI. #[cfg_attr(feature = "clap", value(name = "claude-code", alias = "claude"))] ClaudeCode, + /// `OpenAI` Codex CLI. + Codex, /// GitHub Copilot CLI. #[cfg_attr(feature = "clap", value(name = "copilot", alias = "copilot-cli"))] Copilot, + /// General-purpose sandbox, no agent-specific defaults. + Generic, + /// Visual Studio Code desktop. + #[cfg_attr(feature = "clap", value(name = "vscode", alias = "code"))] + Vscode, } impl AgentProfile { @@ -25,6 +28,7 @@ impl AgentProfile { Self::Codex => "codex", Self::ClaudeCode => "claude-code", Self::Copilot => "copilot", + Self::Vscode => "vscode", } } @@ -36,6 +40,7 @@ impl AgentProfile { "codex" => Some(Self::Codex), "claude-code" | "claude" => Some(Self::ClaudeCode), "copilot" | "copilot-cli" => Some(Self::Copilot), + "vscode" | "code" => Some(Self::Vscode), _ => None, } } @@ -47,6 +52,7 @@ impl AgentProfile { Self::Generic | Self::ClaudeCode => "anthropic", Self::Codex => "openai", Self::Copilot => "github", + Self::Vscode => "vscode", } } @@ -60,6 +66,9 @@ impl AgentProfile { Self::Copilot => { "GitHub Copilot CLI — sets up Copilot mapping and github MITM bypass by default" } + Self::Vscode => { + "Visual Studio Code — sets up VS Code, marketplace, account, and GitHub mapping by default" + } } } } @@ -81,4 +90,20 @@ mod tests { Some(AgentProfile::Copilot) ); } + + #[test] + fn vscode_round_trips_name_alias_and_provider() { + assert_eq!(AgentProfile::Vscode.as_str(), "vscode"); + assert_eq!(AgentProfile::Vscode.provider(), "vscode"); + assert_eq!( + AgentProfile::from_name("vscode"), + Some(AgentProfile::Vscode) + ); + assert_eq!(AgentProfile::from_name("code"), Some(AgentProfile::Vscode)); + assert!( + AgentProfile::Vscode + .description() + .contains("Visual Studio Code") + ); + } } diff --git a/crates/firma-config-loader/src/resolver.rs b/crates/firma-config-loader/src/resolver.rs index 9dca3fff0..ea42f245f 100644 --- a/crates/firma-config-loader/src/resolver.rs +++ b/crates/firma-config-loader/src/resolver.rs @@ -13,9 +13,8 @@ use crate::{CONFIG_DIR_NAME, CONFIG_ENV_NAME, CONFIG_FILE_NAME, FirmaConfig}; /// `firma` command that's about to execute. /// /// Refer to [`ConfigResolver::resolve_config`] for more details. -#[derive(Debug, Default, Clone)] +#[derive(Debug, Clone, Default)] pub struct ConfigResolver { - #[cfg(feature = "test-utils")] walk_ceiling: Option, } @@ -84,15 +83,9 @@ impl ConfigResolver { /// /// # Implementation details /// - /// This method is used in end-to-end tests. - /// Each test spawns a dedicated process with its current directory - /// set to a freshly-created temporary directory. - /// We use that temporary directory as the ceiling for our search - /// to reduce the risk of test flakiness: e.g. there might be a - /// valid Firma configuration file above the temporary directory - /// folder, and we don't want tests to pick it up. + /// Callers can use this to prevent discovery from escaping an explicit + /// workspace root. #[must_use] - #[cfg(feature = "test-utils")] pub fn walk_up_to(mut self, ceiling: impl Into) -> Self { self.walk_ceiling = Some(ceiling.into()); self @@ -149,7 +142,6 @@ impl ConfigResolver { path: PathBuf::default(), reason: e.into(), })?; - #[cfg(feature = "test-utils")] let walk_ceiling = { let walk_ceiling = self.walk_ceiling.as_deref(); if walk_ceiling.is_some_and(|ceiling| !cwd.starts_with(ceiling)) { @@ -182,7 +174,6 @@ impl ConfigResolver { }); } } - #[cfg(feature = "test-utils")] if walk_ceiling.is_some_and(|ceiling| dir == ceiling) { break; } diff --git a/crates/firma-run/seccomp/vscode-local-command-v1.toml b/crates/firma-run/seccomp/vscode-local-command-v1.toml new file mode 100644 index 000000000..8b9647b89 --- /dev/null +++ b/crates/firma-run/seccomp/vscode-local-command-v1.toml @@ -0,0 +1,13 @@ +# VS Code managed seccomp baseline. It permits filesystem delete because VS +# Code extension install state uses temp files plus atomic rename for manifests. +policy_id = "vscode-local-command" +policy_version = "v1" +default_action = "allow" + +deny_actions = [ + "credential.write", +] + +source_policy_refs = [ + "custom:vscode-local-command", +] diff --git a/crates/firma-run/src/config.rs b/crates/firma-run/src/config.rs index d9819eb6f..21d518d55 100644 --- a/crates/firma-run/src/config.rs +++ b/crates/firma-run/src/config.rs @@ -877,10 +877,9 @@ fn derive_sidecar_local_exec_endpoint( } /// Decides whether the managed default seccomp policy applies to a given -/// profile/backend pair. Every recognized agent profile shares the same managed -/// baseline on the bwrap backend, so all agents enforce `filesystem.delete` and -/// `credential.write` identically (FIR-274 parity requirement). The OS gate -/// (`target_os = "linux"`) is applied separately by the caller. +/// profile/backend pair. Recognized bwrap profiles get a managed baseline; +/// profiles with atomic state stores may use a less restrictive variant. The OS +/// gate (`target_os = "linux"`) is applied separately by the caller. fn managed_seccomp_applies(profile_id: &str, backend: BackendKind) -> bool { backend == BackendKind::Bwrap && AgentProfile::from_name(profile_id).is_some() } @@ -948,17 +947,17 @@ const MANAGED_SECCOMP_POLICY: &str = include_str!("../seccomp/generic-local-comm const COPILOT_SECCOMP_POLICY: &str = include_str!("../seccomp/copilot-local-command-v1.toml"); const COPILOT_MANAGED_POLICY_FILE: &str = "copilot-local-command-v1.toml"; +/// VS Code managed seccomp baseline. Permits atomic extension manifest writes. +const VSCODE_SECCOMP_POLICY: &str = include_str!("../seccomp/vscode-local-command-v1.toml"); +const VSCODE_MANAGED_POLICY_FILE: &str = "vscode-local-command-v1.toml"; + /// Returns the embedded managed seccomp policy content and on-disk filename -/// for `profile_id`. Copilot gets a baseline that permits `filesystem.delete` -/// (`SQLite` session store); every other profile gets the strict generic baseline. +/// for `profile_id`. fn managed_policy_for_profile(profile_id: &str) -> (&'static str, &'static str) { - if matches!( - AgentProfile::from_name(profile_id), - Some(AgentProfile::Copilot) - ) { - (COPILOT_SECCOMP_POLICY, COPILOT_MANAGED_POLICY_FILE) - } else { - (MANAGED_SECCOMP_POLICY, DEFAULT_MANAGED_POLICY_FILE) + match AgentProfile::from_name(profile_id) { + Some(AgentProfile::Copilot) => (COPILOT_SECCOMP_POLICY, COPILOT_MANAGED_POLICY_FILE), + Some(AgentProfile::Vscode) => (VSCODE_SECCOMP_POLICY, VSCODE_MANAGED_POLICY_FILE), + _ => (MANAGED_SECCOMP_POLICY, DEFAULT_MANAGED_POLICY_FILE), } } @@ -1145,10 +1144,7 @@ mod tests { #[test] fn managed_seccomp_applies_to_all_recognized_bwrap_profiles() { - // FIR-274: codex and claude-code must get the same managed seccomp - // baseline as generic, so every agent enforces filesystem.delete and - // credential.write identically under bwrap. - for profile in ["generic", "codex", "claude-code"] { + for profile in ["generic", "codex", "claude-code", "copilot", "vscode"] { assert!( super::managed_seccomp_applies(profile, BackendKind::Bwrap), "managed seccomp must apply to profile '{profile}' on bwrap" @@ -1166,6 +1162,21 @@ mod tests { assert_eq!(generic_filename, "generic-local-command-v1.toml"); } + #[test] + fn vscode_managed_policy_drops_filesystem_delete() { + let (content, filename) = super::managed_policy_for_profile("vscode"); + assert_eq!(filename, "vscode-local-command-v1.toml"); + assert!(content.contains("credential.write")); + assert!(!content.contains("filesystem.delete")); + } + + #[test] + fn unknown_profile_uses_generic_managed_policy() { + let (content, filename) = super::managed_policy_for_profile("unknown-agent"); + assert_eq!(filename, "generic-local-command-v1.toml"); + assert_eq!(content, super::MANAGED_SECCOMP_POLICY); + } + #[test] fn managed_seccomp_applies_to_copilot_on_bwrap() { assert!(super::managed_seccomp_applies( @@ -1302,6 +1313,21 @@ approval_policy = "never" assert!(resolved.env_passthrough.contains("GITHUB_TOKEN")); } + #[test] + fn resolves_vscode_profile() { + let resolved = resolve_profile(&args("vscode")).unwrap(); + assert_eq!(resolved.id, "vscode"); + assert_eq!( + resolved.ca_trust_mode, + super::CaTrustMode::AppendSystemRoots + ); + assert!(resolved.use_http_proxy_sidecar); + assert_eq!( + resolved.env_set.get("FIRMA_RUN_VSCODE_SHIM"), + Some(&"true".to_string()) + ); + } + #[test] fn resolves_codex_profile_with_proxy_sidecar() { let resolved = resolve_profile(&args("codex")).unwrap_or_else(|e| panic!("{e}")); @@ -1510,6 +1536,29 @@ runtime_mode = "precompiled_only" assert_eq!(seccomp.runtime_mode, SeccompRuntimeMode::PrecompiledOnly); } + #[test] + fn managed_runtime_mode_parser_accepts_case_and_rejects_unknown_values() { + assert_eq!( + super::parse_managed_runtime_mode(" PRECOMPILED_ONLY ") + .unwrap_or_else(|error| panic!("{error}")), + SeccompRuntimeMode::PrecompiledOnly + ); + assert_eq!( + super::parse_managed_runtime_mode("compile_on_launch") + .unwrap_or_else(|error| panic!("{error}")), + SeccompRuntimeMode::CompileOnLaunch + ); + + let error = super::parse_managed_runtime_mode("eager") + .expect_err("unknown runtime mode must fail validation"); + assert!( + error + .to_string() + .contains("compile_on_launch' or 'precompiled_only"), + "unexpected error: {error}" + ); + } + #[test] fn seccomp_policy_rejects_checksum_disable() { let tmpdir = tempfile::tempdir().unwrap(); @@ -1822,4 +1871,35 @@ timeout_ms = 700 "stale policy not overwritten by embedded version" ); } + + #[test] + fn read_configured_profile_returns_run_profile_field() { + let tmpdir = tempfile::tempdir().unwrap_or_else(|e| panic!("{e}")); + let config_path = tmpdir.path().join(CONFIG_FILE_NAME); + fs::write( + &config_path, + r#" +[run] +profile = "vscode" +"#, + ) + .unwrap_or_else(|e| panic!("{e}")); + + let profile = + super::read_configured_profile(&config_path).unwrap_or_else(|error| panic!("{error}")); + + assert_eq!(profile.as_deref(), Some("vscode")); + } + + #[test] + fn read_configured_profile_returns_none_when_field_is_absent() { + let tmpdir = tempfile::tempdir().unwrap_or_else(|e| panic!("{e}")); + let config_path = tmpdir.path().join(CONFIG_FILE_NAME); + fs::write(&config_path, "[run]\n").unwrap_or_else(|e| panic!("{e}")); + + let profile = + super::read_configured_profile(&config_path).unwrap_or_else(|error| panic!("{error}")); + + assert_eq!(profile, None); + } } diff --git a/crates/firma-run/src/profile.rs b/crates/firma-run/src/profile.rs index 6d611e9a9..5f7a2e04b 100644 --- a/crates/firma-run/src/profile.rs +++ b/crates/firma-run/src/profile.rs @@ -16,8 +16,9 @@ pub(crate) fn built_in_profile(profile: &str) -> Result Some(AgentProfile::Codex) => Ok(codex_profile()), Some(AgentProfile::ClaudeCode) => Ok(claude_code_profile()), Some(AgentProfile::Copilot) => Ok(copilot_profile()), + Some(AgentProfile::Vscode) => Ok(vscode_profile()), None => Err(RunError::ConfigValidation(format!( - "unknown profile '{profile}'; supported profiles: generic, codex, claude-code, copilot" + "unknown profile '{profile}'; supported profiles: generic, codex, claude-code, copilot, vscode" ))), } } @@ -165,6 +166,29 @@ fn copilot_profile() -> ProfilePatch { base } +fn vscode_profile() -> ProfilePatch { + let mut base = generic_profile(); + base.env_set + .insert("FIRMA_RUN_PROFILE".to_string(), "vscode".to_string()); + base.env_set + .insert("FIRMA_RUN_VSCODE_SHIM".to_string(), "true".to_string()); + base.env_passthrough.extend([ + "DISPLAY".to_string(), + "WAYLAND_DISPLAY".to_string(), + "XAUTHORITY".to_string(), + ]); + if std::path::Path::new("/tmp/.X11-unix").exists() { + base.mounts.push(MountPatch { + source: PathBuf::from("/tmp/.X11-unix"), + target: PathBuf::from("/tmp/.X11-unix"), + read_only: false, + }); + } + base.ca_trust_mode = Some(CaTrustMode::AppendSystemRoots); + base.use_http_proxy_sidecar = true; + base +} + #[cfg(test)] mod tests { use super::*; @@ -180,6 +204,28 @@ mod tests { ); } + #[test] + fn vscode_profile_sets_append_ca_and_profile_env() { + let patch = built_in_profile("vscode").unwrap(); + assert_eq!(patch.ca_trust_mode, Some(CaTrustMode::AppendSystemRoots)); + assert!(patch.use_http_proxy_sidecar); + assert_eq!( + patch.env_set.get("FIRMA_RUN_PROFILE"), + Some(&"vscode".to_string()) + ); + assert_eq!( + patch.env_set.get("FIRMA_RUN_VSCODE_SHIM"), + Some(&"true".to_string()) + ); + assert!(patch.env_passthrough.contains(&"DISPLAY".to_string())); + assert!( + patch + .env_passthrough + .contains(&"WAYLAND_DISPLAY".to_string()) + ); + assert!(patch.env_passthrough.contains(&"XAUTHORITY".to_string())); + } + #[test] fn codex_policy_workspace_write_includes_network_access() { let policy = codex_executable_policy(false); diff --git a/crates/firma/src/args/config.rs b/crates/firma/src/args/config.rs index cbb577c92..807c76c95 100644 --- a/crates/firma/src/args/config.rs +++ b/crates/firma/src/args/config.rs @@ -160,25 +160,41 @@ impl Posture { pub enum Mapping { /// Anthropic Claude API (api.anthropic.com). Anthropic, - /// `OpenAI` API (`api.openai.com`). - Openai, + /// crates.io Rust package registry. + Cargo, + /// GitHub Copilot CLI hosts — CONNECT-only, github hosts MITM-bypassed. + Copilot, /// GitHub REST API — requires MITM for per-endpoint classification. Github, /// Gmail REST API — requires MITM for per-endpoint classification. Gmail, /// npm registry (registry.npmjs.org). Npm, + /// `OpenAI` API (`api.openai.com`). + Openai, /// `PyPI` (`pypi.org`, `files.pythonhosted.org`). Pypi, - /// crates.io Rust package registry. - Cargo, /// Stripe REST API (`api.stripe.com`). Stripe, - /// GitHub Copilot CLI hosts — CONNECT-only, github hosts MITM-bypassed. - Copilot, + /// Visual Studio Code core, marketplace, account, and embedded agent hosts. + Vscode, } impl Mapping { + /// Stable display order for `firma config` mapping choices. + pub const CONFIG_CHOICES: &'static [Self] = &[ + Self::Anthropic, + Self::Cargo, + Self::Copilot, + Self::Github, + Self::Gmail, + Self::Npm, + Self::Openai, + Self::Pypi, + Self::Stripe, + Self::Vscode, + ]; + /// Mapping name used as the file stem (`mappings/{name}.toml`). pub fn as_str(&self) -> &'static str { match self { @@ -191,6 +207,7 @@ impl Mapping { Self::Cargo => "cargo", Self::Stripe => "stripe", Self::Copilot => "copilot", + Self::Vscode => "vscode", } } @@ -212,6 +229,9 @@ impl Mapping { "api.stripe.com — Stripe REST API (MITM optional — check SDK cert pinning first)" } Self::Copilot => "GitHub Copilot CLI hosts (CONNECT only; github MITM-bypassed)", + Self::Vscode => { + "Visual Studio Code hosts (CONNECT only; core services, marketplace, accounts, GitHub)" + } } } @@ -245,6 +265,7 @@ impl Mapping { Self::Cargo => include_str!("../../templates/mappings/cargo.toml"), Self::Stripe => include_str!("../../templates/mappings/stripe.toml"), Self::Copilot => include_str!("../../templates/mappings/copilot.toml"), + Self::Vscode => include_str!("../../templates/mappings/vscode.toml"), } } } @@ -267,4 +288,39 @@ mod tests { .contains("githubcopilot.com") ); } + + #[test] + fn vscode_mapping_is_connect_only_and_excludes_embedded_agent_hosts() { + assert_eq!(Mapping::Vscode.as_str(), "vscode"); + assert!(Mapping::Vscode.mitm_hosts().is_empty()); + assert!(Mapping::Vscode.mitm_bypass_hosts().is_empty()); + let content = Mapping::Vscode.static_content(); + assert!(content.contains("update.code.visualstudio.com")); + assert!(content.contains("marketplace.visualstudio.com")); + assert!(!content.contains("api.githubcopilot.com")); + assert!(!content.contains("*.openai.com")); + assert!(!content.contains("*.anthropic.com")); + } + + #[test] + fn vscode_mapping_covers_github_sign_in_hosts() { + let content = Mapping::Vscode.static_content(); + for host in [ + "github.com", + "default.exp-tas.com", + "*.githubusercontent.com", + "*.ghe.com", + "accounts.google.com", + "*.gstatic.com", + "*.googleusercontent.com", + "appleid.apple.com", + "idmsa.apple.com", + "*.cdn-apple.com", + ] { + assert!( + content.contains(&format!("host = \"{host}\"")), + "vscode mapping should include {host}" + ); + } + } } diff --git a/crates/firma/src/services/config.rs b/crates/firma/src/services/config.rs index ba4b9e07a..a7815a511 100644 --- a/crates/firma/src/services/config.rs +++ b/crates/firma/src/services/config.rs @@ -909,7 +909,7 @@ fn prompt_mappings_with_default( theme: &ColorfulTheme, default: Option<&[Mapping]>, ) -> Result> { - let variants = Mapping::value_variants(); + let variants = Mapping::CONFIG_CHOICES; let items: Vec = variants .iter() .map(|m| format!("{:<12} {}", m.as_str(), m.description())) @@ -967,7 +967,7 @@ fn collect_profile( } fn prompt_profile_with_default(theme: &ColorfulTheme, default: AgentProfile) -> Result { - let variants = AgentProfile::value_variants(); + let variants = config_profile_choices(); let items: Vec = variants .iter() .map(|p| format!("{:<16} {}", p.as_str(), p.description())) @@ -989,10 +989,21 @@ fn prompt_profile_with_default(theme: &ColorfulTheme, default: AgentProfile) -> Ok(chosen) } +fn config_profile_choices() -> &'static [AgentProfile] { + &[ + AgentProfile::ClaudeCode, + AgentProfile::Codex, + AgentProfile::Copilot, + AgentProfile::Generic, + AgentProfile::Vscode, + ] +} + fn profile_default_mappings(profile: &str) -> Vec { match profile { "codex" => vec![Mapping::Openai, Mapping::Github], "copilot" => vec![Mapping::Copilot], + "vscode" => vec![Mapping::Vscode], _ => vec![Mapping::Anthropic], } } @@ -1258,6 +1269,7 @@ fn provider_to_mappings(provider: &str) -> Vec { match provider { "openai" => vec![Mapping::Openai], "github" => vec![Mapping::Copilot], + "vscode" => vec![Mapping::Vscode], _ => vec![Mapping::Anthropic], } } @@ -1270,6 +1282,7 @@ fn provider_to_profile(provider: &str) -> String { AgentProfile::ClaudeCode.as_str().to_string() } p if AgentProfile::Copilot.provider() == p => AgentProfile::Copilot.as_str().to_string(), + p if AgentProfile::Vscode.provider() == p => AgentProfile::Vscode.as_str().to_string(), _ => AgentProfile::Generic.as_str().to_string(), } } @@ -1312,6 +1325,58 @@ mod tests { ); } + #[test] + fn vscode_profile_scaffolds_vscode_mapping() { + assert_eq!( + super::profile_default_mappings("vscode") + .iter() + .map(Mapping::as_str) + .collect::>(), + vec!["vscode"] + ); + assert_eq!(super::provider_to_profile("vscode"), "vscode"); + assert_eq!( + super::provider_to_mappings("vscode") + .iter() + .map(Mapping::as_str) + .collect::>(), + vec!["vscode"] + ); + } + + #[test] + fn config_profile_choices_are_alphabetical() { + assert_eq!( + config_profile_choices() + .iter() + .map(|profile| profile.as_str()) + .collect::>(), + vec!["claude-code", "codex", "copilot", "generic", "vscode"] + ); + } + + #[test] + fn config_mapping_choices_are_alphabetical() { + assert_eq!( + Mapping::CONFIG_CHOICES + .iter() + .map(Mapping::as_str) + .collect::>(), + vec![ + "anthropic", + "cargo", + "copilot", + "github", + "gmail", + "npm", + "openai", + "pypi", + "stripe", + "vscode" + ] + ); + } + fn make_files( posture: &Posture, mappings: &[Mapping], @@ -1390,6 +1455,7 @@ mod tests { Mapping::Pypi, Mapping::Cargo, Mapping::Stripe, + Mapping::Vscode, ]; make_files(&Posture::Dev, &all_mappings, &[]); } @@ -1634,6 +1700,44 @@ mod tests { ); } + #[test] + fn vscode_mapping_has_core_connect_rules_without_embedded_agent_hosts() { + let rules = parse_rules(Mapping::Vscode.static_content()).rules; + for host in [ + "update.code.visualstudio.com", + "marketplace.visualstudio.com", + "*.vscode-unpkg.net", + "default.exp-tas.com", + "github.com", + "login.microsoftonline.com", + ] { + assert!( + rules.iter().any(|rule| rule.host == host + && rule.method.as_deref() == Some("CONNECT") + && rule.action_class == "communication.external.send"), + "expected {host} CONNECT rule" + ); + } + assert!( + rules.iter().all(|rule| rule.path.is_none()), + "vscode mapping must stay CONNECT-level in v1" + ); + for agent_host in [ + "api.githubcopilot.com", + "api.individual.githubcopilot.com", + "*.openai.com", + "chatgpt.com", + "*.anthropic.com", + "*.claude.com", + "*.claude.ai", + ] { + assert!( + rules.iter().all(|rule| rule.host != agent_host), + "agent host {agent_host} should be selected through its own mapping" + ); + } + } + #[test] fn all_mapping_files_parse_and_validate() { for m in Mapping::iter() { diff --git a/crates/firma/src/services/policy.rs b/crates/firma/src/services/policy.rs index 41ce84cea..719800962 100644 --- a/crates/firma/src/services/policy.rs +++ b/crates/firma/src/services/policy.rs @@ -24,7 +24,7 @@ pub fn list() -> ExitCode { println!(" {:<28} {}", p.file_name(), p.description()); } println!("\nMappings (--mapping, repeatable):\n"); - for m in Mapping::value_variants() { + for m in Mapping::CONFIG_CHOICES { println!(" {:<28} {}", m.as_str(), m.description()); } println!(); diff --git a/crates/firma/templates/mappings/vscode.toml b/crates/firma/templates/mappings/vscode.toml new file mode 100644 index 000000000..b36082048 --- /dev/null +++ b/crates/firma/templates/mappings/vscode.toml @@ -0,0 +1,168 @@ +# Visual Studio Code mapping — CONNECT-level classification for VS Code core +# services, marketplace, account sync, and common embedded agent extensions. +# This mapping intentionally avoids HTTPS MITM in v1. + +# VS Code core update, docs, web, and experiments. +[[rules]] +method = "CONNECT" +host = "update.code.visualstudio.com" +action_class = "communication.external.send" + +[[rules]] +method = "CONNECT" +host = "code.visualstudio.com" +action_class = "communication.external.send" + +[[rules]] +method = "CONNECT" +host = "*.code.visualstudio.com" +action_class = "communication.external.send" + +[[rules]] +method = "CONNECT" +host = "vscode.dev" +action_class = "communication.external.send" + +[[rules]] +method = "CONNECT" +host = "*.vscode.dev" +action_class = "communication.external.send" + +[[rules]] +method = "CONNECT" +host = "az764295.vo.msecnd.net" +action_class = "communication.external.send" + +[[rules]] +method = "CONNECT" +host = "vscodeexperiments.azureedge.net" +action_class = "communication.external.send" + +[[rules]] +method = "CONNECT" +host = "default.exp-tas.com" +action_class = "communication.external.send" + +# Marketplace, gallery, and web extension assets. +[[rules]] +method = "CONNECT" +host = "marketplace.visualstudio.com" +action_class = "communication.external.send" + +[[rules]] +method = "CONNECT" +host = "*.gallerycdn.vsassets.io" +action_class = "communication.external.send" + +[[rules]] +method = "CONNECT" +host = "*.gallery.vsassets.io" +action_class = "communication.external.send" + +[[rules]] +method = "CONNECT" +host = "*.vscode-unpkg.net" +action_class = "communication.external.send" + +[[rules]] +method = "CONNECT" +host = "*.vscode-cdn.net" +action_class = "communication.external.send" + +# Settings Sync and Microsoft account flows. +[[rules]] +method = "CONNECT" +host = "vscode-sync.trafficmanager.net" +action_class = "communication.external.send" + +[[rules]] +method = "CONNECT" +host = "*.vscode-sync.trafficmanager.net" +action_class = "communication.external.send" + +[[rules]] +method = "CONNECT" +host = "login.microsoftonline.com" +action_class = "communication.external.send" + +[[rules]] +method = "CONNECT" +host = "login.live.com" +action_class = "communication.external.send" + +[[rules]] +method = "CONNECT" +host = "*.microsoft.com" +action_class = "communication.external.send" + +[[rules]] +method = "CONNECT" +host = "*.msauth.net" +action_class = "communication.external.send" + +[[rules]] +method = "CONNECT" +host = "*.msftauth.net" +action_class = "communication.external.send" + +# GitHub account and repository flows. +[[rules]] +method = "CONNECT" +host = "github.com" +action_class = "communication.external.send" + +[[rules]] +method = "CONNECT" +host = "*.github.com" +action_class = "communication.external.send" + +[[rules]] +method = "CONNECT" +host = "api.github.com" +action_class = "communication.external.send" + +[[rules]] +method = "CONNECT" +host = "uploads.github.com" +action_class = "communication.external.send" + +[[rules]] +method = "CONNECT" +host = "*.githubusercontent.com" +action_class = "communication.external.send" + +[[rules]] +method = "CONNECT" +host = "*.ghe.com" +action_class = "communication.external.send" + +# GitHub social sign-in providers. +[[rules]] +method = "CONNECT" +host = "accounts.google.com" +action_class = "communication.external.send" + +[[rules]] +method = "CONNECT" +host = "*.gstatic.com" +action_class = "communication.external.send" + +[[rules]] +method = "CONNECT" +host = "*.googleusercontent.com" +action_class = "communication.external.send" + +[[rules]] +method = "CONNECT" +host = "appleid.apple.com" +action_class = "communication.external.send" + +[[rules]] +method = "CONNECT" +host = "idmsa.apple.com" +action_class = "communication.external.send" + +[[rules]] +method = "CONNECT" +host = "*.cdn-apple.com" +action_class = "communication.external.send" diff --git a/crates/firma/tests/run_implicit_init_prompt.rs b/crates/firma/tests/run_implicit_init_prompt.rs index 4b03186cb..e15dfbfa5 100644 --- a/crates/firma/tests/run_implicit_init_prompt.rs +++ b/crates/firma/tests/run_implicit_init_prompt.rs @@ -22,16 +22,20 @@ fn firma_bin() -> PathBuf { PathBuf::from(env!("CARGO_BIN_EXE_firma")) } +fn isolated_firma_command(cwd: &std::path::Path) -> Command { + let mut command = Command::new(firma_bin()); + command.current_dir(cwd).env_remove("FIRMA_CONFIG"); + command +} + /// Run `firma run codex` in `cwd` with stdin redirected from /dev/null /// (Unix) or NUL (Windows) so the prompt sees a non-TTY. fn run_non_tty(cwd: &std::path::Path) -> std::process::Output { - Command::new(firma_bin()) - .current_dir(cwd) + isolated_firma_command(cwd) .args(["run", "codex"]) .stdin(Stdio::null()) .stdout(Stdio::piped()) .stderr(Stdio::piped()) - .env_remove("FIRMA_CONFIG") .output() .expect("spawn firma run") } @@ -66,13 +70,11 @@ fn no_autostart_without_firma_toml_errors_with_hint() { let tmp = tempfile::tempdir().expect("tmpdir"); let cwd = tmp.path(); - let out = Command::new(firma_bin()) - .current_dir(cwd) + let out = isolated_firma_command(cwd) .args(["run", "--no-autostart", "codex"]) .stdin(Stdio::null()) .stdout(Stdio::piped()) .stderr(Stdio::piped()) - .env_remove("FIRMA_CONFIG") .output() .expect("spawn firma run"); @@ -100,13 +102,11 @@ fn copilot_command_auto_selects_profile_without_flag() { let tmp = tempfile::tempdir().expect("tmpdir"); let cwd = tmp.path(); - let out = Command::new(firma_bin()) - .current_dir(cwd) + let out = isolated_firma_command(cwd) .args(["run", "--no-autostart", "copilot"]) .stdin(Stdio::null()) .stdout(Stdio::piped()) .stderr(Stdio::piped()) - .env_remove("FIRMA_CONFIG") .output() .expect("spawn firma run"); @@ -131,15 +131,13 @@ fn explicit_missing_run_config_does_not_scaffold() { let cwd = tmp.path(); let missing = cwd.join("missing-run.toml"); - let out = Command::new(firma_bin()) - .current_dir(cwd) + let out = isolated_firma_command(cwd) .args(["run", "--config"]) .arg(&missing) .arg("codex") .stdin(Stdio::null()) .stdout(Stdio::piped()) .stderr(Stdio::piped()) - .env_remove("FIRMA_CONFIG") .output() .expect("spawn firma run"); @@ -155,8 +153,7 @@ fn remote_authority_does_not_implicit_scaffold_without_trust_material() { let tmp = tempfile::tempdir().expect("tmpdir"); let cwd = tmp.path(); - let out = Command::new(firma_bin()) - .current_dir(cwd) + let out = isolated_firma_command(cwd) .args([ "run", "--authority", @@ -166,7 +163,6 @@ fn remote_authority_does_not_implicit_scaffold_without_trust_material() { .stdin(Stdio::null()) .stdout(Stdio::piped()) .stderr(Stdio::piped()) - .env_remove("FIRMA_CONFIG") .output() .expect("spawn firma run"); From e640aa905056883c095914206aef352bed0c9190 Mon Sep 17 00:00:00 2001 From: veeso Date: Thu, 9 Jul 2026 16:07:23 +0200 Subject: [PATCH 2/4] feat(vscode): add launch shim runtime --- crates/firma-run/src/runtime.rs | 741 +++++++++++++++++++++++++++++++- 1 file changed, 735 insertions(+), 6 deletions(-) diff --git a/crates/firma-run/src/runtime.rs b/crates/firma-run/src/runtime.rs index e2365a2c1..bba379698 100644 --- a/crates/firma-run/src/runtime.rs +++ b/crates/firma-run/src/runtime.rs @@ -1,14 +1,15 @@ use std::collections::BTreeMap; +use std::ffi::OsStr; use std::path::Path; use std::path::PathBuf; use std::time::Duration; use serde::Serialize; -use crate::backend::{LaunchSpec, PrepareRequest, build_backend}; +use crate::backend::{LaunchSpec, PrepareRequest, SandboxHandle, build_backend}; use crate::capability::CapabilityLeaseManager; use crate::config::{ - CaTrustMode, CapabilitySource, ResolvedProfile, SidecarEndpoint, resolve_profile, + CaTrustMode, CapabilitySource, MountSpec, ResolvedProfile, SidecarEndpoint, resolve_profile, }; use crate::error::RunError; use crate::identity::RunIdentity; @@ -238,7 +239,7 @@ pub fn execute_run(args: &RunInput) -> Result { "resolved managed static seccomp artifact" ); } - let env = build_execution_env( + let mut env = build_execution_env( &profile, &identity, &lease, @@ -246,7 +247,7 @@ pub fn execute_run(args: &RunInput) -> Result { network_runtime.env_overrides(), ); - let executable = args + let mut executable = args .command .first() .cloned() @@ -256,12 +257,35 @@ pub fn execute_run(args: &RunInput) -> Result { &executable, args.command.iter().skip(1).cloned().collect(), ); - let launch_args = + let mut launch_args = maybe_apply_claude_settings(handle_ref, &profile, &executable, launch_args)?; + let vscode_state_dir = if should_apply_vscode_shim(&profile, &executable) { + let state_dir = + resolve_vscode_state_dir(args.config.as_deref(), &handle_ref.runtime_dir)?; + let prepared = prepare_vscode_shim( + &handle_ref.runtime_dir, + &state_dir, + &executable, + launch_args, + &mut env, + std::env::var_os("PATH").as_deref(), + )?; + executable = prepared.executable.display().to_string(); + launch_args = prepared.args; + Some(state_dir) + } else { + None + }; if let Some(mediator) = &profile.sidecar_local_exec { let canonical = resolve_governed_executable(mediator, &executable)?; enforce_local_command_governance(mediator, &identity, &canonical, &launch_args)?; } + if let Some(state_dir) = vscode_state_dir { + let handle_mut = handle + .as_mut() + .ok_or_else(|| RunError::Internal("sandbox handle missing".to_string()))?; + ensure_vscode_state_mount(handle_mut, &state_dir); + } let launch = LaunchSpec { executable, args: launch_args, @@ -272,7 +296,12 @@ pub fn execute_run(args: &RunInput) -> Result { identity_mode: profile.identity_mode, }; - let child = backend.start_agent(handle_ref, &launch)?; + let child = { + let handle_ref = handle + .as_ref() + .ok_or_else(|| RunError::Internal("sandbox handle missing".to_string()))?; + backend.start_agent(handle_ref, &launch)? + }; wait_with_signal_forwarding(child, backend.kind()) })(); @@ -429,6 +458,474 @@ fn config_item_matches_key(item: &str, key: &str) -> bool { item.split_once('=').is_some_and(|(k, _)| k.trim() == key) } +fn should_apply_vscode_shim(profile: &ResolvedProfile, executable: &str) -> bool { + profile.id == "vscode" + && std::path::Path::new(executable) + .file_name() + .and_then(std::ffi::OsStr::to_str) + .is_some_and(is_vscode_code_launcher) +} + +fn is_vscode_code_launcher(name: &str) -> bool { + name == "code" || name.eq_ignore_ascii_case("code.cmd") || name.eq_ignore_ascii_case("code.exe") +} + +#[derive(Debug)] +struct PreparedVscodeShim { + executable: PathBuf, + args: Vec, +} + +fn prepare_vscode_shim( + runtime_dir: &Path, + state_dir: &Path, + executable: &str, + args: Vec, + env: &mut BTreeMap, + host_path: Option<&OsStr>, +) -> Result { + reject_vscode_conflicting_args(&args)?; + let real_code = resolve_host_executable(executable, host_path)?; + let user_data_dir = state_dir.join("user-data"); + let extensions_dir = state_dir.join("extensions"); + let desktop_runtime_dir = runtime_dir.join("vscode").join("xdg-runtime"); + let shim_dir = runtime_dir.join("bin"); + let shim_path = vscode_shim_path(&shim_dir); + + std::fs::create_dir_all(&user_data_dir).map_err(|error| { + RunError::Internal(format!( + "create VS Code user-data dir {}: {error}", + user_data_dir.display() + )) + })?; + std::fs::create_dir_all(&extensions_dir).map_err(|error| { + RunError::Internal(format!( + "create VS Code extensions dir {}: {error}", + extensions_dir.display() + )) + })?; + create_private_dir(&desktop_runtime_dir, "VS Code desktop runtime dir")?; + std::fs::create_dir_all(&shim_dir).map_err(|error| { + RunError::Internal(format!( + "create VS Code shim dir {}: {error}", + shim_dir.display() + )) + })?; + + let script = vscode_shim_script(&real_code); + std::fs::write(&shim_path, script).map_err(|error| { + RunError::Internal(format!( + "write VS Code shim {}: {error}", + shim_path.display() + )) + })?; + #[cfg(unix)] + set_executable_permissions(&shim_path)?; + seed_vscode_user_settings(&user_data_dir)?; + + env.insert( + "FIRMA_RUN_VSCODE_USER_DATA_DIR".to_string(), + user_data_dir.display().to_string(), + ); + env.insert( + "FIRMA_RUN_VSCODE_EXTENSIONS_DIR".to_string(), + extensions_dir.display().to_string(), + ); + env.insert( + "TMPDIR".to_string(), + desktop_runtime_dir.display().to_string(), + ); + env.insert( + "XDG_RUNTIME_DIR".to_string(), + desktop_runtime_dir.display().to_string(), + ); + #[cfg(unix)] + configure_vscode_desktop_sockets(&desktop_runtime_dir, env)?; + prepend_path(env, &shim_dir, host_path); + + tracing::info!( + shim = %shim_path.display(), + real_code = %real_code.display(), + "prepared VS Code runtime shim" + ); + + Ok(PreparedVscodeShim { + executable: shim_path, + args, + }) +} + +fn resolve_vscode_state_dir( + config_path: Option<&Path>, + runtime_dir: &Path, +) -> Result { + let state_dir = config_path + .and_then(Path::parent) + .filter(|parent| !parent.as_os_str().is_empty()) + .map_or_else( + || runtime_dir.join("vscode"), + |config_dir| config_dir.join("vscode"), + ); + std::path::absolute(&state_dir).map_err(|error| { + RunError::Internal(format!( + "resolve VS Code state dir {}: {error}", + state_dir.display() + )) + }) +} + +fn ensure_vscode_state_mount(handle: &mut SandboxHandle, state_dir: &Path) { + let already_mounted = handle + .mounts + .iter() + .any(|mount| mount.source == state_dir && mount.target == state_dir && !mount.read_only); + if already_mounted { + return; + } + handle.mounts.push(MountSpec { + source: state_dir.to_path_buf(), + target: state_dir.to_path_buf(), + read_only: false, + }); +} + +fn seed_vscode_user_settings(user_data_dir: &Path) -> Result<(), RunError> { + let user_settings_dir = user_data_dir.join("User"); + std::fs::create_dir_all(&user_settings_dir).map_err(|error| { + RunError::Internal(format!( + "create VS Code settings dir {}: {error}", + user_settings_dir.display() + )) + })?; + let settings_path = user_settings_dir.join("settings.json"); + let mut settings = if settings_path.exists() { + let bytes = std::fs::read(&settings_path).map_err(|error| { + RunError::Internal(format!( + "read VS Code settings file {}: {error}", + settings_path.display() + )) + })?; + serde_json::from_slice::>(&bytes).map_err( + |error| { + RunError::Internal(format!( + "parse VS Code settings file {}: {error}", + settings_path.display() + )) + }, + )? + } else { + serde_json::Map::new() + }; + settings.insert( + "github-authentication.preferDeviceCodeFlow".to_string(), + serde_json::Value::Bool(true), + ); + let serialized = serde_json::to_vec_pretty(&settings).map_err(|error| { + RunError::Internal(format!( + "serialize VS Code settings file {}: {error}", + settings_path.display() + )) + })?; + std::fs::write(&settings_path, serialized).map_err(|error| { + RunError::Internal(format!( + "write VS Code settings file {}: {error}", + settings_path.display() + )) + }) +} + +fn reject_vscode_conflicting_args(args: &[String]) -> Result<(), RunError> { + for arg in args { + let conflict = matches!( + arg.as_str(), + "--reuse-window" | "-r" | "--user-data-dir" | "--extensions-dir" + ) || arg.starts_with("--user-data-dir=") + || arg.starts_with("--extensions-dir="); + if conflict { + return Err(RunError::ConfigValidation(format!( + "vscode profile manages VS Code process lifetime and isolated state; remove conflicting argument '{arg}'" + ))); + } + } + Ok(()) +} + +fn create_private_dir(path: &Path, label: &str) -> Result<(), RunError> { + std::fs::create_dir_all(path).map_err(|error| { + RunError::Internal(format!("create {label} {}: {error}", path.display())) + })?; + #[cfg(unix)] + set_private_dir_permissions(path)?; + Ok(()) +} + +#[cfg(unix)] +fn set_private_dir_permissions(path: &Path) -> Result<(), RunError> { + use std::os::unix::fs::PermissionsExt as _; + + let mut permissions = std::fs::metadata(path) + .map_err(|error| RunError::Internal(format!("stat {}: {error}", path.display())))? + .permissions(); + permissions.set_mode(0o700); + std::fs::set_permissions(path, permissions) + .map_err(|error| RunError::Internal(format!("chmod {}: {error}", path.display()))) +} + +#[cfg(unix)] +fn configure_vscode_desktop_sockets( + desktop_runtime_dir: &Path, + env: &mut BTreeMap, +) -> Result<(), RunError> { + configure_vscode_wayland_socket(desktop_runtime_dir, env)?; + configure_vscode_dbus_socket(desktop_runtime_dir, env)?; + Ok(()) +} + +#[cfg(unix)] +fn configure_vscode_wayland_socket( + desktop_runtime_dir: &Path, + env: &BTreeMap, +) -> Result<(), RunError> { + let Some(wayland_display) = env.get("WAYLAND_DISPLAY").cloned() else { + tracing::debug!("VS Code Wayland socket not configured because WAYLAND_DISPLAY is unset"); + return Ok(()); + }; + let display_path = Path::new(&wayland_display); + if display_path.is_absolute() { + tracing::debug!( + wayland_display = %wayland_display, + "VS Code Wayland socket not configured because WAYLAND_DISPLAY is already absolute" + ); + return Ok(()); + } + let Some(host_runtime_dir) = std::env::var_os("XDG_RUNTIME_DIR") else { + tracing::debug!( + wayland_display = %wayland_display, + "VS Code Wayland socket not configured because host XDG_RUNTIME_DIR is unset" + ); + return Ok(()); + }; + let source = PathBuf::from(host_runtime_dir).join(&wayland_display); + if source.exists() { + let target = desktop_runtime_dir.join(&wayland_display); + replace_symlink(&source, &target)?; + tracing::debug!( + source = %source.display(), + target = %target.display(), + "VS Code Wayland socket linked into runtime" + ); + } else { + tracing::debug!( + source = %source.display(), + "VS Code Wayland socket not configured because host socket is missing" + ); + } + Ok(()) +} + +#[cfg(unix)] +fn configure_vscode_dbus_socket( + desktop_runtime_dir: &Path, + env: &mut BTreeMap, +) -> Result<(), RunError> { + let Some(address) = std::env::var("DBUS_SESSION_BUS_ADDRESS").ok() else { + tracing::debug!( + "VS Code D-Bus socket not configured because DBUS_SESSION_BUS_ADDRESS is unset" + ); + return Ok(()); + }; + let Some(path) = address.strip_prefix("unix:path=") else { + tracing::debug!( + "VS Code D-Bus socket not configured because DBUS_SESSION_BUS_ADDRESS is not a unix:path address" + ); + return Ok(()); + }; + let source = PathBuf::from(path); + if source.exists() { + let target = desktop_runtime_dir.join("bus"); + replace_symlink(&source, &target)?; + env.insert( + "DBUS_SESSION_BUS_ADDRESS".to_string(), + format!("unix:path={}", target.display()), + ); + tracing::debug!( + source = %source.display(), + target = %target.display(), + "VS Code D-Bus socket linked into runtime" + ); + } else { + tracing::debug!( + source = %source.display(), + "VS Code D-Bus socket not configured because host socket is missing" + ); + } + Ok(()) +} + +#[cfg(unix)] +fn replace_symlink(source: &Path, target: &Path) -> Result<(), RunError> { + match std::fs::remove_file(target) { + Ok(()) => {} + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => { + return Err(RunError::Internal(format!( + "remove stale VS Code desktop socket link {}: {error}", + target.display() + ))); + } + } + std::os::unix::fs::symlink(source, target).map_err(|error| { + RunError::Internal(format!( + "link VS Code desktop socket {} -> {}: {error}", + target.display(), + source.display() + )) + }) +} + +fn resolve_host_executable( + executable: &str, + host_path: Option<&OsStr>, +) -> Result { + let candidate = PathBuf::from(executable); + if candidate + .parent() + .is_some_and(|parent| !parent.as_os_str().is_empty()) + { + return require_file(candidate, executable); + } + + let path_value = host_path + .map(std::ffi::OsString::from) + .or_else(|| std::env::var_os("PATH")) + .ok_or_else(|| { + RunError::ConfigValidation(format!( + "cannot resolve executable '{executable}' because host PATH is not set" + )) + })?; + for dir in std::env::split_paths(&path_value) { + for candidate in executable_search_candidates(&dir, executable) { + if candidate.is_file() { + return Ok(candidate); + } + } + } + Err(RunError::ConfigValidation(format!( + "cannot resolve executable '{executable}' on host PATH" + ))) +} + +#[cfg(windows)] +fn executable_search_candidates(dir: &Path, executable: &str) -> Vec { + let direct = dir.join(executable); + if Path::new(executable).extension().is_some() { + return vec![direct]; + } + + let mut candidates = vec![direct]; + let path_ext = std::env::var_os("PATHEXT").map_or_else( + || ".COM;.EXE;.BAT;.CMD".to_string(), + |value| value.to_string_lossy().into_owned(), + ); + candidates.extend( + path_ext + .split(';') + .filter(|extension| !extension.is_empty()) + .map(|extension| dir.join(format!("{executable}{extension}"))), + ); + candidates +} + +#[cfg(not(windows))] +fn executable_search_candidates(dir: &Path, executable: &str) -> Vec { + vec![dir.join(executable)] +} + +fn require_file(candidate: PathBuf, executable: &str) -> Result { + if candidate.is_file() { + Ok(candidate) + } else { + Err(RunError::ConfigValidation(format!( + "cannot resolve executable '{executable}' at {}", + candidate.display() + ))) + } +} + +#[cfg(windows)] +fn vscode_shim_path(shim_dir: &Path) -> PathBuf { + shim_dir.join("code.cmd") +} + +#[cfg(not(windows))] +fn vscode_shim_path(shim_dir: &Path) -> PathBuf { + shim_dir.join("code") +} + +#[cfg(windows)] +fn vscode_shim_script(real_code: &Path) -> String { + let quoted_real_code = batch_quote(&real_code.display().to_string()); + format!( + "@echo off\r\n\ + setlocal\r\n\ + if \"%FIRMA_RUN_VSCODE_USER_DATA_DIR%\"==\"\" exit /b 2\r\n\ + if \"%FIRMA_RUN_VSCODE_EXTENSIONS_DIR%\"==\"\" exit /b 2\r\n\ + call {quoted_real_code} --no-sandbox --wait --new-window \ + --user-data-dir \"%FIRMA_RUN_VSCODE_USER_DATA_DIR%\" \ + --extensions-dir \"%FIRMA_RUN_VSCODE_EXTENSIONS_DIR%\" %*\r\n\ + exit /b %ERRORLEVEL%\r\n" + ) +} + +#[cfg(not(windows))] +fn vscode_shim_script(real_code: &Path) -> String { + let quoted_real_code = shell_single_quote(&real_code.display().to_string()); + format!( + "#!/bin/sh\n\ + set -eu\n\ + exec {quoted_real_code} --no-sandbox --wait --new-window \ + --user-data-dir \"${{FIRMA_RUN_VSCODE_USER_DATA_DIR:?}}\" \ + --extensions-dir \"${{FIRMA_RUN_VSCODE_EXTENSIONS_DIR:?}}\" \"$@\"\n" + ) +} + +#[cfg(windows)] +fn batch_quote(value: &str) -> String { + format!("\"{}\"", value.replace('"', "\"\"")) +} + +#[cfg(not(windows))] +fn shell_single_quote(value: &str) -> String { + format!("'{}'", value.replace('\'', "'\\''")) +} + +fn prepend_path(env: &mut BTreeMap, shim_dir: &Path, host_path: Option<&OsStr>) { + let previous = env + .get("PATH") + .cloned() + .or_else(|| host_path.map(|value| value.to_string_lossy().into_owned())) + .unwrap_or_default(); + let separator = if cfg!(windows) { ";" } else { ":" }; + let next = if previous.is_empty() { + shim_dir.display().to_string() + } else { + format!("{}{separator}{previous}", shim_dir.display()) + }; + env.insert("PATH".to_string(), next); +} + +#[cfg(unix)] +fn set_executable_permissions(path: &Path) -> Result<(), RunError> { + use std::os::unix::fs::PermissionsExt as _; + + let mut permissions = std::fs::metadata(path) + .map_err(|error| RunError::Internal(format!("stat {}: {error}", path.display())))? + .permissions(); + permissions.set_mode(0o700); + std::fs::set_permissions(path, permissions) + .map_err(|error| RunError::Internal(format!("chmod {}: {error}", path.display()))) +} + /// Resolve `executable` to its canonical UTF-8 path, enforce the configured /// allowlist policy, and return the canonical string. /// @@ -1301,6 +1798,238 @@ mod tests { ); } + #[test] + fn vscode_shim_creates_runtime_wrapper_and_prepends_path() { + let tmp = tempfile::tempdir().unwrap_or_else(|e| panic!("{e}")); + let host_bin = tmp.path().join("host-bin"); + fs::create_dir(&host_bin).unwrap_or_else(|e| panic!("{e}")); + let real_code = host_bin.join("code"); + fs::write(&real_code, "#!/bin/sh\nexit 0\n").unwrap_or_else(|e| panic!("{e}")); + + let mut env = BTreeMap::from([("PATH".to_string(), host_bin.display().to_string())]); + let state_dir = tmp.path().join(".firma").join("vscode"); + let prepared = super::prepare_vscode_shim( + tmp.path(), + &state_dir, + "code", + vec![".".to_string()], + &mut env, + Some(host_bin.as_os_str()), + ) + .unwrap_or_else(|e| panic!("{e}")); + + let shim = super::vscode_shim_path(&tmp.path().join("bin")); + assert_eq!(prepared.args, vec![".".to_string()]); + assert_eq!(prepared.executable, shim); + assert!(shim.is_file()); + let script = fs::read_to_string(&shim).unwrap_or_else(|e| panic!("{e}")); + assert!(script.contains("--no-sandbox")); + assert!(script.contains("--wait")); + assert!(script.contains("--new-window")); + assert!(script.contains("--user-data-dir")); + assert!(script.contains("--extensions-dir")); + assert!(script.contains(&real_code.display().to_string())); + assert_eq!( + env.get("FIRMA_RUN_VSCODE_USER_DATA_DIR"), + Some(&state_dir.join("user-data").display().to_string()) + ); + assert_eq!( + env.get("FIRMA_RUN_VSCODE_EXTENSIONS_DIR"), + Some(&state_dir.join("extensions").display().to_string()) + ); + let settings_path = state_dir + .join("user-data") + .join("User") + .join("settings.json"); + let settings = fs::read_to_string(&settings_path).unwrap_or_else(|e| panic!("{e}")); + let parsed: serde_json::Value = + serde_json::from_str(&settings).unwrap_or_else(|e| panic!("{e}")); + assert_eq!( + parsed.get("github-authentication.preferDeviceCodeFlow"), + Some(&serde_json::Value::Bool(true)) + ); + assert!( + env.get("PATH") + .is_some_and(|path| path.starts_with(&tmp.path().join("bin").display().to_string())) + ); + assert_eq!( + env.get("TMPDIR"), + Some( + &tmp.path() + .join("vscode") + .join("xdg-runtime") + .display() + .to_string() + ) + ); + assert_eq!( + env.get("XDG_RUNTIME_DIR"), + Some( + &tmp.path() + .join("vscode") + .join("xdg-runtime") + .display() + .to_string() + ) + ); + } + + #[cfg(unix)] + #[test] + fn vscode_shim_invokes_fake_code_with_managed_contract() { + use std::os::unix::fs::PermissionsExt as _; + use std::process::Command; + + let tmp = tempfile::tempdir().unwrap_or_else(|e| panic!("{e}")); + let host_bin = tmp.path().join("host-bin"); + fs::create_dir(&host_bin).unwrap_or_else(|e| panic!("{e}")); + let real_code = host_bin.join("code"); + let record_path = tmp.path().join("vscode-invocation.txt"); + fs::write( + &real_code, + "#!/bin/sh\n\ + set -eu\n\ + {\n\ + printf 'USER_DATA=%s\\n' \"$FIRMA_RUN_VSCODE_USER_DATA_DIR\"\n\ + printf 'EXTENSIONS=%s\\n' \"$FIRMA_RUN_VSCODE_EXTENSIONS_DIR\"\n\ + i=0\n\ + for arg in \"$@\"; do\n\ + printf 'ARG_%s=%s\\n' \"$i\" \"$arg\"\n\ + i=$((i + 1))\n\ + done\n\ + } > \"$FIRMA_TEST_VSCODE_RECORD\"\n", + ) + .unwrap_or_else(|e| panic!("{e}")); + let mut permissions = fs::metadata(&real_code) + .unwrap_or_else(|e| panic!("{e}")) + .permissions(); + permissions.set_mode(0o700); + fs::set_permissions(&real_code, permissions).unwrap_or_else(|e| panic!("{e}")); + + let state_dir = tmp.path().join(".firma").join("vscode"); + let mut env = BTreeMap::from([ + ("PATH".to_string(), host_bin.display().to_string()), + ( + "FIRMA_TEST_VSCODE_RECORD".to_string(), + record_path.display().to_string(), + ), + ]); + let prepared = super::prepare_vscode_shim( + tmp.path(), + &state_dir, + "code", + vec![".".to_string()], + &mut env, + Some(host_bin.as_os_str()), + ) + .unwrap_or_else(|e| panic!("{e}")); + + let output = Command::new(&prepared.executable) + .args(&prepared.args) + .env_clear() + .envs(&env) + .output() + .unwrap_or_else(|e| panic!("{e}")); + assert!( + output.status.success(), + "shim failed\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + + let user_data_dir = state_dir.join("user-data").display().to_string(); + let extensions_dir = state_dir.join("extensions").display().to_string(); + let record = fs::read_to_string(&record_path).unwrap_or_else(|e| panic!("{e}")); + let expected = [ + format!("USER_DATA={user_data_dir}"), + format!("EXTENSIONS={extensions_dir}"), + "ARG_0=--no-sandbox".to_string(), + "ARG_1=--wait".to_string(), + "ARG_2=--new-window".to_string(), + "ARG_3=--user-data-dir".to_string(), + format!("ARG_4={user_data_dir}"), + "ARG_5=--extensions-dir".to_string(), + format!("ARG_6={extensions_dir}"), + "ARG_7=.".to_string(), + ] + .join("\n"); + assert_eq!(record.trim_end(), expected); + } + + #[test] + fn vscode_state_dir_uses_config_parent_when_available() { + let tmp = tempfile::tempdir().unwrap_or_else(|e| panic!("{e}")); + let config_path = tmp.path().join(".firma").join("firma.toml"); + let runtime_dir = tmp.path().join("runtime"); + + let state_dir = super::resolve_vscode_state_dir(Some(&config_path), &runtime_dir) + .unwrap_or_else(|e| panic!("{e}")); + + assert_eq!(state_dir, tmp.path().join(".firma").join("vscode")); + } + + #[test] + fn vscode_state_mount_is_added_once() { + let state_dir = PathBuf::from("/workspace/.firma/vscode"); + let mut handle = crate::backend::SandboxHandle { + backend: crate::backend::BackendKind::Bwrap, + runtime_dir: PathBuf::from("/tmp/firma-run/session"), + identity: crate::identity::RunIdentity::new("vscode".to_string()), + mounts: Vec::new(), + network_policy: crate::config::NetworkPolicy { + enforce_network_namespace: true, + fail_closed: true, + }, + }; + + super::ensure_vscode_state_mount(&mut handle, &state_dir); + super::ensure_vscode_state_mount(&mut handle, &state_dir); + + assert_eq!(handle.mounts.len(), 1); + assert_eq!(handle.mounts[0].source, state_dir); + assert!(!handle.mounts[0].read_only); + } + + #[test] + fn vscode_shim_rejects_state_and_window_conflicts() { + let tmp = tempfile::tempdir().unwrap_or_else(|e| panic!("{e}")); + let mut env = BTreeMap::new(); + let error = super::prepare_vscode_shim( + tmp.path(), + &tmp.path().join("vscode"), + "code", + vec!["--user-data-dir".to_string(), "/tmp/code".to_string()], + &mut env, + Some(std::ffi::OsStr::new("/usr/bin")), + ) + .unwrap_err(); + assert!(error.to_string().contains("--user-data-dir")); + + let error = super::prepare_vscode_shim( + tmp.path(), + &tmp.path().join("vscode"), + "code", + vec!["--reuse-window".to_string()], + &mut env, + Some(std::ffi::OsStr::new("/usr/bin")), + ) + .unwrap_err(); + assert!(error.to_string().contains("--reuse-window")); + } + + #[test] + fn vscode_host_executable_resolution_uses_supplied_path() { + let tmp = tempfile::tempdir().unwrap_or_else(|e| panic!("{e}")); + let host_bin = tmp.path().join("host-bin"); + fs::create_dir(&host_bin).unwrap_or_else(|e| panic!("{e}")); + let real_code = host_bin.join("code"); + fs::write(&real_code, "#!/bin/sh\nexit 0\n").unwrap_or_else(|e| panic!("{e}")); + + let resolved = super::resolve_host_executable("code", Some(host_bin.as_os_str())) + .unwrap_or_else(|e| panic!("{e}")); + assert_eq!(resolved, real_code); + } + #[test] fn resolve_sidecar_template_prefers_explicit_sidecar_config() { let args = super::RunInput { From 114b743f5dbd7590c677f84297c8e5681da1d4de Mon Sep 17 00:00:00 2001 From: veeso Date: Thu, 9 Jul 2026 16:23:00 +0200 Subject: [PATCH 3/4] feat(vscode): integrate sidecar network flow --- crates/firma-run/src/authority/supervisor.rs | 85 ++++- crates/firma-run/src/backend/linux_bwrap.rs | 29 ++ crates/firma-run/src/dns_stub.rs | 94 +++++- crates/firma-run/src/proxy_bridge.rs | 116 ++++++- crates/firma-run/src/routing.rs | 315 ++++++++++++++++-- crates/firma-run/src/sidecar/config.rs | 230 ++++++++++++- crates/firma-run/src/sidecar/supervisor.rs | 85 ++++- .../tests/authority_autostart_ready_scrape.rs | 29 +- .../tests/sidecar_autostart_ready_scrape.rs | 19 +- .../firma-run/tests/sidecar_config_merge.rs | 45 +++ crates/firma/src/args.rs | 35 +- crates/firma/src/main.rs | 4 +- 12 files changed, 1028 insertions(+), 58 deletions(-) diff --git a/crates/firma-run/src/authority/supervisor.rs b/crates/firma-run/src/authority/supervisor.rs index 5e339120f..125a956b4 100644 --- a/crates/firma-run/src/authority/supervisor.rs +++ b/crates/firma-run/src/authority/supervisor.rs @@ -186,9 +186,18 @@ impl AuthoritySupervisor { })?; let reader = std::io::BufReader::new(stderr); let (tx, rx) = mpsc::sync_channel::(1); + let mirror_child_logs = child_log_mirroring_enabled(); let try_tee_handle = std::thread::Builder::new() .name("firma-authority-tee".into()) - .spawn(move || run_scraper(reader, log_file, tx)) + .spawn(move || { + run_scraper_with_mirror( + reader, + log_file, + std::io::stderr(), + mirror_child_logs, + tx, + ); + }) .map_err(|e| RunError::AuthorityStartupFailed { reason: format!("spawn scraper thread: {e}"), log_path: log_path.clone(), @@ -494,15 +503,30 @@ fn select_loopback_v6_port() -> Result { const LISTENING_TOKEN: &str = "listening"; +#[doc(hidden)] +pub fn run_scraper(mut reader: R, mut log: W, tx: mpsc::SyncSender) +where + R: BufRead, + W: Write, +{ + run_scraper_with_mirror(&mut reader, &mut log, std::io::sink(), false, tx); +} + #[doc(hidden)] #[expect( clippy::needless_pass_by_value, reason = "tx is moved into the spawned thread" )] -pub fn run_scraper(mut reader: R, mut log: W, tx: mpsc::SyncSender) -where +pub fn run_scraper_with_mirror( + mut reader: R, + mut log: W, + mut mirror: M, + mirror_enabled: bool, + tx: mpsc::SyncSender, +) where R: BufRead, W: Write, + M: Write, { let mut capture = ReadyCapture::default(); let mut signalled = false; @@ -518,6 +542,9 @@ where } Ok(_) => { let _ = log.write_all(buf.as_bytes()); + if mirror_enabled { + let _ = mirror.write_all(buf.as_bytes()); + } if !signalled { let plain = strip_ansi(&buf); if plain.contains(LISTENING_TOKEN) @@ -540,6 +567,56 @@ where } } +#[cfg(unix)] +fn child_log_mirroring_enabled() -> bool { + std::env::var("FIRMA_LOG_FILTER") + .or_else(|_| std::env::var("RUST_LOG")) + .is_ok_and(|filter| log_filter_requests_child_mirroring(&filter)) +} + +#[cfg(unix)] +fn log_filter_requests_child_mirroring(filter: &str) -> bool { + filter + .split(',') + .map(str::trim) + .map(str::to_ascii_lowercase) + .any(|directive| { + directive == "debug" + || directive == "trace" + || directive.ends_with("=debug") + || directive.ends_with("=trace") + }) +} + +#[cfg(all(test, unix))] +mod log_filter_tests { + #[test] + fn debug_and_trace_filters_request_child_log_mirroring() { + for filter in [ + "debug", + "trace", + "firma_authority=debug", + "openfirma=trace", + " info , firma_run=debug ", + ] { + assert!( + super::log_filter_requests_child_mirroring(filter), + "{filter} should mirror child logs" + ); + } + } + + #[test] + fn less_verbose_filters_do_not_request_child_log_mirroring() { + for filter in ["", "info", "warn,firma=info", "firma_authority=warn"] { + assert!( + !super::log_filter_requests_child_mirroring(filter), + "{filter} should not mirror child logs" + ); + } + } +} + fn strip_ansi(input: &str) -> String { let mut out = String::with_capacity(input.len()); let bytes = input.as_bytes(); @@ -584,7 +661,7 @@ fn extract_kv(line: &str, key: &str) -> Option { #[doc(hidden)] pub mod testing { - pub use super::{ReadyCapture, ScrapeResult, run_scraper}; + pub use super::{ReadyCapture, ScrapeResult, run_scraper, run_scraper_with_mirror}; } #[cfg(all(test, unix))] diff --git a/crates/firma-run/src/backend/linux_bwrap.rs b/crates/firma-run/src/backend/linux_bwrap.rs index a4722bbcb..1cb7bf931 100644 --- a/crates/firma-run/src/backend/linux_bwrap.rs +++ b/crates/firma-run/src/backend/linux_bwrap.rs @@ -594,6 +594,35 @@ mod tests { ); } + #[test] + #[cfg(target_os = "linux")] + fn create_bwrap_runtime_dir_creates_private_sandbox_dir() { + use std::os::unix::fs::PermissionsExt as _; + + let sandbox_id = + crate::identity::SandboxId::from(format!("coverage-{}", uuid::Uuid::now_v7())); + + let runtime_dir = + super::create_bwrap_runtime_dir(&sandbox_id).expect("create bwrap runtime dir"); + + assert!(runtime_dir.is_dir(), "runtime dir should exist"); + assert_eq!( + runtime_dir.file_name(), + Some(std::ffi::OsStr::new(&sandbox_id.to_string())) + ); + let mode = std::fs::metadata(&runtime_dir) + .expect("runtime dir metadata") + .permissions() + .mode(); + assert_eq!( + mode & 0o077, + 0, + "runtime dir should not be group/world accessible" + ); + + std::fs::remove_dir_all(&runtime_dir).expect("cleanup runtime dir"); + } + #[test] #[cfg(target_os = "linux")] fn mask_sensitive_paths_adds_expected_mounts() { diff --git a/crates/firma-run/src/dns_stub.rs b/crates/firma-run/src/dns_stub.rs index 158f4af4e..648f8ad0d 100644 --- a/crates/firma-run/src/dns_stub.rs +++ b/crates/firma-run/src/dns_stub.rs @@ -317,10 +317,23 @@ fn refused_response(query: &[u8]) -> Option> { #[cfg(test)] mod tests { - use std::net::UdpSocket; + use std::io::{Read, Write}; + use std::net::{TcpListener, TcpStream, UdpSocket}; use std::time::Duration; - use super::{DNS_RCODE_REFUSED, HostDnsStubHandle, refused_response}; + use super::{DNS_RCODE_REFUSED, HostDnsStubHandle, handle_tcp_client, refused_response}; + + fn sample_query() -> Vec { + vec![ + 0xAB, 0xCD, // transaction id + 0x01, 0x00, // flags: standard query + 0x00, 0x01, // 1 question + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // no answers/authority/additional + 0x07, b'e', b'x', b'a', b'm', b'p', b'l', b'e', 0x03, b'c', b'o', b'm', 0x00, 0x00, + 0x01, // QTYPE A + 0x00, 0x01, // QCLASS IN + ] + } #[test] fn refused_response_preserves_query_id_and_question() { @@ -344,6 +357,72 @@ mod tests { assert!(refused_response(&[0_u8; 11]).is_none()); } + #[test] + fn tcp_client_receives_length_prefixed_refused_response() { + let listener = TcpListener::bind("127.0.0.1:0").expect("bind"); + let addr = listener.local_addr().expect("addr"); + let server = std::thread::spawn(move || { + let (stream, _) = listener.accept().expect("accept"); + handle_tcp_client(stream).expect("handle tcp client"); + }); + + let mut client = TcpStream::connect(addr).expect("connect"); + client + .set_read_timeout(Some(Duration::from_secs(2))) + .expect("read timeout"); + let query = sample_query(); + let len = u16::try_from(query.len()) + .expect("sample query fits u16") + .to_be_bytes(); + client.write_all(&len).expect("write len"); + client.write_all(&query).expect("write query"); + + let mut response_len = [0_u8; 2]; + client.read_exact(&mut response_len).expect("read len"); + let response_len = u16::from_be_bytes(response_len) as usize; + let mut response = vec![0_u8; response_len]; + client.read_exact(&mut response).expect("read response"); + drop(client); + server.join().expect("server thread"); + + assert_eq!(&response[..2], &[0xAB, 0xCD]); + assert_ne!(response[2] & 0x80, 0, "QR bit must be set"); + assert_eq!(response[3] & 0x0F, DNS_RCODE_REFUSED); + assert_eq!(&response[12..], &query[12..]); + } + + #[test] + fn tcp_client_skips_malformed_query_without_response() { + let listener = TcpListener::bind("127.0.0.1:0").expect("bind"); + let addr = listener.local_addr().expect("addr"); + let server = std::thread::spawn(move || { + let (stream, _) = listener.accept().expect("accept"); + handle_tcp_client(stream).expect("handle tcp client"); + }); + + let mut client = TcpStream::connect(addr).expect("connect"); + client + .set_read_timeout(Some(Duration::from_millis(200))) + .expect("read timeout"); + client.write_all(&5_u16.to_be_bytes()).expect("write len"); + client.write_all(&[0_u8; 5]).expect("write malformed query"); + + let mut response_len = [0_u8; 2]; + let error = client + .read_exact(&mut response_len) + .expect_err("malformed query must not produce a TCP response"); + drop(client); + server.join().expect("server thread"); + + assert!( + matches!( + error.kind(), + std::io::ErrorKind::WouldBlock | std::io::ErrorKind::TimedOut + ), + "unexpected read error: {error}" + ); + } + // ── HostDnsStubHandle ───────────────────────────────────────────────────── #[test] @@ -356,16 +435,7 @@ mod tests { .set_read_timeout(Some(Duration::from_secs(2))) .expect("set timeout"); - let query = [ - 0xAB, 0xCD, // transaction id - 0x01, 0x00, // flags: standard query - 0x00, 0x01, // 1 question - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // no answers/authority/additional - 0x07, b'e', b'x', b'a', b'm', b'p', b'l', b'e', 0x03, b'c', b'o', b'm', - 0x00, // root - 0x00, 0x01, // QTYPE A - 0x00, 0x01, // QCLASS IN - ]; + let query = sample_query(); client.send_to(&query, addr).expect("send query"); let mut buf = [0_u8; 512]; diff --git a/crates/firma-run/src/proxy_bridge.rs b/crates/firma-run/src/proxy_bridge.rs index 0888d707d..d4715d40b 100644 --- a/crates/firma-run/src/proxy_bridge.rs +++ b/crates/firma-run/src/proxy_bridge.rs @@ -166,6 +166,11 @@ fn run_host_bridge_loop( } match listener.accept() { Ok((client, client_addr)) => { + tracing::debug!( + client_addr = %client_addr, + upstream = %upstream, + "host proxy bridge accepted client connection" + ); // Listener is non-blocking for stop polling; on some platforms // accepted sockets may inherit non-blocking mode. The relay // path relies on blocking I/O, so normalize the accepted @@ -389,6 +394,11 @@ fn run_proxy_bridge_unix(args: &ProxyBridgeInput) -> Result<(), RunError> { let (client_stream, client_addr) = listener .accept() .map_err(|error| RunError::Spawn(format!("proxy bridge accept failed: {error}")))?; + tracing::debug!( + client_addr = %client_addr, + upstream = %args.upstream_uds.display(), + "sandbox proxy bridge accepted client connection" + ); let upstream_path = args.upstream_uds.clone(); let attribution_headers = attribution_headers.clone(); let bridge_listen_addr = args.listen.to_string(); @@ -514,6 +524,11 @@ where request.truncate(header_end); let meta = parse_request_metadata(&request)?; + tracing::debug!( + method = %meta.method, + target = %sanitized_request_target_for_log(&meta.target), + "proxy bridge forwarding request headers" + ); append_missing_headers(&mut request, attribution_headers)?; // `request` already ends with \r\n (the CRLF of the last header @@ -650,6 +665,7 @@ enum BodyKind { #[derive(Debug, Clone, PartialEq, Eq)] struct RequestMetadata { method: String, + target: String, body: BodyKind, } @@ -666,11 +682,9 @@ fn parse_request_metadata(header_block: &[u8]) -> io::Result { let request_line = lines .next() .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, "missing request line"))?; - let method = request_line - .split_whitespace() - .next() - .unwrap_or_default() - .to_string(); + let mut request_line_parts = request_line.split_whitespace(); + let method = request_line_parts.next().unwrap_or_default().to_string(); + let target = request_line_parts.next().unwrap_or_default().to_string(); let mut transfer_chunked = false; let mut content_length = None; @@ -699,7 +713,24 @@ fn parse_request_metadata(header_block: &[u8]) -> io::Result { BodyKind::None }; - Ok(RequestMetadata { method, body }) + Ok(RequestMetadata { + method, + target, + body, + }) +} + +#[cfg(any(unix, test))] +fn sanitized_request_target_for_log(target: &str) -> &str { + let query = target.find('?'); + let fragment = target.find('#'); + let end = match (query, fragment) { + (Some(query), Some(fragment)) => query.min(fragment), + (Some(query), None) => query, + (None, Some(fragment)) => fragment, + (None, None) => return target, + }; + &target[..end] } #[cfg(unix)] @@ -811,7 +842,10 @@ fn find_crlf(buffer: &[u8]) -> Option { mod tests { use std::collections::BTreeMap; - use super::{BodyKind, append_missing_headers, find_header_terminator, parse_request_metadata}; + use super::{ + BodyKind, append_missing_headers, find_header_terminator, parse_request_metadata, + sanitized_request_target_for_log, + }; #[test] fn finds_header_terminator() { @@ -832,6 +866,34 @@ mod tests { assert!(rendered.contains("x-firma-profile: claude-code\r\n")); } + #[test] + fn append_missing_headers_does_not_duplicate_existing_names_case_insensitively() { + let mut req_head = b"GET http://example.com/ HTTP/1.1\r\nHost: example.com\r\nX-Firma-Session-Id: existing\r\n".to_vec(); + let mut headers = BTreeMap::new(); + headers.insert("x-firma-session-id".to_string(), "injected".to_string()); + headers.insert("x-firma-agent".to_string(), "vscode".to_string()); + + append_missing_headers(&mut req_head, &headers).expect("append headers"); + let rendered = String::from_utf8(req_head).expect("utf8"); + + assert!(rendered.contains("X-Firma-Session-Id: existing\r\n")); + assert!(!rendered.contains("x-firma-session-id: injected\r\n")); + assert!(rendered.contains("x-firma-agent: vscode\r\n")); + } + + #[test] + fn append_missing_headers_rejects_non_utf8_header_block() { + let mut req_head = b"GET http://example.com/ HTTP/1.1\r\nHost: example.com\r\n".to_vec(); + req_head.push(0xFF); + let mut headers = BTreeMap::new(); + headers.insert("x-firma-session-id".to_string(), "sess_001".to_string()); + + let error = append_missing_headers(&mut req_head, &headers) + .expect_err("non-utf8 headers must fail closed"); + + assert_eq!(error.kind(), std::io::ErrorKind::InvalidData); + } + #[test] fn header_block_ends_with_single_crlf_for_terminator() { // `append_missing_headers` rebuilds the header block and always @@ -860,15 +922,55 @@ mod tests { let req = b"POST http://example.com/upload HTTP/1.1\r\nHost: example.com\r\nContent-Length: 12\r\n\r\n"; let meta = parse_request_metadata(req).expect("metadata"); assert_eq!(meta.method, "POST"); + assert_eq!(meta.target, "http://example.com/upload"); assert_eq!(meta.body, BodyKind::ContentLength(12)); } + #[test] + fn zero_or_invalid_content_length_is_treated_as_no_body() { + let zero = b"POST http://example.com/upload HTTP/1.1\r\nContent-Length: 0\r\n\r\n"; + let invalid = b"POST http://example.com/upload HTTP/1.1\r\nContent-Length: nope\r\n\r\n"; + + assert_eq!( + parse_request_metadata(zero).expect("zero metadata").body, + BodyKind::None + ); + assert_eq!( + parse_request_metadata(invalid) + .expect("invalid content length metadata") + .body, + BodyKind::None + ); + } + #[test] fn parses_chunked_body_metadata() { let req = b"POST http://example.com/upload HTTP/1.1\r\nHost: example.com\r\nTransfer-Encoding: chunked\r\n\r\n"; let meta = parse_request_metadata(req).expect("metadata"); assert_eq!(meta.body, BodyKind::Chunked); } + + #[test] + fn chunked_transfer_encoding_wins_over_content_length() { + let req = b"POST http://example.com/upload HTTP/1.1\r\nTransfer-Encoding: gzip, chunked\r\nContent-Length: 12\r\n\r\n"; + let meta = parse_request_metadata(req).expect("metadata"); + + assert_eq!(meta.body, BodyKind::Chunked); + } + + #[test] + fn sanitizes_request_target_for_debug_logs() { + assert_eq!( + sanitized_request_target_for_log( + "https://github.com/login/oauth/authorize?client_id=abc#state" + ), + "https://github.com/login/oauth/authorize" + ); + assert_eq!( + sanitized_request_target_for_log("github.com:443"), + "github.com:443" + ); + } } // The bridge spins up real TCP listeners and only exists on the non-structural diff --git a/crates/firma-run/src/routing.rs b/crates/firma-run/src/routing.rs index 7724811fa..6a79caab5 100644 --- a/crates/firma-run/src/routing.rs +++ b/crates/firma-run/src/routing.rs @@ -9,7 +9,7 @@ use std::io; #[cfg(unix)] use std::os::unix::net::{UnixListener, UnixStream}; #[cfg(unix)] -use std::sync::mpsc; +use std::sync::{Arc, Mutex, mpsc}; #[cfg(unix)] use std::thread::{self, JoinHandle}; @@ -1067,9 +1067,26 @@ struct SidecarAdapter { task: Option>, } +#[cfg(unix)] +const DEFAULT_SIDECAR_ADAPTER_MAX_CONNECTIONS: usize = 512; +#[cfg(unix)] +const DEFAULT_SIDECAR_ADAPTER_IDLE_TIMEOUT: Duration = Duration::from_mins(2); + #[cfg(unix)] impl SidecarAdapter { fn start(socket_path: &Path, upstream: &SidecarEndpoint) -> Result { + Self::start_with_connection_limit( + socket_path, + upstream, + DEFAULT_SIDECAR_ADAPTER_MAX_CONNECTIONS, + ) + } + + fn start_with_connection_limit( + socket_path: &Path, + upstream: &SidecarEndpoint, + max_connections: usize, + ) -> Result { if let Some(parent) = socket_path.parent() { std::fs::create_dir_all(parent).map_err(|error| RunError::Backend { backend: "sidecar_adapter".to_string(), @@ -1098,6 +1115,7 @@ impl SidecarAdapter { let (stop_tx, stop_rx) = mpsc::channel::<()>(); let socket_for_task = socket_path.to_path_buf(); let upstream_for_task = upstream.clone(); + let limiter = Arc::new(SidecarAdapterConnectionLimiter::new(max_connections.max(1))); let task = thread::Builder::new() .name("firma-run-sidecar-adapter".to_string()) @@ -1107,19 +1125,33 @@ impl SidecarAdapter { break; } + let Some(permit) = limiter.try_acquire() else { + thread::sleep(Duration::from_millis(25)); + continue; + }; + match listener.accept() { Ok((client, _)) => { + if let Err(error) = client.set_nonblocking(false) { + tracing::warn!( + "sidecar adapter failed to set accepted socket blocking mode: {error}" + ); + continue; + } let upstream_target = upstream_for_task.clone(); thread::spawn(move || { + let _permit = permit; if let Err(error) = relay_to_sidecar(&client, &upstream_target) { - tracing::warn!("sidecar adapter relay failed: {error}"); + log_sidecar_adapter_relay_error(&error); } }); } Err(error) if error.kind() == io::ErrorKind::WouldBlock => { + drop(permit); thread::sleep(Duration::from_millis(25)); } Err(error) => { + drop(permit); tracing::warn!("sidecar adapter accept failed: {error}"); thread::sleep(Duration::from_millis(50)); } @@ -1139,6 +1171,72 @@ impl SidecarAdapter { } } +#[cfg(unix)] +fn log_sidecar_adapter_relay_error(error: &io::Error) { + if is_sidecar_adapter_transient_relay_error(error) { + tracing::debug!("sidecar adapter relay closed/transient: {error}"); + } else { + tracing::warn!("sidecar adapter relay failed: {error}"); + } +} + +#[cfg(unix)] +fn is_sidecar_adapter_transient_relay_error(error: &io::Error) -> bool { + matches!( + error.kind(), + io::ErrorKind::WouldBlock + | io::ErrorKind::ConnectionReset + | io::ErrorKind::BrokenPipe + | io::ErrorKind::TimedOut + | io::ErrorKind::UnexpectedEof + ) +} + +#[cfg(unix)] +#[derive(Debug)] +struct SidecarAdapterConnectionLimiter { + limit: usize, + in_flight: Mutex, +} + +#[cfg(unix)] +impl SidecarAdapterConnectionLimiter { + fn new(limit: usize) -> Self { + Self { + limit, + in_flight: Mutex::new(0), + } + } + + fn try_acquire(self: &Arc) -> Option { + let Ok(mut in_flight) = self.in_flight.lock() else { + return None; + }; + if *in_flight >= self.limit { + return None; + } + *in_flight += 1; + Some(SidecarAdapterConnectionPermit { + limiter: Arc::clone(self), + }) + } +} + +#[cfg(unix)] +#[derive(Debug)] +struct SidecarAdapterConnectionPermit { + limiter: Arc, +} + +#[cfg(unix)] +impl Drop for SidecarAdapterConnectionPermit { + fn drop(&mut self) { + if let Ok(mut in_flight) = self.limiter.in_flight.lock() { + *in_flight = in_flight.saturating_sub(1); + } + } +} + #[cfg(unix)] impl Drop for SidecarAdapter { fn drop(&mut self) { @@ -1192,48 +1290,109 @@ fn connect_tcp_with_retry(addr: &std::net::SocketAddr) -> io::Result #[cfg(unix)] fn relay_unix_to_tcp(client: &UnixStream, target: &TcpStream) -> io::Result<()> { + relay_unix_to_tcp_with_idle_timeout(client, target, DEFAULT_SIDECAR_ADAPTER_IDLE_TIMEOUT) +} + +#[cfg(unix)] +fn relay_unix_to_tcp_with_idle_timeout( + client: &UnixStream, + target: &TcpStream, + idle_timeout: Duration, +) -> io::Result<()> { + client.set_read_timeout(Some(idle_timeout))?; + target.set_read_timeout(Some(idle_timeout))?; let mut client_read = client.try_clone()?; + let client_shutdown_for_upload = client.try_clone()?; + let client_shutdown_for_download = client.try_clone()?; let mut client_write = client.try_clone()?; let mut target_read = target.try_clone()?; + let target_shutdown_for_upload = target.try_clone()?; + let target_shutdown_for_download = target.try_clone()?; let mut target_write = target.try_clone()?; - let c_to_t = thread::spawn(move || io::copy(&mut client_read, &mut target_write)); - let t_to_c = thread::spawn(move || io::copy(&mut target_read, &mut client_write)); + let c_to_t = thread::spawn(move || { + let result = io::copy(&mut client_read, &mut target_write); + let _ = client_shutdown_for_upload.shutdown(std::net::Shutdown::Both); + let _ = target_shutdown_for_upload.shutdown(std::net::Shutdown::Both); + result + }); + let t_to_c = thread::spawn(move || { + let result = io::copy(&mut target_read, &mut client_write); + let _ = target_shutdown_for_download.shutdown(std::net::Shutdown::Both); + let _ = client_shutdown_for_download.shutdown(std::net::Shutdown::Both); + result + }); - c_to_t - .join() - .map_err(|_| io::Error::other("relay panic"))??; - t_to_c - .join() - .map_err(|_| io::Error::other("relay panic"))??; - Ok(()) + let upload_result = c_to_t.join().map_err(|_| io::Error::other("relay panic"))?; + let download_result = t_to_c.join().map_err(|_| io::Error::other("relay panic"))?; + finish_sidecar_adapter_relay(upload_result, download_result) } #[cfg(unix)] fn relay_unix_to_unix(client: &UnixStream, target: &UnixStream) -> io::Result<()> { + relay_unix_to_unix_with_idle_timeout(client, target, DEFAULT_SIDECAR_ADAPTER_IDLE_TIMEOUT) +} + +#[cfg(unix)] +fn relay_unix_to_unix_with_idle_timeout( + client: &UnixStream, + target: &UnixStream, + idle_timeout: Duration, +) -> io::Result<()> { + client.set_read_timeout(Some(idle_timeout))?; + target.set_read_timeout(Some(idle_timeout))?; let mut client_read = client.try_clone()?; + let client_shutdown_for_upload = client.try_clone()?; + let client_shutdown_for_download = client.try_clone()?; let mut client_write = client.try_clone()?; let mut target_read = target.try_clone()?; + let target_shutdown_for_upload = target.try_clone()?; + let target_shutdown_for_download = target.try_clone()?; let mut target_write = target.try_clone()?; - let c_to_t = thread::spawn(move || io::copy(&mut client_read, &mut target_write)); - let t_to_c = thread::spawn(move || io::copy(&mut target_read, &mut client_write)); + let c_to_t = thread::spawn(move || { + let result = io::copy(&mut client_read, &mut target_write); + let _ = client_shutdown_for_upload.shutdown(std::net::Shutdown::Both); + let _ = target_shutdown_for_upload.shutdown(std::net::Shutdown::Both); + result + }); + let t_to_c = thread::spawn(move || { + let result = io::copy(&mut target_read, &mut client_write); + let _ = target_shutdown_for_download.shutdown(std::net::Shutdown::Both); + let _ = client_shutdown_for_download.shutdown(std::net::Shutdown::Both); + result + }); - c_to_t - .join() - .map_err(|_| io::Error::other("relay panic"))??; - t_to_c - .join() - .map_err(|_| io::Error::other("relay panic"))??; + let upload_result = c_to_t.join().map_err(|_| io::Error::other("relay panic"))?; + let download_result = t_to_c.join().map_err(|_| io::Error::other("relay panic"))?; + finish_sidecar_adapter_relay(upload_result, download_result) +} + +#[cfg(unix)] +fn finish_sidecar_adapter_relay( + upload_result: io::Result, + download_result: io::Result, +) -> io::Result<()> { + for result in [upload_result, download_result] { + if let Err(error) = result + && !is_sidecar_adapter_transient_relay_error(&error) + { + return Err(error); + } + } Ok(()) } #[cfg(test)] #[cfg(unix)] mod non_structural_env_tests { + use std::error::Error; use std::io::{Read, Write}; use std::net::{SocketAddr, TcpListener, TcpStream}; + use std::os::unix::net::UnixStream; use std::str::FromStr; + use std::sync::Arc; + use std::sync::atomic::{AtomicUsize, Ordering}; use std::time::Duration; use crate::backend::{BackendKind, SandboxHandle}; @@ -1242,7 +1401,8 @@ mod non_structural_env_tests { use crate::identity::RunIdentity; use super::{ - AutostartFlags, EnvOverrides, ResolvedAuthority, prepare_network_runtime, setup_host_bridge, + AutostartFlags, EnvOverrides, ResolvedAuthority, SidecarAdapter, prepare_network_runtime, + setup_host_bridge, }; /// Verifies that `setup_host_bridge` inserts all proxy env vars pointing @@ -1594,6 +1754,27 @@ mod non_structural_env_tests { ); } + #[test] + fn sidecar_adapter_disconnect_errors_are_transient() { + for kind in [ + std::io::ErrorKind::BrokenPipe, + std::io::ErrorKind::ConnectionReset, + std::io::ErrorKind::UnexpectedEof, + ] { + let error = std::io::Error::from(kind); + assert!( + super::is_sidecar_adapter_transient_relay_error(&error), + "{kind:?} should not be logged as an adapter relay warning" + ); + } + + let error = std::io::Error::from(std::io::ErrorKind::ConnectionRefused); + assert!( + !super::is_sidecar_adapter_transient_relay_error(&error), + "connection failures before relay setup should remain warnings" + ); + } + #[test] fn env_overrides_dns_stub_address_is_optional() { let absent = EnvOverrides::default().with_dns_stub_address(None); @@ -1626,6 +1807,100 @@ mod non_structural_env_tests { assert_eq!(env.get("K").map(String::as_str), Some("V")); assert_eq!(env.len(), 1); } + + #[test] + fn sidecar_adapter_relay_terminates_idle_connections() -> Result<(), Box> { + let (client_for_relay, _client_peer) = UnixStream::pair()?; + let upstream_listener = TcpListener::bind("127.0.0.1:0")?; + let _upstream_peer = TcpStream::connect(upstream_listener.local_addr()?)?; + let (upstream_for_relay, _) = upstream_listener.accept()?; + let (done_tx, done_rx) = std::sync::mpsc::channel(); + + std::thread::spawn(move || { + let result = super::relay_unix_to_tcp_with_idle_timeout( + &client_for_relay, + &upstream_for_relay, + Duration::from_millis(100), + ); + let _ = done_tx.send(result); + }); + + let relay_result = done_rx + .recv_timeout(Duration::from_secs(2)) + .map_err(|_| std::io::Error::other("idle relay did not terminate"))?; + relay_result?; + Ok(()) + } + + #[test] + fn sidecar_adapter_connection_limit_queues_extra_clients_before_upstream_connect() + -> Result<(), Box> { + let tmp = tempfile::tempdir()?; + let socket_path = tmp.path().join("sidecar-upstream.sock"); + let upstream_listener = TcpListener::bind("127.0.0.1:0")?; + let upstream_addr = upstream_listener.local_addr()?; + upstream_listener.set_nonblocking(true)?; + let upstream_connections = Arc::new(AtomicUsize::new(0)); + let accepted = Arc::clone(&upstream_connections); + let upstream_task = std::thread::spawn(move || { + let mut held = Vec::new(); + for _ in 0..100 { + match upstream_listener.accept() { + Ok((stream, _)) => { + accepted.fetch_add(1, Ordering::SeqCst); + held.push(stream); + } + Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => { + std::thread::sleep(Duration::from_millis(10)); + } + Err(_) => break, + } + } + held + }); + + let adapter = SidecarAdapter::start_with_connection_limit( + &socket_path, + &SidecarEndpoint::Tcp { + addr: upstream_addr, + }, + 1, + )?; + + let first_client = UnixStream::connect(&socket_path)?; + for _ in 0..50 { + if upstream_connections.load(Ordering::SeqCst) == 1 { + break; + } + std::thread::sleep(Duration::from_millis(10)); + } + assert_eq!(upstream_connections.load(Ordering::SeqCst), 1); + + let second_client = UnixStream::connect(&socket_path)?; + std::thread::sleep(Duration::from_millis(200)); + assert_eq!( + upstream_connections.load(Ordering::SeqCst), + 1, + "adapter must not accept and connect a second relay while the single permit is held" + ); + + drop(second_client); + drop(first_client); + drop(adapter); + let _held = upstream_task + .join() + .map_err(|_| std::io::Error::other("upstream task panicked"))?; + Ok(()) + } + + #[test] + fn sidecar_adapter_default_limit_supports_browser_like_clients() { + let max_connections = std::hint::black_box(super::DEFAULT_SIDECAR_ADAPTER_MAX_CONNECTIONS); + assert!( + max_connections >= 256, + "VS Code can hold many concurrent CONNECT/keep-alive sockets during extension installs" + ); + } } #[cfg(test)] diff --git a/crates/firma-run/src/sidecar/config.rs b/crates/firma-run/src/sidecar/config.rs index ab9220cf0..36a20e5e7 100644 --- a/crates/firma-run/src/sidecar/config.rs +++ b/crates/firma-run/src/sidecar/config.rs @@ -121,6 +121,171 @@ path = \"/*\" action_class = \"communication.external.send\" "; +const VSCODE_MINIMAL_MAPPING_RULES_TOML: &str = "\ +# Visual Studio Code zero-config mapping — CONNECT-level classification for +# core services, marketplace, account sync, and GitHub account flows. + +[[rules]] +method = \"CONNECT\" +host = \"update.code.visualstudio.com\" +action_class = \"communication.external.send\" + +[[rules]] +method = \"CONNECT\" +host = \"code.visualstudio.com\" +action_class = \"communication.external.send\" + +[[rules]] +method = \"CONNECT\" +host = \"*.code.visualstudio.com\" +action_class = \"communication.external.send\" + +[[rules]] +method = \"CONNECT\" +host = \"vscode.dev\" +action_class = \"communication.external.send\" + +[[rules]] +method = \"CONNECT\" +host = \"*.vscode.dev\" +action_class = \"communication.external.send\" + +[[rules]] +method = \"CONNECT\" +host = \"az764295.vo.msecnd.net\" +action_class = \"communication.external.send\" + +[[rules]] +method = \"CONNECT\" +host = \"vscodeexperiments.azureedge.net\" +action_class = \"communication.external.send\" + +[[rules]] +method = \"CONNECT\" +host = \"default.exp-tas.com\" +action_class = \"communication.external.send\" + +[[rules]] +method = \"CONNECT\" +host = \"marketplace.visualstudio.com\" +action_class = \"communication.external.send\" + +[[rules]] +method = \"CONNECT\" +host = \"*.gallerycdn.vsassets.io\" +action_class = \"communication.external.send\" + +[[rules]] +method = \"CONNECT\" +host = \"*.gallery.vsassets.io\" +action_class = \"communication.external.send\" + +[[rules]] +method = \"CONNECT\" +host = \"*.vscode-unpkg.net\" +action_class = \"communication.external.send\" + +[[rules]] +method = \"CONNECT\" +host = \"*.vscode-cdn.net\" +action_class = \"communication.external.send\" + +[[rules]] +method = \"CONNECT\" +host = \"vscode-sync.trafficmanager.net\" +action_class = \"communication.external.send\" + +[[rules]] +method = \"CONNECT\" +host = \"*.vscode-sync.trafficmanager.net\" +action_class = \"communication.external.send\" + +[[rules]] +method = \"CONNECT\" +host = \"login.microsoftonline.com\" +action_class = \"communication.external.send\" + +[[rules]] +method = \"CONNECT\" +host = \"login.live.com\" +action_class = \"communication.external.send\" + +[[rules]] +method = \"CONNECT\" +host = \"*.microsoft.com\" +action_class = \"communication.external.send\" + +[[rules]] +method = \"CONNECT\" +host = \"*.msauth.net\" +action_class = \"communication.external.send\" + +[[rules]] +method = \"CONNECT\" +host = \"*.msftauth.net\" +action_class = \"communication.external.send\" + +[[rules]] +method = \"CONNECT\" +host = \"github.com\" +action_class = \"communication.external.send\" + +[[rules]] +method = \"CONNECT\" +host = \"*.github.com\" +action_class = \"communication.external.send\" + +[[rules]] +method = \"CONNECT\" +host = \"api.github.com\" +action_class = \"communication.external.send\" + +[[rules]] +method = \"CONNECT\" +host = \"uploads.github.com\" +action_class = \"communication.external.send\" + +[[rules]] +method = \"CONNECT\" +host = \"*.githubusercontent.com\" +action_class = \"communication.external.send\" + +[[rules]] +method = \"CONNECT\" +host = \"*.ghe.com\" +action_class = \"communication.external.send\" + +[[rules]] +method = \"CONNECT\" +host = \"accounts.google.com\" +action_class = \"communication.external.send\" + +[[rules]] +method = \"CONNECT\" +host = \"*.gstatic.com\" +action_class = \"communication.external.send\" + +[[rules]] +method = \"CONNECT\" +host = \"*.googleusercontent.com\" +action_class = \"communication.external.send\" + +[[rules]] +method = \"CONNECT\" +host = \"appleid.apple.com\" +action_class = \"communication.external.send\" + +[[rules]] +method = \"CONNECT\" +host = \"idmsa.apple.com\" +action_class = \"communication.external.send\" + +[[rules]] +method = \"CONNECT\" +host = \"*.cdn-apple.com\" +action_class = \"communication.external.send\" +"; + /// Inputs for [`synthesize`]. #[doc(hidden)] #[derive(Debug, Clone)] @@ -245,7 +410,7 @@ pub fn synthesize(req: SynthesizeRequest<'_>) -> Result Result { .map_err(|error| RunError::Internal(format!("encode audit signing key pem: {error}"))) } -fn ensure_mapping_rules(value: &mut toml::Value, out_path: &Path) -> Result<(), RunError> { +fn ensure_mapping_rules( + value: &mut toml::Value, + out_path: &Path, + agent_id: &str, +) -> Result<(), RunError> { let sidecar = sidecar_table_mut(value)?; let mapping = sidecar .entry("mapping".to_string()) @@ -660,9 +829,9 @@ fn ensure_mapping_rules(value: &mut toml::Value, out_path: &Path) -> Result<(), })?; let rules_path = parent.join("mapping-rules.toml"); if !rules_path.exists() { - std::fs::write(&rules_path, MINIMAL_MAPPING_RULES_TOML).map_err(|error| { - RunError::Internal(format!("write {}: {error}", rules_path.display())) - })?; + std::fs::write(&rules_path, minimal_mapping_rules_for_agent(agent_id)).map_err( + |error| RunError::Internal(format!("write {}: {error}", rules_path.display())), + )?; } let has_rules_path = mapping @@ -682,6 +851,16 @@ fn ensure_mapping_rules(value: &mut toml::Value, out_path: &Path) -> Result<(), Ok(()) } +fn minimal_mapping_rules_for_agent(agent_id: &str) -> &'static str { + if firma_config_loader::AgentProfile::from_name(agent_id) + .is_some_and(|profile| profile == firma_config_loader::AgentProfile::Vscode) + { + VSCODE_MINIMAL_MAPPING_RULES_TOML + } else { + MINIMAL_MAPPING_RULES_TOML + } +} + /// Append the per-session capability seed file to `[capability_seed].paths` /// so the autostarted sidecar loads it through its existing verifier path. /// No-op when no seed was minted. @@ -777,7 +956,10 @@ pub mod testing { #[cfg(test)] mod tests { - use super::{configure_capability_seed, normalize_to_sectioned_sidecar}; + use super::{ + SynthesizeRequest, TemplateSource, configure_capability_seed, + normalize_to_sectioned_sidecar, synthesize, + }; #[test] fn capability_seed_path_is_injected_and_no_preflight() { @@ -798,4 +980,40 @@ mod tests { ); assert!(sidecar.get("preflight").is_none()); } + + #[test] + fn vscode_minimal_synthesis_writes_marketplace_mapping_rules() { + let tmp = tempfile::tempdir().unwrap_or_else(|error| panic!("{error}")); + let cfg_path = tmp.path().join("sidecar.toml"); + let source = synthesize(SynthesizeRequest { + agent_id: "vscode", + session_id: "sess_001", + explicit_template: None, + env_template: None, + cwd_template: None, + socket_path: &tmp.path().join("sidecar.sock"), + listen_addr: Some( + "127.0.0.1:18080" + .parse() + .unwrap_or_else(|error| panic!("{error}")), + ), + out_path: &cfg_path, + authority_url: None, + authority_ca_cert: None, + authority_pub_key: None, + authority_credentials: None, + capability_seed_path: None, + audit_fallback_path: None, + monitor_mode: false, + }) + .unwrap_or_else(|error| panic!("{error}")); + + assert_eq!(source, TemplateSource::Minimal); + let rules_path = tmp.path().join("mapping-rules.toml"); + let rules = std::fs::read_to_string(&rules_path) + .unwrap_or_else(|error| panic!("read {}: {error}", rules_path.display())); + assert!(rules.contains("marketplace.visualstudio.com")); + assert!(rules.contains("vscode-sync.trafficmanager.net")); + assert!(!rules.contains("api.openai.com")); + } } diff --git a/crates/firma-run/src/sidecar/supervisor.rs b/crates/firma-run/src/sidecar/supervisor.rs index 802bad6bf..1fac8560d 100644 --- a/crates/firma-run/src/sidecar/supervisor.rs +++ b/crates/firma-run/src/sidecar/supervisor.rs @@ -237,9 +237,18 @@ impl SidecarSupervisor { let reader = std::io::BufReader::new(stderr); let (tx, rx) = mpsc::sync_channel::(1); + let mirror_child_logs = child_log_mirroring_enabled(); let tee_handle = std::thread::Builder::new() .name("firma-sidecar-tee".into()) - .spawn(move || run_scraper(reader, log_file, tx)) + .spawn(move || { + run_scraper_with_mirror( + reader, + log_file, + std::io::stderr(), + mirror_child_logs, + tx, + ); + }) .map_err(|error| RunError::SidecarStartupFailed { reason: format!("spawn scraper thread: {error}"), log_path: log_path.clone(), @@ -414,15 +423,30 @@ const INTERCEPTOR_TOKEN: &str = "interceptor listening"; /// `tracing-subscriber` decides to enable ANSI even though stderr is /// piped). The raw bytes are written to the log file unmodified so a /// human `cat`ting the log still sees the colors the subscriber chose. +#[doc(hidden)] +pub fn run_scraper(mut reader: R, mut log: W, tx: mpsc::SyncSender) +where + R: BufRead, + W: Write, +{ + run_scraper_with_mirror(&mut reader, &mut log, std::io::sink(), false, tx); +} + #[doc(hidden)] #[expect( clippy::needless_pass_by_value, reason = "tx is moved into the spawned thread and owns the sender for the thread lifetime" )] -pub fn run_scraper(mut reader: R, mut log: W, tx: mpsc::SyncSender) -where +pub fn run_scraper_with_mirror( + mut reader: R, + mut log: W, + mut mirror: M, + mirror_enabled: bool, + tx: mpsc::SyncSender, +) where R: BufRead, W: Write, + M: Write, { let mut capture = ReadyCapture::default(); let mut signalled = false; @@ -438,6 +462,9 @@ where } Ok(_) => { let _ = log.write_all(buf.as_bytes()); + if mirror_enabled { + let _ = mirror.write_all(buf.as_bytes()); + } if !signalled { let plain = strip_ansi(&buf); if plain.contains(POLICY_TOKEN) @@ -474,6 +501,56 @@ where } } +#[cfg(unix)] +fn child_log_mirroring_enabled() -> bool { + std::env::var("FIRMA_LOG_FILTER") + .or_else(|_| std::env::var("RUST_LOG")) + .is_ok_and(|filter| log_filter_requests_child_mirroring(&filter)) +} + +#[cfg(unix)] +fn log_filter_requests_child_mirroring(filter: &str) -> bool { + filter + .split(',') + .map(str::trim) + .map(str::to_ascii_lowercase) + .any(|directive| { + directive == "debug" + || directive == "trace" + || directive.ends_with("=debug") + || directive.ends_with("=trace") + }) +} + +#[cfg(all(test, unix))] +mod log_filter_tests { + #[test] + fn debug_and_trace_filters_request_child_log_mirroring() { + for filter in [ + "debug", + "trace", + "firma_sidecar=debug", + "openfirma=trace", + " info , firma_run=debug ", + ] { + assert!( + super::log_filter_requests_child_mirroring(filter), + "{filter} should mirror child logs" + ); + } + } + + #[test] + fn less_verbose_filters_do_not_request_child_log_mirroring() { + for filter in ["", "info", "warn,firma=info", "firma_sidecar=warn"] { + assert!( + !super::log_filter_requests_child_mirroring(filter), + "{filter} should not mirror child logs" + ); + } + } +} + /// Strip CSI escape sequences (`ESC '[' ... final_byte`) used by ANSI /// colour. Conservative implementation: only handles the common /// `\x1b[...m` form `tracing-subscriber` emits. ESC and the terminator @@ -525,5 +602,5 @@ fn extract_kv(line: &str, key: &str) -> Option { #[doc(hidden)] pub mod testing { - pub use super::{ReadyCapture, ScrapeResult, run_scraper}; + pub use super::{ReadyCapture, ScrapeResult, run_scraper, run_scraper_with_mirror}; } diff --git a/crates/firma-run/tests/authority_autostart_ready_scrape.rs b/crates/firma-run/tests/authority_autostart_ready_scrape.rs index aab12d24e..3284558af 100644 --- a/crates/firma-run/tests/authority_autostart_ready_scrape.rs +++ b/crates/firma-run/tests/authority_autostart_ready_scrape.rs @@ -11,7 +11,9 @@ use std::io::Cursor; use std::sync::mpsc; -use firma_run::authority::supervisor::testing::{ScrapeResult, run_scraper}; +use firma_run::authority::supervisor::testing::{ + ScrapeResult, run_scraper, run_scraper_with_mirror, +}; #[test] fn ready_signalled_with_listen_addr_capture() { @@ -47,3 +49,28 @@ fn eof_before_ready_signals_eof() { .expect("scraper thread"); assert!(matches!(rx.try_recv().unwrap(), ScrapeResult::Eof)); } + +#[test] +fn scraper_can_mirror_child_logs_to_parent_output() { + let stderr = b"\ +2026-05-14T12:00:00Z INFO firma_authority: listening addr=\"[::1]:50051\"\n\ +2026-05-14T12:00:00Z INFO firma_authority: authority ready\n\ +"; + let (tx, rx) = mpsc::sync_channel(1); + let mut log_sink = Vec::::new(); + let mut mirror_sink = Vec::::new(); + + run_scraper_with_mirror( + Cursor::new(stderr.to_vec()), + &mut log_sink, + &mut mirror_sink, + true, + tx, + ); + + assert!(matches!(rx.recv().expect("result"), ScrapeResult::Ready(_))); + assert_eq!( + String::from_utf8(log_sink).expect("log utf8"), + String::from_utf8(mirror_sink).expect("mirror utf8") + ); +} diff --git a/crates/firma-run/tests/sidecar_autostart_ready_scrape.rs b/crates/firma-run/tests/sidecar_autostart_ready_scrape.rs index 4f3af4530..66f2ac33d 100644 --- a/crates/firma-run/tests/sidecar_autostart_ready_scrape.rs +++ b/crates/firma-run/tests/sidecar_autostart_ready_scrape.rs @@ -15,7 +15,9 @@ use std::io::Cursor; use std::sync::mpsc; -use firma_run::sidecar::supervisor::testing::{ReadyCapture, ScrapeResult, run_scraper}; +use firma_run::sidecar::supervisor::testing::{ + ReadyCapture, ScrapeResult, run_scraper, run_scraper_with_mirror, +}; const FULL: &str = "\ 2026-05-13T10:00:00Z INFO firma_sidecar::startup::log_contract: config loaded path=\"/tmp/firma_sidecar.toml\"\n\ @@ -144,3 +146,18 @@ INFO firma_sidecar::startup::log_contract: sidecar ready\n"; other => panic!("expected Ready, got {other:?}"), } } + +#[test] +fn scraper_can_mirror_child_logs_to_parent_output() { + let (tx, rx) = mpsc::sync_channel(1); + let mut log_sink = Vec::::new(); + let mut mirror_sink = Vec::::new(); + + run_scraper_with_mirror(Cursor::new(FULL), &mut log_sink, &mut mirror_sink, true, tx); + + assert!(matches!(rx.recv().expect("result"), ScrapeResult::Ready(_))); + assert_eq!( + String::from_utf8(log_sink).expect("log utf8"), + String::from_utf8(mirror_sink).expect("mirror utf8") + ); +} diff --git a/crates/firma-run/tests/sidecar_config_merge.rs b/crates/firma-run/tests/sidecar_config_merge.rs index 2a0f6dd7d..14e2bcbbe 100644 --- a/crates/firma-run/tests/sidecar_config_merge.rs +++ b/crates/firma-run/tests/sidecar_config_merge.rs @@ -16,6 +16,8 @@ use std::path::{Path, PathBuf}; use firma_config_loader::CONFIG_FILE_NAME; use firma_run::sidecar::config::testing::{SynthesizeRequest, TemplateSource, synthesize}; +use firma_sidecar::enforcement::registry::ActionClassRegistry; +use firma_sidecar::normalizer::{MappingTable, MatchResult}; use tempfile::TempDir; fn read(path: &Path) -> toml::Value { @@ -23,6 +25,12 @@ fn read(path: &Path) -> toml::Value { toml::from_str(&text).expect("parse synthesized") } +fn synthesized_mapping_table(rules_path: &Path) -> MappingTable { + let rules = fs::read_to_string(rules_path).expect("read mapping rules"); + let file = toml::from_str(&rules).expect("parse mapping rules"); + MappingTable::from_config(&file, &ActionClassRegistry::v0_1(), true).expect("mapping table") +} + /// Reads `[sidecar.audit]` from a synthesized config file. fn audit_table(value: &toml::Value) -> &toml::value::Table { value @@ -443,6 +451,43 @@ fn nonexistent_template_paths_fall_through_to_minimal() { assert_eq!(source, TemplateSource::Minimal); } +#[test] +fn vscode_minimal_mapping_covers_github_sign_in_hosts() { + let tmp = TempDir::new().expect("tmp"); + let out = tmp.path().join("sidecar.toml"); + let sock = tmp.path().join("sidecar.sock"); + synthesize(SynthesizeRequest { + agent_id: "vscode", + listen_addr: Some("127.0.0.1:18080".parse().expect("listen addr")), + ..req(&sock, &out) + }) + .expect("synthesize"); + + let table = synthesized_mapping_table(&tmp.path().join("mapping-rules.toml")); + for host in [ + "github.com", + "api.github.com", + "vscode.dev", + "insiders.vscode.dev", + "default.exp-tas.com", + "acme.ghe.com", + "api.acme.ghe.com", + "accounts.google.com", + "ssl.gstatic.com", + "avatars.githubusercontent.com", + "appleid.apple.com", + "idmsa.apple.com", + "appleid.cdn-apple.com", + ] { + match table.find_match("CONNECT", host, "/") { + MatchResult::Matched(rule) => { + assert_eq!(rule.action_class, "communication.external.send", "{host}"); + } + other => panic!("expected {host} to be mapped, got {other:?}"), + } + } +} + #[test] fn monitor_mode_injects_mode_monitor_into_sidecar_section() { let tmp = TempDir::new().expect("tmp"); diff --git a/crates/firma/src/args.rs b/crates/firma/src/args.rs index 4c22d4c47..14a43a322 100644 --- a/crates/firma/src/args.rs +++ b/crates/firma/src/args.rs @@ -74,8 +74,9 @@ pub struct Cli { #[arg(long, global = true, env = "FIRMA_LOG_FILE")] pub log_file: Option, /// `tracing` `EnvFilter` directive controlling log verbosity (e.g. `info,firma=debug`). - #[arg(long, global = true, env = "FIRMA_LOG_FILTER", default_value = "info")] - pub log_filter: String, + /// Falls back to `RUST_LOG`, then `info`. + #[arg(long, global = true, env = "FIRMA_LOG_FILTER")] + pub log_filter: Option, #[command(subcommand)] pub command: Command, @@ -114,3 +115,33 @@ pub enum Command { /// Approve or revoke HITL governance tokens for high-risk actions. Token(token::TokenArgs), } + +pub fn resolve_log_filter(firma_log_filter: Option<&str>, rust_log: Option<&str>) -> String { + firma_log_filter.or(rust_log).unwrap_or("info").to_string() +} + +#[cfg(test)] +mod tests { + #[test] + fn log_filter_falls_back_to_rust_log() { + assert_eq!( + super::resolve_log_filter(None, Some("debug")), + "debug", + "RUST_LOG should control logging when FIRMA_LOG_FILTER/--log-filter is absent" + ); + } + + #[test] + fn explicit_firma_log_filter_wins_over_rust_log() { + assert_eq!( + super::resolve_log_filter(Some("warn"), Some("debug")), + "warn", + "FIRMA_LOG_FILTER/--log-filter should keep precedence over RUST_LOG" + ); + } + + #[test] + fn default_log_filter_is_info() { + assert_eq!(super::resolve_log_filter(None, None), "info"); + } +} diff --git a/crates/firma/src/main.rs b/crates/firma/src/main.rs index a85c2c812..a8c84dc1b 100644 --- a/crates/firma/src/main.rs +++ b/crates/firma/src/main.rs @@ -14,8 +14,10 @@ use crate::args::Command; fn main() -> ExitCode { let cli = args::parse(); + let rust_log = std::env::var("RUST_LOG").ok(); + let log_filter = args::resolve_log_filter(cli.log_filter.as_deref(), rust_log.as_deref()); - if let Err(e) = log::init(&cli.log_filter, cli.log_file.as_deref()) { + if let Err(e) = log::init(&log_filter, cli.log_file.as_deref()) { output::err(format!("{e}")); return ExitCode::from(1); } From bc2079abd443b550eee51ba1cb38bfed13050124 Mon Sep 17 00:00:00 2001 From: veeso Date: Thu, 9 Jul 2026 16:23:08 +0200 Subject: [PATCH 4/4] feat(vscode): document policy and audit support --- crates/firma-core/src/run_audit.rs | 39 ++++++ crates/firma-sidecar/src/interceptor/http.rs | 23 +++- crates/firma-sidecar/src/run_audit.rs | 62 ++++++++++ crates/firma/src/doctor/render.rs | 24 ++-- crates/firma/src/services/doctor.rs | 5 +- docs-site/astro.config.mjs | 1 + docs-site/public/llms.txt | 1 + .../src/content/docs/guides/secure-vscode.md | 115 ++++++++++++++++++ 8 files changed, 261 insertions(+), 9 deletions(-) create mode 100644 docs-site/src/content/docs/guides/secure-vscode.md diff --git a/crates/firma-core/src/run_audit.rs b/crates/firma-core/src/run_audit.rs index b0fa329dc..0852e1db3 100644 --- a/crates/firma-core/src/run_audit.rs +++ b/crates/firma-core/src/run_audit.rs @@ -79,4 +79,43 @@ mod tests { actual ); } + + #[test] + fn serialize_loopback_message_uses_stable_snake_case_tag() { + let msg = RunAuditMessage { + session_id: "sess".to_string(), + agent_id: "vscode".to_string(), + event: RunAuditEvent::LoopbackBlocked { + dst_ip: "::1".to_string(), + dst_port: 9443, + }, + }; + + let value = serde_json::to_value(&msg).unwrap_or_else(|error| panic!("{error}")); + + assert_eq!(value["session_id"], "sess"); + assert_eq!(value["agent_id"], "vscode"); + assert_eq!(value["event"]["kind"], "loopback_blocked"); + assert_eq!(value["event"]["dst_ip"], "::1"); + assert_eq!(value["event"]["dst_port"], 9443); + } + + #[test] + fn unknown_event_kind_is_rejected() { + let msg = r#"{ + "session_id": "sess", + "agent_id": "claude-code", + "event": { + "kind": "not_a_real_event" + } + }"#; + + let error = serde_json::from_str::(msg) + .expect_err("unknown event kind must not deserialize"); + + assert!( + error.to_string().contains("not_a_real_event"), + "unexpected error: {error}" + ); + } } diff --git a/crates/firma-sidecar/src/interceptor/http.rs b/crates/firma-sidecar/src/interceptor/http.rs index 6b3d50880..10a854468 100644 --- a/crates/firma-sidecar/src/interceptor/http.rs +++ b/crates/firma-sidecar/src/interceptor/http.rs @@ -239,7 +239,11 @@ impl HttpInterceptor { loop { tokio::select! { accepted = listener.accept() => { - if let Ok((stream, _)) = accepted { + if let Ok((stream, peer_addr)) = accepted { + tracing::debug!( + peer_addr = %peer_addr, + "http proxy accepted client connection" + ); let handler = Arc::clone(&handler); let mitm_runtime = mitm_runtime.clone(); let max_request_body_bytes = self.max_request_body_bytes; @@ -345,6 +349,13 @@ async fn handle_request( .get("x-firma-session-id") .cloned() .unwrap_or_default(); + tracing::debug!( + method = %raw.method, + host = %raw.host, + path = %path_without_query(&raw.path), + session_id = %session_id, + "HTTP request received by sidecar" + ); let response = match handler.handle(raw, &session_id).await { HandledResponse::Ok(response) | HandledResponse::Passthrough(response) => { @@ -456,6 +467,12 @@ async fn handle_connect_request( .cloned() .unwrap_or_default(); let target_info = connect_target_info(&host_with_default_port(req, true)); + tracing::debug!( + host = %target_info.host, + port = target_info.port, + session_id = %session_id, + "CONNECT request received by sidecar" + ); let mitm_candidate = mitm_runtime .as_ref() .filter(|runtime| runtime.should_intercept_host(&target_info.host)) @@ -1703,6 +1720,10 @@ fn resource_label_from_host(host: &str) -> String { } } +fn path_without_query(path: &str) -> &str { + path.split_once('?').map_or(path, |(path, _)| path) +} + /// Best-effort extraction of the `x-firma-session-id` header for audit /// attribution on pre-pipeline denials. fn header_session_id(req: &Request) -> String { diff --git a/crates/firma-sidecar/src/run_audit.rs b/crates/firma-sidecar/src/run_audit.rs index acce308bf..59f9a996c 100644 --- a/crates/firma-sidecar/src/run_audit.rs +++ b/crates/firma-sidecar/src/run_audit.rs @@ -292,4 +292,66 @@ mod tests { exit.cancel(); let _ = handle.await; } + + #[tokio::test] + async fn closed_audit_channel_drops_valid_message_without_panic() { + let (tx, rx) = mpsc::channel(1); + drop(rx); + let line = serde_json::to_string(&loopback_msg("127.0.0.1", 9999)) + .expect("serialize run-audit message"); + + ingest_line(&line, &tx).await; + } + + #[tokio::test] + async fn blank_lines_are_ignored_before_valid_messages() { + let dir = tempfile::tempdir().expect("tempdir"); + let socket = socket_path_in(dir.path()); + let (tx, mut rx) = mpsc::channel(4); + let exit = CancellationToken::new(); + let handle = + spawn_listener(socket.clone(), tx, exit.clone()).expect("listener should bind"); + + let mut client = UnixStream::connect(&socket).await.expect("connect"); + let line = serde_json::to_string(&loopback_msg("127.0.0.1", 5432)).expect("serialize"); + client.write_all(b"\n \n").await.expect("write blanks"); + client + .write_all(line.as_bytes()) + .await + .expect("write event"); + client.write_all(b"\n").await.expect("write newline"); + client.flush().await.expect("flush"); + + let payload = tokio::time::timeout(std::time::Duration::from_secs(2), rx.recv()) + .await + .expect("payload within timeout") + .expect("payload present"); + assert_eq!(payload.resource, "tcp://127.0.0.1:5432"); + assert!(rx.try_recv().is_err(), "blank lines must not emit payloads"); + + exit.cancel(); + let _ = handle.await; + } + + #[tokio::test] + async fn stale_socket_file_is_replaced_and_cleaned_up() { + let dir = tempfile::tempdir().expect("tempdir"); + let socket = socket_path_in(dir.path()); + let stale = UnixListener::bind(&socket).expect("bind stale socket"); + drop(stale); + assert!(socket.exists(), "dropped Unix listener leaves stale path"); + + let (tx, _rx) = mpsc::channel(4); + let exit = CancellationToken::new(); + let handle = + spawn_listener(socket.clone(), tx, exit.clone()).expect("listener should rebind"); + assert!(socket.exists(), "listener should bind the requested socket"); + + exit.cancel(); + let _ = handle.await; + assert!( + !socket.exists(), + "listener cleanup should remove the per-run socket path" + ); + } } diff --git a/crates/firma/src/doctor/render.rs b/crates/firma/src/doctor/render.rs index 6f57abbdb..a76bfcaf7 100644 --- a/crates/firma/src/doctor/render.rs +++ b/crates/firma/src/doctor/render.rs @@ -8,30 +8,40 @@ use crate::doctor::report::{Check, Report, Status}; /// Render `report` in pretty form to `out`. pub fn pretty(report: &Report, out: &mut dyn Write) -> io::Result<()> { + pretty_with_color(report, out, false) +} + +/// Render `report` in pretty form with terminal color enabled. +pub fn pretty_for_stdout(report: &Report, out: &mut dyn Write) -> io::Result<()> { + pretty_with_color(report, out, true) +} + +fn pretty_with_color(report: &Report, out: &mut dyn Write, color: bool) -> io::Result<()> { writeln!(out, "firma doctor")?; writeln!(out, "=============")?; for check in &report.checks { - writeln!(out, "{}", format_check(check))?; + writeln!(out, "{}", format_check(check, color))?; } Ok(()) } -fn format_check(check: &Check) -> String { - // Color is gated on stdout being a TTY via owo-colors' `if_supports_color`, - // so captured buffers (tests, `firma doctor | tee log`) stay plain ASCII. +fn format_check(check: &Check, color: bool) -> String { let tag = match check.status { - Status::Ok => format!( + Status::Ok if color => format!( "{}", "[OK] ".if_supports_color(Stream::Stdout, |t| t.green()) ), - Status::Warn => format!( + Status::Warn if color => format!( "{}", "[WARN]".if_supports_color(Stream::Stdout, |t| t.yellow()) ), - Status::Fail => format!( + Status::Fail if color => format!( "{}", "[FAIL]".if_supports_color(Stream::Stdout, |t| t.bright_red()) ), + Status::Ok => "[OK] ".to_string(), + Status::Warn => "[WARN]".to_string(), + Status::Fail => "[FAIL]".to_string(), }; format!("{tag} {:<22} {}", check.category, check.reason) } diff --git a/crates/firma/src/services/doctor.rs b/crates/firma/src/services/doctor.rs index a4b6018a1..b9050e334 100644 --- a/crates/firma/src/services/doctor.rs +++ b/crates/firma/src/services/doctor.rs @@ -1,6 +1,6 @@ //! Wire `firma doctor` CLI args to the doctor module. -use std::io::{self, Write}; +use std::io::{self, IsTerminal as _, Write}; use std::path::PathBuf; use std::process::ExitCode; use std::time::Duration; @@ -45,9 +45,12 @@ pub fn run(args: Args) -> ExitCode { let RenderedReport(report, json) = runtime.block_on(build_report(args)); + let stdout_is_terminal = io::stdout().is_terminal(); let mut stdout = io::stdout().lock(); let render_result = if json { render::json(&report, &mut stdout) + } else if stdout_is_terminal { + render::pretty_for_stdout(&report, &mut stdout) } else { render::pretty(&report, &mut stdout) }; diff --git a/docs-site/astro.config.mjs b/docs-site/astro.config.mjs index 64b09d9b8..0610c1a9c 100644 --- a/docs-site/astro.config.mjs +++ b/docs-site/astro.config.mjs @@ -136,6 +136,7 @@ export default defineConfig({ { label: 'Read & verify the audit log', slug: 'guides/audit-log' }, { label: 'Secure a local coding agent', slug: 'guides/secure-a-coding-agent' }, { label: 'Secure GitHub Copilot CLI', slug: 'guides/secure-github-copilot' }, + { label: 'Secure Visual Studio Code', slug: 'guides/secure-vscode' }, { label: 'Deploy a GenAI web app', slug: 'guides/deploy-a-genai-webapp' }, ], }, diff --git a/docs-site/public/llms.txt b/docs-site/public/llms.txt index 474ed9f21..573f7b2f1 100644 --- a/docs-site/public/llms.txt +++ b/docs-site/public/llms.txt @@ -23,6 +23,7 @@ OpenFirma docs highlights for LLM-based retrieval: - Capability token non-exposure applies when capabilities are pre-seeded into the Sidecar; current `firma run --capability-file` exports capability material into the wrapped process environment for compatibility. - Runtime logs for non-structural backends use "backend compatibility proof" with `mode=proxy_only enforced=false` instead of "backend network enforcement proof". - The built-in `copilot` profile secures the GitHub Copilot CLI: its seccomp baseline permits `filesystem.delete` (Copilot's `~/.copilot/*.db` SQLite session store), it sets CA trust mode `AppendSystemRoots` (system roots + firma-ca, fixing `UnknownIssuer` on real GitHub TLS), and it passes through `GITHUB_TOKEN`/`GH_TOKEN`/`GH_COPILOT_TOKEN`. `firma config --profile copilot` writes the `copilot` mapping plus `https_mitm.bypass_hosts` for `github.com`, `api.github.com`, `uploads.github.com`. `firma run -- copilot` auto-selects the profile from the command word, but `gh copilot` needs explicit `--profile copilot`. Validated on Linux `bwrap` only. +- The built-in `vscode` profile supports `firma run --profile vscode -- code .` and `firma run -- code .`. It creates a per-run `code` shim that forces `--wait`, `--new-window`, project-local isolated `--user-data-dir` and `--extensions-dir` under `.firma/vscode`, a writable per-run desktop runtime directory, and `--no-sandbox` for VS Code's inner Chromium sandbox; it seeds the isolated profile to prefer VS Code's GitHub device-code sign-in flow; it sets CA trust mode `AppendSystemRoots`; Linux bwrap closes idle sandbox-to-Sidecar adapter relays after two minutes to avoid bridge slot exhaustion during extension installs; and `firma config --profile vscode` writes a CONNECT-level `vscode` mapping for VS Code core, marketplace, sync, Microsoft account, GitHub.com, hosted GitHub Enterprise (`*.ghe.com`), and GitHub social sign-in through Google or Apple. Add separate mappings for embedded agents such as Copilot, OpenAI/Codex, or Anthropic/Claude. Integrated terminal command governance is not part of this v1 profile. - `firma config` reads existing target `firma.toml` values as defaults; CLI flags override only supplied fields. - Simultaneous Authority and Sidecar startup is safe. The Sidecar retries Authority policy and revocation streams with exponential backoff and remains fail-closed until stream hydration. - `sidecar ready` is emitted only after the first policy bundle is applied and the revocation stream is ready. Cloud Run multi-container deployments should probe the Sidecar `/healthz` endpoint on port 9000 instead of adding a startup script that sequences Authority before Sidecar. diff --git a/docs-site/src/content/docs/guides/secure-vscode.md b/docs-site/src/content/docs/guides/secure-vscode.md new file mode 100644 index 000000000..6747e7da1 --- /dev/null +++ b/docs-site/src/content/docs/guides/secure-vscode.md @@ -0,0 +1,115 @@ +--- +title: Secure Visual Studio Code +description: Run VS Code under firma run with an isolated project-local profile and a per-run code shim. +--- + +Visual Studio Code behaves more like a browser than a normal CLI. The desktop +app can open marketplace, sync, account, update, and GitHub endpoints. +Extensions can also make network requests and spawn local commands. + +The built-in `vscode` profile is a compatibility profile for that shape. It +wraps the `code` launcher with a per-run shim, keeps the VS Code process +attached to `firma run`, and routes cooperative HTTP traffic through the +Sidecar. + +## What the vscode profile does + +| Concern | Behavior | +| ---------------------- | -------------------------------------------------------------------- | +| Launch command | Supports `firma run --profile vscode -- code .` | +| Process lifetime | Forces `--wait` and `--new-window` through a runtime `code` shim | +| VS Code state | Persists user-data and extensions under `.firma/vscode/` | +| Desktop runtime | Uses a writable per-run `XDG_RUNTIME_DIR` for VS Code IPC | +| Chromium inner sandbox | Disabled because `firma run` provides the outer sandbox boundary | +| GitHub sign-in | Prefers VS Code's device-code flow inside the isolated profile | +| Mapping | CONNECT rules for VS Code, marketplace, accounts, and GitHub sign-in | +| Sandbox CA trust store | system roots + firma-ca (`AppendSystemRoots`) | + +The shim is created inside the per-run runtime directory. It is not installed +globally, does not replace `/usr/bin/code`, and only affects the current +`firma run` session. + +VS Code user data and installed extensions persist next to the resolved config +under `.firma/vscode/user-data` and `.firma/vscode/extensions`. The per-run +runtime directory is still used for the shim, desktop IPC files, and other +process-local state. + +## Step 1: Scaffold the config + +```bash +firma config --profile vscode --posture dev +``` + +This selects the `vscode` mapping. The mapping is CONNECT-level in v1: VS Code +core services, marketplace hosts, Settings Sync, and Microsoft and GitHub +account or repository flows are classified as `communication.external.send`. +That includes GitHub.com, hosted GitHub Enterprise (`*.ghe.com`), and the +Google and Apple identity-provider hosts GitHub can use during sign-in. Add +the matching extra mapping when you want an embedded agent, such as Copilot, +OpenAI/Codex, or Anthropic/Claude. + +## Step 2: Run VS Code + +```bash +firma run --config .firma/firma.toml --profile vscode -- code . +``` + +`firma run -- code .` also auto-selects the `vscode` profile because `code` is +an alias for the profile. Passing `--profile vscode` keeps scripts explicit. + +The profile rejects VS Code arguments that would bypass the managed runtime +state, including `--reuse-window`, `--user-data-dir`, and `--extensions-dir`. +Use the default command shape above unless you are deliberately testing launch +behavior. + +GitHub sign-in uses VS Code's device-code flow by default inside the isolated +profile. This avoids browser callback paths that can break across the sandbox +network namespace while still supporting GitHub.com, hosted GitHub Enterprise, +and GitHub social sign-in through Google or Apple. + +## State Cleanup + +To reset the isolated VS Code profile for a project, remove the project-local +state directory: + +```bash +rm -rf .firma/vscode +``` + +This does not remove the host VS Code profile under `~/.config/Code` or host +extensions under `~/.vscode/extensions`. + +## Sharp edges + +- **Project-local state.** Accounts, extensions, Settings Sync state, and + extension installs live under `.firma/vscode` for the resolved config. The + state is intentionally separate from the host VS Code profile. +- **Linux desktop integration.** The profile passes through display-related + environment, creates a writable per-run desktop runtime directory, and links + the host Wayland and D-Bus sockets into it when present. This keeps VS Code's + own IPC files writable without making the host runtime directory writable. +- **Chromium sandboxing.** The shim adds `--no-sandbox` for VS Code's inner + Chromium sandbox on Linux. The bwrap backend remains the sandbox boundary. +- **Extensions are broad.** Extensions run with VS Code's permissions. Their + network traffic is only covered when it uses the sandboxed process and + reaches hosts covered by the mapping, extra selected mappings, or your added + rules. +- **Idle adapter relays are closed.** On Linux bwrap, idle sandbox-to-Sidecar + adapter relays are closed after two minutes so browser-style keep-alive + sockets do not exhaust the local bridge during extension installs. +- **Integrated terminal commands are not governed in v1.** Network egress from + terminal commands is still constrained by the backend boundary. Per-command + local-exec decisions for integrated terminals depend on the separate command + interception work. +- **Linux bwrap is the validated path.** macOS `vz` and Windows/WSL2 `wsl2` + remain proxy-only compatibility backends unless you opt into a structural + mode documented in [Wrap an agent with firma run](../firma-run/). + +## What's next + +- [Wrap an agent with firma run](../firma-run/) for backend guarantees and + non-structural caveats. +- [Extend the action-class mapping](../extend-mapping/) when an extension needs + another host. +- [Enable HTTPS MITM](../https-mitm/) if you need per-endpoint classification + for a specific REST API.