From 724fed49d9c303f59820c5ddfc527307490a1526 Mon Sep 17 00:00:00 2001 From: Wintermute Date: Sat, 8 Aug 2026 01:25:38 +0200 Subject: [PATCH 1/2] fix(desktop): seed nest Claude settings with buzz CLI allowlist MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Since #4609 the ACP harness rejects every session/request_permission (fail closed) and desktop managed sessions run in dontAsk mode with no in-app approval prompt. Claude Code raises a permission request for any Bash pattern not pre-authorized in its settings, so managed Claude agents lost all access to the bundled `buzz` CLI — they could not read channels or send replies (#5262, #5260). Claude Code evaluates permissions.allow rules before the permission mode, so a project-level allow rule survives dontAsk. Seed `/.claude/settings.json` with `Bash(buzz)` and `Bash(buzz:*)` at nest creation: managed sessions start with the nest root as cwd and the ACP adapter passes settingSources ["user", "project", "local"], so the rules apply to every managed Claude agent without touching the user's own ~/.claude/settings.json. The merge is conservative: existing settings are preserved, only missing rules are appended, unparseable or unexpectedly-shaped files are left untouched, and the write is atomic (temp file + persist, matching refresh_skill_md_if_stale). Idempotent across launches. This deliberately does not reopen the blanket auto-approval that #4609 removed: only the platform's own CLI is pre-authorized, agent-side, which #4609 explicitly scopes as outside the harness guarantee. The broader fix (harness permission policy with desktop approval cards) is in flight as #5106; this unblocks agents until it lands. Known limitation: allow rules match single commands only — `buzz feed get` runs, but `buzz ... | head` or `buzz ...; echo $?` still raises a request and is rejected under dontAsk. Fixes #5262. Co-authored-by: Thomas Petersen Signed-off-by: Thomas Petersen --- desktop/src-tauri/src/managed_agents/nest.rs | 8 + .../managed_agents/nest/claude_settings.rs | 196 ++++++++++++++++++ 2 files changed, 204 insertions(+) create mode 100644 desktop/src-tauri/src/managed_agents/nest/claude_settings.rs diff --git a/desktop/src-tauri/src/managed_agents/nest.rs b/desktop/src-tauri/src/managed_agents/nest.rs index a57676f0a9..4e5eabb414 100644 --- a/desktop/src-tauri/src/managed_agents/nest.rs +++ b/desktop/src-tauri/src/managed_agents/nest.rs @@ -218,6 +218,12 @@ pub fn ensure_nest_at(root: &Path) -> Result<(), String> { refresh_agents_md_if_stale(root)?; refresh_skill_md_if_stale(root)?; + // Seed the nest's project-level Claude settings with an allowlist for + // the bundled `buzz` CLI, so managed Claude sessions (which run in + // `dontAsk` mode with all permission requests rejected — see #4609) + // can still reach the relay. See claude_settings.rs for the rationale. + claude_settings::ensure_claude_buzz_allowlist(root)?; + // Set owner-only permissions on root and all subdirectories. // Skip any path that is a symlink — chmod would affect the target. #[cfg(unix)] @@ -674,5 +680,7 @@ pub fn try_regenerate_nest(app: &AppHandle) { } } +mod claude_settings; + #[cfg(test)] mod tests; diff --git a/desktop/src-tauri/src/managed_agents/nest/claude_settings.rs b/desktop/src-tauri/src/managed_agents/nest/claude_settings.rs new file mode 100644 index 0000000000..786915d073 --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/nest/claude_settings.rs @@ -0,0 +1,196 @@ +//! Seed the nest's project-level Claude Code settings with an allowlist +//! for the bundled `buzz` CLI. +//! +//! Since #4609 the ACP harness answers every `session/request_permission` +//! with a rejection (fail closed), and desktop managed sessions run in +//! `dontAsk` mode with no in-app approval prompt. Claude Code evaluates +//! `permissions.allow` rules *before* the permission mode, so a project +//! allow rule for the bundled CLI keeps agents able to reach the relay +//! without reopening the blanket auto-approval that #4609 removed. +//! +//! The rules land in `/.claude/settings.json` — a Buzz-owned project +//! directory (managed sessions start with the nest root as cwd, and the +//! ACP adapter passes `settingSources: ["user", "project", "local"]`). +//! The user's own `~/.claude/settings.json` is never touched. +//! +//! Known limitation: allow rules match single commands only. `buzz feed get` +//! is authorized; `buzz feed get | head` or `buzz ...; echo $?` still raises +//! a permission request and is rejected under `dontAsk`. + +use std::fs; +use std::path::Path; + +/// Permission rules granted to the bundled CLI. Exact-match rule covers a +/// bare `buzz` invocation; the `:*` prefix rule covers `buzz `. +const ALLOW_RULES: &[&str] = &["Bash(buzz)", "Bash(buzz:*)"]; + +/// Merge the `buzz` CLI allow rules into `/.claude/settings.json`, +/// creating the file if absent. +/// +/// Conservative by design: +/// - Existing settings are preserved; only missing rules are appended. +/// - A file that is not valid JSON, or whose `permissions` / `allow` nodes +/// have unexpected shapes, is left untouched (never clobber user edits). +/// - Idempotent: a second call with the rules present writes nothing. +pub(super) fn ensure_claude_buzz_allowlist(root: &Path) -> Result<(), String> { + let claude_dir = root.join(".claude"); + fs::create_dir_all(&claude_dir).map_err(|e| format!("create {}: {e}", claude_dir.display()))?; + let settings_path = claude_dir.join("settings.json"); + + let mut settings: serde_json::Value = match fs::read_to_string(&settings_path) { + Ok(text) => match serde_json::from_str(&text) { + Ok(value) => value, + Err(e) => { + // Unparseable user file — leave it alone rather than clobber. + eprintln!( + "buzz-desktop: {} is not valid JSON ({e}); skipping buzz CLI allowlist seed", + settings_path.display() + ); + return Ok(()); + } + }, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => serde_json::json!({}), + Err(e) => return Err(format!("read {}: {e}", settings_path.display())), + }; + + let Some(obj) = settings.as_object_mut() else { + // Top level isn't an object — unexpected shape, don't touch it. + return Ok(()); + }; + let permissions = obj + .entry("permissions") + .or_insert_with(|| serde_json::json!({})); + let Some(permissions) = permissions.as_object_mut() else { + return Ok(()); + }; + let allow = permissions + .entry("allow") + .or_insert_with(|| serde_json::json!([])); + let Some(allow) = allow.as_array_mut() else { + return Ok(()); + }; + + let mut changed = false; + for rule in ALLOW_RULES { + if !allow.iter().any(|v| v.as_str() == Some(*rule)) { + allow.push(serde_json::Value::String((*rule).to_string())); + changed = true; + } + } + if !changed { + return Ok(()); + } + + let rendered = serde_json::to_string_pretty(&settings) + .map_err(|e| format!("serialize {}: {e}", settings_path.display()))?; + + // Atomic write via temp file, matching refresh_skill_md_if_stale. + let mut tmp = tempfile::NamedTempFile::new_in(&claude_dir) + .map_err(|e| format!("tempfile in {}: {e}", claude_dir.display()))?; + { + use std::io::Write; + tmp.write_all(rendered.as_bytes()) + .map_err(|e| format!("write tempfile: {e}"))?; + tmp.write_all(b"\n") + .map_err(|e| format!("write tempfile: {e}"))?; + } + tmp.persist(&settings_path) + .map_err(|e| format!("persist {}: {e}", settings_path.display()))?; + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn read_settings(root: &Path) -> serde_json::Value { + let text = fs::read_to_string(root.join(".claude/settings.json")).unwrap(); + serde_json::from_str(&text).unwrap() + } + + fn allow_rules(value: &serde_json::Value) -> Vec { + value["permissions"]["allow"] + .as_array() + .unwrap() + .iter() + .map(|v| v.as_str().unwrap().to_string()) + .collect() + } + + #[test] + fn creates_settings_with_allow_rules_when_absent() { + let tmp = tempfile::tempdir().unwrap(); + ensure_claude_buzz_allowlist(tmp.path()).unwrap(); + + let settings = read_settings(tmp.path()); + assert_eq!(allow_rules(&settings), vec!["Bash(buzz)", "Bash(buzz:*)"]); + } + + #[test] + fn merges_into_existing_settings_preserving_other_keys() { + let tmp = tempfile::tempdir().unwrap(); + let claude_dir = tmp.path().join(".claude"); + fs::create_dir_all(&claude_dir).unwrap(); + fs::write( + claude_dir.join("settings.json"), + r#"{"model": "opus", "permissions": {"allow": ["Bash(git status)"], "deny": ["WebFetch"]}}"#, + ) + .unwrap(); + + ensure_claude_buzz_allowlist(tmp.path()).unwrap(); + + let settings = read_settings(tmp.path()); + assert_eq!(settings["model"], "opus"); + assert_eq!(settings["permissions"]["deny"][0], "WebFetch"); + assert_eq!( + allow_rules(&settings), + vec!["Bash(git status)", "Bash(buzz)", "Bash(buzz:*)"] + ); + } + + #[test] + fn is_idempotent() { + let tmp = tempfile::tempdir().unwrap(); + ensure_claude_buzz_allowlist(tmp.path()).unwrap(); + let first = fs::read_to_string(tmp.path().join(".claude/settings.json")).unwrap(); + + ensure_claude_buzz_allowlist(tmp.path()).unwrap(); + let second = fs::read_to_string(tmp.path().join(".claude/settings.json")).unwrap(); + + assert_eq!(first, second); + let settings = read_settings(tmp.path()); + assert_eq!(allow_rules(&settings).len(), 2, "rules must not duplicate"); + } + + #[test] + fn leaves_invalid_json_untouched() { + let tmp = tempfile::tempdir().unwrap(); + let claude_dir = tmp.path().join(".claude"); + fs::create_dir_all(&claude_dir).unwrap(); + fs::write(claude_dir.join("settings.json"), "{ not json").unwrap(); + + ensure_claude_buzz_allowlist(tmp.path()).unwrap(); + + let content = fs::read_to_string(claude_dir.join("settings.json")).unwrap(); + assert_eq!(content, "{ not json"); + } + + #[test] + fn leaves_unexpected_shapes_untouched() { + let tmp = tempfile::tempdir().unwrap(); + let claude_dir = tmp.path().join(".claude"); + fs::create_dir_all(&claude_dir).unwrap(); + // permissions.allow is an object, not an array. + fs::write( + claude_dir.join("settings.json"), + r#"{"permissions": {"allow": {"weird": true}}}"#, + ) + .unwrap(); + + ensure_claude_buzz_allowlist(tmp.path()).unwrap(); + + let settings = read_settings(tmp.path()); + assert_eq!(settings["permissions"]["allow"]["weird"], true); + } +} From 91838616a99e8941b9fb47ce910e6dc45052ad9d Mon Sep 17 00:00:00 2001 From: Wes Date: Sat, 8 Aug 2026 08:32:09 -0600 Subject: [PATCH 2/2] fix(desktop): honor Claude allowlist before workspace trust Write the managed nest allowlist to Claude's local settings source, which remains active before a fresh headless workspace is trusted. Refuse symlinked Claude settings paths so the seed cannot escape the nest. Co-authored-by: Carl Signed-off-by: Wes --- desktop/src-tauri/src/managed_agents/nest.rs | 2 +- .../managed_agents/nest/claude_settings.rs | 101 +++++++++++++++--- 2 files changed, 87 insertions(+), 16 deletions(-) diff --git a/desktop/src-tauri/src/managed_agents/nest.rs b/desktop/src-tauri/src/managed_agents/nest.rs index 4e5eabb414..a7ed70eb0b 100644 --- a/desktop/src-tauri/src/managed_agents/nest.rs +++ b/desktop/src-tauri/src/managed_agents/nest.rs @@ -218,7 +218,7 @@ pub fn ensure_nest_at(root: &Path) -> Result<(), String> { refresh_agents_md_if_stale(root)?; refresh_skill_md_if_stale(root)?; - // Seed the nest's project-level Claude settings with an allowlist for + // Seed the nest's local Claude settings with an allowlist for // the bundled `buzz` CLI, so managed Claude sessions (which run in // `dontAsk` mode with all permission requests rejected — see #4609) // can still reach the relay. See claude_settings.rs for the rationale. diff --git a/desktop/src-tauri/src/managed_agents/nest/claude_settings.rs b/desktop/src-tauri/src/managed_agents/nest/claude_settings.rs index 786915d073..bfc551c2e0 100644 --- a/desktop/src-tauri/src/managed_agents/nest/claude_settings.rs +++ b/desktop/src-tauri/src/managed_agents/nest/claude_settings.rs @@ -1,17 +1,20 @@ -//! Seed the nest's project-level Claude Code settings with an allowlist +//! Seed the nest's local Claude Code settings with an allowlist //! for the bundled `buzz` CLI. //! //! Since #4609 the ACP harness answers every `session/request_permission` //! with a rejection (fail closed), and desktop managed sessions run in //! `dontAsk` mode with no in-app approval prompt. Claude Code evaluates -//! `permissions.allow` rules *before* the permission mode, so a project +//! `permissions.allow` rules *before* the permission mode, so a local //! allow rule for the bundled CLI keeps agents able to reach the relay //! without reopening the blanket auto-approval that #4609 removed. //! -//! The rules land in `/.claude/settings.json` — a Buzz-owned project -//! directory (managed sessions start with the nest root as cwd, and the -//! ACP adapter passes `settingSources: ["user", "project", "local"]`). -//! The user's own `~/.claude/settings.json` is never touched. +//! The rules land in `/.claude/settings.local.json`. Unlike project-level +//! `settings.json`, Claude Code honors local allow rules before workspace trust +//! has been accepted — essential for fresh headless nests where no trust dialog +//! can be presented. The user's own `~/.claude` directory is never touched. +//! +//! This is temporary Buzz-owned compatibility behavior. Reconcile or remove +//! the workspace grant when Desktop permission approval UI ships in #5106. //! //! Known limitation: allow rules match single commands only. `buzz feed get` //! is authorized; `buzz feed get | head` or `buzz ...; echo $?` still raises @@ -24,7 +27,7 @@ use std::path::Path; /// bare `buzz` invocation; the `:*` prefix rule covers `buzz `. const ALLOW_RULES: &[&str] = &["Bash(buzz)", "Bash(buzz:*)"]; -/// Merge the `buzz` CLI allow rules into `/.claude/settings.json`, +/// Merge the `buzz` CLI allow rules into `/.claude/settings.local.json`, /// creating the file if absent. /// /// Conservative by design: @@ -34,8 +37,12 @@ const ALLOW_RULES: &[&str] = &["Bash(buzz)", "Bash(buzz:*)"]; /// - Idempotent: a second call with the rules present writes nothing. pub(super) fn ensure_claude_buzz_allowlist(root: &Path) -> Result<(), String> { let claude_dir = root.join(".claude"); + refuse_symlink(&claude_dir)?; fs::create_dir_all(&claude_dir).map_err(|e| format!("create {}: {e}", claude_dir.display()))?; - let settings_path = claude_dir.join("settings.json"); + // Recheck after creation so a concurrent replacement cannot redirect later I/O. + refuse_symlink(&claude_dir)?; + let settings_path = claude_dir.join("settings.local.json"); + refuse_symlink(&settings_path)?; let mut settings: serde_json::Value = match fs::read_to_string(&settings_path) { Ok(text) => match serde_json::from_str(&text) { @@ -94,18 +101,36 @@ pub(super) fn ensure_claude_buzz_allowlist(root: &Path) -> Result<(), String> { tmp.write_all(b"\n") .map_err(|e| format!("write tempfile: {e}"))?; } + // Recheck immediately before replacement in case the path changed while + // the existing settings were being parsed. + refuse_symlink(&claude_dir)?; + refuse_symlink(&settings_path)?; tmp.persist(&settings_path) .map_err(|e| format!("persist {}: {e}", settings_path.display()))?; Ok(()) } +/// Reject an existing symlink without following it. Missing paths are safe: the +/// caller either just created the directory or will atomically create the file. +fn refuse_symlink(path: &Path) -> Result<(), String> { + match path.symlink_metadata() { + Ok(metadata) if metadata.file_type().is_symlink() => Err(format!( + "{} is a symlink; refusing to seed Claude settings", + path.display() + )), + Ok(_) => Ok(()), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(e) => Err(format!("inspect {}: {e}", path.display())), + } +} + #[cfg(test)] mod tests { use super::*; fn read_settings(root: &Path) -> serde_json::Value { - let text = fs::read_to_string(root.join(".claude/settings.json")).unwrap(); + let text = fs::read_to_string(root.join(".claude/settings.local.json")).unwrap(); serde_json::from_str(&text).unwrap() } @@ -133,7 +158,7 @@ mod tests { let claude_dir = tmp.path().join(".claude"); fs::create_dir_all(&claude_dir).unwrap(); fs::write( - claude_dir.join("settings.json"), + claude_dir.join("settings.local.json"), r#"{"model": "opus", "permissions": {"allow": ["Bash(git status)"], "deny": ["WebFetch"]}}"#, ) .unwrap(); @@ -153,10 +178,10 @@ mod tests { fn is_idempotent() { let tmp = tempfile::tempdir().unwrap(); ensure_claude_buzz_allowlist(tmp.path()).unwrap(); - let first = fs::read_to_string(tmp.path().join(".claude/settings.json")).unwrap(); + let first = fs::read_to_string(tmp.path().join(".claude/settings.local.json")).unwrap(); ensure_claude_buzz_allowlist(tmp.path()).unwrap(); - let second = fs::read_to_string(tmp.path().join(".claude/settings.json")).unwrap(); + let second = fs::read_to_string(tmp.path().join(".claude/settings.local.json")).unwrap(); assert_eq!(first, second); let settings = read_settings(tmp.path()); @@ -168,11 +193,11 @@ mod tests { let tmp = tempfile::tempdir().unwrap(); let claude_dir = tmp.path().join(".claude"); fs::create_dir_all(&claude_dir).unwrap(); - fs::write(claude_dir.join("settings.json"), "{ not json").unwrap(); + fs::write(claude_dir.join("settings.local.json"), "{ not json").unwrap(); ensure_claude_buzz_allowlist(tmp.path()).unwrap(); - let content = fs::read_to_string(claude_dir.join("settings.json")).unwrap(); + let content = fs::read_to_string(claude_dir.join("settings.local.json")).unwrap(); assert_eq!(content, "{ not json"); } @@ -183,7 +208,7 @@ mod tests { fs::create_dir_all(&claude_dir).unwrap(); // permissions.allow is an object, not an array. fs::write( - claude_dir.join("settings.json"), + claude_dir.join("settings.local.json"), r#"{"permissions": {"allow": {"weird": true}}}"#, ) .unwrap(); @@ -193,4 +218,50 @@ mod tests { let settings = read_settings(tmp.path()); assert_eq!(settings["permissions"]["allow"]["weird"], true); } + + #[test] + fn leaves_project_settings_untouched() { + let tmp = tempfile::tempdir().unwrap(); + let claude_dir = tmp.path().join(".claude"); + fs::create_dir_all(&claude_dir).unwrap(); + let project_settings = claude_dir.join("settings.json"); + fs::write(&project_settings, r#"{"model":"opus"}"#).unwrap(); + + ensure_claude_buzz_allowlist(tmp.path()).unwrap(); + + assert_eq!( + fs::read_to_string(project_settings).unwrap(), + r#"{"model":"opus"}"# + ); + assert_eq!(allow_rules(&read_settings(tmp.path())).len(), 2); + } + + #[cfg(unix)] + #[test] + fn rejects_symlinked_claude_directory_without_writing_target() { + let tmp = tempfile::tempdir().unwrap(); + let outside = tempfile::tempdir().unwrap(); + std::os::unix::fs::symlink(outside.path(), tmp.path().join(".claude")).unwrap(); + + let error = ensure_claude_buzz_allowlist(tmp.path()).unwrap_err(); + + assert!(error.contains("is a symlink")); + assert!(!outside.path().join("settings.local.json").exists()); + } + + #[cfg(unix)] + #[test] + fn rejects_symlinked_settings_file_without_modifying_target() { + let tmp = tempfile::tempdir().unwrap(); + let claude_dir = tmp.path().join(".claude"); + fs::create_dir_all(&claude_dir).unwrap(); + let outside = tmp.path().join("outside.json"); + fs::write(&outside, r#"{"model":"opus"}"#).unwrap(); + std::os::unix::fs::symlink(&outside, claude_dir.join("settings.local.json")).unwrap(); + + let error = ensure_claude_buzz_allowlist(tmp.path()).unwrap_err(); + + assert!(error.contains("is a symlink")); + assert_eq!(fs::read_to_string(outside).unwrap(), r#"{"model":"opus"}"#); + } }