Skip to content

security(firma-run): mask bwrap access to Firma config - #413

Open
luca-iachini wants to merge 1 commit into
mainfrom
fir-463-hide-firma-config
Open

security(firma-run): mask bwrap access to Firma config#413
luca-iachini wants to merge 1 commit into
mainfrom
fir-463-hide-firma-config

Conversation

@luca-iachini

@luca-iachini luca-iachini commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

What

Mask every discoverable .firma/ inside the bwrap sandbox so a compromised or
prompt-injected agent cannot read Authority topology / agent_id or poison
enforcement 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 --config file. Fail-closed on
canonicalize errors; symlink-swap guarded.

Workspace-parent mounts (review follow-up)

Addresses the review point that the workspace .firma/ was still exposed. Agent
profiles bind the workspace read-write (e.g. codex mounts the run cwd) — a
parent of .firma/. Since bwrap is last-write-wins, a naive bind re-exposed
firma.toml over 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 a
workspace-parent bind); mounts inside one (e.g. the vscode profile's
.firma/vscode/ state) emit after, re-exposing that subpath via last-write-wins.

Invariants

  • Fail closed; the mask is a bwrap mechanism (macOS falls back to non-structural
    compatibility mode and cannot mask the file).

Tests

  • firma-run unit: filesystem_layout_* (mount ordering), targets_firma_dir_*.
  • firma integration (structural_sandbox, real bwrap, codex profile):
    masks_firma_config_under_workspace_mount — plants a sentinel in firma.toml,
    cats it inside the sandbox, asserts it never reaches stdout under the
    workspace-parent mount. CI-only serial group.

@luca-iachini
luca-iachini force-pushed the fir-463-hide-firma-config branch from 2f7455f to f74406f Compare July 31, 2026 18:28
@LukeMathWalker

Copy link
Copy Markdown
Contributor

We should mask all .firma directories, not just the resolved one, for similar reasons.

Explicit submounts under .firma/ still survive

Are we sure we want to add this caveat? What's the practical usecase?

@luca-iachini
luca-iachini force-pushed the fir-463-hide-firma-config branch 10 times, most recently from 21b6d44 to 38610aa Compare August 4, 2026 15:17
@luca-iachini

luca-iachini commented Aug 4, 2026

Copy link
Copy Markdown
Contributor Author

should mask all .firma directories, not just the resolved one, for similar reasons.

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:

  • It masks every .firma directory found while walking up the directory tree.
  • it masks the home dir .firma because home dir is mounted as rw.
  • It does not mask the workspace .firma directories. I think we need a broader solution for this, including protection against planting/TOCTOU attacks. For example, a compromised prompt could create a .firma directory in a subdirectory or cwd, and a later cd subdir && firma run would use that crafted configuration. I'll create a separate ticket to track this issue.

Explicit submounts under .firma/ still survive

Are we sure we want to add this caveat? What's the practical usecase?

We store the VS Code profile under .firma/vscode, and I did not want to break that behavior in this PR. We should investigate this further. For now, these mounts are managed by the firma binary, so the agent cannot craft them directly.

@luca-iachini
luca-iachini force-pushed the fir-463-hide-firma-config branch from 38610aa to 731c264 Compare August 4, 2026 15:38
@luca-iachini
luca-iachini marked this pull request as ready for review August 4, 2026 16:05
@LukeMathWalker

Copy link
Copy Markdown
Contributor

It does not mask the workspace .firma directories. I think we need a broader solution for this, including protection against planting/TOCTOU attacks. For example, a compromised prompt could create a .firma directory in a subdirectory or cwd, and a later cd subdir && firma run would use that crafted configuration. I'll create a separate ticket to track this issue.

That's what I was referring to—we need to mask all candidates because even if it's not the .firma directory that's being used for the current session, it can become the config directory for another session.
Alternatively, we can restrict masking to the config file itself, rather than the directory, but I'm not sure if bubblewrap can support us there.

@luca-iachini

Copy link
Copy Markdown
Contributor Author

That's what I was referring to—we need to mask all candidates because even if it's not the .firma directory that's being used for the current session, it can become the config directory for another session.

Yes, that's covered. It masks both the candidate directories and the active configuration.

Alternatively, we can restrict masking to the config file itself, rather than the directory, but I'm not sure if bubblewrap can support us there.

bubblewrap does support masking individual files (it makes the file point to /dev/null), and I already use that for the file passed via the --config flag.

I chose to mask the entire .firma directory because I want to prevent the agent from adding new policies or mappings there. Masking the directory also ensures that any sensitive configuration files we add to .firma in the future are protected.

Comment thread crates/firma-config-loader/src/resolver.rs Outdated
@luca-iachini
luca-iachini force-pushed the fir-463-hide-firma-config branch 3 times, most recently from 87e6e79 to ab63579 Compare August 5, 2026 16:22
@LukeMathWalker

LukeMathWalker commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

I asked Sol to poke at the PR from an adversarial perspective, and it came up with a few ways to circumvent our guardrails.
Namely:

  • Symlinks, a never-ending source of pain. At this stage, we may simply reject them for simplicity.
  • Interactions with other mount directives.
  • Planting at a sub-directory (which we already identified, but worth perhaps capturing an ignored test for it as part of this PR)
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}"
    );
}

Comment thread crates/firma-config-loader/src/resolver.rs Outdated
Comment thread crates/firma-config-loader/src/resolver.rs Outdated
@luca-iachini
luca-iachini force-pushed the fir-463-hide-firma-config branch from ab63579 to 5895d33 Compare August 6, 2026 14:47
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants