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>
This commit is contained in:
Will Pfleger
2026-06-26 10:39:11 -07:00
committed by GitHub
co-authored by npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7
parent 6b0564618b
commit 1a61d783ad
3 changed files with 354 additions and 4 deletions
+112 -1
View File
@@ -527,6 +527,26 @@ pub async fn cmd_set_add_policy(client: &BuzzClient, policy: &str) -> Result<(),
}
}
// Check if this policy is allowed by the deployment.
// NOTE: This gate covers only the `buzz channels set-add-policy` CLI path.
// A client that submits a kind:10100 event directly to the relay bypasses
// this check. Full enforcement requires relay-side validation, which is
// intentionally out of scope for this change (see team decision: no
// relay-side enforcement of client behavior).
if let Ok(allowed_raw) = std::env::var("BUZZ_ACP_ALLOWED_CHANNEL_ADD_POLICIES") {
let allowed: Vec<&str> = allowed_raw
.split(',')
.map(str::trim)
.filter(|s| !s.is_empty())
.collect();
if !allowed.is_empty() && !allowed.contains(&policy) {
return Err(CliError::Usage(format!(
"channel_add_policy '{policy}' is not permitted on this deployment \
(BUZZ_ACP_ALLOWED_CHANNEL_ADD_POLICIES={allowed_raw})"
)));
}
}
let content = serde_json::json!({ "channel_add_policy": policy }).to_string();
use nostr::{EventBuilder, Kind};
let builder = EventBuilder::new(
@@ -648,7 +668,9 @@ pub async fn dispatch_canvas(cmd: crate::CanvasCmd, client: &BuzzClient) -> Resu
#[cfg(test)]
mod tests {
use super::{name_matches, validate_ttl_seconds, ChannelSummary};
use super::{cmd_set_add_policy, name_matches, validate_ttl_seconds, ChannelSummary};
use crate::client::BuzzClient;
use crate::CliError;
use serde_json::json;
fn event(tags: serde_json::Value) -> serde_json::Value {
@@ -756,4 +778,93 @@ mod tests {
fn validate_ttl_rejects_overflow() {
assert!(validate_ttl_seconds(i32::MAX as i64 + 1).is_err());
}
// --- BUZZ_ACP_ALLOWED_CHANNEL_ADD_POLICIES gate ---
fn check_allowed_channel_add_policy(allowed_raw: &str, policy: &str) -> Result<(), CliError> {
let allowed: Vec<&str> = allowed_raw
.split(',')
.map(str::trim)
.filter(|s| !s.is_empty())
.collect();
if !allowed.is_empty() && !allowed.contains(&policy) {
return Err(CliError::Usage(format!(
"channel_add_policy '{policy}' is not permitted on this deployment \
(BUZZ_ACP_ALLOWED_CHANNEL_ADD_POLICIES={allowed_raw})"
)));
}
Ok(())
}
#[test]
fn set_add_policy_rejects_disallowed_policy() {
let result = check_allowed_channel_add_policy("owner_only,nobody", "anyone");
assert!(
result.is_err(),
"anyone should be rejected when not in allowed set"
);
let msg = result.unwrap_err().to_string();
assert!(
msg.contains("not permitted"),
"error should mention 'not permitted': {msg}"
);
assert!(
msg.contains("anyone"),
"error should name the disallowed policy: {msg}"
);
}
#[test]
fn set_add_policy_accepts_allowed_policy() {
let result = check_allowed_channel_add_policy("owner_only,nobody", "owner_only");
assert!(result.is_ok(), "owner_only should be accepted: {result:?}");
}
#[test]
fn set_add_policy_no_restriction_allows_all() {
// Empty allowed list means no restriction.
let result = check_allowed_channel_add_policy("", "anyone");
assert!(
result.is_ok(),
"empty allowed list should permit any policy: {result:?}"
);
}
// --- Integration test: full env-var → cmd_set_add_policy() path ---
//
// This test calls cmd_set_add_policy directly with the env var set. The function
// returns early with an error before any network call, so no relay is needed.
// If the BUZZ_ACP_ALLOWED_CHANNEL_ADD_POLICIES check were removed from cmd_set_add_policy,
// this test would fail (it would proceed to sign_event and return a different error).
fn make_test_client() -> BuzzClient {
// Scalar = 1 is the smallest valid secp256k1 private key.
let keys =
nostr::Keys::parse("0000000000000000000000000000000000000000000000000000000000000001")
.expect("valid test key");
BuzzClient::new("ws://localhost:3000".to_string(), keys, None, None)
.expect("client construction should not fail")
}
#[tokio::test]
async fn set_add_policy_env_gate_rejects_disallowed_via_full_path() {
std::env::set_var("BUZZ_ACP_ALLOWED_CHANNEL_ADD_POLICIES", "owner_only,nobody");
let client = make_test_client();
let result = cmd_set_add_policy(&client, "anyone").await;
std::env::remove_var("BUZZ_ACP_ALLOWED_CHANNEL_ADD_POLICIES");
assert!(
result.is_err(),
"cmd_set_add_policy should reject 'anyone' when not in allowed set"
);
match result.unwrap_err() {
crate::CliError::Usage(msg) => {
assert!(
msg.contains("not permitted"),
"error should mention 'not permitted': {msg}"
);
}
other => panic!("expected CliError::Usage, got {other:?}"),
}
}
}