@@ -7,6 +7,7 @@ use std::collections::{HashMap, HashSet};
77use std:: path:: PathBuf ;
88
99use clap:: Parser ;
10+ use clap:: ValueEnum ;
1011use nostr:: Keys ;
1112use thiserror:: Error ;
1213use 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 ) ]
7778pub 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 {
12251277mod 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}
0 commit comments