feat: bound untrusted summons and serve multiple Discord contexts #82

Merged
coilysiren merged 3 commits from sirens-deep-admission-hardening into main 2026-08-10 23:12:16 +00:00
Member

Sirens Deep is being deployed into a Discord guild whose members the operator does not moderate. Three things blocked that, and a fourth set of defects surfaced while reading.

The deploy blocker

agent/sirens-deep.yaml has channel: "", and LoadConfig required Channel == "#bots" whenever Discord was enabled. Sirens Deep could not start on Discord at all. The channel field is now the prompt's boundary label, and routing moves to DISCORD_CHANNEL_ID where deployment already owns it.

Admission control

Every accepted turn costs an Agent Proxy completion and up to six MCP rounds, and nothing bounded it. The runtime queued every summon behind one execution slot with no bound, so a flood grew process memory and answered conversations that had moved on.

Adds per-user, per-context, and global token buckets plus a bounded pending queue at the shared turn boundary, so Discord and HTTP are governed by one policy.

  • Defaults - 3/30s per user, 10/10s per guild, 20/5s global, 8 pending - sized for a guild the operator does not moderate, and each tier is env-tunable or off.
  • Every tier is checked before any is charged, so a global refusal does not quietly spend a member's own budget.
  • Denials notify at most once per window. A reply per denial would burn the bot's Discord message budget and hand a flooder an amplifier.
  • Bucket state is a capacity-bounded LRU, because rotating identities is the cheapest way to attack a limiter that keeps unbounded per-key state.

Multiple Discord contexts

Scope was a single channel ID. It is now a channel list plus an optional guild allowlist, with opt-in direct messages, served by one process and one token. Each guild is one admission context, so one guild cannot spend another's budget. Every ID is validated as a snowflake at startup, so a channel name in place of an ID fails loudly.

HTTP authentication

POST /v1/turn was unauthenticated and the deployment binds it to 0.0.0.0. A shared secret is now required whenever the listener is not loopback, derived from the bind address rather than left to the operator to remember. Health routes stay open.

Defects found while reading

  • The request timeout started before queue admission, so a queued turn reached the model with a partly spent budget. QUEUE_TIMEOUT now covers the wait and REQUEST_TIMEOUT starts once the slot is held.
  • The typing indicator fired before admission and expired mid-turn. It now starts with the turn and refreshes.
  • Handler goroutines had no panic recovery, so one bad turn killed the process serving every guild.
  • Scope resolution issued a Discord REST call for every unseen channel with no negative caching. Gates now run cheapest first, decisions cache both ways, and remaining lookups are separately rate limited.
  • The prompt boundary claimed tailnet HTTP for a definition that names no channel, which would have been wrong for a Discord deployment of Sirens Deep.

Verification

pre-commit run --all-files exit 0 across 23 hooks, go vet, go test -race ./..., and sirens-echo-policy-check all pass. New unit coverage for the limiter (burst, refill, per-guild isolation, no-charge-on-later-denial, queue shedding and release, notify throttling, LRU bound), scope resolution across guilds and DMs, and the HTTP auth and admission edges.

ward exec eval-echo against the real Agent Proxy has not been run and is the remaining gate.

Note for review

X-Ward-Target-Repo: coilyco-gaming/sirens-echo stays hard-coded in proxy.go, so turns from a foreign guild are attributed to this repo in the Agent Proxy audit trail. Left as-is deliberately, flagged as a decision rather than a default.

New docs: sirens-echo-admission.md, sirens-echo-contexts.md, sirens-echo-http.md. The repo's 80-line doc cap pushed the HTTP entrypoint out of sirens-echo.md into its own page.

Sirens Deep is being deployed into a Discord guild whose members the operator does not moderate. Three things blocked that, and a fourth set of defects surfaced while reading. ## The deploy blocker `agent/sirens-deep.yaml` has `channel: ""`, and `LoadConfig` required `Channel == "#bots"` whenever Discord was enabled. Sirens Deep could not start on Discord at all. The channel field is now the prompt's boundary label, and routing moves to `DISCORD_CHANNEL_ID` where deployment already owns it. ## Admission control Every accepted turn costs an Agent Proxy completion and up to six MCP rounds, and nothing bounded it. The runtime queued every summon behind one execution slot with no bound, so a flood grew process memory and answered conversations that had moved on. Adds per-user, per-context, and global token buckets plus a bounded pending queue at the shared turn boundary, so Discord and HTTP are governed by one policy. * Defaults - `3/30s` per user, `10/10s` per guild, `20/5s` global, 8 pending - sized for a guild the operator does not moderate, and each tier is env-tunable or `off`. * Every tier is checked before any is charged, so a global refusal does not quietly spend a member's own budget. * Denials notify at most once per window. A reply per denial would burn the bot's Discord message budget and hand a flooder an amplifier. * Bucket state is a capacity-bounded LRU, because rotating identities is the cheapest way to attack a limiter that keeps unbounded per-key state. ## Multiple Discord contexts Scope was a single channel ID. It is now a channel list plus an optional guild allowlist, with opt-in direct messages, served by one process and one token. Each guild is one admission context, so one guild cannot spend another's budget. Every ID is validated as a snowflake at startup, so a channel name in place of an ID fails loudly. ## HTTP authentication `POST /v1/turn` was unauthenticated and the deployment binds it to `0.0.0.0`. A shared secret is now required whenever the listener is not loopback, derived from the bind address rather than left to the operator to remember. Health routes stay open. ## Defects found while reading * The request timeout started before queue admission, so a queued turn reached the model with a partly spent budget. `QUEUE_TIMEOUT` now covers the wait and `REQUEST_TIMEOUT` starts once the slot is held. * The typing indicator fired before admission and expired mid-turn. It now starts with the turn and refreshes. * Handler goroutines had no panic recovery, so one bad turn killed the process serving every guild. * Scope resolution issued a Discord REST call for every unseen channel with no negative caching. Gates now run cheapest first, decisions cache both ways, and remaining lookups are separately rate limited. * The prompt boundary claimed tailnet HTTP for a definition that names no channel, which would have been wrong for a Discord deployment of Sirens Deep. ## Verification `pre-commit run --all-files` exit 0 across 23 hooks, `go vet`, `go test -race ./...`, and `sirens-echo-policy-check` all pass. New unit coverage for the limiter (burst, refill, per-guild isolation, no-charge-on-later-denial, queue shedding and release, notify throttling, LRU bound), scope resolution across guilds and DMs, and the HTTP auth and admission edges. `ward exec eval-echo` against the real Agent Proxy has not been run and is the remaining gate. ## Note for review `X-Ward-Target-Repo: coilyco-gaming/sirens-echo` stays hard-coded in `proxy.go`, so turns from a foreign guild are attributed to this repo in the Agent Proxy audit trail. Left as-is deliberately, flagged as a decision rather than a default. New docs: `sirens-echo-admission.md`, `sirens-echo-contexts.md`, `sirens-echo-http.md`. The repo's 80-line doc cap pushed the HTTP entrypoint out of `sirens-echo.md` into its own page.
feat: bound untrusted summons and serve multiple Discord contexts
All checks were successful
ci / test (pull_request) Successful in 50s
ci / publish-echo-image (pull_request) Has been skipped
c3221f4c5e
Sirens Deep is being deployed into a Discord guild whose members the
operator does not moderate. Three things blocked that.

