## Summary

Eryn's Discord tools ran with the bot token, so any member could reach channels the bot could see. They now act within the prompting user's Discord permissions, and schedules run under a stored capability scope instead of borrowing the last speaker.

## How it works

The prompt author's id is stored as `authorId` on the session's Discord context. It is not rendered into the model's prompt; the tools read it from metadata when they run. An automated prompt or a turn adopted from a schedule has no speaker, so it is cleared and cross-channel actions fail closed. The conversation's own read and send are still allowed so a reply can land.

`src/discord/permissions.ts` implements Discord's algorithm as a pure function: guild owner gets everything, otherwise `@everyone` plus the member's roles, then the Administrator short-circuit, then channel overwrites in the order `@everyone`, role position ascending, member. A deny wins when one overwrite names the same bit in both `allow` and `deny`. Threads apply their parent's overwrites first, then their own, so a thread-specific entry cannot discard a parent deny. Private threads also need membership or Manage Threads/Channels. Missing roles or malformed bitmasks resolve to zero.

`src/discord/authority.ts` fetches and caches what the resolver needs, one instance per guild host. Guild roles and owner live for 5 minutes; member roles, channel overwrites, and thread membership for 30 seconds; failed lookups are retried after 5 seconds. Every failed lookup denies. A failed thread-membership check is retried once and is not cached, so a transient Discord error cannot lock a real thread member out after recovery.

Every tool now has a speaker check: list/read require ViewChannel (and Read Message History where relevant), send requires Send Messages or Send Messages in Threads, create-thread requires Create Public Threads, join/leave requires ViewChannel, react requires Add Reactions, edit/delete/pin require Manage Messages or visibility for Eryn's own message, archive requires Manage Threads, and join/leave-channel requires Manage Server. Cross-channel calls additionally assert a synthetic `discord.cross-channel` action through the existing policy path; Full allows it and Chat, Read-only, and Locked block it. A policy approval still cannot widen the speaker's own Discord permissions.

Two related hardening changes are included: interaction responses and edits now set `allowed_mentions: { parse: [] }`, and `discord_subscribe_message` / `discord_create_thread` refuse a bot- or webhook-authored message so a bot message cannot grow a thread and loop the agent.

A schedule is a capability scope, not a person. The first `schedule_create` in a conversation stores a scope beside the Discord context: its owner, the schedule ids, and the creator's actual read, send (including sending in threads), moderation, thread-creation, and reaction capabilities in each channel. The default is the conversation only; creation is refused if the creator can neither read nor send there. `discord_subscribe_message` and `discord_join_channel` can add a channel on a human turn when the speaker is the scope owner or has Manage Server, storing that editor's actual capabilities there; unsubscribing removes it. A schedule turn cannot change the scope, except that a thread it creates inside an in-scope channel inherits that channel's thread-send, read, moderation, and reaction capabilities, never more. A scope channel Discord reports as deleted is dropped instead of blocking everyone forever.

The session object marks a turn as scheduled by matching the turn's latest user message with the wake message the schedule runner writes. That message carries the schedule id in its metadata, and the `schedule` row stores the same message id, so the match is exact and has no time window. While the wake is still being projected, the session inbox carries the same metadata and is checked too. A human prompt clears the mark.

The two kinds of turn cannot borrow each other's authority. A human prompt first interrupts and waits out a schedule execution that is already running, so the human message cannot join it and let the schedule's remaining tools read the human's id. In the other direction, a schedule wake that arrives during an active human turn switches that turn to the schedule scope; the human's earlier steps stand, but the rest of the turn is restricted and they can retry a cross-channel request afterwards. A participant is forwarded only when their own permissions cover every capability in the scope.

The scope is recorded through the plugin's `execute.before` / `execute.after` hooks. Code Mode calls tools through the same hook path, so a `schedule_create` made from a Code Mode program is recorded like any other. As a safety net, a fired schedule whose id is missing from an existing scope is added on its next turn; a conversation whose first schedule was never recorded still fails closed as a no-speaker turn.

## Evidence

- **Before:** the tools called Discord after only checking that the channel belonged to the guild; there was no per-speaker check.
  **After:** `pnpm test` with 34 tests covering owner, Administrator, overwrite order, role position, same-bit deny, parent-thread layering, private-thread membership, missing roles, the bot-author guard, and the schedule scope's defaults, capability mappings (read, send, sending in threads, moderation, thread creation, reactions), participant superset, expansion, owner checks, and decoding of scopes stored before the newer capabilities existed:

  ```
  ℹ tests 34
  ℹ pass 34
  ℹ fail 0
  ```

- **Before:** no test script existed.
  **After:** `pnpm check` (typecheck plus production build):

  ```
  > eryn@ typecheck /Users/oscar/.worktree/discord-user-permissions
  > cf workers types && tsc && tsc -p tsconfig.landing.json

  > eryn@ build /Users/oscar/.worktree/discord-user-permissions
  > cf build

  ◆  Build complete
  ```

No live Discord guild was used. The tests exercise the pure resolver, the bot-author guard, and the schedule-scope helpers; the REST calls, schedule hooks, message-id and inbox attribution, turn interruption, participant gating, and policy approval path were not run against Discord.

## Risks and security concerns

- **Public visibility:** a schedule in a public thread or channel is public. The scope controls who can steer the session, not who can read it. Private content needs a private thread or channel; automatic private-thread creation is not implemented.
- **Shared channel subscriptions:** a subscribed channel is a shared room. A schedule's output there is visible to everyone in the channel, including members below the scope who cannot send into the session.
- **A human turn can be restricted mid-way:** if a schedule fires while a person is mid-turn, the rest of that turn runs under the schedule scope. The person can retry a cross-channel request afterwards. This is deliberate: the alternative is letting the schedule act with the person's permissions.
- **Scope capabilities are derived from the person:** the creator must be able to read or send in the conversation, and expansion stores the editor's actual capabilities. A scope can be narrower than the bot's access and is never wider than the person who set it up. Moderation is a single boolean gated on both Manage Messages and Manage Threads, so it never grants a bit the person lacked. A thread created by a schedule uses the creator's Send Messages in Threads reach captured when the channel was added. Stored scopes are a snapshot and are not recomputed when roles change.
- **Scope edits:** only the scope owner or someone with Manage Server may add or remove channels; a schedule turn cannot edit its own scope except for threads it creates. A non-owner can still use the normal subscribe tools, but the scope does not change.
- **Ownership:** if the owner leaves or loses permissions, the stored scope stays. Ownership is not transferred automatically; a manager must delete the old schedules and create replacements, or edit the existing scope.
- **Non-Discord capabilities:** MCP, workspace, shell, web, and subagents are governed by the conversation policy, not by the schedule scope.
- **Permission caches:** member roles, channel overwrites, and thread membership can be up to 30 seconds stale; guild roles and owner up to 5 minutes. Failed lookups are retried after 5 seconds and deny in the meantime; a failed thread-membership lookup is retried once and not cached.
- **Policy dependency:** the cross-channel check uses the existing plugin `permission.assert` path with the `discord.cross-channel` action. The guard lives outside the tool's try/catch so a block or ask ends the step, but this path has not been exercised against a live guild.
- **No live Discord test:** no live guild was used. The pure resolver, bot-author guard, and schedule-scope helpers are tested; the REST calls, schedule hooks, attribution, turn interruption, and participant gating were not run against Discord.
- **No new dependencies.**

<sub>Generated on _MrBeast_ using _opencode-go/deepseek-v4.1-flash_ in _ses_e3fb5574effemS99mhsK0JQwRz_</sub>
