Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 32 additions & 2 deletions crates/buzz-acp/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -140,14 +140,15 @@ Controls which authors' events the harness forwards to the agent. Events from di

| Flag | Env Var | Default | Description |
|------|---------|---------|-------------|
| `--respond-to` | `BUZZ_ACP_RESPOND_TO` | `owner-only` | Author gate mode: `owner-only`, `allowlist`, `anyone`, `nobody`. |
| `--respond-to` | `BUZZ_ACP_RESPOND_TO` | `owner-only` | Author gate mode: `owner-only`, `owner`, `allowlist`, `anyone`, `nobody`. |
| `--respond-to-allowlist` | `BUZZ_ACP_RESPOND_TO_ALLOWLIST` | — | Comma-separated 64-char hex pubkeys (required when mode is `allowlist`). Owner is always implicitly included. |

**Modes:**

| Mode | Behavior |
|------|----------|
| `owner-only` | Forward only events from the agent's registered owner. If no owner is set, all events are dropped until the owner is resolved. |
| `owner-only` | Forward events from the agent's registered owner and from sibling agents (agents that have the same owner). Relay-signed workflow messages that target this agent count as their recorded owner. If no owner is set, all events are dropped until the owner is resolved. |
| `owner` | Stricter than `owner-only`: forward only events that the owner's key signed, in channels and DMs. Sibling agents and relay-signed workflow messages are dropped. If no owner is set, all events are dropped. Required by `--bound-session`. |
| `allowlist` | Forward events from the listed pubkeys plus the owner. |
| `anyone` | Forward all events (no author filtering). |
| `nobody` | Drop all inbound events. Agent only acts on heartbeat prompts. |
Expand Down Expand Up @@ -197,6 +198,35 @@ buzz-acp --respond-to anyone
buzz-acp --respond-to nobody --heartbeat-interval 300
```

### Bound session

| Flag | Env Var | Default | Description |
|------|---------|---------|-------------|
| `--bound-session` | `BUZZ_ACP_BOUND_SESSION` | — | JSON object `{"channel_id": "<uuid>", "thread_root": "<64 lowercase hex event ID>"}`. Restricts the harness to one channel and one thread. |

A bound harness drops every event outside the bound thread, including owner
`!shutdown`, `!cancel`, and `!rotate` commands. It resolves an edit to its
original message's thread before it admits the edit. Desktop `cancel_turn` and
`switch_model` observer controls for another channel are also dropped.

A bound session requires `--respond-to owner`, `--subscribe mentions`,
`--session-policy thread`, and `--heartbeat-interval 0`. It refuses
`--no-mention-filter` and `--initial-message`. The startup error names the
flag that fails.

### Permission mode

| Flag | Env Var | Default | Description |
|------|---------|---------|-------------|
| `--permission-mode` | `BUZZ_ACP_PERMISSION_MODE` | `bypass-permissions` | Mode applied with `session/set_config_option` after each new session: `default`, `agent-full-access`, `auto`, `accept-edits`, `bypass-permissions`, `dont-ask`, `plan`. |

If the adapter does not advertise or accept most modes, the harness falls back
to per-tool auto-approval. `agent-full-access` is for adapters (for example,
Codex) that run in an externally isolated container. It never falls back: the
adapter must advertise the mode in `session/new` and confirm it as the current
`mode` option. If it does not, the harness fails the request once with a
visible error, and it does not retry or restart the agent.

### Host-controlled launch wrapper

On Unix, a trusted host can set `BUZZ_ACP_LAUNCH_PREFIX` to a JSON argument array, for
Expand Down
5 changes: 5 additions & 0 deletions crates/buzz-acp/src/acp.rs
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,11 @@ pub enum AcpError {

#[error("Agent reported error (code {code}): {message}")]
AgentError { code: i64, message: String },

/// The adapter cannot run the permission mode this harness requires.
/// The pipe is intact, and neither a retry nor a respawn can repair it.
#[error("Required permission mode unavailable: {0}")]
PermissionModeUnavailable(String),
}

/// Build an [`AcpError::AgentError`] from a JSON-RPC error object,
Expand Down
198 changes: 194 additions & 4 deletions crates/buzz-acp/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,35 @@ use uuid::Uuid;

use crate::filter::SubscriptionRule;

/// Immutable conversation binding for a remote workspace.
#[derive(Debug, Clone, serde::Deserialize)]
#[serde(deny_unknown_fields)]
pub struct BoundSession {
pub channel_id: Uuid,
pub thread_root: String,
}

impl BoundSession {
pub fn accepts(&self, scope: &crate::scope::SessionScope) -> bool {
scope.channel_id() == self.channel_id && scope.root_event_id() == Some(&self.thread_root)
}
}

fn parse_bound_session(value: &str) -> Result<BoundSession, String> {
let binding: BoundSession = serde_json::from_str(value).map_err(|_| {
"bound session requires a channel UUID and canonical thread root".to_string()
})?;
if binding.thread_root.len() != 64
|| !binding
.thread_root
.bytes()
.all(|b| b.is_ascii_hexdigit() && !b.is_ascii_uppercase())
{
return Err("bound session thread_root must be a lowercase event ID".into());
}
Ok(binding)
}

/// Default idle timeout (seconds) when neither `--idle-timeout` nor the
/// deprecated `--turn-timeout` is set.
///
Expand Down Expand Up @@ -99,6 +128,8 @@ pub enum MultipleEventHandling {
pub enum RespondTo {
#[default]
OwnerOnly,
/// Only the human owner's signed events; exclude sibling agents and workflows.
Owner,
Comment thread
tee8z marked this conversation as resolved.
Allowlist,
Anyone,
Nobody,
Expand All @@ -108,6 +139,7 @@ impl std::fmt::Display for RespondTo {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::OwnerOnly => f.write_str("owner-only"),
Self::Owner => f.write_str("owner"),
Self::Allowlist => f.write_str("allowlist"),
Self::Anyone => f.write_str("anyone"),
Self::Nobody => f.write_str("nobody"),
Expand All @@ -128,6 +160,8 @@ pub enum PermissionMode {
/// Agent default — permission requests per tool call.
#[value(alias = "default")]
Default,
/// Codex tools run inside an externally isolated development container.
AgentFullAccess,
/// Auto mode — fully autonomous execution; model-gated (requires a model
/// that supports `supportsAutoMode`). Degrades gracefully to `default`
/// when the session's active model does not support it.
Expand All @@ -153,6 +187,7 @@ impl PermissionMode {
pub fn as_wire_str(&self) -> &'static str {
match self {
Self::Default => "default",
Self::AgentFullAccess => "agent-full-access",
Self::Auto => "auto",
Self::AcceptEdits => "acceptEdits",
Self::BypassPermissions => "bypassPermissions",
Expand Down Expand Up @@ -476,7 +511,7 @@ pub struct CliArgs {
pub permission_mode: PermissionMode,

/// Inbound author gate: which authors' events the harness forwards.
/// Modes: owner-only (default), allowlist, anyone, nobody.
/// Modes: owner-only (default), owner, allowlist, anyone, nobody.
#[arg(
long,
env = "BUZZ_ACP_RESPOND_TO",
Expand All @@ -492,7 +527,7 @@ pub struct CliArgs {

/// Comma-separated list of allowed `--respond-to` modes.
/// When set, the harness rejects startup if `--respond-to` is not in this list.
/// Modes: owner-only, allowlist, anyone, nobody.
/// Modes: owner-only, owner, allowlist, anyone, nobody.
/// Default: empty (all modes allowed — no restriction).
/// Example: `BUZZ_ACP_ALLOWED_RESPOND_TO=owner-only,allowlist`
#[arg(long, env = "BUZZ_ACP_ALLOWED_RESPOND_TO", value_delimiter = ',')]
Expand All @@ -506,6 +541,10 @@ pub struct CliArgs {
#[arg(long, env = "BUZZ_ACP_RELAY_OBSERVER", default_value_t = false)]
pub relay_observer: bool,

/// Restrict this remote workspace to one channel and canonical parent thread.
#[arg(long, env = "BUZZ_ACP_BOUND_SESSION", value_parser = parse_bound_session)]
pub bound_session: Option<BoundSession>,

/// Exit after this many seconds with no dispatched events and no turn in flight.
/// 0 disables inactivity self-termination.
#[arg(long, env = "BUZZ_ACP_EXIT_AFTER_INACTIVITY", default_value_t = 0)]
Expand Down Expand Up @@ -611,6 +650,7 @@ pub struct Config {
pub has_generated_codex_config: bool,
/// Whether to publish encrypted observer frames through the relay.
pub relay_observer: bool,
pub bound_session: Option<BoundSession>,
/// Seconds without dispatched events before an idle harness exits. 0 = disabled.
pub exit_after_inactivity_secs: u64,
/// Whether ACP/LLM subprocess initialization is deferred until accepted work arrives.
Expand Down Expand Up @@ -935,6 +975,28 @@ impl Config {
/// tests can construct `CliArgs` via `CliArgs::try_parse_from` and exercise the full
/// validation path without going through process args.
pub fn from_args(mut args: CliArgs) -> Result<Self, ConfigError> {
if args.bound_session.is_some() {
let refusal = if args.respond_to != RespondTo::Owner {
Some("--respond-to owner")
} else if args.subscribe != SubscribeMode::Mentions {
Some("--subscribe mentions")
} else if args.no_mention_filter {
Some("no --no-mention-filter")
} else if args.session_policy != crate::scope::SessionPolicy::Thread {
Some("--session-policy thread")
} else if args.heartbeat_interval != 0 {
Some("--heartbeat-interval 0")
} else if args.initial_message.is_some() {
Some("no --initial-message")
} else {
None
};
if let Some(requirement) = refusal {
return Err(ConfigError::ConfigFile(format!(
"--bound-session requires {requirement}"
)));
}
}
let keys = Keys::parse(&args.private_key)?;
// Best-effort zeroize: overwrite the raw private key string to reduce
// exposure via core dumps or heap inspection (#41). Without the `zeroize`
Expand Down Expand Up @@ -1119,7 +1181,7 @@ impl Config {
RespondTo::from_str(s.trim(), true).map_err(|_| {
ConfigError::ConfigFile(format!(
"invalid value in BUZZ_ACP_ALLOWED_RESPOND_TO: '{s}' \
(valid values: owner-only, allowlist, anyone, nobody)"
(valid values: owner-only, owner, allowlist, anyone, nobody)"
))
})?;
}
Expand Down Expand Up @@ -1202,6 +1264,7 @@ impl Config {
persona_env_vars,
has_generated_codex_config,
relay_observer: args.relay_observer,
bound_session: args.bound_session,
exit_after_inactivity_secs: args.exit_after_inactivity,
lazy_pool: args.lazy_pool,
idle_pool_sleep_secs: args.idle_pool_sleep,
Expand Down Expand Up @@ -1539,6 +1602,124 @@ mod tests {
use crate::filter::{ChannelScope, SubscriptionRule};
use clap::{Parser, ValueEnum};

#[test]
fn bound_session_accepts_only_its_canonical_channel_and_thread() {
use crate::scope::{SessionPolicy, SessionScope};
let channel_id = Uuid::new_v4();
let keys = Keys::generate();
let root = nostr::EventBuilder::new(nostr::Kind::Custom(9), "@agent start")
.sign_with_keys(&keys)
.unwrap();
let binding = parse_bound_session(
&serde_json::json!({
"channel_id": channel_id, "thread_root": root.id.to_hex()
})
.to_string(),
)
.unwrap();
assert!(binding.accepts(&SessionScope::derive(
SessionPolicy::Thread,
channel_id,
false,
&root
)));
let reply = nostr::EventBuilder::new(nostr::Kind::Custom(9), "@agent continue")
.tags([
nostr::Tag::parse(["e", &root.id.to_hex(), "", "root"]).unwrap(),
nostr::Tag::parse(["e", &"b".repeat(64), "", "reply"]).unwrap(),
])
.sign_with_keys(&keys)
.unwrap();
assert!(binding.accepts(&SessionScope::derive(
SessionPolicy::Thread,
channel_id,
false,
&reply
)));
assert!(!binding.accepts(&SessionScope::derive(
SessionPolicy::Thread,
Uuid::new_v4(),
false,
&reply
)));
assert!(!binding.accepts(&SessionScope::derive(
SessionPolicy::Thread,
channel_id,
true,
&reply
)));
let other = nostr::EventBuilder::new(nostr::Kind::Custom(9), "@agent new thread")
.sign_with_keys(&keys)
.unwrap();
assert!(!binding.accepts(&SessionScope::derive(
SessionPolicy::Thread,
channel_id,
false,
&other
)));
for invalid in ["short".to_string(), "A".repeat(64)] {
assert!(parse_bound_session(
&serde_json::json!({
"channel_id": channel_id, "thread_root": invalid
})
.to_string()
)
.is_err());
}
}

#[test]
fn bound_session_requires_explicit_owner_mentions_without_autonomous_prompts() {
let key = Keys::generate().secret_key().to_secret_hex();
let owner = Keys::generate().public_key().to_hex();
let binding =
serde_json::json!({"channel_id":Uuid::new_v4(), "thread_root":"a".repeat(64)})
.to_string();
let base = [
"buzz-acp",
"--private-key",
&key,
"--agent-owner",
&owner,
"--bound-session",
&binding,
"--respond-to",
"owner",
"--subscribe",
"mentions",
"--session-policy",
"thread",
"--heartbeat-interval",
"0",
];
assert!(Config::from_args(CliArgs::try_parse_from(base).unwrap()).is_ok());
for (variation, flag) in [
(0, "--respond-to owner"),
(1, "--no-mention-filter"),
(2, "--session-policy thread"),
(3, "--heartbeat-interval 0"),
(4, "--initial-message"),
(5, "--respond-to owner"),
(6, "--subscribe mentions"),
] {
let mut args = CliArgs::try_parse_from(base).unwrap();
match variation {
0 => args.respond_to = RespondTo::Anyone,
1 => args.no_mention_filter = true,
2 => args.session_policy = crate::scope::SessionPolicy::Channel,
3 => args.heartbeat_interval = 30,
4 => args.initial_message = Some("autonomous".into()),
5 => args.respond_to = RespondTo::OwnerOnly,
_ => args.subscribe = SubscribeMode::All,
}
let error = Config::from_args(args)
.err()
.unwrap_or_else(|| panic!("unsafe bound configuration {variation}"))
.to_string();
assert!(error.contains(flag), "configuration {variation}: {error}");
}
}

/// Build a minimal Config for testing without CLI parsing.
fn test_config(mode: SubscribeMode) -> Config {
Config {
Expand Down Expand Up @@ -1580,6 +1761,7 @@ mod tests {
persona_env_vars: vec![],
has_generated_codex_config: false,
relay_observer: false,
bound_session: None,
exit_after_inactivity_secs: 0,
lazy_pool: false,
idle_pool_sleep_secs: 0,
Expand Down Expand Up @@ -2407,6 +2589,10 @@ channels = "ALL"

#[test]
fn test_permission_mode_wire_strings() {
assert_eq!(
PermissionMode::AgentFullAccess.as_wire_str(),
"agent-full-access"
);
assert_eq!(PermissionMode::Default.as_wire_str(), "default");
assert_eq!(PermissionMode::Auto.as_wire_str(), "auto");
assert_eq!(PermissionMode::AcceptEdits.as_wire_str(), "acceptEdits");
Expand Down Expand Up @@ -2594,6 +2780,10 @@ channels = "ALL"
#[test]
fn test_respond_to_value_enum_parsing() {
use clap::ValueEnum;
assert_eq!(
RespondTo::from_str("owner", true).unwrap(),
RespondTo::Owner
);
assert_eq!(
RespondTo::from_str("owner-only", true).unwrap(),
RespondTo::OwnerOnly
Expand Down Expand Up @@ -2867,7 +3057,7 @@ channels = "ALL"
let mode = RespondTo::from_str(s.trim(), true).map_err(|_| {
ConfigError::ConfigFile(format!(
"invalid value in BUZZ_ACP_ALLOWED_RESPOND_TO: '{s}' \
(valid values: owner-only, allowlist, anyone, nobody)"
(valid values: owner-only, owner, allowlist, anyone, nobody)"
))
})?;
set.insert(mode);
Expand Down
Loading
Loading