Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
```

Expand Down
93 changes: 93 additions & 0 deletions lib/discord-guild-wide.test.ts
Original file line number Diff line number Diff line change
@@ -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<ReturnType<typeof discordPolicyConfigFromEnv>> = {}) {
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);
});
13 changes: 12 additions & 1 deletion lib/discord-policy.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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);
}
});
});
28 changes: 27 additions & 1 deletion lib/discord-policy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 =
Expand All @@ -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),
Expand All @@ -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
Expand Down Expand Up @@ -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.
Expand All @@ -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) &&
Expand Down
Loading