Admission control. Every accepted turn costs an Agent Proxy completion
and up to six MCP rounds, and nothing bounded it. Any member could
summon in a loop, and the runtime queued every summon behind one
execution slot with no bound, growing memory and answering conversations
that had moved on. Adds per-user, per-context, and global token buckets
plus a bounded pending queue at the shared turn boundary, so Discord and
HTTP are governed by one policy. Denials notify at most once per window.

Multiple contexts. Scope was a single channel ID, and LoadConfig
required the definition channel to be exactly #bots, which made
sirens-deep.yaml (channel: "") impossible to run on Discord at all.
Scope is now a channel list plus an optional guild allowlist, with
opt-in direct messages. The channel field is the prompt's boundary
label, not the routing key.

HTTP auth. POST /v1/turn was unauthenticated, and deployment binds it to
0.0.0.0. A shared secret is now required whenever the listener is not
loopback, derived from the bind address rather than left to the operator.

Also fixes defects found while reading: the request timeout started
before queue admission so queued turns burned their budget waiting, the
typing indicator fired before admission and expired mid-turn, handler
goroutines had no panic recovery, and scope resolution issued a Discord
REST call for every unseen channel with no negative caching.

The transport-neutral prompt boundary no longer claims HTTP-only for a
definition that names no channel.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: Kai Siren <coilysiren@gmail.com>
Co-authored-by: Claude <noreply@anthropic.com>
feat: add a git-tracked access policy with roles and a deny list
All checks were successful
ci / test (pull_request) Successful in 34s
ci / publish-echo-image (pull_request) Has been skipped
25e4931486
Scope was two environment variables holding flat ID lists, which cannot
express "these members, in this channel, in that guild" without a
combinatorial string format that is unreviewable in a diff.

Adds a ConfigMap-backed YAML policy named by SIRENS_ECHO_ACCESS_POLICY.
One guild entry carries its channels, users, roles, and an optional
rate_limit override, with `all` as the widening token. A top-level deny
list is evaluated before every allow rule, so one abusive member can be
stopped without withdrawing the guild they are in.

Roles matter most in a guild whose membership the operator does not
control. A user allowlist makes every new person a config change plus a
rollout, while a role grant covers members nobody enumerated. It costs
nothing: Message.Member.Roles already arrives on the Gateway payload.

The policy fails closed. A missing file, an unknown field, a misspelled
widening token, a non-snowflake ID, a duplicate guild, and a guild that
grants no channel or no member are all startup failures. policy-check
validates the tracked reference copy in CI and validates a candidate
ConfigMap when SIRENS_ECHO_ACCESS_POLICY is set, so an operator learns
about a bad allowlist before the rollout rather than during it.

Deployments without the file get the same policy synthesized from
DISCORD_CHANNEL_ID, DISCORD_GUILD_IDS, and the direct message switch, so
there is one runtime representation and the gate stays single-pathed.

Per the repository's config-placement rule, this repo owns the schema,
the parser, and a clearly-marked reference copy. coilyco-bridge/deploy
owns the values, and nothing is fetched downward.

Denials record sirens_echo.access.checks with a closed-set reason, so an
operator can see why traffic was refused without any identifier reaching
a metric label.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: Kai Siren <coilysiren@gmail.com>
Co-authored-by: Claude <noreply@anthropic.com>
Merge remote-tracking branch 'origin/main' into sirens-deep-admission-hardening
All checks were successful
ci / test (pull_request) Successful in 34s
ci / publish-echo-image (pull_request) Has been skipped
319113891a
Co-authored-by: Kai Siren <coilysiren@gmail.com>
Co-authored-by: Claude <noreply@anthropic.com>

# Conflicts:
#	README.md
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
coilyco-gaming/sirens-echo!82
No description provided.