Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 1 addition & 2 deletions crates/firma-config-loader/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,6 @@ workspace = true

[features]
clap = ["dep:clap"]
test-utils = []

[dependencies]
anyhow = { workspace = true }
Expand All @@ -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 }
6 changes: 6 additions & 0 deletions crates/firma-config-loader/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
33 changes: 29 additions & 4 deletions crates/firma-config-loader/src/profile.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -25,6 +28,7 @@ impl AgentProfile {
Self::Codex => "codex",
Self::ClaudeCode => "claude-code",
Self::Copilot => "copilot",
Self::Vscode => "vscode",
}
}

Expand All @@ -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,
}
}
Expand All @@ -47,6 +52,7 @@ impl AgentProfile {
Self::Generic | Self::ClaudeCode => "anthropic",
Self::Codex => "openai",
Self::Copilot => "github",
Self::Vscode => "vscode",
}
}

Expand All @@ -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"
}
}
}
}
Expand All @@ -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")
);
}
}
15 changes: 3 additions & 12 deletions crates/firma-config-loader/src/resolver.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<PathBuf>,
}

Expand Down Expand Up @@ -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<PathBuf>) -> Self {
self.walk_ceiling = Some(ceiling.into());
self
Expand Down Expand Up @@ -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)) {
Expand Down Expand Up @@ -182,7 +174,6 @@ impl ConfigResolver {
});
}
}
#[cfg(feature = "test-utils")]
if walk_ceiling.is_some_and(|ceiling| dir == ceiling) {
break;
}
Expand Down
39 changes: 39 additions & 0 deletions crates/firma-core/src/run_audit.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::<RunAuditMessage>(msg)
.expect_err("unknown event kind must not deserialize");

assert!(
error.to_string().contains("not_a_real_event"),
"unexpected error: {error}"
);
}
}
13 changes: 13 additions & 0 deletions crates/firma-run/seccomp/vscode-local-command-v1.toml
Original file line number Diff line number Diff line change
@@ -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",
]
85 changes: 81 additions & 4 deletions crates/firma-run/src/authority/supervisor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -186,9 +186,18 @@ impl AuthoritySupervisor {
})?;
let reader = std::io::BufReader::new(stderr);
let (tx, rx) = mpsc::sync_channel::<ScrapeResult>(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(),
Expand Down Expand Up @@ -494,15 +503,30 @@ fn select_loopback_v6_port() -> Result<SocketAddr, RunError> {

const LISTENING_TOKEN: &str = "listening";

#[doc(hidden)]
pub fn run_scraper<R, W>(mut reader: R, mut log: W, tx: mpsc::SyncSender<ScrapeResult>)
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<R, W>(mut reader: R, mut log: W, tx: mpsc::SyncSender<ScrapeResult>)
where
pub fn run_scraper_with_mirror<R, W, M>(
mut reader: R,
mut log: W,
mut mirror: M,
mirror_enabled: bool,
tx: mpsc::SyncSender<ScrapeResult>,
) where
R: BufRead,
W: Write,
M: Write,
{
let mut capture = ReadyCapture::default();
let mut signalled = false;
Expand All @@ -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)
Expand All @@ -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();
Expand Down Expand Up @@ -584,7 +661,7 @@ fn extract_kv(line: &str, key: &str) -> Option<String> {

#[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))]
Expand Down
29 changes: 29 additions & 0 deletions crates/firma-run/src/backend/linux_bwrap.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down
Loading
Loading