diff --git a/KNOWN_BUGS.md b/KNOWN_BUGS.md
new file mode 100644
index 00000000000..742174db71c
--- /dev/null
+++ b/KNOWN_BUGS.md
@@ -0,0 +1,45 @@
+# Known Bugs
+
+## personas-in-rooms — known defect, confuses agents
+
+**Status:** Rolled back. Do not re-land.
+**Introduced by:** `a9217e6b1` — *feat(buzz-acp): channel-scoped personas*
+**Rolled back to:** `924515dd8` — *docs: proposal — channel-scoped personas* (the
+last commit before the persona code landed in `crates/buzz-acp/src/channel_tools.rs`
++ `pool.rs` and the desktop persona panel/commands).
+
+### What it did
+
+The "channel-scoped personas" feature auto-derived a Buzz channel's persona
+(system prompt) from whatever persona file lived in the mapped Harbor room,
+resolved per-channel at agent spawn time.
+
+### Why it's a bug
+
+It **confuses the agents**. A channel's system prompt stopped being a single,
+owned, reviewable instruction set — it was silently overridden/replaced by
+room state the agent could not see or reason about. Behavior became
+inconsistent depending on room contents, and prompt provenance was opaque.
+
+### Remediation applied
+
+- Fork rolled back to `924515dd8`; built and installed (no persona code in
+ `buzz-acp` or `buzz-desktop`).
+- Harbor room persona files relocated out of Harbor and staged as first-class
+ **actual agents** (owner-reviewed identities with explicit system prompts)
+ instead of room-derived persona overrides.
+- All other Buzz installs on the machine removed; only the rolled-back build
+ remains.
+
+### Scope
+
+Only the **personas-in-rooms** surface is reverted. The fork's other addition —
+**channel-scoped tools** (skills + MCP servers per channel via
+`channel-tools.toml`) — is preserved; it predates `a9217e6b1` and does not
+confuse agents.
+
+### Recommendation
+
+If per-channel instructions are needed, model them as **actual agents**
+(owner-reviewed identities with explicit system prompts), never as
+room-derived persona overrides.
diff --git a/README.md b/README.md
index 6aa3eeff0be..22e2266f519 100644
--- a/README.md
+++ b/README.md
@@ -13,6 +13,14 @@
Apache 2.0
+> [!NOTE]
+> **This is a fork of [`block/buzz`](https://github.com/block/buzz).** The one thing
+> it adds is **channel-scoped tools**: map a channel to a [Harbor](https://github.com/TDH-Labs/Harbor)
+> room so every agent working there is confined to exactly that room's skills and MCP
+> tools — enforced server-side, managed from a **Channel tools** panel in the channel
+> header. Everything else is unmodified upstream Buzz.
+> **→ [What this fork adds](docs/CHANNEL_SCOPED_TOOLS.md)**
+
diff --git a/crates/buzz-acp/src/channel_tools.rs b/crates/buzz-acp/src/channel_tools.rs
new file mode 100644
index 00000000000..884980d82de
--- /dev/null
+++ b/crates/buzz-acp/src/channel_tools.rs
@@ -0,0 +1,271 @@
+//! Channel-scoped tool policy: map channels to the MCP servers (tools/skills)
+//! an agent may use when responding in that channel.
+//!
+//! Loaded from the TOML file named by `--channel-tools` / `BUZZ_ACP_CHANNEL_TOOLS`.
+//! Keys are channel UUIDs or channel names (names match case-insensitively);
+//! values declare either a harbor `room` (shorthand for
+//! `harbor mcp-server --room=`) or an explicit `mcp` server list.
+//!
+//! Scoping is strict, not additive: when a channel has an entry, its server
+//! list *replaces* the global `--mcp-command` default for sessions created on
+//! that channel — and because the agent runtime skips its own configured
+//! extensions whenever explicit MCP servers are passed, a scoped channel
+//! exposes exactly the listed servers and nothing else. Channels without an
+//! entry keep today's behavior.
+//!
+//! ```toml
+//! # ~/.buzz/channel-tools.toml
+//! harbor_command = "harbor" # default for `room` entries
+//!
+//! [channels.bookkeeping] # channel name…
+//! room = "bookkeeping"
+//!
+//! [channels."6f8199d3-479f-4dce-8851-000a4b0a2586"] # …or channel UUID
+//! room = "bookkeeping"
+//!
+//! [channels.devops]
+//! room = "devops"
+//! [[channels.devops.mcp]] # extra explicit servers are additive to `room`
+//! name = "system-monitoring"
+//! command = "/usr/local/bin/sysmon-mcp"
+//! args = ["--stdio"]
+//! ```
+
+use std::collections::HashMap;
+use std::path::Path;
+
+use uuid::Uuid;
+
+use crate::acp::{EnvVar, McpServer};
+
+/// One channel's tool policy as written in TOML.
+#[derive(Debug, Clone, serde::Deserialize)]
+#[serde(deny_unknown_fields)]
+struct ChannelToolsEntry {
+ /// Harbor room shorthand: expands to ` mcp-server --room=`.
+ #[serde(default)]
+ room: Option,
+ /// Per-entry harbor binary override.
+ #[serde(default)]
+ harbor_command: Option,
+ /// Explicit MCP servers for this channel (additive with `room`).
+ #[serde(default)]
+ mcp: Vec,
+}
+
+#[derive(Debug, Clone, serde::Deserialize)]
+#[serde(deny_unknown_fields)]
+struct McpToml {
+ name: String,
+ command: String,
+ #[serde(default)]
+ args: Vec,
+ #[serde(default)]
+ env: HashMap,
+}
+
+#[derive(Debug, serde::Deserialize)]
+#[serde(deny_unknown_fields)]
+struct ChannelToolsFile {
+ /// Default harbor binary for `room` entries. Defaults to `"harbor"` (PATH).
+ #[serde(default)]
+ harbor_command: Option,
+ #[serde(default)]
+ channels: HashMap,
+}
+
+/// Resolved channel→MCP-server policy, keyed by UUID and by lowercased name.
+#[derive(Debug, Default)]
+pub struct ChannelTools {
+ by_uuid: HashMap>,
+ by_name: HashMap>,
+}
+
+impl ChannelTools {
+ /// Load and validate a policy file. Errors are fatal by design: a tool
+ /// policy that fails to parse must never silently degrade to "no policy".
+ pub fn load(path: &Path) -> Result {
+ let content = std::fs::read_to_string(path)
+ .map_err(|e| format!("cannot read channel-tools file {}: {e}", path.display()))?;
+ let file: ChannelToolsFile = toml::from_str(&content)
+ .map_err(|e| format!("invalid channel-tools file {}: {e}", path.display()))?;
+
+ let default_harbor = file.harbor_command.as_deref().unwrap_or("harbor");
+ let mut tools = ChannelTools::default();
+
+ for (key, entry) in &file.channels {
+ let servers = entry.to_servers(key, default_harbor)?;
+ match key.parse::() {
+ Ok(uuid) => {
+ tools.by_uuid.insert(uuid, servers);
+ }
+ Err(_) => {
+ tools
+ .by_name
+ .insert(key.trim_start_matches('#').to_ascii_lowercase(), servers);
+ }
+ }
+ }
+ Ok(tools)
+ }
+
+ /// Resolve the policy for a channel: UUID match first, then name match.
+ /// `None` means no entry — the caller keeps the global default.
+ pub fn resolve(&self, id: &Uuid, name: Option<&str>) -> Option<&Vec> {
+ self.by_uuid.get(id).or_else(|| {
+ name.and_then(|n| {
+ self.by_name
+ .get(&n.trim_start_matches('#').to_ascii_lowercase())
+ })
+ })
+ }
+
+ /// Number of channel entries in the policy (for startup logging).
+ pub fn entry_count(&self) -> usize {
+ self.by_uuid.len() + self.by_name.len()
+ }
+}
+
+impl ChannelToolsEntry {
+ fn to_servers(&self, key: &str, default_harbor: &str) -> Result, String> {
+ if self.room.is_none() && self.mcp.is_empty() {
+ return Err(format!(
+ "channel-tools entry '{key}' declares neither `room` nor `mcp`"
+ ));
+ }
+ let mut servers = Vec::new();
+ if let Some(room) = &self.room {
+ if room.trim().is_empty() {
+ return Err(format!("channel-tools entry '{key}' has an empty `room`"));
+ }
+ servers.push(McpServer {
+ name: format!("harbor-{room}"),
+ command: self
+ .harbor_command
+ .as_deref()
+ .unwrap_or(default_harbor)
+ .to_string(),
+ args: vec!["mcp-server".into(), format!("--room={room}")],
+ env: vec![],
+ });
+ }
+ for m in &self.mcp {
+ servers.push(McpServer {
+ name: m.name.clone(),
+ command: m.command.clone(),
+ args: m.args.clone(),
+ env: m
+ .env
+ .iter()
+ .map(|(k, v)| EnvVar {
+ name: k.clone(),
+ value: v.clone(),
+ })
+ .collect(),
+ });
+ }
+ Ok(servers)
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ fn load_str(s: &str) -> Result {
+ let dir = std::env::temp_dir().join(format!("chtools-test-{}", std::process::id()));
+ std::fs::create_dir_all(&dir).unwrap();
+ let path = dir.join(format!("{:x}.toml", md5ish(s)));
+ std::fs::write(&path, s).unwrap();
+ ChannelTools::load(&path)
+ }
+
+ fn md5ish(s: &str) -> u64 {
+ use std::hash::{Hash, Hasher};
+ let mut h = std::collections::hash_map::DefaultHasher::new();
+ s.hash(&mut h);
+ h.finish()
+ }
+
+ #[test]
+ fn room_entry_expands_to_harbor_server() {
+ let t = load_str("[channels.bookkeeping]\nroom = \"bookkeeping\"\n").unwrap();
+ let id = Uuid::nil();
+ let servers = t.resolve(&id, Some("bookkeeping")).unwrap();
+ assert_eq!(servers.len(), 1);
+ assert_eq!(servers[0].name, "harbor-bookkeeping");
+ assert_eq!(servers[0].command, "harbor");
+ assert_eq!(servers[0].args, vec!["mcp-server", "--room=bookkeeping"]);
+ }
+
+ #[test]
+ fn uuid_key_matches_by_uuid_and_wins_over_name() {
+ let id = "6f8199d3-479f-4dce-8851-000a4b0a2586";
+ let toml = format!(
+ "[channels.\"{id}\"]\nroom = \"legal\"\n[channels.general]\nroom = \"devops\"\n"
+ );
+ let t = load_str(&toml).unwrap();
+ let uuid: Uuid = id.parse().unwrap();
+ let servers = t.resolve(&uuid, Some("general")).unwrap();
+ assert_eq!(servers[0].args[1], "--room=legal");
+ }
+
+ #[test]
+ fn name_match_is_case_insensitive_and_hash_insensitive() {
+ let t = load_str("[channels.\"#BookKeeping\"]\nroom = \"bookkeeping\"\n").unwrap();
+ assert!(t.resolve(&Uuid::nil(), Some("bookkeeping")).is_some());
+ assert!(t.resolve(&Uuid::nil(), Some("#Bookkeeping")).is_some());
+ assert!(t.resolve(&Uuid::nil(), Some("other")).is_none());
+ }
+
+ #[test]
+ fn unmatched_channel_resolves_none() {
+ let t = load_str("[channels.bookkeeping]\nroom = \"bookkeeping\"\n").unwrap();
+ assert!(t.resolve(&Uuid::nil(), Some("marketing")).is_none());
+ assert!(t.resolve(&Uuid::nil(), None).is_none());
+ }
+
+ #[test]
+ fn explicit_mcp_servers_carry_args_and_env() {
+ let t = load_str(
+ "[channels.devops]\nroom = \"devops\"\n[[channels.devops.mcp]]\nname = \"sysmon\"\ncommand = \"/bin/sysmon\"\nargs = [\"--stdio\"]\nenv = { FOO = \"bar\" }\n",
+ )
+ .unwrap();
+ let servers = t.resolve(&Uuid::nil(), Some("devops")).unwrap();
+ assert_eq!(servers.len(), 2);
+ assert_eq!(servers[1].name, "sysmon");
+ assert_eq!(servers[1].env[0].name, "FOO");
+ assert_eq!(servers[1].env[0].value, "bar");
+ }
+
+ #[test]
+ fn custom_harbor_command_overrides_default() {
+ let t = load_str(
+ "harbor_command = \"/opt/harbor\"\n[channels.a]\nroom = \"x\"\n[channels.b]\nroom = \"y\"\nharbor_command = \"/special/harbor\"\n",
+ )
+ .unwrap();
+ assert_eq!(
+ t.resolve(&Uuid::nil(), Some("a")).unwrap()[0].command,
+ "/opt/harbor"
+ );
+ assert_eq!(
+ t.resolve(&Uuid::nil(), Some("b")).unwrap()[0].command,
+ "/special/harbor"
+ );
+ }
+
+ #[test]
+ fn entry_without_room_or_mcp_is_rejected() {
+ assert!(load_str("[channels.empty]\n").is_err());
+ }
+
+ #[test]
+ fn empty_room_is_rejected() {
+ assert!(load_str("[channels.a]\nroom = \" \"\n").is_err());
+ }
+
+ #[test]
+ fn unknown_keys_are_rejected() {
+ assert!(load_str("[channels.a]\nroom = \"x\"\ntypo_key = 1\n").is_err());
+ }
+}
diff --git a/crates/buzz-acp/src/config.rs b/crates/buzz-acp/src/config.rs
index a38d6faa14b..96eb11924c5 100644
--- a/crates/buzz-acp/src/config.rs
+++ b/crates/buzz-acp/src/config.rs
@@ -341,6 +341,12 @@ pub struct CliArgs {
#[arg(long, env = "BUZZ_ACP_CONFIG", default_value = "./buzz-acp.toml")]
pub config: PathBuf,
+ /// Channel-scoped tool policy file (TOML). Maps channels (by UUID or name)
+ /// to the MCP servers exposed to sessions on that channel; see
+ /// `channel_tools.rs` for the format. Unset = no per-channel scoping.
+ #[arg(long, env = "BUZZ_ACP_CHANNEL_TOOLS")]
+ pub channel_tools: Option,
+
#[arg(long, env = "BUZZ_ACP_DEDUP", default_value = "queue", value_enum)]
pub dedup: DedupMode,
@@ -510,6 +516,8 @@ pub struct Config {
pub channels_override: Option>,
pub no_mention_filter: bool,
pub config_path: PathBuf,
+ /// Channel-scoped tool policy file; see `channel_tools.rs`.
+ pub channel_tools_path: Option,
pub context_message_limit: u32,
/// Maximum turns per session before proactive rotation. 0 = disabled.
pub max_turns_per_session: u32,
@@ -986,6 +994,7 @@ impl Config {
channels_override: args.channels,
no_mention_filter: args.no_mention_filter,
config_path: args.config,
+ channel_tools_path: args.channel_tools,
context_message_limit: args.context_message_limit,
max_turns_per_session: args.max_turns_per_session,
presence_enabled: !args.no_presence,
@@ -1355,6 +1364,7 @@ mod tests {
channels_override: None,
no_mention_filter: false,
config_path: PathBuf::from("./buzz-acp.toml"),
+ channel_tools_path: None,
context_message_limit: 12,
max_turns_per_session: 0,
presence_enabled: true,
diff --git a/crates/buzz-acp/src/lib.rs b/crates/buzz-acp/src/lib.rs
index 862732f4780..7d93fc88167 100644
--- a/crates/buzz-acp/src/lib.rs
+++ b/crates/buzz-acp/src/lib.rs
@@ -1,6 +1,7 @@
#![deny(unsafe_code)]
mod acp;
+mod channel_tools;
mod config;
mod engram_fetch;
mod filter;
@@ -1478,9 +1479,26 @@ async fn tokio_main() -> Result<()> {
);
}
+ // Channel-scoped tool policy: parse failures are fatal by design — a tool
+ // policy must never silently degrade to "no scoping".
+ let channel_tools = match config.channel_tools_path.as_deref() {
+ Some(path) => {
+ let tools = channel_tools::ChannelTools::load(path)
+ .map_err(|e| anyhow::anyhow!("channel-tools policy: {e}"))?;
+ tracing::info!(
+ policy = %path.display(),
+ entries = tools.entry_count(),
+ "channel-scoped tool policy loaded"
+ );
+ tools
+ }
+ None => Default::default(),
+ };
+
let base_prompt_content = config.base_prompt_content.take();
let ctx = Arc::new(PromptContext {
mcp_servers: build_mcp_servers(&config),
+ channel_tools,
initial_message: config.initial_message.clone(),
idle_timeout: Duration::from_secs(config.idle_timeout_secs),
max_turn_duration: Duration::from_secs(config.max_turn_duration_secs),
@@ -4632,6 +4650,7 @@ mod build_mcp_servers_tests {
channels_override: None,
no_mention_filter: false,
config_path: std::path::PathBuf::from("./buzz-acp.toml"),
+ channel_tools_path: None,
context_message_limit: 12,
max_turns_per_session: 0,
presence_enabled: true,
@@ -4798,6 +4817,7 @@ mod error_outcome_emission_tests {
channels_override: None,
no_mention_filter: false,
config_path: std::path::PathBuf::from("./buzz-acp.toml"),
+ channel_tools_path: None,
context_message_limit: 12,
max_turns_per_session: 0,
presence_enabled: true,
diff --git a/crates/buzz-acp/src/pool.rs b/crates/buzz-acp/src/pool.rs
index fb08096792c..b8b54acc951 100644
--- a/crates/buzz-acp/src/pool.rs
+++ b/crates/buzz-acp/src/pool.rs
@@ -429,6 +429,9 @@ pub enum PromptOutcome {
/// into every task.
pub struct PromptContext {
pub mcp_servers: Vec,
+ /// Channel-scoped tool policy: channels with an entry replace
+ /// `mcp_servers` for sessions created on that channel.
+ pub channel_tools: crate::channel_tools::ChannelTools,
pub initial_message: Option,
pub idle_timeout: Duration,
pub max_turn_duration: Duration,
@@ -754,6 +757,7 @@ async fn create_session_and_apply_model(
ctx: &PromptContext,
agent_core: Option<&str>,
agent_canvas: Option<&str>,
+ channel_id: Option<&Uuid>,
) -> Result {
// Build base_prompt + system_prompt + agent core + canvas metadata into a
// single prompt. Standard protocol-v2 agents receive it in `session/new`;
@@ -773,11 +777,30 @@ async fn create_session_and_apply_model(
agent_canvas,
);
+ // Channel-scoped tools: a channel with a policy entry replaces the global
+ // MCP set for its session. The agent runtime skips its own configured
+ // extensions whenever explicit servers are passed, so a scoped channel
+ // exposes exactly the listed servers.
+ let mcp_servers = channel_id
+ .and_then(|cid| {
+ let name = ctx.channel_info.get(cid).map(|i| i.name.as_str());
+ ctx.channel_tools.resolve(cid, name).map(|servers| {
+ tracing::info!(
+ channel = %cid,
+ channel_name = name.unwrap_or("?"),
+ servers = servers.len(),
+ "channel-scoped tools applied to new session"
+ );
+ servers.clone()
+ })
+ })
+ .unwrap_or_else(|| ctx.mcp_servers.clone());
+
let resp = agent
.acp
.session_new_full(
&ctx.cwd,
- ctx.mcp_servers.clone(),
+ mcp_servers,
session_new_system_prompt(
is_goose,
agent.protocol_version,
@@ -1425,6 +1448,7 @@ pub async fn run_prompt_task(
&ctx,
agent_core.as_deref(),
agent_canvas.as_deref(),
+ Some(cid),
)
.await
{
@@ -1472,7 +1496,7 @@ pub async fn run_prompt_task(
if let Some(sid) = &agent.state.heartbeat_session {
(sid.clone(), false)
} else {
- match create_session_and_apply_model(&mut agent, &ctx, None, None).await {
+ match create_session_and_apply_model(&mut agent, &ctx, None, None, None).await {
Ok(sid) => {
tracing::info!(
target: "pool::session",
@@ -5237,6 +5261,7 @@ mod tests {
use crate::relay::RestClient;
PromptContext {
mcp_servers: vec![],
+ channel_tools: Default::default(),
initial_message: None,
idle_timeout: Duration::from_secs(60),
max_turn_duration: Duration::from_secs(120),
diff --git a/desktop/src-tauri/src/commands/channel_tools.rs b/desktop/src-tauri/src/commands/channel_tools.rs
new file mode 100644
index 00000000000..22b9d71b96f
--- /dev/null
+++ b/desktop/src-tauri/src/commands/channel_tools.rs
@@ -0,0 +1,284 @@
+//! Channel-scoped tools GUI bridge.
+//!
+//! Harbor is the source of truth for which skills + MCP servers a channel
+//! exposes: buzz-acp maps a channel to a Harbor *room* via
+//! `~/.buzz/channel-tools.toml`, and the room's skill/MCP allowlist is enforced
+//! server-side by Harbor's gate. These commands are the GUI's window onto that
+//! model — they shell out to the same `harbor` binary buzz-acp uses, so the
+//! panel, the CLI, and enforcement all read and write ONE policy rather than
+//! three divergent notions of "what's in this channel".
+//!
+//! Read commands (`*_get`, `*_pool_skills`) return `harbor ... --json` verbatim
+//! for the frontend to render. Mutations (`*_add_*`) wrap Harbor's own audited
+//! verbs (`skill-install` / `skill-room-add` / `mcp-add --room`); Harbor applies
+//! its confirm policy and token budget, so this layer never bypasses the gate.
+//!
+//! The desktop Tauri crate does not enable tokio's `process` feature, so every
+//! subprocess runs inside `spawn_blocking` over synchronous `std::process`,
+//! mirroring `agent_model_process.rs`. Values are passed as distinct argv
+//! entries (never a shell string), and any value that could be reinterpreted as
+//! a flag is rejected up front.
+
+use std::path::{Path, PathBuf};
+use std::process::{Command, Output};
+
+use serde_json::Value;
+
+use crate::managed_agents::login_shell_path;
+
+/// The channel-tools policy file buzz-acp reads (env override mirrors
+/// buzz-acp's `BUZZ_ACP_CHANNEL_TOOLS`, else `~/.buzz/channel-tools.toml`).
+fn policy_path() -> PathBuf {
+ if let Ok(p) = std::env::var("BUZZ_ACP_CHANNEL_TOOLS") {
+ if !p.trim().is_empty() {
+ return PathBuf::from(p);
+ }
+ }
+ let mut base = dirs::home_dir().unwrap_or_else(|| PathBuf::from("."));
+ base.push(".buzz");
+ base.push("channel-tools.toml");
+ base
+}
+
+/// Resolve the harbor binary the same way buzz-acp does: the policy's
+/// top-level `harbor_command`, else `harbor` on PATH.
+fn harbor_binary(policy: &Path) -> String {
+ if let Ok(text) = std::fs::read_to_string(policy) {
+ if let Ok(toml::Value::Table(table)) = text.parse::() {
+ if let Some(toml::Value::String(cmd)) = table.get("harbor_command") {
+ if !cmd.trim().is_empty() {
+ return cmd.clone();
+ }
+ }
+ }
+ }
+ "harbor".to_string()
+}
+
+/// Reject a value that is empty or could be reinterpreted as a CLI flag. Values
+/// reach harbor as separate argv entries (no shell), so the only injection
+/// vector is a leading `-` being parsed as an option — guard exactly that.
+fn safe_arg(label: &str, value: &str) -> Result {
+ let v = value.trim();
+ if v.is_empty() {
+ return Err(format!("{label} must not be empty"));
+ }
+ if v.starts_with('-') {
+ return Err(format!("{label} must not start with '-'"));
+ }
+ Ok(v.to_string())
+}
+
+/// Run `harbor ` with the login-shell PATH so the binary resolves, off
+/// the async runtime (the desktop crate lacks tokio's `process` feature).
+async fn run_harbor(args: Vec) -> Result