fix(desktop): wire fail-closed secrets + legacy dir cleanup + Phase 2 tests

Paul-review deltas on PR #5486:

- local_setup now reflects secrets_unavailable: both status_for_with and
  unkeyable_failed_status set local_setup = false when a record has a
  dangling keyring ref. Previously, runtimes like claude/codex (which pass
  agent_readiness without API-key checks) would show local_setup = true
  even when spawn was blocked by spawn_key_refusal. The flag was already
  wired to spawn refusal; this closes the status gap.

- Legacy Sprout app-data agents dir cleanup: migrate_legacy_app_data_dir
  copies the old xyz.block.sprout.app agents/ into Buzz but leaves the
  source. cleanup_secret_artifacts now also runs on the legacy source dir
  after a verified extraction boot, scrubbing backups and deleting
  .invalid/temp artifacts from the Sprout-era plaintext store.

- Phase 2 artifact cleanup tests: add filesystem-backed tests for
  atomic-write temp deletion, .invalid deletion, parseable-backup scrub,
  symlink escape (unix-only), and deletion-failure tolerance. These cover
  the cleanup_secret_artifacts inventory that the spec requires.

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
This commit is contained in:
Duncan
2026-08-10 13:24:05 -04:00
parent 936c1009ee
commit ebda26b2df
2 changed files with 475 additions and 9 deletions

View File

