Skip to content
Merged
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
5 changes: 3 additions & 2 deletions crates/buzz-acp/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -135,14 +135,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-strict`, `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 its sibling agents (any agent whose profile carries an attestation from the same owner). If no owner is set, all events are dropped until the owner is resolved. |
| `owner-strict` | Forward only events the registered owner signed, in channels and DMs. Sibling agents are dropped, so a compromised sibling cannot hand this agent work. Relay-signed workflow messages are dropped too, because a trigger can carry another author's text. If no owner is set, all events are dropped. |
| `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
19 changes: 14 additions & 5 deletions crates/buzz-acp/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -314,14 +314,17 @@ pub enum MultipleEventHandling {

/// Inbound author gate: which authors' events the harness forwards to the agent.
///
/// - `owner-only` — only the agent's registered owner (default).
/// - `owner-only` — the agent's registered owner and its sibling agents (default).
/// - `owner-strict` — the owner alone, as raw signer, in channels and DMs; never
/// siblings or owner-attributed workflow messages.
/// - `allowlist` — owner + explicit pubkey list (`--respond-to-allowlist`).
/// - `anyone` — all events forwarded (no author filtering).
/// - `nobody` — all events dropped (proactive/heartbeat-only mode).
#[derive(Debug, Clone, Default, PartialEq, Eq, Hash, clap::ValueEnum)]
pub enum RespondTo {
#[default]
OwnerOnly,
OwnerStrict,
Allowlist,
Anyone,
Nobody,
Expand All @@ -331,6 +334,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::OwnerStrict => f.write_str("owner-strict"),
Self::Allowlist => f.write_str("allowlist"),
Self::Anyone => f.write_str("anyone"),
Self::Nobody => f.write_str("nobody"),
Expand Down Expand Up @@ -489,7 +493,7 @@ pub struct CliArgs {
#[arg(long, default_value_t = false)]
pub gwp_worker: bool,

/// Agent owner pubkey (64-char hex). Used for --respond-to=owner-only gate.
/// Agent owner pubkey (64-char hex). Used by the owner-only and owner-strict gates.
#[arg(long, env = "BUZZ_ACP_AGENT_OWNER")]
pub agent_owner: Option<String>,

Expand Down Expand Up @@ -726,7 +730,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-strict, allowlist, anyone, nobody.
#[arg(
long,
env = "BUZZ_ACP_RESPOND_TO",
Expand Down Expand Up @@ -1478,7 +1482,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-strict, allowlist, anyone, nobody)"
))
})?;
}
Expand Down Expand Up @@ -3154,6 +3158,7 @@ channels = "ALL"
#[test]
fn test_respond_to_display() {
assert_eq!(format!("{}", RespondTo::OwnerOnly), "owner-only");
assert_eq!(format!("{}", RespondTo::OwnerStrict), "owner-strict");
assert_eq!(format!("{}", RespondTo::Allowlist), "allowlist");
assert_eq!(format!("{}", RespondTo::Anyone), "anyone");
assert_eq!(format!("{}", RespondTo::Nobody), "nobody");
Expand All @@ -3166,6 +3171,10 @@ channels = "ALL"
RespondTo::from_str("owner-only", true).unwrap(),
RespondTo::OwnerOnly
);
assert_eq!(
RespondTo::from_str("owner-strict", true).unwrap(),
RespondTo::OwnerStrict
);
assert_eq!(
RespondTo::from_str("allowlist", true).unwrap(),
RespondTo::Allowlist
Expand Down Expand Up @@ -3435,7 +3444,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-strict, allowlist, anyone, nobody)"
))
})?;
set.insert(mode);
Expand Down
112 changes: 107 additions & 5 deletions crates/buzz-acp/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -384,13 +384,19 @@ mod inbound_author_gate {
if is_dm {
return match respond_to {
RespondTo::Nobody => false,
RespondTo::OwnerStrict => owner_cache.get() == Some(author),
_ => is_owner_or_sibling(author, owner_cache, rest_client).await,
};
}
match respond_to {
RespondTo::Anyone => true,
RespondTo::Nobody => false,
RespondTo::OwnerOnly => is_owner_or_sibling(author, owner_cache, rest_client).await,
// Siblings are any agent carrying an attestation from the same
// owner, so one compromised sibling could hand work to another.
// Strict mode admits the owner alone; an unresolved owner admits
// nobody.
RespondTo::OwnerStrict => owner_cache.get() == Some(author),
RespondTo::Allowlist => {
allowlist.contains(author)
|| is_owner_or_sibling(author, owner_cache, rest_client).await
Expand Down Expand Up @@ -507,8 +513,15 @@ mod inbound_author_gate {
owner_cache: &OwnerCache,
rest_client: &relay::RestClient,
) -> InboundAuthorGateDecision {
let effective_author =
effective_prompt_author(event, self.relay_self.as_deref(), &self.agent_pubkey_hex);
// Workflow attribution names the workflow's owner, but a trigger
// can carry another author's text into its message. Strict mode
// trusts only the raw signer, so an owner workflow cannot launder
// a sibling's post into an owner turn.
let effective_author = if matches!(respond_to, RespondTo::OwnerStrict) {
event.pubkey.to_hex()
} else {
effective_prompt_author(event, self.relay_self.as_deref(), &self.agent_pubkey_hex)
};
let allowed = author_allowed(
respond_to,
allowlist,
Expand Down Expand Up @@ -2652,10 +2665,11 @@ async fn tokio_main() -> Result<()> {
// Warn if owner-dependent mode but no owner resolved yet.
if startup_owner.is_none() {
match &config.respond_to {
RespondTo::OwnerOnly => {
RespondTo::OwnerOnly | RespondTo::OwnerStrict => {
tracing::warn!(
"respond-to=owner-only but no owner is set — all events will be \
dropped. Set BUZZ_AUTH_TAG or --agent-owner, or use --respond-to=anyone."
"respond-to={} but no owner is set — all events will be \
dropped. Set BUZZ_AUTH_TAG or --agent-owner, or use --respond-to=anyone.",
config.respond_to
);
}
RespondTo::Allowlist => {
Expand Down Expand Up @@ -7202,6 +7216,38 @@ mod author_gate_tests {
}
}

/// Strict mode must not trust workflow attribution: a message-triggered
/// owner workflow could carry a sibling's text. Both production listeners
/// must drop a genuine relay-signed dispatch that names the owner.
#[tokio::test]
async fn production_listener_boundaries_ignore_workflow_owner_under_owner_strict() {
for listener in [ListenerBoundary::Normal, ListenerBoundary::Setup] {
let relay_keys = nostr::Keys::generate();
let relay_hex = relay_keys.public_key().to_hex();
let owner = nostr::Keys::generate().public_key().to_hex();
let (attributed, allowed) = listener_boundary_scenario(ListenerBoundaryScenario {
listener,
relay_keys: &relay_keys,
workflow_owner: &owner,
responses: std::collections::VecDeque::from([Ok(
serde_json::json!({ "self": relay_hex }),
)]),
event_generation: 0,
channel_type: "stream",
respond_to: RespondTo::OwnerStrict,
allowlist: HashSet::new(),
cache_owner: true,
cache_sibling: false,
})
.await;
assert!(
!allowed && attributed.is_none(),
"{} listener must drop an owner-attributed workflow under owner-strict",
listener.name()
);
}
}

/// Both production listener callables must attribute relay-signed workflow
/// events to the workflow owner and enforce policy there. A local
/// `allowed: true` replacement at either call site makes the Nobody case
Expand Down Expand Up @@ -8071,6 +8117,62 @@ mod author_gate_tests {
}
}

#[tokio::test]
async fn test_owner_strict_admits_only_the_owner() {
let cache = cache_with_sibling();
for is_dm in [false, true] {
assert!(
inbound_author_gate::test_author_allowed(
&RespondTo::OwnerStrict,
&HashSet::new(),
OWNER,
is_dm,
&cache,
&dummy_rest_client()
)
.await,
"owner-strict must admit the owner (dm={is_dm})"
);
for (who, label) in [
(SIBLING, "sibling"),
(STRANGER, "stranger"),
(EXTERNAL, "external"),
] {
assert!(
!inbound_author_gate::test_author_allowed(
&RespondTo::OwnerStrict,
&HashSet::from([EXTERNAL.to_string()]),
who,
is_dm,
&cache,
&dummy_rest_client()
)
.await,
"owner-strict must drop the {label} (dm={is_dm})"
);
}
}
}

#[tokio::test]
async fn test_owner_strict_without_owner_admits_nobody() {
let cache = OwnerCache::new(None);
for is_dm in [false, true] {
assert!(
!inbound_author_gate::test_author_allowed(
&RespondTo::OwnerStrict,
&HashSet::new(),
OWNER,
is_dm,
&cache,
&dummy_rest_client()
)
.await,
"an unresolved owner must fail closed (dm={is_dm})"
);
}
}

// ── DM hardening ──────────────────────────────────────────────────────
//
// In a DM, clients auto-p-tag every participant, and an agent can be
Expand Down
Loading