diff --git a/.github/ARCHITECTURE.md b/.github/ARCHITECTURE.md index de328aa..7ebf86e 100644 --- a/.github/ARCHITECTURE.md +++ b/.github/ARCHITECTURE.md @@ -18,7 +18,7 @@ The result is a draft pull request. Merge and ready-for-review actions are inten - `agent/channels/github.ts` handles authorized mentions, `factory`-label intake, CI-failure follow-up, and PR summaries. - Failure comments are policy-controlled. `lib/failure-policy.ts` decides what a channel may say when a turn or session fails: a **deployment fault** (an unusable credential, an unpaid gateway account, or a model the account cannot reach) posts nothing, because only an operator can clear it, and every other failure posts one generic sentence that never carries the upstream provider's text. eve's built-in handler echoes that text verbatim, which is how a revoked key repeated `Model provider API error: Authentication Fails, Your api key: ****53a6...` into the originating thread on every dispatch. -- `agent/channels/discord-mentions.ts` owns ordinary `@Computer` mentions in the internal Discord channel; `bridge/discord-gateway/` holds the Gateway connection that carries them, and `lib/discord-mention-policy.ts` decides admission. +- `agent/channels/discord-mentions.ts` owns ordinary `@Computer` mentions in the internal Discord channel; `bridge/discord-gateway/` holds the Gateway connection that carries them, and `lib/discord-mention-policy.ts` decides admission. Admission is default-deny and fail-closed: an allowlisted guild, plus either an allowlisted channel or — with `DISCORD_INTERNAL_GUILD_WIDE` — an allowlisted operator role, plus an explicit bot mention with a non-empty prompt. - `agent/extensions/github.ts` mounts the official GitHub tools with an explicit allowlist and the WazooComputer GitHub App installation token. - `agent/subagents/` contains isolated station prompts, sandboxes, and handoff tools. - `agent/skills/` contains load-on-demand triage, writing, and tracker-bridging procedures. diff --git a/README.md b/README.md index c918fda..3cde482 100644 --- a/README.md +++ b/README.md @@ -106,7 +106,7 @@ Operator setup: 1. Create the Discord application and bot, and enable the privileged **Message Content** intent in its Bot settings. It is required for `content` to arrive at all; a socket that requests it without the portal toggle is closed with code 4014. 2. Give the bot a channel permission set that can read and reply: View Channels, Send Messages, Send Messages in Threads, Read Message History, and Embed Links. No administrator permission is needed, and the `applications.commands` scope is no longer required. 3. Leave the application's **Interactions Endpoint URL unset**. Discord is exclusive here: once a URL is configured, interactions stop arriving on the Gateway, so the approval buttons and modal answers a mention session waits on would go nowhere. -4. Set the deployment environment (comma-separated ids): `DISCORD_INTERNAL_GUILD_IDS`, `DISCORD_INTERNAL_CHANNEL_IDS`, `DISCORD_INTERNAL_USER_IDS`, `DISCORD_INTERNAL_ROLE_IDS`, plus `DISCORD_BRIDGE_SECRET` and `DISCORD_BOT_TOKEN` (replies are posted with the bot token). No `DISCORD_PUBLIC_KEY` is needed: nothing verifies an inbound interaction signature any more. +4. Set the deployment environment (comma-separated ids): `DISCORD_INTERNAL_GUILD_IDS`, `DISCORD_INTERNAL_CHANNEL_IDS`, `DISCORD_INTERNAL_USER_IDS`, `DISCORD_INTERNAL_ROLE_IDS`, plus `DISCORD_BRIDGE_SECRET` and `DISCORD_BOT_TOKEN` (replies are posted with the bot token). Set `DISCORD_INTERNAL_GUILD_WIDE=1` to let an allowlisted operator mention the bot in any channel of an allowlisted guild, not only in the allowlisted channels; the guild allowlist and the user/role allowlist still both apply, so a shared or public server stays closed. No `DISCORD_PUBLIC_KEY` is needed: nothing verifies an inbound interaction signature any more. 5. Run the bridge on an always-on host, with the same allowlists, the same secret, and the deployment origin: ```bash @@ -117,6 +117,7 @@ DISCORD_INTERNAL_GUILD_IDS=... \ DISCORD_INTERNAL_CHANNEL_IDS=... \ DISCORD_INTERNAL_USER_IDS=... \ DISCORD_INTERNAL_ROLE_IDS=... \ +DISCORD_INTERNAL_GUILD_WIDE=1 \ pnpm discord:bridge ``` diff --git a/lib/discord-guild-wide.test.ts b/lib/discord-guild-wide.test.ts new file mode 100644 index 0000000..b394fe6 --- /dev/null +++ b/lib/discord-guild-wide.test.ts @@ -0,0 +1,93 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { discordPolicyConfigFromEnv, resolveDiscordAccess } from "./discord-policy.ts"; + +const GUILD = "guild-1"; +const OTHER_GUILD = "guild-2"; +const CHANNEL = "channel-1"; +const ADMIN_ROLE = "role-admin"; +const MEMBER = "user-member"; + +function config(overrides: Partial> = {}) { + return { + publicGuildIds: [], + publicChannelIds: [], + internalGuildIds: [GUILD], + internalChannelIds: [CHANNEL], + internalUserIds: [], + internalRoleIds: [ADMIN_ROLE], + internalGuildWide: false, + ...overrides, + }; +} + +test("an allowlisted channel with an admin role resolves to the internal tier", () => { + const access = resolveDiscordAccess( + { guildId: GUILD, channelId: CHANNEL, userId: MEMBER, memberRoleIds: [ADMIN_ROLE] }, + config(), + ); + assert.equal(access?.tier, "internal"); +}); + +test("a channel outside the allowlist is denied while guild-wide is off", () => { + const access = resolveDiscordAccess( + { guildId: GUILD, channelId: "channel-2", userId: MEMBER, memberRoleIds: [ADMIN_ROLE] }, + config(), + ); + assert.equal(access, null); +}); + +test("guild-wide admits an admin mention in any channel of the allowlisted guild", () => { + const access = resolveDiscordAccess( + { guildId: GUILD, channelId: "channel-2", userId: MEMBER, memberRoleIds: [ADMIN_ROLE] }, + config({ internalGuildWide: true }), + ); + assert.equal(access?.tier, "internal"); + assert.equal(access?.principalId, `discord-team:${MEMBER}`); +}); + +test("guild-wide still requires the guild allowlist", () => { + const access = resolveDiscordAccess( + { guildId: OTHER_GUILD, channelId: CHANNEL, userId: MEMBER, memberRoleIds: [ADMIN_ROLE] }, + config({ internalGuildWide: true }), + ); + assert.equal(access, null); +}); + +test("guild-wide still requires an allowlisted user or role", () => { + const access = resolveDiscordAccess( + { guildId: GUILD, channelId: "channel-2", userId: MEMBER, memberRoleIds: ["role-other"] }, + config({ internalGuildWide: true }), + ); + assert.equal(access, null); +}); + +test("guild-wide never admits a DM, where no guild id is present", () => { + const access = resolveDiscordAccess( + { guildId: undefined, channelId: "dm-channel", userId: MEMBER, memberRoleIds: [] }, + config({ internalGuildWide: true }), + ); + assert.equal(access, null); +}); + +test("guild-wide does not open the public tier", () => { + const access = resolveDiscordAccess( + { guildId: GUILD, channelId: CHANNEL, userId: MEMBER, memberRoleIds: [ADMIN_ROLE] }, + config({ internalGuildWide: true, publicGuildIds: [GUILD] }), + ); + assert.equal(access?.tier, "internal"); +}); + +test("reads the guild-wide switch from the environment, defaulting to off", () => { + const flag = (raw?: string): boolean => + discordPolicyConfigFromEnv({ + NODE_ENV: "test", + ...(raw === undefined ? {} : { DISCORD_INTERNAL_GUILD_WIDE: raw }), + }).internalGuildWide ?? false; + assert.equal(flag(), false); + assert.equal(flag(""), false); + assert.equal(flag("0"), false); + assert.equal(flag("false"), false); + assert.equal(flag("1"), true); + assert.equal(flag("true"), true); +}); diff --git a/lib/discord-policy.test.ts b/lib/discord-policy.test.ts index 5659ff9..f634dda 100644 --- a/lib/discord-policy.test.ts +++ b/lib/discord-policy.test.ts @@ -107,7 +107,7 @@ describe("resolveDiscordAccess", () => { }); describe("discordPolicyConfigFromEnv", () => { - it("reads the six allowlists from the environment", () => { + it("reads the six allowlists and the guild-wide flag from the environment", () => { const parsed = discordPolicyConfigFromEnv({ NODE_ENV: "development", DISCORD_PUBLIC_GUILD_IDS: "g1", @@ -124,6 +124,17 @@ describe("discordPolicyConfigFromEnv", () => { internalChannelIds: ["c3"], internalUserIds: ["u1"], internalRoleIds: ["r1", "r2"], + internalGuildWide: false, }); }); + + it("turns guild-wide admission on only for an explicit truthy flag", () => { + const base = { NODE_ENV: "development" as const, DISCORD_INTERNAL_GUILD_IDS: "g1", DISCORD_INTERNAL_ROLE_IDS: "r1" }; + for (const on of ["1", "true", "TRUE", " true "]) { + assert.equal(discordPolicyConfigFromEnv({ ...base, DISCORD_INTERNAL_GUILD_WIDE: on }).internalGuildWide, true); + } + for (const off of [undefined, "", "0", "false", "no", "on", "yes", "banana"]) { + assert.equal(discordPolicyConfigFromEnv({ ...base, DISCORD_INTERNAL_GUILD_WIDE: off }).internalGuildWide, false); + } + }); }); diff --git a/lib/discord-policy.ts b/lib/discord-policy.ts index 3884311..ac02dad 100644 --- a/lib/discord-policy.ts +++ b/lib/discord-policy.ts @@ -25,6 +25,13 @@ export interface DiscordPolicyConfig { readonly internalChannelIds: readonly string[]; readonly internalUserIds: readonly string[]; readonly internalRoleIds: readonly string[]; + /** + * Admit an allowlisted operator in any channel of an allowlisted guild, so a + * mention from someone with the operator role works wherever the bot can + * read, not only in the allowlisted channels. The guild allowlist and the + * user/role allowlist still both apply. + */ + readonly internalGuildWide?: boolean; } export type DiscordAccess = @@ -40,6 +47,12 @@ export function parseIdList(raw: string | undefined): string[] { .filter((id) => id.length > 0); } +/** Parse a boolean switch from the environment. Only "1" and "true" turn it on. */ +export function parseFlag(raw: string | undefined): boolean { + const value = raw?.trim().toLowerCase(); + return value === "1" || value === "true"; +} + export function discordPolicyConfigFromEnv(env: NodeJS.ProcessEnv = process.env): DiscordPolicyConfig { const config = { publicGuildIds: parseIdList(env.DISCORD_PUBLIC_GUILD_IDS), @@ -48,6 +61,7 @@ export function discordPolicyConfigFromEnv(env: NodeJS.ProcessEnv = process.env) internalChannelIds: parseIdList(env.DISCORD_INTERNAL_CHANNEL_IDS), internalUserIds: parseIdList(env.DISCORD_INTERNAL_USER_IDS), internalRoleIds: parseIdList(env.DISCORD_INTERNAL_ROLE_IDS), + internalGuildWide: parseFlag(env.DISCORD_INTERNAL_GUILD_WIDE), }; // Fail fast on the one misconfiguration that would otherwise depend on code // order: a channel id in both tier allowlists would silently demote internal @@ -75,7 +89,11 @@ function isGuildAllowlisted(guildId: string | undefined, guildIds: readonly stri * with respect to Wazoo systems and public-safe knowledge only. * - Internal tier: guild and channel on the internal allowlist AND the user * id or at least one role on the internal operator lists. Neither the - * channel alone nor a role alone grants access. + * channel alone nor a role alone grants access. With `internalGuildWide` + * set, an allowlisted guild plus an allowlisted user or role is enough and + * the channel allowlist is not consulted, so an operator mention works + * wherever the bot can read. The guild and operator allowlists still both + * apply, and a DM stays denied because it carries no guild id. * * The returned principal ids differ per tier, so public and internal sessions * never share a principal, and every emitted attribute set carries its tier. @@ -97,6 +115,14 @@ export function resolveDiscordAccess( const userAllowed = config.internalUserIds.includes(request.userId); const roleAllowed = request.memberRoleIds.some((roleId) => config.internalRoleIds.includes(roleId)); + if ( + config.internalGuildWide && + isGuildAllowlisted(request.guildId, config.internalGuildIds) && + (userAllowed || roleAllowed) + ) { + return { tier: "internal", principalId: `discord-team:${request.userId}` }; + } + if ( isGuildAllowlisted(request.guildId, config.internalGuildIds) && config.internalChannelIds.includes(request.channelId) &&