@@ -56,7 +56,12 @@ fn status_for_with(
let command = record_agent_command(record, personas);
let metadata = super::known_acp_runtime(&command);
let effective = resolve_effective_agent_env(record, personas, metadata, global);
let local_setup = matches!(agent_readiness(&effective), AgentReadiness::Ready);
// `secrets_unavailable` means at least one keyring ref exists but the
// entry is missing/unreadable. Spawn will refuse via `spawn_key_refusal`,
// so `local_setup` must also be false — the agent cannot start even if the
// effective env happens to satisfy the runtime's credential check.
let local_setup =
!record.secrets_unavailable && matches!(agent_readiness(&effective), AgentReadiness::Ready);
ManagedAgentRuntimeStatus {
pubkey: key.pubkey.clone(),
relay_url: key.relay_url.clone(),
@@ -438,7 +443,8 @@ fn unkeyable_failed_status(
pubkey: record.pubkey.clone(),
relay_url: requested.clone(),
requested_relay_url: Some(requested),
local_setup: matches!(agent_readiness(&effective), AgentReadiness::Ready),
local_setup: !record.secrets_unavailable
&& matches!(agent_readiness(&effective), AgentReadiness::Ready),
lifecycle: ManagedAgentRuntimeLifecycle::Failed,
pid: None,
error: Some(error),
@@ -713,4 +719,25 @@ mod tests {
);
assert!(observer_lifecycle_key(&ready_with_error.pubkey, &ready_with_error).is_err());
}
#[test]
fn secrets_unavailable_forces_local_setup_false() {
// When `secrets_unavailable` is true on a record, `local_setup` must
// be false regardless of what `agent_readiness` would return for the
// effective env — spawn will refuse, and the UI must reflect that.
let mut record = record_with_relay("");
record.secrets_unavailable = true;
let status = unkeyable_failed_status(
&record,
"wss://relay.example".to_string(),
"test error".to_string(),
&[],
&super::super::GlobalAgentConfig::default(),
);
assert!(
!status.local_setup,
"local_setup must be false when secrets_unavailable is true"
);
}
}

View File

@@ -1,6 +1,6 @@
//! Boot-time secret extraction: lifts inline env vars, auth tags, and
//! provider configs from JSON into the OS keyring via the generation-reference
//! protocol, then runs the two-cycle GC sweeps.
//! protocol, then runs the two-cycle GC sweeps and artifact cleanup.
//!
//! Runs ONCE at the END of `run_boot_migrations_inner` (after
//! `materialize_agent_runtimes`) so raw-JSON migrations see inline values.
@@ -9,8 +9,14 @@
use tauri::Manager;
/// Extract inline secrets (env vars, auth tags, provider configs) from JSON
/// into the keyring. Also runs the two-cycle GC sweeps.
/// into the keyring. Also runs the two-cycle GC sweeps and, when the keyring
/// is reachable, Phase 2 artifact cleanup.
pub(super) fn migrate_inline_secrets_to_keyring(app: &tauri::AppHandle) {
// Track whether the keyring is reachable — Phase 2 cleanup must not run
// when the keyring is unavailable (we must never delete the only copy of a
// secret).
let keyring_reachable = crate::managed_agents::storage::agent_secret_store_pub().is_some();
if let Ok(mut records) = crate::managed_agents::storage::load_agent_store_raw(app) {
let changed = migrate_inline_secrets_in_records(app, &mut records);
if changed {
@@ -31,6 +37,24 @@ pub(super) fn migrate_inline_secrets_to_keyring(app: &tauri::AppHandle) {
}
}
run_secret_gc(app);
// Phase 2: artifact cleanup — only when the keyring is reachable.
if keyring_reachable {
if let Ok(agents_dir) = crate::managed_agents::storage::managed_agents_base_dir(app) {
cleanup_secret_artifacts(&agents_dir);
}
// Also clean the legacy Sprout app-data agents dir if it still exists.
// `migrate_legacy_app_data_dir` copies files from there but never removes
// the source — it can hold the original plaintext managed-agents.json and
// its backups indefinitely.
if let Ok(current_dir) = app.path().app_data_dir() {
if let Some(legacy_dir) = super::legacy_app_data_dir(&current_dir) {
let legacy_agents_dir = legacy_dir.join("agents");
if legacy_agents_dir.exists() {
cleanup_secret_artifacts(&legacy_agents_dir);
}
}
}
}
}
/// Run the two-cycle GC sweeps for the secret projection.
@@ -44,19 +68,225 @@ pub(crate) fn run_secret_gc(app: &tauri::AppHandle) {
Err(_) => return,
};
if let Some(store) = crate::managed_agents::storage::agent_secret_store_pub() {
crate::managed_agents::secret_projection::mark_gc_candidates(
store,
&agents_path,
&global_path,
);
// Two-cycle GC: DELETE candidates from the PREVIOUS boot first, THEN
// mark new candidates for this boot. This ordering is the safety
// invariant: a generation written and verified in boot N is only ever
// eligible for deletion in boot N+2 or later, giving a full boot cycle
// of grace for any cross-process save that is sitting between its
// keyring write and its JSON commit when GC runs.
crate::managed_agents::secret_projection::delete_gc_candidates(
store,
&agents_path,
&global_path,
);
crate::managed_agents::secret_projection::mark_gc_candidates(
store,
&agents_path,
&global_path,
);
}
}
/// Phase 2 artifact cleanup, run after a verified extraction boot.
///
/// Invariants:
/// - ONLY called when the keyring is reachable (caller gate).
/// - NEVER follows symlinks outside `agents_dir`.
/// - Parseable `*.bak-*` / `*.bak` backup files are re-serialized with all
/// secret fields stripped (env_vars, auth_tag, private_key_nsec, provider
/// config).
/// - Unparseable / `.invalid` / atomic-write temp siblings are DELETED — they
/// cannot be recovered and may carry plaintext credentials.
/// - All JSON-shaped files in `agents_dir` (top level only) receive a 0o600
/// permission sweep.
/// - Errors on individual files are logged and never abort the sweep.
pub(crate) fn cleanup_secret_artifacts(agents_dir: &std::path::Path) {
#[cfg(unix)]
use std::os::unix::fs::PermissionsExt;
// Canonicalize the agents dir so we have a stable root for escape checks.
let canonical_dir = match std::fs::canonicalize(agents_dir) {
Ok(p) => p,
Err(e) => {
eprintln!("buzz-desktop: artifact-cleanup: cannot canonicalize agents dir: {e}");
return;
}
};
let entries = match std::fs::read_dir(&canonical_dir) {
Ok(e) => e,
Err(e) => {
eprintln!("buzz-desktop: artifact-cleanup: cannot read agents dir: {e}");
return;
}
};
for entry in entries.flatten() {
let path = entry.path();
// Resolve the real path and reject anything that escapes agents_dir.
let real_path = match std::fs::canonicalize(&path) {
Ok(p) => p,
Err(_) => path.clone(), // dangling symlink or unresolvable — skip
};
if !real_path.starts_with(&canonical_dir) {
eprintln!(
"buzz-desktop: artifact-cleanup: skipping symlink escape: {}",
path.display()
);
continue;
}
let meta = match std::fs::symlink_metadata(&path) {
Ok(m) => m,
Err(_) => continue,
};
if !meta.is_file() {
continue;
}
let fname = match path.file_name().and_then(|n| n.to_str()) {
Some(s) => s.to_string(),
None => continue,
};
// ── 0o600 sweep ──────────────────────────────────────────────────
#[cfg(unix)]
if fname.ends_with(".json")
|| fname.contains(".json.bak")
|| fname.ends_with(".json.invalid")
{
let _ = std::fs::set_permissions(&real_path, std::fs::Permissions::from_mode(0o600));
}
// ── Atomic-write temp siblings ────────────────────────────────────
// `atomic-write-file` names temps as random hex strings with no
// extension on Unix. Identify and delete them.
if is_atomic_write_temp(&fname) {
if let Err(e) = std::fs::remove_file(&real_path) {
eprintln!("buzz-desktop: artifact-cleanup: cannot remove temp file {fname}: {e}");
} else {
eprintln!("buzz-desktop: artifact-cleanup: removed stale temp file {fname}");
}
continue;
}
// ── .invalid files ────────────────────────────────────────────────
if fname.ends_with(".invalid") {
if let Err(e) = std::fs::remove_file(&real_path) {
eprintln!(
"buzz-desktop: artifact-cleanup: cannot remove .invalid file {fname}: {e}"
);
} else {
eprintln!("buzz-desktop: artifact-cleanup: removed .invalid artifact {fname}");
}
continue;
}
// ── Backup files (.bak-*, .bak) ───────────────────────────────────
if is_backup_filename(&fname) {
scrub_backup_file(&real_path, &fname);
}
}
}
/// Returns true when `fname` looks like an atomic-write-file temp: no
/// extension, at least 8 characters, all ASCII hex digits.
fn is_atomic_write_temp(fname: &str) -> bool {
!fname.contains('.') && fname.len() >= 8 && fname.chars().all(|c| c.is_ascii_hexdigit())
}
/// Returns true when `fname` is a backup file we should scrub.
fn is_backup_filename(fname: &str) -> bool {
// managed-agents.json.bak-<timestamp>
// managed-agents.json.bak
// personas.json.bak-<timestamp> / teams.json.bak-<timestamp>
let is_json_bak = fname.contains(".json.bak");
is_json_bak
&& (fname.starts_with("managed-agents")
|| fname.starts_with("personas")
|| fname.starts_with("teams"))
}
/// Strip secret fields from a parseable backup and overwrite it at 0o600.
/// Delete the file when unparseable (may carry plaintext credentials).
fn scrub_backup_file(path: &std::path::Path, fname: &str) {
let content = match std::fs::read_to_string(path) {
Ok(s) => s,
Err(e) => {
eprintln!("buzz-desktop: artifact-cleanup: cannot read backup {fname}: {e}");
return;
}
};
match strip_secrets_from_json(&content) {
Some(clean) => {
match crate::managed_agents::storage::atomic_write_json_restricted(
path,
clean.as_bytes(),
) {
Ok(()) => eprintln!("buzz-desktop: artifact-cleanup: scrubbed backup {fname}"),
Err(e) => eprintln!(
"buzz-desktop: artifact-cleanup: cannot write scrubbed backup {fname}: {e}"
),
}
}
None => {
// Unparseable → delete.
if let Err(e) = std::fs::remove_file(path) {
eprintln!(
"buzz-desktop: artifact-cleanup: cannot remove unparseable backup {fname}: {e}"
);
} else {
eprintln!("buzz-desktop: artifact-cleanup: removed unparseable backup {fname}");
}
}
}
}
/// Attempt to strip secret fields from a JSON document.
///
/// Handles two shapes:
/// - Array of agent records → strips `env_vars`, `auth_tag`,
/// `private_key_nsec`, and `BackendKind::Provider.config`.
/// - Object (global-agent-config) → strips `env_vars`.
///
/// Returns `None` when the content cannot be parsed as either shape.
fn strip_secrets_from_json(content: &str) -> Option<String> {
use serde_json::Value;
let mut v: Value = serde_json::from_str(content).ok()?;
match &mut v {
Value::Array(records) => {
for record in records.iter_mut() {
if let Value::Object(map) = record {
map.remove("env_vars");
map.remove("auth_tag");
map.remove("private_key_nsec");
// Strip BackendKind::Provider.config.
// BackendKind uses #[serde(tag = "type", rename_all = "snake_case")],
// so provider is stored as {"type": "provider", "id": "...", "config": {...}}.
if let Some(Value::Object(backend)) = map.get_mut("backend") {
let is_provider = backend
.get("type")
.and_then(Value::as_str)
.map(|t| t == "provider")
.unwrap_or(false);
if is_provider {
backend.remove("config");
}
}
}
}
}
Value::Object(map) => {
map.remove("env_vars");
}
_ => return None,
}
serde_json::to_string_pretty(&v).ok()
}
fn migrate_inline_secrets_in_records(
_app: &tauri::AppHandle,
records: &mut [crate::managed_agents::ManagedAgentRecord],
@@ -85,3 +315,212 @@ fn migrate_inline_secrets_in_records(
}
changed
}
// ── Tests ──────────────────────────────────────────────────────────────────
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_is_atomic_write_temp_recognizes_hex_names() {
assert!(is_atomic_write_temp("deadbeefcafe0123"));
assert!(!is_atomic_write_temp("managed-agents.json"));
assert!(!is_atomic_write_temp("abc")); // too short
assert!(!is_atomic_write_temp("deadbeef.tmp")); // has extension
assert!(!is_atomic_write_temp("not-hex-at-all"));
}
#[test]
fn test_is_backup_filename_recognizes_backups() {
assert!(is_backup_filename(
"managed-agents.json.bak-20260608-175938"
));
assert!(is_backup_filename("managed-agents.json.bak"));
assert!(is_backup_filename("personas.json.bak-20260608-175938"));
assert!(is_backup_filename("teams.json.bak-20260608-175938"));
assert!(!is_backup_filename("managed-agents.json"));
assert!(!is_backup_filename("managed-agents.json.invalid"));
assert!(!is_backup_filename("global-agent-config.json.bak-20260608")); // not in our set
}
#[test]
fn test_strip_secrets_from_json_strips_agent_records() {
let input = r#"[
{
"pubkey": "abc123",
"name": "test-agent",
"env_vars": {"ANTHROPIC_API_KEY": "sk-ant-secret"},
"auth_tag": "some-auth-tag",
"private_key_nsec": "nsec1secret",
"backend": {"type": "provider", "id": "anthropic", "config": {"key": "secret"}},
"created_at": "2026-01-01",
"updated_at": "2026-01-01"
}
]"#;
let stripped = strip_secrets_from_json(input).unwrap();
let v: serde_json::Value = serde_json::from_str(&stripped).unwrap();
let record = &v[0];
assert!(
record.get("env_vars").is_none(),
"env_vars must be stripped"
);
assert!(
record.get("auth_tag").is_none(),
"auth_tag must be stripped"
);
assert!(
record.get("private_key_nsec").is_none(),
"private_key_nsec must be stripped"
);
let provider_config = record.get("backend").and_then(|b| b.get("config"));
assert!(
provider_config.is_none(),
"provider config must be stripped"
);
// Non-secret fields survive.
assert_eq!(
record.get("name").and_then(|n| n.as_str()),
Some("test-agent")
);
}
#[test]
fn test_strip_secrets_from_json_preserves_local_backend() {
let input = r#"[
{
"pubkey": "abc123",
"name": "local-agent",
"backend": {"type": "local"},
"created_at": "2026-01-01",
"updated_at": "2026-01-01"
}
]"#;
let stripped = strip_secrets_from_json(input).unwrap();
let v: serde_json::Value = serde_json::from_str(&stripped).unwrap();
// Local backend must survive unchanged — no config field to strip.
let backend = v[0].get("backend").expect("backend must be present");
assert_eq!(backend.get("type").and_then(|t| t.as_str()), Some("local"));
assert!(
backend.get("config").is_none(),
"local backend has no config"
);
}
#[test]
fn test_strip_secrets_from_json_strips_global_config() {
let input = r#"{"env_vars": {"KEY": "value"}, "other_field": "keep"}"#;
let stripped = strip_secrets_from_json(input).unwrap();
let v: serde_json::Value = serde_json::from_str(&stripped).unwrap();
assert!(v.get("env_vars").is_none(), "env_vars must be stripped");
assert_eq!(v.get("other_field").and_then(|f| f.as_str()), Some("keep"));
}
#[test]
fn test_strip_secrets_from_json_returns_none_on_unparseable() {
assert!(strip_secrets_from_json("not valid json").is_none());
assert!(strip_secrets_from_json("").is_none());
}
// ── cleanup_secret_artifacts filesystem tests ─────────────────────────
/// Atomic-write-file temp siblings (hex-only names, no extension) are
/// deleted during cleanup. These can carry plaintext if a write was
/// interrupted before the atomic rename.
#[test]
fn test_cleanup_deletes_atomic_write_temps() {
let dir = tempfile::tempdir().expect("tempdir");
let temp_path = dir.path().join("deadbeefcafe0123");
std::fs::write(&temp_path, b"leftover content").expect("write temp");
assert!(temp_path.exists());
cleanup_secret_artifacts(dir.path());
assert!(
!temp_path.exists(),
"atomic-write temp must be deleted by cleanup"
);
}
/// Unparseable `.invalid` files are deleted during cleanup.
#[test]
fn test_cleanup_deletes_invalid_files() {
let dir = tempfile::tempdir().expect("tempdir");
let invalid_path = dir.path().join("managed-agents.json.invalid");
std::fs::write(&invalid_path, b"not valid json with sk-ant-secret").expect("write");
cleanup_secret_artifacts(dir.path());
assert!(
!invalid_path.exists(),
".invalid file must be deleted by cleanup"
);
}
/// A backup file with parseable content is scrubbed (secrets stripped) rather
/// than deleted. The file should survive but without the secret value.
#[test]
fn test_cleanup_scrubs_parseable_backup() {
let dir = tempfile::tempdir().expect("tempdir");
let backup_path = dir.path().join("managed-agents.json.bak-20260608-175938");
let content = r#"[{"pubkey":"abc","name":"test","env_vars":{"K":"v"},"created_at":"2026","updated_at":"2026"}]"#;
std::fs::write(&backup_path, content).expect("write backup");
cleanup_secret_artifacts(dir.path());
assert!(backup_path.exists(), "parseable backup must survive");
let result = std::fs::read_to_string(&backup_path).expect("read");
assert!(
!result.contains("\"env_vars\""),
"secrets must be stripped from parseable backup"
);
}
/// Cleanup must not follow symlinks that escape the agents dir.
#[cfg(unix)]
#[test]
fn test_cleanup_skips_symlink_escape() {
let agents_dir = tempfile::tempdir().expect("agents dir");
// Create a target OUTSIDE the agents dir.
let outside_dir = tempfile::tempdir().expect("outside dir");
let outside_file = outside_dir.path().join("secret.txt");
std::fs::write(&outside_file, b"outside-secret").expect("write outside");
// Create a symlink inside agents_dir that points outside.
let symlink_path = agents_dir.path().join("escape.json");
std::os::unix::fs::symlink(&outside_file, &symlink_path).expect("create symlink");
cleanup_secret_artifacts(agents_dir.path());
// The file outside agents_dir must NOT be deleted.
assert!(
outside_file.exists(),
"symlink escape must not delete the target outside agents dir"
);
}
/// Cleanup must handle deletion failures gracefully: if a file cannot be
/// removed (e.g. read-only), the rest of the sweep continues and no panic
/// occurs.
#[cfg(unix)]
#[test]
fn test_cleanup_tolerates_deletion_failure() {
use std::os::unix::fs::PermissionsExt;
let dir = tempfile::tempdir().expect("tempdir");
// Make a read-only .invalid file that cannot be deleted.
let undeletable = dir.path().join("managed-agents.json.invalid");
std::fs::write(&undeletable, b"bad json").expect("write");
// Also add a normal .invalid file that CAN be deleted.
let deletable = dir.path().join("personas.json.invalid");
std::fs::write(&deletable, b"also bad").expect("write");
// Set the dir to read-only to prevent deletion of its entries.
let dir_meta = std::fs::metadata(dir.path()).expect("dir meta");
let mut perms = dir_meta.permissions();
perms.set_mode(0o555); // r-xr-xr-x: no write
std::fs::set_permissions(dir.path(), perms.clone()).expect("set perms");
// Cleanup should not panic despite the failure.
cleanup_secret_artifacts(dir.path());
// Restore permissions so tempdir cleanup can succeed.
perms.set_mode(0o755);
std::fs::set_permissions(dir.path(), perms).ok();
}
}