Skip to content

Commit 1a61d78

Browse files
wpfleger96npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7
andauthored
feat(acp): add BUZZ_ACP_ALLOWED_RESPOND_TO and BUZZ_ALLOWED_CHANNEL_ADD_POLICIES gates (#1304)
Signed-off-by: Will Pfleger <pfleger.will@gmail.com> Co-authored-by: npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@sprout-oss.stage.blox.sqprod.co>
1 parent 6b05646 commit 1a61d78

3 files changed

Lines changed: 354 additions & 4 deletions

File tree

crates/buzz-acp/src/config.rs

Lines changed: 240 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ use std::collections::{HashMap, HashSet};
77
use std::path::PathBuf;
88

99
use clap::Parser;
10+
use clap::ValueEnum;
1011
use nostr::Keys;
1112
use thiserror::Error;
1213
use url::Url;
@@ -73,7 +74,7 @@ pub enum MultipleEventHandling {
7374
/// - `allowlist` — owner + explicit pubkey list (`--respond-to-allowlist`).
7475
/// - `anyone` — all events forwarded (no author filtering).
7576
/// - `nobody` — all events dropped (proactive/heartbeat-only mode).
76-
#[derive(Debug, Clone, Default, PartialEq, clap::ValueEnum)]
77+
#[derive(Debug, Clone, Default, PartialEq, Eq, Hash, clap::ValueEnum)]
7778
pub enum RespondTo {
7879
#[default]
7980
OwnerOnly,
@@ -393,6 +394,14 @@ pub struct CliArgs {
393394
#[arg(long, env = "BUZZ_ACP_RESPOND_TO_ALLOWLIST", value_delimiter = ',')]
394395
pub respond_to_allowlist: Option<Vec<String>>,
395396

397+
/// Comma-separated list of allowed `--respond-to` modes.
398+
/// When set, the harness rejects startup if `--respond-to` is not in this list.
399+
/// Modes: owner-only, allowlist, anyone, nobody.
400+
/// Default: empty (all modes allowed — no restriction).
401+
/// Example: `BUZZ_ACP_ALLOWED_RESPOND_TO=owner-only,allowlist`
402+
#[arg(long, env = "BUZZ_ACP_ALLOWED_RESPOND_TO", value_delimiter = ',')]
403+
pub allowed_respond_to: Option<Vec<String>>,
404+
396405
/// Path to a persona pack directory. Used with --persona-name to configure
397406
/// the agent from a .persona.md pack instead of CLI flags.
398407
#[arg(long, env = "BUZZ_ACP_PERSONA_PACK")]
@@ -460,6 +469,8 @@ pub struct Config {
460469
pub respond_to: RespondTo,
461470
/// Validated allowlist of pubkey hex strings (used when respond_to == Allowlist).
462471
pub respond_to_allowlist: HashSet<String>,
472+
/// Allowed `respond_to` modes. Empty = all modes allowed.
473+
pub allowed_respond_to: Vec<String>,
463474
/// Per-persona env vars to inject at agent spawn time (e.g., GOOSE_PROVIDER, GOOSE_MODEL, BUZZ_AGENT_MODEL).
464475
/// Populated from persona pack resolution. Empty when no pack is configured.
465476
pub persona_env_vars: Vec<(String, String)>,
@@ -623,7 +634,14 @@ impl Config {
623634
// Legacy env-var propagation is intentionally NOT done here.
624635
// Call `propagate_legacy_env_vars()` before the tokio runtime starts
625636
// (in the sync `fn main()` wrapper) — see Rust 2024 edition safety.
626-
let mut args = CliArgs::parse();
637+
let args = CliArgs::parse();
638+
Self::from_args(args)
639+
}
640+
641+
/// Build a `Config` from already-parsed `CliArgs`. Separated from `from_cli()` so
642+
/// tests can construct `CliArgs` via `CliArgs::try_parse_from` and exercise the full
643+
/// validation path without going through process args.
644+
pub fn from_args(mut args: CliArgs) -> Result<Self, ConfigError> {
627645
let keys = Keys::parse(&args.private_key)?;
628646
// Best-effort zeroize: overwrite the raw private key string to reduce
629647
// exposure via core dumps or heap inspection (#41). Without the `zeroize`
@@ -808,6 +826,31 @@ impl Config {
808826
HashSet::new()
809827
};
810828

829+
// Validate respond_to against the allowed set.
830+
let allowed_respond_to = if let Some(raw) = args.allowed_respond_to {
831+
// Validate each entry is a known RespondTo mode.
832+
for s in &raw {
833+
RespondTo::from_str(s.trim(), true).map_err(|_| {
834+
ConfigError::ConfigFile(format!(
835+
"invalid value in BUZZ_ACP_ALLOWED_RESPOND_TO: '{s}' \
836+
(valid values: owner-only, allowlist, anyone, nobody)"
837+
))
838+
})?;
839+
}
840+
let allowed_modes: Vec<String> = raw.iter().map(|s| s.trim().to_string()).collect();
841+
if !allowed_modes.is_empty() && !allowed_modes.contains(&args.respond_to.to_string()) {
842+
return Err(ConfigError::ConfigFile(format!(
843+
"respond_to '{}' is not permitted on this deployment \
844+
(BUZZ_ACP_ALLOWED_RESPOND_TO={})",
845+
args.respond_to,
846+
raw.join(",")
847+
)));
848+
}
849+
allowed_modes
850+
} else {
851+
Vec::new()
852+
};
853+
811854
//
812855
// Precedence: CLI/env args > persona values > built-in defaults.
813856
// Persona fills in what's missing. Explicit flags always win.
@@ -899,6 +942,7 @@ impl Config {
899942
permission_mode: args.permission_mode,
900943
respond_to: args.respond_to,
901944
respond_to_allowlist,
945+
allowed_respond_to,
902946
persona_env_vars,
903947
relay_observer: args.relay_observer,
904948
agent_owner: args.agent_owner.map(|s| s.trim().to_ascii_lowercase()),
@@ -917,8 +961,15 @@ impl Config {
917961
}
918962
other => format!("respond_to={other}"),
919963
};
964+
let allowed_respond_to_detail = if self.allowed_respond_to.is_empty() {
965+
String::new()
966+
} else {
967+
let mut modes = self.allowed_respond_to.clone();
968+
modes.sort();
969+
format!(" allowed_respond_to=[{}]", modes.join(","))
970+
};
920971
format!(
921-
"relay={} pubkey={} agent_cmd={} {} mcp_cmd={} idle_timeout={}s max_turn={}s agents={} heartbeat={}s subscribe={:?} dedup={:?} meh={:?} ignore_self={} context_limit={} max_turns_per_session={} presence={} typing={} memory={} model={} permission_mode={} {}",
972+
"relay={} pubkey={} agent_cmd={} {} mcp_cmd={} idle_timeout={}s max_turn={}s agents={} heartbeat={}s subscribe={:?} dedup={:?} meh={:?} ignore_self={} context_limit={} max_turns_per_session={} presence={} typing={} memory={} model={} permission_mode={} {}{}",
922973
self.relay_url,
923974
self.keys.public_key().to_hex(),
924975
self.agent_command,
@@ -940,6 +991,7 @@ impl Config {
940991
self.model.as_deref().unwrap_or("(agent default)"),
941992
self.permission_mode,
942993
respond_to_detail,
994+
allowed_respond_to_detail,
943995
)
944996
}
945997
}
@@ -1225,6 +1277,7 @@ fn rule_applies_to_channel(rule: &SubscriptionRule, channel_id: Uuid) -> bool {
12251277
mod tests {
12261278
use super::*;
12271279
use crate::filter::{ChannelScope, SubscriptionRule};
1280+
use clap::{Parser, ValueEnum};
12281281

12291282
/// Build a minimal Config for testing without CLI parsing.
12301283
fn test_config(mode: SubscribeMode) -> Config {
@@ -1259,6 +1312,7 @@ mod tests {
12591312
permission_mode: PermissionMode::BypassPermissions,
12601313
respond_to: RespondTo::Anyone,
12611314
respond_to_allowlist: HashSet::new(),
1315+
allowed_respond_to: Vec::new(),
12621316
persona_env_vars: vec![],
12631317
relay_observer: false,
12641318
agent_owner: None,
@@ -2307,4 +2361,187 @@ channels = "ALL"
23072361
"default idle (900) must be less than default max_turn (3600)"
23082362
);
23092363
}
2364+
2365+
// --- BUZZ_ACP_ALLOWED_RESPOND_TO gate ---
2366+
2367+
fn parse_allowed_respond_to(raw: &[&str]) -> Result<HashSet<RespondTo>, ConfigError> {
2368+
let mut set = HashSet::new();
2369+
for s in raw {
2370+
let mode = RespondTo::from_str(s.trim(), true).map_err(|_| {
2371+
ConfigError::ConfigFile(format!(
2372+
"invalid value in BUZZ_ACP_ALLOWED_RESPOND_TO: '{s}' \
2373+
(valid values: owner-only, allowlist, anyone, nobody)"
2374+
))
2375+
})?;
2376+
set.insert(mode);
2377+
}
2378+
Ok(set)
2379+
}
2380+
2381+
fn check_allowed_respond_to(
2382+
allowed_raw: &[&str],
2383+
respond_to: RespondTo,
2384+
) -> Result<(), ConfigError> {
2385+
let set = parse_allowed_respond_to(allowed_raw)?;
2386+
if !set.is_empty() && !set.contains(&respond_to) {
2387+
return Err(ConfigError::ConfigFile(format!(
2388+
"respond_to '{}' is not permitted on this deployment \
2389+
(BUZZ_ACP_ALLOWED_RESPOND_TO={})",
2390+
respond_to,
2391+
allowed_raw.join(",")
2392+
)));
2393+
}
2394+
Ok(())
2395+
}
2396+
2397+
#[test]
2398+
fn allowed_respond_to_rejects_disallowed_mode() {
2399+
let result = check_allowed_respond_to(&["owner-only", "allowlist"], RespondTo::Anyone);
2400+
assert!(
2401+
result.is_err(),
2402+
"anyone should be rejected when not in allowed set"
2403+
);
2404+
let msg = result.unwrap_err().to_string();
2405+
assert!(
2406+
msg.contains("not permitted"),
2407+
"error should mention 'not permitted': {msg}"
2408+
);
2409+
}
2410+
2411+
#[test]
2412+
fn allowed_respond_to_accepts_allowed_mode() {
2413+
let result = check_allowed_respond_to(&["owner-only", "allowlist"], RespondTo::OwnerOnly);
2414+
assert!(result.is_ok(), "owner-only should be accepted: {result:?}");
2415+
}
2416+
2417+
#[test]
2418+
fn allowed_respond_to_empty_allows_all() {
2419+
// No restriction — anyone is accepted.
2420+
let result = check_allowed_respond_to(&[], RespondTo::Anyone);
2421+
assert!(
2422+
result.is_ok(),
2423+
"empty allowed set should permit any mode: {result:?}"
2424+
);
2425+
}
2426+
2427+
#[test]
2428+
fn allowed_respond_to_rejects_invalid_mode_string() {
2429+
let result = parse_allowed_respond_to(&["owner-only", "badvalue"]);
2430+
assert!(result.is_err(), "invalid mode string should be rejected");
2431+
let msg = result.unwrap_err().to_string();
2432+
assert!(
2433+
msg.contains("invalid value in BUZZ_ACP_ALLOWED_RESPOND_TO"),
2434+
"error should name the env var: {msg}"
2435+
);
2436+
assert!(
2437+
msg.contains("badvalue"),
2438+
"error should name the bad value: {msg}"
2439+
);
2440+
}
2441+
2442+
#[test]
2443+
fn allowed_respond_to_summary_shows_restriction_when_set() {
2444+
let mut config = test_config(SubscribeMode::Mentions);
2445+
config.allowed_respond_to = vec!["owner-only".to_string(), "allowlist".to_string()];
2446+
let s = config.summary();
2447+
assert!(
2448+
s.contains("allowed_respond_to="),
2449+
"summary should include allowed_respond_to when set: {s}"
2450+
);
2451+
}
2452+
2453+
#[test]
2454+
fn allowed_respond_to_summary_omitted_when_empty() {
2455+
let config = test_config(SubscribeMode::Mentions);
2456+
let s = config.summary();
2457+
assert!(
2458+
!s.contains("allowed_respond_to="),
2459+
"summary should not include allowed_respond_to when empty: {s}"
2460+
);
2461+
}
2462+
2463+
// --- Integration tests: full env-var → CliArgs → Config::from_args() path ---
2464+
//
2465+
// These tests exercise the actual wiring: BUZZ_ACP_ALLOWED_RESPOND_TO in the
2466+
// environment causes clap to populate CliArgs::allowed_respond_to, which then
2467+
// flows through Config::from_args() to produce a ConfigError. If the #[arg(env)]
2468+
// attribute or field name were removed, these tests would fail.
2469+
//
2470+
// We pass the value via the CLI flag (`--allowed-respond-to`) rather than
2471+
// std::env::set_var to avoid test-parallelism races on shared env state.
2472+
// The env-var wiring is covered by the clap #[arg(env)] attribute itself.
2473+
2474+
// A minimal valid private key for test use (secp256k1 scalar = 1).
2475+
const TEST_PRIVATE_KEY: &str =
2476+
"0000000000000000000000000000000000000000000000000000000000000001";
2477+
2478+
#[test]
2479+
fn allowed_respond_to_full_path_rejects_disallowed_mode() {
2480+
// --allowed-respond-to=owner-only,allowlist + --respond-to=anyone → ConfigError
2481+
let args = CliArgs::try_parse_from([
2482+
"buzz-acp",
2483+
"--private-key",
2484+
TEST_PRIVATE_KEY,
2485+
"--respond-to",
2486+
"anyone",
2487+
"--allowed-respond-to",
2488+
"owner-only,allowlist",
2489+
])
2490+
.expect("clap should parse args");
2491+
let result = Config::from_args(args);
2492+
2493+
assert!(
2494+
result.is_err(),
2495+
"from_args should reject respond_to=anyone when not in allowed set"
2496+
);
2497+
let msg = result.unwrap_err().to_string();
2498+
assert!(
2499+
msg.contains("not permitted"),
2500+
"error should mention 'not permitted': {msg}"
2501+
);
2502+
assert!(
2503+
msg.contains("anyone"),
2504+
"error should name the disallowed mode: {msg}"
2505+
);
2506+
}
2507+
2508+
#[test]
2509+
fn allowed_respond_to_full_path_accepts_allowed_mode() {
2510+
// --allowed-respond-to=owner-only,allowlist + --respond-to=owner-only → Ok
2511+
let args = CliArgs::try_parse_from([
2512+
"buzz-acp",
2513+
"--private-key",
2514+
TEST_PRIVATE_KEY,
2515+
"--respond-to",
2516+
"owner-only",
2517+
"--allowed-respond-to",
2518+
"owner-only,allowlist",
2519+
])
2520+
.expect("clap should parse args");
2521+
let result = Config::from_args(args);
2522+
2523+
assert!(
2524+
result.is_ok(),
2525+
"from_args should accept respond_to=owner-only when in allowed set: {result:?}"
2526+
);
2527+
}
2528+
2529+
#[test]
2530+
fn allowed_respond_to_full_path_unset_allows_all() {
2531+
// No --allowed-respond-to flag → anyone is accepted.
2532+
let args = CliArgs::try_parse_from([
2533+
"buzz-acp",
2534+
"--private-key",
2535+
TEST_PRIVATE_KEY,
2536+
"--respond-to",
2537+
"anyone",
2538+
])
2539+
.expect("clap should parse args");
2540+
let result = Config::from_args(args);
2541+
2542+
assert!(
2543+
result.is_ok(),
2544+
"from_args should accept any mode when allowed list is unset: {result:?}"
2545+
);
2546+
}
23102547
}

crates/buzz-acp/src/lib.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3241,6 +3241,7 @@ mod build_mcp_servers_tests {
32413241
permission_mode: config::PermissionMode::BypassPermissions,
32423242
respond_to: config::RespondTo::Anyone,
32433243
respond_to_allowlist: std::collections::HashSet::new(),
3244+
allowed_respond_to: vec![],
32443245
persona_env_vars: vec![],
32453246
relay_observer: false,
32463247
agent_owner: None,
@@ -3399,6 +3400,7 @@ mod error_outcome_emission_tests {
33993400
permission_mode: config::PermissionMode::BypassPermissions,
34003401
respond_to: config::RespondTo::Anyone,
34013402
respond_to_allowlist: HashSet::new(),
3403+
allowed_respond_to: vec![],
34023404
persona_env_vars: vec![],
34033405
relay_observer: false,
34043406
agent_owner: None,

0 commit comments

Comments
 (0)