Skip to content

feat(nostr): add NIP-29 group chat support for Buzz channels - #1168

Open
penso wants to merge 16 commits into
mainfrom
claude/buzz-support-moltis-jr1iub
Open

feat(nostr): add NIP-29 group chat support for Buzz channels#1168
penso wants to merge 16 commits into
mainfrom
claude/buzz-support-moltis-jr1iub

Conversation

@penso

@penso penso commented Jul 25, 2026

Copy link
Copy Markdown
Collaborator

Summary

Buzz is Block's open-source workspace where AI agents and humans are equal members of team channels. It is a Nostr relay you self-host: its API is NIP-29 group chat over a NIP-42-authenticated connection.

moltis-nostr previously spoke only NIP-04/NIP-59 encrypted DMs, so it could not join those channels. This makes a moltis agent a first-class Buzz channel member: it reads channel traffic, replies in-thread, streams its answer live, and acknowledges work with reactions.

Wire details were taken from Buzz's own source — crates/buzz-core/src/kind.rs for kind numbers and crates/buzz-sdk/src/builders.rs for exact tag arrays — not inferred.

Capability How
Join channels NIP-29 h-tag subscription, NIP-42 auth
Read messages kind:9 and Buzz kind:40002
Reply in thread NIP-10 ["e", <id>, "", "reply"] marker + p-tag on the author
Stream answers live Buzz edits (kind:40003), throttled ~1/s
Acknowledge NIP-25 kind:7 👀 → ✅/❌, retracted via NIP-09 kind:5
Slash commands /help etc. handled in-channel, as in DMs
Per-channel sessions group id is the session key

Two message kinds

Buzz posts channel messages as kind:40002 (KIND_STREAM_MESSAGE_V2); plain NIP-29 uses kind:9. Buzz's architecture notes are explicit that "clients filtering by kind 9 alone would not receive kind 40002 messages, and vice versa" — an earlier revision of this branch spoke only kind:9 and would have been mutually invisible on a real Buzz relay.

Both kinds are now read, and replies mirror the kind they answer, so Buzz works with no configuration. group_message_kind only picks the dialect for bot-initiated messages in a channel nothing has been received from yet.

Config

groups, group_mention_mode (mention default / always / none), group_message_kind (nip29 / buzz_v2), group_ack_reactions (default true). Empty groups keeps the account DM-only, so existing configs are unaffected. Group subscriptions are fixed at connect, so changing groups needs an account restart.

Security

groups is the entire group access model — both the set subscribed to and the set accepted from. A group's h tag is an unauthenticated label: the signature proves who wrote an event, not which group it belongs to, and nostr-sdk leaves verify_subscriptions off by default. Without an explicit re-check a hostile or buggy relay could push a message carrying any h tag straight into the agent.

An earlier revision had exactly that hole (a group_policy = "open" default). That knob is gone: once access is bounded by the joined set, open and allowlist are indistinguishable, so keeping it would have advertised protection it did not provide. Matching is exact. The client also sets verify_subscriptions(true).

Group chat has no sender allowlist by design: in NIP-29 the relay owns membership, so anyone it admits to a joined channel can address the bot — the same trust model as a Slack channel, and a reason to prefer a relay you host. Reactions are group-only; reacting to a gift-wrapped DM would reveal that a conversation happened.

Validation

Completed

  • cargo fmt --all -- --check
  • cargo clippy -p moltis-nostr --all-targets --all-features (clean)
  • cargo test -p moltis-nostr82 unit tests, covering the inbound gate (unjoined groups dropped, mention modes, no self-reply loops, replay dedup), dialect selection, NIP-10 marker shape, ack shortcode→glyph mapping, reaction-id retraction, and outbound group-vs-DM routing
  • cargo doc -p moltis-nostr --no-deps (no broken intra-doc links)
  • cd crates/web/ui && npx biome ci --diagnostic-level=error src/ e2e/
  • cd crates/web/ui && npx tsc --noEmit (strict, 0 errors)
  • cd crates/web/ui && npm run build
  • npx playwright test e2e/specs/channels-nostr.spec.js — 6/6 against a freshly built target/debug/moltis
  • ./scripts/check-file-size.sh, check-install-package-names.sh, check-install-docs.sh, ./scripts/i18n-check.sh, cargo fetch --locked

