security(firma-run): mask bwrap access to Firma config - #413
Conversation
2f7455f to
f74406f
Compare
|
We should mask all
Are we sure we want to add this caveat? What's the practical usecase? |
21b6d44 to
38610aa
Compare
Could you clarify what all means? The PR was still in draft, and its description was outdated. I wanted to review all potential attack vectors:
We store the VS Code profile under |
38610aa to
731c264
Compare
That's what I was referring to—we need to mask all candidates because even if it's not the |
Yes, that's covered. It masks both the candidate directories and the active configuration.
I chose to mask the entire |
87e6e79 to
ab63579
Compare
|
I asked Sol to poke at the PR from an adversarial perspective, and it came up with a few ways to circumvent our guardrails.
use std::fmt::Write as _;
/// A missing, higher-precedence `.firma/` candidate must not be plantable from
/// inside the sandbox.
#[test]
#[ignore = "regression target: absent discovery candidates are not masked yet"]
fn missing_nearer_firma_candidate_cannot_be_planted() {
let tmp = tempfile::tempdir().expect("tempdir");
let workspace = tmp.path().join("workspace");
let run_cwd = workspace.join("service");
let config_dir = workspace.join(".firma");
let state_dir = tmp.path().join("state");
std::fs::create_dir_all(&run_cwd).expect("mkdir run cwd");
scaffold_mask_test_config(&config_dir, &state_dir, &workspace);
let planted_config = run_cwd.join(".firma/firma.toml");
assert!(
!planted_config.exists(),
"precondition: nearer config candidate must be absent"
);
let shell = format!(
"mkdir -p {candidate_dir} && printf '%s\\n' '# planted by sandbox' > {candidate}; \
echo {ran}",
candidate_dir = shell_quote(planted_config.parent().expect("candidate parent")),
candidate = shell_quote(&planted_config),
ran = MASK_TEST_RAN_MARKER,
);
let output = run_structural_shell(None, &run_cwd, &shell);
assert_mask_test_ran(&output);
assert!(
!planted_config.exists(),
"sandbox created a higher-precedence host config at {}",
planted_config.display()
);
}
/// Discovery may follow a `.firma` directory symlink, but the selected config
/// must not remain readable through the symlink's canonical target.
#[test]
fn directory_symlink_config_cannot_be_read_via_canonical_path() {
let tmp = tempfile::tempdir().expect("tempdir");
let workspace = tmp.path().join("workspace");
let external_config_dir = tmp.path().join("external-config");
let state_dir = tmp.path().join("state");
std::fs::create_dir_all(&workspace).expect("mkdir workspace");
let config_file =
scaffold_mask_test_config(&external_config_dir, &state_dir, &workspace);
std::os::unix::fs::symlink(&external_config_dir, workspace.join(".firma"))
.expect("symlink workspace .firma to external config directory");
let shell = format!(
"cat {config} 2>/dev/null; echo {ran}",
config = shell_quote(&config_file),
ran = MASK_TEST_RAN_MARKER,
);
let output = run_structural_shell(None, &workspace, &shell);
assert_mask_test_ran(&output);
assert!(
!String::from_utf8_lossy(&output.stdout).contains(MASK_TEST_SENTINEL),
"sandbox read the selected config through the canonical directory-symlink target"
);
}
/// Masking the lexical `.firma/` directory must also protect a selected
/// `firma.toml` that is itself a symlink to a writable workspace file.
#[test]
fn file_symlink_config_cannot_be_read_or_modified_via_target() {
let tmp = tempfile::tempdir().expect("tempdir");
let workspace = tmp.path().join("workspace");
let config_dir = workspace.join(".firma");
let state_dir = tmp.path().join("state");
std::fs::create_dir_all(&workspace).expect("mkdir workspace");
let lexical_config =
scaffold_mask_test_config(&config_dir, &state_dir, &workspace);
let canonical_target = workspace.join("firma-target.toml");
std::fs::rename(&lexical_config, &canonical_target)
.expect("move config to symlink target");
std::os::unix::fs::symlink(&canonical_target, &lexical_config)
.expect("symlink firma.toml to workspace target");
let original =
std::fs::read_to_string(&canonical_target).expect("read pristine target");
let shell = format!(
"cat {target} 2>/dev/null; printf '%s\\n' '# modified by sandbox' >> {target}; \
echo {ran}",
target = shell_quote(&canonical_target),
ran = MASK_TEST_RAN_MARKER,
);
let output = run_structural_shell(None, &workspace, &shell);
assert_mask_test_ran(&output);
assert!(
!String::from_utf8_lossy(&output.stdout).contains(MASK_TEST_SENTINEL),
"sandbox read the selected config through the canonical file-symlink target"
);
assert_eq!(
std::fs::read_to_string(&canonical_target).expect("read target after run"),
original,
"sandbox modified the selected config through its canonical symlink target"
);
}
/// A general-purpose mount of the workspace at another target must not create
/// an unmasked alias for the config directory contained in that workspace.
#[test]
fn workspace_mount_alias_does_not_reexpose_firma_config() {
let tmp = tempfile::tempdir().expect("tempdir");
let workspace = tmp.path().join("workspace");
let config_dir = workspace.join(".firma");
let mount_alias = tmp.path().join("workspace-alias");
let state_dir = tmp.path().join("state");
std::fs::create_dir_all(&workspace).expect("mkdir workspace");
std::fs::create_dir_all(&mount_alias).expect("mkdir mount alias target");
let config_file =
scaffold_mask_test_config(&config_dir, &state_dir, &workspace);
append_profile_mount(&config_file, &workspace, &mount_alias);
let aliased_config = mount_alias.join(".firma/firma.toml");
let shell = format!(
"cat {config} 2>/dev/null; echo {ran}",
config = shell_quote(&aliased_config),
ran = MASK_TEST_RAN_MARKER,
);
let output =
run_structural_shell(Some(&config_file), &workspace, &shell);
assert_mask_test_ran(&output);
assert!(
!String::from_utf8_lossy(&output.stdout).contains(MASK_TEST_SENTINEL),
"workspace mount exposed firma.toml through {}",
aliased_config.display()
);
}
/// A general-purpose mount directly targeting `.firma/` must not be implicitly
/// authorized to replace the security mask after it has been installed.
#[test]
fn mount_targeting_firma_dir_does_not_replace_mask() {
let tmp = tempfile::tempdir().expect("tempdir");
let workspace = tmp.path().join("workspace");
let config_dir = workspace.join(".firma");
let state_dir = tmp.path().join("state");
std::fs::create_dir_all(&workspace).expect("mkdir workspace");
let config_file =
scaffold_mask_test_config(&config_dir, &state_dir, &workspace);
append_profile_mount(&config_file, &config_dir, &config_dir);
let shell = format!(
"cat {config} 2>/dev/null; echo {ran}",
config = shell_quote(&config_file),
ran = MASK_TEST_RAN_MARKER,
);
let output =
run_structural_shell(Some(&config_file), &workspace, &shell);
assert_mask_test_ran(&output);
assert!(
!String::from_utf8_lossy(&output.stdout).contains(MASK_TEST_SENTINEL),
"post-mask mount targeting .firma re-exposed the selected config"
);
}
const MASK_TEST_SENTINEL: &str = "firma-mask-adversarial-sentinel";
const MASK_TEST_RAN_MARKER: &str = "FIRMA-MASK-ADVERSARIAL-RAN";
fn scaffold_mask_test_config(
config_dir: &Path,
state_dir: &Path,
workspace: &Path,
) -> PathBuf {
bootstrap_config(config_dir, state_dir, workspace);
let config_file = config_dir.join("firma.toml");
disable_host_home_masks(&config_file);
let generated =
std::fs::read_to_string(&config_file).expect("read generated config");
std::fs::write(
&config_file,
format!("{generated}\n# {MASK_TEST_SENTINEL}\n"),
)
.expect("plant config sentinel");
config_file
}
fn append_profile_mount(config_file: &Path, source: &Path, target: &Path) {
let mut config =
std::fs::read_to_string(config_file).expect("read generated config");
write!(
config,
"\n[[run.profiles.generic.mounts]]\n\
source = \"{}\"\n\
target = \"{}\"\n\
read_only = false\n",
source.display(),
target.display(),
)
.expect("render adversarial profile mount");
std::fs::write(config_file, config)
.expect("append adversarial profile mount");
}
fn run_structural_shell(
config_file: Option<&Path>,
cwd: &Path,
shell: &str,
) -> std::process::Output {
let mut command = Command::new(firma_bin());
command.args(["run", "--profile", "generic"]);
if let Some(config_file) = config_file {
command.arg("--config").arg(config_file);
}
command
.args(["--", "sh", "-c", shell])
.current_dir(cwd)
.env("NO_COLOR", "1")
.output()
.expect("spawn firma run")
}
fn assert_mask_test_ran(output: &std::process::Output) {
let stdout = String::from_utf8_lossy(&output.stdout);
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
output.status.success(),
"firma run failed:\nstdout:\n{stdout}\nstderr:\n{stderr}"
);
assert!(
stdout.contains(MASK_TEST_RAN_MARKER),
"sandboxed command did not run:\nstdout:\n{stdout}\nstderr:\n{stderr}"
);
} |
ab63579 to
5895d33
Compare
What
Mask every discoverable
.firma/inside the bwrap sandbox so a compromised orprompt-injected agent cannot read Authority topology /
agent_idor poisonenforcement config for a later
firma run(cross-session config poisoning).Masks the discovery-relevant set: every
.firma/on the cwd walk-up path,$HOME/.firma, and the explicitly-resolved--configfile. Fail-closed oncanonicalize errors; symlink-swap guarded.
Workspace-parent mounts (review follow-up)
Addresses the review point that the workspace
.firma/was still exposed. Agentprofiles bind the workspace read-write (e.g.
codexmounts the run cwd) — aparent of
.firma/. Since bwrap is last-write-wins, a naive bind re-exposedfirma.tomlover the mask.Fix: partition profile mounts around the mask by
MountSpec::targets_firma_dir().Mounts outside any
.firma/emit before the mask (so it wins over aworkspace-parent bind); mounts inside one (e.g. the
vscodeprofile's.firma/vscode/state) emit after, re-exposing that subpath via last-write-wins.Invariants
compatibility mode and cannot mask the file).
Tests
firma-rununit:filesystem_layout_*(mount ordering),targets_firma_dir_*.firmaintegration (structural_sandbox, real bwrap, codex profile):masks_firma_config_under_workspace_mount— plants a sentinel infirma.toml,cats it inside the sandbox, asserts it never reaches stdout under the
workspace-parent mount. CI-only serial group.