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 36644a533..6ae078c19 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(&HeaderName::from_static("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 5a0bbf64e..c8dd39caa 100644 --- a/crates/firma-sidecar/src/run_audit.rs +++ b/crates/firma-sidecar/src/run_audit.rs @@ -295,4 +295,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 aaf1a2d3d..3cc1b4db5 100644 --- a/docs-site/public/llms.txt +++ b/docs-site/public/llms.txt @@ -27,7 +27,8 @@ OpenFirma docs highlights for LLM-based retrieval: - FirmaTeam capability verification: set `run.profiles..capability.public_key_path` (or the run default) to FirmaTeam's exported raw 32-byte Ed25519 workspace public key. This key overrides `[sidecar.authority].public_key_path` for the run, verifies every `IssueCapability` PASETO response locally, enables per-session mint/refresh management, and is written into the autostarted Sidecar config. Missing or unreadable keys, non-32-byte files, malformed or expired tokens, and signature mismatches fail closed. Relative paths remain `firma run` working-directory-relative. `--capability-file` and capability-seed TOML parsing are unchanged. - Runtime logs for non-structural backends use "backend compatibility proof" with `mode=proxy_only enforced=false` instead of "backend network enforcement proof". - The managed seccomp baseline does not deny `filesystem.delete`: seccomp cannot encode path scopes, so workspace-scoped delete is enforced structurally by the bwrap read-only rootfs plus read-write workspace/runtime-home mounts (deletes outside the workspace fail on the read-only mount). This lets development tools like Cargo run. -- The built-in `copilot` profile secures the GitHub Copilot CLI: 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` (Copilot's `~/.copilot/*.db` SQLite session store works via the workspace/runtime-home delete path above). `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 `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. - Native GitHub HTTPS git enforcement uses the `github` mapping on `github.com`: `GET /*/*.git/info/refs` and `POST /*/*.git/git-upload-pack` map to `code.read`; `POST /*/*.git/git-receive-pack` maps to `code.write`, with delete ref updates promoted to `code.destructive`. - GitHub branch/ref policy fields are optional Cedar context attributes: `git_provider`, `git_owner`, `git_repo`, `git_ref`, `git_ref_type`, and `git_operation` (`read`, `write`, `delete`). - Native GitHub git enforcement and git credential injection require HTTPS MITM for `github.com`; CONNECT-only mode cannot see `git-receive-pack`, cannot enforce branch/ref policy, and cannot attach credentials to the inner request. 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.