Remaining

  • ./scripts/local-validate.sh 1168must run from a maintainer machine. The red fmt/clippy/test/biome/i18n checks do not run any checks; they poll for the local/* commit statuses that script publishes (Waiting for local/lint=success (current: missing)).
  • Full-workspace --all-features clippy/test — llama-cpp-sys-2 needs a native toolchain unavailable in the dev container
  • Verification against a live Buzz relay — all logic is unit-tested and the wire shapes come from Buzz's SDK, but nothing here has talked to a real relay

Manual QA

Requires a self-hosted Buzz relay.

  1. Generate a bot keypair; have the workspace admin admit its npub to a channel.
  2. Configure:
    [channels.nostr.buzz-bot]
    secret_key = "nsec1..."
    relays = ["wss://<your-buzz-relay>"]
    groups = ["<channel-h-tag>"]
    group_mention_mode = "mention"
    group_message_kind = "buzz_v2"
    group_ack_reactions = true
  3. Start Moltis; confirm the log line subscribing to group chat (kind:9 + kind:40002).
  4. Post without mentioning the bot → silence (default mention mode).
  5. @-mention the bot → 👀 appears on your message, a reply appears and visibly grows as it streams, then 👀 is replaced by ✅.
  6. Confirm the reply renders in the Buzz client and is threaded under your message — this exercises the kind:40002 round trip and the NIP-10 marker.
  7. Trigger a failure (e.g. no model configured) → ❌ instead of ✅.
  8. Set group_ack_reactions = false, restart → no reactions.
  9. Send /help → command output, not a model answer.
  10. Second joined channel keeps its own session context.
  11. DMs still work (gift-wrapped DM → reply).
  12. Remove a channel from groups, restart, post there → dropped with group message rejected: group not joined.

Web UI

Settings → Channels → Connect Nostr exposes Groups, Group Mention Mode, Group Message Kind, and the ack-reactions toggle, plus the advanced-JSON escape hatch.

Notes / follow-ups

  • Typing indicator (kind:20002) is defined in Buzz's kind.rs but has no builder in buzz-sdk, so its tag shape is unspecified — deliberately not guessed.
  • Canvases (40100), forum threads (45001/45003), workflows (4600146012), job requests (43001), Blossom media, and git are separate subsystems, not chat.
  • Changing groups requires an account restart; live re-subscription would be a natural improvement.
  • A relay-trusted auto-join mode was not built: subscribing to unfiltered group traffic on a public relay would be both a firehose and an injection vector.

claude added 2 commits July 25, 2026 23:26
Block's Buzz (https://github.com/block/buzz) is a Nostr relay where AI
agents and humans collaborate in group "channels". Its primary API is
NIP-29 group chat (kind:9 events scoped by an `h` tag) over a NIP-42
authenticated connection. The existing moltis-nostr channel only spoke
NIP-04/NIP-59 encrypted DMs, so it could not join those groups.

Extend the Nostr channel to speak plain NIP-29 — the same wire protocol
any NIP-29 client uses, so it works against Buzz relays and generic
NIP-29 relays alike:

- groups.rs: kind:9 helpers, `h`-tag extraction/building, `p`-tag mention
  detection, group access control, and plaintext group message sending.
- bus.rs: subscribe to kind:9 filtered by the configured groups' `h` tags
  and route inbound group messages (dedup, self/stale filtering, group
  policy + mention-mode gating) with the group id as the session/chat id.
- outbound.rs: route replies to a group (kind:9 + `h` tag + NIP-10 `e`
  reply tag) or a DM (gift wrap) based on whether the target matches a
  configured group.
- plugin.rs: enable NIP-42 automatic authentication so the bot can join
  relays that challenge every connection (Buzz relays do).

New account config fields: `groups`, `group_policy`, and
`group_mention_mode` (default `mention`, so the bot only replies when
@-tagged). Empty `groups` keeps the account in DM-only mode, so existing
configs are unaffected. Group membership is fixed at connect, so changing
`groups` requires an account restart.

Web UI gains dedicated group fields in the Add-Nostr modal (plus the
existing advanced-JSON escape hatch); config template, docs, README, and
tests updated.
Clippy needless_option_as_deref: Tag::content() already returns
Option<&str>, so the .as_deref() calls were no-ops.
@greptile-apps

greptile-apps Bot commented Jul 25, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

Adds NIP-29/Buzz group-chat support to the Nostr channel.

  • Subscribes to configured groups for standard kind 9 and Buzz kind 40002 messages.
  • Adds group access, mention, threading, streaming, command, and acknowledgement-reaction handling.
  • Uses typed group reply targets and current-membership checks so removed groups fail closed rather than becoming DM recipients.
  • Exposes group settings through configuration, documentation, and the web UI.

Confidence Score: 5/5

The PR appears safe to merge.

No blocking failure remains; current outbound routing fails closed when a group is removed and preserves the group-versus-DM distinction for pubkey-shaped group IDs.

Important Files Changed

Filename Overview
crates/nostr/src/outbound.rs Routes explicitly prefixed group targets through a current-membership check, preventing removed groups from receiving replies or being reinterpreted as DM recipients.
crates/nostr/src/groups.rs Introduces NIP-29/Buzz wire-format helpers, access checks, target typing, reactions, and group-message construction.
crates/nostr/src/bus.rs Adds group subscriptions and inbound processing with joined-group enforcement, mention filtering, command dispatch, and reply-context capture.
crates/nostr/src/config.rs Adds group membership, mention mode, message dialect, and acknowledgement-reaction configuration.
crates/nostr/src/reply_ctx.rs Tracks bounded reply and reaction context needed for threaded, dialect-preserving group responses.
crates/web/ui/src/pages/channels/modals/AddNostrModal.tsx Exposes the new Nostr group-chat settings in the account configuration UI.

Sequence Diagram

sequenceDiagram
  participant Relay as Nostr/Buzz relay
  participant Bus as Nostr subscription loop
  participant Gateway
  participant Agent
  participant Outbound as Nostr outbound
  Relay->>Bus: kind 9/40002 event with h tag
  Bus->>Bus: Verify configured group and mention policy
  Bus->>Gateway: Dispatch with group-prefixed reply target
  Gateway->>Agent: Run channel session
  Agent-->>Gateway: Stream/final response
  Gateway->>Outbound: Send to group-prefixed target
  Outbound->>Outbound: Re-check current group membership
  Outbound-->>Relay: Mirrored group-message kind and reply tags
Loading

Reviews (4): Last reviewed commit: "fix(nostr): make group reply targets sel..." | Re-trigger Greptile

Comment thread crates/nostr/src/outbound.rs Outdated
@codspeed-hq

codspeed-hq Bot commented Jul 25, 2026

Copy link
Copy Markdown
Contributor

Merging this PR will not alter performance

✅ 39 untouched benchmarks
⏩ 5 skipped benchmarks1


Comparing claude/buzz-support-moltis-jr1iub (bb7ec7d) with main (1f53cd2)

Open in CodSpeed

Footnotes

  1. 5 benchmarks were skipped, so the baseline results were used instead. If they were deleted from the codebase, click here and archive them to remove them from the performance reports.

claude added 2 commits July 25, 2026 23:48
Self-review of the NIP-29 support found that the group gate trusted the
relay. Two problems, one of them a prompt-injection hole:

1. `group_policy` defaulted to `open`, and `check_group_access` returned
   Ok for *any* group under that policy. A group's `h` tag is an
   unauthenticated label — an event signature proves who wrote the event,
   not which group it belongs to — and `nostr-sdk` leaves
   `verify_subscriptions` off by default, so it never checks that
   delivered events match the filters we subscribed with. A hostile or
   buggy relay could therefore push a kind:9 carrying any `h` tag and have
   its content dispatched straight to the agent.

2. Under `allowlist`, an empty list fell through to `gating::is_allowed`,
   whose documented convention is "empty allowlist allows everyone" —
   exactly backwards here, where the list defines membership rather than
   filtering within it.

Make the configured `groups` list authoritative: it is the only set we
subscribe to, so it is also the only set we accept, and empty now denies
instead of allowing. Matching is exact — an `h` tag is an opaque id, not a
user-facing handle, so no globbing or case folding.

This makes `open` and `allowlist` indistinguishable, so drop the
`group_policy` field rather than keep a knob that suggests protection it
does not provide. `groups` (join list) plus `group_mention_mode` (when to
reply, including `none`) already cover the real cases; the derived
`ChannelConfigView::group_policy()` reports Allowlist/Disabled so the
cross-channel view stays consistent.

Also build the client with `verify_subscriptions(true)` so the SDK drops
off-filter events for DMs as well as groups, and correct the NIP-42
comment: auto-authentication was already the upstream default, so the
previous call was a no-op rather than the fix it claimed to be. It is now
set explicitly alongside the verify option to pin both behaviours.
Automated review flagged that outbound group sends did not honour a
disabled group policy: `resolve` keyed only on membership in the
configured list, so with `group_policy = "disabled"` an inbound message
was blocked while a queued or agent-initiated send could still publish a
kind:9 event to the group.

Removing `group_policy` in favour of a single authoritative `groups` list
closed that gap — disabling group chat now means clearing `groups`, and
outbound reads the same list — but nothing pinned the behaviour. Cover
`resolve` directly: a configured id routes as a group send, a pubkey
routes as a DM, an unknown account is unavailable, and a cleared group
fails closed rather than falling through to the DM path (a group id is
not a valid pubkey).

penso commented Jul 25, 2026

Copy link
Copy Markdown
Collaborator Author

Re the Greptile finding on crates/nostr/src/outbound.rs — that was a real bug at the reviewed commit (7a9300b), and it's fixed in 96c870d/9d124ce.

The finding was correct: resolve keyed only on membership in the configured list, so with group_policy = "disabled" an inbound message was blocked while a queued or agent-initiated send could still publish a kind:9 to the group. "Disabled" didn't disable outbound.

Rather than add a policy check on the send path, I removed group_policy entirely. Self-review turned up a worse problem with the same root cause: the field defaulted to "open", and the inbound gate returned Ok for any group under that policy. Since a kind:9's h tag is an unauthenticated label and nostr-sdk leaves verify_subscriptions off by default, a hostile or buggy relay could push an event with an arbitrary h tag straight into the agent.

So groups is now the single authoritative list — the only set we subscribe to, and the only set we accept or send to, in both directions. Disabling group chat means clearing groups, which the send path reads too; a cleared group fails closed, since a group id isn't a valid pubkey. Once access is bounded by the joined set, open and allowlist become indistinguishable, so keeping the knob would have advertised protection it didn't provide.

9d124ce adds direct coverage of resolve for exactly this scenario, including refuses_group_send_once_groups_cleared.


Generated by Claude Code

@codecov

codecov Bot commented Jul 26, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 93.32777% with 80 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
crates/nostr/src/bus.rs 88.52% 42 Missing ⚠️
crates/nostr/src/outbound.rs 92.28% 23 Missing ⚠️
crates/nostr/src/groups.rs 98.12% 6 Missing ⚠️
crates/nostr/src/config.rs 92.59% 4 Missing ⚠️
crates/nostr/src/reply_ctx.rs 97.48% 4 Missing ⚠️
crates/nostr/src/plugin.rs 50.00% 1 Missing ⚠️

📢 Thoughts on this report? Let us know!

claude added 5 commits July 26, 2026 00:05
Buzz defines two chat kinds and posts channel messages as kind:40002
(KIND_STREAM_MESSAGE_V2), reserving kind:9 for standard NIP-29. Its
architecture notes are explicit that "clients filtering by kind 9 alone
would not receive kind 40002 messages, and vice versa" — so the previous
kind:9-only implementation would have been mutually invisible on a real
Buzz relay: it would neither see channel traffic nor have its replies
rendered. That made the integration Buzz-shaped but not Buzz-compatible.

Read both kinds and answer in the dialect we were addressed in:

- groups.rs: add the kind:40002 constant, `group_message_kinds()` for the
  subscription filter, and `is_group_message_kind()` for the inbound gate.
  `send_group_message` now takes the kind to publish.
- reply_ctx.rs (new): bounded per-event store of the author and kind of
  each accepted group message, plus the last kind seen per group. Outbound
  only receives a chat id and the event id being replied to, so neither is
  otherwise recoverable there.
- outbound.rs: resolve the dialect from the message being answered, else
  the group's observed dialect, else the configured default — and `p`-tag
  the author being replied to, which is what turns a NIP-29 reply into a
  notification for them.
- bus.rs: subscribe to and accept both kinds, record reply context, and
  intercept slash commands so `/help` behaves in a Buzz channel as it does
  in a DM instead of being sent to the model.

New `group_message_kind` config (`nip29` | `buzz_v2`, default `nip29`)
only picks the dialect for bot-initiated messages in a group nothing has
been received from yet; replies and learned groups never consult it, so
Buzz works without configuration.

Also covers the previously untested inbound gate in bus.rs with a
recording ChannelEventSink: unjoined groups are dropped, mention modes are
honoured, self-messages and replays do not loop, and accepted messages
record their reply context.
…ions

Reading Buzz's SDK (crates/buzz-sdk/src/builders.rs) and kind registry
(crates/buzz-core/src/kind.rs) turned up three things the integration was
getting wrong or leaving on the table.

Threading was wrong. Buzz's build_message emits NIP-10 markers —
["e", <id>, "", "reply"] — while we emitted a bare ["e", <id>], which is
not recognised as a threaded reply. Replies now carry the marker.

Replies were not live. Buzz defines KIND_STREAM_MESSAGE_EDIT (kind:40003)
with tags ["h", <channel>] and ["e", <target>], so a published message can
be revised in place. Group replies are now published as soon as the first
tokens arrive and edited as the rest stream in, so a channel sees the
answer forming instead of waiting out the whole turn. Edits are throttled
to ~1/second: a long answer should not become hundreds of signed events in
the relay's audit log. Editing is gated on the Buzz dialect — plain NIP-29
has no edit kind, and gift-wrapped DMs have no edit path, so both keep
collecting and sending once.

Messages were never acknowledged. Buzz uses standard NIP-25 reactions
(kind:7, content is the emoji, tag ["e", <target>]), and the gateway
already drives 👀-on-receipt and ✅/❌-on-completion for any channel that
implements add_reaction and sets ack_message_id. Implement both, mapping
the gateway's Slack-style shortcodes to the glyphs NIP-25 wants. Retracting
the 👀 needs a NIP-09 deletion referencing the reaction event, so reaction
ids are tracked alongside the reply context. Reactions are group-only:
reacting to a gift-wrapped DM would reveal that a conversation happened.

New `group_ack_reactions` config (default true) gates the acknowledgement
behaviour, with a matching web UI toggle.
The Buzz kinds and tag shapes this branch emits were read out of Buzz's
own source — crates/buzz-core/src/kind.rs for the kind numbers and
crates/buzz-sdk/src/builders.rs for the tag arrays. Reading them is not
the same as proving we emit them, and a wrong kind integer or a missing
NIP-10 marker is invisible in unit tests while making the bot silently
malformed in a real channel.

Add nostr-relay-builder as a dev-dependency and round-trip every published
event through an in-process relay, asserting on what comes back off the
wire:

- a Buzz reply is kind:40002 with ["h", channel], ["e", parent, "", "reply"]
  and a ["p", author] mention;
- a plain NIP-29 reply is kind:9 with only the channel tag;
- an edit is kind:40003 with ["h", channel] and a bare ["e", target] —
  Buzz's build_edit uses no marker there, unlike build_message;
- a reaction is NIP-25 kind:7 carrying the emoji glyph and only ["e", target];
- retracting one publishes a NIP-09 kind:5 referencing the reaction event
  rather than the message it was attached to;
- an h-tag subscription returns both dialects and excludes other channels.

The same events are then fed back through `extract_group_id` and
`is_bot_mentioned` so the inbound gate is shown to read what the outbound
path writes.

MockRelay binds to loopback, so these need no network and run by default,
unlike the existing live-relay tests which stay #[ignore]d.
Codecov flagged outbound.rs as the weakest-covered file, and the gap was
almost entirely the streaming edit loop — the newest logic on this branch
and the one with the most branching (publish-on-first-chunk, throttled
edits, final edit when the last chunks were throttled, and the
collect-and-send fallback for dialects that cannot edit).

Drive it through the mock relay and assert what the channel would actually
see:

- a Buzz stream publishes one kind:40002 carrying the first chunk, then a
  kind:40003 edit targeting it with the complete text. Chunks arriving
  faster than the throttle collapse into that single final edit, which is
  the case that would otherwise strand the reply truncated;
- a plain NIP-29 stream posts exactly one kind:9 with the whole answer and
  emits no edit kinds at all, since publishing a partial message on a relay
  that cannot edit would leave it permanently incomplete;
- a stream that errors before producing any text still says something,
  rather than leaving the channel silent.
The PR claims `/help` and friends behave in a Buzz channel the way they do
in a DM, but nothing verified it — a regression would have quietly started
feeding commands to the model instead.

Assert both directions: a known channel command never reaches the agent,
and a slash that is not a command ("/notacommand hello") still does, so the
interception cannot silently swallow ordinary text that happens to start
with a slash.

penso commented Jul 26, 2026

Copy link
Copy Markdown
Collaborator Author

@greptileai review

The branch has moved on substantially since the last review (7a9300b1ecd260): the group_policy knob was removed in favour of a single authoritative groups list, Buzz's kind:40002 dialect and kind:40003 streaming edits were added, along with NIP-25 ack reactions and NIP-10 reply markers. A fresh pass on the current head would be useful.


Generated by Claude Code

claude added 5 commits July 26, 2026 01:23
The streamed-reply path incremented MESSAGES_SENT_TOTAL after the match on
whether anything had been published, which was wrong in two of the three
arms: the fallback arm calls send_text, which already records the metric,
so a non-Buzz streamed reply counted twice; and the empty-stream arm
counted a message that was never sent at all.

Move the increment into the arm that actually needs it. A streamed Buzz
reply publishes through send_group_message and then edits, neither of which
records metrics, and all of it is one logical message — so it is counted
exactly once there, and not at all when nothing was published.
Three statements in the Nostr page were written when the integration spoke
only kind:9 and were never revisited when Buzz's kind:40002 dialect landed,
so the page now contradicted the code:

- the Buzz overview described channel messages as "kind:9 chat events",
  when kind:40002 is what a Buzz relay actually carries;
- the reply description claimed replies are posted as kind:9, when they
  mirror the kind of the message they answer and also carry a `p` tag
  notifying its author;
- the security warning said only inbound kind:9 is re-checked against
  `groups`, understating the gate, which covers both kinds.

A reader following the old text would have concluded the bot cannot see or
be seen in a Buzz channel, which is the opposite of what it does.
Three defects found in an adversarial review of the group chat path.

Relay rejection was reported as success. `RelayPool::send_event_to` returns
`Ok` even when every relay answered `OK false` — the rejections are filed
into `output.failed` and the call still succeeds. Nothing inspected that.
This matters far more for group chat than for DMs: a NIP-29 relay refusing
a post from a pubkey it has not admitted to the group is the single most
likely misconfiguration of this feature, and the bot would have logged
"sent group message", counted it, and let the gateway stamp a ✅ on the
user's message while the channel saw nothing. Streaming made it worse —
`published` was set to an id the relay never stored, so every following
kind:40003 edit targeted a phantom event and the whole turn vanished with
no warning. All four publish sites now fail when no relay accepted, keeping
the relay's reason so the cause is diagnosable.

A failed throttled edit truncated the reply permanently. `pending_edit`
means "the buffer has moved on since what the relay holds", but it was
cleared on an *attempted* edit rather than a successful one, so a single
transient edit failure skipped the final edit and left the channel showing
a partial answer forever — the exact invariant the surrounding comment
claims to guarantee. It now stays set when the edit fails, and the failure
logs at warn rather than debug.

Remembered reaction ids grew without bound. Eviction only ever pruned
`entries`. The gateway retracts the 👀 it adds on receipt, but the terminal
✅/❌ is never retracted, so the map gained an entry per completed group
turn for the life of the process, at a rate set by remote traffic. It is
now bounded like `entries`; losing an old id only costs a best-effort
retraction nothing waits on.
NIP-29 relays route and authorize group events by the `h` tag, so an
unscoped write is refused. Buzz's own build_reaction omits `h` and gets away
with it because Buzz does not enforce it, but copying that leaves
acknowledgement reactions — and the NIP-09 deletions that retract them —
failing on every other NIP-29 relay.

Now that a relay rejection surfaces as an error rather than being swallowed,
that would have turned into a visible error on every acknowledged group
message outside Buzz. Add the tag: it is what the spec asks for, and an
extra tag is inert on Buzz.
Group chat deliberately has no per-sender allowlist: NIP-29 makes the relay
the authority on who is in a channel. That is the right model for
conversation, but two channel commands are not conversation. `/sh` turns
subsequent messages in the session into shell commands, and `/sandbox` can
move execution off the sandbox onto the host — so anyone the relay admitted
to a joined channel could obtain host execution.

The gateway gates `/approve`, `/deny` and `/update` centrally via
`is_sender_on_allowlist`, but `/sh` and `/sandbox` are dispatched without a
sender at all. That is longstanding behaviour shared with the other group
channels, so rather than change the shared dispatcher underneath Slack and
Discord, gate them where the new exposure is: the Nostr group path.

Operators are the account's existing `allowed_pubkeys` — the keys the owner
already nominated as trusted for DMs — so no new configuration appears. An
empty list means nobody qualifies, so these commands fail closed. A refused
command is answered in the channel rather than dropped, per the project's
no-silent-failures rule.

Fixing this properly for every channel belongs in the gateway dispatcher and
should be its own change.

penso commented Jul 26, 2026

Copy link
Copy Markdown
Collaborator Author

@greptileai review

Head is now 26560e8 — 14 commits past the last reviewed commit (7a9300b). The blocker from that review (outbound sends ignoring a disabled group policy) is fixed and its thread resolved; group_policy was removed in favour of a single authoritative groups list that both the inbound gate and the send path read.

Since then a self-review and an adversarial pass fixed five further defects worth re-checking:

  • relay rejections were reported as success (RelayPool::send_event_to returns Ok even when every relay answers OK false), which silently swallowed "not a member of the group" and broke streamed replies entirely;
  • a failed throttled edit cleared pending_edit, permanently truncating a streamed reply;
  • remembered reaction ids grew without bound;
  • reactions/deletions lacked the NIP-29 h scoping tag;
  • /sh and /sandbox were reachable by any relay-admitted group member, and are now restricted to allowed_pubkeys.

Also added: Buzz's kind:40002 dialect and kind:40003 streaming edits, NIP-25 ack reactions, NIP-10 reply markers, and 12 wire-format round-trip tests against an in-process relay.


Generated by Claude Code

Comment thread crates/nostr/src/outbound.rs
…ecome DMs

Outbound classified a reply target by asking "is this id currently in
`groups`?" and treated anything else parseable as a public key. A NIP-29
group id is an opaque string, and nothing stops it being 64 hex characters —
which is exactly what a Nostr public key looks like. Buzz uses UUID channel
ids, but a generic NIP-29 relay need not.

The reply target is persisted with the session (`_channel_reply_target`), so
it outlives the config that produced it. Remove such a group while a turn is
queued or resumable and the next send falls through to the DM path, parses
the former group id as a pubkey, and gift-wraps the channel's reply to
whoever owns that key — disclosing channel content to an unintended
recipient. The earlier claim that this "fails closed because a group id is
not a valid pubkey" was only true for UUID-shaped ids.

Make the target carry its own kind: group reply targets are now prefixed
(`group:<id>`), so classification never consults mutable config. Membership
is still re-checked afterwards, so a removed group fails closed instead of
being delivered anywhere. A DM target is a bare pubkey and can never collide
with the prefix.

Tests cover the exact scenario: a group whose id is a real public key is
refused once removed, and still routes as a group while configured.

penso commented Jul 26, 2026

Copy link
Copy Markdown
Collaborator Author

@greptileai review

Head d0864c7. The "removed groups become DM recipients" finding was correct and is fixed: group reply targets are now self-describing (group:<id>), so outbound classification never consults mutable config, and a target that was a group can never be reinterpreted as a pubkey. Membership is still re-checked after parsing, so a removed group fails closed rather than being delivered anywhere.

Two tests pin the reported scenario specifically — a group whose id is a real generated public key is refused once removed, and still routes as a group while configured.


Generated by Claude Code

The h-tag fix added the NIP-29 scoping tag to reactions and deletions, but
only the deletion test gained the matching assertion — the reaction one was
edited against text the formatter had already reflowed, so the replacement
silently did nothing and the test kept asserting the pre-fix shape. Its doc
comment still claimed reactions carry "only an `e` tag".

Assert the tag and correct the comment, so a regression that dropped group
scoping from reactions would fail rather than pass quietly.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants