@@ -19,6 +19,35 @@ use uuid::Uuid;
1919
2020use crate :: filter:: SubscriptionRule ;
2121
22+ /// Immutable conversation binding for a remote workspace.
23+ #[ derive( Debug , Clone , serde:: Deserialize ) ]
24+ #[ serde( deny_unknown_fields) ]
25+ pub struct BoundSession {
26+ pub channel_id : Uuid ,
27+ pub thread_root : String ,
28+ }
29+
30+ impl BoundSession {
31+ pub fn accepts ( & self , scope : & crate :: scope:: SessionScope ) -> bool {
32+ scope. channel_id ( ) == self . channel_id && scope. root_event_id ( ) == Some ( & self . thread_root )
33+ }
34+ }
35+
36+ fn parse_bound_session ( value : & str ) -> Result < BoundSession , String > {
37+ let binding: BoundSession = serde_json:: from_str ( value) . map_err ( |_| {
38+ "bound session requires a channel UUID and canonical thread root" . to_string ( )
39+ } ) ?;
40+ if binding. thread_root . len ( ) != 64
41+ || !binding
42+ . thread_root
43+ . bytes ( )
44+ . all ( |b| b. is_ascii_hexdigit ( ) && !b. is_ascii_uppercase ( ) )
45+ {
46+ return Err ( "bound session thread_root must be a lowercase event ID" . into ( ) ) ;
47+ }
48+ Ok ( binding)
49+ }
50+
2251/// Default idle timeout (seconds) when neither `--idle-timeout` nor the
2352/// deprecated `--turn-timeout` is set.
2453///
@@ -99,6 +128,8 @@ pub enum MultipleEventHandling {
99128pub enum RespondTo {
100129 #[ default]
101130 OwnerOnly ,
131+ /// Only the human owner's signed events; exclude sibling agents and workflows.
132+ Owner ,
102133 Allowlist ,
103134 Anyone ,
104135 Nobody ,
@@ -108,6 +139,7 @@ impl std::fmt::Display for RespondTo {
108139 fn fmt ( & self , f : & mut std:: fmt:: Formatter < ' _ > ) -> std:: fmt:: Result {
109140 match self {
110141 Self :: OwnerOnly => f. write_str ( "owner-only" ) ,
142+ Self :: Owner => f. write_str ( "owner" ) ,
111143 Self :: Allowlist => f. write_str ( "allowlist" ) ,
112144 Self :: Anyone => f. write_str ( "anyone" ) ,
113145 Self :: Nobody => f. write_str ( "nobody" ) ,
@@ -128,6 +160,8 @@ pub enum PermissionMode {
128160 /// Agent default — permission requests per tool call.
129161 #[ value( alias = "default" ) ]
130162 Default ,
163+ /// Codex tools run inside an externally isolated development container.
164+ AgentFullAccess ,
131165 /// Auto mode — fully autonomous execution; model-gated (requires a model
132166 /// that supports `supportsAutoMode`). Degrades gracefully to `default`
133167 /// when the session's active model does not support it.
@@ -153,6 +187,7 @@ impl PermissionMode {
153187 pub fn as_wire_str ( & self ) -> & ' static str {
154188 match self {
155189 Self :: Default => "default" ,
190+ Self :: AgentFullAccess => "agent-full-access" ,
156191 Self :: Auto => "auto" ,
157192 Self :: AcceptEdits => "acceptEdits" ,
158193 Self :: BypassPermissions => "bypassPermissions" ,
@@ -506,6 +541,10 @@ pub struct CliArgs {
506541 #[ arg( long, env = "BUZZ_ACP_RELAY_OBSERVER" , default_value_t = false ) ]
507542 pub relay_observer : bool ,
508543
544+ /// Restrict this remote workspace to one channel and canonical parent thread.
545+ #[ arg( long, env = "BUZZ_ACP_BOUND_SESSION" , value_parser = parse_bound_session) ]
546+ pub bound_session : Option < BoundSession > ,
547+
509548 /// Exit after this many seconds with no dispatched events and no turn in flight.
510549 /// 0 disables inactivity self-termination.
511550 #[ arg( long, env = "BUZZ_ACP_EXIT_AFTER_INACTIVITY" , default_value_t = 0 ) ]
@@ -611,6 +650,7 @@ pub struct Config {
611650 pub has_generated_codex_config : bool ,
612651 /// Whether to publish encrypted observer frames through the relay.
613652 pub relay_observer : bool ,
653+ pub bound_session : Option < BoundSession > ,
614654 /// Seconds without dispatched events before an idle harness exits. 0 = disabled.
615655 pub exit_after_inactivity_secs : u64 ,
616656 /// Whether ACP/LLM subprocess initialization is deferred until accepted work arrives.
@@ -935,6 +975,19 @@ impl Config {
935975 /// tests can construct `CliArgs` via `CliArgs::try_parse_from` and exercise the full
936976 /// validation path without going through process args.
937977 pub fn from_args ( mut args : CliArgs ) -> Result < Self , ConfigError > {
978+ if args. bound_session . is_some ( )
979+ && ( args. respond_to != RespondTo :: Owner
980+ || args. subscribe != SubscribeMode :: Mentions
981+ || args. no_mention_filter
982+ || args. session_policy != crate :: scope:: SessionPolicy :: Thread
983+ || args. heartbeat_interval != 0
984+ || args. initial_message . is_some ( ) )
985+ {
986+ return Err ( ConfigError :: ConfigFile (
987+ "bound sessions require owner mentions, thread policy, and no autonomous prompts"
988+ . into ( ) ,
989+ ) ) ;
990+ }
938991 let keys = Keys :: parse ( & args. private_key ) ?;
939992 // Best-effort zeroize: overwrite the raw private key string to reduce
940993 // exposure via core dumps or heap inspection (#41). Without the `zeroize`
@@ -1202,6 +1255,7 @@ impl Config {
12021255 persona_env_vars,
12031256 has_generated_codex_config,
12041257 relay_observer : args. relay_observer ,
1258+ bound_session : args. bound_session ,
12051259 exit_after_inactivity_secs : args. exit_after_inactivity ,
12061260 lazy_pool : args. lazy_pool ,
12071261 idle_pool_sleep_secs : args. idle_pool_sleep ,
@@ -1539,6 +1593,114 @@ mod tests {
15391593 use crate :: filter:: { ChannelScope , SubscriptionRule } ;
15401594 use clap:: { Parser , ValueEnum } ;
15411595
1596+ #[ test]
1597+ fn bound_session_accepts_only_its_canonical_channel_and_thread ( ) {
1598+ use crate :: scope:: { SessionPolicy , SessionScope } ;
1599+ let channel_id = Uuid :: new_v4 ( ) ;
1600+ let keys = Keys :: generate ( ) ;
1601+ let root = nostr:: EventBuilder :: new ( nostr:: Kind :: Custom ( 9 ) , "@agent start" )
1602+ . sign_with_keys ( & keys)
1603+ . unwrap ( ) ;
1604+ let binding = parse_bound_session (
1605+ & serde_json:: json!( {
1606+ "channel_id" : channel_id, "thread_root" : root. id. to_hex( )
1607+ } )
1608+ . to_string ( ) ,
1609+ )
1610+ . unwrap ( ) ;
1611+ assert ! ( binding. accepts( & SessionScope :: derive(
1612+ SessionPolicy :: Thread ,
1613+ channel_id,
1614+ false ,
1615+ & root
1616+ ) ) ) ;
1617+ let reply = nostr:: EventBuilder :: new ( nostr:: Kind :: Custom ( 9 ) , "@agent continue" )
1618+ . tags ( [
1619+ nostr:: Tag :: parse ( [ "e" , & root. id . to_hex ( ) , "" , "root" ] ) . unwrap ( ) ,
1620+ nostr:: Tag :: parse ( [ "e" , & "b" . repeat ( 64 ) , "" , "reply" ] ) . unwrap ( ) ,
1621+ ] )
1622+ . sign_with_keys ( & keys)
1623+ . unwrap ( ) ;
1624+ assert ! ( binding. accepts( & SessionScope :: derive(
1625+ SessionPolicy :: Thread ,
1626+ channel_id,
1627+ false ,
1628+ & reply
1629+ ) ) ) ;
1630+ assert ! ( !binding. accepts( & SessionScope :: derive(
1631+ SessionPolicy :: Thread ,
1632+ Uuid :: new_v4( ) ,
1633+ false ,
1634+ & reply
1635+ ) ) ) ;
1636+ assert ! ( !binding. accepts( & SessionScope :: derive(
1637+ SessionPolicy :: Thread ,
1638+ channel_id,
1639+ true ,
1640+ & reply
1641+ ) ) ) ;
1642+ let other = nostr:: EventBuilder :: new ( nostr:: Kind :: Custom ( 9 ) , "@agent new thread" )
1643+ . sign_with_keys ( & keys)
1644+ . unwrap ( ) ;
1645+ assert ! ( !binding. accepts( & SessionScope :: derive(
1646+ SessionPolicy :: Thread ,
1647+ channel_id,
1648+ false ,
1649+ & other
1650+ ) ) ) ;
1651+ for invalid in [ "short" . to_string ( ) , "A" . repeat ( 64 ) ] {
1652+ assert ! ( parse_bound_session(
1653+ & serde_json:: json!( {
1654+ "channel_id" : channel_id, "thread_root" : invalid
1655+ } )
1656+ . to_string( )
1657+ )
1658+ . is_err( ) ) ;
1659+ }
1660+ }
1661+
1662+ #[ test]
1663+ fn bound_session_requires_explicit_owner_mentions_without_autonomous_prompts ( ) {
1664+ let key = Keys :: generate ( ) . secret_key ( ) . to_secret_hex ( ) ;
1665+ let owner = Keys :: generate ( ) . public_key ( ) . to_hex ( ) ;
1666+ let binding =
1667+ serde_json:: json!( { "channel_id" : Uuid :: new_v4( ) , "thread_root" : "a" . repeat( 64 ) } )
1668+ . to_string ( ) ;
1669+ let base = [
1670+ "buzz-acp" ,
1671+ "--private-key" ,
1672+ & key,
1673+ "--agent-owner" ,
1674+ & owner,
1675+ "--bound-session" ,
1676+ & binding,
1677+ "--respond-to" ,
1678+ "owner" ,
1679+ "--subscribe" ,
1680+ "mentions" ,
1681+ "--session-policy" ,
1682+ "thread" ,
1683+ "--heartbeat-interval" ,
1684+ "0" ,
1685+ ] ;
1686+ assert ! ( Config :: from_args( CliArgs :: try_parse_from( base) . unwrap( ) ) . is_ok( ) ) ;
1687+ for variation in 0 ..6 {
1688+ let mut args = CliArgs :: try_parse_from ( base) . unwrap ( ) ;
1689+ match variation {
1690+ 0 => args. respond_to = RespondTo :: Anyone ,
1691+ 1 => args. no_mention_filter = true ,
1692+ 2 => args. session_policy = crate :: scope:: SessionPolicy :: Channel ,
1693+ 3 => args. heartbeat_interval = 30 ,
1694+ 4 => args. initial_message = Some ( "autonomous" . into ( ) ) ,
1695+ _ => args. respond_to = RespondTo :: OwnerOnly ,
1696+ }
1697+ assert ! (
1698+ Config :: from_args( args) . is_err( ) ,
1699+ "unsafe bound configuration {variation}"
1700+ ) ;
1701+ }
1702+ }
1703+
15421704 /// Build a minimal Config for testing without CLI parsing.
15431705 fn test_config ( mode : SubscribeMode ) -> Config {
15441706 Config {
@@ -1580,6 +1742,7 @@ mod tests {
15801742 persona_env_vars : vec ! [ ] ,
15811743 has_generated_codex_config : false ,
15821744 relay_observer : false ,
1745+ bound_session : None ,
15831746 exit_after_inactivity_secs : 0 ,
15841747 lazy_pool : false ,
15851748 idle_pool_sleep_secs : 0 ,
@@ -2407,6 +2570,10 @@ channels = "ALL"
24072570
24082571 #[ test]
24092572 fn test_permission_mode_wire_strings ( ) {
2573+ assert_eq ! (
2574+ PermissionMode :: AgentFullAccess . as_wire_str( ) ,
2575+ "agent-full-access"
2576+ ) ;
24102577 assert_eq ! ( PermissionMode :: Default . as_wire_str( ) , "default" ) ;
24112578 assert_eq ! ( PermissionMode :: Auto . as_wire_str( ) , "auto" ) ;
24122579 assert_eq ! ( PermissionMode :: AcceptEdits . as_wire_str( ) , "acceptEdits" ) ;
@@ -2594,6 +2761,10 @@ channels = "ALL"
25942761 #[ test]
25952762 fn test_respond_to_value_enum_parsing ( ) {
25962763 use clap:: ValueEnum ;
2764+ assert_eq ! (
2765+ RespondTo :: from_str( "owner" , true ) . unwrap( ) ,
2766+ RespondTo :: Owner
2767+ ) ;
25972768 assert_eq ! (
25982769 RespondTo :: from_str( "owner-only" , true ) . unwrap( ) ,
25992770 RespondTo :: OwnerOnly
0 commit comments