Skip to content

Commit a6e1121

Browse files
committed
feat(acp): bind the harness to one owner thread
Add a strict owner respond-to mode that admits only the human owner's signed events, in DMs and channels, without sibling or workflow attribution. Add an optional bound session that restricts ingress and control commands to one channel and canonical thread root, and refuse bound configurations that allow non-owner, non-mention, non-thread, or autonomous prompts. Add the agent-full-access permission mode for externally isolated containers. The adapter must advertise and confirm it; unlike other modes it never falls back to per-tool approval. Signed-off-by: Tee8z <[email protected]>
1 parent 3cdaad7 commit a6e1121

3 files changed

Lines changed: 326 additions & 5 deletions

File tree

‎crates/buzz-acp/src/config.rs‎

Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,35 @@ use uuid::Uuid;
1919

2020
use 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 {
99128
pub 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

Comments
 (0)