## 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 failure denies.

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 now 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 channels it may read, send in, or manage. The default is the conversation only, with the creator's actual read and send permissions there; creation is refused if the creator can do neither. `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 own read and send permissions; unsubscribing removes it. A schedule turn cannot change the scope.

The session object marks a turn as scheduled when the `schedule` table shows a fire within the last 60 seconds. The Discord tools then use the stored scope instead of the speaker, and a participant below that scope is not forwarded into the session. This scope only covers Discord channels; MCP, workspace, web, and subagents stay under the conversation policy.

## 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 33 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 mapping, participant superset, expansion, and owner checks:

  ```
  ℹ tests 33
  ℹ pass 33
  ℹ 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, `last_fired_at` attribution, 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.
- **Schedule-turn detection is a heuristic:** it uses `last_fired_at` from the `schedule` table and a 60 second window. A human message that arrives immediately after a schedule fire, in a conversation with no active turn, could be misclassified as a schedule turn. Human prompts are protected by an active turn, and a start with no active turn that is not attributable to a recent fire is treated as a no-speaker turn, but this is not a transaction.
- **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 read and send permissions. A scope can be narrower than the bot's access and is never wider than the person who set it up. Existing stored scopes are not retroactively recomputed when roles change.
- **Code Mode fallback:** if a `schedule_create` runs through Code Mode and the tool hooks do not fire, no scope is recorded. The schedule then runs as a no-speaker turn: its own read/send works, but cross-channel access is refused rather than granted.
- **Scope edits:** only the scope owner or someone with Manage Server may add or remove channels; a schedule turn cannot edit its own scope. 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.
- **Private threads:** membership comes from `GET /channels/{thread}/thread-members/{user}` and every failure is treated as "not a member", so a transient REST failure can deny a legitimate member until the next attempt.
- **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, `last_fired_at` attribution, and participant gating were not run against Discord.
- **Schedule tool coverage:** the scope stores read, send, and manage. `discord_create_thread` and `discord_react` have no scope flag yet and are denied on schedule turns even when the channel is in scope. `manage` is stored but forced false, so schedule-driven archive, deleting or pinning other people's messages is denied; editing/deleting Eryn's own message still works with view.
- **No new dependencies.**

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

