From 8bf25d05a7612b3491b676127522ef5f97f7c2a6 Mon Sep 17 00:00:00 2001 From: Michael Wu Date: Tue, 1 Sep 2026 14:14:52 +0900 Subject: [PATCH 01/37] feat: harden Discord actor-scoped workflows --- contrib/chart/templates/apirs.yaml | 12 + contrib/chart/templates/discordbot.yaml | 17 +- contrib/chart/templates/networkpolicy.yaml | 12 +- contrib/chart/values.yaml | 21 +- docs/pages/extend/workflows-v2.mdx | 63 ++ patches/@chat-adapter__discord@4.31.0.patch | 208 +++- pnpm-lock.yaml | 6 +- services/api-rs/Cargo.lock | 2 + .../crates/centaur-api-server/src/args.rs | 1 + .../crates/centaur-api-server/src/auth.rs | 53 +- .../crates/centaur-api-server/src/error.rs | 3 + .../crates/centaur-api-server/src/lib.rs | 59 ++ .../crates/centaur-api-server/src/routes.rs | 407 +++++++- .../crates/centaur-iron-control/src/error.rs | 4 + .../centaur-iron-control/src/principal.rs | 62 +- .../centaur-iron-control/src/session.rs | 353 ++++++- .../0054_workflow_action_proposals.sql | 73 ++ .../crates/centaur-workflows/Cargo.toml | 2 + .../centaur-workflows/src/action_proposals.rs | 908 ++++++++++++++++++ .../crates/centaur-workflows/src/lib.rs | 100 ++ .../api/v1/broker_credentials_controller.rb | 5 +- .../console/broker_credentials_controller.rb | 8 +- .../console/app/models/broker_credential.rb | 24 + .../console/broker_credentials/_form.html.erb | 8 +- .../app/views/console/credential.html.erb | 1 + ..._repository_scope_to_broker_credentials.rb | 9 + services/console/db/schema.rb | 3 +- .../console/lib/broker/credential_grants.rb | 1 + .../broker/github_app_installation_client.rb | 34 +- .../v1/broker_credentials_controller_test.rb | 30 +- .../broker_credentials_controller_test.rb | 35 +- .../github_app_installation_client_test.rb | 77 ++ .../test/models/broker_credential_test.rb | 24 + services/discordbot/README.md | 67 +- services/discordbot/src/discord-allowlist.ts | 8 +- services/discordbot/src/discord-delivery.ts | 211 ++++ services/discordbot/src/discord-ingress.ts | 460 +++++++++ services/discordbot/src/discord-policy.ts | 230 +++++ services/discordbot/src/index.ts | 211 +++- services/discordbot/src/server.ts | 32 +- services/discordbot/src/session-api.ts | 157 ++- services/discordbot/src/types.ts | 58 ++ .../discordbot/test/chat-sdk-emulate.test.ts | 145 ++- .../discordbot/test/discord-allowlist.test.ts | 24 +- .../discordbot/test/discord-delivery.test.ts | 124 +++ .../discordbot/test/discord-ingress.test.ts | 366 +++++++ .../discordbot/test/discord-policy.test.ts | 183 ++++ services/discordbot/test/session-api.test.ts | 141 ++- .../workflow-python/api/workflow_engine.py | 52 + .../tests/test_workflow_host.py | 89 ++ 50 files changed, 5016 insertions(+), 167 deletions(-) create mode 100644 services/api-rs/crates/centaur-session-sqlx/migrations/0054_workflow_action_proposals.sql create mode 100644 services/api-rs/crates/centaur-workflows/src/action_proposals.rs create mode 100644 services/console/db/migrate/20260901090000_add_github_repository_scope_to_broker_credentials.rb create mode 100644 services/discordbot/src/discord-delivery.ts create mode 100644 services/discordbot/src/discord-ingress.ts create mode 100644 services/discordbot/src/discord-policy.ts create mode 100644 services/discordbot/test/discord-delivery.test.ts create mode 100644 services/discordbot/test/discord-ingress.test.ts create mode 100644 services/discordbot/test/discord-policy.test.ts diff --git a/contrib/chart/templates/apirs.yaml b/contrib/chart/templates/apirs.yaml index d482fd9ba9..c4baa61d9c 100644 --- a/contrib/chart/templates/apirs.yaml +++ b/contrib/chart/templates/apirs.yaml @@ -2,6 +2,12 @@ {{- $console := include "centaur.consoleValues" . | fromYaml -}} {{- $mcpPublicUrl := default .Values.slackbotv2.mcpPublicUrl .Values.apiRs.mcpPublicUrl -}} {{- $apiRsName := include "centaur.componentName" (dict "root" . "component" "api-rs") -}} +{{- $discordApprovalRoles := list -}} +{{- range $binding := .Values.discordbot.roleBindings -}} +{{- if (get $binding "can_approve") -}} +{{- $discordApprovalRoles = append $discordApprovalRoles (get $binding "principal_role") -}} +{{- end -}} +{{- end -}} {{- $repoCacheStorageType := include "centaur.repoCacheStorageType" . -}} {{- $repoCacheUsePvc := eq $repoCacheStorageType "persistentVolumeClaim" -}} {{- $repoCachePvcName := include "centaur.repoCachePvcName" . -}} @@ -238,6 +244,12 @@ spec: name: {{ include "centaur.secretEnvName" $ }} key: {{ printf "%s%s" $.Values.secretManager.envPrefix $ingress.key }} {{- end }} +{{- end }} +{{- if .Values.discordbot.enabled }} + - name: DISCORDBOT_INTERNAL_URL + value: {{ printf "http://%s:%v" (include "centaur.componentName" (dict "root" . "component" "discordbot")) 3001 | quote }} + - name: DISCORDBOT_APPROVAL_ROLE_ALLOWLIST + value: {{ join "," ($discordApprovalRoles | uniq) | quote }} {{- end }} - name: BIND_ADDR value: {{ printf "0.0.0.0:%v" .Values.apiRs.port | quote }} diff --git a/contrib/chart/templates/discordbot.yaml b/contrib/chart/templates/discordbot.yaml index 2762296983..30df0fac8c 100644 --- a/contrib/chart/templates/discordbot.yaml +++ b/contrib/chart/templates/discordbot.yaml @@ -1,5 +1,8 @@ {{- if .Values.discordbot.enabled }} {{- $apiRsName := include "centaur.componentName" (dict "root" . "component" "api-rs") -}} +{{- $guildAllowlist := required "discordbot.guildAllowlist is required when discordbot is enabled" .Values.discordbot.guildAllowlist -}} +{{- $channelAllowlist := required "discordbot.channelAllowlist is required when discordbot is enabled" .Values.discordbot.channelAllowlist -}} +{{- $roleBindings := required "discordbot.roleBindings must contain reviewed role policy when discordbot is enabled" .Values.discordbot.roleBindings -}} apiVersion: apps/v1 kind: Deployment metadata: @@ -67,11 +70,17 @@ spec: - name: DISCORDBOT_USER_NAME value: {{ .Values.discordbot.userName | quote }} - name: DISCORDBOT_GUILD_ALLOWLIST - value: {{ .Values.discordbot.guildAllowlist | quote }} + value: {{ $guildAllowlist | quote }} - name: DISCORDBOT_CHANNEL_ALLOWLIST - value: {{ .Values.discordbot.channelAllowlist | quote }} - - name: DISCORDBOT_TRIGGER_ROLE_ALLOWLIST - value: {{ .Values.discordbot.triggerRoleAllowlist | quote }} + value: {{ $channelAllowlist | quote }} + - name: DISCORDBOT_ROLE_BINDINGS_JSON + value: {{ $roleBindings | toJson | quote }} + - name: DISCORDBOT_CONTINUATION_TTL_MS + value: {{ .Values.discordbot.continuationTtlMs | quote }} + - name: DISCORDBOT_INGRESS_MAX_EVENT_AGE_MS + value: {{ .Values.discordbot.ingressMaxEventAgeMs | quote }} + - name: DISCORDBOT_INGRESS_DELIVERY_TTL_MS + value: {{ .Values.discordbot.ingressDeliveryTtlMs | quote }} {{- if .Values.discordbot.mentionRoleIds }} - name: DISCORD_MENTION_ROLE_IDS value: {{ .Values.discordbot.mentionRoleIds | quote }} diff --git a/contrib/chart/templates/networkpolicy.yaml b/contrib/chart/templates/networkpolicy.yaml index c6986b271e..8b6a9bb837 100644 --- a/contrib/chart/templates/networkpolicy.yaml +++ b/contrib/chart/templates/networkpolicy.yaml @@ -514,7 +514,8 @@ spec: - Ingress - Egress ingress: - # Only health probes reach discordbot; nothing routes traffic to it (the Gateway is outbound). + # Health probes and the API workflow runtime's narrow authenticated delivery + # endpoint are the only inbound paths. Discord event ingress remains Gateway-only. - from: {{- range $ingressSourceNamespaces }} - namespaceSelector: @@ -524,6 +525,15 @@ spec: ports: - protocol: TCP port: 3001 +{{- if .Values.apiRs.enabled }} + - from: + - podSelector: + matchLabels: +{{ include "centaur.componentSelectorLabels" (dict "root" . "component" "api-rs") | nindent 14 }} + ports: + - protocol: TCP + port: 3001 +{{- end }} egress: {{- if .Values.apiRs.enabled }} - to: diff --git a/contrib/chart/values.yaml b/contrib/chart/values.yaml index e6c048bd1b..c0bc0db049 100644 --- a/contrib/chart/values.yaml +++ b/contrib/chart/values.yaml @@ -737,7 +737,7 @@ githubbot: # Discord chat ingress — mirrors slackbotv2, forwards to the api-rs control plane (:8080) over a # persistent Discord Gateway connection. Off by default; needs a Discord app + Message Content -# Intent + guild/channel/role allowlists. Always exactly one replica (singleton Gateway session). +# Intent + guild/channel/role policy. Always exactly one replica (singleton Gateway session). discordbot: enabled: false image: @@ -749,9 +749,22 @@ discordbot: guildAllowlist: "" # Comma/space-separated parent channel IDs. Required and fail-closed. channelAllowlist: "" - # Comma/space-separated immutable role IDs allowed to trigger/continue human turns. - # Required and fail-closed. Role names are never used for authorization. - triggerRoleAllowlist: "" + # Reviewed immutable Discord-role bindings. Enabling the deployment with an + # empty list fails Helm rendering. Multiple matching roles use explicit + # priority; an equal-priority semantic conflict is denied. Use exact repo + # names only; wildcards and role names are rejected at process startup. + roleBindings: [] + # - role_id: "100000000000000001" + # capability_class: github:observe + # principal_role: discord-observer + # can_approve: false + # priority: 0 + # repository_scope: [example-org/example-repo] + # project_scope: [] + # Root-to-continuation TTL and replay/audit retention, in milliseconds. + continuationTtlMs: 86400000 + ingressMaxEventAgeMs: 300000 + ingressDeliveryTtlMs: 604800000 # Comma/space-separated role IDs whose mentions also trigger the bot. mentionRoleIds: "" # Rename auto-created threads to the triggering message; set false to keep generic names. diff --git a/docs/pages/extend/workflows-v2.mdx b/docs/pages/extend/workflows-v2.mdx index 34d10ce5e2..30b4f3dc7e 100644 --- a/docs/pages/extend/workflows-v2.mdx +++ b/docs/pages/extend/workflows-v2.mdx @@ -63,6 +63,9 @@ Supported v2 primitives: | `ctx.run_model_ensemble(...)` | Supported for replay-safe cross-model review, bounded fallback, and validated synthesis | | `ctx.call_tool(...)` | Supported through the generated `centaur-tools call` bridge in the workflow-host sandbox | | `ctx.post_to_slack(...)` | Supported | +| `ctx.post_to_discord(...)` | Supported for authenticated, allowlisted, idempotent workflow notifications | +| `ctx.put_action_proposal(...)` | Supported for validated, canonical, expiring action proposals | +| `ctx.transition_notification_state(...)` | Supported for durable semantic notification suppression/resolution | | `ctx._pool` | Supported when the workflow-host sandbox receives `DATABASE_URL` | | `WEBHOOKS` | Supported | | `SCHEDULE` | Supported | @@ -113,6 +116,66 @@ async def handler(inp: dict, ctx: WorkflowContext) -> dict: for that message and require the app installation to grant `chat:write.customize`. Omitting them preserves the app's default identity. +### Keep observation separate from approval and action + +An observer workflow can persist a typed proposal without gaining mutation +authority. The runtime validates its exact action type, action workflow, +repository, refs, source IDs, bounded evidence, parameters, and validation +statuses, then derives the canonical `sha256:` fingerprint: + +```python +proposal = await ctx.put_action_proposal( + { + "action_type": "github:create_improvement_pr", + "action_workflow": "execute_approved_improvement", + "repository": "example-org/automation", + "base_ref": "0123456789abcdef0123456789abcdef01234567", + "head_ref": None, + "source_ids": {"sentry_issue": "OPS-42"}, + "evidence": [ + { + "source_type": "sentry", + "source_id": "OPS-42", + "content_digest": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + } + ], + "validations": [{"name": "source_current", "status": "passed"}], + "parameters": {"summary": "Bounded operator-facing summary"}, + }, + expires_in_seconds=7 * 24 * 60 * 60, +) +``` + +Only authenticated Discord ingress with a reviewed approval role can consume +that exact fingerprint. Consumption and action-run creation are idempotent; +repeated approval returns the existing action. Expired, changed, malformed, or +out-of-scope proposals fail closed and require a new observation. The action +workflow receives the normalized proposal plus immutable approval context and +must enforce the tuple again before any write. + +For quiet recurring workflows, derive semantic state from stable proposal, +source, action, and blocker identifiers—not model prose, timestamps, run IDs, +or raw errors: + +```python +transition = await ctx.transition_notification_state( + "weekly_ops_review:automations", + semantic_fingerprint, + "proposal_pending", +) +if transition["notify"]: + await ctx.post_to_discord( + "1542739830591459369", + concise_digest, + delivery_id=f"weekly-ops:{semantic_fingerprint}", + ) +``` + +The state is durable. A storage failure returns `state_persisted: false` and +permits one concise handoff, but never authorizes a mutation. The Discord +delivery endpoint rejects non-allowlisted channels, disables mentions and +embeds, and deduplicates a stable delivery ID. + ### Make agent turns explicit Use `ctx.agent_turn(...)` when the workflow needs an agent sandbox: diff --git a/patches/@chat-adapter__discord@4.31.0.patch b/patches/@chat-adapter__discord@4.31.0.patch index 5fb4de3d58..c433a10da6 100644 --- a/patches/@chat-adapter__discord@4.31.0.patch +++ b/patches/@chat-adapter__discord@4.31.0.patch @@ -1,5 +1,5 @@ diff --git a/dist/index.d.ts b/dist/index.d.ts -index 0024735d2441f1d2e3c2d5ed659fa4c4265f3922..2dd6143d856269b494c64bb3f092ad7040e94d3a 100644 +index 0024735d2441f1d2e3c2d5ed659fa4c4265f3922..444aa7b36347c4eea42e049504665dd3d53505ca 100644 --- a/dist/index.d.ts +++ b/dist/index.d.ts @@ -20,8 +20,8 @@ import { ButtonStyle, APIEmbed, ChannelType, APIMessage, InteractionType } from @@ -13,7 +13,7 @@ index 0024735d2441f1d2e3c2d5ed659fa4c4265f3922..2dd6143d856269b494c64bb3f092ad70 */ private convertMentionsToDiscord; /** -@@ -61,6 +61,45 @@ interface DiscordAdapterConfig { +@@ -61,6 +61,69 @@ interface DiscordAdapterConfig { publicKey?: string; /** Override bot username (optional) */ userName?: string; @@ -30,6 +30,30 @@ index 0024735d2441f1d2e3c2d5ed659fa4c4265f3922..2dd6143d856269b494c64bb3f092ad70 + roleIds: string[]; + }) => boolean | Promise; + /** ++ * Admission hook for every Gateway MESSAGE_CREATE event. It runs before ++ * bot filtering, thread creation, or chat dispatch. Returning false drops ++ * the event without side effects. ++ */ ++ shouldHandleGatewayMessage?: (info: { ++ applicationId?: string; ++ authorId: string; ++ authorIsBot: boolean; ++ authorIsSelf: boolean; ++ channelId: string; ++ content: string; ++ createdTimestamp: number; ++ gatewayIdentityVerified: boolean; ++ guildId: string; ++ isMentioned: boolean; ++ messageId: string; ++ messageType: number; ++ roleIds: string[]; ++ threadId?: string; ++ webhookId?: string; ++ }) => boolean | Promise; ++ /** Whether direct Gateway interactions may use the adapter handler. Default true. */ ++ allowGatewayInteractions?: boolean; ++ /** + * Invoked (fire-and-forget) when an incoming message is dropped because of + * a concurrency lock conflict (LockError from the chat SDK). + */ @@ -59,19 +83,22 @@ index 0024735d2441f1d2e3c2d5ed659fa4c4265f3922..2dd6143d856269b494c64bb3f092ad70 } /** * Discord thread ID components. -@@ -363,6 +402,11 @@ declare class DiscordAdapter implements Adapter { +@@ -363,6 +426,14 @@ declare class DiscordAdapter implements Adapter { protected readonly publicKey: string; protected readonly applicationId: string; protected readonly mentionRoleIds: string[]; + protected readonly shouldHandleMention?: DiscordAdapterConfig["shouldHandleMention"]; ++ protected readonly shouldHandleGatewayMessage?: DiscordAdapterConfig["shouldHandleGatewayMessage"]; ++ protected readonly allowGatewayInteractions: boolean; + protected readonly onMessageDropped?: DiscordAdapterConfig["onMessageDropped"]; + protected readonly shouldForwardBotMessage?: DiscordAdapterConfig["shouldForwardBotMessage"]; + protected readonly onGatewayStatusChange?: DiscordAdapterConfig["onGatewayStatusChange"]; + private lastGatewayConnected?; ++ private gatewayIdentityVerified; protected chat: ChatInstance | null; protected readonly logger: Logger; protected readonly formatConverter: DiscordFormatConverter; -@@ -563,6 +607,23 @@ declare class DiscordAdapter implements Adapter { +@@ -563,6 +634,23 @@ declare class DiscordAdapter implements Adapter { * Handle a message received via the Gateway WebSocket. */ protected handleGatewayMessage(message: Message$1, isMentioned: boolean): Promise; @@ -96,7 +123,7 @@ index 0024735d2441f1d2e3c2d5ed659fa4c4265f3922..2dd6143d856269b494c64bb3f092ad70 * Handle a reaction received via the Gateway WebSocket. */ diff --git a/dist/index.js b/dist/index.js -index e4f6fe07808ab3c9858fda724c36f7aef5084502..cc6ff27fd07eb954ef333b05a4634d26c61aac61 100644 +index e4f6fe07808ab3c9858fda724c36f7aef5084502..bc4fc7b0bab4114133404f7de21324886cc2800c 100644 --- a/dist/index.js +++ b/dist/index.js @@ -272,11 +272,11 @@ import { @@ -123,30 +150,35 @@ index e4f6fe07808ab3c9858fda724c36f7aef5084502..cc6ff27fd07eb954ef333b05a4634d26 } if (isStrongNode(node)) { const content = getNodeChildren(node).map((child) => this.nodeToDiscordMarkdown(child)).join(""); -@@ -398,6 +398,11 @@ var DiscordAdapter = class _DiscordAdapter { +@@ -398,6 +398,14 @@ var DiscordAdapter = class _DiscordAdapter { publicKey; applicationId; mentionRoleIds; + shouldHandleMention; ++ shouldHandleGatewayMessage; ++ allowGatewayInteractions; + onMessageDropped; + shouldForwardBotMessage; + onGatewayStatusChange; + lastGatewayConnected; ++ gatewayIdentityVerified = false; chat = null; logger; formatConverter = new DiscordFormatConverter(); -@@ -432,6 +437,10 @@ var DiscordAdapter = class _DiscordAdapter { +@@ -432,6 +440,12 @@ var DiscordAdapter = class _DiscordAdapter { this.applicationId = applicationId; this.mentionRoleIds = config.mentionRoleIds ?? (process.env.DISCORD_MENTION_ROLE_IDS ? process.env.DISCORD_MENTION_ROLE_IDS.split(",").map((id) => id.trim()) : []); this.botUserId = applicationId; + this.shouldHandleMention = config.shouldHandleMention; ++ this.shouldHandleGatewayMessage = config.shouldHandleGatewayMessage; ++ this.allowGatewayInteractions = config.allowGatewayInteractions ?? true; + this.onMessageDropped = config.onMessageDropped; + this.shouldForwardBotMessage = config.shouldForwardBotMessage; + this.onGatewayStatusChange = config.onGatewayStatusChange; this.logger = config.logger ?? new ConsoleLogger("info").child("discord"); this.userName = config.userName ?? "bot"; if (!HEX_64_PATTERN.test(this.publicKey)) { -@@ -927,6 +936,33 @@ var DiscordAdapter = class _DiscordAdapter { +@@ -927,6 +941,33 @@ var DiscordAdapter = class _DiscordAdapter { (roleId) => this.mentionRoleIds.includes(roleId) ); const isMentioned = isUserMentioned || isRoleMentioned; @@ -180,7 +212,7 @@ index e4f6fe07808ab3c9858fda724c36f7aef5084502..cc6ff27fd07eb954ef333b05a4634d26 if (!discordThreadId && isMentioned) { try { const newThread = await this.createDiscordThread(channelId, data.id); -@@ -978,6 +1014,19 @@ var DiscordAdapter = class _DiscordAdapter { +@@ -978,6 +1019,19 @@ var DiscordAdapter = class _DiscordAdapter { try { await this.chat.handleIncomingMessage(this, threadId, chatMessage); } catch (error) { @@ -200,7 +232,7 @@ index e4f6fe07808ab3c9858fda724c36f7aef5084502..cc6ff27fd07eb954ef333b05a4634d26 this.logger.error("Error handling forwarded message", { error: String(error), messageId: data.id -@@ -1063,7 +1112,9 @@ var DiscordAdapter = class _DiscordAdapter { +@@ -1063,7 +1117,9 @@ var DiscordAdapter = class _DiscordAdapter { if (discordThreadId) { channelId = discordThreadId; } @@ -211,7 +243,7 @@ index e4f6fe07808ab3c9858fda724c36f7aef5084502..cc6ff27fd07eb954ef333b05a4634d26 const embeds = []; const components = []; const card = extractCard(message); -@@ -1318,7 +1369,9 @@ var DiscordAdapter = class _DiscordAdapter { +@@ -1318,7 +1374,9 @@ var DiscordAdapter = class _DiscordAdapter { async editMessage(threadId, messageId, message) { const { channelId, threadId: discordThreadId } = this.decodeThreadId(threadId); const targetChannelId = discordThreadId || channelId; @@ -222,7 +254,7 @@ index e4f6fe07808ab3c9858fda724c36f7aef5084502..cc6ff27fd07eb954ef333b05a4634d26 const embeds = []; const components = []; const card = extractCard(message); -@@ -1634,11 +1687,49 @@ var DiscordAdapter = class _DiscordAdapter { +@@ -1634,11 +1692,49 @@ var DiscordAdapter = class _DiscordAdapter { if (body) { headers["Content-Type"] = "application/json"; } @@ -273,27 +305,60 @@ index e4f6fe07808ab3c9858fda724c36f7aef5084502..cc6ff27fd07eb954ef333b05a4634d26 if (!response.ok) { const errorText = await response.text(); this.logger.error("Discord API error", { -@@ -1737,6 +1828,27 @@ var DiscordAdapter = class _DiscordAdapter { +@@ -1704,10 +1800,7 @@ var DiscordAdapter = class _DiscordAdapter { + intents: [ + GatewayIntentBits.Guilds, + GatewayIntentBits.GuildMessages, +- GatewayIntentBits.MessageContent, +- GatewayIntentBits.DirectMessages, +- GatewayIntentBits.GuildMessageReactions, +- GatewayIntentBits.DirectMessageReactions ++ GatewayIntentBits.MessageContent + ], + partials: [Partials.Channel] + }); +@@ -1733,10 +1826,48 @@ var DiscordAdapter = class _DiscordAdapter { + this.setupLegacyGatewayHandlers(client, () => isShuttingDown); + } + client.on(Events.ClientReady, () => { ++ const actualBotUserId = client.user?.id; ++ if (!actualBotUserId || actualBotUserId !== this.applicationId) { ++ this.gatewayIdentityVerified = false; ++ this.logger.error("Discord Gateway identity mismatch", { ++ actualBotUserId, ++ expectedBotUserId: this.applicationId ++ }); ++ this.emitGatewayStatus(false); ++ client.destroy(); ++ return; ++ } ++ this.gatewayIdentityVerified = true; + this.logger.info("Discord Gateway connected", { username: client.user?.username, id: client.user?.id }); + this.emitGatewayStatus(true); + }); + client.on(Events.ShardReady, () => { -+ this.emitGatewayStatus(true); ++ this.gatewayIdentityVerified = client.user?.id === this.applicationId; ++ this.emitGatewayStatus(this.gatewayIdentityVerified); + }); + client.on(Events.ShardResume, () => { -+ this.emitGatewayStatus(true); ++ this.gatewayIdentityVerified = client.user?.id === this.applicationId; ++ this.emitGatewayStatus(this.gatewayIdentityVerified); + }); + client.on(Events.ShardDisconnect, () => { ++ this.gatewayIdentityVerified = false; + this.logger.warn("Discord Gateway shard disconnected"); + this.emitGatewayStatus(false); + }); + client.on(Events.Invalidated, () => { ++ this.gatewayIdentityVerified = false; + this.logger.error("Discord Gateway session invalidated"); + this.emitGatewayStatus(false); + }); + client.on(Events.ShardError, (error) => { ++ this.gatewayIdentityVerified = false; + this.logger.error("Discord Gateway shard error", { + error: String(error) + }); @@ -301,26 +366,80 @@ index e4f6fe07808ab3c9858fda724c36f7aef5084502..cc6ff27fd07eb954ef333b05a4634d26 }); client.on(Events.Error, (error) => { this.logger.error("Discord Gateway error", { error: String(error) }); -@@ -1774,6 +1886,7 @@ var DiscordAdapter = class _DiscordAdapter { +@@ -1773,7 +1904,9 @@ var DiscordAdapter = class _DiscordAdapter { + }); } finally { isShuttingDown = true; ++ this.gatewayIdentityVerified = false; client.destroy(); + this.emitGatewayStatus(false); this.logger.info("Discord Gateway listener stopped"); } } -@@ -1787,18 +1900,57 @@ var DiscordAdapter = class _DiscordAdapter { +@@ -1786,27 +1919,103 @@ var DiscordAdapter = class _DiscordAdapter { + this.logger.debug("Ignoring message - Gateway is shutting down"); return; } - if (message.author.bot) { +- if (message.author.bot) { - this.logger.debug("Ignoring message from bot", { -+ const isMe = message.author.id === client.user?.id; -+ const forwardBotMessage = !isMe && this.shouldForwardBotMessage?.({ - authorId: message.author.id, +- authorId: message.author.id, - authorName: message.author.username, - isMe: message.author.id === client.user?.id - }); - return; +- } +- const isUserMentioned = message.mentions.has(client.user?.id ?? ""); ++ const isUserMentioned = message.mentions.has(client.user?.id ?? "", { ++ ignoreEveryone: true, ++ ignoreRoles: true ++ }); + const isRoleMentioned = this.mentionRoleIds.length > 0 && message.mentions.roles.some( + (role) => this.mentionRoleIds.includes(role.id) + ); + const isMentioned = isUserMentioned || isRoleMentioned; ++ const parentChannelId = message.channel.isThread() && message.channel.parentId ? message.channel.parentId : message.channelId; ++ const discordThreadId = message.channel.isThread() ? message.channelId : void 0; ++ const authorIsSelf = message.author.id === client.user?.id; ++ const gatewayIdentityVerified = this.gatewayIdentityVerified && client.user?.id === this.applicationId; ++ if (this.shouldHandleGatewayMessage) { ++ let shouldHandle = false; ++ try { ++ shouldHandle = await this.shouldHandleGatewayMessage({ ++ applicationId: message.applicationId ?? void 0, ++ authorId: message.author.id, ++ authorIsBot: message.author.bot, ++ authorIsSelf, ++ channelId: parentChannelId, ++ content: message.content, ++ createdTimestamp: message.createdTimestamp, ++ gatewayIdentityVerified, ++ guildId: message.guildId ?? "@me", ++ isMentioned, ++ messageId: message.id, ++ messageType: message.type, ++ roleIds: message.member ? Array.from(message.member.roles.cache.keys()) : [], ++ threadId: discordThreadId, ++ webhookId: message.webhookId ?? void 0 ++ }); ++ } catch (error) { ++ this.logger.warn("shouldHandleGatewayMessage failed closed", { ++ error: String(error), ++ messageId: message.id ++ }); ++ } ++ if (!shouldHandle) { ++ return; ++ } ++ } ++ if (!gatewayIdentityVerified) { ++ this.logger.error("Ignoring message from unverified Gateway identity", { ++ messageId: message.id ++ }); ++ return; ++ } ++ if (message.author.bot) { ++ const forwardBotMessage = !authorIsSelf && this.shouldForwardBotMessage?.({ ++ authorId: message.author.id, + guildId: message.guildId ?? "@me", + channelId: message.channelId + }) === true; @@ -328,22 +447,12 @@ index e4f6fe07808ab3c9858fda724c36f7aef5084502..cc6ff27fd07eb954ef333b05a4634d26 + this.logger.debug("Ignoring message from bot", { + authorId: message.author.id, + authorName: message.author.username, -+ isMe ++ isMe: authorIsSelf + }); + return; + } - } -- const isUserMentioned = message.mentions.has(client.user?.id ?? ""); -+ const isUserMentioned = message.mentions.has(client.user?.id ?? "", { -+ ignoreEveryone: true, -+ ignoreRoles: true -+ }); - const isRoleMentioned = this.mentionRoleIds.length > 0 && message.mentions.roles.some( - (role) => this.mentionRoleIds.includes(role.id) - ); - const isMentioned = isUserMentioned || isRoleMentioned; ++ } + if (isMentioned && this.shouldHandleMention) { -+ const parentChannelId = message.channel.isThread() && message.channel.parentId ? message.channel.parentId : message.channelId; + let shouldHandle = false; + try { + shouldHandle = await this.shouldHandleMention({ @@ -373,7 +482,30 @@ index e4f6fe07808ab3c9858fda724c36f7aef5084502..cc6ff27fd07eb954ef333b05a4634d26 this.logger.info("Discord Gateway message received", { channelId: message.channelId, guildId: message.guildId, -@@ -1983,9 +2135,16 @@ var DiscordAdapter = class _DiscordAdapter { + authorId: message.author.id, + isMentioned, + isUserMentioned, +- isRoleMentioned, +- content: message.content.slice(0, 100) ++ isRoleMentioned + }); + await this.handleGatewayMessage(message, isMentioned); + }); +@@ -1815,6 +2024,13 @@ var DiscordAdapter = class _DiscordAdapter { + this.logger.debug("Ignoring interaction - Gateway is shutting down"); + return; + } ++ if (!this.allowGatewayInteractions) { ++ this.logger.info("Ignoring unsupported Discord Gateway interaction", { ++ id: interaction.id, ++ type: interaction.type ++ }); ++ return; ++ } + this.logger.info("Discord Gateway interaction received", { + id: interaction.id, + type: interaction.type +@@ -1983,9 +2199,18 @@ var DiscordAdapter = class _DiscordAdapter { })), raw: { id: message.id, @@ -387,10 +519,12 @@ index e4f6fe07808ab3c9858fda724c36f7aef5084502..cc6ff27fd07eb954ef333b05a4634d26 + member: { + roles: message.member ? Array.from(message.member.roles.cache.keys()) : [] + }, ++ application_id: message.applicationId ?? void 0, ++ webhook_id: message.webhookId ?? void 0, author: { id: message.author.id, username: message.author.username -@@ -1998,6 +2157,19 @@ var DiscordAdapter = class _DiscordAdapter { +@@ -1998,6 +2223,19 @@ var DiscordAdapter = class _DiscordAdapter { try { await this.chat.handleIncomingMessage(this, threadId, chatMessage); } catch (error) { @@ -410,7 +544,7 @@ index e4f6fe07808ab3c9858fda724c36f7aef5084502..cc6ff27fd07eb954ef333b05a4634d26 this.logger.error("Error handling Gateway message", { error: String(error), messageId: message.id -@@ -2262,7 +2434,9 @@ var DiscordAdapter = class _DiscordAdapter { +@@ -2262,7 +2500,9 @@ var DiscordAdapter = class _DiscordAdapter { `Invalid Discord channel ID: ${channelId}` ); } @@ -421,7 +555,7 @@ index e4f6fe07808ab3c9858fda724c36f7aef5084502..cc6ff27fd07eb954ef333b05a4634d26 const embeds = []; const components = []; const card = extractCard(message); -@@ -2313,6 +2487,43 @@ var DiscordAdapter = class _DiscordAdapter { +@@ -2313,6 +2553,43 @@ var DiscordAdapter = class _DiscordAdapter { raw: result }; } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d9eb6f3bd4..f79729d2f2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -11,7 +11,7 @@ overrides: patchedDependencies: '@chat-adapter/discord@4.31.0': - hash: ae2b002ae710b1c1c443862742ed72d36da8a1bcab37aa8a39e572aecaaf183a + hash: d0406a4a8e52d5a6a0cd5518f2208abf84de5dd6f421053ab1535da073349e68 path: patches/@chat-adapter__discord@4.31.0.patch '@chat-adapter/linear@4.31.0': hash: fce7a692b030cfe3d325b020a4472b9424b8976aa2f7faded6ad4c83421e9132 @@ -84,7 +84,7 @@ importers: version: link:../../packages/rendering '@chat-adapter/discord': specifier: ^4.31.0 - version: 4.31.0(patch_hash=ae2b002ae710b1c1c443862742ed72d36da8a1bcab37aa8a39e572aecaaf183a)(zod@4.4.3) + version: 4.31.0(patch_hash=d0406a4a8e52d5a6a0cd5518f2208abf84de5dd6f421053ab1535da073349e68)(zod@4.4.3) '@chat-adapter/state-pg': specifier: ^4.31.0 version: 4.31.0(patch_hash=69262b03c278ca9d4af0bed7b4e05f9d7a5a36d3d79fad31df2a85da4d349274)(zod@4.4.3) @@ -1963,7 +1963,7 @@ snapshots: '@azure/msal-common': 16.10.0 jsonwebtoken: 9.0.3 - '@chat-adapter/discord@4.31.0(patch_hash=ae2b002ae710b1c1c443862742ed72d36da8a1bcab37aa8a39e572aecaaf183a)(zod@4.4.3)': + '@chat-adapter/discord@4.31.0(patch_hash=d0406a4a8e52d5a6a0cd5518f2208abf84de5dd6f421053ab1535da073349e68)(zod@4.4.3)': dependencies: '@chat-adapter/shared': 4.31.0(zod@4.4.3) chat: 4.31.0(zod@4.4.3) diff --git a/services/api-rs/Cargo.lock b/services/api-rs/Cargo.lock index e414aceb00..8a6ecb22c5 100644 --- a/services/api-rs/Cargo.lock +++ b/services/api-rs/Cargo.lock @@ -1129,9 +1129,11 @@ dependencies = [ "chrono-tz", "cron", "futures-util", + "hex", "reqwest", "serde", "serde_json", + "sha2 0.11.0", "sqlx", "thiserror 2.0.20", "time", diff --git a/services/api-rs/crates/centaur-api-server/src/args.rs b/services/api-rs/crates/centaur-api-server/src/args.rs index 78fcad5445..af622f307b 100644 --- a/services/api-rs/crates/centaur-api-server/src/args.rs +++ b/services/api-rs/crates/centaur-api-server/src/args.rs @@ -1421,6 +1421,7 @@ fn should_retry_iron_control_register(error: &RegisterError) -> bool { match error { RegisterError::Translate(_) => false, RegisterError::Control(IronControlError::PrincipalDerivation(_)) => false, + RegisterError::Control(IronControlError::DiscordPolicy(_)) => false, RegisterError::Control(IronControlError::Transport { .. }) => true, RegisterError::Control(IronControlError::Decode { .. }) => false, RegisterError::Control(IronControlError::Status { status, .. }) => { diff --git a/services/api-rs/crates/centaur-api-server/src/auth.rs b/services/api-rs/crates/centaur-api-server/src/auth.rs index ab3b09a8e1..60ac286857 100644 --- a/services/api-rs/crates/centaur-api-server/src/auth.rs +++ b/services/api-rs/crates/centaur-api-server/src/auth.rs @@ -24,18 +24,20 @@ pub(crate) enum Capability { WorkflowsRead, WorkflowsWrite, WorkflowsEvents, + WorkflowApprovals, AdminArchive, AdminSync, } impl Capability { - const ALL: [Self; 8] = [ + const ALL: [Self; 9] = [ Self::SessionsRead, Self::SessionsWrite, Self::SandboxesDrain, Self::WorkflowsRead, Self::WorkflowsWrite, Self::WorkflowsEvents, + Self::WorkflowApprovals, Self::AdminArchive, Self::AdminSync, ]; @@ -131,6 +133,9 @@ impl ApiAuthConfig { if spec.workflow_events { capabilities.push(Capability::WorkflowsEvents); } + if spec.workflow_approvals { + capabilities.push(Capability::WorkflowApprovals); + } callers.push(static_caller( spec.identity, CallerClass::Ingress, @@ -182,6 +187,30 @@ impl ApiAuthConfig { } } + #[cfg(test)] + pub(crate) fn testing_with_discord_ingress( + discord_key: impl Into, + jwt_secret: impl Into, + ) -> Self { + let callers = vec![static_caller( + "discordbot", + CallerClass::Ingress, + discord_key.into(), + [ + Capability::SessionsRead, + Capability::SessionsWrite, + Capability::WorkflowApprovals, + ], + Some(&["discord:"]), + )]; + Self { + static_callers: Arc::new(callers), + jwt_secret: Arc::from(jwt_secret.into()), + jwt_audience: Arc::from(DEFAULT_API_JWT_AUDIENCE), + jwt_issuer: Arc::from(DEFAULT_API_JWT_ISSUER), + } + } + pub(crate) fn authenticate( &self, headers: &HeaderMap, @@ -296,12 +325,14 @@ const INGRESS_SPECS: &[IngressSpec] = &[ identity: "slackbot", platform_prefixes: &["slack:"], workflow_events: true, + workflow_approvals: false, }, IngressSpec { env_var: "DISCORDBOT_API_KEY", identity: "discordbot", platform_prefixes: &["discord:"], workflow_events: false, + workflow_approvals: true, }, IngressSpec { env_var: "GITHUBBOT_API_KEY", @@ -313,18 +344,21 @@ const INGRESS_SPECS: &[IngressSpec] = &[ "github-review:", ], workflow_events: true, + workflow_approvals: false, }, IngressSpec { env_var: "LINEARBOT_API_KEY", identity: "linearbot", platform_prefixes: &["linear:"], workflow_events: false, + workflow_approvals: false, }, IngressSpec { env_var: "TEAMSBOT_API_KEY", identity: "teamsbot", platform_prefixes: &["teams:"], workflow_events: false, + workflow_approvals: false, }, ]; @@ -334,6 +368,7 @@ struct IngressSpec { /// Every session thread-key prefix this ingress mints. platform_prefixes: &'static [&'static str], workflow_events: bool, + workflow_approvals: bool, } fn static_caller( @@ -444,6 +479,22 @@ mod tests { assert_eq!(caller.principal_subject(), None); } + #[test] + fn discord_ingress_has_proposal_approval_without_general_workflow_control() { + let auth = ApiAuthConfig::testing_with_discord_ingress("discord-key", "jwt-secret"); + let mut headers = HeaderMap::new(); + headers.insert(header::AUTHORIZATION, "Bearer discord-key".parse().unwrap()); + + let caller = auth.authenticate(&headers).unwrap(); + + assert_eq!(caller.class(), CallerClass::Ingress); + assert_eq!(caller.identity(), "discordbot"); + assert!(caller.has_capability(Capability::WorkflowApprovals)); + assert!(!caller.has_capability(Capability::WorkflowsRead)); + assert!(!caller.has_capability(Capability::WorkflowsWrite)); + assert!(!caller.has_capability(Capability::WorkflowsEvents)); + } + #[test] fn console_service_jwt_requires_the_exact_service_subject() { let auth = ApiAuthConfig::testing("jwt-secret"); diff --git a/services/api-rs/crates/centaur-api-server/src/error.rs b/services/api-rs/crates/centaur-api-server/src/error.rs index beb43ba956..66d07ba4b8 100644 --- a/services/api-rs/crates/centaur-api-server/src/error.rs +++ b/services/api-rs/crates/centaur-api-server/src/error.rs @@ -73,6 +73,9 @@ impl IntoResponse for ApiError { Self::Runtime(SessionRuntimeError::IronControl( centaur_iron_control::IronControlError::PrincipalDerivation(_), )) => StatusCode::BAD_REQUEST, + Self::Runtime(SessionRuntimeError::IronControl( + centaur_iron_control::IronControlError::DiscordPolicy(_), + )) => StatusCode::FORBIDDEN, Self::Workflow(WorkflowRuntimeError::BadRequest(_)) => StatusCode::BAD_REQUEST, Self::Workflow(WorkflowRuntimeError::Disabled(_)) => StatusCode::FORBIDDEN, Self::Workflow(WorkflowRuntimeError::NotFound(_)) => StatusCode::NOT_FOUND, diff --git a/services/api-rs/crates/centaur-api-server/src/lib.rs b/services/api-rs/crates/centaur-api-server/src/lib.rs index f4e7721a2d..a95d43f953 100644 --- a/services/api-rs/crates/centaur-api-server/src/lib.rs +++ b/services/api-rs/crates/centaur-api-server/src/lib.rs @@ -56,6 +56,10 @@ mod tests { ApiAuthConfig::testing_with_slack_ingress("test-slackbot-key", "test-secret") } + fn test_auth_with_discord() -> ApiAuthConfig { + ApiAuthConfig::testing_with_discord_ingress("test-discordbot-key", "test-secret") + } + fn console_token() -> String { encode( &Header::new(Algorithm::HS256), @@ -439,6 +443,61 @@ mod tests { } } + #[tokio::test] + async fn workflow_proposal_approval_is_discord_ingress_only() { + let fingerprint = format!("sha256:{}", "a".repeat(64)); + let path = format!("/api/workflows/proposals/{fingerprint}/approve"); + let body = json!({ + "actor_id": "100000000000000001", + "capability_class": "github:approve", + "channel_id": "300000000000000001", + "guild_id": "200000000000000001", + "message_id": "600000000000000001", + "policy_fingerprint": format!("sha256:{}", "b".repeat(64)), + "principal_role": "discord-operator", + "repository_scope": ["508-dev/508-workflows"], + "root_message_id": "600000000000000001", + "thread_id": "600000000000000001" + }); + let discord_response = + build_router_with_app_state(AppState::unready(test_auth_with_discord())) + .oneshot( + Request::builder() + .method(Method::POST) + .uri(&path) + .header(header::AUTHORIZATION, "Bearer test-discordbot-key") + .header(header::CONTENT_TYPE, "application/json") + .body(Body::from(body.to_string())) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(discord_response.status(), StatusCode::SERVICE_UNAVAILABLE); + + for (auth, token, expected) in [ + ( + test_auth_with_slack(), + "test-slackbot-key", + StatusCode::FORBIDDEN, + ), + (test_auth(), "not-a-valid-token", StatusCode::UNAUTHORIZED), + ] { + let response = build_router_with_app_state(AppState::unready(auth)) + .oneshot( + Request::builder() + .method(Method::POST) + .uri(&path) + .header(header::AUTHORIZATION, format!("Bearer {token}")) + .header(header::CONTENT_TYPE, "application/json") + .body(Body::from(body.to_string())) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), expected); + } + } + #[tokio::test] async fn principal_jwt_is_capability_scoped_and_archive_exception_is_subject_scoped() { let principal = principal_token("prn_sandbox"); diff --git a/services/api-rs/crates/centaur-api-server/src/routes.rs b/services/api-rs/crates/centaur-api-server/src/routes.rs index d2f46f0040..c794fcb68f 100644 --- a/services/api-rs/crates/centaur-api-server/src/routes.rs +++ b/services/api-rs/crates/centaur-api-server/src/routes.rs @@ -38,8 +38,8 @@ use centaur_telemetry::{ record_http_request_finished, record_http_request_started, }; use centaur_workflows::{ - CreateWorkflowRunRequest, WebhookFilter, WorkflowRuntime, WorkflowWebhookAuth, - WorkflowWebhookSpec, WorkflowWebhookTriggerKey, + ApproveActionProposalRequest, CreateWorkflowRunRequest, WebhookFilter, WorkflowRuntime, + WorkflowWebhookAuth, WorkflowWebhookSpec, WorkflowWebhookTriggerKey, }; use futures_util::{Stream, StreamExt}; use hmac::{Hmac, KeyInit, Mac}; @@ -271,6 +271,10 @@ pub fn build_router_with_app_state(state: AppState) -> Router { post(cancel_workflow_run), ) .route("/api/workflows/events", post(emit_workflow_event)) + .route( + "/api/workflows/proposals/{fingerprint}/approve", + post(approve_workflow_proposal), + ) .route( "/api/admin/slack/archive-imports", get(list_slack_archive_imports).post(presign_slack_archive_import), @@ -552,6 +556,9 @@ fn route_access(method: &Method, route: &str) -> Option { capability(Capability::WorkflowsWrite) } (&Method::POST, "/api/workflows/events") => capability(Capability::WorkflowsEvents), + (&Method::POST, "/api/workflows/proposals/{fingerprint}/approve") => { + capability(Capability::WorkflowApprovals) + } (&Method::POST, "/api/admin/slack/archive-imports/{import_id}/download-url") => { Some(RouteAccess::ArchiveDownload) } @@ -621,10 +628,12 @@ fn session_thread_key_from_path(path: &str) -> Option { async fn create_or_get_session( State(state): State, + Extension(caller): Extension, Path(raw_thread_key): Path, - Json(request): Json, + Json(mut request): Json, ) -> Result, ApiError> { let thread_key = ThreadKey::try_from(raw_thread_key)?; + request.metadata = sanitize_session_metadata(&caller, &thread_key, request.metadata)?; let harness_type = request.harness_type; let runtime = state.runtime()?; let on_harness_conflict = match request.on_harness_conflict { @@ -745,10 +754,19 @@ async fn get_session_context( async fn append_messages( State(state): State, + Extension(caller): Extension, Path(raw_thread_key): Path, - Json(request): Json, + Json(mut request): Json, ) -> Result, ApiError> { let thread_key = ThreadKey::try_from(raw_thread_key)?; + for message in &mut request.messages { + message.metadata = sanitize_session_metadata( + &caller, + &thread_key, + Some(std::mem::take(&mut message.metadata)), + )? + .unwrap_or_else(|| json!({})); + } let message_ids = state .runtime()? .append_messages(&thread_key, &request.messages) @@ -766,7 +784,7 @@ async fn execute_session( Json(request): Json, ) -> Result, ApiError> { let thread_key = ThreadKey::try_from(raw_thread_key)?; - let metadata = sanitize_execute_metadata(caller.class(), request.metadata); + let metadata = sanitize_session_metadata(&caller, &thread_key, request.metadata)?; let execution = state .runtime()? .enqueue_session_execution( @@ -788,20 +806,266 @@ async fn execute_session( })) } -/// `requester_principal_foreign_id` is an identity assertion made by the -/// authenticated Console service, not ordinary caller-controlled metadata. -/// Strip it from every other caller class before the execution is persisted so -/// the runtime can safely honor Console requesters on any thread namespace. -fn sanitize_execute_metadata( +/// Strip reserved identity assertions unless the authenticated service owns +/// them, then validate trusted Discord policy metadata against the immutable +/// session key. This applies equally to create, append, and execute so durable +/// audit records cannot claim a different actor or repository scope than the +/// policy that selected the session principal. +fn sanitize_session_metadata( + caller: &AuthenticatedCaller, + thread_key: &ThreadKey, + metadata: Option, +) -> Result, ApiError> { + sanitize_session_metadata_for(caller.class(), caller.identity(), thread_key, metadata) +} + +fn sanitize_session_metadata_for( caller_class: CallerClass, + caller_identity: &str, + thread_key: &ThreadKey, mut metadata: Option, -) -> Option { +) -> Result, ApiError> { if caller_class != CallerClass::Console && let Some(Value::Object(fields)) = metadata.as_mut() { fields.remove("requester_principal_foreign_id"); } - metadata + let trusted_discord = caller_class == CallerClass::Ingress && caller_identity == "discordbot"; + if !trusted_discord { + if let Some(Value::Object(fields)) = metadata.as_mut() { + for field in DISCORD_POLICY_METADATA_FIELDS { + fields.remove(*field); + } + } + return Ok(metadata); + } + validate_discord_policy_metadata(thread_key, metadata.as_ref())?; + Ok(metadata) +} + +const DISCORD_POLICY_METADATA_FIELDS: &[&str] = &[ + "discord_actor_user_id", + "discord_capability_class", + "discord_channel_id", + "discord_conversation_name", + "discord_guild_id", + "discord_policy_fingerprint", + "discord_policy_role_foreign_ids", + "discord_project_scope", + "discord_repository_scope", + "discord_root_message_id", + "discord_thread_id", +]; + +fn validate_discord_policy_metadata( + thread_key: &ThreadKey, + metadata: Option<&Value>, +) -> Result<(), ApiError> { + let Some(Value::Object(fields)) = metadata else { + return Err(ApiError::BadRequest( + "authenticated Discord ingress requires policy metadata".to_owned(), + )); + }; + let Some(ChatDestination::Discord { + guild_id, + channel_id, + thread_id: Some(thread_id), + }) = thread_key.chat_destination() + else { + return Err(ApiError::BadRequest( + "Discord policy metadata requires a Discord thread session key".to_owned(), + )); + }; + + let actor_id = required_metadata_string(fields, "discord_actor_user_id")?; + let metadata_guild = required_metadata_string(fields, "discord_guild_id")?; + let metadata_channel = required_metadata_string(fields, "discord_channel_id")?; + let metadata_thread = required_metadata_string(fields, "discord_thread_id")?; + let root_message = required_metadata_string(fields, "discord_root_message_id")?; + for (name, value) in [ + ("discord_actor_user_id", actor_id), + ("discord_guild_id", metadata_guild), + ("discord_channel_id", metadata_channel), + ("discord_thread_id", metadata_thread), + ("discord_root_message_id", root_message), + ] { + if !is_discord_snowflake(value) { + return Err(ApiError::BadRequest(format!( + "{name} must be a numeric Discord ID" + ))); + } + } + if let Some(message_id) = fields.get("message_id") { + let message_id = message_id.as_str().unwrap_or_default(); + if !is_discord_snowflake(message_id) { + return Err(ApiError::BadRequest( + "message_id must be a numeric Discord ID".to_owned(), + )); + } + } + if let Some(user_id) = fields.get("user_id") + && user_id.as_str() != Some(actor_id) + { + return Err(ApiError::BadRequest( + "Discord message actor does not match the authenticated policy actor".to_owned(), + )); + } + if metadata_guild != guild_id + || metadata_channel != channel_id + || metadata_thread != thread_id + || root_message != thread_id + { + return Err(ApiError::BadRequest( + "Discord policy scope does not match the immutable session key".to_owned(), + )); + } + + let capability = required_metadata_string(fields, "discord_capability_class")?; + if capability.len() > 64 + || !capability.bytes().enumerate().all(|(index, byte)| { + if index == 0 { + byte.is_ascii_lowercase() + } else { + byte.is_ascii_lowercase() + || byte.is_ascii_digit() + || matches!(byte, b':' | b'_' | b'-') + } + }) + { + return Err(ApiError::BadRequest( + "discord_capability_class is invalid".to_owned(), + )); + } + let fingerprint = required_metadata_string(fields, "discord_policy_fingerprint")?; + if fingerprint.len() != 71 + || !fingerprint.starts_with("sha256:") + || !fingerprint[7..] + .bytes() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) + { + return Err(ApiError::BadRequest( + "discord_policy_fingerprint must be a sha256 fingerprint".to_owned(), + )); + } + let roles = required_metadata_array(fields, "discord_policy_role_foreign_ids")?; + if roles.len() != 1 + || roles.iter().any(|role| { + role.as_str().is_none_or(|role| { + let role = role.trim(); + role.is_empty() + || role.len() > 128 + || !role.bytes().enumerate().all(|(index, byte)| { + if index == 0 { + byte.is_ascii_alphanumeric() + } else { + byte.is_ascii_alphanumeric() + || matches!(byte, b'_' | b'-' | b'.' | b':') + } + }) + }) + }) + { + return Err(ApiError::BadRequest( + "discord_policy_role_foreign_ids must select exactly one role".to_owned(), + )); + } + validate_discord_string_scope(fields, "discord_project_scope", false)?; + validate_discord_string_scope(fields, "discord_repository_scope", true)?; + if let Some(name) = fields.get("discord_conversation_name") { + let valid = name.as_str().map(str::trim).is_some_and(|name| { + !name.is_empty() && name.len() <= 100 && !name.chars().any(char::is_control) + }); + if !valid { + return Err(ApiError::BadRequest( + "discord_conversation_name must be a non-empty string".to_owned(), + )); + } + } + Ok(()) +} + +fn validate_discord_string_scope( + fields: &serde_json::Map, + name: &str, + repositories: bool, +) -> Result<(), ApiError> { + let values = required_metadata_array(fields, name)?; + if values.len() > 64 || (repositories && values.is_empty()) { + return Err(ApiError::BadRequest(format!("{name} has an invalid size"))); + } + let mut unique = BTreeSet::new(); + for value in values { + let Some(value) = value.as_str().map(str::trim) else { + return Err(ApiError::BadRequest(format!( + "{name} entries must be strings" + ))); + }; + if value.is_empty() + || value.len() > 128 + || !value.bytes().enumerate().all(|(index, byte)| { + if index == 0 { + byte.is_ascii_alphanumeric() + } else { + byte.is_ascii_alphanumeric() + || matches!(byte, b'_' | b'-' | b'.' | b':') + || (repositories && byte == b'/') + } + }) + || !unique.insert(value.to_ascii_lowercase()) + { + return Err(ApiError::BadRequest(format!( + "{name} entries must be unique bounded strings" + ))); + } + if repositories { + let mut parts = value.split('/'); + let owner = parts.next().unwrap_or_default(); + let repo = parts.next().unwrap_or_default(); + if owner.is_empty() + || repo.is_empty() + || parts.next().is_some() + || value.contains('*') + || !owner.bytes().all(is_github_name_byte) + || !repo.bytes().all(is_github_name_byte) + { + return Err(ApiError::BadRequest( + "discord_repository_scope entries must be exact owner/repository names" + .to_owned(), + )); + } + } + } + Ok(()) +} + +fn required_metadata_string<'a>( + fields: &'a serde_json::Map, + name: &str, +) -> Result<&'a str, ApiError> { + fields + .get(name) + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .ok_or_else(|| ApiError::BadRequest(format!("{name} is required"))) +} + +fn required_metadata_array<'a>( + fields: &'a serde_json::Map, + name: &str, +) -> Result<&'a Vec, ApiError> { + fields + .get(name) + .and_then(Value::as_array) + .ok_or_else(|| ApiError::BadRequest(format!("{name} must be an array"))) +} + +fn is_discord_snowflake(value: &str) -> bool { + (16..=22).contains(&value.len()) && value.bytes().all(|byte| byte.is_ascii_digit()) +} + +fn is_github_name_byte(byte: u8) -> bool { + byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.') } async fn interrupt_session_execution( @@ -815,7 +1079,7 @@ async fn interrupt_session_execution( .as_deref() .map(str::trim) .filter(|value| !value.is_empty()) - .unwrap_or("Interrupted from Slack"); + .unwrap_or("Interrupted by authenticated ingress"); let outcome = state .runtime()? .interrupt_active_execution(&thread_key, reason) @@ -905,11 +1169,32 @@ fn principal_subject_owns_session(subject: Option<&str>, session_principal: Opti #[cfg(test)] mod session_authorization_tests { use super::{ - CallerClass, principal_subject_owns_session, sanitize_execute_metadata, + CallerClass, principal_subject_owns_session, sanitize_session_metadata_for, thread_key_matches_platform, }; + use centaur_session_core::ThreadKey; use serde_json::json; + fn thread_key(value: &str) -> ThreadKey { + ThreadKey::parse(value).expect("valid test thread key") + } + + fn discord_policy_metadata() -> serde_json::Value { + json!({ + "source": "discordbot", + "discord_actor_user_id": "100000000000000001", + "discord_capability_class": "github:observe", + "discord_channel_id": "300000000000000001", + "discord_guild_id": "200000000000000001", + "discord_policy_fingerprint": format!("sha256:{}", "a".repeat(64)), + "discord_policy_role_foreign_ids": ["discord-observer"], + "discord_project_scope": ["operations"], + "discord_repository_scope": ["508-dev/centaur"], + "discord_root_message_id": "400000000000000001", + "discord_thread_id": "400000000000000001" + }) + } + #[test] fn ingress_scope_covers_every_family_the_bot_mints() { let github = [ @@ -961,7 +1246,13 @@ mod session_authorization_tests { }); assert_eq!( - sanitize_execute_metadata(CallerClass::Console, Some(metadata.clone())), + sanitize_session_metadata_for( + CallerClass::Console, + "console-service", + &thread_key("console:test"), + Some(metadata.clone()) + ) + .unwrap(), Some(metadata.clone()) ); for caller_class in [ @@ -970,11 +1261,77 @@ mod session_authorization_tests { CallerClass::Principal, ] { assert_eq!( - sanitize_execute_metadata(caller_class, Some(metadata.clone())), + sanitize_session_metadata_for( + caller_class, + "not-console", + &thread_key("console:test"), + Some(metadata.clone()) + ) + .unwrap(), Some(json!({ "source": "console" })) ); } } + + #[test] + fn only_authenticated_discord_ingress_may_assert_actor_policy_scope() { + let key = thread_key("discord:200000000000000001:300000000000000001:400000000000000001"); + let metadata = discord_policy_metadata(); + assert_eq!( + sanitize_session_metadata_for( + CallerClass::Ingress, + "discordbot", + &key, + Some(metadata.clone()) + ) + .unwrap(), + Some(metadata.clone()) + ); + + for (class, identity) in [ + (CallerClass::Admin, "admin"), + (CallerClass::Ingress, "slackbot"), + (CallerClass::Principal, "discord-user-spoof"), + ] { + assert_eq!( + sanitize_session_metadata_for(class, identity, &key, Some(metadata.clone())) + .unwrap(), + Some(json!({ "source": "discordbot" })) + ); + } + } + + #[test] + fn authenticated_discord_policy_must_match_the_immutable_thread_scope() { + let key = thread_key("discord:200000000000000001:300000000000000001:400000000000000001"); + let mut metadata = discord_policy_metadata(); + metadata["discord_repository_scope"] = json!(["508-dev/*"]); + assert!( + sanitize_session_metadata_for(CallerClass::Ingress, "discordbot", &key, Some(metadata)) + .is_err() + ); + + let mut metadata = discord_policy_metadata(); + metadata["discord_repository_scope"] = json!(["508-dev/centaur", "508-DEV/CENTAUR"]); + assert!( + sanitize_session_metadata_for(CallerClass::Ingress, "discordbot", &key, Some(metadata)) + .is_err() + ); + + let mut metadata = discord_policy_metadata(); + metadata["discord_thread_id"] = json!("400000000000000099"); + assert!( + sanitize_session_metadata_for(CallerClass::Ingress, "discordbot", &key, Some(metadata)) + .is_err() + ); + + let mut metadata = discord_policy_metadata(); + metadata["user_id"] = json!("100000000000000099"); + assert!( + sanitize_session_metadata_for(CallerClass::Ingress, "discordbot", &key, Some(metadata)) + .is_err() + ); + } } #[derive(Debug, Deserialize)] @@ -2907,6 +3264,24 @@ async fn emit_workflow_event( Ok(Json(json!({ "ok": true }))) } +async fn approve_workflow_proposal( + State(state): State, + Extension(caller): Extension, + Path(fingerprint): Path, + Json(request): Json, +) -> Result, ApiError> { + if caller.class() != CallerClass::Ingress || caller.identity() != "discordbot" { + return Err(ApiError::Forbidden( + "only authenticated Discord ingress may approve workflow proposals".to_owned(), + )); + } + let workflows = workflow_runtime(&state)?; + let approval = workflows + .approve_action_proposal(&fingerprint, request) + .await?; + Ok(Json(serde_json::to_value(approval)?)) +} + async fn invoke_workflow_webhook( State(state): State, Path(slug): Path, diff --git a/services/api-rs/crates/centaur-iron-control/src/error.rs b/services/api-rs/crates/centaur-iron-control/src/error.rs index a611693d1c..58c8df0816 100644 --- a/services/api-rs/crates/centaur-iron-control/src/error.rs +++ b/services/api-rs/crates/centaur-iron-control/src/error.rs @@ -9,6 +9,10 @@ pub enum IronControlError { /// canonical principal. #[error(transparent)] PrincipalDerivation(#[from] PrincipalDerivationError), + /// Trusted ingress policy metadata or its reviewed iron-control role did + /// not satisfy the fail-closed Discord authorization contract. + #[error("Discord policy reconciliation failed: {0}")] + DiscordPolicy(String), /// The HTTP request could not be sent or the response could not be read. #[error("iron-control request to {path} failed: {source}")] Transport { diff --git a/services/api-rs/crates/centaur-iron-control/src/principal.rs b/services/api-rs/crates/centaur-iron-control/src/principal.rs index 61451a45f1..184829c08d 100644 --- a/services/api-rs/crates/centaur-iron-control/src/principal.rs +++ b/services/api-rs/crates/centaur-iron-control/src/principal.rs @@ -1,8 +1,9 @@ //! Derive the iron-control principal a session's proxy should act as. //! //! A principal is the identity that holds roles and owns proxies. For Centaur -//! the principal is the conversation: a Discord **channel** (every thread in it -//! shares one principal), a Linear **issue** (every agent session on it shares +//! the principal is the conversation or verified actor: Discord policy-bound +//! sessions use a **user** principal while legacy sessions without trusted +//! actor metadata retain the channel fallback; a Linear **issue** (every agent session on it shares //! one principal), a Microsoft Teams **channel/conversation** (or **user** for //! a personal/user-scoped run when the acting user is known), or — for Slack — //! a **user** for a 1:1 DM and a **channel** for a multi-party channel/group @@ -31,6 +32,7 @@ use crate::util::{managed_labels, slugify}; const SLACK_DM_KIND: &str = "slack_dm"; const SLACK_CHANNEL_KIND: &str = "slack_channel"; const DISCORD_CHANNEL_KIND: &str = "discord_channel"; +const DISCORD_USER_KIND: &str = "discord_user"; const LINEAR_ISSUE_KIND: &str = "linear_issue"; const TEAMS_USER_KIND: &str = "teams_user"; const TEAMS_CONVERSATION_KIND: &str = "teams_conversation"; @@ -113,18 +115,35 @@ pub fn derive_principal_with_slack_team( .map(str::trim) .filter(|name| !name.is_empty()); - // Discord sessions key on the channel so every thread in a channel shares - // one principal (mirrors the Slack channel model). The thread key is - // ``discord::[:]``; the guild id is folded - // into the key so the same channel id in two guilds never collides. + // Actor-aware Discord sessions key on the verified human, scoped by guild, + // so no role or grant learned from one message can be inherited by another + // participant in the channel. Legacy sessions without trusted actor + // metadata keep the historical channel principal for compatibility; the + // Discord ingress fails closed before creating such a session. if let Some((guild_id, channel_id)) = parse_discord_segments(thread_key) { let mut labels = BTreeMap::new(); labels.insert("discord_guild_id".to_owned(), guild_id.to_owned()); - let scope = format!("{}-", slugify(guild_id)); - let key_id = channel_id.unwrap_or(guild_id); if let Some(channel) = channel_id { labels.insert("discord_channel_id".to_owned(), channel.to_owned()); } + if let Some(user) = actor_user_id.map(str::trim).filter(|user| !user.is_empty()) { + labels.insert("discord_user_id".to_owned(), user.to_owned()); + labels.insert( + "centaur_discord_policy_managed".to_owned(), + "true".to_owned(), + ); + return Ok(PrincipalRef { + foreign_id: format!("discord-user-{}-{}", slugify(guild_id), slugify(user)), + name: format!("Discord User {user} (guild {guild_id})"), + kind: Some(DISCORD_USER_KIND.to_owned()), + slack_user_id: None, + slack_channel_id: None, + slack_team_id: None, + labels, + }); + } + let scope = format!("{}-", slugify(guild_id)); + let key_id = channel_id.unwrap_or(guild_id); return Ok(PrincipalRef { foreign_id: format!("discord-channel-{scope}{}", slugify(key_id)), name: display_name @@ -549,7 +568,8 @@ mod tests { #[test] fn discord_sessions_key_on_the_channel() { - // Two threads in the same channel resolve to one principal. + // Legacy sessions without authenticated actor metadata retain the + // channel principal and cannot inherit a user's policy grants. let thread_a = derive_principal("discord:111:222:333", None, None); let thread_b = derive_principal("discord:111:222:444", None, None); assert_eq!(thread_a.foreign_id, "discord-channel-111-222"); @@ -570,6 +590,30 @@ mod tests { assert!(!thread_a.labels.contains_key("kind")); } + #[test] + fn discord_sessions_with_verified_actors_key_on_the_user() { + let actor_a = derive_principal("discord:111:222:333", Some("777"), Some("general")); + let actor_a_other_thread = derive_principal("discord:111:222:444", Some("777"), None); + let actor_b = derive_principal("discord:111:222:333", Some("888"), None); + + assert_eq!(actor_a.foreign_id, "discord-user-111-777"); + assert_eq!(actor_a.foreign_id, actor_a_other_thread.foreign_id); + assert_ne!(actor_a.foreign_id, actor_b.foreign_id); + assert_eq!(actor_a.name, "Discord User 777 (guild 111)"); + assert_eq!(actor_a.kind.as_deref(), Some("discord_user")); + assert_eq!( + actor_a.labels.get("discord_user_id").map(String::as_str), + Some("777") + ); + assert_eq!( + actor_a + .labels + .get("centaur_discord_policy_managed") + .map(String::as_str), + Some("true") + ); + } + #[test] fn linear_sessions_key_on_the_issue() { // Two agent sessions on the same issue resolve to one principal. diff --git a/services/api-rs/crates/centaur-iron-control/src/session.rs b/services/api-rs/crates/centaur-iron-control/src/session.rs index 1eb30f8b27..a979c1b62b 100644 --- a/services/api-rs/crates/centaur-iron-control/src/session.rs +++ b/services/api-rs/crates/centaur-iron-control/src/session.rs @@ -7,6 +7,8 @@ //! in console or ``centaur-perms`` remain sticky. The principal is derived from //! the thread key (see [`crate::derive_principal`]). +use std::collections::BTreeSet; + use serde_json::Value; use crate::IronControlClient; @@ -20,6 +22,8 @@ use crate::principal::{ #[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] struct SessionPrincipalMetadata<'a> { actor_user_id: Option<&'a str>, + discord_actor_user_id: Option<&'a str>, + discord_policy_roles: Option<&'a Value>, slack_team_id: Option<&'a str>, slack_user_email: Option<&'a str>, conversation_name: Option<&'a str>, @@ -36,6 +40,10 @@ impl<'a> SessionPrincipalMetadata<'a> { .or_else(|| metadata.get("aad_object_id")) .or_else(|| metadata.get("user_id")) .and_then(Value::as_str), + discord_actor_user_id: metadata + .get("discord_actor_user_id") + .and_then(Value::as_str), + discord_policy_roles: metadata.get("discord_policy_role_foreign_ids"), slack_team_id: metadata.get("slack_team_id").and_then(Value::as_str), slack_user_email: metadata.get("slack_user_email").and_then(Value::as_str), conversation_name: metadata @@ -75,9 +83,15 @@ impl SessionRegistrar { metadata: Option<&Value>, ) -> Result { let metadata = SessionPrincipalMetadata::from_session_metadata(metadata); + let is_discord = thread_key.starts_with("discord:"); + let actor_user_id = if is_discord { + metadata.discord_actor_user_id + } else { + metadata.actor_user_id + }; let principal = derive_principal_with_slack_team( thread_key, - metadata.actor_user_id, + actor_user_id, metadata.slack_team_id, metadata.conversation_name, )?; @@ -99,6 +113,16 @@ impl SessionRegistrar { .upsert_slack_channel_permission(&record.id, &permission) .await?; } + if is_discord + && (metadata.discord_actor_user_id.is_some() || metadata.discord_policy_roles.is_some()) + { + self.reconcile_discord_policy_roles( + &record, + metadata.discord_actor_user_id, + metadata.discord_policy_roles, + ) + .await?; + } Ok(record) } @@ -194,6 +218,117 @@ impl SessionRegistrar { input.labels = labels; Ok(true) } + + /// Replace every role on an actor-scoped Discord principal with the one + /// reviewed policy role asserted by the authenticated Discord ingress. + /// Defaults and stale roles are removed before the desired role is added, + /// so a partial failure can only narrow access. Direct grants are never + /// deleted implicitly; their presence fails session creation for an + /// operator to reconcile explicitly. + async fn reconcile_discord_policy_roles( + &self, + principal: &Principal, + actor_user_id: Option<&str>, + role_value: Option<&Value>, + ) -> Result<()> { + let actor_user_id = actor_user_id + .map(str::trim) + .filter(|value| !value.is_empty()) + .ok_or_else(|| { + IronControlError::DiscordPolicy( + "actor-scoped Discord session is missing discord_actor_user_id".to_owned(), + ) + })?; + let role_foreign_ids = parse_discord_policy_roles(role_value)?; + if principal.labels.get("discord_user_id").map(String::as_str) != Some(actor_user_id) + || principal + .labels + .get("centaur_discord_policy_managed") + .map(String::as_str) + != Some("true") + { + return Err(IronControlError::DiscordPolicy( + "resolved principal is not the expected policy-managed Discord actor".to_owned(), + )); + } + + let direct_grants = self.client.list_principal_grants(&principal.id).await?; + if !direct_grants.is_empty() { + return Err(IronControlError::DiscordPolicy(format!( + "principal {} has direct grants outside the reviewed role bundle", + principal.foreign_id.as_deref().unwrap_or(&principal.id) + ))); + } + + let mut desired_roles = Vec::with_capacity(role_foreign_ids.len()); + for foreign_id in role_foreign_ids { + let role = self.client.get_role(foreign_id).await?; + if role.foreign_id.as_deref() != Some(foreign_id) + || role + .labels + .get("centaur_discord_policy_managed") + .map(String::as_str) + != Some("true") + { + return Err(IronControlError::DiscordPolicy(format!( + "role {foreign_id} is not marked as a reviewed Discord policy role" + ))); + } + desired_roles.push(role); + } + + let desired_ids = desired_roles + .iter() + .map(|role| role.id.as_str()) + .collect::>(); + let current_roles = self.client.list_principal_roles(&principal.id).await?; + for role in ¤t_roles { + if !desired_ids.contains(role.id.as_str()) { + self.client.unassign_role(&principal.id, &role.id).await?; + } + } + let current_ids = current_roles + .iter() + .map(|role| role.id.as_str()) + .collect::>(); + for role in desired_roles { + if !current_ids.contains(role.id.as_str()) { + self.client.assign_role(&principal.id, &role.id).await?; + } + } + Ok(()) + } +} + +fn parse_discord_policy_roles(value: Option<&Value>) -> Result> { + let roles = value.and_then(Value::as_array).ok_or_else(|| { + IronControlError::DiscordPolicy( + "discord_policy_role_foreign_ids must be an array".to_owned(), + ) + })?; + if roles.len() != 1 { + return Err(IronControlError::DiscordPolicy( + "discord_policy_role_foreign_ids must select exactly one role".to_owned(), + )); + } + let mut unique = BTreeSet::new(); + for role in roles { + let role = role + .as_str() + .map(str::trim) + .filter(|value| !value.is_empty() && value.len() <= 128) + .ok_or_else(|| { + IronControlError::DiscordPolicy( + "Discord policy role foreign IDs must be non-empty strings".to_owned(), + ) + })?; + if !unique.insert(role) { + return Err(IronControlError::DiscordPolicy( + "Discord policy role foreign IDs must be unique".to_owned(), + )); + } + } + Ok(unique.into_iter().collect()) } fn eligible_slack_requester_team(metadata: &Value) -> Option<&str> { @@ -305,6 +440,35 @@ mod tests { ); } + #[test] + fn session_principal_metadata_keeps_discord_actor_separate() { + let value = json!({ + "discord_actor_user_id": "discord-user-1", + "user_id": "generic-user-1", + "discord_policy_role_foreign_ids": ["discord-observer"] + }); + let metadata = SessionPrincipalMetadata::from_session_metadata(Some(&value)); + assert_eq!(metadata.discord_actor_user_id, Some("discord-user-1")); + assert_eq!(metadata.actor_user_id, Some("generic-user-1")); + assert_eq!( + parse_discord_policy_roles(metadata.discord_policy_roles).unwrap(), + vec!["discord-observer"] + ); + } + + #[test] + fn discord_policy_roles_require_one_bounded_role() { + for value in [ + None, + Some(json!([])), + Some(json!(["one", "two"])), + Some(json!([""])), + Some(json!([1])), + ] { + assert!(parse_discord_policy_roles(value.as_ref()).is_err()); + } + } + #[test] fn session_principal_metadata_accepts_teams_name() { assert_eq!( @@ -518,6 +682,99 @@ mod tests { server.abort(); } + #[tokio::test] + async fn register_session_reconciles_discord_actor_to_the_exact_reviewed_role() { + let (base_url, requests, server) = spawn_discord_policy_stub(DiscordPolicyStub { + current_stale_role: true, + direct_grant: false, + reviewed_role: true, + }) + .await; + let registrar = SessionRegistrar::new(IronControlClient::new(base_url, "test-key")); + let metadata = discord_policy_metadata(); + + let principal = registrar + .register_session( + "discord:200000000000000001:300000000000000001:400000000000000001", + Some(&metadata), + ) + .await + .unwrap(); + assert_eq!(principal.id, "prn_discord"); + + let requests = requests.lock().unwrap(); + let remove = requests + .iter() + .position(|request| request == "DELETE /api/v1/principals/prn_discord/roles/role_stale") + .expect("stale/default role is removed"); + let assign = requests + .iter() + .position(|request| request == "POST /api/v1/principals/prn_discord/roles") + .expect("reviewed role is assigned"); + assert!( + remove < assign, + "role reconciliation narrows before widening" + ); + server.abort(); + } + + #[tokio::test] + async fn register_session_rejects_discord_principal_direct_grants() { + let (base_url, requests, server) = spawn_discord_policy_stub(DiscordPolicyStub { + current_stale_role: false, + direct_grant: true, + reviewed_role: true, + }) + .await; + let registrar = SessionRegistrar::new(IronControlClient::new(base_url, "test-key")); + + let error = registrar + .register_session( + "discord:200000000000000001:300000000000000001:400000000000000001", + Some(&discord_policy_metadata()), + ) + .await + .unwrap_err(); + assert!(matches!(error, IronControlError::DiscordPolicy(_))); + assert!( + !requests + .lock() + .unwrap() + .iter() + .any(|request| request.contains("/roles")), + "a direct grant blocks before any role mutation" + ); + server.abort(); + } + + #[tokio::test] + async fn register_session_rejects_unreviewed_discord_policy_role() { + let (base_url, requests, server) = spawn_discord_policy_stub(DiscordPolicyStub { + current_stale_role: true, + direct_grant: false, + reviewed_role: false, + }) + .await; + let registrar = SessionRegistrar::new(IronControlClient::new(base_url, "test-key")); + + let error = registrar + .register_session( + "discord:200000000000000001:300000000000000001:400000000000000001", + Some(&discord_policy_metadata()), + ) + .await + .unwrap_err(); + assert!(matches!(error, IronControlError::DiscordPolicy(_))); + assert!( + !requests.lock().unwrap().iter().any(|request| { + request.starts_with("DELETE ") + || request == "POST /api/v1/principals/prn_discord/roles" + }), + "an unreviewed role blocks before any assignment mutation" + ); + server.abort(); + } + #[test] fn slack_email_applies_only_to_user_principals_with_non_blank_email() { let mut user_input = derive_principal("slack:T123:D123:ts", Some("U123"), None) @@ -832,6 +1089,100 @@ mod tests { ); } + fn discord_policy_metadata() -> Value { + json!({ + "discord_actor_user_id": "100000000000000001", + "discord_policy_role_foreign_ids": ["discord-observer"] + }) + } + + #[derive(Clone, Copy)] + struct DiscordPolicyStub { + current_stale_role: bool, + direct_grant: bool, + reviewed_role: bool, + } + + async fn spawn_discord_policy_stub( + config: DiscordPolicyStub, + ) -> (String, Arc>>, tokio::task::JoinHandle<()>) { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let base_url = format!("http://{}", listener.local_addr().unwrap()); + let requests = Arc::new(Mutex::new(Vec::new())); + let seen = requests.clone(); + let handle = tokio::spawn(async move { + loop { + let Ok((mut stream, _)) = listener.accept().await else { + return; + }; + let mut request = Vec::new(); + let mut buf = [0u8; 1024]; + while !request.windows(4).any(|window| window == b"\r\n\r\n") { + match stream.read(&mut buf).await { + Ok(0) | Err(_) => break, + Ok(read) => request.extend_from_slice(&buf[..read]), + } + } + let request = String::from_utf8_lossy(&request); + let first_line = request.lines().next().unwrap_or_default(); + let mut parts = first_line.split_whitespace(); + let method = parts.next().unwrap_or_default(); + let path = parts.next().unwrap_or_default(); + seen.lock().unwrap().push(format!("{method} {path}")); + + let principal = r#"{"data":{"id":"prn_discord","foreign_id":"discord-user-200000000000000001-100000000000000001","name":"Discord User","labels":{"managed-by":"centaur","discord_guild_id":"200000000000000001","discord_channel_id":"300000000000000001","discord_user_id":"100000000000000001","centaur_discord_policy_managed":"true"}}}"#; + let role_labels = if config.reviewed_role { + r#"{"centaur_discord_policy_managed":"true"}"# + } else { + "{}" + }; + let role = format!( + r#"{{"data":{{"id":"role_observer","foreign_id":"discord-observer","name":"Discord Observer","labels":{role_labels}}}}}"# + ); + let grants = if config.direct_grant { + r#"{"data":[{"id":"grant_direct","principal_id":"prn_discord"}]}"# + } else { + r#"{"data":[]}"# + }; + let roles = if config.current_stale_role { + r#"{"data":[{"id":"role_stale","foreign_id":"default-agent","name":"Default Agent","labels":{}}]}"# + } else { + r#"{"data":[]}"# + }; + let (status_line, body) = match (method, path) { + ( + "GET", + "/api/v1/principals/lookup/discord-user-200000000000000001-100000000000000001", + ) => ("404 Not Found", r#"{"error":"not found"}"#.to_owned()), + ( + "PUT", + "/api/v1/principals/discord-user-200000000000000001-100000000000000001", + ) => ("200 OK", principal.to_owned()), + ("GET", "/api/v1/principals/prn_discord/grants?page=1&limit=100") => { + ("200 OK", grants.to_owned()) + } + ("GET", "/api/v1/roles/lookup/discord-observer") => ("200 OK", role), + ("GET", "/api/v1/principals/prn_discord/roles") => ("200 OK", roles.to_owned()), + ("DELETE", "/api/v1/principals/prn_discord/roles/role_stale") + | ("POST", "/api/v1/principals/prn_discord/roles") => { + ("200 OK", r#"{"data":{"ok":true}}"#.to_owned()) + } + _ => ( + "500 Internal Server Error", + r#"{"error":"unexpected"}"#.to_owned(), + ), + }; + let response = format!( + "HTTP/1.1 {status_line}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", + body.len(), + ); + let _ = stream.write_all(response.as_bytes()).await; + let _ = stream.shutdown().await; + } + }); + (base_url, requests, handle) + } + async fn spawn_iron_control_stub( principal_exists: bool, ) -> (String, Arc>>, tokio::task::JoinHandle<()>) { diff --git a/services/api-rs/crates/centaur-session-sqlx/migrations/0054_workflow_action_proposals.sql b/services/api-rs/crates/centaur-session-sqlx/migrations/0054_workflow_action_proposals.sql new file mode 100644 index 0000000000..c6c5e22cdc --- /dev/null +++ b/services/api-rs/crates/centaur-session-sqlx/migrations/0054_workflow_action_proposals.sql @@ -0,0 +1,73 @@ +create table workflow_action_proposals ( + fingerprint text primary key, + proposal jsonb not null, + action_workflow text not null, + observer_workflow text not null, + observer_task_id text not null, + observer_run_id text not null, + expires_at timestamptz not null, + consumed_at timestamptz, + approved_by_actor_id text, + approved_message_id text, + approved_guild_id text, + approved_channel_id text, + approved_thread_id text, + approved_root_message_id text, + approved_policy_fingerprint text, + approved_capability_class text, + approved_principal_role text, + approved_repository_scope jsonb, + action_task_id text, + action_run_id text, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now(), + constraint workflow_action_proposals_fingerprint_check + check (fingerprint ~ '^sha256:[0-9a-f]{64}$'), + constraint workflow_action_proposals_consumption_check + check ((consumed_at is null + and approved_by_actor_id is null + and approved_message_id is null + and approved_guild_id is null + and approved_channel_id is null + and approved_thread_id is null + and approved_root_message_id is null + and approved_policy_fingerprint is null + and approved_capability_class is null + and approved_principal_role is null + and approved_repository_scope is null + and action_task_id is null + and action_run_id is null) + or (consumed_at is not null + and approved_by_actor_id is not null + and approved_message_id is not null + and approved_guild_id is not null + and approved_channel_id is not null + and approved_thread_id is not null + and approved_root_message_id is not null + and approved_policy_fingerprint is not null + and approved_capability_class is not null + and approved_principal_role is not null + and approved_repository_scope is not null + and action_task_id is not null + and action_run_id is not null)) +); + +create index workflow_action_proposals_pending_idx + on workflow_action_proposals (expires_at) + where consumed_at is null; + +create table workflow_semantic_notification_states ( + scope text primary key, + semantic_fingerprint text, + state_class text not null, + active boolean not null, + last_workflow_run_id text not null, + last_notified_at timestamptz, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now(), + constraint workflow_semantic_notification_fingerprint_check + check (semantic_fingerprint is null or semantic_fingerprint ~ '^sha256:[0-9a-f]{64}$') +); + +revoke all on workflow_action_proposals from public; +revoke all on workflow_semantic_notification_states from public; diff --git a/services/api-rs/crates/centaur-workflows/Cargo.toml b/services/api-rs/crates/centaur-workflows/Cargo.toml index 4d4361f4e4..36edba1e07 100644 --- a/services/api-rs/crates/centaur-workflows/Cargo.toml +++ b/services/api-rs/crates/centaur-workflows/Cargo.toml @@ -17,9 +17,11 @@ chrono.workspace = true chrono-tz.workspace = true cron.workspace = true futures-util.workspace = true +hex.workspace = true reqwest.workspace = true serde.workspace = true serde_json.workspace = true +sha2.workspace = true sqlx.workspace = true thiserror.workspace = true time.workspace = true diff --git a/services/api-rs/crates/centaur-workflows/src/action_proposals.rs b/services/api-rs/crates/centaur-workflows/src/action_proposals.rs new file mode 100644 index 0000000000..50b4f80036 --- /dev/null +++ b/services/api-rs/crates/centaur-workflows/src/action_proposals.rs @@ -0,0 +1,908 @@ +use std::{ + collections::{BTreeMap, BTreeSet}, + env, +}; + +use absurd::Client; +use serde::{Deserialize, Serialize}; +use serde_json::{Value, json}; +use sha2::{Digest, Sha256}; +use sqlx::Row; +use time::OffsetDateTime; + +use super::{CreateWorkflowRunRequest, WorkflowRuntime, WorkflowRuntimeError}; + +const MIN_PROPOSAL_TTL_SECONDS: i64 = 5 * 60; +const MAX_PROPOSAL_TTL_SECONDS: i64 = 30 * 24 * 60 * 60; +const MAX_PARAMETERS_BYTES: usize = 16 * 1024; +const MAX_PARAMETER_DEPTH: usize = 8; +const APPROVAL_ROLE_ALLOWLIST_ENV: &str = "DISCORDBOT_APPROVAL_ROLE_ALLOWLIST"; + +#[derive(Clone, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)] +#[serde(deny_unknown_fields)] +pub struct ProposalEvidence { + pub content_digest: String, + pub source_id: String, + pub source_type: String, +} + +#[derive(Clone, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)] +#[serde(deny_unknown_fields)] +pub struct ProposalValidation { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub evidence_digest: Option, + pub name: String, + pub status: String, +} + +#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +pub struct ActionProposal { + pub action_type: String, + pub action_workflow: String, + pub base_ref: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub head_ref: Option, + #[serde(default)] + pub parameters: Value, + pub repository: String, + #[serde(default)] + pub source_ids: BTreeMap, + #[serde(default)] + pub evidence: Vec, + pub validations: Vec, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct PutActionProposalRequest { + pub proposal: ActionProposal, + pub expires_in_seconds: i64, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct ActionProposalState { + pub action_run_id: Option, + pub action_task_id: Option, + pub action_workflow: String, + pub created: bool, + pub expires_at: OffsetDateTime, + pub fingerprint: String, + pub status: String, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct ApproveActionProposalRequest { + pub actor_id: String, + pub capability_class: String, + pub channel_id: String, + pub guild_id: String, + pub message_id: String, + pub policy_fingerprint: String, + pub principal_role: String, + pub repository_scope: Vec, + pub root_message_id: String, + pub thread_id: String, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct ApproveActionProposalResponse { + pub action_run_id: String, + pub action_task_id: String, + pub action_workflow: String, + pub console_url: Option, + pub created: bool, + pub fingerprint: String, + pub ok: bool, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct NotificationTransitionRequest { + pub scope: String, + pub semantic_fingerprint: Option, + pub state_class: String, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct NotificationTransitionResponse { + pub notify: bool, + pub resolution: bool, + pub state_persisted: bool, +} + +impl ActionProposal { + pub fn normalize_and_fingerprint(mut self) -> Result<(Self, String), WorkflowRuntimeError> { + self.action_type = bounded_identifier("action_type", &self.action_type, 96)?; + self.action_workflow = bounded_identifier("action_workflow", &self.action_workflow, 128)?; + self.repository = exact_repository(&self.repository)?; + self.base_ref = bounded_string("base_ref", &self.base_ref, 128)?; + self.head_ref = self + .head_ref + .as_deref() + .map(|value| bounded_string("head_ref", value, 128)) + .transpose()?; + if self.action_type.starts_with("github:") { + self.base_ref = immutable_git_object_id("base_ref", &self.base_ref)?; + self.head_ref = self + .head_ref + .as_deref() + .map(|value| immutable_git_object_id("head_ref", value)) + .transpose()?; + } + if self.source_ids.len() > 16 { + return Err(WorkflowRuntimeError::BadRequest( + "proposal source_ids must contain at most 16 entries".to_owned(), + )); + } + self.source_ids = self + .source_ids + .into_iter() + .map(|(key, value)| { + Ok(( + bounded_identifier("source id name", &key, 64)?, + bounded_string("source id", &value, 256)?, + )) + }) + .collect::>()?; + if self.evidence.len() > 32 { + return Err(WorkflowRuntimeError::BadRequest( + "proposal evidence must contain at most 32 entries".to_owned(), + )); + } + for evidence in &mut self.evidence { + evidence.source_type = + bounded_identifier("evidence source_type", &evidence.source_type, 64)?; + evidence.source_id = bounded_string("evidence source_id", &evidence.source_id, 256)?; + evidence.content_digest = + sha256_fingerprint("evidence content_digest", &evidence.content_digest)?; + } + self.evidence.sort(); + self.evidence.dedup(); + if self.validations.is_empty() || self.validations.len() > 16 { + return Err(WorkflowRuntimeError::BadRequest( + "proposal validations must contain between 1 and 16 entries".to_owned(), + )); + } + for validation in &mut self.validations { + validation.name = bounded_identifier("validation name", &validation.name, 64)?; + if !matches!(validation.status.as_str(), "passed" | "failed" | "skipped") { + return Err(WorkflowRuntimeError::BadRequest( + "validation status must be passed, failed, or skipped".to_owned(), + )); + } + validation.evidence_digest = validation + .evidence_digest + .as_deref() + .map(|value| sha256_fingerprint("validation evidence_digest", value)) + .transpose()?; + } + self.validations + .sort_by(|left, right| left.name.cmp(&right.name)); + if self + .validations + .windows(2) + .any(|pair| pair[0].name == pair[1].name) + { + return Err(WorkflowRuntimeError::BadRequest( + "proposal validation names must be unique".to_owned(), + )); + } + validate_parameter_value(&self.parameters, 0)?; + let canonical = serde_json::to_vec(&self)?; + if canonical.len() > MAX_PARAMETERS_BYTES * 2 { + return Err(WorkflowRuntimeError::BadRequest( + "canonical proposal is too large".to_owned(), + )); + } + let fingerprint = format!("sha256:{}", hex::encode(Sha256::digest(&canonical))); + Ok((self, fingerprint)) + } + + fn is_approvable(&self) -> bool { + self.validations + .iter() + .all(|validation| validation.status != "failed") + } +} + +pub async fn put_action_proposal( + client: &Client, + request: PutActionProposalRequest, + observer_workflow: &str, + observer_task_id: &str, + observer_run_id: &str, +) -> Result { + if !(MIN_PROPOSAL_TTL_SECONDS..=MAX_PROPOSAL_TTL_SECONDS).contains(&request.expires_in_seconds) + { + return Err(WorkflowRuntimeError::BadRequest(format!( + "proposal expires_in_seconds must be between {MIN_PROPOSAL_TTL_SECONDS} and {MAX_PROPOSAL_TTL_SECONDS}" + ))); + } + let (proposal, fingerprint) = request.proposal.normalize_and_fingerprint()?; + let proposal_json = serde_json::to_value(&proposal)?; + let expires_at = + OffsetDateTime::now_utc() + time::Duration::seconds(request.expires_in_seconds); + let inserted = sqlx::query( + "INSERT INTO workflow_action_proposals (\ + fingerprint, proposal, action_workflow, observer_workflow, observer_task_id, \ + observer_run_id, expires_at) VALUES ($1, $2::jsonb, $3, $4, $5, $6, $7) \ + ON CONFLICT (fingerprint) DO NOTHING", + ) + .bind(&fingerprint) + .bind(&proposal_json) + .bind(&proposal.action_workflow) + .bind(observer_workflow) + .bind(observer_task_id) + .bind(observer_run_id) + .bind(expires_at) + .execute(client.pool()) + .await? + .rows_affected() + == 1; + + let mut row = proposal_row(client, &fingerprint).await?; + let stored_proposal: Value = row.try_get("proposal")?; + if stored_proposal != proposal_json { + return Err(WorkflowRuntimeError::Internal( + "action proposal fingerprint collision".to_owned(), + )); + } + let mut created = inserted; + let consumed_at: Option = row.try_get("consumed_at")?; + let stored_expires_at: OffsetDateTime = row.try_get("expires_at")?; + if !inserted && consumed_at.is_none() && stored_expires_at <= OffsetDateTime::now_utc() { + let reactivated = sqlx::query( + "UPDATE workflow_action_proposals SET observer_workflow = $2, observer_task_id = $3, \ + observer_run_id = $4, expires_at = $5, updated_at = NOW() \ + WHERE fingerprint = $1 AND consumed_at IS NULL AND expires_at <= NOW()", + ) + .bind(&fingerprint) + .bind(observer_workflow) + .bind(observer_task_id) + .bind(observer_run_id) + .bind(expires_at) + .execute(client.pool()) + .await? + .rows_affected() + == 1; + row = proposal_row(client, &fingerprint).await?; + created = reactivated; + } + action_proposal_state(row, created) +} + +impl WorkflowRuntime { + pub async fn approve_action_proposal( + &self, + fingerprint: &str, + mut request: ApproveActionProposalRequest, + ) -> Result { + validate_approval_request(fingerprint, &request)?; + request.repository_scope = request + .repository_scope + .iter() + .map(|repository| exact_repository(repository)) + .collect::, _>>()?; + request.repository_scope.sort(); + let mut tx = self.inner.client.pool().begin().await?; + let row = sqlx::query( + "SELECT proposal, action_workflow, expires_at, consumed_at, action_task_id, action_run_id \ + FROM workflow_action_proposals WHERE fingerprint = $1 FOR UPDATE", + ) + .bind(fingerprint) + .fetch_optional(&mut *tx) + .await? + .ok_or_else(|| WorkflowRuntimeError::NotFound("action proposal not found".to_owned()))?; + let action_workflow: String = row.try_get("action_workflow")?; + let proposal_value: Value = row.try_get("proposal")?; + let (proposal, computed_fingerprint) = + serde_json::from_value::(proposal_value.clone())? + .normalize_and_fingerprint()?; + if computed_fingerprint != fingerprint { + return Err(WorkflowRuntimeError::Internal( + "stored action proposal fingerprint is invalid".to_owned(), + )); + } + if !proposal.is_approvable() { + return Err(WorkflowRuntimeError::BadRequest( + "action proposal has a failed validation".to_owned(), + )); + } + if !request + .repository_scope + .iter() + .any(|repository| repository.eq_ignore_ascii_case(&proposal.repository)) + { + return Err(WorkflowRuntimeError::BadRequest( + "approver policy does not include the proposal repository".to_owned(), + )); + } + if let (Some(action_task_id), Some(action_run_id)) = ( + row.try_get::, _>("action_task_id")?, + row.try_get::, _>("action_run_id")?, + ) { + tx.commit().await?; + return Ok(approval_response( + fingerprint, + &action_workflow, + action_task_id, + action_run_id, + false, + )); + } + let expires_at: OffsetDateTime = row.try_get("expires_at")?; + if expires_at <= OffsetDateTime::now_utc() { + return Err(WorkflowRuntimeError::BadRequest( + "action proposal expired; run a fresh observation".to_owned(), + )); + } + let run = self + .create_run(CreateWorkflowRunRequest { + workflow_name: action_workflow.clone(), + input: json!({ + "approval": { + "actor_id": request.actor_id, + "capability_class": request.capability_class, + "channel_id": request.channel_id, + "guild_id": request.guild_id, + "message_id": request.message_id, + "policy_fingerprint": request.policy_fingerprint, + "principal_role": request.principal_role, + "proposal_fingerprint": fingerprint, + "repository_scope": request.repository_scope, + "root_message_id": request.root_message_id, + "thread_id": request.thread_id, + }, + "proposal": proposal_value, + }), + idempotency_key: Some(format!("approved-proposal:{fingerprint}")), + harness_type: None, + max_attempts: Some(3), + }) + .await?; + sqlx::query( + "UPDATE workflow_action_proposals SET consumed_at = NOW(), approved_by_actor_id = $2, \ + approved_message_id = $3, approved_guild_id = $4, approved_channel_id = $5, \ + approved_thread_id = $6, approved_root_message_id = $7, \ + approved_policy_fingerprint = $8, approved_capability_class = $9, \ + approved_principal_role = $10, approved_repository_scope = $11::jsonb, \ + action_task_id = $12, action_run_id = $13, updated_at = NOW() \ + WHERE fingerprint = $1 AND consumed_at IS NULL", + ) + .bind(fingerprint) + .bind(&request.actor_id) + .bind(&request.message_id) + .bind(&request.guild_id) + .bind(&request.channel_id) + .bind(&request.thread_id) + .bind(&request.root_message_id) + .bind(&request.policy_fingerprint) + .bind(&request.capability_class) + .bind(&request.principal_role) + .bind(serde_json::to_value(&request.repository_scope)?) + .bind(&run.task_id) + .bind(&run.run_id) + .execute(&mut *tx) + .await?; + tx.commit().await?; + Ok(approval_response( + fingerprint, + &action_workflow, + run.task_id, + run.run_id, + run.created, + )) + } +} + +pub async fn transition_notification_state( + client: &Client, + request: NotificationTransitionRequest, + workflow_run_id: &str, +) -> Result { + let request = normalize_notification_request(request)?; + match try_transition_notification_state(client, request, workflow_run_id).await { + Ok(response) => Ok(response), + Err(error) => { + tracing::warn!(%error, workflow_run_id, "workflow semantic notification state unavailable"); + Ok(NotificationTransitionResponse { + notify: true, + resolution: false, + state_persisted: false, + }) + } + } +} + +fn normalize_notification_request( + mut request: NotificationTransitionRequest, +) -> Result { + request.scope = bounded_identifier("notification scope", &request.scope, 160)?; + request.state_class = bounded_identifier("notification state_class", &request.state_class, 96)?; + request.semantic_fingerprint = request + .semantic_fingerprint + .as_deref() + .map(|value| sha256_fingerprint("semantic_fingerprint", value)) + .transpose()?; + Ok(request) +} + +async fn try_transition_notification_state( + client: &Client, + request: NotificationTransitionRequest, + workflow_run_id: &str, +) -> Result { + let scope = request.scope; + let state_class = request.state_class; + let active = request.semantic_fingerprint.is_some(); + let mut tx = client.pool().begin().await?; + let previous = sqlx::query( + "SELECT semantic_fingerprint, state_class, active FROM workflow_semantic_notification_states \ + WHERE scope = $1 FOR UPDATE", + ) + .bind(&scope) + .fetch_optional(&mut *tx) + .await?; + let (notify, resolution) = match previous.as_ref() { + None => (active, false), + Some(row) => { + let previous_active: bool = row.try_get("active")?; + let previous_fingerprint: Option = row.try_get("semantic_fingerprint")?; + let previous_class: String = row.try_get("state_class")?; + if !active { + (previous_active, previous_active) + } else { + ( + !previous_active + || previous_fingerprint != request.semantic_fingerprint + || previous_class != state_class, + false, + ) + } + } + }; + sqlx::query( + "INSERT INTO workflow_semantic_notification_states (\ + scope, semantic_fingerprint, state_class, active, last_workflow_run_id, last_notified_at) \ + VALUES ($1, $2, $3, $4, $5, CASE WHEN $6 THEN NOW() END) \ + ON CONFLICT (scope) DO UPDATE SET semantic_fingerprint = EXCLUDED.semantic_fingerprint, \ + state_class = EXCLUDED.state_class, active = EXCLUDED.active, \ + last_workflow_run_id = EXCLUDED.last_workflow_run_id, \ + last_notified_at = CASE WHEN $6 THEN NOW() ELSE workflow_semantic_notification_states.last_notified_at END, \ + updated_at = NOW()", + ) + .bind(&scope) + .bind(&request.semantic_fingerprint) + .bind(&state_class) + .bind(active) + .bind(workflow_run_id) + .bind(notify) + .execute(&mut *tx) + .await?; + tx.commit().await?; + Ok(NotificationTransitionResponse { + notify, + resolution, + state_persisted: true, + }) +} + +async fn proposal_row( + client: &Client, + fingerprint: &str, +) -> Result { + sqlx::query( + "SELECT proposal, action_workflow, expires_at, consumed_at, action_task_id, action_run_id \ + FROM workflow_action_proposals WHERE fingerprint = $1", + ) + .bind(fingerprint) + .fetch_one(client.pool()) + .await + .map_err(WorkflowRuntimeError::from) +} + +fn action_proposal_state( + row: sqlx::postgres::PgRow, + created: bool, +) -> Result { + let consumed_at: Option = row.try_get("consumed_at")?; + let expires_at: OffsetDateTime = row.try_get("expires_at")?; + let status = if consumed_at.is_some() { + "consumed" + } else if expires_at <= OffsetDateTime::now_utc() { + "expired" + } else { + "pending" + }; + let proposal: Value = row.try_get("proposal")?; + let (_, fingerprint) = + serde_json::from_value::(proposal)?.normalize_and_fingerprint()?; + Ok(ActionProposalState { + action_run_id: row.try_get("action_run_id")?, + action_task_id: row.try_get("action_task_id")?, + action_workflow: row.try_get("action_workflow")?, + created, + expires_at, + fingerprint, + status: status.to_owned(), + }) +} + +fn validate_approval_request( + fingerprint: &str, + request: &ApproveActionProposalRequest, +) -> Result<(), WorkflowRuntimeError> { + let allowed_roles = env::var(APPROVAL_ROLE_ALLOWLIST_ENV) + .unwrap_or_default() + .split(|ch: char| ch == ',' || ch.is_whitespace()) + .filter(|role| !role.is_empty()) + .map(str::to_owned) + .collect::>(); + validate_approval_request_with_roles(fingerprint, request, &allowed_roles) +} + +fn validate_approval_request_with_roles( + fingerprint: &str, + request: &ApproveActionProposalRequest, + allowed_roles: &[String], +) -> Result<(), WorkflowRuntimeError> { + sha256_fingerprint("proposal fingerprint", fingerprint)?; + for (name, value) in [ + ("actor_id", request.actor_id.as_str()), + ("channel_id", request.channel_id.as_str()), + ("guild_id", request.guild_id.as_str()), + ("message_id", request.message_id.as_str()), + ("root_message_id", request.root_message_id.as_str()), + ("thread_id", request.thread_id.as_str()), + ] { + if !is_discord_snowflake(value) { + return Err(WorkflowRuntimeError::BadRequest(format!( + "approval {name} must be a numeric Discord ID" + ))); + } + } + if request.root_message_id != request.thread_id { + return Err(WorkflowRuntimeError::BadRequest( + "approval root must match the immutable Discord thread".to_owned(), + )); + } + bounded_identifier("approval capability_class", &request.capability_class, 64)?; + sha256_fingerprint("approval policy_fingerprint", &request.policy_fingerprint)?; + bounded_identifier("approval principal_role", &request.principal_role, 128)?; + if !allowed_roles + .iter() + .any(|role| role == &request.principal_role) + { + return Err(WorkflowRuntimeError::BadRequest( + "Discord role is not permitted to approve workflow proposals".to_owned(), + )); + } + if request.repository_scope.is_empty() || request.repository_scope.len() > 64 { + return Err(WorkflowRuntimeError::BadRequest( + "approval repository_scope has an invalid size".to_owned(), + )); + } + let mut repositories = BTreeSet::new(); + for repository in &request.repository_scope { + let repository = exact_repository(repository)?; + if !repositories.insert(repository) { + return Err(WorkflowRuntimeError::BadRequest( + "approval repository_scope must contain unique repositories".to_owned(), + )); + } + } + Ok(()) +} + +fn approval_response( + fingerprint: &str, + action_workflow: &str, + action_task_id: String, + action_run_id: String, + created: bool, +) -> ApproveActionProposalResponse { + let console_url = env::var("CENTAUR_CONSOLE_PUBLIC_URL") + .ok() + .map(|base| base.trim_end_matches('/').to_owned()) + .filter(|base| !base.is_empty()) + .map(|base| format!("{base}/console/workflows/{action_workflow}")); + ApproveActionProposalResponse { + action_run_id, + action_task_id, + action_workflow: action_workflow.to_owned(), + console_url, + created, + fingerprint: fingerprint.to_owned(), + ok: true, + } +} + +fn validate_parameter_value(value: &Value, depth: usize) -> Result<(), WorkflowRuntimeError> { + if depth > MAX_PARAMETER_DEPTH { + return Err(WorkflowRuntimeError::BadRequest( + "proposal parameters are nested too deeply".to_owned(), + )); + } + match value { + Value::Null | Value::Bool(_) | Value::Number(_) => {} + Value::String(value) if value.len() <= 2_048 => {} + Value::String(_) => { + return Err(WorkflowRuntimeError::BadRequest( + "proposal parameter strings must be at most 2048 bytes".to_owned(), + )); + } + Value::Array(values) if values.len() <= 64 => { + for value in values { + validate_parameter_value(value, depth + 1)?; + } + } + Value::Array(_) => { + return Err(WorkflowRuntimeError::BadRequest( + "proposal parameter arrays must contain at most 64 entries".to_owned(), + )); + } + Value::Object(values) if values.len() <= 64 => { + for (key, value) in values { + bounded_identifier("proposal parameter key", key, 64)?; + validate_parameter_value(value, depth + 1)?; + } + } + Value::Object(_) => { + return Err(WorkflowRuntimeError::BadRequest( + "proposal parameter objects must contain at most 64 entries".to_owned(), + )); + } + } + if serde_json::to_vec(value)?.len() > MAX_PARAMETERS_BYTES { + return Err(WorkflowRuntimeError::BadRequest( + "proposal parameters must be at most 16384 bytes".to_owned(), + )); + } + Ok(()) +} + +fn bounded_identifier( + name: &str, + value: &str, + maximum: usize, +) -> Result { + let value = value.trim(); + if value.is_empty() + || value.len() > maximum + || !value.bytes().enumerate().all(|(index, byte)| { + if index == 0 { + byte.is_ascii_alphanumeric() + } else { + byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-' | b'.' | b':') + } + }) + { + return Err(WorkflowRuntimeError::BadRequest(format!( + "{name} is invalid" + ))); + } + Ok(value.to_owned()) +} + +fn bounded_string(name: &str, value: &str, maximum: usize) -> Result { + let value = value.trim(); + if value.is_empty() || value.len() > maximum || value.chars().any(char::is_control) { + return Err(WorkflowRuntimeError::BadRequest(format!( + "{name} is invalid" + ))); + } + Ok(value.to_owned()) +} + +fn exact_repository(value: &str) -> Result { + let value = value.trim().to_ascii_lowercase(); + let mut parts = value.split('/'); + let owner = parts.next().unwrap_or_default(); + let repository = parts.next().unwrap_or_default(); + let valid = !owner.is_empty() + && !repository.is_empty() + && parts.next().is_none() + && !value.contains('*') + && owner.bytes().all(is_github_name_byte) + && repository.bytes().all(is_github_name_byte); + if !valid { + return Err(WorkflowRuntimeError::BadRequest( + "proposal repository must be an exact owner/repository name".to_owned(), + )); + } + Ok(value) +} + +fn immutable_git_object_id(name: &str, value: &str) -> Result { + let value = value.trim().to_ascii_lowercase(); + if !matches!(value.len(), 40 | 64) + || !value + .bytes() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) + { + return Err(WorkflowRuntimeError::BadRequest(format!( + "GitHub proposal {name} must be an immutable 40- or 64-character object ID" + ))); + } + Ok(value) +} + +fn is_github_name_byte(byte: u8) -> bool { + byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.') +} + +fn sha256_fingerprint(name: &str, value: &str) -> Result { + let value = value.trim().to_ascii_lowercase(); + if value.len() != 71 + || !value.starts_with("sha256:") + || !value[7..] + .bytes() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) + { + return Err(WorkflowRuntimeError::BadRequest(format!( + "{name} must be a sha256 fingerprint" + ))); + } + Ok(value) +} + +fn is_discord_snowflake(value: &str) -> bool { + (16..=22).contains(&value.len()) && value.bytes().all(|byte| byte.is_ascii_digit()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn proposal() -> ActionProposal { + ActionProposal { + action_type: "github:create_improvement_pr".to_owned(), + action_workflow: "execute_approved_improvement".to_owned(), + base_ref: "0123456789abcdef0123456789abcdef01234567".to_owned(), + head_ref: None, + parameters: json!({"source": "weekly_ops_review"}), + repository: "508-dev/508-workflows".to_owned(), + source_ids: BTreeMap::from([ + ("github_run".to_owned(), "123".to_owned()), + ("sentry_issue".to_owned(), "OPS-42".to_owned()), + ]), + evidence: vec![ProposalEvidence { + content_digest: format!("sha256:{}", "a".repeat(64)), + source_id: "OPS-42".to_owned(), + source_type: "sentry".to_owned(), + }], + validations: vec![ProposalValidation { + evidence_digest: Some(format!("sha256:{}", "b".repeat(64))), + name: "source_current".to_owned(), + status: "passed".to_owned(), + }], + } + } + + #[test] + fn proposal_fingerprint_is_canonical_and_excludes_ordering_noise() { + let first = proposal(); + let mut second = proposal(); + second.repository = "508-DEV/508-WORKFLOWS".to_owned(); + second.evidence.reverse(); + + let (first, first_fingerprint) = first.normalize_and_fingerprint().unwrap(); + let (second, second_fingerprint) = second.normalize_and_fingerprint().unwrap(); + + assert_eq!(first.repository, "508-dev/508-workflows"); + assert_eq!(first, second); + assert_eq!(first_fingerprint, second_fingerprint); + } + + #[test] + fn proposal_rejects_unknown_validation_status_and_wildcard_scope() { + let mut invalid_status = proposal(); + invalid_status.validations[0].status = "unknown".to_owned(); + assert!( + invalid_status + .normalize_and_fingerprint() + .unwrap_err() + .to_string() + .contains("passed, failed, or skipped") + ); + + let mut wildcard = proposal(); + wildcard.repository = "508-dev/*".to_owned(); + assert!( + wildcard + .normalize_and_fingerprint() + .unwrap_err() + .to_string() + .contains("exact owner/repository") + ); + } + + #[test] + fn github_proposal_rejects_mutable_refs() { + for field in ["base", "head"] { + let mut mutable = proposal(); + if field == "base" { + mutable.base_ref = "main".to_owned(); + } else { + mutable.head_ref = Some("automation/fix".to_owned()); + } + assert!( + mutable + .normalize_and_fingerprint() + .unwrap_err() + .to_string() + .contains("must be an immutable") + ); + } + } + + #[test] + fn proposal_rejects_conflicting_duplicate_validation_names() { + let mut duplicate = proposal(); + duplicate.validations.push(ProposalValidation { + evidence_digest: None, + name: "source_current".to_owned(), + status: "failed".to_owned(), + }); + assert!( + duplicate + .normalize_and_fingerprint() + .unwrap_err() + .to_string() + .contains("must be unique") + ); + } + + #[test] + fn notification_state_rejects_malformed_semantic_inputs_before_storage() { + assert!( + normalize_notification_request(NotificationTransitionRequest { + scope: "weekly ops with spaces".to_owned(), + semantic_fingerprint: Some(format!("sha256:{}", "a".repeat(64))), + state_class: "proposal_pending".to_owned(), + }) + .is_err() + ); + assert!( + normalize_notification_request(NotificationTransitionRequest { + scope: "weekly_ops_review:automations".to_owned(), + semantic_fingerprint: Some("model-prose-is-not-state".to_owned()), + state_class: "proposal_pending".to_owned(), + }) + .is_err() + ); + } + + #[test] + fn approval_request_requires_exact_discord_and_repository_scope() { + let fingerprint = format!("sha256:{}", "a".repeat(64)); + let mut request = ApproveActionProposalRequest { + actor_id: "100000000000000001".to_owned(), + capability_class: "github:approve".to_owned(), + channel_id: "300000000000000001".to_owned(), + guild_id: "200000000000000001".to_owned(), + message_id: "600000000000000001".to_owned(), + policy_fingerprint: format!("sha256:{}", "b".repeat(64)), + principal_role: "discord-operator".to_owned(), + repository_scope: vec!["508-dev/508-workflows".to_owned()], + root_message_id: "400000000000000001".to_owned(), + thread_id: "400000000000000001".to_owned(), + }; + let allowed_roles = vec!["discord-operator".to_owned()]; + + assert!( + validate_approval_request_with_roles(&fingerprint, &request, &allowed_roles).is_ok() + ); + + request.repository_scope = vec!["508-dev/*".to_owned()]; + assert!( + validate_approval_request_with_roles(&fingerprint, &request, &allowed_roles).is_err() + ); + request.repository_scope = vec!["508-dev/508-workflows".to_owned()]; + request.root_message_id = "400000000000000002".to_owned(); + assert!( + validate_approval_request_with_roles(&fingerprint, &request, &allowed_roles).is_err() + ); + request.root_message_id = request.thread_id.clone(); + assert!(validate_approval_request_with_roles(&fingerprint, &request, &[]).is_err()); + } +} diff --git a/services/api-rs/crates/centaur-workflows/src/lib.rs b/services/api-rs/crates/centaur-workflows/src/lib.rs index c995b920b1..24d8975946 100644 --- a/services/api-rs/crates/centaur-workflows/src/lib.rs +++ b/services/api-rs/crates/centaur-workflows/src/lib.rs @@ -36,6 +36,13 @@ use tokio::{ }; use tracing::{info, warn}; +mod action_proposals; +pub use action_proposals::{ + ActionProposal, ActionProposalState, ApproveActionProposalRequest, + ApproveActionProposalResponse, NotificationTransitionRequest, NotificationTransitionResponse, + ProposalEvidence, ProposalValidation, PutActionProposalRequest, +}; + pub const WORKFLOW_QUEUE: &str = "centaur_workflows"; pub const WORKFLOW_SLACK_LIVE_QUEUE: &str = "centaur_workflows_slack_live"; pub const WORKFLOW_ETL_QUEUE: &str = "centaur_workflows_etl"; @@ -46,6 +53,8 @@ pub const WORKFLOW_SCHEDULE_TASK: &str = "centaur.workflow.schedule_tick"; const PYTHON_HOST_ENV: &str = "PYTHON_WORKFLOW_HOST_PATH"; const PYTHON_HOST_INTERPRETER_ENV: &str = "PYTHON_WORKFLOW_HOST_PYTHON"; const WORKFLOW_TOOL_API_URL_ENV: &str = "WORKFLOW_TOOL_API_URL"; +const DISCORDBOT_INTERNAL_URL_ENV: &str = "DISCORDBOT_INTERNAL_URL"; +const DISCORDBOT_API_KEY_ENV: &str = "DISCORDBOT_API_KEY"; const DEFAULT_AGENT_IDLE_TIMEOUT_MS: u64 = 60_000; const DEFAULT_AGENT_MAX_DURATION_MS: u64 = 30 * 60 * 1_000; const DEFAULT_AGENT_BATCH_CONCURRENCY: usize = 4; @@ -3420,6 +3429,56 @@ async fn handle_python_context_request( Err(error) => Err(error.to_string()), } } + Some("ctx.proposal.put") => { + let request = message + .get("request") + .cloned() + .ok_or_else(|| "ctx.proposal.put requires request".to_owned()) + .and_then(|value| { + serde_json::from_value::(value) + .map_err(|error| error.to_string()) + }); + match request { + Ok(request) => match action_proposals::put_action_proposal( + &workflow_clients.standard, + request, + &input.workflow_name, + ctx.task_id(), + ctx.run_id(), + ) + .await + { + Ok(value) => serde_json::to_value(value).map_err(|error| error.to_string()), + Err(error) => Err(error.to_string()), + }, + Err(error) => Err(error), + } + } + Some("ctx.notification.transition") => { + let request = message + .get("request") + .cloned() + .ok_or_else(|| "ctx.notification.transition requires request".to_owned()) + .and_then(|value| { + serde_json::from_value::(value) + .map_err(|error| error.to_string()) + }); + match request { + Ok(request) => { + match action_proposals::transition_notification_state( + &workflow_clients.standard, + request, + ctx.run_id(), + ) + .await + { + Ok(value) => serde_json::to_value(value).map_err(|error| error.to_string()), + Err(error) => Err(error.to_string()), + } + } + Err(error) => Err(error), + } + } Some("ctx.call_tool") => match call_python_workflow_tool(message).await { Ok(value) => Ok(value), Err(error) => Err(error.to_string()), @@ -3430,6 +3489,10 @@ async fn handle_python_context_request( Err(error) => Err(error.to_string()), } } + Some("ctx.post_to_discord") => match post_python_discord_message(message).await { + Ok(value) => Ok(value), + Err(error) => Err(error.to_string()), + }, other => Err(format!("unsupported context request type {other:?}")), }; Ok(match result { @@ -4124,6 +4187,43 @@ async fn post_python_slack_message( .map_err(WorkflowRuntimeError::from) } +async fn post_python_discord_message(message: &Value) -> Result { + let channel_id = required_python_string(message, "channel_id", "ctx.post_to_discord")?; + let delivery_id = required_python_string(message, "delivery_id", "ctx.post_to_discord")?; + let text = required_python_string(message, "text", "ctx.post_to_discord")?; + let base_url = env::var(DISCORDBOT_INTERNAL_URL_ENV).map_err(|_| { + WorkflowRuntimeError::BadRequest(format!( + "{DISCORDBOT_INTERNAL_URL_ENV} must be set for ctx.post_to_discord" + )) + })?; + let api_key = env::var(DISCORDBOT_API_KEY_ENV).map_err(|_| { + WorkflowRuntimeError::BadRequest(format!( + "{DISCORDBOT_API_KEY_ENV} must be set for ctx.post_to_discord" + )) + })?; + let response = reqwest::Client::new() + .post(format!( + "{}/internal/deliveries", + base_url.trim_end_matches('/') + )) + .bearer_auth(api_key) + .json(&json!({ + "channel_id": channel_id, + "delivery_id": delivery_id, + "text": text, + })) + .send() + .await?; + let status = response.status(); + let body: Value = response.json().await.unwrap_or_else(|_| json!({})); + if !status.is_success() { + return Err(WorkflowRuntimeError::BadRequest(format!( + "ctx.post_to_discord failed with status {status}" + ))); + } + Ok(body) +} + fn python_slack_message_payload( channel: &str, text: &str, diff --git a/services/console/app/controllers/api/v1/broker_credentials_controller.rb b/services/console/app/controllers/api/v1/broker_credentials_controller.rb index 5af28020d0..3c55d054c6 100644 --- a/services/console/app/controllers/api/v1/broker_credentials_controller.rb +++ b/services/console/app/controllers/api/v1/broker_credentials_controller.rb @@ -52,7 +52,7 @@ def assign_and_save!(ref, attrs) :grant, :client_id, :github_installation_id, :early_refresh_slack_seconds, :early_refresh_fraction, :max_refresh_interval_seconds, :refresh_timeout_seconds, - labels: {}, scopes: []) + labels: {}, scopes: [], github_repositories: []) # A PUT upsert by foreign_id sets identity before assignment; a blank body # value must not wipe it. base.delete(:foreign_id) if base[:foreign_id].blank? && ref.foreign_id.present? @@ -67,7 +67,7 @@ def assign_and_save!(ref, attrs) ref.assign_attributes(base) github_app_grant_changed = was_github_app_installation != ref.github_app_installation? github_app_identity_changed = ref.github_app_installation? && - (ref.github_installation_id_changed? || ref.client_id_changed?) + (ref.github_installation_id_changed? || ref.client_id_changed? || ref.github_repositories_changed?) if github_app_grant_changed || github_app_identity_changed reset_refresh_state(ref, discard_access_token: true) end @@ -148,6 +148,7 @@ def record_payload(ref) scopes: ref.scopes, client_id: ref.client_id, github_installation_id: ref.github_installation_id, + github_repositories: ref.github_repositories, token_endpoint_header_names: (ref.token_endpoint_headers || {}).keys, early_refresh_slack_seconds: ref.early_refresh_slack_seconds, early_refresh_fraction: ref.early_refresh_fraction, diff --git a/services/console/app/controllers/console/broker_credentials_controller.rb b/services/console/app/controllers/console/broker_credentials_controller.rb index 4d5935481a..96082fc4e0 100644 --- a/services/console/app/controllers/console/broker_credentials_controller.rb +++ b/services/console/app/controllers/console/broker_credentials_controller.rb @@ -71,9 +71,11 @@ def assign_form(credential) fields[:foreign_id] = fields[:foreign_id].presence was_github_app_installation = credential.github_app_installation? credential.assign_attributes(fields) + credential.github_repositories = github_repository_params github_app_grant_changed = was_github_app_installation != credential.github_app_installation? github_app_identity_changed = credential.github_app_installation? && - (credential.github_installation_id_changed? || credential.client_id_changed?) + (credential.github_installation_id_changed? || credential.client_id_changed? || + credential.github_repositories_changed?) if github_app_grant_changed || github_app_identity_changed reset_refresh_state(credential, discard_access_token: true) end @@ -141,6 +143,10 @@ def scope_params credential_params[:scopes].to_s.split.map(&:strip).reject(&:blank?) end + def github_repository_params + credential_params[:github_repositories].to_s.split.map(&:strip).reject(&:blank?) + end + # Token-endpoint headers use the same key/value row editor as labels # (KvRowParams), but collapse to nil when none are given (the column's # default), where labels stay an empty hash. diff --git a/services/console/app/models/broker_credential.rb b/services/console/app/models/broker_credential.rb index 81786c5f2b..bfdda91491 100644 --- a/services/console/app/models/broker_credential.rb +++ b/services/console/app/models/broker_credential.rb @@ -20,6 +20,8 @@ class BrokerCredential < ApplicationRecord URL_SAFE_FORMAT = /\A[A-Za-z0-9\-._~]+\z/ URL_SAFE_MESSAGE = "must contain only URL-safe characters (A-Z, a-z, 0-9, -, ., _, ~)" + GITHUB_REPOSITORY_FORMAT = /\A[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+\z/ + GITHUB_REPOSITORY_LIMIT = 500 PREQIN_TOKEN_ENDPOINT = Broker::CredentialGrants::PREQIN_TOKEN_ENDPOINT GITHUB_API_ENDPOINT = Broker::CredentialGrants::GITHUB_API_ENDPOINT @@ -96,6 +98,7 @@ class BrokerCredential < ApplicationRecord numericality: { only_integer: true, greater_than: 0 } validate :labels_is_a_hash validate :scopes_is_an_array + validate :github_repositories_valid validate :grant_credentials_present validate :token_endpoint_headers_valid @@ -268,6 +271,27 @@ def scopes_is_an_array errors.add(:scopes, "must be an array of strings") end + def github_repositories_valid + unless github_repositories.is_a?(Array) && github_repositories.all?(String) + errors.add(:github_repositories, "must be an array of owner/repository strings") + return + end + if github_repositories.length > GITHUB_REPOSITORY_LIMIT + errors.add(:github_repositories, "must contain at most #{GITHUB_REPOSITORY_LIMIT} repositories") + end + + normalized = github_repositories.map(&:downcase) + errors.add(:github_repositories, "must not contain duplicates") if normalized.uniq.length != normalized.length + unless github_repositories.all? { |repository| repository.match?(GITHUB_REPOSITORY_FORMAT) } + errors.add(:github_repositories, "must contain exact owner/repository names without wildcards") + end + + owners = github_repositories.filter_map { |repository| repository.split("/", 2).first&.downcase }.uniq + if owners.length > 1 + errors.add(:github_repositories, "must all belong to the same GitHub App installation owner") + end + end + def grant_credentials_present Broker::CredentialGrants.validate(self) end diff --git a/services/console/app/views/console/broker_credentials/_form.html.erb b/services/console/app/views/console/broker_credentials/_form.html.erb index f701131c1a..4c53bc1b8d 100644 --- a/services/console/app/views/console/broker_credentials/_form.html.erb +++ b/services/console/app/views/console/broker_credentials/_form.html.erb @@ -56,6 +56,12 @@ <%= field_error(credential, :scopes) %>

One scope per line. Not used for Preqin.

+
+ + <%= text_area_tag "credential[github_repositories]", Array(credential.github_repositories).join("\n"), id: "credential_github_repositories", rows: 4, class: "form-input #{field_error_class(credential, :github_repositories)}", placeholder: "508-dev/centaur" %> + <%= field_error(credential, :github_repositories) %> +

Optional exact owner/repository allowlist for a GitHub App installation token, one per line. Empty means the installation-selected repository set.

+
@@ -109,7 +115,7 @@
-

Uses the GitHub App Client ID and installation ID above. The Console worker signs a short-lived App JWT with its read-only private-key mount, then exchanges it for an installation token. The private key is never entered or stored in this form.

+

Uses the GitHub App Client ID and installation ID above. The Console worker signs a short-lived App JWT with its read-only private-key mount, then exchanges it for an installation token restricted to the configured GitHub repositories. The private key is never entered or stored in this form.

diff --git a/services/console/app/views/console/credential.html.erb b/services/console/app/views/console/credential.html.erb index bf1784b3f0..9eb6edc1bd 100644 --- a/services/console/app/views/console/credential.html.erb +++ b/services/console/app/views/console/credential.html.erb @@ -64,6 +64,7 @@ [ "Grant", @credential.grant ], ([ "Client ID", @credential.effective_client_id ] if @credential.effective_client_id.present?), ([ "GitHub installation ID", @credential.github_installation_id ] if @credential.github_app_installation?), + ([ "GitHub repositories", Array(@credential.github_repositories).join("\n") ] if @credential.github_app_installation?), [ "Token endpoint", @credential.token_endpoint ], [ "Scopes", Array(@credential.scopes).join("\n") ], ([ "Username", "[redacted]" ] if @credential.grant == "password" && @credential.username.present?), diff --git a/services/console/db/migrate/20260901090000_add_github_repository_scope_to_broker_credentials.rb b/services/console/db/migrate/20260901090000_add_github_repository_scope_to_broker_credentials.rb new file mode 100644 index 0000000000..02190dbb4e --- /dev/null +++ b/services/console/db/migrate/20260901090000_add_github_repository_scope_to_broker_credentials.rb @@ -0,0 +1,9 @@ +class AddGithubRepositoryScopeToBrokerCredentials < ActiveRecord::Migration[8.1] + def change + # Exact owner/repository names requested when minting a GitHub App + # installation token. An empty list preserves installation-wide behavior + # for existing credentials; security-sensitive roles can require a nonempty + # list in their declarative policy and deployment preflight. + add_column :broker_credentials, :github_repositories, :jsonb, null: false, default: [] + end +end diff --git a/services/console/db/schema.rb b/services/console/db/schema.rb index 18b66b73cf..ec0d79616b 100644 --- a/services/console/db/schema.rb +++ b/services/console/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.1].define(version: 2026_08_23_120000) do +ActiveRecord::Schema[8.1].define(version: 2026_09_01_090000) do # These are extensions that must be enabled in order to support this database enable_extension "pg_catalog.plpgsql" enable_extension "pg_search" @@ -59,6 +59,7 @@ t.integer "failure_count", default: 0, null: false t.string "foreign_id" t.string "github_installation_id" + t.jsonb "github_repositories", default: [], null: false t.string "grant", default: "refresh_token", null: false t.jsonb "labels", default: {}, null: false t.datetime "last_refresh" diff --git a/services/console/lib/broker/credential_grants.rb b/services/console/lib/broker/credential_grants.rb index 4bc3885084..c552934cc9 100644 --- a/services/console/lib/broker/credential_grants.rb +++ b/services/console/lib/broker/credential_grants.rb @@ -141,6 +141,7 @@ def refresh_github_app_installation(credential) result = credential.github_app_installation_client.refresh( client_id: credential.effective_client_id, installation_id: credential.github_installation_id, + repositories: credential.github_repositories, timeout: credential.refresh_timeout_seconds ) success(result) diff --git a/services/console/lib/broker/github_app_installation_client.rb b/services/console/lib/broker/github_app_installation_client.rb index 7584a12f39..e15ea63d7a 100644 --- a/services/console/lib/broker/github_app_installation_client.rb +++ b/services/console/lib/broker/github_app_installation_client.rb @@ -28,11 +28,14 @@ def initialize(http_client: nil, http: nil, @clock = clock end - def refresh(client_id:, installation_id:, timeout: Broker::RefreshClient::DEFAULT_TIMEOUT) - validate_inputs!(client_id, installation_id) + def refresh(client_id:, installation_id:, repositories: [], + timeout: Broker::RefreshClient::DEFAULT_TIMEOUT) + validate_inputs!(client_id, installation_id, repositories) signed_jwt = app_jwt(client_id) + request_body = repositories.present? ? { repositories: repositories.map { |name| name.split("/", 2).last } } : nil response = http_client_for(timeout).post( "#{API_ENDPOINT}/app/installations/#{installation_id}/access_tokens", + json: request_body, headers: { "Accept" => "application/vnd.github+json", "Authorization" => "Bearer #{signed_jwt}", @@ -42,7 +45,7 @@ def refresh(client_id:, installation_id:, timeout: Broker::RefreshClient::DEFAUL ) classify_error!(response) unless response.success? - parse_success(response) + parse_success(response, repositories: repositories) rescue Broker::RefreshError raise rescue OpenSSL::PKey::PKeyError, OpenSSL::OpenSSLError, Errno::ENOENT, Errno::EACCES @@ -71,7 +74,7 @@ def http_client_for(timeout) ) end - def validate_inputs!(client_id, installation_id) + def validate_inputs!(client_id, installation_id, repositories) unless client_id.to_s.match?(/\A[A-Za-z][A-Za-z0-9._-]*\z/) raise RefreshError.new("GitHub App client ID is missing or invalid", stage: "configuration", code: "github_app_client_id", retryable: false) @@ -80,6 +83,14 @@ def validate_inputs!(client_id, installation_id) raise RefreshError.new("GitHub App installation ID is invalid", stage: "configuration", code: "github_app_installation_id", retryable: false) end + unless repositories.is_a?(Array) && repositories.length <= BrokerCredential::GITHUB_REPOSITORY_LIMIT && + repositories.all? { |repository| repository.is_a?(String) && + repository.match?(BrokerCredential::GITHUB_REPOSITORY_FORMAT) } && + repositories.map(&:downcase).uniq.length == repositories.length && + repositories.map { |repository| repository.split("/", 2).first.downcase }.uniq.length <= 1 + raise RefreshError.new("GitHub App repository scope is invalid", + stage: "configuration", code: "github_app_repositories", retryable: false) + end if @private_key_path.blank? raise RefreshError.new("GitHub App private key path is not configured", stage: "configuration", code: "github_app_private_key", retryable: true) @@ -122,7 +133,7 @@ def rate_limited?(response) response["retry-after"].present? || response["x-ratelimit-remaining"].to_s == "0" end - def parse_success(response) + def parse_success(response, repositories:) parsed = response.json access_token = parsed.fetch("token") expires_at = Time.iso8601(parsed.fetch("expires_at")) @@ -130,6 +141,8 @@ def parse_success(response) raise KeyError end + verify_repository_scope!(parsed, repositories) if repositories.present? + RefreshClient::Result.new( access_token: access_token, refresh_token: nil, @@ -139,5 +152,16 @@ def parse_success(response) raise RefreshError.new("GitHub App installation token response was invalid", stage: "parse", code: "github_app_response", retryable: true) end + + def verify_repository_scope!(parsed, requested) + returned = parsed.fetch("repositories").map { |repository| repository.fetch("full_name") } + return if returned.map(&:downcase).sort == requested.map(&:downcase).sort + + raise RefreshError.new("GitHub App installation token repository scope did not match the request", + stage: "parse", code: "github_app_repository_scope", retryable: false) + rescue KeyError, NoMethodError, TypeError + raise RefreshError.new("GitHub App installation token repository scope was missing or invalid", + stage: "parse", code: "github_app_repository_scope", retryable: false) + end end end diff --git a/services/console/test/controllers/api/v1/broker_credentials_controller_test.rb b/services/console/test/controllers/api/v1/broker_credentials_controller_test.rb index 0353390762..040c74a227 100644 --- a/services/console/test/controllers/api/v1/broker_credentials_controller_test.rb +++ b/services/console/test/controllers/api/v1/broker_credentials_controller_test.rb @@ -181,7 +181,8 @@ def json_body = JSON.parse(response.body) grant: "github_app_installation", token_endpoint: "https://untrusted.example/token", client_id: "Iv1.0123456789abcdef", - github_installation_id: "12345678" + github_installation_id: "12345678", + github_repositories: [ "508-dev/centaur", "508-dev/centaur-overlay" ] } } @@ -194,9 +195,11 @@ def json_body = JSON.parse(response.body) assert_equal BrokerCredential::GITHUB_API_ENDPOINT, data["token_endpoint"] assert_equal "Iv1.0123456789abcdef", data["client_id"] assert_equal "12345678", data["github_installation_id"] + assert_equal [ "508-dev/centaur", "508-dev/centaur-overlay" ], data["github_repositories"] created = BrokerCredential.find_by_oid(data["id"]) assert_equal "12345678", created.github_installation_id + assert_equal [ "508-dev/centaur", "508-dev/centaur-overlay" ], created.github_repositories assert created.next_attempt_at.present? end @@ -227,6 +230,31 @@ def json_body = JSON.parse(response.body) assert credential.next_attempt_at.present? end + test "updating a GitHub App repository scope discards the wider token" do + credential = BrokerCredential.create!( + foreign_id: "github-app-rescope", + grant: "github_app_installation", + client_id: "Iv1.0123456789abcdef", + github_installation_id: "12345678", + github_repositories: [ "508-dev/centaur", "508-dev/508-infra" ], + access_token: "ghs-wide-token", + expires_at: 30.minutes.from_now, + last_refresh: Time.current, + created_by: users(:acme_admin) + ) + + put api_v1_broker_credential_url(id: credential.oid), params: { + data: { github_repositories: [ "508-dev/centaur" ] } + }.to_json, headers: auth_headers + + assert_response :ok + credential.reload + assert_equal [ "508-dev/centaur" ], credential.github_repositories + assert_nil credential.access_token + assert_nil credential.expires_at + assert_nil credential.last_refresh + end + test "updating a stale GitHub App record locks and discards a concurrently minted token" do credential = BrokerCredential.create!( foreign_id: "github-app-concurrent-rotate", diff --git a/services/console/test/controllers/console/broker_credentials_controller_test.rb b/services/console/test/controllers/console/broker_credentials_controller_test.rb index f6a23d11d3..94c0521fef 100644 --- a/services/console/test/controllers/console/broker_credentials_controller_test.rb +++ b/services/console/test/controllers/console/broker_credentials_controller_test.rb @@ -134,7 +134,8 @@ class BrokerCredentialsControllerTest < ActionDispatch::IntegrationTest credential: { foreign_id: "github-app-installation", name: "GitHub App installation", grant: "github_app_installation", token_endpoint: "https://untrusted.example/token", - client_id: "Iv1.0123456789abcdef", github_installation_id: "12345678" + client_id: "Iv1.0123456789abcdef", github_installation_id: "12345678", + github_repositories: "508-dev/centaur\n508-dev/centaur-overlay" } } end @@ -144,6 +145,7 @@ class BrokerCredentialsControllerTest < ActionDispatch::IntegrationTest assert_equal "github_app_installation", cred.grant assert_equal BrokerCredential::GITHUB_API_ENDPOINT, cred.token_endpoint assert_equal "12345678", cred.github_installation_id + assert_equal [ "508-dev/centaur", "508-dev/centaur-overlay" ], cred.github_repositories assert_nil cred.refresh_token end @@ -214,6 +216,37 @@ class BrokerCredentialsControllerTest < ActionDispatch::IntegrationTest assert cred.next_attempt_at.present? end + test "PATCH GitHub App repository rescope discards the previous token" do + cred = BrokerCredential.create!( + foreign_id: "github-app-console-rescope", + grant: "github_app_installation", + client_id: "Iv1.0123456789abcdef", + github_installation_id: "12345678", + github_repositories: [ "508-dev/centaur", "508-dev/508-infra" ], + access_token: "ghs-wide-token", + expires_at: 30.minutes.from_now, + last_refresh: Time.current, + created_by: @operator + ) + + patch console_broker_credential_url(cred.oid), params: { + credential: { + foreign_id: cred.foreign_id, + grant: "github_app_installation", + client_id: cred.client_id, + github_installation_id: cred.github_installation_id, + github_repositories: "508-dev/centaur" + } + } + + assert_redirected_to console_credential_path(cred.oid) + cred.reload + assert_equal [ "508-dev/centaur" ], cred.github_repositories + assert_nil cred.access_token + assert_nil cred.expires_at + assert_nil cred.last_refresh + end + test "PATCH changing the GitHub App grant in either direction discards the prior token" do exiting = BrokerCredential.create!( foreign_id: "github-app-console-exit", diff --git a/services/console/test/lib/broker/github_app_installation_client_test.rb b/services/console/test/lib/broker/github_app_installation_client_test.rb index 24d9f64bec..e8f6566570 100644 --- a/services/console/test/lib/broker/github_app_installation_client_test.rb +++ b/services/console/test/lib/broker/github_app_installation_client_test.rb @@ -57,6 +57,83 @@ def with_private_key end end + test "mints and verifies a repository-scoped installation token" do + repositories = [ "508-dev/centaur", "508-dev/centaur-overlay" ] + with_private_key do |path, _key| + http = expect_http_call( + status: 201, + body: { + token: "ghs_scoped_token", + expires_at: (NOW + 1.hour).iso8601, + repositories: repositories.reverse.map { |full_name| { full_name: full_name } } + }.to_json + ) do |request| + assert_equal({ "repositories" => %w[centaur centaur-overlay] }, JSON.parse(request[:body])) + assert_equal "application/json", request[:headers]["Content-Type"] + end + + client = GithubAppInstallationClient.new( + http: http, private_key_path: path, clock: -> { NOW } + ) + result = client.refresh( + client_id: CLIENT_ID, + installation_id: INSTALLATION_ID, + repositories: repositories + ) + + http.verify + assert_equal "ghs_scoped_token", result.access_token + end + end + + test "rejects a token whose returned repository scope is missing or wider" do + repositories = [ "508-dev/centaur" ] + responses = [ + { token: "ghs_missing_scope", expires_at: (NOW + 1.hour).iso8601 }, + { + token: "ghs_wide_scope", + expires_at: (NOW + 1.hour).iso8601, + repositories: [ { full_name: "508-dev/centaur" }, { full_name: "508-dev/508-infra" } ] + } + ] + + responses.each do |body| + with_private_key do |path, _key| + http = expect_http_call(status: 201, body: body.to_json) + client = GithubAppInstallationClient.new( + http: http, private_key_path: path, clock: -> { NOW } + ) + + error = assert_raises(RefreshError) do + client.refresh( + client_id: CLIENT_ID, + installation_id: INSTALLATION_ID, + repositories: repositories + ) + end + + http.verify + refute error.retryable? + assert_equal "github_app_repository_scope", error.code + end + end + end + + test "rejects invalid repository scope before calling GitHub" do + client = GithubAppInstallationClient.new(private_key_path: "/unused/key.pem", clock: -> { NOW }) + + error = assert_raises(RefreshError) do + client.refresh( + client_id: CLIENT_ID, + installation_id: INSTALLATION_ID, + repositories: [ "508-dev/*" ] + ) + end + + refute error.retryable? + assert_equal "github_app_repositories", error.code + end + test "retries without calling GitHub while the private key path is missing or unavailable" do [ nil, "/missing/github-app.pem" ].each do |path| client = GithubAppInstallationClient.new(private_key_path: path, clock: -> { NOW }) diff --git a/services/console/test/models/broker_credential_test.rb b/services/console/test/models/broker_credential_test.rb index b5ef31861f..4d93770341 100644 --- a/services/console/test/models/broker_credential_test.rb +++ b/services/console/test/models/broker_credential_test.rb @@ -126,6 +126,28 @@ def create_credential(**kw) assert bc.valid?, bc.errors.full_messages.to_sentence end + test "GitHub App repository scope is exact, unique, and installation-owner bounded" do + bc = build_credential( + grant: "github_app_installation", + client_id: "Iv1.0123456789abcdef", + github_installation_id: "12345678", + refresh_token: nil + ) + + bc.github_repositories = [ "508-dev/centaur", "508-dev/centaur-overlay" ] + assert bc.valid?, bc.errors.full_messages.to_sentence + + [ + [ "508-dev/*" ], + [ "508-dev/centaur", "508-DEV/CENTAUR" ], + [ "508-dev/centaur", "someone-else/repository" ] + ].each do |scope| + bc.github_repositories = scope + refute bc.valid?, "expected #{scope.inspect} to be rejected" + assert bc.errors[:github_repositories].any? + end + end + # --- oauth_app provenance (flow-minted credentials) ----------------------- def build_app(**overrides) @@ -396,6 +418,7 @@ def build_app(**overrides) client.expect(:refresh, result(access_token: "ghs-token", refresh_token: nil, expires_in: 3600)) do |**request| assert_equal "Iv1.0123456789abcdef", request[:client_id] assert_equal "12345678", request[:installation_id] + assert_equal [ "508-dev/centaur" ], request[:repositories] assert_equal 30, request[:timeout] true end @@ -403,6 +426,7 @@ def build_app(**overrides) grant: "github_app_installation", client_id: "Iv1.0123456789abcdef", github_installation_id: "12345678", + github_repositories: [ "508-dev/centaur" ], refresh_token: nil ) bc.github_app_installation_client = client diff --git a/services/discordbot/README.md b/services/discordbot/README.md index eed85f0b85..7aa69bb2d4 100644 --- a/services/discordbot/README.md +++ b/services/discordbot/README.md @@ -2,8 +2,9 @@ Discord chat ingress for the Centaur agent. Mirrors `slackbotv2` (streamed, session-backed replies to `@`-mentions) using Vercel's Chat SDK Discord adapter. The session logic is a -deliberate clone of `services/slackbotv2` kept in sync manually (there is no shared package); -the Rust `api-rs` control plane is unchanged (`discord:…` thread keys flow through identically). +deliberate clone of `services/slackbotv2` kept in sync manually (there is no shared package). +Authenticated actor policy is carried through `api-rs` and reconciled onto a user-scoped +iron-control principal; it is never attached to the channel principal. ## Behavior @@ -11,7 +12,11 @@ the Rust `api-rs` control plane is unchanged (`discord:…` thread keys flow thr bot streams the answer inside it, and the thread is renamed to the message text. The session is keyed by the new thread (`discord:{guild}:{channel}:{threadId}`). - **`@`-mention inside an existing thread** → the bot answers in that thread. -- **Follow-ups inside an active thread** append to the same session without a re-mention. +- **Follow-ups inside an authorized thread** append to the same session without a re-mention + only for the original actor, while the root TTL and their current role policy remain valid. +- **`@centaur stop`** interrupts only the active execution attached to that authorized thread. +- **`@centaur approve sha256:…`** atomically consumes one exact, unexpired workflow proposal + when the actor's reviewed role is permitted to approve it. It does not create a coding session. - **Safe public output**: a run instantly reacts 👀 on the triggering message. The **final answer** streams into a separate message created when its first text arrives, so it lands at the bottom of the thread even when users chime in mid-run. On settle the 👀 flips to ✅ (or ❌). @@ -23,7 +28,10 @@ the Rust `api-rs` control plane is unchanged (`discord:…` thread keys flow thr Discord delivers normal messages over a **Gateway WebSocket** (outbound), not HTTP webhooks. The bot opens a single long-lived Gateway connection in "direct mode" (`startGatewayListener` with a large duration; discord.js maintains the session with native RESUME). There is **no public -ingress** — only a `GET /health` endpoint that reflects the Gateway connection state. +event ingress** — only `GET /health` plus an authenticated internal workflow-delivery endpoint. +The ready Gateway identity must exactly match `DISCORD_APPLICATION_ID`; forwarded JSON from the +Chat SDK's in-process emulator is not accepted in production. Each message ID is durably claimed +and audited before thread, session, workflow, or sandbox side effects. > ⚠️ **Run exactly one replica.** Two pods on the same bot token open two Gateway sessions and > every message is handled twice. Deploy with `replicas: 1` + `strategy: Recreate`, never autoscale. @@ -36,16 +44,18 @@ ingress** — only a `GET /health` endpoint that reflects the Gateway connection | Var | Required | Notes | |-----|----------|-------| | `DISCORD_BOT_TOKEN` | ✅ | Bot token (account-level credential — keep secret). | -| `DISCORD_PUBLIC_KEY` | ✅ | Ed25519 public key (used by the adapter for any HTTP interactions). | +| `DISCORD_PUBLIC_KEY` | ✅ | Ed25519 public key required by the adapter constructor. Centaur does not expose Discord HTTP interactions. | | `DISCORD_APPLICATION_ID` | ✅ | Doubles as the bot user id for mention detection. | | `DISCORDBOT_GUILD_ALLOWLIST` | ✅ to do anything | Comma/space-separated guild IDs. **Fail-closed when empty.** | | `DISCORDBOT_CHANNEL_ALLOWLIST` | ✅ to do anything | Comma/space-separated parent channel IDs. Messages in other channels and their threads are ignored before a thread/session is created. | -| `DISCORDBOT_TRIGGER_ROLE_ALLOWLIST` | ✅ to do anything | Comma/space-separated immutable Discord role IDs. A human must currently hold at least one on every message; role names are never authorization inputs. | -| `DISCORDBOT_API_KEY` | – | Bearer to api-rs. Use a dedicated key, not the Slack one. | +| `DISCORDBOT_ROLE_BINDINGS_JSON` | ✅ to do anything | Non-empty reviewed JSON array mapping immutable numeric role IDs to one capability class, policy-managed principal role, exact repository/project scopes, explicit priority, and optional `can_approve`. Unknown or ambiguous combinations fail closed. | +| `DISCORDBOT_API_KEY` | ✅ | Dedicated bearer used for Discordbot → api-rs and api-rs → Discordbot internal calls. Do not reuse another ingress key. | | `CENTAUR_API_URL` | – | api-rs base URL (default `http://127.0.0.1:8080`). | | `DISCORDBOT_DATABASE_URL` / `DATABASE_URL` / `POSTGRES_URL` | ✅ | Thread-state store. The bot refuses to boot without one (no silent localhost fallback). | -| `DISCORDBOT_TRIGGER_BOT_ALLOWLIST` | – | Comma/space-separated bot/webhook author IDs whose messages may enter sessions (e.g. a Sentry webhook). Empty (default) ⇒ all bot messages are ignored. Use the ID the message is authored as: the bot's user id, or the webhook id for webhook integrations. | | `DISCORDBOT_MAX_CONCURRENT_EXECUTIONS_PER_GUILD` | – | In-flight execution cap per guild (default 3). Over the cap, the triggering message gets a 🚦 reaction and is kept as context only. | +| `DISCORDBOT_CONTINUATION_TTL_MS` | – | Maximum lifetime of an authorized root interaction (default 24h). A new authorized mention is required after expiry. | +| `DISCORDBOT_INGRESS_MAX_EVENT_AGE_MS` | – | Maximum accepted Gateway event age (default 5m). Future/stale events fail closed. | +| `DISCORDBOT_INGRESS_DELIVERY_TTL_MS` | – | Durable inbound delivery/audit dedupe retention (default 7d). | | `DISCORDBOT_ACTIVE_EXECUTION_TTL_MS` | – | Staleness TTL for the per-thread active-execution flag (default 30 min) — unwedges threads after a crash mid-handoff. | | `DISCORDBOT_ANSWER_EDIT_INTERVAL_MS` | – | Edit cadence for the streamed answer message (default 1500 ms, clamped to ≥1500 to respect Discord rate limits). | | `DISCORD_MENTION_ROLE_IDS` | – | Role mentions that also trigger the bot. | @@ -56,11 +66,32 @@ ingress** — only a `GET /health` endpoint that reflects the Gateway connection | `PORT` | – | Health server port (default 3001). | | `SESSION_IDLE_TIMEOUT_MS` / `SESSION_MAX_DURATION_MS` | – | Forwarded to api-rs execute. | -DMs are denied by the guild allowlist: the adapter does request the DirectMessages intent, but a -DM has no guild, so the fail-closed allowlist check rejects it. Guild, parent channel, and role -checks happen before a channel mention creates a public thread and again before every follow-up is -forwarded. Removing a role therefore blocks the member's next message, including replies inside an -already-active thread. +DM, private-message, bot, self, and webhook-authored events are denied. The adapter requests only +Guilds, GuildMessages, and Message Content intents. Guild, parent channel, current role policy, +actor, thread, and TTL checks happen before a channel mention creates a public thread and again +before every follow-up. Removing a role therefore blocks the member's next message, including +replies inside an already-active thread. Every allow/deny decision is logged with a stable reason +code and immutable IDs, never message content. + +Example role policy: + +```json +[ + { + "role_id": "100000000000000001", + "capability_class": "github:observe", + "principal_role": "discord-observer", + "can_approve": false, + "priority": 10, + "repository_scope": ["example-org/example-repo"], + "project_scope": [] + } +] +``` + +Multiple held roles do not form an implicit union: the highest explicit priority wins, while +equal-priority non-identical bundles are denied. Repository wildcards and mutable role names are +invalid configuration. ## Discord application setup @@ -74,9 +105,9 @@ already-active thread. _View Channels_, _Send Messages_, _Send Messages in Threads_, **Create Public Threads**, _Embed Links_, _Read Message History_, _Add Reactions_ (the 👀/✅ run-status indicator). 5. Set `DISCORDBOT_GUILD_ALLOWLIST`, `DISCORDBOT_CHANNEL_ALLOWLIST`, and - `DISCORDBOT_TRIGGER_ROLE_ALLOWLIST` to numeric IDs. The bot is **inert** for human messages until + `DISCORDBOT_ROLE_BINDINGS_JSON` with numeric IDs. The bot is **inert** for human messages until all three are set. Use Discord's role API or Developer Mode during deployment to translate role - names to IDs; pin the resulting IDs rather than checking mutable names at runtime. + names to IDs; review and pin the resulting IDs rather than checking mutable names at runtime. ## Runtime assumptions (validated 2026-06-02) @@ -105,6 +136,6 @@ bun run dev # run the server locally (needs env above) bot created from a channel mention (`isThreadCreatedForMessage`); a mention inside a user-created thread never renames it (set `DISCORDBOT_NAME_THREADS=false` to disable renaming entirely). -- A Gateway RESUME that replays a channel mention before state commits could, in rare cases, let - the adapter create a second thread (the dedup guards execution, but thread creation happens - inside the adapter). See the plan's invariant #2. +- Exact actor binding intentionally prevents a different participant from continuing an + authorized shared thread. A future collaborative mode needs an explicit reviewed delegation + policy; it must not infer authority from thread membership. diff --git a/services/discordbot/src/discord-allowlist.ts b/services/discordbot/src/discord-allowlist.ts index a717abbd05..b29debd3b9 100644 --- a/services/discordbot/src/discord-allowlist.ts +++ b/services/discordbot/src/discord-allowlist.ts @@ -1,5 +1,6 @@ import type { Logger, Message } from "chat"; import type { DiscordbotOptions } from "./types"; +import { configuredDiscordRoleIds } from "./discord-policy"; export type DiscordIngressContext = { authorIsBot: boolean; @@ -35,8 +36,7 @@ export function parseDiscordThreadKey(threadKey: string): { * Authorization gate for inbound Discord messages. * * Unlike the Slack allowlist (which is fail-open), this is intentionally **fail-closed**: - * the api-rs control plane has no ingress auth, so this guard is the primary authorization - * boundary. Direct messages are denied outright, and all three human ingress + * Direct messages are denied outright, and all three human ingress * allowlists (guild, parent channel, and trigger role) must be configured. */ export function isAllowedDiscordMessage( @@ -218,6 +218,8 @@ export function resolveTriggerBotAllowlist( export function resolveTriggerRoleAllowlist( options: DiscordbotOptions, ): string[] { + const policyRoleIds = configuredDiscordRoleIds(options); + if (policyRoleIds.length > 0) return policyRoleIds; return [ ...(options.triggerRoleAllowlist ?? splitEnvList(process.env.DISCORDBOT_TRIGGER_ROLE_ALLOWLIST)), @@ -236,7 +238,7 @@ export function isDiscordIngressAllowlistEmpty( return ( resolveGuildAllowlist(options).length === 0 || resolveChannelAllowlist(options).length === 0 || - resolveTriggerRoleAllowlist(options).length === 0 + configuredDiscordRoleIds(options).length === 0 ); } diff --git a/services/discordbot/src/discord-delivery.ts b/services/discordbot/src/discord-delivery.ts new file mode 100644 index 0000000000..f3f9c9a229 --- /dev/null +++ b/services/discordbot/src/discord-delivery.ts @@ -0,0 +1,211 @@ +import { createHash, randomUUID, timingSafeEqual } from "node:crypto"; +import type { Logger, StateAdapter } from "chat"; +import { resolveChannelAllowlist } from "./discord-allowlist"; +import { DEFAULT_DISCORD_API_URL } from "./discord-threading"; +import type { DiscordbotOptions } from "./types"; + +const MAX_DELIVERY_ID_LENGTH = 128; +const MAX_DELIVERY_TEXT_LENGTH = 1_900; +const DELIVERY_RESULT_TTL_MS = 30 * 24 * 60 * 60 * 1_000; +const DELIVERY_LEASE_TTL_MS = 60 * 1_000; + +export type DiscordDeliveryInput = { + channel_id: string; + delivery_id: string; + text: string; +}; + +export type DiscordDeliveryResult = { + channel_id: string; + delivery_id: string; + message_id: string; + ok: true; +}; + +export class DiscordDeliveryError extends Error { + constructor( + readonly code: string, + readonly status: 400 | 401 | 409 | 502 | 503, + ) { + super(code); + } +} + +export function authorizeDiscordDelivery( + authorization: string | undefined, + apiKey: string | undefined, +): void { + const prefix = "Bearer "; + const provided = authorization?.startsWith(prefix) + ? authorization.slice(prefix.length) + : ""; + if (!apiKey || !provided || !constantTimeEqual(provided, apiKey)) { + throw new DiscordDeliveryError("unauthorized", 401); + } +} + +export async function deliverDiscordNotification( + raw: unknown, + options: DiscordbotOptions, + state: StateAdapter, + logger: Logger, +): Promise { + const input = validateDeliveryInput(raw, options); + const resultKey = `discordbot:delivery:result:${input.delivery_id}`; + const leaseKey = `discordbot:delivery:lease:${input.delivery_id}`; + + let existing: unknown; + try { + existing = await state.get(resultKey); + } catch { + throw new DiscordDeliveryError("state_unavailable", 503); + } + if (isDeliveryResult(existing, input)) return existing; + + const leaseToken = randomUUID(); + let claimed: boolean; + try { + claimed = await state.setIfNotExists( + leaseKey, + leaseToken, + DELIVERY_LEASE_TTL_MS, + ); + } catch { + throw new DiscordDeliveryError("state_unavailable", 503); + } + if (!claimed) throw new DiscordDeliveryError("delivery_in_progress", 409); + + try { + existing = await state.get(resultKey); + if (isDeliveryResult(existing, input)) return existing; + + const result = await postDiscordMessage(input, options); + await state.set(resultKey, result, DELIVERY_RESULT_TTL_MS); + logger.info("discordbot_delivery_audit", { + channel_id: result.channel_id, + delivery_id: result.delivery_id, + message_id: result.message_id, + reason: "delivered", + }); + return result; + } catch (error) { + logger.warn("discordbot_delivery_audit", { + channel_id: input.channel_id, + delivery_id: input.delivery_id, + reason: + error instanceof DiscordDeliveryError ? error.code : "delivery_failed", + }); + if (error instanceof DiscordDeliveryError) throw error; + throw new DiscordDeliveryError("delivery_failed", 502); + } finally { + try { + if ((await state.get(leaseKey)) === leaseToken) { + await state.delete(leaseKey); + } + } catch { + // The short lease expires automatically. Never turn a successful, + // durably recorded delivery into a retry solely because cleanup failed. + } + } +} + +function validateDeliveryInput( + raw: unknown, + options: DiscordbotOptions, +): DiscordDeliveryInput { + if (!raw || typeof raw !== "object" || Array.isArray(raw)) { + throw new DiscordDeliveryError("invalid_request", 400); + } + const value = raw as Record; + const channelId = value.channel_id; + const deliveryId = value.delivery_id; + const text = value.text; + if ( + typeof channelId !== "string" || + !/^\d{16,22}$/.test(channelId) || + !resolveChannelAllowlist(options).includes(channelId) + ) { + throw new DiscordDeliveryError("channel_not_allowlisted", 400); + } + if ( + typeof deliveryId !== "string" || + deliveryId.length === 0 || + deliveryId.length > MAX_DELIVERY_ID_LENGTH || + !/^[A-Za-z0-9:_-]+$/.test(deliveryId) + ) { + throw new DiscordDeliveryError("invalid_delivery_id", 400); + } + if ( + typeof text !== "string" || + text.trim().length === 0 || + text.length > MAX_DELIVERY_TEXT_LENGTH + ) { + throw new DiscordDeliveryError("invalid_text", 400); + } + return { channel_id: channelId, delivery_id: deliveryId, text }; +} + +async function postDiscordMessage( + input: DiscordDeliveryInput, + options: DiscordbotOptions, +): Promise { + const apiBase = (options.discordApiUrl ?? DEFAULT_DISCORD_API_URL).replace( + /\/$/, + "", + ); + const nonce = createHash("sha256") + .update(input.delivery_id) + .digest("hex") + .slice(0, 24); + const response = await (options.fetch ?? fetch)( + `${apiBase}/channels/${input.channel_id}/messages`, + { + method: "POST", + headers: { + authorization: `Bot ${options.botToken}`, + "content-type": "application/json", + }, + body: JSON.stringify({ + allowed_mentions: { parse: [] }, + content: input.text, + enforce_nonce: true, + flags: 1 << 2, + nonce, + }), + }, + ); + if (!response.ok) throw new DiscordDeliveryError("discord_rejected", 502); + const body = (await response.json()) as { id?: unknown }; + if (typeof body.id !== "string" || !/^\d{16,22}$/.test(body.id)) { + throw new DiscordDeliveryError("discord_response_invalid", 502); + } + return { + channel_id: input.channel_id, + delivery_id: input.delivery_id, + message_id: body.id, + ok: true, + }; +} + +function isDeliveryResult( + value: unknown, + input: DiscordDeliveryInput, +): value is DiscordDeliveryResult { + if (!value || typeof value !== "object" || Array.isArray(value)) return false; + const result = value as Partial; + return ( + result.ok === true && + result.channel_id === input.channel_id && + result.delivery_id === input.delivery_id && + typeof result.message_id === "string" + ); +} + +function constantTimeEqual(left: string, right: string): boolean { + const leftBytes = Buffer.from(left); + const rightBytes = Buffer.from(right); + return ( + leftBytes.length === rightBytes.length && + timingSafeEqual(leftBytes, rightBytes) + ); +} diff --git a/services/discordbot/src/discord-ingress.ts b/services/discordbot/src/discord-ingress.ts new file mode 100644 index 0000000000..121702042f --- /dev/null +++ b/services/discordbot/src/discord-ingress.ts @@ -0,0 +1,460 @@ +import type { Logger, Message, StateAdapter } from "chat"; +import { + parseDiscordThreadKey, + resolveChannelAllowlist, + resolveGuildAllowlist, +} from "./discord-allowlist"; +import { + resolveDiscordPermissionBundle, + type DiscordPermissionBundle, +} from "./discord-policy"; +import type { DiscordbotOptions } from "./types"; + +const DEFAULT_DELIVERY_TTL_MS = 7 * 24 * 60 * 60 * 1000; +const DEFAULT_CONTINUATION_TTL_MS = 24 * 60 * 60 * 1000; +const DEFAULT_MAX_EVENT_AGE_MS = 5 * 60 * 1000; +const MAX_CLOCK_SKEW_MS = 60 * 1000; +const SUPPORTED_MESSAGE_TYPES = new Set([0, 19]); + +export type DiscordGatewayMessageEvent = { + applicationId?: string; + authorId: string; + authorIsBot: boolean; + authorIsSelf: boolean; + channelId: string; + content: string; + createdTimestamp: number; + gatewayIdentityVerified: boolean; + guildId: string; + isMentioned: boolean; + messageId: string; + messageType: number; + roleIds: string[]; + threadId?: string; + webhookId?: string; +}; + +export type DiscordIngressReason = + | "accepted" + | "approval_not_authorized" + | "actor_mismatch" + | "authorized_root_missing" + | "bot_message" + | "channel_not_allowlisted" + | "direct_message" + | "duplicate_delivery" + | "future_delivery" + | "gateway_identity_unverified" + | "guild_not_allowlisted" + | "invalid_event" + | "invalid_approval_command" + | "policy_changed_requires_root_trigger" + | "role_not_authorized" + | "role_policy_ambiguous" + | "role_policy_missing" + | "root_expired" + | "root_trigger_required" + | "self_message" + | "stale_delivery" + | "state_unavailable" + | "unsupported_message_type" + | "webhook_message"; + +export type DiscordAcceptedAdmission = { + actorId: string; + channelId: string; + control?: "approve" | "stop"; + decision: "allow"; + guildId: string; + messageId: string; + policy: DiscordPermissionBundle; + proposalFingerprint?: string; + reason: "accepted"; + receivedAt: number; + rootMessageId: string; + roleIds: string[]; + threadId: string; + version: 1; +}; + +type DiscordDeniedAdmission = { + actorId: string; + channelId: string; + decision: "deny"; + guildId: string; + messageId: string; + reason: Exclude; + receivedAt: number; + threadId?: string; + version: 1; +}; + +type DiscordAdmissionRecord = DiscordAcceptedAdmission | DiscordDeniedAdmission; + +type AuthorizedRoot = { + actorId: string; + channelId: string; + expiresAt: number; + guildId: string; + latestTriggerMessageId: string; + policy: DiscordPermissionBundle; + rootMessageId: string; + threadId: string; + version: 1; +}; + +/** + * Authenticate and authorize one Gateway event before the adapter creates a + * Discord thread or dispatches into Chat. The delivery claim is atomic and its + * stable audit record is durable; state errors deny the event. + */ +export async function admitDiscordGatewayMessage( + event: DiscordGatewayMessageEvent, + options: DiscordbotOptions, + state: StateAdapter, + logger: Logger, + now = Date.now(), +): Promise { + const pending: DiscordDeniedAdmission = { + actorId: event.authorId, + channelId: event.channelId, + decision: "deny", + guildId: event.guildId, + messageId: event.messageId, + reason: "state_unavailable", + receivedAt: now, + threadId: event.threadId, + version: 1, + }; + let claimed: boolean; + try { + claimed = await state.setIfNotExists( + deliveryKey(event.messageId), + pending, + options.ingressDeliveryTtlMs ?? DEFAULT_DELIVERY_TTL_MS, + ); + } catch { + audit(logger, pending); + return null; + } + if (!claimed) { + audit(logger, { ...pending, reason: "duplicate_delivery" }); + return null; + } + + let record: DiscordAdmissionRecord; + try { + record = await evaluateAdmission(event, options, state, now); + } catch { + record = { ...pending, reason: "state_unavailable" }; + } + try { + await state.set( + deliveryKey(event.messageId), + record, + options.ingressDeliveryTtlMs ?? DEFAULT_DELIVERY_TTL_MS, + ); + } catch { + record = { ...pending, reason: "state_unavailable" }; + } + audit(logger, record); + return record.decision === "allow" ? record : null; +} + +/** Load the immutable accepted admission that the Gateway persisted. */ +export async function acceptedDiscordAdmissionForMessage( + message: Message, + state: StateAdapter, +): Promise { + const record = await state.get(deliveryKey(message.id)); + if (!isAcceptedAdmission(record)) return null; + const parsed = parseDiscordThreadKey(message.threadId); + if ( + record.actorId !== message.author.userId || + record.guildId !== parsed.guildId || + record.channelId !== parsed.channelId || + record.threadId !== parsed.threadId + ) { + return null; + } + return record; +} + +/** Build the same authenticated event shape for tests/direct Chat dispatch. */ +export function discordGatewayEventFromMessage( + message: Message, + options: DiscordbotOptions, +): DiscordGatewayMessageEvent | null { + const raw = message.raw && typeof message.raw === "object" + ? (message.raw as Record) + : {}; + const parsed = parseDiscordThreadKey(message.threadId); + if (!parsed.guildId || !parsed.channelId) return null; + const member = raw.member && typeof raw.member === "object" + ? (raw.member as Record) + : {}; + const roles = Array.isArray(member.roles) + ? member.roles.filter((role): role is string => typeof role === "string") + : []; + return { + applicationId: + typeof raw.application_id === "string" ? raw.application_id : undefined, + authorId: message.author.userId, + authorIsBot: message.author.isBot === true, + authorIsSelf: message.author.isMe === true, + channelId: parsed.channelId, + content: message.text, + createdTimestamp: message.metadata.dateSent.getTime(), + gatewayIdentityVerified: true, + guildId: parsed.guildId, + isMentioned: message.isMention === true, + messageId: message.id, + messageType: typeof raw.type === "number" ? raw.type : 0, + roleIds: roles, + threadId: parsed.threadId, + webhookId: typeof raw.webhook_id === "string" ? raw.webhook_id : undefined, + }; +} + +function baseDenied( + event: DiscordGatewayMessageEvent, + reason: DiscordDeniedAdmission["reason"], + now: number, +): DiscordDeniedAdmission { + return { + actorId: event.authorId, + channelId: event.channelId, + decision: "deny", + guildId: event.guildId, + messageId: event.messageId, + reason, + receivedAt: now, + threadId: event.threadId, + version: 1, + }; +} + +async function evaluateAdmission( + event: DiscordGatewayMessageEvent, + options: DiscordbotOptions, + state: StateAdapter, + now: number, +): Promise { + const deny = (reason: DiscordDeniedAdmission["reason"]) => + baseDenied(event, reason, now); + if (event.guildId === "@me") return deny("direct_message"); + if (!validEventIds(event)) return deny("invalid_event"); + if (!event.gatewayIdentityVerified) return deny("gateway_identity_unverified"); + if (event.createdTimestamp > now + MAX_CLOCK_SKEW_MS) return deny("future_delivery"); + if ( + now - event.createdTimestamp > + (options.ingressMaxEventAgeMs ?? DEFAULT_MAX_EVENT_AGE_MS) + ) { + return deny("stale_delivery"); + } + if (event.authorIsSelf) return deny("self_message"); + if (event.webhookId) return deny("webhook_message"); + if (event.authorIsBot) return deny("bot_message"); + if (!SUPPORTED_MESSAGE_TYPES.has(event.messageType)) { + return deny("unsupported_message_type"); + } + if (!resolveGuildAllowlist(options).includes(event.guildId)) { + return deny("guild_not_allowlisted"); + } + if (!resolveChannelAllowlist(options).includes(event.channelId)) { + return deny("channel_not_allowlisted"); + } + const resolution = resolveDiscordPermissionBundle(event.roleIds, options); + if (resolution.decision === "deny") return deny(resolution.reason); + const policy = resolution.bundle; + const threadId = event.threadId ?? event.messageId; + const key = rootKey(event.guildId, event.channelId, threadId); + const existing = await state.get(key); + const root = isAuthorizedRoot(existing) ? existing : undefined; + const control = event.isMentioned ? controlCommand(event.content) : undefined; + if (control && "invalid" in control) return deny("invalid_approval_command"); + if (control?.type === "approve" && !policy.canApprove) { + return deny("approval_not_authorized"); + } + + if (control?.type === "stop") { + if (!root) return deny("authorized_root_missing"); + if (root.expiresAt < now) return deny("root_expired"); + if (root.actorId !== event.authorId) return deny("actor_mismatch"); + if (root.policy.fingerprint !== policy.fingerprint) { + return deny("policy_changed_requires_root_trigger"); + } + return accepted(event, root, policy, now, { type: "stop" }); + } + + if (!event.isMentioned) { + if (!event.threadId) return deny("root_trigger_required"); + if (!root) return deny("authorized_root_missing"); + if (root.expiresAt < now) return deny("root_expired"); + if (root.actorId !== event.authorId) return deny("actor_mismatch"); + if (root.policy.fingerprint !== policy.fingerprint) { + return deny("policy_changed_requires_root_trigger"); + } + return accepted(event, root, policy, now); + } + + // A mention inside an arbitrary existing thread is not a new authority + // root. Production roots arrive in the explicitly allowlisted parent + // channel; the adapter then creates a thread whose immutable id is the root + // message id. Every later thread event must find that durable root. + if (event.threadId) { + if (!root) return deny("authorized_root_missing"); + if (root.expiresAt < now) return deny("root_expired"); + } + + if (root && root.actorId !== event.authorId && root.expiresAt >= now) { + return deny("actor_mismatch"); + } + const continuationTtlMs = + options.continuationTtlMs ?? DEFAULT_CONTINUATION_TTL_MS; + const nextRoot: AuthorizedRoot = { + actorId: event.authorId, + channelId: event.channelId, + expiresAt: now + continuationTtlMs, + guildId: event.guildId, + latestTriggerMessageId: event.messageId, + policy, + rootMessageId: root?.rootMessageId ?? event.messageId, + threadId, + version: 1, + }; + if (root) { + await state.set(key, nextRoot, continuationTtlMs); + } else { + const claimed = await state.setIfNotExists( + key, + nextRoot, + continuationTtlMs, + ); + if (!claimed) return deny("state_unavailable"); + } + return accepted(event, nextRoot, policy, now, control); +} + +function accepted( + event: DiscordGatewayMessageEvent, + root: AuthorizedRoot, + policy: DiscordPermissionBundle, + now: number, + control?: { fingerprint?: string; type: "approve" | "stop" }, +): DiscordAcceptedAdmission { + return { + actorId: event.authorId, + channelId: event.channelId, + ...(control ? { control: control.type } : {}), + decision: "allow", + guildId: event.guildId, + messageId: event.messageId, + policy, + ...(control?.fingerprint + ? { proposalFingerprint: control.fingerprint } + : {}), + reason: "accepted", + receivedAt: now, + rootMessageId: root.rootMessageId, + roleIds: [...new Set(event.roleIds)].sort(), + threadId: root.threadId, + version: 1, + }; +} + +function controlCommand(content: string): + | { fingerprint: string; type: "approve" } + | { type: "stop" } + | { invalid: true } + | undefined { + const command = content + .replace(/<@!?\d+>/g, " ") + .replace(/<@&\d+>/g, " ") + .trim() + .toLowerCase(); + if (command === "stop" || command === "cancel") return { type: "stop" }; + const approval = command.match(/^approve\s+(sha256:[0-9a-f]{64})$/); + if (approval?.[1]) return { fingerprint: approval[1], type: "approve" }; + if (/^approve(?:\s|$)/.test(command)) return { invalid: true }; + return undefined; +} + +function validEventIds(event: DiscordGatewayMessageEvent): boolean { + const snowflake = /^\d{16,22}$/; + return ( + [event.authorId, event.channelId, event.guildId, event.messageId].every( + (value) => typeof value === "string" && snowflake.test(value), + ) && + (event.threadId === undefined || snowflake.test(event.threadId)) && + Array.isArray(event.roleIds) && + event.roleIds.every((roleId) => snowflake.test(roleId)) && + typeof event.content === "string" && + Number.isFinite(event.createdTimestamp) && + Number.isSafeInteger(event.messageType) + ); +} + +function deliveryKey(messageId: string): string { + return `discordbot:ingress:delivery:${messageId}`; +} + +function rootKey(guildId: string, channelId: string, threadId: string): string { + return `discordbot:ingress:root:${guildId}:${channelId}:${threadId}`; +} + +function audit(logger: Logger, record: DiscordAdmissionRecord): void { + logger.info("discordbot_ingress_audit", { + actor_id: record.actorId, + channel_id: record.channelId, + decision: record.decision, + guild_id: record.guildId, + message_id: record.messageId, + reason: record.reason, + thread_id: record.threadId, + ...(record.decision === "allow" + ? { + capability_class: record.policy.capabilityClass, + control: record.control, + policy_fingerprint: record.policy.fingerprint, + project_scope: record.policy.projectScope, + repository_scope: record.policy.repositoryScope, + role_ids: record.roleIds, + } + : {}), + }); +} + +function isAcceptedAdmission(value: unknown): value is DiscordAcceptedAdmission { + if (!value || typeof value !== "object" || Array.isArray(value)) return false; + const record = value as Partial; + return ( + record.version === 1 && + record.decision === "allow" && + record.reason === "accepted" && + typeof record.actorId === "string" && + typeof record.channelId === "string" && + typeof record.guildId === "string" && + typeof record.messageId === "string" && + typeof record.threadId === "string" && + record.policy !== undefined && + typeof record.policy.fingerprint === "string" + ); +} + +function isAuthorizedRoot(value: unknown): value is AuthorizedRoot { + if (!value || typeof value !== "object" || Array.isArray(value)) return false; + const root = value as Partial; + return ( + root.version === 1 && + typeof root.actorId === "string" && + typeof root.channelId === "string" && + typeof root.expiresAt === "number" && + Number.isFinite(root.expiresAt) && + typeof root.guildId === "string" && + typeof root.rootMessageId === "string" && + typeof root.threadId === "string" && + root.policy !== undefined && + typeof root.policy.fingerprint === "string" + ); +} diff --git a/services/discordbot/src/discord-policy.ts b/services/discordbot/src/discord-policy.ts new file mode 100644 index 0000000000..aae0f08316 --- /dev/null +++ b/services/discordbot/src/discord-policy.ts @@ -0,0 +1,230 @@ +import { createHash } from "node:crypto"; +import type { DiscordbotOptions, DiscordRoleBinding } from "./types"; + +const CAPABILITY_CLASS = /^[a-z][a-z0-9:_-]{0,63}$/; +const PRINCIPAL_ROLE = /^[a-z0-9][a-z0-9._:-]{0,127}$/i; +const REPOSITORY = /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/; +const PROJECT = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/; +const MAX_SCOPE_ENTRIES = 64; + +export type DiscordPermissionBundle = { + canApprove: boolean; + capabilityClass: string; + fingerprint: string; + principalRole: string; + projectScope: string[]; + repositoryScope: string[]; + sourceRoleId: string; +}; + +export type DiscordPolicyResolution = + | { decision: "allow"; bundle: DiscordPermissionBundle } + | { decision: "deny"; reason: "role_policy_ambiguous" | "role_policy_missing" | "role_not_authorized" }; + +/** + * Parse and validate reviewed role policy. Numeric Discord IDs and exact + * repository names are mandatory; wildcards and duplicate role mappings are + * rejected at startup rather than interpreted at runtime. + */ +export function parseDiscordRoleBindings( + raw: string | undefined, +): DiscordRoleBinding[] | undefined { + if (!raw?.trim()) return undefined; + let value: unknown; + try { + value = JSON.parse(raw); + } catch { + throw new Error("DISCORDBOT_ROLE_BINDINGS_JSON must be valid JSON"); + } + if (!Array.isArray(value) || value.length === 0) { + throw new Error("DISCORDBOT_ROLE_BINDINGS_JSON must be a non-empty array"); + } + const roleIds = new Set(); + return value.map((item, index) => { + if (!item || typeof item !== "object" || Array.isArray(item)) { + throw new Error(`Discord role binding ${index} must be an object`); + } + const record = item as Record; + const roleId = requiredString(record.role_id, `binding ${index} role_id`); + const capabilityClass = requiredString( + record.capability_class, + `binding ${index} capability_class`, + ); + const principalRole = requiredString( + record.principal_role, + `binding ${index} principal_role`, + ); + if (!/^\d{16,22}$/.test(roleId)) { + throw new Error(`binding ${index} role_id must be a numeric Discord ID`); + } + if (roleIds.has(roleId)) { + throw new Error(`Discord role ${roleId} has more than one binding`); + } + roleIds.add(roleId); + if (!CAPABILITY_CLASS.test(capabilityClass)) { + throw new Error(`binding ${index} capability_class is invalid`); + } + if (!PRINCIPAL_ROLE.test(principalRole)) { + throw new Error(`binding ${index} principal_role is invalid`); + } + const priority = optionalPriority(record.priority, index); + const canApprove = optionalBoolean(record.can_approve, index, "can_approve"); + return { + canApprove, + capabilityClass, + principalRole, + priority, + projectScope: projectArray(record.project_scope, index), + repositoryScope: repositoryArray(record.repository_scope, index), + roleId, + }; + }); +} + +/** + * Resolve multiple Discord roles with explicit precedence, never an implicit + * union. Equal-priority matches must describe the exact same bundle or the + * event fails closed as ambiguous. + */ +export function resolveDiscordPermissionBundle( + roleIds: readonly string[], + options: Pick, +): DiscordPolicyResolution { + const bindings = options.roleBindings; + if (!bindings?.length) return { decision: "deny", reason: "role_policy_missing" }; + const held = new Set(roleIds); + const matches = bindings.filter((binding) => held.has(binding.roleId)); + if (matches.length === 0) { + return { decision: "deny", reason: "role_not_authorized" }; + } + const highestPriority = Math.max(...matches.map((binding) => binding.priority)); + const winners = matches.filter( + (binding) => binding.priority === highestPriority, + ); + const semantic = new Set(winners.map(bindingSemanticKey)); + if (semantic.size !== 1) { + return { decision: "deny", reason: "role_policy_ambiguous" }; + } + const selected = [...winners].sort((a, b) => + a.roleId.localeCompare(b.roleId), + )[0]; + if (!selected) return { decision: "deny", reason: "role_not_authorized" }; + const canonical = { + can_approve: selected.canApprove, + capability_class: selected.capabilityClass, + principal_role: selected.principalRole, + project_scope: normalized(selected.projectScope), + repository_scope: normalized(selected.repositoryScope, true), + }; + return { + decision: "allow", + bundle: { + canApprove: canonical.can_approve, + capabilityClass: canonical.capability_class, + fingerprint: `sha256:${createHash("sha256") + .update(JSON.stringify(canonical)) + .digest("hex")}`, + principalRole: canonical.principal_role, + projectScope: canonical.project_scope, + repositoryScope: canonical.repository_scope, + sourceRoleId: selected.roleId, + }, + }; +} + +export function configuredDiscordRoleIds( + options: Pick, +): string[] { + return options.roleBindings?.map((binding) => binding.roleId) ?? []; +} + +function bindingSemanticKey(binding: DiscordRoleBinding): string { + return JSON.stringify({ + canApprove: binding.canApprove, + capabilityClass: binding.capabilityClass, + principalRole: binding.principalRole, + projectScope: normalized(binding.projectScope), + repositoryScope: normalized(binding.repositoryScope, true), + }); +} + +function optionalBoolean(value: unknown, index: number, name: string): boolean { + if (value === undefined) return false; + if (typeof value !== "boolean") { + throw new Error(`binding ${index} ${name} must be a boolean`); + } + return value; +} + +function normalized(values: readonly string[], lower = false): string[] { + return [...new Set(values.map((value) => (lower ? value.toLowerCase() : value)))] + .sort(); +} + +function requiredString(value: unknown, name: string): string { + if (typeof value !== "string" || !value.trim()) { + throw new Error(`${name} must be a non-empty string`); + } + return value.trim(); +} + +function optionalPriority(value: unknown, index: number): number { + if (value === undefined) return 0; + if (!Number.isSafeInteger(value) || (value as number) < 0) { + throw new Error(`binding ${index} priority must be a non-negative integer`); + } + return value as number; +} + +function stringArray(value: unknown, index: number, name: string): string[] { + if (!Array.isArray(value)) { + throw new Error(`binding ${index} ${name} must be an array`); + } + if (value.length > MAX_SCOPE_ENTRIES) { + throw new Error( + `binding ${index} ${name} must contain at most ${MAX_SCOPE_ENTRIES} entries`, + ); + } + const output = value.map((item) => requiredString(item, `${name} entry`)); + if (new Set(output).size !== output.length) { + throw new Error(`binding ${index} ${name} contains duplicates`); + } + return output; +} + +function projectArray(value: unknown, index: number): string[] { + const projects = stringArray(value, index, "project_scope"); + if (projects.some((project) => !PROJECT.test(project))) { + throw new Error( + `binding ${index} project_scope must contain stable project identifiers`, + ); + } + return projects; +} + +function repositoryArray(value: unknown, index: number): string[] { + const repositories = stringArray(value, index, "repository_scope"); + if (repositories.length === 0) { + throw new Error(`binding ${index} repository_scope must not be empty`); + } + for (const repository of repositories) { + if ( + repository.length > 128 || + !REPOSITORY.test(repository) || + repository.includes("*") + ) { + throw new Error( + `binding ${index} repository_scope must contain exact owner/repository names`, + ); + } + } + if ( + new Set(repositories.map((repository) => repository.toLowerCase())).size !== + repositories.length + ) { + throw new Error( + `binding ${index} repository_scope contains case-insensitive duplicates`, + ); + } + return repositories; +} diff --git a/services/discordbot/src/index.ts b/services/discordbot/src/index.ts index d1458b8249..8edecd266e 100644 --- a/services/discordbot/src/index.ts +++ b/services/discordbot/src/index.ts @@ -19,12 +19,16 @@ import { Hono } from "hono"; import pg from "pg"; import { discordIngressDenialReason, - isAllowedDiscordGuild, isAllowedDiscordMessage, isDiscordIngressAllowlistEmpty, parseDiscordThreadKey, - resolveTriggerBotAllowlist, } from "./discord-allowlist"; +import { + acceptedDiscordAdmissionForMessage, + admitDiscordGatewayMessage, + discordGatewayEventFromMessage, + type DiscordAcceptedAdmission, +} from "./discord-ingress"; import { DiscordNarrator, reactToDiscordMessage } from "./discord-narrator"; import { fetchThreadStarterMessage } from "./discord-starter"; import { @@ -35,21 +39,29 @@ import { } from "./discord-threading"; import { setGatewayConnected } from "./gateway"; import { + approveActionProposal, collectInitialContext, executeSessionTurn, forwardToSessionApi, isContentlessApiMessage, isDiscordPermissionError, isRetryableSessionApiError, + interruptSessionExecution, openSessionEventStream, serializeMessage, sessionStreamError, startingStreamNotification, } from "./session-api"; +import { + authorizeDiscordDelivery, + deliverDiscordNotification, + DiscordDeliveryError, +} from "./discord-delivery"; import type { Discordbot, DiscordbotApiMessage, DiscordbotExecuteSessionResponse, + DiscordExecutionPolicy, DiscordbotMessageMode, DiscordbotOptions, DiscordbotRenderObligation, @@ -178,10 +190,11 @@ export function createDiscordbot(options: DiscordbotOptions): Discordbot { if (isDiscordIngressAllowlistEmpty(options)) { logger.warn("discordbot_ingress_allowlist_incomplete_inert", { - hint: "Set DISCORDBOT_GUILD_ALLOWLIST, DISCORDBOT_CHANNEL_ALLOWLIST, and DISCORDBOT_TRIGGER_ROLE_ALLOWLIST; human messages are ignored until all three are configured.", + hint: "Set DISCORDBOT_GUILD_ALLOWLIST, DISCORDBOT_CHANNEL_ALLOWLIST, and reviewed DISCORDBOT_ROLE_BINDINGS_JSON; human messages are ignored until all three are configured.", }); } + const state = options.state ?? createDefaultState(options, logger); const discord = createDiscordAdapter({ apiUrl: options.discordApiUrl, applicationId: options.applicationId, @@ -190,6 +203,12 @@ export function createDiscordbot(options: DiscordbotOptions): Discordbot { mentionRoleIds: options.mentionRoleIds, userName, logger, + // Direct-mode interactions are intentionally disabled. The reviewed root + // trigger is an authenticated Gateway mention; unscoped slash commands + // must not bypass the role/channel admission path. + allowGatewayInteractions: false, + shouldHandleGatewayMessage: async (event) => + (await admitDiscordGatewayMessage(event, options, state, logger)) !== null, // Discord delta (patched adapter): gate mentions BEFORE the adapter // creates a public thread. The full gate still runs in the handlers below // so every follow-up is re-authorized as well. @@ -214,15 +233,6 @@ export function createDiscordbot(options: DiscordbotOptions): Discordbot { } return denialReason === undefined; }, - // Discord delta (patched adapter): the gateway drops bot-authored - // messages by default; forward only allowlisted trigger bots in - // allowlisted guilds. The allowlist entry must be the id the message is - // authored as (bot user id, or the webhook id for webhook integrations); - // isAllowedDiscordMessage applies the broader application_id/webhook_id - // matching once the full payload is available. - shouldForwardBotMessage: ({ authorId, guildId }) => - isAllowedDiscordGuild(guildId, options) && - resolveTriggerBotAllowlist(options).includes(authorId), // Discord delta (patched adapter): the Gateway never redelivers, so a // message dropped on a thread-lock conflict is otherwise lost with zero // signal — surface it with a 🔁 reaction so the user knows to resend. @@ -249,7 +259,6 @@ export function createDiscordbot(options: DiscordbotOptions): Discordbot { // 503 once the connection has been down for >60s (see gateway.ts). onGatewayStatusChange: (connected) => setGatewayConnected(connected), }); - const state = options.state ?? createDefaultState(options, logger); const chat = new Chat<{ discord: typeof discord }, DiscordbotThreadState>({ userName, adapters: { discord }, @@ -279,8 +288,24 @@ export function createDiscordbot(options: DiscordbotOptions): Discordbot { chat.onNewMention(async (thread, message) => { if (!isAllowedDiscordMessage(message, options, logger)) return; + const admission = await discordAdmissionForHandler( + message, + options, + state, + logger, + ); + if (!admission) return; + if (admission.control === "stop") { + await stopDiscordExecution(thread, message, admission, options, logger); + return; + } + if (admission.control === "approve") { + await approveDiscordProposal(thread, message, admission, options, logger); + return; + } await thread.subscribe(); await syncThreadMessageToSession(thread, message, { + admission, executionLimiter, mode: "execute", options, @@ -290,7 +315,23 @@ export function createDiscordbot(options: DiscordbotOptions): Discordbot { chat.onSubscribedMessage(async (thread, message) => { if (!isAllowedDiscordMessage(message, options, logger)) return; + const admission = await discordAdmissionForHandler( + message, + options, + state, + logger, + ); + if (!admission) return; + if (admission.control === "stop") { + await stopDiscordExecution(thread, message, admission, options, logger); + return; + } + if (admission.control === "approve") { + await approveDiscordProposal(thread, message, admission, options, logger); + return; + } await syncThreadMessageToSession(thread, message, { + admission, executionLimiter, mode: message.isMention === true ? "execute" : "append", options, @@ -308,6 +349,30 @@ export function createDiscordbot(options: DiscordbotOptions): Discordbot { gatewayActive ? 200 : 503, ); }); + app.post("/internal/deliveries", async (c) => { + try { + authorizeDiscordDelivery(c.req.header("authorization"), options.apiKey); + const result = await deliverDiscordNotification( + await c.req.json(), + options, + state, + logger, + ); + return c.json(result); + } catch (error) { + const deliveryError = + error instanceof DiscordDeliveryError + ? error + : new DiscordDeliveryError("invalid_request", 400); + return new Response( + JSON.stringify({ error: deliveryError.code, ok: false }), + { + headers: { "content-type": "application/json" }, + status: deliveryError.status, + }, + ); + } + }); if (options.recoverRenderObligationsOnStart !== false) { scheduleRenderObligationRecovery(chat, state, options); @@ -316,6 +381,97 @@ export function createDiscordbot(options: DiscordbotOptions): Discordbot { return { app, chat, adapter: discord }; } +async function discordAdmissionForHandler( + message: ChatMessage, + options: DiscordbotOptions, + state: StateAdapter, + logger: Logger, +): Promise { + const accepted = await acceptedDiscordAdmissionForMessage(message, state); + if (accepted) return accepted; + // Production events must already have been admitted by the authenticated + // Discord Gateway callback before the adapter creates a thread. The Chat SDK + // also exposes an in-process webhook emulator; never treat its forwarded + // JSON as transport-authenticated outside explicit tests. + if (options.allowInProcessGatewayEmulation !== true) { + logger.warn("discordbot_missing_verified_gateway_admission", { + message_id: message.id, + thread_id: message.threadId, + }); + return null; + } + const event = discordGatewayEventFromMessage(message, options); + if (!event) return null; + return admitDiscordGatewayMessage(event, options, state, logger); +} + +async function stopDiscordExecution( + thread: Thread, + message: ChatMessage, + admission: DiscordAcceptedAdmission, + options: DiscordbotOptions, + logger: Logger, +): Promise { + try { + const outcome = await interruptSessionExecution( + options, + thread.id, + `Stopped by Discord actor ${admission.actorId}`, + ); + await thread.post( + outcome.interrupted + ? "Stopped the current Centaur run for this thread." + : "There is no active Centaur run in this thread.", + ); + await reactToDiscordMessage( + options, + { emoji: "⏹️", messageId: message.id, threadKey: thread.id }, + logger, + ); + } catch (error) { + traceLog(options, "discordbot_stop_failed", undefined, { + actor_id: admission.actorId, + error: errorMessage(error), + message_id: message.id, + thread_id: thread.id, + }); + await thread.post("I couldn't stop that run. Check Console and try again."); + } +} + +async function approveDiscordProposal( + thread: Thread, + message: ChatMessage, + admission: DiscordAcceptedAdmission, + options: DiscordbotOptions, + logger: Logger, +): Promise { + try { + const outcome = await approveActionProposal(options, admission); + const state = outcome.created ? "Queued" : "Already queued"; + const destination = outcome.console_url + ? ` Track it in Console: ${outcome.console_url}` + : " Track it in Console."; + await thread.post(`${state} the approved improvement action.${destination}`); + await reactToDiscordMessage( + options, + { emoji: "✅", messageId: message.id, threadKey: thread.id }, + logger, + ); + } catch (error) { + traceLog(options, "discordbot_proposal_approval_failed", undefined, { + actor_id: admission.actorId, + error: errorMessage(error), + message_id: message.id, + proposal_fingerprint: admission.proposalFingerprint, + thread_id: thread.id, + }); + await thread.post( + "I couldn't approve that proposal. It may be expired or changed; run a fresh observation and check Console.", + ); + } +} + function createDefaultState( options: DiscordbotOptions, logger: Logger, @@ -380,6 +536,7 @@ async function syncThreadMessageToSession( thread: Thread, message: ChatMessage, input: { + admission: DiscordAcceptedAdmission; executionLimiter: GuildExecutionLimiter; mode: DiscordbotMessageMode; options: DiscordbotOptions; @@ -541,10 +698,11 @@ async function syncThreadMessageToSession( conversationName, executeMessage: shouldStartExecution ? serializedMessage : undefined, messages: messagesToAppend, - onEventId: (eventId) => { + onEventId: (eventId: number) => { lastEventId = Math.max(lastEventId, eventId); }, openStream: false, + policy: executionPolicy(input.admission), threadId: thread.id, trace, }; @@ -694,6 +852,23 @@ async function syncThreadMessageToSession( } } +function executionPolicy( + admission: DiscordAcceptedAdmission, +): DiscordExecutionPolicy { + return { + actorId: admission.actorId, + capabilityClass: admission.policy.capabilityClass, + channelId: admission.channelId, + guildId: admission.guildId, + policyFingerprint: admission.policy.fingerprint, + principalRole: admission.policy.principalRole, + projectScope: admission.policy.projectScope, + repositoryScope: admission.policy.repositoryScope, + rootMessageId: admission.rootMessageId, + threadId: admission.threadId, + }; +} + function scheduleExecutionRender( thread: Thread, message: DiscordbotApiMessage, @@ -1033,11 +1208,15 @@ async function recoverRenderObligation( threadState.lastEventId ?? 0, obligation.afterEventId, ); - const input: ForwardSessionInput = { + // Recovery only reopens an already-authorized execution's event stream; it + // never creates, appends, or executes a session turn, so it intentionally + // carries only the stream cursor contract rather than reconstructing policy + // from Discord text or stale process memory. + const input = { afterEventId: lastEventId, executionId: obligation.executionId, messages: [], - onEventId: (eventId) => { + onEventId: (eventId: number) => { lastEventId = Math.max(lastEventId, eventId); }, openStream: false, diff --git a/services/discordbot/src/server.ts b/services/discordbot/src/server.ts index 825a1d4d78..c7912a8d5a 100644 --- a/services/discordbot/src/server.ts +++ b/services/discordbot/src/server.ts @@ -1,11 +1,18 @@ import { createGatewayController } from "./gateway"; import { createDiscordbot, type DiscordbotOptions } from "./index"; +import { parseDiscordRoleBindings } from "./discord-policy"; const port = numberEnv("PORT", 3001); const apiUrl = stringEnv("CENTAUR_API_URL", "http://127.0.0.1:8080"); const botToken = requiredEnv("DISCORD_BOT_TOKEN"); const publicKey = requiredEnv("DISCORD_PUBLIC_KEY"); const applicationId = requiredEnv("DISCORD_APPLICATION_ID"); +const guildAllowlist = optionalList("DISCORDBOT_GUILD_ALLOWLIST"); +const channelAllowlist = optionalList("DISCORDBOT_CHANNEL_ALLOWLIST"); +const mentionRoleIds = optionalList("DISCORD_MENTION_ROLE_IDS"); +validateDiscordIds("DISCORDBOT_GUILD_ALLOWLIST", guildAllowlist); +validateDiscordIds("DISCORDBOT_CHANNEL_ALLOWLIST", channelAllowlist); +validateDiscordIds("DISCORD_MENTION_ROLE_IDS", mentionRoleIds); const consoleLogger = { debug: (message: string, data?: unknown) => log("debug", message, data), @@ -38,21 +45,25 @@ const options: DiscordbotOptions = { applicationId, botToken, publicKey, - channelAllowlist: optionalList("DISCORDBOT_CHANNEL_ALLOWLIST"), + channelAllowlist, + continuationTtlMs: optionalNumberEnv("DISCORDBOT_CONTINUATION_TTL_MS"), discordApiUrl: optionalEnv("DISCORD_API_URL"), - guildAllowlist: optionalList("DISCORDBOT_GUILD_ALLOWLIST"), + guildAllowlist, + ingressDeliveryTtlMs: optionalNumberEnv("DISCORDBOT_INGRESS_DELIVERY_TTL_MS"), + ingressMaxEventAgeMs: optionalNumberEnv("DISCORDBOT_INGRESS_MAX_EVENT_AGE_MS"), idleTimeoutMs: optionalNumberEnv("SESSION_IDLE_TIMEOUT_MS"), isGatewayActive: () => gateway.isActive(), maxConcurrentExecutionsPerGuild: optionalNumberEnv( "DISCORDBOT_MAX_CONCURRENT_EXECUTIONS_PER_GUILD", ), maxDurationMs: optionalNumberEnv("SESSION_MAX_DURATION_MS"), - mentionRoleIds: optionalList("DISCORD_MENTION_ROLE_IDS"), + mentionRoleIds, nameThreads: optionalEnv("DISCORDBOT_NAME_THREADS") !== "false", postgresUrl, + roleBindings: parseDiscordRoleBindings( + optionalEnv("DISCORDBOT_ROLE_BINDINGS_JSON"), + ), stateKeyPrefix: optionalEnv("DISCORDBOT_STATE_KEY_PREFIX"), - triggerBotAllowlist: optionalList("DISCORDBOT_TRIGGER_BOT_ALLOWLIST"), - triggerRoleAllowlist: optionalList("DISCORDBOT_TRIGGER_ROLE_ALLOWLIST"), userName: stringEnv("DISCORDBOT_USER_NAME", "centaur"), logger: consoleLogger, }; @@ -118,6 +129,17 @@ function optionalNumberEnv(name: string): number | undefined { return parsed; } +function validateDiscordIds( + name: string, + values: readonly string[] | undefined, +): void { + for (const value of values ?? []) { + if (!/^\d{16,22}$/.test(value)) { + throw new Error(`${name} must contain only numeric Discord IDs`); + } + } +} + function log(level: string, message: string, data?: unknown): void { console.log( JSON.stringify({ diff --git a/services/discordbot/src/session-api.ts b/services/discordbot/src/session-api.ts index 072e202506..518f590611 100644 --- a/services/discordbot/src/session-api.ts +++ b/services/discordbot/src/session-api.ts @@ -1,14 +1,18 @@ import type { RustSessionStreamEvent } from "@centaur/harness-events"; import { isRetryableCodexErrorNotification } from "@centaur/rendering"; import type { Attachment, Message } from "chat"; +import type { DiscordAcceptedAdmission } from "./discord-ingress"; import { withDiscordEmbedText } from "./discord-starter"; import type { + DiscordbotApproveProposalResponse, DiscordbotApiAttachment, DiscordbotApiMessage, DiscordbotAppendMessagesRequest, DiscordbotCreateSessionRequest, DiscordbotExecuteSessionRequest, DiscordbotExecuteSessionResponse, + DiscordExecutionPolicy, + DiscordbotInterruptSessionResponse, DiscordbotOptions, DiscordbotRendererSource, DiscordbotSessionMessage, @@ -189,13 +193,23 @@ export async function forwardToSessionApi( callbacks: ForwardSessionApiCallbacks = {}, ): Promise | null> { const createStartedAtMs = nowMs(); - await createSession(options, input.threadId, input.conversationName); + await createSession( + options, + input.threadId, + input.policy, + input.conversationName, + ); traceLog(options, "discordbot_session_create_complete", input.trace, { phase_ms: elapsedMs(createStartedAtMs), }); if (input.messages.length > 0) { const appendStartedAtMs = nowMs(); - await appendSessionMessages(options, input.threadId, input.messages); + await appendSessionMessages( + options, + input.threadId, + input.messages, + input.policy, + ); traceLog(options, "discordbot_session_append_complete", input.trace, { message_count: input.messages.length, phase_ms: elapsedMs(appendStartedAtMs), @@ -213,6 +227,7 @@ export async function forwardToSessionApi( options, input.threadId, input.executeMessage, + input.policy, ); traceLog(options, "discordbot_session_execute_complete", input.trace, { execution_id: execution.execution_id, @@ -241,6 +256,7 @@ export async function executeSessionTurn( options, input.threadId, input.executeMessage, + input.policy, ); traceLog(options, "discordbot_session_execute_complete", input.trace, { execution_id: execution.execution_id, @@ -390,6 +406,7 @@ async function bytesToBase64(data: Buffer | Blob): Promise { async function createSession( options: DiscordbotOptions, threadId: string, + policy: DiscordExecutionPolicy, conversationName?: string, ): Promise { const fetchFn = options.fetch ?? fetch; @@ -400,6 +417,7 @@ async function createSession( source: "discordbot", platform: "discord", thread_id: threadId, + ...policyMetadata(policy), // api-rs reads this as the session principal's display name. ...(name ? { discord_conversation_name: name } : {}), }, @@ -416,10 +434,11 @@ async function appendSessionMessages( options: DiscordbotOptions, threadId: string, messages: DiscordbotApiMessage[], + policy: DiscordExecutionPolicy, ): Promise { const fetchFn = options.fetch ?? fetch; const body: DiscordbotAppendMessagesRequest = { - messages: messages.map(toSessionMessage), + messages: messages.map((message) => toSessionMessage(message, policy)), }; const response = await fetchFn( apiSessionUrl(options.apiUrl, threadId, "messages"), @@ -436,12 +455,13 @@ async function executeSession( options: DiscordbotOptions, threadId: string, message: DiscordbotApiMessage, + policy: DiscordExecutionPolicy, ): Promise { const fetchFn = options.fetch ?? fetch; const body: DiscordbotExecuteSessionRequest = { idempotency_key: message.id, - metadata: sessionMetadata(message, { action: "execute" }), - input_lines: toCodexInputLines(message, threadId), + metadata: sessionMetadata(message, policy, { action: "execute" }), + input_lines: toCodexInputLines(message, threadId, policy), ...(options.idleTimeoutMs === undefined ? {} : { idle_timeout_ms: options.idleTimeoutMs }), @@ -461,6 +481,71 @@ async function executeSession( return (await response.json()) as DiscordbotExecuteSessionResponse; } +export async function interruptSessionExecution( + options: DiscordbotOptions, + threadId: string, + reason: string, +): Promise { + const fetchFn = options.fetch ?? fetch; + const response = await fetchFn( + apiSessionUrl(options.apiUrl, threadId, "interrupt"), + { + method: "POST", + headers: apiHeaders(options), + body: JSON.stringify({ reason: reason.slice(0, 500) }), + }, + ); + await ensureApiOk(response, "interrupt session", options); + return (await response.json()) as DiscordbotInterruptSessionResponse; +} + +/** + * Atomically consume one exact, previously observed workflow proposal. The + * authenticated Discord admission is the authority; message text contributes + * only the canonical fingerprint selected by the operator. + */ +export async function approveActionProposal( + options: DiscordbotOptions, + admission: DiscordAcceptedAdmission, +): Promise { + const fingerprint = admission.proposalFingerprint; + if (!fingerprint || admission.control !== "approve") { + throw new Error("a canonical proposal fingerprint is required"); + } + const fetchFn = options.fetch ?? fetch; + const response = await fetchFn( + apiWorkflowProposalApprovalUrl(options.apiUrl, fingerprint), + { + method: "POST", + headers: apiHeaders(options), + body: JSON.stringify({ + actor_id: admission.actorId, + capability_class: admission.policy.capabilityClass, + channel_id: admission.channelId, + guild_id: admission.guildId, + message_id: admission.messageId, + policy_fingerprint: admission.policy.fingerprint, + principal_role: admission.policy.principalRole, + repository_scope: admission.policy.repositoryScope, + root_message_id: admission.rootMessageId, + thread_id: admission.threadId, + }), + }, + ); + await ensureApiOk(response, "approve workflow proposal", options); + const value: unknown = await response.json(); + if (!isApprovalResponse(value, fingerprint)) { + throw new SessionApiError({ + action: "approve workflow proposal", + body: "invalid success response", + retryable: false, + status: 502, + statusText: "Bad Gateway", + }); + } + return value; +} + async function ensureApiOk( response: Response, action: string, @@ -520,12 +605,39 @@ async function streamSessionNotifications( function apiSessionUrl( apiUrl: string, threadId: string, - suffix?: "messages" | "execute" | "events", + suffix?: "messages" | "execute" | "events" | "interrupt", ): string { const path = `/api/session/${encodeURIComponent(threadId)}${suffix ? `/${suffix}` : ""}`; return new URL(path, ensureTrailingSlash(apiUrl)).toString(); } +function apiWorkflowProposalApprovalUrl( + apiUrl: string, + fingerprint: string, +): string { + const path = `/api/workflows/proposals/${encodeURIComponent(fingerprint)}/approve`; + return new URL(path, ensureTrailingSlash(apiUrl)).toString(); +} + +function isApprovalResponse( + value: unknown, + fingerprint: string, +): value is DiscordbotApproveProposalResponse { + if (!isJsonObject(value)) return false; + return ( + value.ok === true && + value.fingerprint === fingerprint && + typeof value.created === "boolean" && + typeof value.action_run_id === "string" && + value.action_run_id.length > 0 && + typeof value.action_task_id === "string" && + value.action_task_id.length > 0 && + typeof value.action_workflow === "string" && + value.action_workflow.length > 0 && + (value.console_url === undefined || typeof value.console_url === "string") + ); +} + function ensureTrailingSlash(value: string): string { return value.endsWith("/") ? value : `${value}/`; } @@ -540,12 +652,13 @@ function apiHeaders(options: DiscordbotOptions, jsonBody = true): HeadersInit { function toSessionMessage( message: DiscordbotApiMessage, + policy: DiscordExecutionPolicy, ): DiscordbotSessionMessage { return { client_message_id: message.id, role: message.author.isMe ? "assistant" : "user", parts: sessionMessageParts(message), - metadata: sessionMetadata(message), + metadata: sessionMetadata(message, policy), }; } @@ -580,6 +693,7 @@ function sessionAttachmentPart(attachment: DiscordbotApiAttachment): JsonObject function sessionMetadata( message: DiscordbotApiMessage, + policy: DiscordExecutionPolicy, extra: JsonObject = {}, ): JsonObject { return { @@ -591,10 +705,26 @@ function sessionMetadata( timestamp: message.timestamp, user_id: message.author.userId, user_name: message.author.userName, + ...policyMetadata(policy), ...extra, }; } +function policyMetadata(policy: DiscordExecutionPolicy): JsonObject { + return { + discord_actor_user_id: policy.actorId, + discord_capability_class: policy.capabilityClass, + discord_channel_id: policy.channelId, + discord_guild_id: policy.guildId, + discord_policy_fingerprint: policy.policyFingerprint, + discord_policy_role_foreign_ids: [policy.principalRole], + discord_project_scope: policy.projectScope, + discord_repository_scope: policy.repositoryScope, + discord_root_message_id: policy.rootMessageId, + discord_thread_id: policy.threadId, + }; +} + /** * Build the codex input lines for an execute turn. Attachments whose inlined * `data:` URL would push the user-message line past `MAX_CODEX_INPUT_LINE_CHARS` @@ -604,12 +734,18 @@ function sessionMetadata( export function toCodexInputLines( message: DiscordbotApiMessage, threadId: string, + policy: DiscordExecutionPolicy, ): string[] { const staged = new Map(); const lines: string[] = []; for (const attachment of message.attachments) { if (!attachment.dataBase64) continue; - const inlineLine = toCodexInputLineWithStaged(message, threadId, staged); + const inlineLine = toCodexInputLineWithStaged( + message, + threadId, + policy, + staged, + ); if ( inlineLine.length <= MAX_CODEX_INPUT_LINE_CHARS && attachment.dataBase64.length <= MAX_CODEX_INPUT_LINE_CHARS @@ -620,19 +756,20 @@ export function toCodexInputLines( staged.set(attachment, stagedAttachmentId); lines.push(...stagedAttachmentInputLines(attachment, stagedAttachmentId)); } - lines.push(toCodexInputLineWithStaged(message, threadId, staged)); + lines.push(toCodexInputLineWithStaged(message, threadId, policy, staged)); return lines; } function toCodexInputLineWithStaged( message: DiscordbotApiMessage, threadId: string, + policy: DiscordExecutionPolicy, staged: Map, ): string { return JSON.stringify({ type: "user", thread_key: threadId, - trace_metadata: sessionMetadata(message, { action: "execute" }), + trace_metadata: sessionMetadata(message, policy, { action: "execute" }), message: { role: "user", content: codexInputContent(message, staged), diff --git a/services/discordbot/src/types.ts b/services/discordbot/src/types.ts index a832c4aebd..6f04e3db20 100644 --- a/services/discordbot/src/types.ts +++ b/services/discordbot/src/types.ts @@ -77,6 +77,23 @@ export type DiscordbotExecuteSessionResponse = { thread_key: string; }; +export type DiscordbotInterruptSessionResponse = { + execution_id?: string; + interrupted: boolean; + ok: boolean; + thread_key: string; +}; + +export type DiscordbotApproveProposalResponse = { + action_run_id: string; + action_task_id: string; + action_workflow: string; + console_url?: string; + created: boolean; + fingerprint: string; + ok: boolean; +}; + export type DiscordbotFetch = ( input: RequestInfo | URL, init?: RequestInit, @@ -89,6 +106,8 @@ export type DiscordbotOptions = { * wedge the thread forever — Gateway ingress has no redelivery to kick it). */ activeExecutionTtlMs?: number; + /** Test-only escape hatch for the adapter's in-process event emulator. */ + allowInProcessGatewayEmulation?: boolean; /** Discord delta: edit cadence for the in-progress answer message. */ answerEditIntervalMs?: number; apiKey?: string; @@ -102,6 +121,12 @@ export type DiscordbotOptions = { * unset is fail-closed so a guild-wide bot cannot be activated accidentally. */ channelAllowlist?: readonly string[]; + /** Maximum age of a Gateway MESSAGE_CREATE accepted at ingress. Default 5 minutes. */ + ingressMaxEventAgeMs?: number; + /** Durable inbound-delivery dedup/audit retention. Default 7 days. */ + ingressDeliveryTtlMs?: number; + /** Authorized root-to-follow-up lifetime. Default 24 hours. */ + continuationTtlMs?: number; guildAllowlist?: readonly string[]; idleTimeoutMs?: number; /** Liveness probe for `/health`; reflects the Gateway connection state. */ @@ -117,6 +142,8 @@ export type DiscordbotOptions = { postgresUrl?: string; publicKey: string; recoverRenderObligationsOnStart?: boolean; + /** Reviewed Discord role-to-Centaur permission bundles. Empty is inert. */ + roleBindings?: readonly DiscordRoleBinding[]; state?: StateAdapter; stateKeyPrefix?: string; /** @@ -132,6 +159,36 @@ export type DiscordbotOptions = { userName?: string; }; +export type DiscordRoleBinding = { + /** Whether this reviewed role may atomically consume action proposals. */ + canApprove: boolean; + /** Stable, machine-readable capability class recorded on every run. */ + capabilityClass: string; + /** Existing iron-control role foreign ID reconciled onto the actor principal. */ + principalRole: string; + /** Higher wins; equal-priority, non-identical matches fail closed. */ + priority: number; + /** Exact project identifiers this actor bundle may address. */ + projectScope: readonly string[]; + /** Exact owner/repository names; wildcards are forbidden. */ + repositoryScope: readonly string[]; + /** Immutable numeric Discord role ID. */ + roleId: string; +}; + +export type DiscordExecutionPolicy = { + actorId: string; + capabilityClass: string; + channelId: string; + guildId: string; + policyFingerprint: string; + principalRole: string; + projectScope: string[]; + repositoryScope: string[]; + rootMessageId: string; + threadId: string; +}; + export type Discordbot = { app: Hono; chat: Chat; @@ -185,6 +242,7 @@ export type ForwardSessionInput = { messages: DiscordbotApiMessage[]; onEventId(eventId: number): void; openStream: boolean; + policy: DiscordExecutionPolicy; threadId: string; trace?: DiscordbotTrace; }; diff --git a/services/discordbot/test/chat-sdk-emulate.test.ts b/services/discordbot/test/chat-sdk-emulate.test.ts index 155790524c..1833dd5a3c 100644 --- a/services/discordbot/test/chat-sdk-emulate.test.ts +++ b/services/discordbot/test/chat-sdk-emulate.test.ts @@ -32,6 +32,7 @@ import { } from "bun:test"; import type { ServerNotification } from "@centaur/harness-events"; import { createMemoryState } from "@chat-adapter/state-memory"; +import type { Logger, StateAdapter } from "chat"; import { createDiscordbot, type Discordbot, @@ -42,6 +43,7 @@ import { type DiscordbotOptions, type DiscordbotSessionMessage, } from "../src/index"; +import { admitDiscordGatewayMessage } from "../src/discord-ingress"; const BOT_TOKEN = "discordbot-emulate-token"; const APP_ID = "900000000000000001"; @@ -55,6 +57,16 @@ const PUBLIC_KEY = "a".repeat(64); let discordApi: FakeDiscordApi; let codexApi: MockSessionApi; let bot: Discordbot; +let botOptions: DiscordbotOptions; +let botState: StateAdapter; + +const testLogger: Logger = { + child: () => testLogger, + debug: () => undefined, + error: () => undefined, + info: () => undefined, + warn: () => undefined, +}; beforeAll(async () => { discordApi = await startFakeDiscordApi(); @@ -1296,7 +1308,7 @@ describe("discordbot", () => { ).toBe(false); }); - it("keeps fail-closed guild, channel, role, and trigger-bot behavior", async () => { + it("keeps fail-closed guild, channel, role, policy, and bot behavior", async () => { // A mention from a non-allowlisted guild is dropped before any mutation. await dispatchMessage({ channelId: CHANNEL_ID, @@ -1336,7 +1348,21 @@ describe("discordbot", () => { ).toBe(false); expect(codexApi.executes).toHaveLength(0); - // A bot-authored mention is ignored unless the bot is allowlisted. + // A missing reviewed role policy is inert even when the legacy trigger + // role allowlist matches. + bot = createTestBot({ roleBindings: undefined }); + await dispatchMessage({ + channelId: CHANNEL_ID, + content: `<@${APP_ID}> with no capability policy`, + mention: true, + }); + await sleep(50); + expect(codexApi.creates).toHaveLength(0); + expect(codexApi.executes).toHaveLength(0); + + // Bot-authored messages are always denied. A legacy trigger-bot allowlist + // cannot bypass the actor-aware human policy boundary. + bot = createTestBot({ triggerBotAllowlist: [TRIGGER_BOT_ID] }); const threadId = discordApi.nextId(); discordApi.seedThreadChannel(threadId, CHANNEL_ID); const deniedBotMentionId = await dispatchMessage({ @@ -1351,28 +1377,26 @@ describe("discordbot", () => { expect(codexApi.executes).toHaveLength(0); expect(reactionsOn(threadId, deniedBotMentionId)).toEqual([]); - bot = createTestBot({ triggerBotAllowlist: [TRIGGER_BOT_ID] }); - const allowedThreadId = discordApi.nextId(); - discordApi.seedThreadChannel(allowedThreadId, CHANNEL_ID); - const allowedBotMentionId = await dispatchMessage({ - authorBot: true, - authorId: TRIGGER_BOT_ID, - channelId: allowedThreadId, - content: `<@${APP_ID}> from an allowed bot`, + // Follow-ups are re-authorized; removing the human role blocks new context + // even inside a previously authorized thread. + bot = createTestBot(); + const authorizedThreadId = discordApi.nextId(); + discordApi.seedThreadChannel(authorizedThreadId, CHANNEL_ID); + const authorizedMentionId = await dispatchMessage({ + channelId: authorizedThreadId, + content: `<@${APP_ID}> establish an authorized root`, mention: true, - thread: { id: allowedThreadId, parentId: CHANNEL_ID }, + thread: { id: authorizedThreadId, parentId: CHANNEL_ID }, }); - await waitForSettle(allowedThreadId, allowedBotMentionId); + await waitForSettle(authorizedThreadId, authorizedMentionId); expect(codexApi.executes).toHaveLength(1); - // Follow-ups are re-authorized; removing the human role blocks new context - // even inside an already-active thread. const appendCount = codexApi.appends.length; await dispatchMessage({ - channelId: allowedThreadId, + channelId: authorizedThreadId, content: "unauthorized follow-up", roleIds: [], - thread: { id: allowedThreadId, parentId: CHANNEL_ID }, + thread: { id: authorizedThreadId, parentId: CHANNEL_ID }, }); await sleep(50); expect(codexApi.appends).toHaveLength(appendCount); @@ -1380,9 +1404,11 @@ describe("discordbot", () => { }); function createTestBot(overrides: Partial = {}): Discordbot { - return createDiscordbot({ + const state = overrides.state ?? createMemoryState(); + const options: DiscordbotOptions = { apiKey: "discordbot-test-key", apiUrl: codexApi.url, + allowInProcessGatewayEmulation: true, applicationId: APP_ID, botToken: BOT_TOKEN, discordApiUrl: discordApi.url, @@ -1390,10 +1416,24 @@ function createTestBot(overrides: Partial = {}): Discordbot { guildAllowlist: [GUILD_ID], publicKey: PUBLIC_KEY, recoverRenderObligationsOnStart: false, - state: createMemoryState(), + roleBindings: [ + { + canApprove: false, + capabilityClass: "github:observe", + principalRole: "discord-observer", + priority: 0, + projectScope: [], + repositoryScope: ["508-dev/centaur"], + roleId: TRIGGER_ROLE_ID, + }, + ], triggerRoleAllowlist: [TRIGGER_ROLE_ID], ...overrides, - }); + state, + }; + botOptions = options; + botState = state; + return createDiscordbot(options); } function threadKey(threadId: string): string { @@ -1402,9 +1442,9 @@ function threadKey(threadId: string): string { /** * Seeds the raw message into the fake Discord store and dispatches it to the - * bot through the REAL adapter's forwarded-Gateway-event webhook path, the - * production ingress shape (`startGatewayListener` direct mode constructs the - * identical payloads). + * bot through the real adapter's in-process webhook emulator. Production uses + * the authenticated Gateway listener; tests explicitly pre-authorize the root + * that production admits before it creates a Discord thread. */ async function dispatchMessage(input: { attachments?: Record[]; @@ -1414,9 +1454,14 @@ async function dispatchMessage(input: { content: string; guildId?: string; mention?: boolean; + /** Existing-thread tests seed the durable root production creates upstream. */ + preauthorizeRoot?: boolean; roleIds?: string[]; thread?: { id: string; parentId: string }; }): Promise { + if (input.thread && input.mention && input.preauthorizeRoot !== false) { + await preauthorizeTestThreadRoot({ ...input, thread: input.thread }); + } const raw = discordApi.seedRawMessage(input.channelId, { attachments: input.attachments ?? [], author: { @@ -1427,6 +1472,28 @@ async function dispatchMessage(input: { }, content: input.content, }); + if (!input.thread && input.mention) { + await botState.connect(); + await admitDiscordGatewayMessage( + { + authorId: input.authorId ?? USER_ID, + authorIsBot: input.authorBot === true, + authorIsSelf: false, + channelId: input.channelId, + content: input.content, + createdTimestamp: Date.now(), + gatewayIdentityVerified: true, + guildId: input.guildId ?? GUILD_ID, + isMentioned: true, + messageId: String(raw.id), + messageType: 0, + roleIds: input.roleIds ?? [TRIGGER_ROLE_ID], + }, + botOptions, + botState, + testLogger, + ); + } const data: Record = { ...raw, guild_id: input.guildId ?? GUILD_ID, @@ -1456,6 +1523,40 @@ async function dispatchMessage(input: { return String(raw.id); } +async function preauthorizeTestThreadRoot(input: { + authorBot?: boolean; + authorId?: string; + content: string; + guildId?: string; + roleIds?: string[]; + thread: { id: string; parentId: string }; +}): Promise { + await botState.connect(); + const rootKey = `discordbot:ingress:root:${ + input.guildId ?? GUILD_ID + }:${input.thread.parentId}:${input.thread.id}`; + if (await botState.get(rootKey)) return; + await admitDiscordGatewayMessage( + { + authorId: input.authorId ?? USER_ID, + authorIsBot: input.authorBot === true, + authorIsSelf: false, + channelId: input.thread.parentId, + content: input.content, + createdTimestamp: Date.now(), + gatewayIdentityVerified: true, + guildId: input.guildId ?? GUILD_ID, + isMentioned: true, + messageId: input.thread.id, + messageType: 0, + roleIds: input.roleIds ?? [TRIGGER_ROLE_ID], + }, + botOptions, + botState, + testLogger, + ); +} + function recoveryApiMessage( key: string, messageId: string, diff --git a/services/discordbot/test/discord-allowlist.test.ts b/services/discordbot/test/discord-allowlist.test.ts index 57852b788b..7bcaf85865 100644 --- a/services/discordbot/test/discord-allowlist.test.ts +++ b/services/discordbot/test/discord-allowlist.test.ts @@ -288,12 +288,30 @@ describe("Discord ingress context", () => { }); it("treats any missing required human allowlist as inert", () => { - expect(isDiscordIngressAllowlistEmpty(options())).toBe(false); + // The legacy trigger-role allowlist is not a capability policy and cannot + // activate production ingress by itself. + expect(isDiscordIngressAllowlistEmpty(options())).toBe(true); + const reviewedPolicy = { + canApprove: false, + capabilityClass: "github:observe", + principalRole: "discord-observer", + priority: 0, + projectScope: [], + repositoryScope: ["example/example"], + roleId: "R1", + }; expect( - isDiscordIngressAllowlistEmpty(options({ channelAllowlist: [] })), + isDiscordIngressAllowlistEmpty( + options({ roleBindings: [reviewedPolicy] }), + ), + ).toBe(false); + expect( + isDiscordIngressAllowlistEmpty( + options({ channelAllowlist: [], roleBindings: [reviewedPolicy] }), + ), ).toBe(true); expect( - isDiscordIngressAllowlistEmpty(options({ triggerRoleAllowlist: [] })), + isDiscordIngressAllowlistEmpty(options({ roleBindings: [] })), ).toBe(true); }); }); diff --git a/services/discordbot/test/discord-delivery.test.ts b/services/discordbot/test/discord-delivery.test.ts new file mode 100644 index 0000000000..d62d6ac05f --- /dev/null +++ b/services/discordbot/test/discord-delivery.test.ts @@ -0,0 +1,124 @@ +import { describe, expect, it } from "bun:test"; +import { createMemoryState } from "@chat-adapter/state-memory"; +import type { Logger } from "chat"; +import { + authorizeDiscordDelivery, + deliverDiscordNotification, + DiscordDeliveryError, +} from "../src/discord-delivery"; +import type { DiscordbotOptions } from "../src/types"; + +const CHANNEL_ID = "1542739830591459369"; +const MESSAGE_ID = "1542739830591459999"; + +function options(fetchFn: typeof fetch): DiscordbotOptions { + return { + apiKey: "internal-key", + apiUrl: "http://api-rs", + applicationId: "900000000000000001", + botToken: "bot-token", + channelAllowlist: [CHANNEL_ID], + discordApiUrl: "https://discord.invalid/api/v10", + fetch: fetchFn, + guildAllowlist: ["1336096360772141148"], + publicKey: "a".repeat(64), + }; +} + +function recordingLogger(records: Record[]): Logger { + const logger: Logger = { + child: () => logger, + debug: () => undefined, + error: () => undefined, + info: (_message, data) => records.push(data as Record), + warn: (_message, data) => records.push(data as Record), + }; + return logger; +} + +describe("Discord workflow delivery", () => { + it("requires the configured internal bearer credential", () => { + expect(() => + authorizeDiscordDelivery("Bearer internal-key", "internal-key"), + ).not.toThrow(); + for (const authorization of [undefined, "internal-key", "Bearer wrong"]) { + expect(() => + authorizeDiscordDelivery(authorization, "internal-key"), + ).toThrow(DiscordDeliveryError); + } + }); + + it("posts once to an allowlisted channel with safe Discord controls", async () => { + const requests: { body: Record; url: string }[] = []; + const fetchFn = (async (input, init) => { + requests.push({ + body: JSON.parse(String(init?.body)) as Record, + url: String(input), + }); + return new Response(JSON.stringify({ id: MESSAGE_ID }), { status: 200 }); + }) as typeof fetch; + const state = createMemoryState(); + await state.connect(); + const audits: Record[] = []; + const input = { + channel_id: CHANNEL_ID, + delivery_id: "weekly-ops:sha256:abc123", + text: "Weekly operations review: one material proposal.", + }; + + const first = await deliverDiscordNotification( + input, + options(fetchFn), + state, + recordingLogger(audits), + ); + const duplicate = await deliverDiscordNotification( + input, + options(fetchFn), + state, + recordingLogger(audits), + ); + + expect(first).toEqual(duplicate); + expect(requests).toHaveLength(1); + expect(requests[0]?.url).toBe( + `https://discord.invalid/api/v10/channels/${CHANNEL_ID}/messages`, + ); + expect(requests[0]?.body).toEqual( + expect.objectContaining({ + allowed_mentions: { parse: [] }, + content: input.text, + enforce_nonce: true, + flags: 4, + }), + ); + expect(String(requests[0]?.body.nonce)).toHaveLength(24); + expect(audits).toHaveLength(1); + expect(audits[0]).not.toHaveProperty("text"); + }); + + it("rejects an unlisted destination before any Discord request", async () => { + let requests = 0; + const fetchFn = (async () => { + requests += 1; + return new Response(JSON.stringify({ id: MESSAGE_ID }), { status: 200 }); + }) as unknown as typeof fetch; + const state = createMemoryState(); + await state.connect(); + + const error = await deliverDiscordNotification( + { + channel_id: "1542739830591459000", + delivery_id: "weekly-ops:blocked", + text: "should not post", + }, + options(fetchFn), + state, + recordingLogger([]), + ).catch((caught) => caught); + + expect(error).toBeInstanceOf(DiscordDeliveryError); + expect(error.code).toBe("channel_not_allowlisted"); + expect(requests).toBe(0); + }); +}); diff --git a/services/discordbot/test/discord-ingress.test.ts b/services/discordbot/test/discord-ingress.test.ts new file mode 100644 index 0000000000..ea1937e8db --- /dev/null +++ b/services/discordbot/test/discord-ingress.test.ts @@ -0,0 +1,366 @@ +import { describe, expect, it } from "bun:test"; +import { createMemoryState } from "@chat-adapter/state-memory"; +import type { Logger, StateAdapter } from "chat"; +import { + admitDiscordGatewayMessage, + type DiscordGatewayMessageEvent, +} from "../src/discord-ingress"; +import type { DiscordbotOptions, DiscordRoleBinding } from "../src/types"; + +const NOW = Date.now(); +const APP = "900000000000000001"; +const USER = "100000000000000001"; +const OTHER_USER = "100000000000000002"; +const GUILD = "200000000000000001"; +const CHANNEL = "300000000000000001"; +const THREAD = "400000000000000001"; +const ROLE = "500000000000000001"; +const WRITE_ROLE = "500000000000000002"; + +type Audit = { message: string; data: Record }; + +function binding( + overrides: Partial = {}, +): DiscordRoleBinding { + return { + canApprove: false, + capabilityClass: "github:observe", + principalRole: "discord-observer", + priority: 0, + projectScope: ["operations"], + repositoryScope: ["508-dev/centaur"], + roleId: ROLE, + ...overrides, + }; +} + +function options(overrides: Partial = {}): DiscordbotOptions { + return { + apiUrl: "http://api.test", + applicationId: APP, + botToken: "token", + channelAllowlist: [CHANNEL], + continuationTtlMs: 1_000, + guildAllowlist: [GUILD], + publicKey: "a".repeat(64), + roleBindings: [binding()], + ...overrides, + }; +} + +function event( + messageId: string, + overrides: Partial = {}, +): DiscordGatewayMessageEvent { + return { + authorId: USER, + authorIsBot: false, + authorIsSelf: false, + channelId: CHANNEL, + content: `<@${APP}> diagnose`, + createdTimestamp: NOW, + gatewayIdentityVerified: true, + guildId: GUILD, + isMentioned: true, + messageId, + messageType: 0, + roleIds: [ROLE], + ...overrides, + }; +} + +async function harness(): Promise<{ + audits: Audit[]; + logger: Logger; + state: StateAdapter; +}> { + const audits: Audit[] = []; + const logger: Logger = { + child: () => logger, + debug: () => undefined, + error: () => undefined, + info: (message, data) => { + audits.push({ + data: (data ?? {}) as Record, + message, + }); + }, + warn: () => undefined, + }; + const state = createMemoryState(); + await state.connect(); + return { audits, logger, state }; +} + +async function reasonFor( + value: DiscordGatewayMessageEvent, + configured = options(), + now = NOW, +): Promise { + const { audits, logger, state } = await harness(); + await admitDiscordGatewayMessage(value, configured, state, logger, now); + return String(audits.at(-1)?.data.reason); +} + +describe("Discord Gateway admission", () => { + it("atomically deduplicates a verified parent-channel root before side effects", async () => { + const { audits, logger, state } = await harness(); + const root = event("600000000000000001"); + const first = await admitDiscordGatewayMessage( + root, + options(), + state, + logger, + NOW, + ); + const duplicate = await admitDiscordGatewayMessage( + root, + options(), + state, + logger, + NOW, + ); + + expect(first).toEqual( + expect.objectContaining({ + actorId: USER, + decision: "allow", + rootMessageId: root.messageId, + threadId: root.messageId, + }), + ); + expect(duplicate).toBeNull(); + expect(audits.map((audit) => audit.data.reason)).toEqual([ + "accepted", + "duplicate_delivery", + ]); + }); + + it("rejects unauthenticated, stale, replay-like, DM, and malformed transport data", async () => { + const cases: Array<[string, Partial, number?]> = [ + ["gateway_identity_unverified", { gatewayIdentityVerified: false }], + ["stale_delivery", { createdTimestamp: NOW - 10_000 }], + ["future_delivery", { createdTimestamp: NOW + 61_000 }], + ["direct_message", { guildId: "@me" }], + ["invalid_event", { authorId: "mutable-user-name" }], + ]; + let suffix = 10; + for (const [reason, overrides] of cases) { + expect( + await reasonFor( + event(`6000000000000000${suffix++}`, overrides), + options({ ingressMaxEventAgeMs: 5_000 }), + ), + ).toBe(reason); + } + }); + + it("records stable default-deny reasons for every rejected actor context", async () => { + const cases: Array<[string, Partial]> = [ + ["guild_not_allowlisted", { guildId: "200000000000000099" }], + ["channel_not_allowlisted", { channelId: "300000000000000099" }], + ["role_not_authorized", { roleIds: [] }], + ["bot_message", { authorIsBot: true }], + ["self_message", { authorIsSelf: true }], + ["webhook_message", { webhookId: "700000000000000001" }], + ["unsupported_message_type", { messageType: 7 }], + ]; + let suffix = 30; + for (const [reason, overrides] of cases) { + expect( + await reasonFor(event(`6000000000000000${suffix++}`, overrides)), + ).toBe(reason); + } + }); + + it("requires a mention root in the parent and never roots an unrelated thread", async () => { + expect( + await reasonFor( + event("600000000000000050", { + content: "sounds actionable", + isMentioned: false, + }), + ), + ).toBe("root_trigger_required"); + expect( + await reasonFor( + event("600000000000000051", { + threadId: THREAD, + }), + ), + ).toBe("authorized_root_missing"); + }); + + it("binds continuation to the same actor, thread, current role policy, and TTL", async () => { + const { audits, logger, state } = await harness(); + const configured = options({ + roleBindings: [ + binding(), + binding({ + capabilityClass: "github:act", + principalRole: "discord-operator", + priority: 10, + roleId: WRITE_ROLE, + }), + ], + }); + await admitDiscordGatewayMessage( + event(THREAD), + configured, + state, + logger, + NOW, + ); + + const follow = (id: string, overrides = {}) => + event(id, { + content: "continue", + isMentioned: false, + threadId: THREAD, + ...overrides, + }); + expect( + await admitDiscordGatewayMessage( + follow("600000000000000060"), + configured, + state, + logger, + NOW + 999, + ), + ).toEqual(expect.objectContaining({ decision: "allow", actorId: USER })); + + const denied: Array<[DiscordGatewayMessageEvent, string, number]> = [ + [ + follow("600000000000000061", { authorId: OTHER_USER }), + "actor_mismatch", + NOW + 100, + ], + [ + follow("600000000000000062", { roleIds: [] }), + "role_not_authorized", + NOW + 100, + ], + [ + follow("600000000000000063", { roleIds: [ROLE, WRITE_ROLE] }), + "policy_changed_requires_root_trigger", + NOW + 100, + ], + [follow("600000000000000064"), "root_expired", NOW + 1_001], + ]; + for (const [candidate, expected, now] of denied) { + expect( + await admitDiscordGatewayMessage( + candidate, + configured, + state, + logger, + now, + ), + ).toBeNull(); + expect(audits.at(-1)?.data.reason).toBe(expected); + } + }); + + it("accepts only an actor-scoped, idempotent stop control", async () => { + const { audits, logger, state } = await harness(); + const configured = options(); + await admitDiscordGatewayMessage( + event(THREAD), + configured, + state, + logger, + NOW, + ); + const stop = event("600000000000000070", { + content: `<@${APP}> stop`, + threadId: THREAD, + }); + const accepted = await admitDiscordGatewayMessage( + stop, + configured, + state, + logger, + NOW + 100, + ); + expect(accepted).toEqual( + expect.objectContaining({ control: "stop", actorId: USER }), + ); + expect( + await admitDiscordGatewayMessage( + stop, + configured, + state, + logger, + NOW + 100, + ), + ).toBeNull(); + expect(audits.at(-1)?.data.reason).toBe("duplicate_delivery"); + + const outsider = event("600000000000000071", { + authorId: OTHER_USER, + content: `<@${APP}> cancel`, + threadId: THREAD, + }); + expect( + await admitDiscordGatewayMessage( + outsider, + configured, + state, + logger, + NOW + 100, + ), + ).toBeNull(); + expect(audits.at(-1)?.data.reason).toBe("actor_mismatch"); + }); + + it("accepts an exact proposal approval only for a reviewed approval role", async () => { + const fingerprint = `sha256:${"a".repeat(64)}`; + const { audits, logger, state } = await harness(); + const configured = options({ + roleBindings: [binding({ canApprove: true })], + }); + const approval = event("600000000000000080", { + content: `<@${APP}> approve ${fingerprint}`, + }); + + const accepted = await admitDiscordGatewayMessage( + approval, + configured, + state, + logger, + NOW, + ); + + expect(accepted).toEqual( + expect.objectContaining({ + control: "approve", + proposalFingerprint: fingerprint, + rootMessageId: approval.messageId, + threadId: approval.messageId, + }), + ); + expect(audits.at(-1)?.data).toMatchObject({ + control: "approve", + decision: "allow", + reason: "accepted", + }); + }); + + it("rejects unauthorized and malformed approval commands", async () => { + expect( + await reasonFor( + event("600000000000000081", { + content: `<@${APP}> approve sha256:${"a".repeat(64)}`, + }), + ), + ).toBe("approval_not_authorized"); + expect( + await reasonFor( + event("600000000000000082", { + content: `<@${APP}> approve not-a-fingerprint`, + }), + options({ roleBindings: [binding({ canApprove: true })] }), + ), + ).toBe("invalid_approval_command"); + }); +}); diff --git a/services/discordbot/test/discord-policy.test.ts b/services/discordbot/test/discord-policy.test.ts new file mode 100644 index 0000000000..95e3f0cd10 --- /dev/null +++ b/services/discordbot/test/discord-policy.test.ts @@ -0,0 +1,183 @@ +import { describe, expect, it } from "bun:test"; +import { + parseDiscordRoleBindings, + resolveDiscordPermissionBundle, +} from "../src/discord-policy"; +import type { DiscordbotOptions, DiscordRoleBinding } from "../src/types"; + +const ROLE_A = "500000000000000001"; +const ROLE_B = "500000000000000002"; + +function binding( + overrides: Partial = {}, +): DiscordRoleBinding { + return { + canApprove: false, + capabilityClass: "github:observe", + principalRole: "discord-observer", + priority: 0, + projectScope: ["operations"], + repositoryScope: ["508-dev/centaur"], + roleId: ROLE_A, + ...overrides, + }; +} + +function options(roleBindings?: readonly DiscordRoleBinding[]): DiscordbotOptions { + return { + apiUrl: "http://api.test", + applicationId: "900000000000000001", + botToken: "token", + publicKey: "a".repeat(64), + roleBindings, + }; +} + +describe("parseDiscordRoleBindings", () => { + it("parses reviewed numeric role policy with exact scopes", () => { + expect( + parseDiscordRoleBindings( + JSON.stringify([ + { + role_id: ROLE_A, + capability_class: "github:observe", + can_approve: true, + principal_role: "discord-observer", + priority: 10, + project_scope: ["operations"], + repository_scope: ["508-dev/centaur", "508-dev/508-infra"], + }, + ]), + ), + ).toEqual([ + { + canApprove: true, + capabilityClass: "github:observe", + principalRole: "discord-observer", + priority: 10, + projectScope: ["operations"], + repositoryScope: ["508-dev/centaur", "508-dev/508-infra"], + roleId: ROLE_A, + }, + ]); + }); + + it("rejects malformed, duplicate, wildcard, and empty-scope policy", () => { + const invalid = [ + "not-json", + "[]", + JSON.stringify([{ role_id: "Administrators" }]), + JSON.stringify([ + { + role_id: ROLE_A, + capability_class: "github:observe", + principal_role: "discord-observer", + project_scope: [], + repository_scope: ["508-dev/*"], + }, + ]), + JSON.stringify([ + { + role_id: ROLE_A, + capability_class: "github:observe", + principal_role: "discord-observer", + can_approve: "yes", + project_scope: [], + repository_scope: ["508-dev/centaur"], + }, + ]), + JSON.stringify([ + { + role_id: ROLE_A, + capability_class: "github:observe", + principal_role: "discord-observer", + project_scope: [], + repository_scope: [], + }, + ]), + JSON.stringify([ + { + role_id: ROLE_A, + capability_class: "github:observe", + principal_role: "discord-observer", + project_scope: [], + repository_scope: ["508-dev/centaur"], + }, + { + role_id: ROLE_A, + capability_class: "github:act", + principal_role: "discord-operator", + project_scope: [], + repository_scope: ["508-dev/centaur"], + }, + ]), + ]; + for (const raw of invalid) { + expect(() => parseDiscordRoleBindings(raw)).toThrow(); + } + }); +}); + +describe("resolveDiscordPermissionBundle", () => { + it("is inert without policy and denies a missing immutable role id", () => { + expect(resolveDiscordPermissionBundle([ROLE_A], options())).toEqual({ + decision: "deny", + reason: "role_policy_missing", + }); + expect( + resolveDiscordPermissionBundle([ROLE_B], options([binding()])), + ).toEqual({ decision: "deny", reason: "role_not_authorized" }); + }); + + it("uses explicit priority instead of unioning capabilities", () => { + const result = resolveDiscordPermissionBundle( + [ROLE_A, ROLE_B], + options([ + binding(), + binding({ + capabilityClass: "github:act", + principalRole: "discord-operator", + priority: 20, + repositoryScope: ["508-dev/508-infra"], + roleId: ROLE_B, + }), + ]), + ); + expect(result.decision).toBe("allow"); + if (result.decision === "allow") { + expect(result.bundle).toEqual( + expect.objectContaining({ + capabilityClass: "github:act", + principalRole: "discord-operator", + repositoryScope: ["508-dev/508-infra"], + sourceRoleId: ROLE_B, + }), + ); + expect(result.bundle.fingerprint).toMatch(/^sha256:[a-f0-9]{64}$/); + } + }); + + it("allows equal-priority aliases only for the identical bundle", () => { + const same = resolveDiscordPermissionBundle( + [ROLE_A, ROLE_B], + options([binding(), binding({ roleId: ROLE_B })]), + ); + expect(same.decision).toBe("allow"); + + const ambiguous = resolveDiscordPermissionBundle( + [ROLE_A, ROLE_B], + options([ + binding(), + binding({ + capabilityClass: "github:act", + principalRole: "discord-operator", + roleId: ROLE_B, + }), + ]), + ); + expect(ambiguous).toEqual({ + decision: "deny", + reason: "role_policy_ambiguous", + }); + }); +}); diff --git a/services/discordbot/test/session-api.test.ts b/services/discordbot/test/session-api.test.ts index 20bf6bdb8a..47f3a17a09 100644 --- a/services/discordbot/test/session-api.test.ts +++ b/services/discordbot/test/session-api.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "bun:test"; import type { Attachment } from "chat"; import { + approveActionProposal, codexAttachmentInput, forwardToSessionApi, isContentlessApiMessage, @@ -11,10 +12,12 @@ import { SessionApiError, toCodexInputLines, } from "../src/session-api"; +import type { DiscordAcceptedAdmission } from "../src/discord-ingress"; import type { DiscordbotApiMessage, DiscordbotFetch, DiscordbotOptions, + DiscordExecutionPolicy, ForwardSessionInput, } from "../src/types"; @@ -46,6 +49,21 @@ function apiMessage( }; } +function executionPolicy(): DiscordExecutionPolicy { + return { + actorId: "100000000000000001", + capabilityClass: "github:observe", + channelId: "100000000000000002", + guildId: "100000000000000003", + policyFingerprint: "sha256:test", + principalRole: "discord-observer", + projectScope: [], + repositoryScope: ["508-dev/centaur"], + rootMessageId: "100000000000000004", + threadId: "100000000000000005", + }; +} + describe("isRetryableSessionApiError", () => { it("respects the SessionApiError retryable flag", () => { const retryable = new SessionApiError({ @@ -163,6 +181,7 @@ describe("forwardToSessionApi principal naming", () => { messages: [apiMessage()], onEventId: () => undefined, openStream: false, + policy: executionPolicy(), threadId: "discord:G1:C1:T1", ...overrides, }; @@ -193,6 +212,116 @@ describe("forwardToSessionApi principal naming", () => { }); }); +describe("approveActionProposal", () => { + function admission( + fingerprint: string, + ): DiscordAcceptedAdmission { + return { + actorId: "100000000000000001", + channelId: "300000000000000001", + control: "approve", + decision: "allow", + guildId: "200000000000000001", + messageId: "600000000000000001", + policy: { + canApprove: true, + capabilityClass: "github:approve", + fingerprint: `sha256:${"b".repeat(64)}`, + principalRole: "discord-operator", + projectScope: ["operations"], + repositoryScope: ["508-dev/508-workflows"], + sourceRoleId: "500000000000000001", + }, + proposalFingerprint: fingerprint, + reason: "accepted", + receivedAt: Date.now(), + rootMessageId: "600000000000000001", + roleIds: ["500000000000000001"], + threadId: "600000000000000001", + version: 1, + }; + } + + it("sends only the authenticated actor policy and exact proposal fingerprint", async () => { + let requestUrl = ""; + let requestBody: JsonRecord = {}; + let authorization = ""; + const fingerprint = `sha256:${"a".repeat(64)}`; + const fetchFn: DiscordbotFetch = async (input, init) => { + requestUrl = String(input); + requestBody = JSON.parse(String(init?.body)) as JsonRecord; + authorization = new Headers(init?.headers).get("authorization") ?? ""; + return Response.json({ + action_run_id: "run-1", + action_task_id: "task-1", + action_workflow: "execute_approved_improvement", + console_url: + "https://centaur.test/console/workflows/execute_approved_improvement", + created: true, + fingerprint, + ok: true, + }); + }; + const accepted = admission(fingerprint); + + const result = await approveActionProposal( + { + apiKey: "discord-api-key", + apiUrl: "http://api.test", + applicationId: "app", + botToken: "token", + fetch: fetchFn, + publicKey: "key", + }, + accepted, + ); + + expect(requestUrl).toBe( + `http://api.test/api/workflows/proposals/${encodeURIComponent(fingerprint)}/approve`, + ); + expect(authorization).toBe("Bearer discord-api-key"); + expect(requestBody).toEqual({ + actor_id: accepted.actorId, + capability_class: accepted.policy.capabilityClass, + channel_id: accepted.channelId, + guild_id: accepted.guildId, + message_id: accepted.messageId, + policy_fingerprint: accepted.policy.fingerprint, + principal_role: accepted.policy.principalRole, + repository_scope: accepted.policy.repositoryScope, + root_message_id: accepted.rootMessageId, + thread_id: accepted.threadId, + }); + expect(result.created).toBe(true); + }); + + it("rejects a mismatched success response", async () => { + const fingerprint = `sha256:${"a".repeat(64)}`; + const fetchFn: DiscordbotFetch = async () => + Response.json({ + action_run_id: "run-1", + action_task_id: "task-1", + action_workflow: "execute_approved_improvement", + created: true, + fingerprint: `sha256:${"c".repeat(64)}`, + ok: true, + }); + + await expect( + approveActionProposal( + { + apiUrl: "http://api.test", + applicationId: "app", + botToken: "token", + fetch: fetchFn, + publicKey: "key", + }, + admission(fingerprint), + ), + ).rejects.toMatchObject({ status: 502 }); + }); +}); + describe("isContentlessApiMessage", () => { it("is true for empty text with no attachments (sticker/forward/poll)", () => { expect(isContentlessApiMessage(apiMessage({ text: "" }))).toBe(true); @@ -341,7 +470,11 @@ describe("toCodexInputLines", () => { ], }); - const lines = toCodexInputLines(message, message.threadId); + const lines = toCodexInputLines( + message, + message.threadId, + executionPolicy(), + ); expect(lines).toHaveLength(1); const content = JSON.parse(lines[0]!).message.content as JsonRecord[]; @@ -362,7 +495,11 @@ describe("toCodexInputLines", () => { ], }); - const lines = toCodexInputLines(message, message.threadId); + const lines = toCodexInputLines( + message, + message.threadId, + executionPolicy(), + ); expect(lines.length).toBeGreaterThan(1); const chunks = lines.slice(0, -1).map((line) => JSON.parse(line)); diff --git a/services/workflow-python/api/workflow_engine.py b/services/workflow-python/api/workflow_engine.py index d7d6f152e9..54ebb2f208 100644 --- a/services/workflow-python/api/workflow_engine.py +++ b/services/workflow-python/api/workflow_engine.py @@ -210,6 +210,41 @@ async def start_workflow( request["idempotency_key"] = idempotency_key return await self._rpc.request(request) + async def put_action_proposal( + self, + proposal: dict[str, Any], + *, + expires_in_seconds: int, + ) -> dict[str, Any]: + """Validate and persist one canonical, expiring action proposal.""" + return await self._rpc.request( + { + "type": "ctx.proposal.put", + "request": { + "proposal": proposal, + "expires_in_seconds": expires_in_seconds, + }, + } + ) + + async def transition_notification_state( + self, + scope: str, + semantic_fingerprint: str | None, + state_class: str, + ) -> dict[str, Any]: + """Claim only new/material/resolved operator notification states.""" + return await self._rpc.request( + { + "type": "ctx.notification.transition", + "request": { + "scope": scope, + "semantic_fingerprint": semantic_fingerprint, + "state_class": state_class, + }, + } + ) + async def call_tool(self, tool: str, method: str, args: dict[str, Any] | None = None) -> Any: return await WorkflowToolManager(self._rpc).call_tool_raw(tool, method, args or {}) @@ -223,6 +258,23 @@ async def post_to_slack(self, channel: str, text: str, **kwargs: Any) -> Any: } ) + async def post_to_discord( + self, + channel_id: str, + text: str, + *, + delivery_id: str, + ) -> Any: + """Post one idempotent, non-mentioning message through discordbot.""" + return await self._rpc.request( + { + "type": "ctx.post_to_discord", + "channel_id": channel_id, + "delivery_id": delivery_id, + "text": text, + } + ) + def duration_seconds(value: dt.timedelta | int | float) -> float: if isinstance(value, dt.timedelta): diff --git a/services/workflow-python/tests/test_workflow_host.py b/services/workflow-python/tests/test_workflow_host.py index f7756f495d..75d82eee42 100644 --- a/services/workflow-python/tests/test_workflow_host.py +++ b/services/workflow-python/tests/test_workflow_host.py @@ -86,8 +86,18 @@ async def request(self, payload): "run_id": "run-child", "created": True, } + if message_type == "ctx.proposal.put": + return { + "created": True, + "fingerprint": "sha256:" + ("a" * 64), + "status": "pending", + } + if message_type == "ctx.notification.transition": + return {"notify": True, "resolution": False, "state_persisted": True} if message_type == "ctx.post_to_slack": return {"channel": payload["channel"], "ts": "1710000000.000100"} + if message_type == "ctx.post_to_discord": + return {"channel_id": payload["channel_id"], "message_id": "1542739830591459999"} if message_type == "ctx.sleep": return {"slept": True} if message_type == "ctx.event.wait": @@ -473,6 +483,85 @@ def test_post_to_slack_sends_optional_custom_identity(self) -> None: ], ) + def test_post_to_discord_requires_a_stable_delivery_id(self) -> None: + host = load_workflow_host() + rpc = RequestRpc() + ctx = host.WorkflowContext( + rpc, + run_id="run-123", + task_id="task-456", + workflow_name="weekly_ops_review", + ) + + result = asyncio.run( + ctx.post_to_discord( + "1542739830591459369", + "One material weekly finding.", + delivery_id="weekly-ops:sha256:abc123", + ) + ) + + self.assertEqual(result["message_id"], "1542739830591459999") + self.assertEqual( + rpc.requests, + [ + { + "type": "ctx.post_to_discord", + "channel_id": "1542739830591459369", + "delivery_id": "weekly-ops:sha256:abc123", + "text": "One material weekly finding.", + } + ], + ) + + def test_action_proposal_and_notification_state_use_runtime_primitives(self) -> None: + host = load_workflow_host() + rpc = RequestRpc() + ctx = host.WorkflowContext( + rpc, + run_id="run-123", + task_id="task-456", + workflow_name="weekly_ops_review", + ) + proposal = { + "action_type": "github:create_improvement_pr", + "repository": "508-dev/508-workflows", + } + + created = asyncio.run( + ctx.put_action_proposal(proposal, expires_in_seconds=604800) + ) + transition = asyncio.run( + ctx.transition_notification_state( + "weekly_ops_review:automations", + created["fingerprint"], + "proposal_pending", + ) + ) + + self.assertTrue(created["created"]) + self.assertTrue(transition["notify"]) + self.assertEqual( + rpc.requests, + [ + { + "type": "ctx.proposal.put", + "request": { + "proposal": proposal, + "expires_in_seconds": 604800, + }, + }, + { + "type": "ctx.notification.transition", + "request": { + "scope": "weekly_ops_review:automations", + "semantic_fingerprint": "sha256:" + ("a" * 64), + "state_class": "proposal_pending", + }, + }, + ], + ) + def test_create_pool_retries_transient_connection_failure(self) -> None: host = load_workflow_host() calls = [] From 707e774822b894b908b870bd337c5151a56efe6f Mon Sep 17 00:00:00 2001 From: Michael Wu Date: Tue, 1 Sep 2026 14:36:48 +0900 Subject: [PATCH 02/37] fix: make notification claims replay safe --- .../0054_workflow_action_proposals.sql | 6 +- .../centaur-workflows/src/action_proposals.rs | 128 +++++++++++++++--- 2 files changed, 112 insertions(+), 22 deletions(-) diff --git a/services/api-rs/crates/centaur-session-sqlx/migrations/0054_workflow_action_proposals.sql b/services/api-rs/crates/centaur-session-sqlx/migrations/0054_workflow_action_proposals.sql index c6c5e22cdc..6a14b6a5e5 100644 --- a/services/api-rs/crates/centaur-session-sqlx/migrations/0054_workflow_action_proposals.sql +++ b/services/api-rs/crates/centaur-session-sqlx/migrations/0054_workflow_action_proposals.sql @@ -62,11 +62,15 @@ create table workflow_semantic_notification_states ( state_class text not null, active boolean not null, last_workflow_run_id text not null, + last_notification_workflow_run_id text, last_notified_at timestamptz, created_at timestamptz not null default now(), updated_at timestamptz not null default now(), constraint workflow_semantic_notification_fingerprint_check - check (semantic_fingerprint is null or semantic_fingerprint ~ '^sha256:[0-9a-f]{64}$') + check (semantic_fingerprint is null or semantic_fingerprint ~ '^sha256:[0-9a-f]{64}$'), + constraint workflow_semantic_notification_active_check + check ((active and semantic_fingerprint is not null) + or (not active and semantic_fingerprint is null)) ); revoke all on workflow_action_proposals from public; diff --git a/services/api-rs/crates/centaur-workflows/src/action_proposals.rs b/services/api-rs/crates/centaur-workflows/src/action_proposals.rs index 50b4f80036..e80615fafb 100644 --- a/services/api-rs/crates/centaur-workflows/src/action_proposals.rs +++ b/services/api-rs/crates/centaur-workflows/src/action_proposals.rs @@ -109,6 +109,14 @@ pub struct NotificationTransitionResponse { pub state_persisted: bool, } +#[derive(Clone, Debug)] +struct PreviousNotificationState { + active: bool, + last_notification_workflow_run_id: Option, + semantic_fingerprint: Option, + state_class: String, +} + impl ActionProposal { pub fn normalize_and_fingerprint(mut self) -> Result<(Self, String), WorkflowRuntimeError> { self.action_type = bounded_identifier("action_type", &self.action_type, 96)?; @@ -436,37 +444,42 @@ async fn try_transition_notification_state( let active = request.semantic_fingerprint.is_some(); let mut tx = client.pool().begin().await?; let previous = sqlx::query( - "SELECT semantic_fingerprint, state_class, active FROM workflow_semantic_notification_states \ + "SELECT semantic_fingerprint, state_class, active, last_notification_workflow_run_id \ + FROM workflow_semantic_notification_states \ WHERE scope = $1 FOR UPDATE", ) .bind(&scope) .fetch_optional(&mut *tx) .await?; - let (notify, resolution) = match previous.as_ref() { - None => (active, false), - Some(row) => { - let previous_active: bool = row.try_get("active")?; - let previous_fingerprint: Option = row.try_get("semantic_fingerprint")?; - let previous_class: String = row.try_get("state_class")?; - if !active { - (previous_active, previous_active) - } else { - ( - !previous_active - || previous_fingerprint != request.semantic_fingerprint - || previous_class != state_class, - false, - ) - } - } - }; + let previous = previous + .map(|row| { + Ok::<_, WorkflowRuntimeError>(PreviousNotificationState { + active: row.try_get("active")?, + last_notification_workflow_run_id: row + .try_get("last_notification_workflow_run_id")?, + semantic_fingerprint: row.try_get("semantic_fingerprint")?, + state_class: row.try_get("state_class")?, + }) + }) + .transpose()?; + let (notify, resolution) = notification_transition_decision( + previous.as_ref(), + active, + request.semantic_fingerprint.as_deref(), + &state_class, + workflow_run_id, + ); sqlx::query( "INSERT INTO workflow_semantic_notification_states (\ - scope, semantic_fingerprint, state_class, active, last_workflow_run_id, last_notified_at) \ - VALUES ($1, $2, $3, $4, $5, CASE WHEN $6 THEN NOW() END) \ + scope, semantic_fingerprint, state_class, active, last_workflow_run_id, \ + last_notification_workflow_run_id, last_notified_at) \ + VALUES ($1, $2, $3, $4, $5, CASE WHEN $6 THEN $5 END, CASE WHEN $6 THEN NOW() END) \ ON CONFLICT (scope) DO UPDATE SET semantic_fingerprint = EXCLUDED.semantic_fingerprint, \ state_class = EXCLUDED.state_class, active = EXCLUDED.active, \ last_workflow_run_id = EXCLUDED.last_workflow_run_id, \ + last_notification_workflow_run_id = CASE WHEN $6 \ + THEN EXCLUDED.last_notification_workflow_run_id \ + ELSE workflow_semantic_notification_states.last_notification_workflow_run_id END, \ last_notified_at = CASE WHEN $6 THEN NOW() ELSE workflow_semantic_notification_states.last_notified_at END, \ updated_at = NOW()", ) @@ -486,6 +499,28 @@ async fn try_transition_notification_state( }) } +fn notification_transition_decision( + previous: Option<&PreviousNotificationState>, + active: bool, + semantic_fingerprint: Option<&str>, + state_class: &str, + workflow_run_id: &str, +) -> (bool, bool) { + let Some(previous) = previous else { + return (active, false); + }; + let unchanged = previous.active == active + && previous.semantic_fingerprint.as_deref() == semantic_fingerprint + && previous.state_class == state_class; + if unchanged && previous.last_notification_workflow_run_id.as_deref() == Some(workflow_run_id) { + return (true, !active); + } + if !active { + return (previous.active, previous.active); + } + (!unchanged, false) +} + async fn proposal_row( client: &Client, fingerprint: &str, @@ -791,6 +826,10 @@ mod tests { assert_eq!(first.repository, "508-dev/508-workflows"); assert_eq!(first, second); assert_eq!(first_fingerprint, second_fingerprint); + assert_eq!( + first_fingerprint, + "sha256:10dee99e59991ad9b3c2caf55898a1647d627e03469da1ac052b67aaa8360659" + ); } #[test] @@ -872,6 +911,53 @@ mod tests { ); } + #[test] + fn notification_claim_replays_only_within_the_claiming_workflow_run() { + let fingerprint = format!("sha256:{}", "a".repeat(64)); + let previous = PreviousNotificationState { + active: true, + last_notification_workflow_run_id: Some("run-claim".to_owned()), + semantic_fingerprint: Some(fingerprint.clone()), + state_class: "proposal_pending".to_owned(), + }; + + assert_eq!( + notification_transition_decision( + Some(&previous), + true, + Some(&fingerprint), + "proposal_pending", + "run-claim", + ), + (true, false) + ); + assert_eq!( + notification_transition_decision( + Some(&previous), + true, + Some(&fingerprint), + "proposal_pending", + "run-later", + ), + (false, false) + ); + + let resolved = PreviousNotificationState { + active: false, + last_notification_workflow_run_id: Some("run-resolve".to_owned()), + semantic_fingerprint: None, + state_class: "clear".to_owned(), + }; + assert_eq!( + notification_transition_decision(Some(&resolved), false, None, "clear", "run-resolve",), + (true, true) + ); + assert_eq!( + notification_transition_decision(Some(&resolved), false, None, "clear", "run-later",), + (false, false) + ); + } + #[test] fn approval_request_requires_exact_discord_and_repository_scope() { let fingerprint = format!("sha256:{}", "a".repeat(64)); From e3cf10c43b6ff4550dacfb7ff4409857dc36a8a2 Mon Sep 17 00:00:00 2001 From: Michael Wu Date: Tue, 1 Sep 2026 15:12:04 +0900 Subject: [PATCH 03/37] Enforce Discord actor capability scopes --- .../crates/centaur-api-server/src/args.rs | 10 + .../centaur-api-server/src/tool_discovery.rs | 59 +++++- .../crates/centaur-iron-control/src/models.rs | 10 + .../centaur-iron-control/src/principal.rs | 5 + .../centaur-iron-control/src/session.rs | 180 ++++++++++++++++-- .../crates/centaur-perms/src/principal.rs | 5 + .../api-rs/crates/centaur-perms/src/tests.rs | 23 ++- .../api-rs/crates/centaur-perms/src/tools.rs | 46 +++++ .../crates/centaur-perms/src/translate.rs | 15 +- .../crates/centaur-workflows/src/lib.rs | 5 + services/console/app/models/principal.rb | 2 +- .../console/test/models/principal_test.rb | 11 ++ tools/README.md | 9 + 13 files changed, 358 insertions(+), 22 deletions(-) diff --git a/services/api-rs/crates/centaur-api-server/src/args.rs b/services/api-rs/crates/centaur-api-server/src/args.rs index af622f307b..d9cabb7ec4 100644 --- a/services/api-rs/crates/centaur-api-server/src/args.rs +++ b/services/api-rs/crates/centaur-api-server/src/args.rs @@ -763,6 +763,11 @@ impl SandboxArgs { slack_channel_id: None, slack_team_id: None, slack_email: None, + sandbox_repo_cache: None, + sandbox_observability_enabled: None, + sandbox_sessions_read_enabled: None, + sandbox_workflows_read_enabled: None, + sandbox_workflows_write_enabled: None, }) .await?; let workflow_host = client @@ -778,6 +783,11 @@ impl SandboxArgs { slack_channel_id: None, slack_team_id: None, slack_email: None, + sandbox_repo_cache: None, + sandbox_observability_enabled: None, + sandbox_sessions_read_enabled: None, + sandbox_workflows_read_enabled: None, + sandbox_workflows_write_enabled: None, }) .await?; Ok(IronControlRuntime { diff --git a/services/api-rs/crates/centaur-api-server/src/tool_discovery.rs b/services/api-rs/crates/centaur-api-server/src/tool_discovery.rs index 8dd06c8d28..60d6021546 100644 --- a/services/api-rs/crates/centaur-api-server/src/tool_discovery.rs +++ b/services/api-rs/crates/centaur-api-server/src/tool_discovery.rs @@ -613,6 +613,8 @@ struct HttpSecret { labels: BTreeMap, mode: HttpSecretMode, hosts: Vec, + http_methods: Vec, + paths: Vec, replacer: String, match_headers: Vec, match_path: bool, @@ -733,6 +735,8 @@ fn parse_secret( labels: labels.clone(), mode: HttpSecretMode::Replace, hosts: default_hosts.to_vec(), + http_methods: Vec::new(), + paths: Vec::new(), replacer: name, match_headers: DEFAULT_MATCH_HEADERS .iter() @@ -786,6 +790,23 @@ fn parse_http_secret( ))); } let mode = optional_str(table, "mode").unwrap_or("replace"); + let http_methods = optional_string_array(table.get("http_methods"))?.unwrap_or_default(); + if http_methods.iter().any(|method| { + !matches!( + method.as_str(), + "GET" | "HEAD" | "POST" | "PUT" | "PATCH" | "DELETE" | "OPTIONS" | "CONNECT" | "*" + ) + }) { + return Err(ToolDiscoveryError::Invalid(format!( + "HTTP secret {name:?} 'http_methods' contains an unsupported method" + ))); + } + let paths = optional_string_array(table.get("paths"))?.unwrap_or_default(); + if paths.iter().any(|path| !path.starts_with('/')) { + return Err(ToolDiscoveryError::Invalid(format!( + "HTTP secret {name:?} 'paths' entries must start with '/'" + ))); + } match mode { "replace" => { let match_headers = @@ -804,6 +825,8 @@ fn parse_http_secret( labels: labels.clone(), mode: HttpSecretMode::Replace, hosts, + http_methods, + paths, replacer, match_headers, match_path, @@ -839,6 +862,8 @@ fn parse_http_secret( labels: labels.clone(), mode: HttpSecretMode::Inject, hosts, + http_methods, + paths, replacer: String::new(), match_headers: Vec::new(), match_path: false, @@ -1108,7 +1133,7 @@ fn http_secret_transform(secrets: &[ToolSecret]) -> Result, To let mut entry = Secret { id: Some(key.name.clone()), source: Some(yaml_map([("placeholder", yaml_string(&key.secret_ref))])?), - rules: host_rules(hosts)?, + rules: http_rules(hosts, &key.http_methods, &key.paths)?, ..Default::default() }; entry.extra.insert("labels".to_owned(), yaml_value(labels)?); @@ -1157,6 +1182,8 @@ struct HttpSecretKey { name: String, secret_ref: String, mode: HttpSecretMode, + http_methods: Vec, + paths: Vec, replacer: String, match_headers: Vec, match_path: bool, @@ -1172,6 +1199,8 @@ impl From<&HttpSecret> for HttpSecretKey { name: secret.name.clone(), secret_ref: secret.secret_ref.clone(), mode: secret.mode.clone(), + http_methods: secret.http_methods.clone(), + paths: secret.paths.clone(), replacer: secret.replacer.clone(), match_headers: secret.match_headers.clone(), match_path: secret.match_path, @@ -1463,6 +1492,26 @@ fn host_rules(hosts: BTreeSet) -> Result, ToolDiscoveryEr .collect() } +fn http_rules( + hosts: BTreeSet, + methods: &[String], + paths: &[String], +) -> Result, ToolDiscoveryError> { + hosts + .into_iter() + .map(|host| { + let mut rule = BTreeMap::from([("host", yaml_string(&host))]); + if !methods.is_empty() { + rule.insert("http_methods", yaml_value(methods)?); + } + if !paths.is_empty() { + rule.insert("paths", yaml_value(paths)?); + } + yaml_value(rule) + }) + .collect() +} + fn host_rules_set(hosts: &[String]) -> Result, ToolDiscoveryError> { host_rules(hosts.iter().cloned().collect()) } @@ -1745,7 +1794,7 @@ secrets = [{type = "http", name = "BASE_TOKEN", match_headers = ["Authorization" description = "overlay alpha" [tool.centaur] -secrets = [{type = "http", name = "OVERLAY_TOKEN", match_query = true, hosts = ["api.overlay.test"]}] +secrets = [{type = "http", name = "OVERLAY_TOKEN", match_query = true, hosts = ["api.overlay.test"], http_methods = ["GET"], paths = ["/v1/*"]}] "#, ); write_tool( @@ -1768,6 +1817,12 @@ secrets = [ let secrets = discovered.fragment.transforms[0].config.secrets.clone(); assert_eq!(secrets.len(), 1); assert_eq!(secrets[0].id.as_deref(), Some("OVERLAY_TOKEN")); + assert_eq!( + secrets[0].rules[0]["host"].as_str(), + Some("api.overlay.test") + ); + assert_eq!(secrets[0].rules[0]["http_methods"][0].as_str(), Some("GET")); + assert_eq!(secrets[0].rules[0]["paths"][0].as_str(), Some("/v1/*")); let labels = secrets[0] .extra .get("labels") diff --git a/services/api-rs/crates/centaur-iron-control/src/models.rs b/services/api-rs/crates/centaur-iron-control/src/models.rs index 89d48ad8b2..d9890d4c7f 100644 --- a/services/api-rs/crates/centaur-iron-control/src/models.rs +++ b/services/api-rs/crates/centaur-iron-control/src/models.rs @@ -467,6 +467,16 @@ pub struct PrincipalInput { pub slack_team_id: Option, #[serde(skip_serializing_if = "Option::is_none")] pub slack_email: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub sandbox_repo_cache: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub sandbox_observability_enabled: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub sandbox_sessions_read_enabled: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub sandbox_workflows_read_enabled: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub sandbox_workflows_write_enabled: Option, } /// A principal as returned by iron-control. Unknown fields are ignored, so this diff --git a/services/api-rs/crates/centaur-iron-control/src/principal.rs b/services/api-rs/crates/centaur-iron-control/src/principal.rs index 184829c08d..43df3bed45 100644 --- a/services/api-rs/crates/centaur-iron-control/src/principal.rs +++ b/services/api-rs/crates/centaur-iron-control/src/principal.rs @@ -74,6 +74,11 @@ impl PrincipalRef { slack_team_id: self.slack_team_id.clone(), slack_email: None, labels, + sandbox_repo_cache: None, + sandbox_observability_enabled: None, + sandbox_sessions_read_enabled: None, + sandbox_workflows_read_enabled: None, + sandbox_workflows_write_enabled: None, } } } diff --git a/services/api-rs/crates/centaur-iron-control/src/session.rs b/services/api-rs/crates/centaur-iron-control/src/session.rs index a979c1b62b..753f8a351e 100644 --- a/services/api-rs/crates/centaur-iron-control/src/session.rs +++ b/services/api-rs/crates/centaur-iron-control/src/session.rs @@ -7,7 +7,7 @@ //! in console or ``centaur-perms`` remain sticky. The principal is derived from //! the thread key (see [`crate::derive_principal`]). -use std::collections::BTreeSet; +use std::collections::{BTreeMap, BTreeSet}; use serde_json::Value; @@ -29,6 +29,52 @@ struct SessionPrincipalMetadata<'a> { conversation_name: Option<&'a str>, } +const DISCORD_REPO_CACHE_LABEL: &str = "centaur.discord.sandbox_repo_cache"; +const DISCORD_OBSERVABILITY_LABEL: &str = "centaur.discord.sandbox_observability_enabled"; +const DISCORD_SESSIONS_READ_LABEL: &str = "centaur.discord.sandbox_sessions_read_enabled"; +const DISCORD_WORKFLOWS_READ_LABEL: &str = "centaur.discord.sandbox_workflows_read_enabled"; +const DISCORD_WORKFLOWS_WRITE_LABEL: &str = "centaur.discord.sandbox_workflows_write_enabled"; + +#[derive(Clone, Debug, Eq, PartialEq)] +struct DiscordPrincipalCapabilities { + repo_cache: String, + observability: bool, + sessions_read: bool, + workflows_read: bool, + workflows_write: bool, +} + +impl DiscordPrincipalCapabilities { + fn safe() -> Self { + Self { + repo_cache: "none".to_owned(), + observability: false, + sessions_read: false, + workflows_read: false, + workflows_write: false, + } + } + + fn from_role_labels(labels: &BTreeMap) -> Result { + let repo_cache = labels + .get(DISCORD_REPO_CACHE_LABEL) + .map(String::as_str) + .unwrap_or("none"); + if !matches!(repo_cache, "none" | "public" | "all") { + return Err(IronControlError::DiscordPolicy(format!( + "reviewed Discord role has invalid {DISCORD_REPO_CACHE_LABEL}" + ))); + } + Ok(Self { + repo_cache: repo_cache.to_owned(), + observability: discord_capability_bool(labels, DISCORD_OBSERVABILITY_LABEL)?, + sessions_read: discord_capability_bool(labels, DISCORD_SESSIONS_READ_LABEL)?, + workflows_read: discord_capability_bool(labels, DISCORD_WORKFLOWS_READ_LABEL)?, + workflows_write: discord_capability_bool(labels, DISCORD_WORKFLOWS_WRITE_LABEL)?, + }) + } +} + impl<'a> SessionPrincipalMetadata<'a> { fn from_session_metadata(metadata: Option<&'a Value>) -> Self { let Some(metadata) = metadata else { @@ -107,7 +153,7 @@ impl SessionRegistrar { || slack_permission .as_ref() .is_some_and(|permission| is_direct_message(Some(&permission.channel_id))); - let record = self.client.upsert_principal(&input).await?; + let mut record = self.client.upsert_principal(&input).await?; if should_upsert_slack_permission && let Some(permission) = slack_permission { self.client .upsert_slack_channel_permission(&record.id, &permission) @@ -116,12 +162,13 @@ impl SessionRegistrar { if is_discord && (metadata.discord_actor_user_id.is_some() || metadata.discord_policy_roles.is_some()) { - self.reconcile_discord_policy_roles( - &record, - metadata.discord_actor_user_id, - metadata.discord_policy_roles, - ) - .await?; + record = self + .reconcile_discord_policy_roles( + &record, + metadata.discord_actor_user_id, + metadata.discord_policy_roles, + ) + .await?; } Ok(record) } @@ -230,7 +277,7 @@ impl SessionRegistrar { principal: &Principal, actor_user_id: Option<&str>, role_value: Option<&Value>, - ) -> Result<()> { + ) -> Result { let actor_user_id = actor_user_id .map(str::trim) .filter(|value| !value.is_empty()) @@ -276,15 +323,31 @@ impl SessionRegistrar { } desired_roles.push(role); } + let capabilities = DiscordPrincipalCapabilities::from_role_labels( + &desired_roles + .first() + .ok_or_else(|| { + IronControlError::DiscordPolicy( + "Discord policy did not resolve a reviewed role".to_owned(), + ) + })? + .labels, + )?; + // Persist a conservative baseline before removing defaults or changing + // role assignments. If a later API call fails, the stored principal is + // no more capable than the global defaults. + let narrowed = self + .apply_discord_principal_capabilities(principal, &DiscordPrincipalCapabilities::safe()) + .await?; let desired_ids = desired_roles .iter() .map(|role| role.id.as_str()) .collect::>(); - let current_roles = self.client.list_principal_roles(&principal.id).await?; + let current_roles = self.client.list_principal_roles(&narrowed.id).await?; for role in ¤t_roles { if !desired_ids.contains(role.id.as_str()) { - self.client.unassign_role(&principal.id, &role.id).await?; + self.client.unassign_role(&narrowed.id, &role.id).await?; } } let current_ids = current_roles @@ -293,10 +356,50 @@ impl SessionRegistrar { .collect::>(); for role in desired_roles { if !current_ids.contains(role.id.as_str()) { - self.client.assign_role(&principal.id, &role.id).await?; + self.client.assign_role(&narrowed.id, &role.id).await?; } } - Ok(()) + self.apply_discord_principal_capabilities(&narrowed, &capabilities) + .await + } + + async fn apply_discord_principal_capabilities( + &self, + principal: &Principal, + capabilities: &DiscordPrincipalCapabilities, + ) -> Result { + let foreign_id = principal.foreign_id.clone().ok_or_else(|| { + IronControlError::DiscordPolicy( + "policy-managed Discord principal has no foreign ID".to_owned(), + ) + })?; + self.client + .upsert_principal(&PrincipalInput { + foreign_id, + name: principal.name.clone(), + labels: BTreeMap::new(), + kind: None, + slack_user_id: None, + slack_channel_id: None, + slack_team_id: None, + slack_email: None, + sandbox_repo_cache: Some(capabilities.repo_cache.clone()), + sandbox_observability_enabled: Some(capabilities.observability), + sandbox_sessions_read_enabled: Some(capabilities.sessions_read), + sandbox_workflows_read_enabled: Some(capabilities.workflows_read), + sandbox_workflows_write_enabled: Some(capabilities.workflows_write), + }) + .await + } +} + +fn discord_capability_bool(labels: &BTreeMap, key: &str) -> Result { + match labels.get(key).map(String::as_str) { + None | Some("false") => Ok(false), + Some("true") => Ok(true), + Some(_) => Err(IronControlError::DiscordPolicy(format!( + "reviewed Discord role has invalid {key}" + ))), } } @@ -469,6 +572,37 @@ mod tests { } } + #[test] + fn discord_role_capabilities_are_typed_and_fail_closed() { + let labels = BTreeMap::from([ + (DISCORD_REPO_CACHE_LABEL.to_owned(), "all".to_owned()), + (DISCORD_OBSERVABILITY_LABEL.to_owned(), "true".to_owned()), + (DISCORD_SESSIONS_READ_LABEL.to_owned(), "false".to_owned()), + (DISCORD_WORKFLOWS_READ_LABEL.to_owned(), "true".to_owned()), + (DISCORD_WORKFLOWS_WRITE_LABEL.to_owned(), "true".to_owned()), + ]); + assert_eq!( + DiscordPrincipalCapabilities::from_role_labels(&labels).unwrap(), + DiscordPrincipalCapabilities { + repo_cache: "all".to_owned(), + observability: true, + sessions_read: false, + workflows_read: true, + workflows_write: true, + } + ); + assert_eq!( + DiscordPrincipalCapabilities::from_role_labels(&BTreeMap::new()).unwrap(), + DiscordPrincipalCapabilities::safe() + ); + for labels in [ + BTreeMap::from([(DISCORD_REPO_CACHE_LABEL.to_owned(), "everything".to_owned())]), + BTreeMap::from([(DISCORD_OBSERVABILITY_LABEL.to_owned(), "yes".to_owned())]), + ] { + assert!(DiscordPrincipalCapabilities::from_role_labels(&labels).is_err()); + } + } + #[test] fn session_principal_metadata_accepts_teams_name() { assert_eq!( @@ -703,6 +837,20 @@ mod tests { assert_eq!(principal.id, "prn_discord"); let requests = requests.lock().unwrap(); + let principal_updates = requests + .iter() + .enumerate() + .filter_map(|(index, request)| { + (request + == "PUT /api/v1/principals/discord-user-200000000000000001-100000000000000001") + .then_some(index) + }) + .collect::>(); + assert_eq!( + principal_updates.len(), + 3, + "initial upsert, conservative baseline, then reviewed capabilities" + ); let remove = requests .iter() .position(|request| request == "DELETE /api/v1/principals/prn_discord/roles/role_stale") @@ -712,8 +860,8 @@ mod tests { .position(|request| request == "POST /api/v1/principals/prn_discord/roles") .expect("reviewed role is assigned"); assert!( - remove < assign, - "role reconciliation narrows before widening" + principal_updates[1] < remove && remove < assign && assign < principal_updates[2], + "capabilities and roles reconcile from a conservative baseline before widening" ); server.abort(); } @@ -1132,7 +1280,7 @@ mod tests { let principal = r#"{"data":{"id":"prn_discord","foreign_id":"discord-user-200000000000000001-100000000000000001","name":"Discord User","labels":{"managed-by":"centaur","discord_guild_id":"200000000000000001","discord_channel_id":"300000000000000001","discord_user_id":"100000000000000001","centaur_discord_policy_managed":"true"}}}"#; let role_labels = if config.reviewed_role { - r#"{"centaur_discord_policy_managed":"true"}"# + r#"{"centaur_discord_policy_managed":"true","centaur.discord.sandbox_repo_cache":"all","centaur.discord.sandbox_observability_enabled":"true","centaur.discord.sandbox_sessions_read_enabled":"false","centaur.discord.sandbox_workflows_read_enabled":"true","centaur.discord.sandbox_workflows_write_enabled":"true"}"# } else { "{}" }; diff --git a/services/api-rs/crates/centaur-perms/src/principal.rs b/services/api-rs/crates/centaur-perms/src/principal.rs index f0f5b1c064..3f76b4f8d1 100644 --- a/services/api-rs/crates/centaur-perms/src/principal.rs +++ b/services/api-rs/crates/centaur-perms/src/principal.rs @@ -30,6 +30,11 @@ pub fn resolve_principal( slack_channel_id: None, slack_team_id: None, slack_email: None, + sandbox_repo_cache: None, + sandbox_observability_enabled: None, + sandbox_sessions_read_enabled: None, + sandbox_workflows_read_enabled: None, + sandbox_workflows_write_enabled: None, }) } } diff --git a/services/api-rs/crates/centaur-perms/src/tests.rs b/services/api-rs/crates/centaur-perms/src/tests.rs index a597730694..878d93b173 100644 --- a/services/api-rs/crates/centaur-perms/src/tests.rs +++ b/services/api-rs/crates/centaur-perms/src/tests.rs @@ -50,7 +50,7 @@ fn secret_type_routes_by_oid_prefix() { #[test] fn parses_http_replace_secret() { let parsed = tools::parse_secret( - &entry(r#"{type = "http", name = "SLACK_BOT_TOKEN", match_headers = ["Authorization"], hosts = ["slack.com"]}"#), + &entry(r#"{type = "http", name = "SLACK_BOT_TOKEN", match_headers = ["Authorization"], hosts = ["slack.com"], http_methods = ["GET"], paths = ["/api/conversations.*"]}"#), &[], ) .unwrap(); @@ -63,6 +63,8 @@ fn parses_http_replace_secret() { assert_eq!(http.replacer, "SLACK_BOT_TOKEN"); assert_eq!(http.match_headers, vec!["Authorization".to_owned()]); assert_eq!(http.hosts, vec!["slack.com".to_owned()]); + assert_eq!(http.http_methods, vec!["GET".to_owned()]); + assert_eq!(http.paths, vec!["/api/conversations.*".to_owned()]); } #[test] @@ -448,7 +450,7 @@ fn legacy_string_shim_is_replace_secret() { fn translates_http_replace_to_static_input() { let secrets = vec![ tools::parse_secret( - &entry(r#"{type = "http", name = "SLACK_BOT_TOKEN", match_headers = ["Authorization"], hosts = ["slack.com"]}"#), + &entry(r#"{type = "http", name = "SLACK_BOT_TOKEN", match_headers = ["Authorization"], hosts = ["slack.com"], http_methods = ["GET"], paths = ["/api/conversations.*"]}"#), &[], ) .unwrap(), @@ -470,6 +472,23 @@ fn translates_http_replace_to_static_input() { ); assert_eq!(input.rules.len(), 1); assert_eq!(input.rules[0].host.as_deref(), Some("slack.com")); + assert_eq!(input.rules[0].http_methods, vec!["GET".to_owned()]); + assert_eq!( + input.rules[0].paths, + vec!["/api/conversations.*".to_owned()] + ); +} + +#[test] +fn http_request_scope_rejects_invalid_methods_and_paths() { + for source in [ + r#"{type = "http", name = "TOKEN", match_headers = ["Authorization"], hosts = ["api.example.com"], http_methods = ["TRACE"]}"#, + r#"{type = "http", name = "TOKEN", match_headers = ["Authorization"], hosts = ["api.example.com"], paths = ["relative/*"]}"#, + r#"{type = "http", name = "TOKEN", match_headers = ["Authorization"], hosts = ["api.example.com"], http_methods = "GET"}"#, + r#"{type = "http", name = "TOKEN", match_headers = ["Authorization"], hosts = ["api.example.com"], paths = [""]}"#, + ] { + assert!(tools::parse_secret(&entry(source), &[]).is_err()); + } } #[test] diff --git a/services/api-rs/crates/centaur-perms/src/tools.rs b/services/api-rs/crates/centaur-perms/src/tools.rs index 33073d34ff..cdbaeb9458 100644 --- a/services/api-rs/crates/centaur-perms/src/tools.rs +++ b/services/api-rs/crates/centaur-perms/src/tools.rs @@ -93,6 +93,10 @@ pub struct HttpSecret { pub secret_ref: String, pub mode: SecretMode, pub hosts: Vec, + /// Optional iron-proxy request-method allowlist. Empty means any method. + pub http_methods: Vec, + /// Optional iron-proxy path globs. Empty means any path on an allowed host. + pub paths: Vec, // replace mode pub replacer: String, pub match_headers: Vec, @@ -428,6 +432,8 @@ pub fn parse_secret(entry: &Value, default_hosts: &[String]) -> Result { @@ -538,6 +557,8 @@ fn parse_http( secret_ref: secret_ref.to_owned(), mode, hosts, + http_methods, + paths, replacer, match_headers, match_path, @@ -574,6 +595,8 @@ fn parse_http( secret_ref: secret_ref.to_owned(), mode, hosts, + http_methods, + paths, replacer: String::new(), match_headers: vec![], match_path: false, @@ -1051,6 +1074,29 @@ fn str_array(value: Option<&Value>) -> Option> { ) } +/// A string array for security-sensitive HTTP request scope fields. Unlike the +/// legacy permissive parser above, a present malformed value fails closed. +fn strict_str_array(table: &toml::Table, name: &str, key: &str) -> Result> { + let Some(value) = table.get(key) else { + return Ok(Vec::new()); + }; + let Some(values) = value.as_array() else { + bail!("HTTP secret {name:?} {key:?} must be an array of non-empty strings"); + }; + values + .iter() + .map(|value| { + value + .as_str() + .filter(|value| !value.is_empty()) + .map(str::to_owned) + .ok_or_else(|| { + eyre!("HTTP secret {name:?} {key:?} must be an array of non-empty strings") + }) + }) + .collect() +} + /// A non-empty array of non-empty strings, or `None` if absent/invalid. fn non_empty_str_array(value: Option<&Value>) -> Option> { let arr = value?.as_array()?; diff --git a/services/api-rs/crates/centaur-perms/src/translate.rs b/services/api-rs/crates/centaur-perms/src/translate.rs index 4554daa285..86f584ac36 100644 --- a/services/api-rs/crates/centaur-perms/src/translate.rs +++ b/services/api-rs/crates/centaur-perms/src/translate.rs @@ -40,6 +40,19 @@ fn rules_from_hosts(hosts: &[String]) -> Vec { hosts.iter().map(RequestRule::host).collect() } +fn rules_from_http(secret: &HttpSecret) -> Vec { + secret + .hosts + .iter() + .map(|host| RequestRule { + host: Some(host.clone()), + cidr: None, + http_methods: secret.http_methods.clone(), + paths: secret.paths.clone(), + }) + .collect() +} + /// Translate every secret declared by a tool into iron-control inputs to grant /// to the tool's role (`role_foreign_id`, e.g. `tool-github`). #[cfg(test)] @@ -184,7 +197,7 @@ fn static_input( inject_config, replace_config, source: source_from_placeholder(policy, &http.secret_ref, None), - rules: rules_from_hosts(&http.hosts), + rules: rules_from_http(http), } } diff --git a/services/api-rs/crates/centaur-workflows/src/lib.rs b/services/api-rs/crates/centaur-workflows/src/lib.rs index 24d8975946..48c8b84711 100644 --- a/services/api-rs/crates/centaur-workflows/src/lib.rs +++ b/services/api-rs/crates/centaur-workflows/src/lib.rs @@ -330,6 +330,11 @@ impl WorkflowPrincipalRegistrar { slack_channel_id: None, slack_team_id: None, slack_email: None, + sandbox_repo_cache: None, + sandbox_observability_enabled: None, + sandbox_sessions_read_enabled: None, + sandbox_workflows_read_enabled: None, + sandbox_workflows_write_enabled: None, }) .await? } diff --git a/services/console/app/models/principal.rb b/services/console/app/models/principal.rb index 4b2c56cf65..a1a101ba4f 100644 --- a/services/console/app/models/principal.rb +++ b/services/console/app/models/principal.rb @@ -34,7 +34,7 @@ class Principal < ApplicationRecord SANDBOX_REPO_CACHE_VALUES = %w[none public all].freeze UNKNOWN_KIND = "unknown".freeze KINDS = %w[ - unknown user console_user workflow slack_channel slack_dm discord_channel linear_issue + unknown user console_user workflow slack_channel slack_dm discord_channel discord_user linear_issue teams_user teams_conversation ].freeze SLACK_USER_ID_FORMAT = /\A(?:[UW][A-Z0-9]{8,}|USLACK)\z/ diff --git a/services/console/test/models/principal_test.rb b/services/console/test/models/principal_test.rb index 96e9c37eae..01f87a1959 100644 --- a/services/console/test/models/principal_test.rb +++ b/services/console/test/models/principal_test.rb @@ -1,6 +1,17 @@ require "test_helper" class PrincipalTest < ActiveSupport::TestCase + test "discord user is a supported actor-scoped principal kind" do + principal = Principal.new(default_attrs( + foreign_id: "discord-user-1336096360772141148-100000000000000001", + name: "Discord user", + kind: "discord_user", + labels: {} + )) + + assert principal.valid?, principal.errors.full_messages.join(", ") + end + def default_attrs(overrides = {}) { created_by: users(:acme_admin) }.merge(overrides) end diff --git a/tools/README.md b/tools/README.md index e7d5e323aa..0f5e2f3372 100644 --- a/tools/README.md +++ b/tools/README.md @@ -40,6 +40,15 @@ Secrets are resolved in this order: 3. **Environment variables** — for Docker, k8s, sops, 1Password, etc. Use `secret("KEY")` to access. Never use `os.environ` — tool secrets are scoped. +For HTTP credentials, add `http_methods` and `paths` to the secret entry when a +tool needs less than all operations on an allowed host. The proxy enforces these +lists independently of the client wrapper; paths are slash-prefixed globs. + +```toml +secrets = [ + {type = "http", name = "API_TOKEN", match_headers = ["Authorization"], hosts = ["api.example.com"], http_methods = ["GET"], paths = ["/v1/reports/*"]}, +] +``` ## Sandbox CLI shims From 6330eb0b283bffc0a6d0e9cc9de29e591e668dbf Mon Sep 17 00:00:00 2001 From: Michael Wu Date: Tue, 1 Sep 2026 16:10:54 +0900 Subject: [PATCH 04/37] Allow narrowed GitHub credential routes --- .../credential_profiles/github_token.rb | 7 +++-- .../credential_profiles/github_token_test.rb | 28 +++++++++++++++++++ 2 files changed, 32 insertions(+), 3 deletions(-) diff --git a/services/console/app/services/credential_profiles/github_token.rb b/services/console/app/services/credential_profiles/github_token.rb index 39d9bd1908..ac9d0313de 100644 --- a/services/console/app/services/credential_profiles/github_token.rb +++ b/services/console/app/services/credential_profiles/github_token.rb @@ -36,12 +36,13 @@ def validate_config(secret) end # Host-based rather than an exact match against RULE_ATTRIBUTES so secrets - # seeded before a host was added stay valid on later saves. + # seeded before a host was added stay valid on later saves and a reviewed + # credential may further narrow an allowed host by HTTP method and path. + # RequestRule owns validation of those optional method/path constraints. def validate_rules(secret, rules:) actual = Array(rules) confined = actual.present? && actual.all? do |rule| - ALLOWED_HOSTS.include?(rule.host) && - rule.cidr.blank? && rule.http_methods.blank? && rule.paths.blank? + ALLOWED_HOSTS.include?(rule.host) && rule.cidr.blank? end return if confined diff --git a/services/console/test/services/credential_profiles/github_token_test.rb b/services/console/test/services/credential_profiles/github_token_test.rb index d616a700e0..45f62a2090 100644 --- a/services/console/test/services/credential_profiles/github_token_test.rb +++ b/services/console/test/services/credential_profiles/github_token_test.rb @@ -46,6 +46,34 @@ class GithubTokenTest < ActiveSupport::TestCase assert secret.valid?, secret.errors.full_messages.inspect end + test "accepts method and path constraints inside canonical GitHub hosts" do + secret = StaticSecret.new(kind: "github_token") + constrained_rules = [ + RequestRule.new( + host: "api.github.com", + http_methods: [ "GET" ], + paths: [ "/repos/example/project", "/repos/example/project/*" ], + position: 0 + ), + RequestRule.new( + host: "api.github.com", + http_methods: [ "POST" ], + paths: [ "/repos/example/project/pulls" ], + position: 1 + ), + RequestRule.new( + host: "github.com", + http_methods: [ "GET", "POST" ], + paths: [ "/example/project.git/*" ], + position: 2 + ) + ] + + secret.rules = secret.apply_kind_defaults(rules: constrained_rules) + + assert secret.valid?, secret.errors.full_messages.inspect + end + test "normalizes an omitted false require flag" do secret = StaticSecret.new( kind: "github_token", From 57e4606ecb098c8c4c4c9e9a7a41ab9388f6aab4 Mon Sep 17 00:00:00 2001 From: Michael Wu Date: Tue, 1 Sep 2026 16:52:45 +0900 Subject: [PATCH 05/37] fix: close Discord workflow authorization gaps --- contrib/chart/templates/apirs.yaml | 2 + contrib/chart/templates/discordbot.yaml | 7 + contrib/chart/templates/networkpolicy.yaml | 10 + contrib/chart/values.schema.json | 23 +++ contrib/chart/values.yaml | 10 + .../crates/centaur-api-server/src/routes.rs | 22 +- .../centaur-workflows/src/action_proposals.rs | 193 ++++++++++++++++-- .../crates/centaur-workflows/src/lib.rs | 2 +- services/discordbot/src/discord-allowlist.ts | 5 +- services/discordbot/src/discord-api.ts | 29 +++ services/discordbot/src/discord-delivery.ts | 62 ++++-- services/discordbot/src/discord-ingress.ts | 11 +- services/discordbot/src/discord-narrator.ts | 9 +- services/discordbot/src/discord-starter.ts | 8 +- services/discordbot/src/discord-threading.ts | 14 +- services/discordbot/src/index.ts | 15 +- services/discordbot/src/server.ts | 3 + .../discordbot/test/chat-sdk-emulate.test.ts | 12 +- .../discordbot/test/discord-allowlist.test.ts | 14 ++ .../discordbot/test/discord-delivery.test.ts | 70 ++++++- .../discordbot/test/discord-ingress.test.ts | 28 +++ 21 files changed, 469 insertions(+), 80 deletions(-) create mode 100644 services/discordbot/src/discord-api.ts diff --git a/contrib/chart/templates/apirs.yaml b/contrib/chart/templates/apirs.yaml index c4baa61d9c..053276176f 100644 --- a/contrib/chart/templates/apirs.yaml +++ b/contrib/chart/templates/apirs.yaml @@ -251,6 +251,8 @@ spec: - name: DISCORDBOT_APPROVAL_ROLE_ALLOWLIST value: {{ join "," ($discordApprovalRoles | uniq) | quote }} {{- end }} + - name: CENTAUR_ACTION_PROPOSAL_BINDINGS_JSON + value: {{ .Values.apiRs.actionProposalBindings | toJson | quote }} - name: BIND_ADDR value: {{ printf "0.0.0.0:%v" .Values.apiRs.port | quote }} - name: SLACK_BOT_TOKEN diff --git a/contrib/chart/templates/discordbot.yaml b/contrib/chart/templates/discordbot.yaml index 30df0fac8c..d39ad24702 100644 --- a/contrib/chart/templates/discordbot.yaml +++ b/contrib/chart/templates/discordbot.yaml @@ -3,6 +3,9 @@ {{- $guildAllowlist := required "discordbot.guildAllowlist is required when discordbot is enabled" .Values.discordbot.guildAllowlist -}} {{- $channelAllowlist := required "discordbot.channelAllowlist is required when discordbot is enabled" .Values.discordbot.channelAllowlist -}} {{- $roleBindings := required "discordbot.roleBindings must contain reviewed role policy when discordbot is enabled" .Values.discordbot.roleBindings -}} +{{- if eq (len $roleBindings) 0 -}} +{{- fail "discordbot.roleBindings must contain reviewed role policy when discordbot is enabled" -}} +{{- end -}} apiVersion: apps/v1 kind: Deployment metadata: @@ -75,6 +78,10 @@ spec: value: {{ $channelAllowlist | quote }} - name: DISCORDBOT_ROLE_BINDINGS_JSON value: {{ $roleBindings | toJson | quote }} +{{- if .Values.discordbot.triggerBotAllowlist }} + - name: DISCORDBOT_TRIGGER_BOT_ALLOWLIST + value: {{ .Values.discordbot.triggerBotAllowlist | quote }} +{{- end }} - name: DISCORDBOT_CONTINUATION_TTL_MS value: {{ .Values.discordbot.continuationTtlMs | quote }} - name: DISCORDBOT_INGRESS_MAX_EVENT_AGE_MS diff --git a/contrib/chart/templates/networkpolicy.yaml b/contrib/chart/templates/networkpolicy.yaml index 8b6a9bb837..1c30a925ba 100644 --- a/contrib/chart/templates/networkpolicy.yaml +++ b/contrib/chart/templates/networkpolicy.yaml @@ -341,6 +341,16 @@ spec: ports: - protocol: TCP port: {{ $console.service.httpPort }} +{{- if .Values.discordbot.enabled }} + # Authenticated workflow delivery to Discordbot's internal endpoint. + - to: + - podSelector: + matchLabels: +{{ include "centaur.componentSelectorLabels" (dict "root" . "component" "discordbot") | nindent 14 }} + ports: + - protocol: TCP + port: 3001 +{{- end }} - ports: - protocol: TCP port: 443 diff --git a/contrib/chart/values.schema.json b/contrib/chart/values.schema.json index bc9b3917bc..6263aad553 100644 --- a/contrib/chart/values.schema.json +++ b/contrib/chart/values.schema.json @@ -349,6 +349,29 @@ "sandboxHotIdleGraceSecs": { "type": "integer", "minimum": 0 }, "workflowHostSandbox": { "type": "boolean" }, "workflowHostResources": { "type": "object" }, + "actionProposalBindings": { + "type": "array", + "maxItems": 128, + "items": { + "type": "object", + "additionalProperties": false, + "required": ["observer_workflow", "action_type", "action_workflow"], + "properties": { + "observer_workflow": { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9_.:-]{0,127}$" + }, + "action_type": { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9_.:-]{0,95}$" + }, + "action_workflow": { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9_.:-]{0,127}$" + } + } + } + }, "etl": { "type": "object", "properties": { diff --git a/contrib/chart/values.yaml b/contrib/chart/values.yaml index c0bc0db049..d0bb8f7937 100644 --- a/contrib/chart/values.yaml +++ b/contrib/chart/values.yaml @@ -472,6 +472,13 @@ apiRs: workflowHostSandbox: true workflowEnableMode: all workflowAllowedNames: "" + # Reviewed observer/action bindings for durable action proposals. The runtime + # rejects persistence and approval unless the exact observer_workflow + + # action_type tuple selects the proposed action_workflow. + actionProposalBindings: [] + # - observer_workflow: weekly_ops_review + # action_type: github:create_improvement_pr + # action_workflow: execute_approved_improvement # Requests/limits for the workflow-host sandbox pod (k8s resources map). workflowHostResources: {} # Scheduled ETL workflow configuration. The chart renders these into api-rs @@ -765,6 +772,9 @@ discordbot: continuationTtlMs: 86400000 ingressMaxEventAgeMs: 300000 ingressDeliveryTtlMs: 604800000 + # Bot user/application/webhook IDs explicitly admitted at the transport + # boundary. A matching bot still needs a reviewed role binding; empty denies. + triggerBotAllowlist: "" # Comma/space-separated role IDs whose mentions also trigger the bot. mentionRoleIds: "" # Rename auto-created threads to the triggering message; set false to keep generic names. diff --git a/services/api-rs/crates/centaur-api-server/src/routes.rs b/services/api-rs/crates/centaur-api-server/src/routes.rs index c794fcb68f..aaf846907b 100644 --- a/services/api-rs/crates/centaur-api-server/src/routes.rs +++ b/services/api-rs/crates/centaur-api-server/src/routes.rs @@ -40,6 +40,7 @@ use centaur_telemetry::{ use centaur_workflows::{ ApproveActionProposalRequest, CreateWorkflowRunRequest, WebhookFilter, WorkflowRuntime, WorkflowWebhookAuth, WorkflowWebhookSpec, WorkflowWebhookTriggerKey, + normalize_exact_repository, }; use futures_util::{Stream, StreamExt}; use hmac::{Hmac, KeyInit, Mac}; @@ -1018,21 +1019,12 @@ fn validate_discord_string_scope( ))); } if repositories { - let mut parts = value.split('/'); - let owner = parts.next().unwrap_or_default(); - let repo = parts.next().unwrap_or_default(); - if owner.is_empty() - || repo.is_empty() - || parts.next().is_some() - || value.contains('*') - || !owner.bytes().all(is_github_name_byte) - || !repo.bytes().all(is_github_name_byte) - { - return Err(ApiError::BadRequest( + normalize_exact_repository(value).map_err(|_| { + ApiError::BadRequest( "discord_repository_scope entries must be exact owner/repository names" .to_owned(), - )); - } + ) + })?; } } Ok(()) @@ -1064,10 +1056,6 @@ fn is_discord_snowflake(value: &str) -> bool { (16..=22).contains(&value.len()) && value.bytes().all(|byte| byte.is_ascii_digit()) } -fn is_github_name_byte(byte: u8) -> bool { - byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.') -} - async fn interrupt_session_execution( State(state): State, Path(raw_thread_key): Path, diff --git a/services/api-rs/crates/centaur-workflows/src/action_proposals.rs b/services/api-rs/crates/centaur-workflows/src/action_proposals.rs index e80615fafb..d8b1d16054 100644 --- a/services/api-rs/crates/centaur-workflows/src/action_proposals.rs +++ b/services/api-rs/crates/centaur-workflows/src/action_proposals.rs @@ -17,6 +17,15 @@ const MAX_PROPOSAL_TTL_SECONDS: i64 = 30 * 24 * 60 * 60; const MAX_PARAMETERS_BYTES: usize = 16 * 1024; const MAX_PARAMETER_DEPTH: usize = 8; const APPROVAL_ROLE_ALLOWLIST_ENV: &str = "DISCORDBOT_APPROVAL_ROLE_ALLOWLIST"; +const ACTION_PROPOSAL_BINDINGS_ENV: &str = "CENTAUR_ACTION_PROPOSAL_BINDINGS_JSON"; + +#[derive(Clone, Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct ActionProposalBinding { + action_type: String, + action_workflow: String, + observer_workflow: String, +} #[derive(Clone, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)] #[serde(deny_unknown_fields)] @@ -121,7 +130,7 @@ impl ActionProposal { pub fn normalize_and_fingerprint(mut self) -> Result<(Self, String), WorkflowRuntimeError> { self.action_type = bounded_identifier("action_type", &self.action_type, 96)?; self.action_workflow = bounded_identifier("action_workflow", &self.action_workflow, 128)?; - self.repository = exact_repository(&self.repository)?; + self.repository = normalize_exact_repository(&self.repository)?; self.base_ref = bounded_string("base_ref", &self.base_ref, 128)?; self.head_ref = self .head_ref @@ -226,6 +235,11 @@ pub async fn put_action_proposal( ))); } let (proposal, fingerprint) = request.proposal.normalize_and_fingerprint()?; + let action_workflow = reviewed_action_workflow( + observer_workflow, + &proposal.action_type, + &proposal.action_workflow, + )?; let proposal_json = serde_json::to_value(&proposal)?; let expires_at = OffsetDateTime::now_utc() + time::Duration::seconds(request.expires_in_seconds); @@ -237,7 +251,7 @@ pub async fn put_action_proposal( ) .bind(&fingerprint) .bind(&proposal_json) - .bind(&proposal.action_workflow) + .bind(&action_workflow) .bind(observer_workflow) .bind(observer_task_id) .bind(observer_run_id) @@ -284,23 +298,24 @@ impl WorkflowRuntime { fingerprint: &str, mut request: ApproveActionProposalRequest, ) -> Result { - validate_approval_request(fingerprint, &request)?; + let fingerprint = validate_approval_request(fingerprint, &request)?; request.repository_scope = request .repository_scope .iter() - .map(|repository| exact_repository(repository)) + .map(|repository| normalize_exact_repository(repository)) .collect::, _>>()?; request.repository_scope.sort(); let mut tx = self.inner.client.pool().begin().await?; let row = sqlx::query( - "SELECT proposal, action_workflow, expires_at, consumed_at, action_task_id, action_run_id \ + "SELECT proposal, action_workflow, observer_workflow, expires_at, consumed_at, action_task_id, action_run_id \ FROM workflow_action_proposals WHERE fingerprint = $1 FOR UPDATE", ) - .bind(fingerprint) + .bind(&fingerprint) .fetch_optional(&mut *tx) .await? .ok_or_else(|| WorkflowRuntimeError::NotFound("action proposal not found".to_owned()))?; - let action_workflow: String = row.try_get("action_workflow")?; + let stored_action_workflow: String = row.try_get("action_workflow")?; + let observer_workflow: String = row.try_get("observer_workflow")?; let proposal_value: Value = row.try_get("proposal")?; let (proposal, computed_fingerprint) = serde_json::from_value::(proposal_value.clone())? @@ -310,6 +325,16 @@ impl WorkflowRuntime { "stored action proposal fingerprint is invalid".to_owned(), )); } + let action_workflow = reviewed_action_workflow( + &observer_workflow, + &proposal.action_type, + &proposal.action_workflow, + )?; + if action_workflow != stored_action_workflow { + return Err(WorkflowRuntimeError::Internal( + "stored action proposal workflow binding is invalid".to_owned(), + )); + } if !proposal.is_approvable() { return Err(WorkflowRuntimeError::BadRequest( "action proposal has a failed validation".to_owned(), @@ -330,7 +355,7 @@ impl WorkflowRuntime { ) { tx.commit().await?; return Ok(approval_response( - fingerprint, + &fingerprint, &action_workflow, action_task_id, action_run_id, @@ -355,7 +380,7 @@ impl WorkflowRuntime { "message_id": request.message_id, "policy_fingerprint": request.policy_fingerprint, "principal_role": request.principal_role, - "proposal_fingerprint": fingerprint, + "proposal_fingerprint": &fingerprint, "repository_scope": request.repository_scope, "root_message_id": request.root_message_id, "thread_id": request.thread_id, @@ -376,7 +401,7 @@ impl WorkflowRuntime { action_task_id = $12, action_run_id = $13, updated_at = NOW() \ WHERE fingerprint = $1 AND consumed_at IS NULL", ) - .bind(fingerprint) + .bind(&fingerprint) .bind(&request.actor_id) .bind(&request.message_id) .bind(&request.guild_id) @@ -393,7 +418,7 @@ impl WorkflowRuntime { .await?; tx.commit().await?; Ok(approval_response( - fingerprint, + &fingerprint, &action_workflow, run.task_id, run.run_id, @@ -443,6 +468,13 @@ async fn try_transition_notification_state( let state_class = request.state_class; let active = request.semantic_fingerprint.is_some(); let mut tx = client.pool().begin().await?; + // A missing scope row cannot be protected by SELECT ... FOR UPDATE. Take a + // transaction-scoped advisory lock first so concurrent first transitions + // calculate notification ownership one at a time across API replicas. + sqlx::query("SELECT pg_advisory_xact_lock(hashtextextended($1, 0))") + .bind(&scope) + .fetch_optional(&mut *tx) + .await?; let previous = sqlx::query( "SELECT semantic_fingerprint, state_class, active, last_notification_workflow_run_id \ FROM workflow_semantic_notification_states \ @@ -565,7 +597,7 @@ fn action_proposal_state( fn validate_approval_request( fingerprint: &str, request: &ApproveActionProposalRequest, -) -> Result<(), WorkflowRuntimeError> { +) -> Result { let allowed_roles = env::var(APPROVAL_ROLE_ALLOWLIST_ENV) .unwrap_or_default() .split(|ch: char| ch == ',' || ch.is_whitespace()) @@ -579,8 +611,8 @@ fn validate_approval_request_with_roles( fingerprint: &str, request: &ApproveActionProposalRequest, allowed_roles: &[String], -) -> Result<(), WorkflowRuntimeError> { - sha256_fingerprint("proposal fingerprint", fingerprint)?; +) -> Result { + let fingerprint = sha256_fingerprint("proposal fingerprint", fingerprint)?; for (name, value) in [ ("actor_id", request.actor_id.as_str()), ("channel_id", request.channel_id.as_str()), @@ -618,14 +650,14 @@ fn validate_approval_request_with_roles( } let mut repositories = BTreeSet::new(); for repository in &request.repository_scope { - let repository = exact_repository(repository)?; + let repository = normalize_exact_repository(repository)?; if !repositories.insert(repository) { return Err(WorkflowRuntimeError::BadRequest( "approval repository_scope must contain unique repositories".to_owned(), )); } } - Ok(()) + Ok(fingerprint) } fn approval_response( @@ -728,7 +760,7 @@ fn bounded_string(name: &str, value: &str, maximum: usize) -> Result Result { +pub fn normalize_exact_repository(value: &str) -> Result { let value = value.trim().to_ascii_lowercase(); let mut parts = value.split('/'); let owner = parts.next().unwrap_or_default(); @@ -737,6 +769,8 @@ fn exact_repository(value: &str) -> Result { && !repository.is_empty() && parts.next().is_none() && !value.contains('*') + && !matches!(owner, "." | "..") + && !matches!(repository, "." | "..") && owner.bytes().all(is_github_name_byte) && repository.bytes().all(is_github_name_byte); if !valid { @@ -747,6 +781,68 @@ fn exact_repository(value: &str) -> Result { Ok(value) } +fn reviewed_action_workflow( + observer_workflow: &str, + action_type: &str, + proposed_action_workflow: &str, +) -> Result { + let raw = env::var(ACTION_PROPOSAL_BINDINGS_ENV).unwrap_or_else(|_| "[]".to_owned()); + let bindings = serde_json::from_str::>(&raw).map_err(|_| { + WorkflowRuntimeError::Internal( + "CENTAUR_ACTION_PROPOSAL_BINDINGS_JSON is invalid".to_owned(), + ) + })?; + reviewed_action_workflow_with_bindings( + observer_workflow, + action_type, + proposed_action_workflow, + &bindings, + ) +} + +fn reviewed_action_workflow_with_bindings( + observer_workflow: &str, + action_type: &str, + proposed_action_workflow: &str, + bindings: &[ActionProposalBinding], +) -> Result { + if bindings.len() > 128 { + return Err(WorkflowRuntimeError::Internal( + "action proposal workflow binding policy is too large".to_owned(), + )); + } + let observer_workflow = bounded_identifier("observer_workflow", observer_workflow, 128)?; + let action_type = bounded_identifier("action_type", action_type, 96)?; + let proposed_action_workflow = + bounded_identifier("action_workflow", proposed_action_workflow, 128)?; + let mut reviewed = BTreeMap::new(); + for binding in bindings { + let key = ( + bounded_identifier("binding observer_workflow", &binding.observer_workflow, 128)?, + bounded_identifier("binding action_type", &binding.action_type, 96)?, + ); + let action_workflow = + bounded_identifier("binding action_workflow", &binding.action_workflow, 128)?; + if reviewed.insert(key, action_workflow).is_some() { + return Err(WorkflowRuntimeError::Internal( + "action proposal workflow binding policy contains a duplicate tuple".to_owned(), + )); + } + } + let Some(action_workflow) = reviewed.get(&(observer_workflow, action_type)) else { + return Err(WorkflowRuntimeError::BadRequest( + "observer workflow and action type are not bound to a reviewed action workflow" + .to_owned(), + )); + }; + if action_workflow != &proposed_action_workflow { + return Err(WorkflowRuntimeError::BadRequest( + "proposal action_workflow does not match the reviewed workflow binding".to_owned(), + )); + } + Ok(action_workflow.clone()) +} + fn immutable_git_object_id(name: &str, value: &str) -> Result { let value = value.trim().to_ascii_lowercase(); if !matches!(value.len(), 40 | 64) @@ -853,6 +949,10 @@ mod tests { .to_string() .contains("exact owner/repository") ); + + for repository in ["./repo", "508-dev/.", "508-dev/.."] { + assert!(normalize_exact_repository(repository).is_err()); + } } #[test] @@ -975,8 +1075,14 @@ mod tests { }; let allowed_roles = vec!["discord-operator".to_owned()]; - assert!( - validate_approval_request_with_roles(&fingerprint, &request, &allowed_roles).is_ok() + assert_eq!( + validate_approval_request_with_roles( + &fingerprint.to_ascii_uppercase(), + &request, + &allowed_roles, + ) + .unwrap(), + fingerprint ); request.repository_scope = vec!["508-dev/*".to_owned()]; @@ -991,4 +1097,53 @@ mod tests { request.root_message_id = request.thread_id.clone(); assert!(validate_approval_request_with_roles(&fingerprint, &request, &[]).is_err()); } + + #[test] + fn observer_and_action_type_select_exactly_one_reviewed_action_workflow() { + let bindings = vec![ActionProposalBinding { + action_type: "github:create_improvement_pr".to_owned(), + action_workflow: "execute_approved_improvement".to_owned(), + observer_workflow: "weekly_ops_review".to_owned(), + }]; + + assert_eq!( + reviewed_action_workflow_with_bindings( + "weekly_ops_review", + "github:create_improvement_pr", + "execute_approved_improvement", + &bindings, + ) + .unwrap(), + "execute_approved_improvement" + ); + assert!( + reviewed_action_workflow_with_bindings( + "weekly_ops_review", + "github:create_improvement_pr", + "unrelated_privileged_workflow", + &bindings, + ) + .is_err() + ); + assert!( + reviewed_action_workflow_with_bindings( + "other_observer", + "github:create_improvement_pr", + "execute_approved_improvement", + &bindings, + ) + .is_err() + ); + + let duplicate = vec![bindings[0].clone(), bindings[0].clone()]; + assert!( + reviewed_action_workflow_with_bindings( + "weekly_ops_review", + "github:create_improvement_pr", + "execute_approved_improvement", + &duplicate, + ) + .is_err() + ); + } } diff --git a/services/api-rs/crates/centaur-workflows/src/lib.rs b/services/api-rs/crates/centaur-workflows/src/lib.rs index 48c8b84711..c2a93846aa 100644 --- a/services/api-rs/crates/centaur-workflows/src/lib.rs +++ b/services/api-rs/crates/centaur-workflows/src/lib.rs @@ -40,7 +40,7 @@ mod action_proposals; pub use action_proposals::{ ActionProposal, ActionProposalState, ApproveActionProposalRequest, ApproveActionProposalResponse, NotificationTransitionRequest, NotificationTransitionResponse, - ProposalEvidence, ProposalValidation, PutActionProposalRequest, + ProposalEvidence, ProposalValidation, PutActionProposalRequest, normalize_exact_repository, }; pub const WORKFLOW_QUEUE: &str = "centaur_workflows"; diff --git a/services/discordbot/src/discord-allowlist.ts b/services/discordbot/src/discord-allowlist.ts index b29debd3b9..c7901e3d1d 100644 --- a/services/discordbot/src/discord-allowlist.ts +++ b/services/discordbot/src/discord-allowlist.ts @@ -219,7 +219,10 @@ export function resolveTriggerRoleAllowlist( options: DiscordbotOptions, ): string[] { const policyRoleIds = configuredDiscordRoleIds(options); - if (policyRoleIds.length > 0) return policyRoleIds; + // An explicitly configured policy list, including [], opts into the actor- + // scoped policy contract. Never let the legacy role allowlist reactivate an + // intentionally empty reviewed policy. + if (options.roleBindings !== undefined) return policyRoleIds; return [ ...(options.triggerRoleAllowlist ?? splitEnvList(process.env.DISCORDBOT_TRIGGER_ROLE_ALLOWLIST)), diff --git a/services/discordbot/src/discord-api.ts b/services/discordbot/src/discord-api.ts new file mode 100644 index 0000000000..f1feea4f70 --- /dev/null +++ b/services/discordbot/src/discord-api.ts @@ -0,0 +1,29 @@ +export const DEFAULT_DISCORD_API_URL = "https://discord.com/api/v10"; + +/** Resolve and validate the one Discord REST base before any bot token is sent. */ +export function resolveDiscordApiBase( + configured?: string, + allowHttpLoopbackForTests = false, +): string { + const raw = configured ?? DEFAULT_DISCORD_API_URL; + let parsed: URL; + try { + parsed = new URL(raw); + } catch { + throw new Error("discord_api_url_invalid"); + } + const testLoopback = + allowHttpLoopbackForTests && + parsed.protocol === "http:" && + ["127.0.0.1", "::1", "localhost"].includes(parsed.hostname); + if ( + (parsed.protocol !== "https:" && !testLoopback) || + parsed.username !== "" || + parsed.password !== "" || + parsed.search !== "" || + parsed.hash !== "" + ) { + throw new Error("discord_api_url_invalid"); + } + return raw.replace(/\/+$/, ""); +} diff --git a/services/discordbot/src/discord-delivery.ts b/services/discordbot/src/discord-delivery.ts index f3f9c9a229..eb12d2a141 100644 --- a/services/discordbot/src/discord-delivery.ts +++ b/services/discordbot/src/discord-delivery.ts @@ -1,7 +1,7 @@ import { createHash, randomUUID, timingSafeEqual } from "node:crypto"; import type { Logger, StateAdapter } from "chat"; +import { resolveDiscordApiBase } from "./discord-api"; import { resolveChannelAllowlist } from "./discord-allowlist"; -import { DEFAULT_DISCORD_API_URL } from "./discord-threading"; import type { DiscordbotOptions } from "./types"; const MAX_DELIVERY_ID_LENGTH = 128; @@ -20,6 +20,7 @@ export type DiscordDeliveryResult = { delivery_id: string; message_id: string; ok: true; + request_fingerprint: string; }; export class DiscordDeliveryError extends Error { @@ -51,6 +52,7 @@ export async function deliverDiscordNotification( logger: Logger, ): Promise { const input = validateDeliveryInput(raw, options); + const requestFingerprint = deliveryRequestFingerprint(input); const resultKey = `discordbot:delivery:result:${input.delivery_id}`; const leaseKey = `discordbot:delivery:lease:${input.delivery_id}`; @@ -60,7 +62,8 @@ export async function deliverDiscordNotification( } catch { throw new DiscordDeliveryError("state_unavailable", 503); } - if (isDeliveryResult(existing, input)) return existing; + const existingResult = existingDeliveryResult(existing, requestFingerprint); + if (existingResult) return existingResult; const leaseToken = randomUUID(); let claimed: boolean; @@ -77,9 +80,10 @@ export async function deliverDiscordNotification( try { existing = await state.get(resultKey); - if (isDeliveryResult(existing, input)) return existing; + const claimedResult = existingDeliveryResult(existing, requestFingerprint); + if (claimedResult) return claimedResult; - const result = await postDiscordMessage(input, options); + const result = await postDiscordMessage(input, requestFingerprint, options); await state.set(resultKey, result, DELIVERY_RESULT_TTL_MS); logger.info("discordbot_delivery_audit", { channel_id: result.channel_id, @@ -147,12 +151,18 @@ function validateDeliveryInput( async function postDiscordMessage( input: DiscordDeliveryInput, + requestFingerprint: string, options: DiscordbotOptions, ): Promise { - const apiBase = (options.discordApiUrl ?? DEFAULT_DISCORD_API_URL).replace( - /\/$/, - "", - ); + let apiBase: string; + try { + apiBase = resolveDiscordApiBase( + options.discordApiUrl, + options.allowInProcessGatewayEmulation === true, + ); + } catch { + throw new DiscordDeliveryError("discord_api_url_invalid", 502); + } const nonce = createHash("sha256") .update(input.delivery_id) .digest("hex") @@ -184,21 +194,39 @@ async function postDiscordMessage( delivery_id: input.delivery_id, message_id: body.id, ok: true, + request_fingerprint: requestFingerprint, }; } -function isDeliveryResult( +function existingDeliveryResult( value: unknown, - input: DiscordDeliveryInput, -): value is DiscordDeliveryResult { - if (!value || typeof value !== "object" || Array.isArray(value)) return false; + requestFingerprint: string, +): DiscordDeliveryResult | undefined { + if (value === undefined || value === null) return undefined; + if (!value || typeof value !== "object" || Array.isArray(value)) { + throw new DiscordDeliveryError("delivery_id_conflict", 409); + } const result = value as Partial; - return ( + const valid = result.ok === true && - result.channel_id === input.channel_id && - result.delivery_id === input.delivery_id && - typeof result.message_id === "string" - ); + result.request_fingerprint === requestFingerprint && + typeof result.channel_id === "string" && + typeof result.delivery_id === "string" && + typeof result.message_id === "string"; + if (!valid) { + throw new DiscordDeliveryError("delivery_id_conflict", 409); + } + return result as DiscordDeliveryResult; +} + +function deliveryRequestFingerprint(input: DiscordDeliveryInput): string { + const canonical = JSON.stringify([ + 1, + input.delivery_id, + input.channel_id, + input.text, + ]); + return `sha256:${createHash("sha256").update(canonical).digest("hex")}`; } function constantTimeEqual(left: string, right: string): boolean { diff --git a/services/discordbot/src/discord-ingress.ts b/services/discordbot/src/discord-ingress.ts index 121702042f..198f7ab87b 100644 --- a/services/discordbot/src/discord-ingress.ts +++ b/services/discordbot/src/discord-ingress.ts @@ -3,6 +3,7 @@ import { parseDiscordThreadKey, resolveChannelAllowlist, resolveGuildAllowlist, + resolveTriggerBotAllowlist, } from "./discord-allowlist"; import { resolveDiscordPermissionBundle, @@ -253,8 +254,14 @@ async function evaluateAdmission( return deny("stale_delivery"); } if (event.authorIsSelf) return deny("self_message"); - if (event.webhookId) return deny("webhook_message"); - if (event.authorIsBot) return deny("bot_message"); + const triggerBotAllowlist = new Set(resolveTriggerBotAllowlist(options)); + const explicitlyAllowedBot = [ + event.authorId, + event.applicationId, + event.webhookId, + ].some((id) => id !== undefined && triggerBotAllowlist.has(id)); + if (event.webhookId && !explicitlyAllowedBot) return deny("webhook_message"); + if (event.authorIsBot && !explicitlyAllowedBot) return deny("bot_message"); if (!SUPPORTED_MESSAGE_TYPES.has(event.messageType)) { return deny("unsupported_message_type"); } diff --git a/services/discordbot/src/discord-narrator.ts b/services/discordbot/src/discord-narrator.ts index 2bf2d9885d..5f47ef6519 100644 --- a/services/discordbot/src/discord-narrator.ts +++ b/services/discordbot/src/discord-narrator.ts @@ -1,7 +1,7 @@ import type { ChatSDKStreamChunk } from "@centaur/rendering"; import type { Logger, Thread } from "chat"; +import { resolveDiscordApiBase } from "./discord-api"; import { parseDiscordThreadKey } from "./discord-allowlist"; -import { DEFAULT_DISCORD_API_URL } from "./discord-threading"; import type { DiscordbotApiMessage, DiscordbotOptions } from "./types"; import { errorMessage } from "./utils"; @@ -151,9 +151,10 @@ async function discordReactionRequest( const { emoji, messageId, method } = input; try { const fetchFn = botOptions.fetch ?? fetch; - const apiBase = ( - botOptions.discordApiUrl ?? DEFAULT_DISCORD_API_URL - ).replace(/\/$/, ""); + const apiBase = resolveDiscordApiBase( + botOptions.discordApiUrl, + botOptions.allowInProcessGatewayEmulation === true, + ); const response = await fetchFn( `${apiBase}/channels/${channelId}/messages/${messageId}/reactions/${encodeURIComponent(emoji)}/@me`, { diff --git a/services/discordbot/src/discord-starter.ts b/services/discordbot/src/discord-starter.ts index d53660c069..8f570b4ba2 100644 --- a/services/discordbot/src/discord-starter.ts +++ b/services/discordbot/src/discord-starter.ts @@ -1,6 +1,6 @@ import type { Attachment, Logger } from "chat"; +import { resolveDiscordApiBase } from "./discord-api"; import { parseDiscordThreadKey } from "./discord-allowlist"; -import { DEFAULT_DISCORD_API_URL } from "./discord-threading"; import type { DiscordbotApiAttachment, DiscordbotApiMessage, @@ -29,9 +29,9 @@ export async function fetchThreadStarterMessage( if (!channelId || !threadId) return null; const fetchFn = options.fetch ?? fetch; - const apiBase = (options.discordApiUrl ?? DEFAULT_DISCORD_API_URL).replace( - /\/$/, - "", + const apiBase = resolveDiscordApiBase( + options.discordApiUrl, + options.allowInProcessGatewayEmulation === true, ); try { const response = await fetchFn( diff --git a/services/discordbot/src/discord-threading.ts b/services/discordbot/src/discord-threading.ts index 4fd109c9d7..630947e9aa 100644 --- a/services/discordbot/src/discord-threading.ts +++ b/services/discordbot/src/discord-threading.ts @@ -1,10 +1,10 @@ import type { Logger } from "chat"; +import { resolveDiscordApiBase } from "./discord-api"; import { parseDiscordThreadKey } from "./discord-allowlist"; import type { DiscordbotOptions } from "./types"; import { sliceSurrogateSafe } from "./utils"; const DISCORD_THREAD_NAME_LIMIT = 100; -export const DEFAULT_DISCORD_API_URL = "https://discord.com/api/v10"; /** * Derive a Discord thread name from the triggering message text. The `@chat-adapter/discord` @@ -55,9 +55,9 @@ export async function renameThreadFromMessage( if (!threadId) return; const fetchFn = options.fetch ?? fetch; - const apiBase = (options.discordApiUrl ?? DEFAULT_DISCORD_API_URL).replace( - /\/$/, - "", + const apiBase = resolveDiscordApiBase( + options.discordApiUrl, + options.allowInProcessGatewayEmulation === true, ); try { const response = await fetchFn(`${apiBase}/channels/${threadId}`, { @@ -94,9 +94,9 @@ export async function fetchDiscordChannelName( logger: Logger, ): Promise { const fetchFn = options.fetch ?? fetch; - const apiBase = (options.discordApiUrl ?? DEFAULT_DISCORD_API_URL).replace( - /\/$/, - "", + const apiBase = resolveDiscordApiBase( + options.discordApiUrl, + options.allowInProcessGatewayEmulation === true, ); try { const response = await fetchFn(`${apiBase}/channels/${channelId}`, { diff --git a/services/discordbot/src/index.ts b/services/discordbot/src/index.ts index 8edecd266e..64cd10c230 100644 --- a/services/discordbot/src/index.ts +++ b/services/discordbot/src/index.ts @@ -17,11 +17,14 @@ import { } from "chat"; import { Hono } from "hono"; import pg from "pg"; +import { resolveDiscordApiBase } from "./discord-api"; import { discordIngressDenialReason, isAllowedDiscordMessage, isDiscordIngressAllowlistEmpty, + isAllowedDiscordGuild, parseDiscordThreadKey, + resolveTriggerBotAllowlist, } from "./discord-allowlist"; import { acceptedDiscordAdmissionForMessage, @@ -187,6 +190,10 @@ export async function resolveDiscordConversationName( export function createDiscordbot(options: DiscordbotOptions): Discordbot { const userName = options.userName ?? "centaur"; const logger = options.logger ?? noopLogger; + const discordApiBase = resolveDiscordApiBase( + options.discordApiUrl, + options.allowInProcessGatewayEmulation === true, + ); if (isDiscordIngressAllowlistEmpty(options)) { logger.warn("discordbot_ingress_allowlist_incomplete_inert", { @@ -196,7 +203,7 @@ export function createDiscordbot(options: DiscordbotOptions): Discordbot { const state = options.state ?? createDefaultState(options, logger); const discord = createDiscordAdapter({ - apiUrl: options.discordApiUrl, + apiUrl: discordApiBase, applicationId: options.applicationId, botToken: options.botToken, publicKey: options.publicKey, @@ -233,6 +240,12 @@ export function createDiscordbot(options: DiscordbotOptions): Discordbot { } return denialReason === undefined; }, + // The adapter drops bot authors before admission by default. Forward only + // an explicitly configured immutable bot identity in an allowed guild; + // durable admission still requires a reviewed role capability bundle. + shouldForwardBotMessage: ({ authorId, guildId }) => + isAllowedDiscordGuild(guildId, options) && + resolveTriggerBotAllowlist(options).includes(authorId), // Discord delta (patched adapter): the Gateway never redelivers, so a // message dropped on a thread-lock conflict is otherwise lost with zero // signal — surface it with a 🔁 reaction so the user knows to resend. diff --git a/services/discordbot/src/server.ts b/services/discordbot/src/server.ts index c7912a8d5a..9de069a3c9 100644 --- a/services/discordbot/src/server.ts +++ b/services/discordbot/src/server.ts @@ -10,9 +10,11 @@ const applicationId = requiredEnv("DISCORD_APPLICATION_ID"); const guildAllowlist = optionalList("DISCORDBOT_GUILD_ALLOWLIST"); const channelAllowlist = optionalList("DISCORDBOT_CHANNEL_ALLOWLIST"); const mentionRoleIds = optionalList("DISCORD_MENTION_ROLE_IDS"); +const triggerBotAllowlist = optionalList("DISCORDBOT_TRIGGER_BOT_ALLOWLIST"); validateDiscordIds("DISCORDBOT_GUILD_ALLOWLIST", guildAllowlist); validateDiscordIds("DISCORDBOT_CHANNEL_ALLOWLIST", channelAllowlist); validateDiscordIds("DISCORD_MENTION_ROLE_IDS", mentionRoleIds); +validateDiscordIds("DISCORDBOT_TRIGGER_BOT_ALLOWLIST", triggerBotAllowlist); const consoleLogger = { debug: (message: string, data?: unknown) => log("debug", message, data), @@ -64,6 +66,7 @@ const options: DiscordbotOptions = { optionalEnv("DISCORDBOT_ROLE_BINDINGS_JSON"), ), stateKeyPrefix: optionalEnv("DISCORDBOT_STATE_KEY_PREFIX"), + triggerBotAllowlist, userName: stringEnv("DISCORDBOT_USER_NAME", "centaur"), logger: consoleLogger, }; diff --git a/services/discordbot/test/chat-sdk-emulate.test.ts b/services/discordbot/test/chat-sdk-emulate.test.ts index 1833dd5a3c..c1e34bd22d 100644 --- a/services/discordbot/test/chat-sdk-emulate.test.ts +++ b/services/discordbot/test/chat-sdk-emulate.test.ts @@ -1360,12 +1360,12 @@ describe("discordbot", () => { expect(codexApi.creates).toHaveLength(0); expect(codexApi.executes).toHaveLength(0); - // Bot-authored messages are always denied. A legacy trigger-bot allowlist - // cannot bypass the actor-aware human policy boundary. + // Bot-authored messages need both the explicit immutable bot allowlist and + // the same reviewed role capability policy as a human actor. bot = createTestBot({ triggerBotAllowlist: [TRIGGER_BOT_ID] }); const threadId = discordApi.nextId(); discordApi.seedThreadChannel(threadId, CHANNEL_ID); - const deniedBotMentionId = await dispatchMessage({ + const allowedBotMentionId = await dispatchMessage({ authorBot: true, authorId: TRIGGER_BOT_ID, channelId: threadId, @@ -1373,9 +1373,9 @@ describe("discordbot", () => { mention: true, thread: { id: threadId, parentId: CHANNEL_ID }, }); - await sleep(50); - expect(codexApi.executes).toHaveLength(0); - expect(reactionsOn(threadId, deniedBotMentionId)).toEqual([]); + await waitForSettle(threadId, allowedBotMentionId); + expect(codexApi.executes).toHaveLength(1); + codexApi.reset(); // Follow-ups are re-authorized; removing the human role blocks new context // even inside a previously authorized thread. diff --git a/services/discordbot/test/discord-allowlist.test.ts b/services/discordbot/test/discord-allowlist.test.ts index 7bcaf85865..c9b364e940 100644 --- a/services/discordbot/test/discord-allowlist.test.ts +++ b/services/discordbot/test/discord-allowlist.test.ts @@ -314,4 +314,18 @@ describe("Discord ingress context", () => { isDiscordIngressAllowlistEmpty(options({ roleBindings: [] })), ).toBe(true); }); + + it("does not let a legacy role reactivate an explicitly empty policy", () => { + expect( + discordIngressDenialReason( + { + authorIsBot: false, + channelId: "C1", + guildId: "G1", + roleIds: ["R1"], + }, + options({ roleBindings: [], triggerRoleAllowlist: ["R1"] }), + ), + ).toBe("role_allowlist_empty"); + }); }); diff --git a/services/discordbot/test/discord-delivery.test.ts b/services/discordbot/test/discord-delivery.test.ts index d62d6ac05f..c7c5cdc25f 100644 --- a/services/discordbot/test/discord-delivery.test.ts +++ b/services/discordbot/test/discord-delivery.test.ts @@ -9,6 +9,7 @@ import { import type { DiscordbotOptions } from "../src/types"; const CHANNEL_ID = "1542739830591459369"; +const OTHER_CHANNEL_ID = "1542739830591459000"; const MESSAGE_ID = "1542739830591459999"; function options(fetchFn: typeof fetch): DiscordbotOptions { @@ -95,6 +96,73 @@ describe("Discord workflow delivery", () => { expect(String(requests[0]?.body.nonce)).toHaveLength(24); expect(audits).toHaveLength(1); expect(audits[0]).not.toHaveProperty("text"); + expect(first.request_fingerprint).toMatch(/^sha256:[0-9a-f]{64}$/); + }); + + it("rejects conflicting reuse of a delivery id before a second post", async () => { + let requests = 0; + const fetchFn = (async () => { + requests += 1; + return new Response(JSON.stringify({ id: MESSAGE_ID }), { status: 200 }); + }) as unknown as typeof fetch; + const configured = options(fetchFn); + configured.channelAllowlist = [CHANNEL_ID, OTHER_CHANNEL_ID]; + const state = createMemoryState(); + await state.connect(); + const original = { + channel_id: CHANNEL_ID, + delivery_id: "weekly-ops:stable-id", + text: "Original bounded digest.", + }; + await deliverDiscordNotification( + original, + configured, + state, + recordingLogger([]), + ); + + for (const conflict of [ + { ...original, text: "Different text." }, + { ...original, channel_id: OTHER_CHANNEL_ID }, + ]) { + const error = await deliverDiscordNotification( + conflict, + configured, + state, + recordingLogger([]), + ).catch((caught) => caught); + expect(error).toBeInstanceOf(DiscordDeliveryError); + expect(error.code).toBe("delivery_id_conflict"); + expect(error.status).toBe(409); + } + expect(requests).toBe(1); + }); + + it("rejects a non-HTTPS Discord API before sending the bot token", async () => { + let requests = 0; + const fetchFn = (async () => { + requests += 1; + return new Response(JSON.stringify({ id: MESSAGE_ID }), { status: 200 }); + }) as unknown as typeof fetch; + const configured = options(fetchFn); + configured.discordApiUrl = "http://discord.invalid/api/v10"; + const state = createMemoryState(); + await state.connect(); + + const error = await deliverDiscordNotification( + { + channel_id: CHANNEL_ID, + delivery_id: "weekly-ops:unsafe-origin", + text: "Must not send.", + }, + configured, + state, + recordingLogger([]), + ).catch((caught) => caught); + + expect(error).toBeInstanceOf(DiscordDeliveryError); + expect(error.code).toBe("discord_api_url_invalid"); + expect(requests).toBe(0); }); it("rejects an unlisted destination before any Discord request", async () => { @@ -108,7 +176,7 @@ describe("Discord workflow delivery", () => { const error = await deliverDiscordNotification( { - channel_id: "1542739830591459000", + channel_id: OTHER_CHANNEL_ID, delivery_id: "weekly-ops:blocked", text: "should not post", }, diff --git a/services/discordbot/test/discord-ingress.test.ts b/services/discordbot/test/discord-ingress.test.ts index ea1937e8db..55395e0861 100644 --- a/services/discordbot/test/discord-ingress.test.ts +++ b/services/discordbot/test/discord-ingress.test.ts @@ -173,6 +173,34 @@ describe("Discord Gateway admission", () => { } }); + it("requires both an explicit bot identity and a reviewed role capability", async () => { + const configured = options({ triggerBotAllowlist: [USER] }); + expect( + await reasonFor( + event("600000000000000045", { authorIsBot: true }), + configured, + ), + ).toBe("accepted"); + expect( + await reasonFor( + event("600000000000000046", { + authorIsBot: true, + roleIds: [], + }), + configured, + ), + ).toBe("role_not_authorized"); + expect( + await reasonFor( + event("600000000000000047", { + authorIsBot: true, + webhookId: "700000000000000001", + }), + options({ triggerBotAllowlist: ["700000000000000001"] }), + ), + ).toBe("accepted"); + }); + it("requires a mention root in the parent and never roots an unrelated thread", async () => { expect( await reasonFor( From a6208ff28154650c8e5309bcdbe64dbbe13a75d4 Mon Sep 17 00:00:00 2001 From: Michael Wu Date: Tue, 1 Sep 2026 17:12:56 +0900 Subject: [PATCH 06/37] fix: serialize actor policy and retry ingress claims --- .../crates/centaur-iron-control/src/client.rs | 19 ++- .../crates/centaur-iron-control/src/lib.rs | 6 +- .../crates/centaur-iron-control/src/models.rs | 11 ++ .../centaur-iron-control/src/session.rs | 117 +++++------------- .../api-rs/crates/centaur-perms/src/tests.rs | 16 +++ .../api-rs/crates/centaur-perms/src/tools.rs | 1 + .../api/v1/principal_roles_controller.rb | 41 ++++++ services/console/app/models/principal.rb | 15 +++ services/console/config/routes.rb | 1 + services/console/docs/API.md | 20 ++- .../api/v1/principal_roles_controller_test.rb | 40 ++++++ services/discordbot/src/discord-ingress.ts | 76 +++++++++++- .../discordbot/test/discord-ingress.test.ts | 53 ++++++++ 13 files changed, 325 insertions(+), 91 deletions(-) diff --git a/services/api-rs/crates/centaur-iron-control/src/client.rs b/services/api-rs/crates/centaur-iron-control/src/client.rs index fbc774a518..f32e5e058f 100644 --- a/services/api-rs/crates/centaur-iron-control/src/client.rs +++ b/services/api-rs/crates/centaur-iron-control/src/client.rs @@ -16,8 +16,8 @@ use crate::models::{ AwsAuthSecretInput, BrokerCredentialInput, BrokerCredentialRecord, DataEnvelope, EffectiveConfig, GcpAuthSecretInput, GcpIdTokenSecretInput, Grant, GrantSecret, Grantee, HmacSecretInput, IdentityInput, OAuthTokenSecretInput, PgDsnSecretInput, Principal, - PrincipalInput, Proxy, ProxyInput, Role, SecretRecord, SlackChannelPermissionInput, - StaticSecretInput, + PrincipalInput, PrincipalPolicyInput, Proxy, ProxyInput, Role, SecretRecord, + SlackChannelPermissionInput, StaticSecretInput, }; const API_PREFIX: &str = "/api/v1"; @@ -157,6 +157,21 @@ impl IronControlClient { .await } + /// Atomically replace a principal's complete role set and sandbox policy. + /// The Console locks the principal row for the whole replacement, so two + /// actor-policy reconciliations cannot leave a union of their roles. + pub async fn replace_principal_policy( + &self, + principal_id: &str, + input: &PrincipalPolicyInput, + ) -> Result<()> { + let path = format!( + "{API_PREFIX}/principals/{}/roles", + urlencoding::encode(principal_id) + ); + self.write_unit(Method::PUT, &path, input).await + } + /// Create or update one Slack channel permission row on a principal without /// replacing that principal's other Slack permissions. pub async fn upsert_slack_channel_permission( diff --git a/services/api-rs/crates/centaur-iron-control/src/lib.rs b/services/api-rs/crates/centaur-iron-control/src/lib.rs index 07bc1d803d..96a345eba5 100644 --- a/services/api-rs/crates/centaur-iron-control/src/lib.rs +++ b/services/api-rs/crates/centaur-iron-control/src/lib.rs @@ -21,9 +21,9 @@ pub use models::{ EffectivePgDsn, EffectiveReplace, EffectiveSecret, GCP_ID_TOKEN_ALLOWED_HEADERS, GcpAuthSecretInput, GcpIdTokenSecretInput, Grant, GrantSecret, Grantee, HmacSecretHeader, HmacSecretInput, IdentityInput, InjectConfig, OAuthTokenSecretInput, PgDsnSecretInput, - PgDsnSettingInput, PgDsnSettingValueFromInput, Principal, PrincipalInput, Proxy, ProxyInput, - ReplaceConfig, RequestRule, Role, SECRET_TYPES, SecretRecord, SecretSource, StaticSecretInput, - normalize_gcp_id_token_header, + PgDsnSettingInput, PgDsnSettingValueFromInput, Principal, PrincipalInput, PrincipalPolicyInput, + Proxy, ProxyInput, ReplaceConfig, RequestRule, Role, SECRET_TYPES, SecretRecord, SecretSource, + StaticSecretInput, normalize_gcp_id_token_header, }; pub use principal::{ PrincipalDerivationError, PrincipalRef, derive_principal, derive_slack_requester_principal, diff --git a/services/api-rs/crates/centaur-iron-control/src/models.rs b/services/api-rs/crates/centaur-iron-control/src/models.rs index d9890d4c7f..5cc324b04c 100644 --- a/services/api-rs/crates/centaur-iron-control/src/models.rs +++ b/services/api-rs/crates/centaur-iron-control/src/models.rs @@ -479,6 +479,17 @@ pub struct PrincipalInput { pub sandbox_workflows_write_enabled: Option, } +/// Atomic replacement body for one principal's role set and sandbox policy. +#[derive(Clone, Debug, PartialEq, Eq, Serialize)] +pub struct PrincipalPolicyInput { + pub role_ids: Vec, + pub sandbox_repo_cache: String, + pub sandbox_observability_enabled: bool, + pub sandbox_sessions_read_enabled: bool, + pub sandbox_workflows_read_enabled: bool, + pub sandbox_workflows_write_enabled: bool, +} + /// A principal as returned by iron-control. Unknown fields are ignored, so this /// captures only what callers need. #[derive(Clone, Debug, PartialEq, Eq, Deserialize)] diff --git a/services/api-rs/crates/centaur-iron-control/src/session.rs b/services/api-rs/crates/centaur-iron-control/src/session.rs index 753f8a351e..6a43892a44 100644 --- a/services/api-rs/crates/centaur-iron-control/src/session.rs +++ b/services/api-rs/crates/centaur-iron-control/src/session.rs @@ -13,7 +13,7 @@ use serde_json::Value; use crate::IronControlClient; use crate::error::{IronControlError, Result}; -use crate::models::{Principal, PrincipalInput, SlackChannelPermissionInput}; +use crate::models::{Principal, PrincipalInput, PrincipalPolicyInput, SlackChannelPermissionInput}; use crate::principal::{ derive_principal_with_slack_team, derive_slack_requester_principal, is_direct_message, slack_conversation_id, @@ -45,6 +45,7 @@ struct DiscordPrincipalCapabilities { } impl DiscordPrincipalCapabilities { + #[cfg(test)] fn safe() -> Self { Self { repo_cache: "none".to_owned(), @@ -268,10 +269,9 @@ impl SessionRegistrar { /// Replace every role on an actor-scoped Discord principal with the one /// reviewed policy role asserted by the authenticated Discord ingress. - /// Defaults and stale roles are removed before the desired role is added, - /// so a partial failure can only narrow access. Direct grants are never - /// deleted implicitly; their presence fails session creation for an - /// operator to reconcile explicitly. + /// Console replaces the role and capability tuple in one row-locked + /// transaction. Direct grants are never deleted implicitly; their presence + /// fails session creation for an operator to reconcile explicitly. async fn reconcile_discord_policy_roles( &self, principal: &Principal, @@ -334,62 +334,20 @@ impl SessionRegistrar { .labels, )?; - // Persist a conservative baseline before removing defaults or changing - // role assignments. If a later API call fails, the stored principal is - // no more capable than the global defaults. - let narrowed = self - .apply_discord_principal_capabilities(principal, &DiscordPrincipalCapabilities::safe()) - .await?; - let desired_ids = desired_roles - .iter() - .map(|role| role.id.as_str()) - .collect::>(); - let current_roles = self.client.list_principal_roles(&narrowed.id).await?; - for role in ¤t_roles { - if !desired_ids.contains(role.id.as_str()) { - self.client.unassign_role(&narrowed.id, &role.id).await?; - } - } - let current_ids = current_roles - .iter() - .map(|role| role.id.as_str()) - .collect::>(); - for role in desired_roles { - if !current_ids.contains(role.id.as_str()) { - self.client.assign_role(&narrowed.id, &role.id).await?; - } - } - self.apply_discord_principal_capabilities(&narrowed, &capabilities) - .await - } - - async fn apply_discord_principal_capabilities( - &self, - principal: &Principal, - capabilities: &DiscordPrincipalCapabilities, - ) -> Result { - let foreign_id = principal.foreign_id.clone().ok_or_else(|| { - IronControlError::DiscordPolicy( - "policy-managed Discord principal has no foreign ID".to_owned(), - ) - })?; + let policy = PrincipalPolicyInput { + role_ids: desired_roles.into_iter().map(|role| role.id).collect(), + sandbox_repo_cache: capabilities.repo_cache, + sandbox_observability_enabled: capabilities.observability, + sandbox_sessions_read_enabled: capabilities.sessions_read, + sandbox_workflows_read_enabled: capabilities.workflows_read, + sandbox_workflows_write_enabled: capabilities.workflows_write, + }; self.client - .upsert_principal(&PrincipalInput { - foreign_id, - name: principal.name.clone(), - labels: BTreeMap::new(), - kind: None, - slack_user_id: None, - slack_channel_id: None, - slack_team_id: None, - slack_email: None, - sandbox_repo_cache: Some(capabilities.repo_cache.clone()), - sandbox_observability_enabled: Some(capabilities.observability), - sandbox_sessions_read_enabled: Some(capabilities.sessions_read), - sandbox_workflows_read_enabled: Some(capabilities.workflows_read), - sandbox_workflows_write_enabled: Some(capabilities.workflows_write), - }) - .await + .replace_principal_policy(&principal.id, &policy) + .await?; + let mut reconciled = principal.clone(); + reconciled.sandbox_observability_enabled = policy.sandbox_observability_enabled; + Ok(reconciled) } } @@ -819,7 +777,6 @@ mod tests { #[tokio::test] async fn register_session_reconciles_discord_actor_to_the_exact_reviewed_role() { let (base_url, requests, server) = spawn_discord_policy_stub(DiscordPolicyStub { - current_stale_role: true, direct_grant: false, reviewed_role: true, }) @@ -848,20 +805,23 @@ mod tests { .collect::>(); assert_eq!( principal_updates.len(), - 3, - "initial upsert, conservative baseline, then reviewed capabilities" + 1, + "identity upsert remains separate from the atomic policy replacement" ); - let remove = requests - .iter() - .position(|request| request == "DELETE /api/v1/principals/prn_discord/roles/role_stale") - .expect("stale/default role is removed"); - let assign = requests + let replace = requests .iter() - .position(|request| request == "POST /api/v1/principals/prn_discord/roles") - .expect("reviewed role is assigned"); + .position(|request| request == "PUT /api/v1/principals/prn_discord/roles") + .expect("roles and capabilities are replaced atomically"); assert!( - principal_updates[1] < remove && remove < assign && assign < principal_updates[2], - "capabilities and roles reconcile from a conservative baseline before widening" + principal_updates[0] < replace, + "identity is verified before its reviewed policy is committed" + ); + assert_eq!( + requests + .iter() + .filter(|request| request.as_str() == "PUT /api/v1/principals/prn_discord/roles") + .count(), + 1, ); server.abort(); } @@ -869,7 +829,6 @@ mod tests { #[tokio::test] async fn register_session_rejects_discord_principal_direct_grants() { let (base_url, requests, server) = spawn_discord_policy_stub(DiscordPolicyStub { - current_stale_role: false, direct_grant: true, reviewed_role: true, }) @@ -898,7 +857,6 @@ mod tests { #[tokio::test] async fn register_session_rejects_unreviewed_discord_policy_role() { let (base_url, requests, server) = spawn_discord_policy_stub(DiscordPolicyStub { - current_stale_role: true, direct_grant: false, reviewed_role: false, }) @@ -917,6 +875,7 @@ mod tests { !requests.lock().unwrap().iter().any(|request| { request.starts_with("DELETE ") || request == "POST /api/v1/principals/prn_discord/roles" + || request == "PUT /api/v1/principals/prn_discord/roles" }), "an unreviewed role blocks before any assignment mutation" ); @@ -1246,7 +1205,6 @@ mod tests { #[derive(Clone, Copy)] struct DiscordPolicyStub { - current_stale_role: bool, direct_grant: bool, reviewed_role: bool, } @@ -1292,11 +1250,6 @@ mod tests { } else { r#"{"data":[]}"# }; - let roles = if config.current_stale_role { - r#"{"data":[{"id":"role_stale","foreign_id":"default-agent","name":"Default Agent","labels":{}}]}"# - } else { - r#"{"data":[]}"# - }; let (status_line, body) = match (method, path) { ( "GET", @@ -1310,9 +1263,7 @@ mod tests { ("200 OK", grants.to_owned()) } ("GET", "/api/v1/roles/lookup/discord-observer") => ("200 OK", role), - ("GET", "/api/v1/principals/prn_discord/roles") => ("200 OK", roles.to_owned()), - ("DELETE", "/api/v1/principals/prn_discord/roles/role_stale") - | ("POST", "/api/v1/principals/prn_discord/roles") => { + ("PUT", "/api/v1/principals/prn_discord/roles") => { ("200 OK", r#"{"data":{"ok":true}}"#.to_owned()) } _ => ( diff --git a/services/api-rs/crates/centaur-perms/src/tests.rs b/services/api-rs/crates/centaur-perms/src/tests.rs index 878d93b173..5d22ead44b 100644 --- a/services/api-rs/crates/centaur-perms/src/tests.rs +++ b/services/api-rs/crates/centaur-perms/src/tests.rs @@ -491,6 +491,22 @@ fn http_request_scope_rejects_invalid_methods_and_paths() { } } +#[test] +fn http_request_scope_trims_reviewed_methods_and_paths() { + let parsed = tools::parse_secret( + &entry( + r#"{type = "http", name = "TOKEN", match_headers = ["Authorization"], hosts = ["api.example.com"], http_methods = [" GET "], paths = [" /v1/* "]}"#, + ), + &[], + ) + .unwrap(); + let ParsedSecret::Http(http) = parsed else { + panic!("expected http") + }; + assert_eq!(http.http_methods, ["GET"]); + assert_eq!(http.paths, ["/v1/*"]); +} + #[test] fn translates_gcp_auth_defaults_scopes_when_unset() { let secrets = vec![ diff --git a/services/api-rs/crates/centaur-perms/src/tools.rs b/services/api-rs/crates/centaur-perms/src/tools.rs index cdbaeb9458..e2ad52fc29 100644 --- a/services/api-rs/crates/centaur-perms/src/tools.rs +++ b/services/api-rs/crates/centaur-perms/src/tools.rs @@ -1088,6 +1088,7 @@ fn strict_str_array(table: &toml::Table, name: &str, key: &str) -> Result e + render_validation_error(e.record) + end + def destroy principal = Principal.find_by_oid!(params[:principal_id]) role = Role.find_by_oid!(params[:id]) @@ -37,6 +58,26 @@ def destroy private + def replacement_policy + raw = data_params.to_unsafe_h.stringify_keys + unless raw.keys.sort == POLICY_KEYS.sort + raise ActionController::BadRequest, "atomic principal policy has invalid fields" + end + role_ids = raw.fetch("role_ids") + unless role_ids.is_a?(Array) && role_ids.length.between?(1, 16) && + role_ids.all? { |role_id| role_id.is_a?(String) && role_id.match?(/\Arole_[A-Za-z0-9_-]+\z/) } && + role_ids.uniq.length == role_ids.length + raise ActionController::BadRequest, "role_ids must contain unique role OIDs" + end + unless Principal::SANDBOX_REPO_CACHE_VALUES.include?(raw.fetch("sandbox_repo_cache")) + raise ActionController::BadRequest, "sandbox_repo_cache is invalid" + end + unless BOOLEAN_POLICY_KEYS.all? { |key| [ true, false ].include?(raw.fetch(key)) } + raise ActionController::BadRequest, "sandbox capability values must be booleans" + end + raw.symbolize_keys + end + def role_payload(role) { id: role.oid, diff --git a/services/console/app/models/principal.rb b/services/console/app/models/principal.rb index a1a101ba4f..b85319ee1a 100644 --- a/services/console/app/models/principal.rb +++ b/services/console/app/models/principal.rb @@ -148,6 +148,21 @@ def apply_default_sandbox_capabilities!(supplied = {}) end end + # Replace the complete role and sandbox-capability tuple while holding the + # principal row lock. Concurrent policy refreshes therefore commit as one + # ordered state rather than interleaving into a union of privileged roles. + def replace_roles_and_sandbox_policy!(roles:, **capabilities) + desired_roles = Array(roles).uniq(&:id) + with_lock do + update!(capabilities) + desired_ids = desired_roles.map(&:id) + principal_roles.where.not(role_id: desired_ids).destroy_all + desired_roles.each { |role| principal_roles.find_or_create_by!(role:) } + end + self.roles.reset + desired_roles + end + # Slackbot only sends a DM partner's email when that user belongs to the # bot's home workspace. Treat an explicitly supplied email as a trusted # bridge to the corresponding Console account. This is called from the API diff --git a/services/console/config/routes.rb b/services/console/config/routes.rb index 2eea33c3cb..7a773f4357 100644 --- a/services/console/config/routes.rb +++ b/services/console/config/routes.rb @@ -226,6 +226,7 @@ # Grants whose grantee is this role. :role_id is the role's oid. resources :grants, only: %i[index], controller: :grantee_grants end + put "principals/:principal_id/roles", to: "principal_roles#replace" resources :principals, only: %i[index show create update] do collection do get "lookup/default/:foreign_id/effective_config", diff --git a/services/console/docs/API.md b/services/console/docs/API.md index 1484d45eba..5e8984811b 100644 --- a/services/console/docs/API.md +++ b/services/console/docs/API.md @@ -1340,12 +1340,30 @@ Assign and unassign roles on a principal. The assignment endpoints are nested un { "data": { "role_id": "role_..." } } ``` -Returns `201` with the assigned role's representation. Assigning an already assigned role returns `422`. An unknown principal or role returns `404`. +Returns `201` with the assigned role's representation. Assigning an already assigned role is idempotent and returns `200`. An unknown principal or role returns `404`. + +`PUT` replaces the complete role set and sandbox capability policy in one +principal-row-locked transaction. Supply every field; partial or unknown policy +documents fail closed: + +```json +{ + "data": { + "role_ids": ["role_..."], + "sandbox_repo_cache": "all", + "sandbox_observability_enabled": true, + "sandbox_sessions_read_enabled": false, + "sandbox_workflows_read_enabled": true, + "sandbox_workflows_write_enabled": true + } +} +``` | Method | Path | Notes | | -------- | ---- | ----- | | `GET` | `/api/v1/principals/:principal_id/roles` | List the roles assigned to the principal. | | `POST` | `/api/v1/principals/:principal_id/roles` | Assign a role (`data: { role_id }`). | +| `PUT` | `/api/v1/principals/:principal_id/roles` | Atomically replace the complete role and sandbox-policy tuple. | | `DELETE` | `/api/v1/principals/:principal_id/roles/:id` | Unassign the role with OID `:id`. Returns `204`; `404` if not assigned. | ## Grants diff --git a/services/console/test/controllers/api/v1/principal_roles_controller_test.rb b/services/console/test/controllers/api/v1/principal_roles_controller_test.rb index c53ba436a2..172d3c5e61 100644 --- a/services/console/test/controllers/api/v1/principal_roles_controller_test.rb +++ b/services/console/test/controllers/api/v1/principal_roles_controller_test.rb @@ -98,6 +98,46 @@ def json_body assert_response :not_found end + test "PUT atomically replaces roles and sandbox policy" do + principal = principals(:acme_channel) + role = roles(:acme_admin_role) + body = { + data: { + role_ids: [ role.oid ], + sandbox_repo_cache: "all", + sandbox_observability_enabled: false, + sandbox_sessions_read_enabled: true, + sandbox_workflows_read_enabled: true, + sandbox_workflows_write_enabled: false + } + } + + put api_v1_principal_roles_url(principal_id: principal.oid), + params: body.to_json, headers: auth_headers + + assert_response :ok + assert_equal [ role.oid ], json_body.fetch("data").map { |item| item.fetch("id") } + principal.reload + assert_equal [ role.id ], principal.role_ids + assert_equal "all", principal.sandbox_repo_cache + assert_not principal.sandbox_observability_enabled + assert principal.sandbox_sessions_read_enabled + assert principal.sandbox_workflows_read_enabled + assert_not principal.sandbox_workflows_write_enabled + end + + test "PUT rejects partial policy without changing roles" do + principal = principals(:acme_channel) + previous_role_ids = principal.role_ids + + put api_v1_principal_roles_url(principal_id: principal.oid), + params: { data: { role_ids: [ roles(:acme_admin_role).oid ] } }.to_json, + headers: auth_headers + + assert_response :bad_request + assert_equal previous_role_ids, principal.reload.role_ids + end + test "DELETE unassigns a role" do principal = principals(:acme_channel) role = roles(:acme_infra) diff --git a/services/discordbot/src/discord-ingress.ts b/services/discordbot/src/discord-ingress.ts index 198f7ab87b..2a74e5641b 100644 --- a/services/discordbot/src/discord-ingress.ts +++ b/services/discordbot/src/discord-ingress.ts @@ -147,7 +147,9 @@ export async function admitDiscordGatewayMessage( try { record = await evaluateAdmission(event, options, state, now); } catch { - record = { ...pending, reason: "state_unavailable" }; + await releasePendingDeliveryClaim(state, pending); + audit(logger, pending); + return null; } try { await state.set( @@ -156,7 +158,15 @@ export async function admitDiscordGatewayMessage( options.ingressDeliveryTtlMs ?? DEFAULT_DELIVERY_TTL_MS, ); } catch { - record = { ...pending, reason: "state_unavailable" }; + const persisted = await recoverPersistedAdmissionOrReleaseClaim( + state, + pending, + record, + ); + if (!persisted) { + audit(logger, pending); + return null; + } } audit(logger, record); return record.decision === "allow" ? record : null; @@ -406,6 +416,68 @@ function deliveryKey(messageId: string): string { return `discordbot:ingress:delivery:${messageId}`; } +async function releasePendingDeliveryClaim( + state: StateAdapter, + pending: DiscordDeniedAdmission, +): Promise { + try { + const current = await state.get(deliveryKey(pending.messageId)); + if (sameAdmissionRecord(current, pending)) { + await state.delete(deliveryKey(pending.messageId)); + } + } catch { + // The audit still reports state_unavailable. A backend that cannot read or + // delete its provisional claim remains fail-closed until its TTL expires. + } +} + +async function recoverPersistedAdmissionOrReleaseClaim( + state: StateAdapter, + pending: DiscordDeniedAdmission, + expected: DiscordAdmissionRecord, +): Promise { + try { + const current = await state.get(deliveryKey(pending.messageId)); + if (sameAdmissionRecord(current, expected)) return true; + if (sameAdmissionRecord(current, pending)) { + await state.delete(deliveryKey(pending.messageId)); + } + } catch { + // Return fail-closed when the final write outcome cannot be proven. + } + return false; +} + +function sameAdmissionRecord( + value: unknown, + expected: DiscordAdmissionRecord, +): boolean { + if (!value || typeof value !== "object" || Array.isArray(value)) return false; + const record = value as Partial; + if ( + record.version !== expected.version || + record.decision !== expected.decision || + record.reason !== expected.reason || + record.actorId !== expected.actorId || + record.channelId !== expected.channelId || + record.guildId !== expected.guildId || + record.messageId !== expected.messageId || + record.receivedAt !== expected.receivedAt || + record.threadId !== expected.threadId + ) { + return false; + } + if (expected.decision === "deny") return record.decision === "deny"; + if (record.decision !== "allow") return false; + const acceptedRecord = record as Partial; + return ( + acceptedRecord.control === expected.control && + acceptedRecord.proposalFingerprint === expected.proposalFingerprint && + acceptedRecord.rootMessageId === expected.rootMessageId && + acceptedRecord.policy?.fingerprint === expected.policy.fingerprint + ); +} + function rootKey(guildId: string, channelId: string, threadId: string): string { return `discordbot:ingress:root:${guildId}:${channelId}:${threadId}`; } diff --git a/services/discordbot/test/discord-ingress.test.ts b/services/discordbot/test/discord-ingress.test.ts index 55395e0861..209077007e 100644 --- a/services/discordbot/test/discord-ingress.test.ts +++ b/services/discordbot/test/discord-ingress.test.ts @@ -136,6 +136,59 @@ describe("Discord Gateway admission", () => { ]); }); + it("releases only its provisional delivery claim after transient state failures", async () => { + for (const failure of ["evaluation", "final_write"] as const) { + const { audits, logger, state } = await harness(); + let failOnce = true; + const flaky = new Proxy(state, { + get(target, property) { + if (property === "get") { + return async (key: string) => { + if ( + failure === "evaluation" && + failOnce && + key.startsWith("discordbot:ingress:root:") + ) { + failOnce = false; + throw new Error("transient read failure"); + } + return target.get(key); + }; + } + if (property === "set") { + return async (key: string, value: unknown, ttlMs?: number) => { + if ( + failure === "final_write" && + failOnce && + key.startsWith("discordbot:ingress:delivery:") + ) { + failOnce = false; + throw new Error("transient write failure"); + } + return target.set(key, value, ttlMs); + }; + } + const value = Reflect.get(target, property, target); + return typeof value === "function" ? value.bind(target) : value; + }, + }) as StateAdapter; + const message = event( + failure === "evaluation" + ? "600000000000000002" + : "600000000000000003", + ); + + expect( + await admitDiscordGatewayMessage(message, options(), flaky, logger, NOW), + ).toBeNull(); + expect(audits.at(-1)?.data.reason).toBe("state_unavailable"); + expect( + await admitDiscordGatewayMessage(message, options(), flaky, logger, NOW), + ).toEqual(expect.objectContaining({ decision: "allow" })); + expect(audits.at(-1)?.data.reason).toBe("accepted"); + } + }); + it("rejects unauthenticated, stale, replay-like, DM, and malformed transport data", async () => { const cases: Array<[string, Partial, number?]> = [ ["gateway_identity_unverified", { gatewayIdentityVerified: false }], From a0ae20a4cee4975c27f543495e9dc049aea11883 Mon Sep 17 00:00:00 2001 From: Michael Wu Date: Tue, 1 Sep 2026 17:33:02 +0900 Subject: [PATCH 07/37] fix: close Discord continuation boundaries --- .../crates/centaur-api-server/src/routes.rs | 90 +++++++++++++++++-- services/discordbot/src/discord-api.ts | 4 +- services/discordbot/src/discord-ingress.ts | 3 + .../discordbot/test/discord-delivery.test.ts | 6 ++ .../discordbot/test/discord-ingress.test.ts | 9 ++ 5 files changed, 105 insertions(+), 7 deletions(-) diff --git a/services/api-rs/crates/centaur-api-server/src/routes.rs b/services/api-rs/crates/centaur-api-server/src/routes.rs index aaf846907b..ee62453968 100644 --- a/services/api-rs/crates/centaur-api-server/src/routes.rs +++ b/services/api-rs/crates/centaur-api-server/src/routes.rs @@ -27,7 +27,7 @@ use axum::{ routing::{any, get, post}, }; use base64::{Engine as _, engine::general_purpose}; -use centaur_session_core::{ChatDestination, ThreadKey}; +use centaur_session_core::{ChatDestination, MessageRole, ThreadKey}; use centaur_session_runtime::{ ExecuteSessionInput, HarnessConflictPolicy, SandboxRuntime, SessionPrincipalRegistrar, SessionRuntime, @@ -761,9 +761,10 @@ async fn append_messages( ) -> Result, ApiError> { let thread_key = ThreadKey::try_from(raw_thread_key)?; for message in &mut request.messages { - message.metadata = sanitize_session_metadata( + message.metadata = sanitize_session_message_metadata( &caller, &thread_key, + &message.role, Some(std::mem::take(&mut message.metadata)), )? .unwrap_or_else(|| json!({})); @@ -820,11 +821,58 @@ fn sanitize_session_metadata( sanitize_session_metadata_for(caller.class(), caller.identity(), thread_key, metadata) } +fn sanitize_session_message_metadata( + caller: &AuthenticatedCaller, + thread_key: &ThreadKey, + role: &MessageRole, + metadata: Option, +) -> Result, ApiError> { + sanitize_session_metadata_for_message( + caller.class(), + caller.identity(), + thread_key, + role, + metadata, + ) +} + fn sanitize_session_metadata_for( + caller_class: CallerClass, + caller_identity: &str, + thread_key: &ThreadKey, + metadata: Option, +) -> Result, ApiError> { + sanitize_session_metadata_with_actor_check( + caller_class, + caller_identity, + thread_key, + metadata, + true, + ) +} + +fn sanitize_session_metadata_for_message( + caller_class: CallerClass, + caller_identity: &str, + thread_key: &ThreadKey, + role: &MessageRole, + metadata: Option, +) -> Result, ApiError> { + sanitize_session_metadata_with_actor_check( + caller_class, + caller_identity, + thread_key, + metadata, + matches!(role, MessageRole::User), + ) +} + +fn sanitize_session_metadata_with_actor_check( caller_class: CallerClass, caller_identity: &str, thread_key: &ThreadKey, mut metadata: Option, + enforce_actor_user_id: bool, ) -> Result, ApiError> { if caller_class != CallerClass::Console && let Some(Value::Object(fields)) = metadata.as_mut() @@ -840,7 +888,7 @@ fn sanitize_session_metadata_for( } return Ok(metadata); } - validate_discord_policy_metadata(thread_key, metadata.as_ref())?; + validate_discord_policy_metadata(thread_key, metadata.as_ref(), enforce_actor_user_id)?; Ok(metadata) } @@ -861,6 +909,7 @@ const DISCORD_POLICY_METADATA_FIELDS: &[&str] = &[ fn validate_discord_policy_metadata( thread_key: &ThreadKey, metadata: Option<&Value>, + enforce_actor_user_id: bool, ) -> Result<(), ApiError> { let Some(Value::Object(fields)) = metadata else { return Err(ApiError::BadRequest( @@ -904,7 +953,8 @@ fn validate_discord_policy_metadata( )); } } - if let Some(user_id) = fields.get("user_id") + if enforce_actor_user_id + && let Some(user_id) = fields.get("user_id") && user_id.as_str() != Some(actor_id) { return Err(ApiError::BadRequest( @@ -1158,9 +1208,9 @@ fn principal_subject_owns_session(subject: Option<&str>, session_principal: Opti mod session_authorization_tests { use super::{ CallerClass, principal_subject_owns_session, sanitize_session_metadata_for, - thread_key_matches_platform, + sanitize_session_metadata_for_message, thread_key_matches_platform, }; - use centaur_session_core::ThreadKey; + use centaur_session_core::{MessageRole, ThreadKey}; use serde_json::json; fn thread_key(value: &str) -> ThreadKey { @@ -1320,6 +1370,34 @@ mod session_authorization_tests { .is_err() ); } + + #[test] + fn discord_history_allows_assistant_authors_but_not_other_user_authority() { + let key = thread_key("discord:200000000000000001:300000000000000001:400000000000000001"); + let mut metadata = discord_policy_metadata(); + metadata["user_id"] = json!("100000000000000099"); + + assert!( + sanitize_session_metadata_for_message( + CallerClass::Ingress, + "discordbot", + &key, + &MessageRole::Assistant, + Some(metadata.clone()), + ) + .is_ok() + ); + assert!( + sanitize_session_metadata_for_message( + CallerClass::Ingress, + "discordbot", + &key, + &MessageRole::User, + Some(metadata), + ) + .is_err() + ); + } } #[derive(Debug, Deserialize)] diff --git a/services/discordbot/src/discord-api.ts b/services/discordbot/src/discord-api.ts index f1feea4f70..4ce5c5c143 100644 --- a/services/discordbot/src/discord-api.ts +++ b/services/discordbot/src/discord-api.ts @@ -25,5 +25,7 @@ export function resolveDiscordApiBase( ) { throw new Error("discord_api_url_invalid"); } - return raw.replace(/\/+$/, ""); + let end = raw.length; + while (end > 0 && raw[end - 1] === "/") end -= 1; + return raw.slice(0, end); } diff --git a/services/discordbot/src/discord-ingress.ts b/services/discordbot/src/discord-ingress.ts index 2a74e5641b..75a05339a7 100644 --- a/services/discordbot/src/discord-ingress.ts +++ b/services/discordbot/src/discord-ingress.ts @@ -322,6 +322,9 @@ async function evaluateAdmission( if (event.threadId) { if (!root) return deny("authorized_root_missing"); if (root.expiresAt < now) return deny("root_expired"); + if (root.policy.fingerprint !== policy.fingerprint) { + return deny("policy_changed_requires_root_trigger"); + } } if (root && root.actorId !== event.authorId && root.expiresAt >= now) { diff --git a/services/discordbot/test/discord-delivery.test.ts b/services/discordbot/test/discord-delivery.test.ts index c7c5cdc25f..6bee769deb 100644 --- a/services/discordbot/test/discord-delivery.test.ts +++ b/services/discordbot/test/discord-delivery.test.ts @@ -6,6 +6,7 @@ import { deliverDiscordNotification, DiscordDeliveryError, } from "../src/discord-delivery"; +import { resolveDiscordApiBase } from "../src/discord-api"; import type { DiscordbotOptions } from "../src/types"; const CHANNEL_ID = "1542739830591459369"; @@ -38,6 +39,11 @@ function recordingLogger(records: Record[]): Logger { } describe("Discord workflow delivery", () => { + it("normalizes an adversarial trailing-slash run in linear time", () => { + const base = "https://discord.invalid/api/v10"; + expect(resolveDiscordApiBase(`${base}${"/".repeat(100_000)}`)).toBe(base); + }); + it("requires the configured internal bearer credential", () => { expect(() => authorizeDiscordDelivery("Bearer internal-key", "internal-key"), diff --git a/services/discordbot/test/discord-ingress.test.ts b/services/discordbot/test/discord-ingress.test.ts index 209077007e..e76d72f710 100644 --- a/services/discordbot/test/discord-ingress.test.ts +++ b/services/discordbot/test/discord-ingress.test.ts @@ -326,6 +326,15 @@ describe("Discord Gateway admission", () => { "policy_changed_requires_root_trigger", NOW + 100, ], + [ + follow("600000000000000065", { + content: `<@${APP}> continue`, + isMentioned: true, + roleIds: [ROLE, WRITE_ROLE], + }), + "policy_changed_requires_root_trigger", + NOW + 100, + ], [follow("600000000000000064"), "root_expired", NOW + 1_001], ]; for (const [candidate, expected, now] of denied) { From 4e713a78ed0fa334cb9f1d27a7ef682c55583173 Mon Sep 17 00:00:00 2001 From: Michael Wu Date: Tue, 1 Sep 2026 17:46:28 +0900 Subject: [PATCH 08/37] fix: preserve reviewed bot identities through dispatch --- patches/@chat-adapter__discord@4.31.0.patch | 24 +++++++----- pnpm-lock.yaml | 6 +-- services/discordbot/src/discord-allowlist.ts | 33 +++++++++++++---- services/discordbot/src/index.ts | 16 +++++++- .../discordbot/test/chat-sdk-emulate.test.ts | 37 +++++++++++++++++++ .../discordbot/test/discord-allowlist.test.ts | 15 ++++++++ 6 files changed, 108 insertions(+), 23 deletions(-) diff --git a/patches/@chat-adapter__discord@4.31.0.patch b/patches/@chat-adapter__discord@4.31.0.patch index c433a10da6..5b414ff97b 100644 --- a/patches/@chat-adapter__discord@4.31.0.patch +++ b/patches/@chat-adapter__discord@4.31.0.patch @@ -13,7 +13,7 @@ index 0024735d2441f1d2e3c2d5ed659fa4c4265f3922..444aa7b36347c4eea42e049504665dd3 */ private convertMentionsToDiscord; /** -@@ -61,6 +61,69 @@ interface DiscordAdapterConfig { +@@ -61,6 +61,71 @@ interface DiscordAdapterConfig { publicKey?: string; /** Override bot username (optional) */ userName?: string; @@ -70,9 +70,11 @@ index 0024735d2441f1d2e3c2d5ed659fa4c4265f3922..444aa7b36347c4eea42e049504665dd3 + * by this bot itself are always dropped. + */ + shouldForwardBotMessage?: (info: { ++ applicationId?: string; + authorId: string; + guildId: string; + channelId: string; ++ webhookId?: string; + }) => boolean; + /** + * Invoked when the Gateway connection state changes. Called with true on @@ -83,7 +85,7 @@ index 0024735d2441f1d2e3c2d5ed659fa4c4265f3922..444aa7b36347c4eea42e049504665dd3 } /** * Discord thread ID components. -@@ -363,6 +426,14 @@ declare class DiscordAdapter implements Adapter { +@@ -363,6 +428,14 @@ declare class DiscordAdapter implements Adapter { protected readonly publicKey: string; protected readonly applicationId: string; protected readonly mentionRoleIds: string[]; @@ -98,7 +100,7 @@ index 0024735d2441f1d2e3c2d5ed659fa4c4265f3922..444aa7b36347c4eea42e049504665dd3 protected chat: ChatInstance | null; protected readonly logger: Logger; protected readonly formatConverter: DiscordFormatConverter; -@@ -563,6 +634,23 @@ declare class DiscordAdapter implements Adapter { +@@ -563,6 +636,23 @@ declare class DiscordAdapter implements Adapter { * Handle a message received via the Gateway WebSocket. */ protected handleGatewayMessage(message: Message$1, isMentioned: boolean): Promise; @@ -376,7 +378,7 @@ index e4f6fe07808ab3c9858fda724c36f7aef5084502..bc4fc7b0bab4114133404f7de2132488 this.logger.info("Discord Gateway listener stopped"); } } -@@ -1786,27 +1919,103 @@ var DiscordAdapter = class _DiscordAdapter { +@@ -1786,27 +1919,105 @@ var DiscordAdapter = class _DiscordAdapter { this.logger.debug("Ignoring message - Gateway is shutting down"); return; } @@ -439,9 +441,11 @@ index e4f6fe07808ab3c9858fda724c36f7aef5084502..bc4fc7b0bab4114133404f7de2132488 + } + if (message.author.bot) { + const forwardBotMessage = !authorIsSelf && this.shouldForwardBotMessage?.({ ++ applicationId: message.applicationId ?? void 0, + authorId: message.author.id, + guildId: message.guildId ?? "@me", -+ channelId: message.channelId ++ channelId: parentChannelId, ++ webhookId: message.webhookId ?? void 0 + }) === true; + if (!forwardBotMessage) { + this.logger.debug("Ignoring message from bot", { @@ -491,7 +495,7 @@ index e4f6fe07808ab3c9858fda724c36f7aef5084502..bc4fc7b0bab4114133404f7de2132488 }); await this.handleGatewayMessage(message, isMentioned); }); -@@ -1815,6 +2024,13 @@ var DiscordAdapter = class _DiscordAdapter { +@@ -1815,6 +2026,13 @@ var DiscordAdapter = class _DiscordAdapter { this.logger.debug("Ignoring interaction - Gateway is shutting down"); return; } @@ -505,7 +509,7 @@ index e4f6fe07808ab3c9858fda724c36f7aef5084502..bc4fc7b0bab4114133404f7de2132488 this.logger.info("Discord Gateway interaction received", { id: interaction.id, type: interaction.type -@@ -1983,9 +2199,18 @@ var DiscordAdapter = class _DiscordAdapter { +@@ -1983,9 +2201,18 @@ var DiscordAdapter = class _DiscordAdapter { })), raw: { id: message.id, @@ -524,7 +528,7 @@ index e4f6fe07808ab3c9858fda724c36f7aef5084502..bc4fc7b0bab4114133404f7de2132488 author: { id: message.author.id, username: message.author.username -@@ -1998,6 +2223,19 @@ var DiscordAdapter = class _DiscordAdapter { +@@ -1998,6 +2225,19 @@ var DiscordAdapter = class _DiscordAdapter { try { await this.chat.handleIncomingMessage(this, threadId, chatMessage); } catch (error) { @@ -544,7 +548,7 @@ index e4f6fe07808ab3c9858fda724c36f7aef5084502..bc4fc7b0bab4114133404f7de2132488 this.logger.error("Error handling Gateway message", { error: String(error), messageId: message.id -@@ -2262,7 +2500,9 @@ var DiscordAdapter = class _DiscordAdapter { +@@ -2262,7 +2502,9 @@ var DiscordAdapter = class _DiscordAdapter { `Invalid Discord channel ID: ${channelId}` ); } @@ -555,7 +559,7 @@ index e4f6fe07808ab3c9858fda724c36f7aef5084502..bc4fc7b0bab4114133404f7de2132488 const embeds = []; const components = []; const card = extractCard(message); -@@ -2313,6 +2553,43 @@ var DiscordAdapter = class _DiscordAdapter { +@@ -2313,6 +2555,43 @@ var DiscordAdapter = class _DiscordAdapter { raw: result }; } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f79729d2f2..9af587fdd6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -11,7 +11,7 @@ overrides: patchedDependencies: '@chat-adapter/discord@4.31.0': - hash: d0406a4a8e52d5a6a0cd5518f2208abf84de5dd6f421053ab1535da073349e68 + hash: 7bba4acc0c4315117c9df6292fbe98e6a8843de96cde695108de10b7ccaccdb5 path: patches/@chat-adapter__discord@4.31.0.patch '@chat-adapter/linear@4.31.0': hash: fce7a692b030cfe3d325b020a4472b9424b8976aa2f7faded6ad4c83421e9132 @@ -84,7 +84,7 @@ importers: version: link:../../packages/rendering '@chat-adapter/discord': specifier: ^4.31.0 - version: 4.31.0(patch_hash=d0406a4a8e52d5a6a0cd5518f2208abf84de5dd6f421053ab1535da073349e68)(zod@4.4.3) + version: 4.31.0(patch_hash=7bba4acc0c4315117c9df6292fbe98e6a8843de96cde695108de10b7ccaccdb5)(zod@4.4.3) '@chat-adapter/state-pg': specifier: ^4.31.0 version: 4.31.0(patch_hash=69262b03c278ca9d4af0bed7b4e05f9d7a5a36d3d79fad31df2a85da4d349274)(zod@4.4.3) @@ -1963,7 +1963,7 @@ snapshots: '@azure/msal-common': 16.10.0 jsonwebtoken: 9.0.3 - '@chat-adapter/discord@4.31.0(patch_hash=d0406a4a8e52d5a6a0cd5518f2208abf84de5dd6f421053ab1535da073349e68)(zod@4.4.3)': + '@chat-adapter/discord@4.31.0(patch_hash=7bba4acc0c4315117c9df6292fbe98e6a8843de96cde695108de10b7ccaccdb5)(zod@4.4.3)': dependencies: '@chat-adapter/shared': 4.31.0(zod@4.4.3) chat: 4.31.0(zod@4.4.3) diff --git a/services/discordbot/src/discord-allowlist.ts b/services/discordbot/src/discord-allowlist.ts index c7901e3d1d..774d977679 100644 --- a/services/discordbot/src/discord-allowlist.ts +++ b/services/discordbot/src/discord-allowlist.ts @@ -154,21 +154,38 @@ export function isAllowedTriggerBotMessage( message: Pick, allowlist: readonly string[] | undefined, ): boolean { - if (!allowlist?.length) return false; const raw = message.raw && typeof message.raw === "object" ? (message.raw as { application_id?: unknown; webhook_id?: unknown }) : {}; - const identifiers = new Set( - [ - message.author.userId, - typeof raw.application_id === "string" ? raw.application_id : undefined, - typeof raw.webhook_id === "string" ? raw.webhook_id : undefined, - ] + return isAllowedTriggerBotIdentifiers( + { + applicationId: + typeof raw.application_id === "string" ? raw.application_id : undefined, + authorId: message.author.userId, + webhookId: + typeof raw.webhook_id === "string" ? raw.webhook_id : undefined, + }, + allowlist, + ); +} + +/** Apply the same immutable multi-ID bot policy at pre- and post-admission gates. */ +export function isAllowedTriggerBotIdentifiers( + identifiers: { + applicationId?: string; + authorId: string; + webhookId?: string; + }, + allowlist: readonly string[] | undefined, +): boolean { + if (!allowlist?.length) return false; + const reviewedIdentifiers = new Set( + [identifiers.authorId, identifiers.applicationId, identifiers.webhookId] .map((value) => value?.trim()) .filter((value): value is string => Boolean(value)), ); - return allowlist.some((entry) => identifiers.has(entry.trim())); + return allowlist.some((entry) => reviewedIdentifiers.has(entry.trim())); } /** diff --git a/services/discordbot/src/index.ts b/services/discordbot/src/index.ts index 64cd10c230..edc9a3440a 100644 --- a/services/discordbot/src/index.ts +++ b/services/discordbot/src/index.ts @@ -23,7 +23,9 @@ import { isAllowedDiscordMessage, isDiscordIngressAllowlistEmpty, isAllowedDiscordGuild, + isAllowedTriggerBotIdentifiers, parseDiscordThreadKey, + resolveChannelAllowlist, resolveTriggerBotAllowlist, } from "./discord-allowlist"; import { @@ -243,9 +245,19 @@ export function createDiscordbot(options: DiscordbotOptions): Discordbot { // The adapter drops bot authors before admission by default. Forward only // an explicitly configured immutable bot identity in an allowed guild; // durable admission still requires a reviewed role capability bundle. - shouldForwardBotMessage: ({ authorId, guildId }) => + shouldForwardBotMessage: ({ + applicationId, + authorId, + channelId, + guildId, + webhookId, + }) => isAllowedDiscordGuild(guildId, options) && - resolveTriggerBotAllowlist(options).includes(authorId), + resolveChannelAllowlist(options).includes(channelId) && + isAllowedTriggerBotIdentifiers( + { applicationId, authorId, webhookId }, + resolveTriggerBotAllowlist(options), + ), // Discord delta (patched adapter): the Gateway never redelivers, so a // message dropped on a thread-lock conflict is otherwise lost with zero // signal — surface it with a 🔁 reaction so the user knows to resend. diff --git a/services/discordbot/test/chat-sdk-emulate.test.ts b/services/discordbot/test/chat-sdk-emulate.test.ts index c1e34bd22d..926821600d 100644 --- a/services/discordbot/test/chat-sdk-emulate.test.ts +++ b/services/discordbot/test/chat-sdk-emulate.test.ts @@ -49,6 +49,7 @@ const BOT_TOKEN = "discordbot-emulate-token"; const APP_ID = "900000000000000001"; const USER_ID = "100000000000000001"; const TRIGGER_BOT_ID = "400000000000000001"; +const TRIGGER_BOT_APPLICATION_ID = "400000000000000002"; const GUILD_ID = "200000000000000001"; const CHANNEL_ID = "300000000000000001"; const TRIGGER_ROLE_ID = "500000000000000001"; @@ -1377,6 +1378,26 @@ describe("discordbot", () => { expect(codexApi.executes).toHaveLength(1); codexApi.reset(); + // Application and webhook IDs accepted by durable admission must survive + // the adapter's later bot-forwarding hook as the same policy identity. + bot = createTestBot({ + triggerBotAllowlist: [TRIGGER_BOT_APPLICATION_ID], + }); + const applicationThreadId = discordApi.nextId(); + discordApi.seedThreadChannel(applicationThreadId, CHANNEL_ID); + const applicationBotMentionId = await dispatchMessage({ + applicationId: TRIGGER_BOT_APPLICATION_ID, + authorBot: true, + authorId: TRIGGER_BOT_ID, + channelId: applicationThreadId, + content: `<@${APP_ID}> from an allowlisted application`, + mention: true, + thread: { id: applicationThreadId, parentId: CHANNEL_ID }, + }); + await waitForSettle(applicationThreadId, applicationBotMentionId); + expect(codexApi.executes).toHaveLength(1); + codexApi.reset(); + // Follow-ups are re-authorized; removing the human role blocks new context // even inside a previously authorized thread. bot = createTestBot(); @@ -1447,6 +1468,7 @@ function threadKey(threadId: string): string { * that production admits before it creates a Discord thread. */ async function dispatchMessage(input: { + applicationId?: string; attachments?: Record[]; authorBot?: boolean; authorId?: string; @@ -1458,6 +1480,7 @@ async function dispatchMessage(input: { preauthorizeRoot?: boolean; roleIds?: string[]; thread?: { id: string; parentId: string }; + webhookId?: string; }): Promise { if (input.thread && input.mention && input.preauthorizeRoot !== false) { await preauthorizeTestThreadRoot({ ...input, thread: input.thread }); @@ -1471,12 +1494,15 @@ async function dispatchMessage(input: { username: "tester", }, content: input.content, + application_id: input.applicationId, + webhook_id: input.webhookId, }); if (!input.thread && input.mention) { await botState.connect(); await admitDiscordGatewayMessage( { authorId: input.authorId ?? USER_ID, + applicationId: input.applicationId, authorIsBot: input.authorBot === true, authorIsSelf: false, channelId: input.channelId, @@ -1488,6 +1514,7 @@ async function dispatchMessage(input: { messageId: String(raw.id), messageType: 0, roleIds: input.roleIds ?? [TRIGGER_ROLE_ID], + webhookId: input.webhookId, }, botOptions, botState, @@ -1524,12 +1551,14 @@ async function dispatchMessage(input: { } async function preauthorizeTestThreadRoot(input: { + applicationId?: string; authorBot?: boolean; authorId?: string; content: string; guildId?: string; roleIds?: string[]; thread: { id: string; parentId: string }; + webhookId?: string; }): Promise { await botState.connect(); const rootKey = `discordbot:ingress:root:${ @@ -1539,6 +1568,7 @@ async function preauthorizeTestThreadRoot(input: { await admitDiscordGatewayMessage( { authorId: input.authorId ?? USER_ID, + applicationId: input.applicationId, authorIsBot: input.authorBot === true, authorIsSelf: false, channelId: input.thread.parentId, @@ -1550,6 +1580,7 @@ async function preauthorizeTestThreadRoot(input: { messageId: input.thread.id, messageType: 0, roleIds: input.roleIds ?? [TRIGGER_ROLE_ID], + webhookId: input.webhookId, }, botOptions, botState, @@ -1833,6 +1864,7 @@ type RawDiscordAuthor = { }; type RawDiscordMessage = { + application_id?: string; attachments: Record[]; author: RawDiscordAuthor; channel_id: string; @@ -1841,6 +1873,7 @@ type RawDiscordMessage = { id: string; timestamp: string; type: number; + webhook_id?: string; }; type DiscordRestCall = { @@ -1868,9 +1901,11 @@ type FakeDiscordApi = { seedRawMessage( channelId: string, input: { + application_id?: string; attachments?: Record[]; author: RawDiscordAuthor; content: string; + webhook_id?: string; }, ): RawDiscordMessage; seedThreadChannel(threadId: string, parentId: string): void; @@ -1908,6 +1943,7 @@ async function startFakeDiscordApi(): Promise { ) => { const message: RawDiscordMessage = { attachments: input.attachments ?? [], + application_id: input.application_id, author: input.author, channel_id: channelId, content: input.content, @@ -1915,6 +1951,7 @@ async function startFakeDiscordApi(): Promise { id: nextId(), timestamp: new Date().toISOString(), type: 0, + webhook_id: input.webhook_id, }; channelMessages(channelId).push(message); return message; diff --git a/services/discordbot/test/discord-allowlist.test.ts b/services/discordbot/test/discord-allowlist.test.ts index c9b364e940..935639070f 100644 --- a/services/discordbot/test/discord-allowlist.test.ts +++ b/services/discordbot/test/discord-allowlist.test.ts @@ -4,6 +4,7 @@ import { discordIngressDenialReason, discordRoleIdsFromRaw, isAllowedDiscordMessage, + isAllowedTriggerBotIdentifiers, isAllowedTriggerBotMessage, isDiscordIngressAllowlistEmpty, isGuildAllowlistEmpty, @@ -254,6 +255,20 @@ describe("isAllowedTriggerBotMessage", () => { }); }); +describe("isAllowedTriggerBotIdentifiers", () => { + it("uses the same author, application, and webhook identities at adapter forwarding", () => { + const identifiers = { + applicationId: "app-9", + authorId: "bot-1", + webhookId: "hook-7", + }; + for (const allowed of ["bot-1", "app-9", "hook-7"]) { + expect(isAllowedTriggerBotIdentifiers(identifiers, [allowed])).toBe(true); + } + expect(isAllowedTriggerBotIdentifiers(identifiers, ["other"])).toBe(false); + }); +}); + describe("isGuildAllowlistEmpty", () => { it("is true when no guilds are configured", () => { expect(isGuildAllowlistEmpty(options({ guildAllowlist: [] }))).toBe(true); From 9f483a0e05b0054b80d8fa00431315847c9b889c Mon Sep 17 00:00:00 2001 From: Michael Wu Date: Tue, 1 Sep 2026 18:17:09 +0900 Subject: [PATCH 09/37] fix: persist reviewed Discord action boundaries --- .../centaur-iron-control/src/session.rs | 14 ++ .../0055_workflow_action_approval_claims.sql | 19 +++ .../centaur-workflows/src/action_proposals.rs | 151 ++++++++++++++---- services/discordbot/src/index.ts | 14 +- services/discordbot/src/session-api.ts | 20 ++- .../discordbot/test/chat-sdk-emulate.test.ts | 14 ++ 6 files changed, 196 insertions(+), 36 deletions(-) create mode 100644 services/api-rs/crates/centaur-session-sqlx/migrations/0055_workflow_action_approval_claims.sql diff --git a/services/api-rs/crates/centaur-iron-control/src/session.rs b/services/api-rs/crates/centaur-iron-control/src/session.rs index 6a43892a44..45ef9a0c99 100644 --- a/services/api-rs/crates/centaur-iron-control/src/session.rs +++ b/services/api-rs/crates/centaur-iron-control/src/session.rs @@ -30,6 +30,7 @@ struct SessionPrincipalMetadata<'a> { } const DISCORD_REPO_CACHE_LABEL: &str = "centaur.discord.sandbox_repo_cache"; +const PRINCIPAL_REPO_CACHE_LABEL: &str = "centaur.sandbox_repo_cache"; const DISCORD_OBSERVABILITY_LABEL: &str = "centaur.discord.sandbox_observability_enabled"; const DISCORD_SESSIONS_READ_LABEL: &str = "centaur.discord.sandbox_sessions_read_enabled"; const DISCORD_WORKFLOWS_READ_LABEL: &str = "centaur.discord.sandbox_workflows_read_enabled"; @@ -346,6 +347,10 @@ impl SessionRegistrar { .replace_principal_policy(&principal.id, &policy) .await?; let mut reconciled = principal.clone(); + reconciled.labels.insert( + PRINCIPAL_REPO_CACHE_LABEL.to_owned(), + policy.sandbox_repo_cache.clone(), + ); reconciled.sandbox_observability_enabled = policy.sandbox_observability_enabled; Ok(reconciled) } @@ -792,6 +797,15 @@ mod tests { .await .unwrap(); assert_eq!(principal.id, "prn_discord"); + assert_eq!( + principal + .labels + .get(PRINCIPAL_REPO_CACHE_LABEL) + .map(String::as_str), + Some("all"), + "the first execution must use the just-reconciled repo-cache policy" + ); + assert!(principal.sandbox_observability_enabled); let requests = requests.lock().unwrap(); let principal_updates = requests diff --git a/services/api-rs/crates/centaur-session-sqlx/migrations/0055_workflow_action_approval_claims.sql b/services/api-rs/crates/centaur-session-sqlx/migrations/0055_workflow_action_approval_claims.sql new file mode 100644 index 0000000000..2853597466 --- /dev/null +++ b/services/api-rs/crates/centaur-session-sqlx/migrations/0055_workflow_action_approval_claims.sql @@ -0,0 +1,19 @@ +create table workflow_action_proposal_approval_claims ( + fingerprint text primary key + references workflow_action_proposals (fingerprint) on delete cascade, + actor_id text not null, + capability_class text not null, + channel_id text not null, + guild_id text not null, + message_id text not null, + policy_fingerprint text not null, + principal_role text not null, + repository_scope jsonb not null, + root_message_id text not null, + thread_id text not null, + claimed_at timestamptz not null default now(), + constraint workflow_action_approval_claim_repository_scope_check + check (jsonb_typeof(repository_scope) = 'array') +); + +revoke all on workflow_action_proposal_approval_claims from public; diff --git a/services/api-rs/crates/centaur-workflows/src/action_proposals.rs b/services/api-rs/crates/centaur-workflows/src/action_proposals.rs index d8b1d16054..be4ea78b58 100644 --- a/services/api-rs/crates/centaur-workflows/src/action_proposals.rs +++ b/services/api-rs/crates/centaur-workflows/src/action_proposals.rs @@ -271,11 +271,18 @@ pub async fn put_action_proposal( let mut created = inserted; let consumed_at: Option = row.try_get("consumed_at")?; let stored_expires_at: OffsetDateTime = row.try_get("expires_at")?; - if !inserted && consumed_at.is_none() && stored_expires_at <= OffsetDateTime::now_utc() { + let approval_claimed: bool = row.try_get("approval_claimed")?; + if !inserted + && consumed_at.is_none() + && !approval_claimed + && stored_expires_at <= OffsetDateTime::now_utc() + { let reactivated = sqlx::query( "UPDATE workflow_action_proposals SET observer_workflow = $2, observer_task_id = $3, \ observer_run_id = $4, expires_at = $5, updated_at = NOW() \ - WHERE fingerprint = $1 AND consumed_at IS NULL AND expires_at <= NOW()", + WHERE fingerprint = $1 AND consumed_at IS NULL AND expires_at <= NOW() \ + AND NOT EXISTS (SELECT 1 FROM workflow_action_proposal_approval_claims claim \ + WHERE claim.fingerprint = workflow_action_proposals.fingerprint)", ) .bind(&fingerprint) .bind(observer_workflow) @@ -362,28 +369,64 @@ impl WorkflowRuntime { false, )); } - let expires_at: OffsetDateTime = row.try_get("expires_at")?; - if expires_at <= OffsetDateTime::now_utc() { - return Err(WorkflowRuntimeError::BadRequest( - "action proposal expired; run a fresh observation".to_owned(), - )); - } + let approval_claim = sqlx::query( + "SELECT actor_id, capability_class, channel_id, guild_id, message_id, \ + policy_fingerprint, principal_role, repository_scope, root_message_id, thread_id \ + FROM workflow_action_proposal_approval_claims WHERE fingerprint = $1", + ) + .bind(&fingerprint) + .fetch_optional(&mut *tx) + .await?; + let approval = if let Some(claim) = approval_claim { + approval_request_from_row(&claim)? + } else { + let expires_at: OffsetDateTime = row.try_get("expires_at")?; + if expires_at <= OffsetDateTime::now_utc() { + return Err(WorkflowRuntimeError::BadRequest( + "action proposal expired; run a fresh observation".to_owned(), + )); + } + sqlx::query( + "INSERT INTO workflow_action_proposal_approval_claims (\ + fingerprint, actor_id, capability_class, channel_id, guild_id, message_id, \ + policy_fingerprint, principal_role, repository_scope, root_message_id, thread_id) \ + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9::jsonb, $10, $11)", + ) + .bind(&fingerprint) + .bind(&request.actor_id) + .bind(&request.capability_class) + .bind(&request.channel_id) + .bind(&request.guild_id) + .bind(&request.message_id) + .bind(&request.policy_fingerprint) + .bind(&request.principal_role) + .bind(serde_json::to_value(&request.repository_scope)?) + .bind(&request.root_message_id) + .bind(&request.thread_id) + .execute(&mut *tx) + .await?; + request.clone() + }; + // Commit the immutable approval context before spawning. If the + // process fails after this point, a repeated authorized approval + // resumes from this claim and the stable spawn idempotency key. + tx.commit().await?; let run = self .create_run(CreateWorkflowRunRequest { workflow_name: action_workflow.clone(), input: json!({ "approval": { - "actor_id": request.actor_id, - "capability_class": request.capability_class, - "channel_id": request.channel_id, - "guild_id": request.guild_id, - "message_id": request.message_id, - "policy_fingerprint": request.policy_fingerprint, - "principal_role": request.principal_role, + "actor_id": &approval.actor_id, + "capability_class": &approval.capability_class, + "channel_id": &approval.channel_id, + "guild_id": &approval.guild_id, + "message_id": &approval.message_id, + "policy_fingerprint": &approval.policy_fingerprint, + "principal_role": &approval.principal_role, "proposal_fingerprint": &fingerprint, - "repository_scope": request.repository_scope, - "root_message_id": request.root_message_id, - "thread_id": request.thread_id, + "repository_scope": &approval.repository_scope, + "root_message_id": &approval.root_message_id, + "thread_id": &approval.thread_id, }, "proposal": proposal_value, }), @@ -392,7 +435,8 @@ impl WorkflowRuntime { max_attempts: Some(3), }) .await?; - sqlx::query( + let mut tx = self.inner.client.pool().begin().await?; + let updated = sqlx::query( "UPDATE workflow_action_proposals SET consumed_at = NOW(), approved_by_actor_id = $2, \ approved_message_id = $3, approved_guild_id = $4, approved_channel_id = $5, \ approved_thread_id = $6, approved_root_message_id = $7, \ @@ -402,20 +446,43 @@ impl WorkflowRuntime { WHERE fingerprint = $1 AND consumed_at IS NULL", ) .bind(&fingerprint) - .bind(&request.actor_id) - .bind(&request.message_id) - .bind(&request.guild_id) - .bind(&request.channel_id) - .bind(&request.thread_id) - .bind(&request.root_message_id) - .bind(&request.policy_fingerprint) - .bind(&request.capability_class) - .bind(&request.principal_role) - .bind(serde_json::to_value(&request.repository_scope)?) + .bind(&approval.actor_id) + .bind(&approval.message_id) + .bind(&approval.guild_id) + .bind(&approval.channel_id) + .bind(&approval.thread_id) + .bind(&approval.root_message_id) + .bind(&approval.policy_fingerprint) + .bind(&approval.capability_class) + .bind(&approval.principal_role) + .bind(serde_json::to_value(&approval.repository_scope)?) .bind(&run.task_id) .bind(&run.run_id) .execute(&mut *tx) - .await?; + .await? + .rows_affected(); + if updated == 0 { + let existing = sqlx::query( + "SELECT action_task_id, action_run_id FROM workflow_action_proposals \ + WHERE fingerprint = $1 FOR UPDATE", + ) + .bind(&fingerprint) + .fetch_one(&mut *tx) + .await?; + if existing + .try_get::, _>("action_task_id")? + .as_deref() + != Some(run.task_id.as_str()) + || existing + .try_get::, _>("action_run_id")? + .as_deref() + != Some(run.run_id.as_str()) + { + return Err(WorkflowRuntimeError::Internal( + "action proposal was consumed by a different workflow run".to_owned(), + )); + } + } tx.commit().await?; Ok(approval_response( &fingerprint, @@ -558,7 +625,9 @@ async fn proposal_row( fingerprint: &str, ) -> Result { sqlx::query( - "SELECT proposal, action_workflow, expires_at, consumed_at, action_task_id, action_run_id \ + "SELECT proposal, action_workflow, expires_at, consumed_at, action_task_id, action_run_id, \ + EXISTS(SELECT 1 FROM workflow_action_proposal_approval_claims claim \ + WHERE claim.fingerprint = workflow_action_proposals.fingerprint) AS approval_claimed \ FROM workflow_action_proposals WHERE fingerprint = $1", ) .bind(fingerprint) @@ -573,8 +642,11 @@ fn action_proposal_state( ) -> Result { let consumed_at: Option = row.try_get("consumed_at")?; let expires_at: OffsetDateTime = row.try_get("expires_at")?; + let approval_claimed: bool = row.try_get("approval_claimed")?; let status = if consumed_at.is_some() { "consumed" + } else if approval_claimed { + "approving" } else if expires_at <= OffsetDateTime::now_utc() { "expired" } else { @@ -594,6 +666,23 @@ fn action_proposal_state( }) } +fn approval_request_from_row( + row: &sqlx::postgres::PgRow, +) -> Result { + Ok(ApproveActionProposalRequest { + actor_id: row.try_get("actor_id")?, + capability_class: row.try_get("capability_class")?, + channel_id: row.try_get("channel_id")?, + guild_id: row.try_get("guild_id")?, + message_id: row.try_get("message_id")?, + policy_fingerprint: row.try_get("policy_fingerprint")?, + principal_role: row.try_get("principal_role")?, + repository_scope: serde_json::from_value(row.try_get("repository_scope")?)?, + root_message_id: row.try_get("root_message_id")?, + thread_id: row.try_get("thread_id")?, + }) +} + fn validate_approval_request( fingerprint: &str, request: &ApproveActionProposalRequest, diff --git a/services/discordbot/src/index.ts b/services/discordbot/src/index.ts index edc9a3440a..6872cbfc3c 100644 --- a/services/discordbot/src/index.ts +++ b/services/discordbot/src/index.ts @@ -46,6 +46,7 @@ import { setGatewayConnected } from "./gateway"; import { approveActionProposal, collectInitialContext, + isAuthorizedContextMessage, executeSessionTurn, forwardToSessionApi, isContentlessApiMessage, @@ -654,7 +655,11 @@ async function syncThreadMessageToSession( if (shouldIncludeContext && !state.historyForwarded) { const contextStartedAtMs = nowMs(); try { - context = await collectInitialContext(thread, message); + context = await collectInitialContext( + thread, + message, + input.admission.actorId, + ); } catch (error) { if (!isDiscordPermissionError(error)) throw error; // Discord delta (no slackbotv2 analog): a 403 here (missing Read Message @@ -688,13 +693,16 @@ async function syncThreadMessageToSession( thread.id, input.options.logger ?? noopLogger, ); - if (starter) { + const starterIncluded = + starter !== null && + isAuthorizedContextMessage(starter, input.admission.actorId); + if (starterIncluded) { context = [starter, ...context.filter((item) => item.id !== starter.id)]; } traceLog(input.options, "discordbot_forward_context_collected", trace, { message_count: context.length, phase_ms: elapsedMs(contextStartedAtMs), - starter_included: starter !== null, + starter_included: starterIncluded, }); } else { traceLog(input.options, "discordbot_forward_context_skipped", trace, { diff --git a/services/discordbot/src/session-api.ts b/services/discordbot/src/session-api.ts index 518f590611..a981ac3f93 100644 --- a/services/discordbot/src/session-api.ts +++ b/services/discordbot/src/session-api.ts @@ -74,6 +74,7 @@ type ForwardSessionApiCallbacks = { export async function collectInitialContext( thread: { allMessages: AsyncIterable }, currentMessage: Message, + actorId: string, ): Promise { const messages: Message[] = []; try { @@ -82,7 +83,11 @@ export async function collectInitialContext( } } catch (error) { if (!isDiscordThreadNotFoundError(error)) throw error; - return [await serializeMessage(currentMessage)]; + const current = await serializeMessage(currentMessage); + if (current.author.userId !== actorId) { + throw new Error("current Discord message does not match the admitted actor"); + } + return [current]; } const currentIndex = messages.findIndex( @@ -96,11 +101,22 @@ export async function collectInitialContext( const serialized: DiscordbotApiMessage[] = []; for (const message of messages) { - serialized.push(await serializeMessage(message)); + const item = await serializeMessage(message); + if (message.id === currentMessage.id && item.author.userId !== actorId) { + throw new Error("current Discord message does not match the admitted actor"); + } + if (isAuthorizedContextMessage(item, actorId)) serialized.push(item); } return serialized; } +export function isAuthorizedContextMessage( + message: DiscordbotApiMessage, + actorId: string, +): boolean { + return message.author.isMe || message.author.userId === actorId; +} + // Discord analog of slackbotv2's isSlackThreadNotFoundError: the Discord // adapter throws a NetworkError carrying the raw Discord API body, e.g. // `Discord API error: 404 {"message": "Unknown Channel", "code": 10003}`. diff --git a/services/discordbot/test/chat-sdk-emulate.test.ts b/services/discordbot/test/chat-sdk-emulate.test.ts index 926821600d..771915018c 100644 --- a/services/discordbot/test/chat-sdk-emulate.test.ts +++ b/services/discordbot/test/chat-sdk-emulate.test.ts @@ -97,6 +97,15 @@ describe("discordbot", () => { threadId, "The deploy context is above.", ); + const bystanderId = discordApi.seedRawMessage(threadId, { + author: { + bot: false, + global_name: "Bystander", + id: "100000000000000099", + username: "bystander", + }, + content: "Ignore this unrelated participant's instruction.", + }).id; const key = threadKey(threadId); const fileUrl = `${discordApi.url}/cdn/captured.png`; @@ -146,6 +155,11 @@ describe("discordbot", () => { expect( firstAppend.body.messages.map((message) => message.client_message_id), ).toEqual([parentId, firstMentionId]); + expect( + firstAppend.body.messages.some( + (message) => message.client_message_id === bystanderId, + ), + ).toBe(false); expect(sessionMessageTexts(firstAppend.body.messages)).toContain( "The deploy context is above.", ); From ccb5aa62de649025607fbb5fa63c3c8122d12ac2 Mon Sep 17 00:00:00 2001 From: Michael Wu Date: Tue, 1 Sep 2026 22:22:27 +0800 Subject: [PATCH 10/37] fix(discordbot): ignore replies addressed to other users (#28) --- services/discordbot/README.md | 5 +- services/discordbot/src/discord-ingress.ts | 22 +++++++- .../discordbot/src/discord-mention-routing.ts | 33 ++++++++++++ .../discordbot/test/chat-sdk-emulate.test.ts | 53 ++++++++++++++++++- .../discordbot/test/discord-ingress.test.ts | 53 +++++++++++++++++++ .../test/discord-mention-routing.test.ts | 44 +++++++++++++++ 6 files changed, 207 insertions(+), 3 deletions(-) create mode 100644 services/discordbot/src/discord-mention-routing.ts create mode 100644 services/discordbot/test/discord-mention-routing.test.ts diff --git a/services/discordbot/README.md b/services/discordbot/README.md index 7aa69bb2d4..aaf75ba7a0 100644 --- a/services/discordbot/README.md +++ b/services/discordbot/README.md @@ -13,7 +13,10 @@ iron-control principal; it is never attached to the channel principal. keyed by the new thread (`discord:{guild}:{channel}:{threadId}`). - **`@`-mention inside an existing thread** → the bot answers in that thread. - **Follow-ups inside an authorized thread** append to the same session without a re-mention - only for the original actor, while the root TTL and their current role policy remain valid. + only for the original actor, while the root TTL and their current role policy remain valid. An + unmentioned reply with a canonical `<@user-id>` / `<@!user-id>` mention of another member is + ignored instead of steering Centaur; a direct Centaur mention keeps its normal behavior even if + another member is also named. - **`@centaur stop`** interrupts only the active execution attached to that authorized thread. - **`@centaur approve sha256:…`** atomically consumes one exact, unexpired workflow proposal when the actor's reviewed role is permitted to approve it. It does not create a coding session. diff --git a/services/discordbot/src/discord-ingress.ts b/services/discordbot/src/discord-ingress.ts index 75a05339a7..4f2f2b388e 100644 --- a/services/discordbot/src/discord-ingress.ts +++ b/services/discordbot/src/discord-ingress.ts @@ -9,6 +9,7 @@ import { resolveDiscordPermissionBundle, type DiscordPermissionBundle, } from "./discord-policy"; +import { discordMentionRoutingDecision } from "./discord-mention-routing"; import type { DiscordbotOptions } from "./types"; const DEFAULT_DELIVERY_TTL_MS = 7 * 24 * 60 * 60 * 1000; @@ -43,6 +44,7 @@ export type DiscordIngressReason = | "bot_message" | "channel_not_allowlisted" | "direct_message" + | "directed_to_other_discord_member" | "duplicate_delivery" | "future_delivery" | "gateway_identity_unverified" @@ -201,6 +203,7 @@ export function discordGatewayEventFromMessage( : {}; const parsed = parseDiscordThreadKey(message.threadId); if (!parsed.guildId || !parsed.channelId) return null; + if (typeof raw.content !== "string") return null; const member = raw.member && typeof raw.member === "object" ? (raw.member as Record) : {}; @@ -214,7 +217,10 @@ export function discordGatewayEventFromMessage( authorIsBot: message.author.isBot === true, authorIsSelf: message.author.isMe === true, channelId: parsed.channelId, - content: message.text, + // Keep the canonical Discord syntax. Chat adapters may strip member + // mentions from their rendered `message.text`, but addressee routing must + // compare immutable user IDs rather than mutable display text. + content: raw.content, createdTimestamp: message.metadata.dateSent.getTime(), gatewayIdentityVerified: true, guildId: parsed.guildId, @@ -312,6 +318,20 @@ async function evaluateAdmission( if (root.policy.fingerprint !== policy.fingerprint) { return deny("policy_changed_requires_root_trigger"); } + // This subscribed-thread path is the legacy all-replies continuation + // mode. Preserve the actor/permission/root checks above, then veto an + // explicit addressee boundary before the message can append to (and steer) + // a Centaur session. Direct Centaur mentions take the mentioned path and + // retain their normal behavior, even when another member is also named. + if ( + discordMentionRoutingDecision( + event.content, + event.isMentioned, + options.applicationId, + ) === "other_member" + ) { + return deny("directed_to_other_discord_member"); + } return accepted(event, root, policy, now); } diff --git a/services/discordbot/src/discord-mention-routing.ts b/services/discordbot/src/discord-mention-routing.ts new file mode 100644 index 0000000000..c4a94fc7d1 --- /dev/null +++ b/services/discordbot/src/discord-mention-routing.ts @@ -0,0 +1,33 @@ +const DISCORD_MEMBER_MENTION_PATTERN = /<@!?(\d{16,22})>/g; + +export type DiscordMentionRoutingDecision = + | "centaur" + | "other_member" + | "unaddressed"; + +/** + * Resolve the addressee boundary from Discord's canonical Gateway content. + * + * `isCentaurMention` is derived by the adapter from Discord's structured + * mention collections. Raw `content` is also inspected because downstream + * adapters may remove mention tokens from their rendered message text. A + * direct Centaur trigger wins when another member is named in the same + * message; otherwise an explicit member mention belongs to that member, not + * to an unmentioned Centaur continuation. + */ +export function discordMentionRoutingDecision( + content: string, + isCentaurMention: boolean, + centaurUserId: string, +): DiscordMentionRoutingDecision { + const mentionedUserIds = new Set( + [...content.matchAll(DISCORD_MEMBER_MENTION_PATTERN)] + .map((match) => match[1]) + .filter((userId): userId is string => userId !== undefined), + ); + + if (isCentaurMention || mentionedUserIds.has(centaurUserId)) { + return "centaur"; + } + return mentionedUserIds.size > 0 ? "other_member" : "unaddressed"; +} diff --git a/services/discordbot/test/chat-sdk-emulate.test.ts b/services/discordbot/test/chat-sdk-emulate.test.ts index 771915018c..a3406f9f2d 100644 --- a/services/discordbot/test/chat-sdk-emulate.test.ts +++ b/services/discordbot/test/chat-sdk-emulate.test.ts @@ -48,6 +48,7 @@ import { admitDiscordGatewayMessage } from "../src/discord-ingress"; const BOT_TOKEN = "discordbot-emulate-token"; const APP_ID = "900000000000000001"; const USER_ID = "100000000000000001"; +const OTHER_USER_ID = "100000000000000002"; const TRIGGER_BOT_ID = "400000000000000001"; const TRIGGER_BOT_APPLICATION_ID = "400000000000000002"; const GUILD_ID = "200000000000000001"; @@ -255,6 +256,53 @@ describe("discordbot", () => { ); }); + it("does not route an unmentioned reply addressed to another member but honors a direct bot mention", async () => { + const threadId = discordApi.nextId(); + discordApi.seedThreadChannel(threadId, CHANNEL_ID); + + const rootMentionId = await dispatchMessage({ + channelId: threadId, + content: `<@${APP_ID}> establish the routing test thread`, + mention: true, + thread: { id: threadId, parentId: CHANNEL_ID }, + }); + await waitForSettle(threadId, rootMentionId); + const appendCount = codexApi.appends.length; + const executeCount = codexApi.executes.length; + + const addressedElsewhere = + `<@${OTHER_USER_ID}> great. Can you write a small summary of where the service is at?`; + await dispatchMessage({ + channelId: threadId, + content: addressedElsewhere, + mentionedUserIds: [OTHER_USER_ID], + thread: { id: threadId, parentId: CHANNEL_ID }, + }); + await sleep(50); + expect(codexApi.appends).toHaveLength(appendCount); + expect(codexApi.executes).toHaveLength(executeCount); + + const directMentionId = await dispatchMessage({ + channelId: threadId, + content: + `<@${APP_ID}> ask <@${OTHER_USER_ID}> for context, then write the summary`, + mention: true, + mentionedUserIds: [APP_ID, OTHER_USER_ID], + thread: { id: threadId, parentId: CHANNEL_ID }, + }); + await waitForSettle(threadId, directMentionId); + expect(codexApi.appends).toHaveLength(appendCount + 1); + expect(codexApi.executes).toHaveLength(executeCount + 1); + expect(codexApi.executes.at(-1)?.body.idempotency_key).toBe( + directMentionId, + ); + expect( + codexApi.appends.flatMap((append) => + sessionMessageTexts(append.body.messages), + ), + ).not.toContain(addressedElsewhere); + }); + it("creates, names, and answers in a bot-created thread for a channel mention", async () => { const mentionId = await dispatchMessage({ channelId: CHANNEL_ID, @@ -1490,6 +1538,7 @@ async function dispatchMessage(input: { content: string; guildId?: string; mention?: boolean; + mentionedUserIds?: string[]; /** Existing-thread tests seed the durable root production creates upstream. */ preauthorizeRoot?: boolean; roleIds?: string[]; @@ -1540,7 +1589,9 @@ async function dispatchMessage(input: { guild_id: input.guildId ?? GUILD_ID, member: { roles: input.roleIds ?? [TRIGGER_ROLE_ID] }, mention_roles: [], - mentions: input.mention ? [{ id: APP_ID }] : [], + mentions: ( + input.mentionedUserIds ?? (input.mention ? [APP_ID] : []) + ).map((id) => ({ id })), ...(input.thread ? { thread: { id: input.thread.id, parent_id: input.thread.parentId } } : {}), diff --git a/services/discordbot/test/discord-ingress.test.ts b/services/discordbot/test/discord-ingress.test.ts index e76d72f710..5699571b75 100644 --- a/services/discordbot/test/discord-ingress.test.ts +++ b/services/discordbot/test/discord-ingress.test.ts @@ -351,6 +351,59 @@ describe("Discord Gateway admission", () => { } }); + it("vetoes another member as an unmentioned addressee but keeps direct Centaur mentions", async () => { + const { audits, logger, state } = await harness(); + const configured = options(); + await admitDiscordGatewayMessage( + event(THREAD), + configured, + state, + logger, + NOW, + ); + + const addressedElsewhere = event("600000000000000065", { + content: + `<@${OTHER_USER}> great. Can you write a small summary of where the service is at?`, + isMentioned: false, + threadId: THREAD, + }); + expect( + await admitDiscordGatewayMessage( + addressedElsewhere, + configured, + state, + logger, + NOW + 100, + ), + ).toBeNull(); + expect(audits.at(-1)?.data.reason).toBe( + "directed_to_other_discord_member", + ); + + const directCentaurMention = event("600000000000000066", { + content: + `<@${APP}> ask <@${OTHER_USER}> for context, then write the summary`, + isMentioned: true, + threadId: THREAD, + }); + expect( + await admitDiscordGatewayMessage( + directCentaurMention, + configured, + state, + logger, + NOW + 100, + ), + ).toEqual( + expect.objectContaining({ + actorId: USER, + decision: "allow", + messageId: directCentaurMention.messageId, + }), + ); + }); + it("accepts only an actor-scoped, idempotent stop control", async () => { const { audits, logger, state } = await harness(); const configured = options(); diff --git a/services/discordbot/test/discord-mention-routing.test.ts b/services/discordbot/test/discord-mention-routing.test.ts new file mode 100644 index 0000000000..a9a74b725d --- /dev/null +++ b/services/discordbot/test/discord-mention-routing.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from "bun:test"; +import { discordMentionRoutingDecision } from "../src/discord-mention-routing"; + +const CENTAUR_ID = "900000000000000001"; +const OTHER_USER_ID = "100000000000000002"; + +describe("Discord mention routing", () => { + it("treats canonical other-member mentions as an addressee boundary", () => { + const actionable = + `<@${OTHER_USER_ID}> great. Can you write a small summary of where the service is at?`; + + expect( + discordMentionRoutingDecision(actionable, false, CENTAUR_ID), + ).toBe("other_member"); + expect( + discordMentionRoutingDecision( + `Can you send the summary to <@!${OTHER_USER_ID}>?`, + false, + CENTAUR_ID, + ), + ).toBe("other_member"); + expect( + discordMentionRoutingDecision( + "@someone great. Can you write a small summary?", + false, + CENTAUR_ID, + ), + ).toBe("unaddressed"); + }); + + it("keeps a direct Centaur mention authoritative when another member is named", () => { + const content = + `<@${CENTAUR_ID}> ask <@${OTHER_USER_ID}> for context, then write the summary`; + + expect(discordMentionRoutingDecision(content, true, CENTAUR_ID)).toBe( + "centaur", + ); + // Canonical raw content also preserves the direct trigger if an adapter's + // rendered mention flag is unavailable downstream. + expect(discordMentionRoutingDecision(content, false, CENTAUR_ID)).toBe( + "centaur", + ); + }); +}); From a67a81605f41e71d77f0f6e6c591d79197c23ed5 Mon Sep 17 00:00:00 2001 From: Michael Wu Date: Tue, 1 Sep 2026 23:47:43 +0900 Subject: [PATCH 11/37] fix: bind Discord bot identities to policies --- contrib/chart/templates/discordbot.yaml | 6 +- contrib/chart/values.yaml | 9 +- services/discordbot/src/discord-allowlist.ts | 29 ++-- services/discordbot/src/discord-ingress.ts | 32 +++-- services/discordbot/src/discord-policy.ts | 124 +++++++++++++++++- services/discordbot/src/server.ts | 20 ++- services/discordbot/src/types.ts | 17 ++- .../discordbot/test/chat-sdk-emulate.test.ts | 45 ++++++- .../discordbot/test/discord-allowlist.test.ts | 20 ++- .../discordbot/test/discord-ingress.test.ts | 31 +++-- .../discordbot/test/discord-policy.test.ts | 81 +++++++++++- 11 files changed, 350 insertions(+), 64 deletions(-) diff --git a/contrib/chart/templates/discordbot.yaml b/contrib/chart/templates/discordbot.yaml index d39ad24702..186672bdb8 100644 --- a/contrib/chart/templates/discordbot.yaml +++ b/contrib/chart/templates/discordbot.yaml @@ -78,9 +78,9 @@ spec: value: {{ $channelAllowlist | quote }} - name: DISCORDBOT_ROLE_BINDINGS_JSON value: {{ $roleBindings | toJson | quote }} -{{- if .Values.discordbot.triggerBotAllowlist }} - - name: DISCORDBOT_TRIGGER_BOT_ALLOWLIST - value: {{ .Values.discordbot.triggerBotAllowlist | quote }} +{{- if gt (len .Values.discordbot.triggerBotBindings) 0 }} + - name: DISCORDBOT_TRIGGER_BOT_BINDINGS_JSON + value: {{ .Values.discordbot.triggerBotBindings | toJson | quote }} {{- end }} - name: DISCORDBOT_CONTINUATION_TTL_MS value: {{ .Values.discordbot.continuationTtlMs | quote }} diff --git a/contrib/chart/values.yaml b/contrib/chart/values.yaml index d0bb8f7937..981b24c1d5 100644 --- a/contrib/chart/values.yaml +++ b/contrib/chart/values.yaml @@ -772,9 +772,12 @@ discordbot: continuationTtlMs: 86400000 ingressMaxEventAgeMs: 300000 ingressDeliveryTtlMs: 604800000 - # Bot user/application/webhook IDs explicitly admitted at the transport - # boundary. A matching bot still needs a reviewed role binding; empty denies. - triggerBotAllowlist: "" + # Exact non-human identities bound to an existing, non-approver role policy. + # A bare bot/webhook allowlist is intentionally insufficient: the identity + # must select one reviewed bundle and cannot approve a proposal. + triggerBotBindings: [] + # - identity_id: "100000000000000001" # bot user, application, or webhook ID + # role_id: "100000000000000002" # one entry in roleBindings # Comma/space-separated role IDs whose mentions also trigger the bot. mentionRoleIds: "" # Rename auto-created threads to the triggering message; set false to keep generic names. diff --git a/services/discordbot/src/discord-allowlist.ts b/services/discordbot/src/discord-allowlist.ts index 774d977679..22f059117c 100644 --- a/services/discordbot/src/discord-allowlist.ts +++ b/services/discordbot/src/discord-allowlist.ts @@ -1,6 +1,9 @@ import type { Logger, Message } from "chat"; import type { DiscordbotOptions } from "./types"; -import { configuredDiscordRoleIds } from "./discord-policy"; +import { + configuredDiscordRoleIds, + configuredDiscordTriggerBotIds, +} from "./discord-policy"; export type DiscordIngressContext = { authorIsBot: boolean; @@ -47,12 +50,9 @@ export function isAllowedDiscordMessage( if (message.author.isMe === true) { return false; } - // Discord delta (mirrors slackbotv2's trigger-bot allowlist semantics): - // bot-authored messages are rejected unless the bot is explicitly - // allowlisted. The gateway only forwards bot messages that pass the - // adapter's `shouldForwardBotMessage` hook (wired at the adapter - // construction site); this gate re-checks with the full payload, where - // application_id/webhook_id matching is possible. + // Bot-authored messages are denied unless a reviewed identity binding maps + // the exact author/application/webhook ID to a static policy bundle. The + // adapter and durable ingress both evaluate the same identifiers. if (message.author.isBot === true) { if ( !isAllowedTriggerBotMessage(message, resolveTriggerBotAllowlist(options)) @@ -92,8 +92,8 @@ export function isAllowedDiscordMessage( /** * Return the deterministic denial reason for a Discord ingress context. * Bot-authored messages still require guild + channel admission, but their - * identity is controlled by the separate trigger-bot allowlist rather than a - * human member role. + * identity is controlled by a separate reviewed bot binding rather than human + * member roles. */ export function discordIngressDenialReason( context: DiscordIngressContext, @@ -221,14 +221,15 @@ export function resolveGuildAllowlist(options: DiscordbotOptions): string[] { ]; } -/** Resolved trigger-bot allowlist (options first, env fallback). */ +/** + * Resolved bot transport identities. A legacy bare allowlist deliberately does + * not reach this gate: every non-human sender must also select a reviewed, + * non-approver policy bundle through `triggerBotBindings`. + */ export function resolveTriggerBotAllowlist( options: DiscordbotOptions, ): string[] { - return [ - ...(options.triggerBotAllowlist ?? - splitEnvList(process.env.DISCORDBOT_TRIGGER_BOT_ALLOWLIST)), - ]; + return configuredDiscordTriggerBotIds(options); } /** Resolved human trigger-role allowlist (options first, env fallback). */ diff --git a/services/discordbot/src/discord-ingress.ts b/services/discordbot/src/discord-ingress.ts index 4f2f2b388e..0f04ee7379 100644 --- a/services/discordbot/src/discord-ingress.ts +++ b/services/discordbot/src/discord-ingress.ts @@ -3,10 +3,10 @@ import { parseDiscordThreadKey, resolveChannelAllowlist, resolveGuildAllowlist, - resolveTriggerBotAllowlist, } from "./discord-allowlist"; import { resolveDiscordPermissionBundle, + resolveDiscordTriggerBotPermissionBundle, type DiscordPermissionBundle, } from "./discord-policy"; import { discordMentionRoutingDecision } from "./discord-mention-routing"; @@ -270,14 +270,6 @@ async function evaluateAdmission( return deny("stale_delivery"); } if (event.authorIsSelf) return deny("self_message"); - const triggerBotAllowlist = new Set(resolveTriggerBotAllowlist(options)); - const explicitlyAllowedBot = [ - event.authorId, - event.applicationId, - event.webhookId, - ].some((id) => id !== undefined && triggerBotAllowlist.has(id)); - if (event.webhookId && !explicitlyAllowedBot) return deny("webhook_message"); - if (event.authorIsBot && !explicitlyAllowedBot) return deny("bot_message"); if (!SUPPORTED_MESSAGE_TYPES.has(event.messageType)) { return deny("unsupported_message_type"); } @@ -287,8 +279,24 @@ async function evaluateAdmission( if (!resolveChannelAllowlist(options).includes(event.channelId)) { return deny("channel_not_allowlisted"); } - const resolution = resolveDiscordPermissionBundle(event.roleIds, options); - if (resolution.decision === "deny") return deny(resolution.reason); + const isNonHumanIdentity = event.authorIsBot || event.webhookId !== undefined; + const resolution = isNonHumanIdentity + ? resolveDiscordTriggerBotPermissionBundle( + { + applicationId: event.applicationId, + authorId: event.authorId, + webhookId: event.webhookId, + }, + options, + ) + : resolveDiscordPermissionBundle(event.roleIds, options); + if (resolution.decision === "deny") { + // Do not leak whether a configured integration identity or its bundle was + // absent. Human role failures retain their precise audit reason. + if (event.webhookId !== undefined) return deny("webhook_message"); + if (event.authorIsBot) return deny("bot_message"); + return deny(resolution.reason); + } const policy = resolution.bundle; const threadId = event.threadId ?? event.messageId; const key = rootKey(event.guildId, event.channelId, threadId); @@ -427,6 +435,8 @@ function validEventIds(event: DiscordGatewayMessageEvent): boolean { (value) => typeof value === "string" && snowflake.test(value), ) && (event.threadId === undefined || snowflake.test(event.threadId)) && + (event.applicationId === undefined || snowflake.test(event.applicationId)) && + (event.webhookId === undefined || snowflake.test(event.webhookId)) && Array.isArray(event.roleIds) && event.roleIds.every((roleId) => snowflake.test(roleId)) && typeof event.content === "string" && diff --git a/services/discordbot/src/discord-policy.ts b/services/discordbot/src/discord-policy.ts index aae0f08316..ee101decc8 100644 --- a/services/discordbot/src/discord-policy.ts +++ b/services/discordbot/src/discord-policy.ts @@ -1,11 +1,16 @@ import { createHash } from "node:crypto"; -import type { DiscordbotOptions, DiscordRoleBinding } from "./types"; +import type { + DiscordbotOptions, + DiscordRoleBinding, + DiscordTriggerBotBinding, +} from "./types"; const CAPABILITY_CLASS = /^[a-z][a-z0-9:_-]{0,63}$/; const PRINCIPAL_ROLE = /^[a-z0-9][a-z0-9._:-]{0,127}$/i; const REPOSITORY = /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/; const PROJECT = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/; const MAX_SCOPE_ENTRIES = 64; +const DISCORD_SNOWFLAKE = /^\d{16,22}$/; export type DiscordPermissionBundle = { canApprove: boolean; @@ -54,7 +59,7 @@ export function parseDiscordRoleBindings( record.principal_role, `binding ${index} principal_role`, ); - if (!/^\d{16,22}$/.test(roleId)) { + if (!DISCORD_SNOWFLAKE.test(roleId)) { throw new Error(`binding ${index} role_id must be a numeric Discord ID`); } if (roleIds.has(roleId)) { @@ -81,6 +86,74 @@ export function parseDiscordRoleBindings( }); } +/** + * Parse exact non-human identity bindings. The referenced role is reused as + * the capability bundle, rather than treating a bot/webhook as a member with + * caller-supplied roles. Bot identities cannot receive proposal approval. + */ +export function parseDiscordTriggerBotBindings( + raw: string | undefined, + roleBindings: readonly DiscordRoleBinding[] | undefined, +): DiscordTriggerBotBinding[] | undefined { + if (!raw?.trim()) return undefined; + let value: unknown; + try { + value = JSON.parse(raw); + } catch { + throw new Error("DISCORDBOT_TRIGGER_BOT_BINDINGS_JSON must be valid JSON"); + } + if (!Array.isArray(value) || value.length === 0) { + throw new Error( + "DISCORDBOT_TRIGGER_BOT_BINDINGS_JSON must be a non-empty array", + ); + } + const roleById = new Map(roleBindings?.map((binding) => [binding.roleId, binding])); + if (roleById.size === 0) { + throw new Error("trigger bot bindings require reviewed Discord role bindings"); + } + const identityIds = new Set(); + return value.map((item, index) => { + if (!item || typeof item !== "object" || Array.isArray(item)) { + throw new Error(`trigger bot binding ${index} must be an object`); + } + const record = item as Record; + if ( + Object.keys(record).length !== 2 || + !Object.hasOwn(record, "identity_id") || + !Object.hasOwn(record, "role_id") + ) { + throw new Error( + `trigger bot binding ${index} must contain only identity_id and role_id`, + ); + } + const identityId = requiredString( + record.identity_id, + `trigger bot binding ${index} identity_id`, + ); + const roleId = requiredString( + record.role_id, + `trigger bot binding ${index} role_id`, + ); + if (!DISCORD_SNOWFLAKE.test(identityId) || !DISCORD_SNOWFLAKE.test(roleId)) { + throw new Error(`trigger bot binding ${index} must use numeric Discord IDs`); + } + if (identityIds.has(identityId)) { + throw new Error(`Discord trigger bot identity ${identityId} has more than one binding`); + } + identityIds.add(identityId); + const policy = roleById.get(roleId); + if (!policy) { + throw new Error( + `trigger bot binding ${index} references an unknown reviewed role`, + ); + } + if (policy.canApprove) { + throw new Error("trigger bot bindings cannot authorize proposal approval"); + } + return { identityId, roleId }; + }); +} + /** * Resolve multiple Discord roles with explicit precedence, never an implicit * union. Equal-priority matches must describe the exact same bundle or the @@ -132,12 +205,59 @@ export function resolveDiscordPermissionBundle( }; } +/** + * Resolve a verified bot/application/webhook identity through a reviewed, + * static binding. Its empty `member.roles` data is never treated as authority. + */ +export function resolveDiscordTriggerBotPermissionBundle( + identities: { + applicationId?: string; + authorId: string; + webhookId?: string; + }, + options: Pick, +): DiscordPolicyResolution { + const held = new Set( + [identities.authorId, identities.applicationId, identities.webhookId] + .filter((identity): identity is string => + typeof identity === "string" && DISCORD_SNOWFLAKE.test(identity), + ), + ); + const matches = options.triggerBotBindings?.filter((binding) => + held.has(binding.identityId), + ) ?? []; + if (matches.length === 0) { + return { decision: "deny", reason: "role_not_authorized" }; + } + // Runtime callers may construct options without the server parser. Never + // infer an identity policy from overlapping bindings in that case. + if (matches.length !== 1) { + return { decision: "deny", reason: "role_policy_ambiguous" }; + } + const selected = matches[0]; + if (!selected) return { decision: "deny", reason: "role_not_authorized" }; + const resolution = resolveDiscordPermissionBundle([selected.roleId], options); + if (resolution.decision === "allow" && resolution.bundle.canApprove) { + return { decision: "deny", reason: "role_not_authorized" }; + } + return resolution; +} + export function configuredDiscordRoleIds( options: Pick, ): string[] { return options.roleBindings?.map((binding) => binding.roleId) ?? []; } +/** Exact transport identities that may reach the durable bot-policy gate. */ +export function configuredDiscordTriggerBotIds( + options: Pick, +): string[] { + return [ + ...new Set(options.triggerBotBindings?.map((binding) => binding.identityId) ?? []), + ].sort(); +} + function bindingSemanticKey(binding: DiscordRoleBinding): string { return JSON.stringify({ canApprove: binding.canApprove, diff --git a/services/discordbot/src/server.ts b/services/discordbot/src/server.ts index 9de069a3c9..ad84461102 100644 --- a/services/discordbot/src/server.ts +++ b/services/discordbot/src/server.ts @@ -1,6 +1,9 @@ import { createGatewayController } from "./gateway"; import { createDiscordbot, type DiscordbotOptions } from "./index"; -import { parseDiscordRoleBindings } from "./discord-policy"; +import { + parseDiscordRoleBindings, + parseDiscordTriggerBotBindings, +} from "./discord-policy"; const port = numberEnv("PORT", 3001); const apiUrl = stringEnv("CENTAUR_API_URL", "http://127.0.0.1:8080"); @@ -10,11 +13,16 @@ const applicationId = requiredEnv("DISCORD_APPLICATION_ID"); const guildAllowlist = optionalList("DISCORDBOT_GUILD_ALLOWLIST"); const channelAllowlist = optionalList("DISCORDBOT_CHANNEL_ALLOWLIST"); const mentionRoleIds = optionalList("DISCORD_MENTION_ROLE_IDS"); -const triggerBotAllowlist = optionalList("DISCORDBOT_TRIGGER_BOT_ALLOWLIST"); validateDiscordIds("DISCORDBOT_GUILD_ALLOWLIST", guildAllowlist); validateDiscordIds("DISCORDBOT_CHANNEL_ALLOWLIST", channelAllowlist); validateDiscordIds("DISCORD_MENTION_ROLE_IDS", mentionRoleIds); -validateDiscordIds("DISCORDBOT_TRIGGER_BOT_ALLOWLIST", triggerBotAllowlist); +const roleBindings = parseDiscordRoleBindings( + optionalEnv("DISCORDBOT_ROLE_BINDINGS_JSON"), +); +const triggerBotBindings = parseDiscordTriggerBotBindings( + optionalEnv("DISCORDBOT_TRIGGER_BOT_BINDINGS_JSON"), + roleBindings, +); const consoleLogger = { debug: (message: string, data?: unknown) => log("debug", message, data), @@ -62,11 +70,9 @@ const options: DiscordbotOptions = { mentionRoleIds, nameThreads: optionalEnv("DISCORDBOT_NAME_THREADS") !== "false", postgresUrl, - roleBindings: parseDiscordRoleBindings( - optionalEnv("DISCORDBOT_ROLE_BINDINGS_JSON"), - ), + roleBindings, stateKeyPrefix: optionalEnv("DISCORDBOT_STATE_KEY_PREFIX"), - triggerBotAllowlist, + triggerBotBindings, userName: stringEnv("DISCORDBOT_USER_NAME", "centaur"), logger: consoleLogger, }; diff --git a/services/discordbot/src/types.ts b/services/discordbot/src/types.ts index 6f04e3db20..6f63fc1f6c 100644 --- a/services/discordbot/src/types.ts +++ b/services/discordbot/src/types.ts @@ -147,8 +147,13 @@ export type DiscordbotOptions = { state?: StateAdapter; stateKeyPrefix?: string; /** - * Discord delta (mirrors slackbotv2's `triggerBotAllowlist`): bot user ids - * whose messages may trigger/append despite being bot-authored. + * Exact bot/application/webhook identities bound to an existing, non-approver + * Discord permission bundle. Empty is deny-by-default. + */ + triggerBotBindings?: readonly DiscordTriggerBotBinding[]; + /** + * @deprecated An identity allowlist without a reviewed policy bundle never + * authorizes bot work. Use `triggerBotBindings` instead. */ triggerBotAllowlist?: readonly string[]; /** @@ -176,6 +181,14 @@ export type DiscordRoleBinding = { roleId: string; }; +/** A verified non-human Discord identity bound to one existing role policy. */ +export type DiscordTriggerBotBinding = { + /** Immutable bot user, application, or webhook snowflake. */ + identityId: string; + /** Existing reviewed Discord role binding that supplies the exact bundle. */ + roleId: string; +}; + export type DiscordExecutionPolicy = { actorId: string; capabilityClass: string; diff --git a/services/discordbot/test/chat-sdk-emulate.test.ts b/services/discordbot/test/chat-sdk-emulate.test.ts index a3406f9f2d..c21d20c6c4 100644 --- a/services/discordbot/test/chat-sdk-emulate.test.ts +++ b/services/discordbot/test/chat-sdk-emulate.test.ts @@ -51,6 +51,7 @@ const USER_ID = "100000000000000001"; const OTHER_USER_ID = "100000000000000002"; const TRIGGER_BOT_ID = "400000000000000001"; const TRIGGER_BOT_APPLICATION_ID = "400000000000000002"; +const TRIGGER_BOT_WEBHOOK_ID = "400000000000000003"; const GUILD_ID = "200000000000000001"; const CHANNEL_ID = "300000000000000001"; const TRIGGER_ROLE_ID = "500000000000000001"; @@ -1423,9 +1424,13 @@ describe("discordbot", () => { expect(codexApi.creates).toHaveLength(0); expect(codexApi.executes).toHaveLength(0); - // Bot-authored messages need both the explicit immutable bot allowlist and - // the same reviewed role capability policy as a human actor. - bot = createTestBot({ triggerBotAllowlist: [TRIGGER_BOT_ID] }); + // A bot's exact identity, not fabricated member.roles, selects its static + // reviewed permission bundle. + bot = createTestBot({ + triggerBotBindings: [ + { identityId: TRIGGER_BOT_ID, roleId: TRIGGER_ROLE_ID }, + ], + }); const threadId = discordApi.nextId(); discordApi.seedThreadChannel(threadId, CHANNEL_ID); const allowedBotMentionId = await dispatchMessage({ @@ -1434,16 +1439,22 @@ describe("discordbot", () => { channelId: threadId, content: `<@${APP_ID}> from another bot`, mention: true, + roleIds: [], thread: { id: threadId, parentId: CHANNEL_ID }, }); await waitForSettle(threadId, allowedBotMentionId); expect(codexApi.executes).toHaveLength(1); codexApi.reset(); - // Application and webhook IDs accepted by durable admission must survive - // the adapter's later bot-forwarding hook as the same policy identity. + // Application and webhook identities use that same explicit static bundle + // with no human role fixture. bot = createTestBot({ - triggerBotAllowlist: [TRIGGER_BOT_APPLICATION_ID], + triggerBotBindings: [ + { + identityId: TRIGGER_BOT_APPLICATION_ID, + roleId: TRIGGER_ROLE_ID, + }, + ], }); const applicationThreadId = discordApi.nextId(); discordApi.seedThreadChannel(applicationThreadId, CHANNEL_ID); @@ -1454,12 +1465,34 @@ describe("discordbot", () => { channelId: applicationThreadId, content: `<@${APP_ID}> from an allowlisted application`, mention: true, + roleIds: [], thread: { id: applicationThreadId, parentId: CHANNEL_ID }, }); await waitForSettle(applicationThreadId, applicationBotMentionId); expect(codexApi.executes).toHaveLength(1); codexApi.reset(); + bot = createTestBot({ + triggerBotBindings: [ + { identityId: TRIGGER_BOT_WEBHOOK_ID, roleId: TRIGGER_ROLE_ID }, + ], + }); + const webhookThreadId = discordApi.nextId(); + discordApi.seedThreadChannel(webhookThreadId, CHANNEL_ID); + const webhookMentionId = await dispatchMessage({ + authorBot: true, + authorId: TRIGGER_BOT_ID, + channelId: webhookThreadId, + content: `<@${APP_ID}> from an allowlisted webhook`, + mention: true, + roleIds: [], + thread: { id: webhookThreadId, parentId: CHANNEL_ID }, + webhookId: TRIGGER_BOT_WEBHOOK_ID, + }); + await waitForSettle(webhookThreadId, webhookMentionId); + expect(codexApi.executes).toHaveLength(1); + codexApi.reset(); + // Follow-ups are re-authorized; removing the human role blocks new context // even inside a previously authorized thread. bot = createTestBot(); diff --git a/services/discordbot/test/discord-allowlist.test.ts b/services/discordbot/test/discord-allowlist.test.ts index 935639070f..5efe488017 100644 --- a/services/discordbot/test/discord-allowlist.test.ts +++ b/services/discordbot/test/discord-allowlist.test.ts @@ -10,7 +10,7 @@ import { isGuildAllowlistEmpty, parseDiscordThreadKey, } from "../src/discord-allowlist"; -import type { DiscordbotOptions } from "../src/types"; +import type { DiscordbotOptions, DiscordTriggerBotBinding } from "../src/types"; const silentLogger: Logger = { debug: () => undefined, @@ -56,6 +56,12 @@ function options( }; } +function triggerBotBinding( + identityId = "u1", +): DiscordTriggerBotBinding { + return { identityId, roleId: "R1" }; +} + describe("parseDiscordThreadKey", () => { it("decodes guild/channel/thread", () => { expect(parseDiscordThreadKey("discord:G1:C1:T1")).toEqual({ @@ -178,31 +184,31 @@ describe("isAllowedDiscordMessage", () => { ).toBe(false); }); - it("allows an allowlisted trigger bot through the bot gate", () => { + it("allows a bot identity with a reviewed policy binding through the bot gate", () => { expect( isAllowedDiscordMessage( message({ threadId: "discord:G1:C1:T1", isBot: true }), - options({ triggerBotAllowlist: ["u1"] }), + options({ triggerBotBindings: [triggerBotBinding()] }), silentLogger, ), ).toBe(true); }); - it("still denies a bot not on the trigger allowlist", () => { + it("still denies a bot without a reviewed identity binding", () => { expect( isAllowedDiscordMessage( message({ threadId: "discord:G1:C1:T1", isBot: true }), - options({ triggerBotAllowlist: ["someone-else"] }), + options({ triggerBotBindings: [triggerBotBinding("someone-else")] }), silentLogger, ), ).toBe(false); }); - it("still denies the bot’s own messages even when allowlisted", () => { + it("still denies the bot’s own messages even when its identity is bound", () => { expect( isAllowedDiscordMessage( message({ threadId: "discord:G1:C1:T1", isBot: true, isMe: true }), - options({ triggerBotAllowlist: ["u1"] }), + options({ triggerBotBindings: [triggerBotBinding()] }), silentLogger, ), ).toBe(false); diff --git a/services/discordbot/test/discord-ingress.test.ts b/services/discordbot/test/discord-ingress.test.ts index 5699571b75..3787ba5ab7 100644 --- a/services/discordbot/test/discord-ingress.test.ts +++ b/services/discordbot/test/discord-ingress.test.ts @@ -5,7 +5,11 @@ import { admitDiscordGatewayMessage, type DiscordGatewayMessageEvent, } from "../src/discord-ingress"; -import type { DiscordbotOptions, DiscordRoleBinding } from "../src/types"; +import type { + DiscordbotOptions, + DiscordRoleBinding, + DiscordTriggerBotBinding, +} from "../src/types"; const NOW = Date.now(); const APP = "900000000000000001"; @@ -34,6 +38,12 @@ function binding( }; } +function triggerBotBinding( + overrides: Partial = {}, +): DiscordTriggerBotBinding { + return { identityId: USER, roleId: ROLE, ...overrides }; +} + function options(overrides: Partial = {}): DiscordbotOptions { return { apiUrl: "http://api.test", @@ -226,11 +236,11 @@ describe("Discord Gateway admission", () => { } }); - it("requires both an explicit bot identity and a reviewed role capability", async () => { - const configured = options({ triggerBotAllowlist: [USER] }); + it("binds an explicitly reviewed bot or webhook identity to one static bundle", async () => { + const configured = options({ triggerBotBindings: [triggerBotBinding()] }); expect( await reasonFor( - event("600000000000000045", { authorIsBot: true }), + event("600000000000000045", { authorIsBot: true, roleIds: [] }), configured, ), ).toBe("accepted"); @@ -238,18 +248,23 @@ describe("Discord Gateway admission", () => { await reasonFor( event("600000000000000046", { authorIsBot: true, - roleIds: [], + roleIds: [ROLE], }), - configured, + options({ triggerBotAllowlist: [USER] }), ), - ).toBe("role_not_authorized"); + ).toBe("bot_message"); expect( await reasonFor( event("600000000000000047", { authorIsBot: true, + roleIds: [], webhookId: "700000000000000001", }), - options({ triggerBotAllowlist: ["700000000000000001"] }), + options({ + triggerBotBindings: [ + triggerBotBinding({ identityId: "700000000000000001" }), + ], + }), ), ).toBe("accepted"); }); diff --git a/services/discordbot/test/discord-policy.test.ts b/services/discordbot/test/discord-policy.test.ts index 95e3f0cd10..34dc1fc4b2 100644 --- a/services/discordbot/test/discord-policy.test.ts +++ b/services/discordbot/test/discord-policy.test.ts @@ -1,12 +1,20 @@ import { describe, expect, it } from "bun:test"; import { parseDiscordRoleBindings, + parseDiscordTriggerBotBindings, resolveDiscordPermissionBundle, + resolveDiscordTriggerBotPermissionBundle, } from "../src/discord-policy"; -import type { DiscordbotOptions, DiscordRoleBinding } from "../src/types"; +import type { + DiscordbotOptions, + DiscordRoleBinding, + DiscordTriggerBotBinding, +} from "../src/types"; const ROLE_A = "500000000000000001"; const ROLE_B = "500000000000000002"; +const BOT_ID = "600000000000000001"; +const APPLICATION_ID = "600000000000000002"; function binding( overrides: Partial = {}, @@ -181,3 +189,74 @@ describe("resolveDiscordPermissionBundle", () => { }); }); }); + +describe("trigger bot policy bindings", () => { + function triggerBinding( + overrides: Partial = {}, + ): DiscordTriggerBotBinding { + return { identityId: BOT_ID, roleId: ROLE_A, ...overrides }; + } + + it("binds an exact non-human identity to one existing non-approver bundle", () => { + const parsed = parseDiscordTriggerBotBindings( + JSON.stringify([{ identity_id: BOT_ID, role_id: ROLE_A }]), + [binding()], + ); + expect(parsed).toEqual([triggerBinding()]); + + const resolution = resolveDiscordTriggerBotPermissionBundle( + { applicationId: APPLICATION_ID, authorId: BOT_ID }, + { ...options([binding()]), triggerBotBindings: parsed }, + ); + expect(resolution).toEqual({ + decision: "allow", + bundle: expect.objectContaining({ + principalRole: "discord-observer", + sourceRoleId: ROLE_A, + }), + }); + }); + + it("rejects malformed, ambiguous, unknown, and approver bot bindings", () => { + const invalid = [ + "not-json", + "[]", + JSON.stringify([{ identity_id: BOT_ID, role_id: ROLE_B }]), + JSON.stringify([{ identity_id: BOT_ID, role_id: ROLE_A, extra: true }]), + JSON.stringify([ + { identity_id: BOT_ID, role_id: ROLE_A }, + { identity_id: BOT_ID, role_id: ROLE_A }, + ]), + ]; + for (const raw of invalid) { + expect(() => parseDiscordTriggerBotBindings(raw, [binding()])).toThrow(); + } + expect(() => + parseDiscordTriggerBotBindings( + JSON.stringify([{ identity_id: BOT_ID, role_id: ROLE_A }]), + [binding({ canApprove: true })], + ), + ).toThrow(/cannot authorize proposal approval/); + expect( + resolveDiscordTriggerBotPermissionBundle( + { authorId: BOT_ID, webhookId: APPLICATION_ID }, + { + ...options([binding(), binding({ roleId: ROLE_B })]), + triggerBotBindings: [ + triggerBinding(), + triggerBinding({ identityId: APPLICATION_ID, roleId: ROLE_B }), + ], + }, + ), + ).toEqual({ decision: "deny", reason: "role_policy_ambiguous" }); + expect( + resolveDiscordTriggerBotPermissionBundle( + { authorId: BOT_ID }, + { + ...options([binding({ canApprove: true })]), + triggerBotBindings: [triggerBinding()], + }, + ), + ).toEqual({ decision: "deny", reason: "role_not_authorized" }); + }); +}); From 8b13787b882d14111dda9802e8961a1c33181e5d Mon Sep 17 00:00:00 2001 From: Michael Wu Date: Wed, 2 Sep 2026 00:09:20 +0900 Subject: [PATCH 12/37] fix: enforce Discord GitHub App repository scope --- .../centaur-iron-control/src/session.rs | 287 +++++++++++++++++- 1 file changed, 284 insertions(+), 3 deletions(-) diff --git a/services/api-rs/crates/centaur-iron-control/src/session.rs b/services/api-rs/crates/centaur-iron-control/src/session.rs index 45ef9a0c99..6f56ba4ec2 100644 --- a/services/api-rs/crates/centaur-iron-control/src/session.rs +++ b/services/api-rs/crates/centaur-iron-control/src/session.rs @@ -24,6 +24,7 @@ struct SessionPrincipalMetadata<'a> { actor_user_id: Option<&'a str>, discord_actor_user_id: Option<&'a str>, discord_policy_roles: Option<&'a Value>, + discord_repository_scope: Option<&'a Value>, slack_team_id: Option<&'a str>, slack_user_email: Option<&'a str>, conversation_name: Option<&'a str>, @@ -35,6 +36,12 @@ const DISCORD_OBSERVABILITY_LABEL: &str = "centaur.discord.sandbox_observability const DISCORD_SESSIONS_READ_LABEL: &str = "centaur.discord.sandbox_sessions_read_enabled"; const DISCORD_WORKFLOWS_READ_LABEL: &str = "centaur.discord.sandbox_workflows_read_enabled"; const DISCORD_WORKFLOWS_WRITE_LABEL: &str = "centaur.discord.sandbox_workflows_write_enabled"; +const DISCORD_REPOSITORY_SCOPE_LABEL: &str = "repository_scope"; +const GITHUB_APP_INSTALLATION_GRANT: &str = "github_app_installation"; +const GITHUB_REPOSITORIES_LABEL: &str = "repositories"; +const GITHUB_TOKEN_KIND: &str = "github_token"; +const TOKEN_BROKER_SOURCE: &str = "token_broker"; +const MAX_DISCORD_REPOSITORY_SCOPE: usize = 64; #[derive(Clone, Debug, Eq, PartialEq)] struct DiscordPrincipalCapabilities { @@ -92,6 +99,7 @@ impl<'a> SessionPrincipalMetadata<'a> { .get("discord_actor_user_id") .and_then(Value::as_str), discord_policy_roles: metadata.get("discord_policy_role_foreign_ids"), + discord_repository_scope: metadata.get("discord_repository_scope"), slack_team_id: metadata.get("slack_team_id").and_then(Value::as_str), slack_user_email: metadata.get("slack_user_email").and_then(Value::as_str), conversation_name: metadata @@ -162,13 +170,16 @@ impl SessionRegistrar { .await?; } if is_discord - && (metadata.discord_actor_user_id.is_some() || metadata.discord_policy_roles.is_some()) + && (metadata.discord_actor_user_id.is_some() + || metadata.discord_policy_roles.is_some() + || metadata.discord_repository_scope.is_some()) { record = self .reconcile_discord_policy_roles( &record, metadata.discord_actor_user_id, metadata.discord_policy_roles, + metadata.discord_repository_scope, ) .await?; } @@ -278,6 +289,7 @@ impl SessionRegistrar { principal: &Principal, actor_user_id: Option<&str>, role_value: Option<&Value>, + repository_scope_value: Option<&Value>, ) -> Result { let actor_user_id = actor_user_id .map(str::trim) @@ -288,6 +300,7 @@ impl SessionRegistrar { ) })?; let role_foreign_ids = parse_discord_policy_roles(role_value)?; + let repository_scope = parse_discord_repository_scope(repository_scope_value)?; if principal.labels.get("discord_user_id").map(String::as_str) != Some(actor_user_id) || principal .labels @@ -322,6 +335,8 @@ impl SessionRegistrar { "role {foreign_id} is not marked as a reviewed Discord policy role" ))); } + self.validate_discord_role_github_scope(&role, &repository_scope) + .await?; desired_roles.push(role); } let capabilities = DiscordPrincipalCapabilities::from_role_labels( @@ -354,6 +369,102 @@ impl SessionRegistrar { reconciled.sandbox_observability_enabled = policy.sandbox_observability_enabled; Ok(reconciled) } + + /// Prove that a Discord role's GitHub App token can reach exactly the + /// repositories asserted by the authenticated ingress policy. Role labels + /// are only declarations; the actual broker credential is authoritative. + async fn validate_discord_role_github_scope( + &self, + role: &crate::models::Role, + expected_scope: &[String], + ) -> Result<()> { + let role_scope = parse_discord_role_repository_scope(&role.labels)?; + if role_scope != expected_scope { + return Err(IronControlError::DiscordPolicy( + "reviewed Discord role repository_scope differs from the ingress policy".to_owned(), + )); + } + + let grants = self.client.list_role_grants(&role.id).await?; + let mut github_credential_count = 0usize; + for grant in grants { + let Some(("static", collection, secret_id)) = grant.secret_target() else { + continue; + }; + let secret = self + .client + .get_secret_detail(collection, "ssr_", secret_id) + .await?; + let kind = secret.get("kind").and_then(Value::as_str); + let source = secret.get("source").and_then(Value::as_object); + let source_type = source + .and_then(|source| source.get("source_type")) + .and_then(Value::as_str); + + // A value advertised as GITHUB_TOKEN must always be a scoped + // GitHub App credential. Other static secrets can be non-GitHub + // capability grants, but a token_broker backed by a GitHub App is + // still checked even if a bad operator relabels the secret. + if kind == Some(GITHUB_TOKEN_KIND) && source_type != Some(TOKEN_BROKER_SOURCE) { + return Err(IronControlError::DiscordPolicy( + "Discord GitHub token is not sourced from a token broker".to_owned(), + )); + } + if source_type != Some(TOKEN_BROKER_SOURCE) { + continue; + } + let credential_id = source + .and_then(|source| source.get("config")) + .and_then(Value::as_object) + .and_then(|config| config.get("credential_id")) + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .ok_or_else(|| { + IronControlError::DiscordPolicy( + "brokered Discord role secret has no credential_id".to_owned(), + ) + })?; + let credential = self + .client + .get_broker_credential_detail(credential_id) + .await?; + let is_github_app = credential.get("grant").and_then(Value::as_str) + == Some(GITHUB_APP_INSTALLATION_GRANT); + if !is_github_app { + if kind == Some(GITHUB_TOKEN_KIND) { + return Err(IronControlError::DiscordPolicy( + "Discord GitHub token is not backed by a GitHub App credential".to_owned(), + )); + } + continue; + } + + if kind == Some(GITHUB_TOKEN_KIND) { + let secret_scope = parse_secret_repository_scope(&secret)?; + if secret_scope != expected_scope { + return Err(IronControlError::DiscordPolicy( + "Discord GitHub token declaration differs from the ingress repository scope" + .to_owned(), + )); + } + } + let credential_scope = parse_broker_repository_scope(&credential)?; + if credential_scope != expected_scope { + return Err(IronControlError::DiscordPolicy( + "Discord GitHub App credential scope differs from the ingress repository scope" + .to_owned(), + )); + } + github_credential_count += 1; + } + if github_credential_count != 1 { + return Err(IronControlError::DiscordPolicy(format!( + "reviewed Discord role must grant exactly one scoped GitHub App credential, found {github_credential_count}" + ))); + } + Ok(()) + } } fn discord_capability_bool(labels: &BTreeMap, key: &str) -> Result { @@ -397,6 +508,125 @@ fn parse_discord_policy_roles(value: Option<&Value>) -> Result> { Ok(unique.into_iter().collect()) } +fn parse_discord_repository_scope(value: Option<&Value>) -> Result> { + let values = value.and_then(Value::as_array).ok_or_else(|| { + IronControlError::DiscordPolicy( + "discord_repository_scope must be a non-empty array".to_owned(), + ) + })?; + let entries = values + .iter() + .map(|value| { + value.as_str().ok_or_else(|| { + IronControlError::DiscordPolicy( + "discord_repository_scope must contain strings".to_owned(), + ) + }) + }) + .collect::>>()?; + normalize_repository_scope(entries, "discord_repository_scope") +} + +fn parse_discord_role_repository_scope(labels: &BTreeMap) -> Result> { + let value = labels + .get(DISCORD_REPOSITORY_SCOPE_LABEL) + .map(String::as_str) + .ok_or_else(|| { + IronControlError::DiscordPolicy( + "reviewed Discord role is missing repository_scope".to_owned(), + ) + })?; + normalize_repository_scope( + value.split(',').map(str::trim).collect::>(), + "reviewed Discord role repository_scope", + ) +} + +fn parse_secret_repository_scope(secret: &Value) -> Result> { + let value = secret + .get("labels") + .and_then(Value::as_object) + .and_then(|labels| labels.get(GITHUB_REPOSITORIES_LABEL)) + .and_then(Value::as_str) + .ok_or_else(|| { + IronControlError::DiscordPolicy( + "Discord GitHub token is missing its repository declaration".to_owned(), + ) + })?; + normalize_repository_scope( + value.split(',').map(str::trim).collect::>(), + "Discord GitHub token repository declaration", + ) +} + +fn parse_broker_repository_scope(credential: &Value) -> Result> { + let values = credential + .get("github_repositories") + .and_then(Value::as_array) + .ok_or_else(|| { + IronControlError::DiscordPolicy( + "GitHub App credential has no repository scope".to_owned(), + ) + })?; + let entries = values + .iter() + .map(|value| { + value.as_str().ok_or_else(|| { + IronControlError::DiscordPolicy( + "GitHub App credential repository scope must contain strings".to_owned(), + ) + }) + }) + .collect::>>()?; + normalize_repository_scope(entries, "GitHub App credential repository scope") +} + +fn normalize_repository_scope(entries: Vec<&str>, label: &str) -> Result> { + if entries.is_empty() || entries.len() > MAX_DISCORD_REPOSITORY_SCOPE { + return Err(IronControlError::DiscordPolicy(format!( + "{label} must contain between one and {MAX_DISCORD_REPOSITORY_SCOPE} repositories" + ))); + } + let mut repositories = BTreeSet::new(); + for entry in entries { + let repository = normalize_repository(entry).ok_or_else(|| { + IronControlError::DiscordPolicy(format!( + "{label} must contain exact owner/repository names" + )) + })?; + if !repositories.insert(repository) { + return Err(IronControlError::DiscordPolicy(format!( + "{label} contains duplicate repositories" + ))); + } + } + Ok(repositories.into_iter().collect()) +} + +fn normalize_repository(value: &str) -> Option { + if value.is_empty() || value != value.trim() || value.len() > 128 { + return None; + } + let (owner, repository) = value.split_once('/')?; + if owner.is_empty() + || repository.is_empty() + || repository.contains('/') + || !owner + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b'-')) + || !repository + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b'-')) + { + return None; + } + Some(format!( + "{}/{}", + owner.to_ascii_lowercase(), + repository.to_ascii_lowercase() + )) +} + fn eligible_slack_requester_team(metadata: &Value) -> Option<&str> { let requester_team = metadata .get("slack_team_id") @@ -783,6 +1013,7 @@ mod tests { async fn register_session_reconciles_discord_actor_to_the_exact_reviewed_role() { let (base_url, requests, server) = spawn_discord_policy_stub(DiscordPolicyStub { direct_grant: false, + github_repositories: r#"["508-dev/centaur"]"#, reviewed_role: true, }) .await; @@ -844,6 +1075,7 @@ mod tests { async fn register_session_rejects_discord_principal_direct_grants() { let (base_url, requests, server) = spawn_discord_policy_stub(DiscordPolicyStub { direct_grant: true, + github_repositories: r#"["508-dev/centaur"]"#, reviewed_role: true, }) .await; @@ -872,6 +1104,7 @@ mod tests { async fn register_session_rejects_unreviewed_discord_policy_role() { let (base_url, requests, server) = spawn_discord_policy_stub(DiscordPolicyStub { direct_grant: false, + github_repositories: r#"["508-dev/centaur"]"#, reviewed_role: false, }) .await; @@ -896,6 +1129,37 @@ mod tests { server.abort(); } + #[tokio::test] + async fn register_session_rejects_unscoped_or_broader_discord_github_credentials() { + for repositories in [r#"[]"#, r#"["508-dev/centaur","508-dev/other"]"#] { + let (base_url, requests, server) = spawn_discord_policy_stub(DiscordPolicyStub { + direct_grant: false, + github_repositories: repositories, + reviewed_role: true, + }) + .await; + let registrar = SessionRegistrar::new(IronControlClient::new(base_url, "test-key")); + + let error = registrar + .register_session( + "discord:200000000000000001:300000000000000001:400000000000000001", + Some(&discord_policy_metadata()), + ) + .await + .unwrap_err(); + assert!(matches!(error, IronControlError::DiscordPolicy(_))); + assert!( + !requests + .lock() + .unwrap() + .iter() + .any(|request| request == "PUT /api/v1/principals/prn_discord/roles"), + "an unscoped or broader credential blocks before role assignment" + ); + server.abort(); + } + } + #[test] fn slack_email_applies_only_to_user_principals_with_non_blank_email() { let mut user_input = derive_principal("slack:T123:D123:ts", Some("U123"), None) @@ -1213,13 +1477,15 @@ mod tests { fn discord_policy_metadata() -> Value { json!({ "discord_actor_user_id": "100000000000000001", - "discord_policy_role_foreign_ids": ["discord-observer"] + "discord_policy_role_foreign_ids": ["discord-observer"], + "discord_repository_scope": ["508-dev/centaur"] }) } #[derive(Clone, Copy)] struct DiscordPolicyStub { direct_grant: bool, + github_repositories: &'static str, reviewed_role: bool, } @@ -1252,7 +1518,7 @@ mod tests { let principal = r#"{"data":{"id":"prn_discord","foreign_id":"discord-user-200000000000000001-100000000000000001","name":"Discord User","labels":{"managed-by":"centaur","discord_guild_id":"200000000000000001","discord_channel_id":"300000000000000001","discord_user_id":"100000000000000001","centaur_discord_policy_managed":"true"}}}"#; let role_labels = if config.reviewed_role { - r#"{"centaur_discord_policy_managed":"true","centaur.discord.sandbox_repo_cache":"all","centaur.discord.sandbox_observability_enabled":"true","centaur.discord.sandbox_sessions_read_enabled":"false","centaur.discord.sandbox_workflows_read_enabled":"true","centaur.discord.sandbox_workflows_write_enabled":"true"}"# + r#"{"centaur_discord_policy_managed":"true","repository_scope":"508-dev/centaur","centaur.discord.sandbox_repo_cache":"all","centaur.discord.sandbox_observability_enabled":"true","centaur.discord.sandbox_sessions_read_enabled":"false","centaur.discord.sandbox_workflows_read_enabled":"true","centaur.discord.sandbox_workflows_write_enabled":"true"}"# } else { "{}" }; @@ -1264,6 +1530,12 @@ mod tests { } else { r#"{"data":[]}"# }; + let role_grants = r#"{"data":[{"id":"grant_github","role_id":"role_observer","static_secret_id":"ssr_github"}]}"#; + let github_secret = r#"{"data":{"id":"ssr_github","kind":"github_token","labels":{"repositories":"508-dev/centaur"},"source":{"source_type":"token_broker","config":{"credential_id":"bcr_github"}}}}"#; + let github_credential = format!( + r#"{{"data":{{"id":"bcr_github","grant":"github_app_installation","github_repositories":{}}}}}"#, + config.github_repositories + ); let (status_line, body) = match (method, path) { ( "GET", @@ -1277,6 +1549,15 @@ mod tests { ("200 OK", grants.to_owned()) } ("GET", "/api/v1/roles/lookup/discord-observer") => ("200 OK", role), + ("GET", "/api/v1/roles/role_observer/grants?page=1&limit=100") => { + ("200 OK", role_grants.to_owned()) + } + ("GET", "/api/v1/static_secrets/ssr_github") => { + ("200 OK", github_secret.to_owned()) + } + ("GET", "/api/v1/broker_credentials/bcr_github") => { + ("200 OK", github_credential) + } ("PUT", "/api/v1/principals/prn_discord/roles") => { ("200 OK", r#"{"data":{"ok":true}}"#.to_owned()) } From 24353d01be2f48a84cf4dcd0e5a327d04a3b123a Mon Sep 17 00:00:00 2001 From: Michael Wu Date: Wed, 2 Sep 2026 00:49:33 +0900 Subject: [PATCH 13/37] fix: enforce Discord GitHub credential boundaries --- .../centaur-iron-control/src/session.rs | 64 +++- .../console/app/models/broker_credential.rb | 5 + services/console/app/models/grant.rb | 5 + services/console/app/models/principal.rb | 6 +- services/console/app/models/principal_role.rb | 5 + .../models/principal_sync_config_snapshot.rb | 10 +- services/console/app/models/role.rb | 5 + services/console/app/models/secret_source.rb | 5 + services/console/app/models/static_secret.rb | 5 + .../services/discord_github_role_policy.rb | 287 ++++++++++++++++++ .../models/discord_github_role_policy_test.rb | 166 ++++++++++ 11 files changed, 549 insertions(+), 14 deletions(-) create mode 100644 services/console/app/services/discord_github_role_policy.rb create mode 100644 services/console/test/models/discord_github_role_policy_test.rb diff --git a/services/api-rs/crates/centaur-iron-control/src/session.rs b/services/api-rs/crates/centaur-iron-control/src/session.rs index 6f56ba4ec2..4575fba648 100644 --- a/services/api-rs/crates/centaur-iron-control/src/session.rs +++ b/services/api-rs/crates/centaur-iron-control/src/session.rs @@ -440,14 +440,18 @@ impl SessionRegistrar { continue; } - if kind == Some(GITHUB_TOKEN_KIND) { - let secret_scope = parse_secret_repository_scope(&secret)?; - if secret_scope != expected_scope { - return Err(IronControlError::DiscordPolicy( - "Discord GitHub token declaration differs from the ingress repository scope" - .to_owned(), - )); - } + if kind != Some(GITHUB_TOKEN_KIND) { + return Err(IronControlError::DiscordPolicy( + "Discord GitHub App credential must use the canonical github_token static-secret profile" + .to_owned(), + )); + } + let secret_scope = parse_secret_repository_scope(&secret)?; + if secret_scope != expected_scope { + return Err(IronControlError::DiscordPolicy( + "Discord GitHub token declaration differs from the ingress repository scope" + .to_owned(), + )); } let credential_scope = parse_broker_repository_scope(&credential)?; if credential_scope != expected_scope { @@ -1013,6 +1017,7 @@ mod tests { async fn register_session_reconciles_discord_actor_to_the_exact_reviewed_role() { let (base_url, requests, server) = spawn_discord_policy_stub(DiscordPolicyStub { direct_grant: false, + github_secret_kind: GITHUB_TOKEN_KIND, github_repositories: r#"["508-dev/centaur"]"#, reviewed_role: true, }) @@ -1075,6 +1080,7 @@ mod tests { async fn register_session_rejects_discord_principal_direct_grants() { let (base_url, requests, server) = spawn_discord_policy_stub(DiscordPolicyStub { direct_grant: true, + github_secret_kind: GITHUB_TOKEN_KIND, github_repositories: r#"["508-dev/centaur"]"#, reviewed_role: true, }) @@ -1104,6 +1110,7 @@ mod tests { async fn register_session_rejects_unreviewed_discord_policy_role() { let (base_url, requests, server) = spawn_discord_policy_stub(DiscordPolicyStub { direct_grant: false, + github_secret_kind: GITHUB_TOKEN_KIND, github_repositories: r#"["508-dev/centaur"]"#, reviewed_role: false, }) @@ -1134,6 +1141,7 @@ mod tests { for repositories in [r#"[]"#, r#"["508-dev/centaur","508-dev/other"]"#] { let (base_url, requests, server) = spawn_discord_policy_stub(DiscordPolicyStub { direct_grant: false, + github_secret_kind: GITHUB_TOKEN_KIND, github_repositories: repositories, reviewed_role: true, }) @@ -1160,6 +1168,36 @@ mod tests { } } + #[tokio::test] + async fn register_session_rejects_custom_discord_github_app_wrapper() { + let (base_url, requests, server) = spawn_discord_policy_stub(DiscordPolicyStub { + direct_grant: false, + github_secret_kind: "custom", + github_repositories: r#"["508-dev/centaur"]"#, + reviewed_role: true, + }) + .await; + let registrar = SessionRegistrar::new(IronControlClient::new(base_url, "test-key")); + + let error = registrar + .register_session( + "discord:200000000000000001:300000000000000001:400000000000000001", + Some(&discord_policy_metadata()), + ) + .await + .unwrap_err(); + assert!(matches!(error, IronControlError::DiscordPolicy(_))); + assert!( + !requests + .lock() + .unwrap() + .iter() + .any(|request| request == "PUT /api/v1/principals/prn_discord/roles"), + "a custom GitHub App wrapper blocks before role assignment" + ); + server.abort(); + } + #[test] fn slack_email_applies_only_to_user_principals_with_non_blank_email() { let mut user_input = derive_principal("slack:T123:D123:ts", Some("U123"), None) @@ -1485,6 +1523,7 @@ mod tests { #[derive(Clone, Copy)] struct DiscordPolicyStub { direct_grant: bool, + github_secret_kind: &'static str, github_repositories: &'static str, reviewed_role: bool, } @@ -1531,7 +1570,10 @@ mod tests { r#"{"data":[]}"# }; let role_grants = r#"{"data":[{"id":"grant_github","role_id":"role_observer","static_secret_id":"ssr_github"}]}"#; - let github_secret = r#"{"data":{"id":"ssr_github","kind":"github_token","labels":{"repositories":"508-dev/centaur"},"source":{"source_type":"token_broker","config":{"credential_id":"bcr_github"}}}}"#; + let github_secret = format!( + r#"{{"data":{{"id":"ssr_github","kind":"{}","labels":{{"repositories":"508-dev/centaur"}},"source":{{"source_type":"token_broker","config":{{"credential_id":"bcr_github"}}}}}}}}"#, + config.github_secret_kind + ); let github_credential = format!( r#"{{"data":{{"id":"bcr_github","grant":"github_app_installation","github_repositories":{}}}}}"#, config.github_repositories @@ -1552,9 +1594,7 @@ mod tests { ("GET", "/api/v1/roles/role_observer/grants?page=1&limit=100") => { ("200 OK", role_grants.to_owned()) } - ("GET", "/api/v1/static_secrets/ssr_github") => { - ("200 OK", github_secret.to_owned()) - } + ("GET", "/api/v1/static_secrets/ssr_github") => ("200 OK", github_secret), ("GET", "/api/v1/broker_credentials/bcr_github") => { ("200 OK", github_credential) } diff --git a/services/console/app/models/broker_credential.rb b/services/console/app/models/broker_credential.rb index bfdda91491..35e2eea6c7 100644 --- a/services/console/app/models/broker_credential.rb +++ b/services/console/app/models/broker_credential.rb @@ -101,6 +101,7 @@ class BrokerCredential < ApplicationRecord validate :github_repositories_valid validate :grant_credentials_present validate :token_endpoint_headers_valid + validate :discord_github_policy_valid # OAuth client identity used for refresh. Flow-minted credentials delegate to # their OauthApp so a client-secret rotation on the app applies to every @@ -303,6 +304,10 @@ def token_endpoint_headers_valid errors.add(:token_endpoint_headers, "must be an object mapping header names to string values") unless valid end + def discord_github_policy_valid + DiscordGithubRolePolicy.validate_broker_credential(self) + end + def default_fixed_token_endpoint return unless %w[preqin github_app_installation].include?(grant) diff --git a/services/console/app/models/grant.rb b/services/console/app/models/grant.rb index 694ac94b7c..1699061dd0 100644 --- a/services/console/app/models/grant.rb +++ b/services/console/app/models/grant.rb @@ -35,6 +35,7 @@ class Grant < ApplicationRecord validate :exactly_one_grantee validate :exactly_one_grantable + validate :discord_github_policy_valid validates :priority, presence: true, numericality: { only_integer: true } # The grantee this grant attaches the secret to: a principal or a role. @@ -76,4 +77,8 @@ def exactly_one_grantable return if set == 1 errors.add(:base, "must reference exactly one of #{GRANTABLE_ASSOCIATIONS.join(", ")}") end + + def discord_github_policy_valid + DiscordGithubRolePolicy.validate_grant(self) + end end diff --git a/services/console/app/models/principal.rb b/services/console/app/models/principal.rb index b85319ee1a..ae2311aec8 100644 --- a/services/console/app/models/principal.rb +++ b/services/console/app/models/principal.rb @@ -281,7 +281,11 @@ def enqueue_slack_channel_catalog_refresh end def roles_blank_for_defaulting? - association(:roles).target.empty? && !roles.exists? + # Actor-scoped Discord principals are always populated by the reviewed + # policy replacement path. They must not transiently inherit a default role + # before that replacement validates their exact GitHub App scope. + labels.to_h["centaur_discord_policy_managed"] != "true" && + association(:roles).target.empty? && !roles.exists? end def assign_default_roles diff --git a/services/console/app/models/principal_role.rb b/services/console/app/models/principal_role.rb index 5c3494b986..46013283b0 100644 --- a/services/console/app/models/principal_role.rb +++ b/services/console/app/models/principal_role.rb @@ -7,10 +7,15 @@ class PrincipalRole < ApplicationRecord belongs_to :role validates :role_id, uniqueness: { scope: :principal_id, message: "is already assigned to this principal" } + validate :discord_github_policy_valid private def sync_config_affected_principals Principal.where(id: principal_id) end + + def discord_github_policy_valid + DiscordGithubRolePolicy.validate_principal_role(self) + end end diff --git a/services/console/app/models/principal_sync_config_snapshot.rb b/services/console/app/models/principal_sync_config_snapshot.rb index 9a8a45e15e..f489e7e9c5 100644 --- a/services/console/app/models/principal_sync_config_snapshot.rb +++ b/services/console/app/models/principal_sync_config_snapshot.rb @@ -328,7 +328,15 @@ def self.effective_pg_dsn_secrets_for(principal) # non-deliverable winner never suppresses a credential that would otherwise # serve. def self.served_credentials_for(principal, extra_static: []) - static = principal.granted_static_secrets.select { |ss| ss.source&.deliverable? } + static = principal.granted_static_secrets.select do |secret| + next false unless secret.source&.deliverable? + next true if DiscordGithubRolePolicy.static_secret_allowed_for_principal?(principal, secret) + + Rails.logger.warn do + "discord_github_policy_credential_denied principal=#{principal.oid} secret=#{secret.oid}" + end + false + end static = merge_static_credentials(static, extra_static) if extra_static.any? gcp_auth = principal.granted_gcp_auth_secrets.to_a gcp_id_token = principal.granted_gcp_id_token_secrets.to_a diff --git a/services/console/app/models/role.rb b/services/console/app/models/role.rb index 68e5eb9140..18de9b1a8f 100644 --- a/services/console/app/models/role.rb +++ b/services/console/app/models/role.rb @@ -18,6 +18,7 @@ class Role < ApplicationRecord validates :foreign_id, uniqueness: { allow_nil: true }, format: { with: URL_SAFE_FORMAT, message: URL_SAFE_MESSAGE }, allow_nil: true validate :labels_is_a_hash + validate :discord_github_policy_valid def self.ensure_default_infra!(created_by:) role = find_or_initialize_by(foreign_id: "infra") @@ -50,4 +51,8 @@ def self.replace_default_assignments!(role_ids) def labels_is_a_hash errors.add(:labels, "must be a hash") unless labels.is_a?(Hash) end + + def discord_github_policy_valid + DiscordGithubRolePolicy.validate_role(self) + end end diff --git a/services/console/app/models/secret_source.rb b/services/console/app/models/secret_source.rb index 75d13cf87f..eea8c7817a 100644 --- a/services/console/app/models/secret_source.rb +++ b/services/console/app/models/secret_source.rb @@ -99,6 +99,7 @@ def self.referencing_broker_credential(credential) validate :at_most_one_owner validate :role_matches_owner validate :token_broker_reference_resolves + validate :discord_github_policy_valid private @@ -134,6 +135,10 @@ def token_broker_reference_resolves end end + def discord_github_policy_valid + DiscordGithubRolePolicy.validate_secret_source(self) + end + def at_most_one_owner # Check the association object, not just the FK column: when built through a # parent (parent.sources.build / parent.keyfile_source =) autosave validates diff --git a/services/console/app/models/static_secret.rb b/services/console/app/models/static_secret.rb index cdbd525fa6..7a56296c86 100644 --- a/services/console/app/models/static_secret.rb +++ b/services/console/app/models/static_secret.rb @@ -104,6 +104,7 @@ def proxy_conflict_targets validate :replace_config_matches_schema validate :kind_config_matches_profile validate :kind_rules_match_profile + validate :discord_github_policy_valid private @@ -140,6 +141,10 @@ def kind_rules_match_profile validate_kind_rules(rules: @kind_rules_for_validation || rules) end + def discord_github_policy_valid + DiscordGithubRolePolicy.validate_static_secret(self) + end + def validate_against_schema(attr, value, schema) return if value.blank? unless value.is_a?(Hash) diff --git a/services/console/app/services/discord_github_role_policy.rb b/services/console/app/services/discord_github_role_policy.rb new file mode 100644 index 0000000000..379e795401 --- /dev/null +++ b/services/console/app/services/discord_github_role_policy.rb @@ -0,0 +1,287 @@ +# Validates the GitHub App boundary for roles that Core marks as reviewed +# Discord policy roles. The policy is enforced where the effective grant graph +# can change and again when a proxy config is rendered, so a later Console +# mutation cannot widen credentials already assigned to a Discord actor. +class DiscordGithubRolePolicy + MANAGED_ROLE_LABEL = "centaur_discord_policy_managed".freeze + MANAGED_PRINCIPAL_LABEL = "centaur_discord_policy_managed".freeze + REPOSITORY_SCOPE_LABEL = "repository_scope".freeze + SECRET_REPOSITORIES_LABEL = "repositories".freeze + TOKEN_BROKER_SOURCE = "token_broker".freeze + GITHUB_APP_INSTALLATION_GRANT = "github_app_installation".freeze + MAX_REPOSITORIES = 64 + REPOSITORY_FORMAT = /\A[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+\z/ + + class << self + def validate_role(role) + add_errors(role, policy_errors(role)) + end + + def validate_grant(grant) + if managed_principal?(grant.principal) + grant.errors.add(:base, "Discord policy-managed principals may not receive direct grants") + return + end + + role = grant.role + return unless managed_role?(role) && grant.static_secret + + extra_secret = grant.new_record? ? grant.static_secret : nil + add_errors(grant, policy_errors(role, extra_static_secret: extra_secret)) + end + + def validate_static_secret(secret) + policy_roles_granting(secret).each do |role| + messages = policy_errors(role, replacement_static_secret: secret) + add_errors(secret, prefix_errors(role, messages)) + end + end + + def validate_secret_source(source) + secret = source.static_secret + return unless secret + + policy_roles_granting(secret).each do |role| + messages = policy_errors( + role, + replacement_static_secret: secret, + replacement_source: source + ) + add_errors(source, prefix_errors(role, messages)) + end + end + + def validate_broker_credential(credential) + policy_roles_referencing(credential).each do |role| + messages = policy_errors(role, replacement_broker: credential) + add_errors(credential, prefix_errors(role, messages)) + end + end + + def validate_principal_role(assignment) + principal = assignment.principal + return unless managed_principal?(principal) + + role = assignment.role + unless managed_role?(role) + assignment.errors.add(:base, "Discord policy-managed principals may only receive reviewed Discord policy roles") + return + end + + roles = principal.roles.to_a + roles << role if assignment.new_record? && !roles.any? { |candidate| same_record?(candidate, role) } + if roles.length != 1 + assignment.errors.add(:base, "Discord policy-managed principals must receive exactly one reviewed role") + end + add_errors(assignment, prefix_errors(role, policy_errors(role))) + end + + # Used immediately before a proxy receives its static credentials. Model + # validations block normal mutations; this is a final fail-closed guard for + # legacy state or an out-of-band write that left a Discord role inconsistent. + def static_secret_allowed_for_principal?(principal, secret) + if managed_principal?(principal) + roles = principal.roles.to_a + return false unless roles.length == 1 && managed_role?(roles.first) + return false unless roles.first.grants.where(static_secret_id: secret.id).exists? + + return policy_errors(roles.first).empty? + end + + roles = principal.roles.to_a.select do |role| + managed_role?(role) && role.grants.where(static_secret_id: secret.id).exists? + end + return true if roles.empty? + + roles.all? { |role| policy_errors(role).empty? } + end + + private + + def add_errors(record, messages) + messages.uniq.each { |message| record.errors.add(:base, message) } + end + + def prefix_errors(role, messages) + messages.map { |message| "Discord policy role #{role.foreign_id || role.oid}: #{message}" } + end + + def managed_role?(role) + role&.labels.to_h[MANAGED_ROLE_LABEL] == "true" + end + + def managed_principal?(principal) + principal&.labels.to_h[MANAGED_PRINCIPAL_LABEL] == "true" + end + + def policy_errors(role, replacement_static_secret: nil, replacement_source: nil, + replacement_broker: nil, extra_static_secret: nil) + return [] unless managed_role?(role) + + expected_scope, scope_error = repository_scope( + role.labels.to_h[REPOSITORY_SCOPE_LABEL], + "reviewed Discord role repository_scope" + ) + return [ scope_error ] if scope_error + + secrets = static_secrets_for_role( + role, + replacement_static_secret: replacement_static_secret, + extra_static_secret: extra_static_secret + ) + github_secrets = secrets.select do |secret| + github_related_secret?(secret, replacement_static_secret, replacement_source, replacement_broker) + end + return [] if github_secrets.empty? + + errors = github_secrets.flat_map do |secret| + github_secret_errors( + secret, + expected_scope, + replacement_static_secret, + replacement_source, + replacement_broker + ) + end + if github_secrets.length != 1 + errors << "must grant exactly one scoped GitHub App credential, found #{github_secrets.length}" + end + errors.uniq + end + + def static_secrets_for_role(role, replacement_static_secret:, extra_static_secret:) + secrets = role.grants.includes(:static_secret).filter_map(&:static_secret) + if replacement_static_secret&.persisted? + secrets.map! do |secret| + same_record?(secret, replacement_static_secret) ? replacement_static_secret : secret + end + end + if extra_static_secret && !secrets.any? { |secret| same_record?(secret, extra_static_secret) } + secrets << extra_static_secret + end + secrets.uniq { |secret| secret.id || secret.object_id } + end + + def github_related_secret?(secret, replacement_static_secret, replacement_source, replacement_broker) + return true if secret.kind == CredentialProfiles::GithubToken::KIND + + broker = broker_for( + source_for(secret, replacement_static_secret, replacement_source), + replacement_broker + ) + broker&.grant == GITHUB_APP_INSTALLATION_GRANT + end + + def github_secret_errors(secret, expected_scope, replacement_static_secret, + replacement_source, replacement_broker) + errors = [] + unless canonical_github_token?(secret) + errors << "GitHub App credential must use the canonical github_token static-secret profile" + end + + source = source_for(secret, replacement_static_secret, replacement_source) + unless source&.source_type == TOKEN_BROKER_SOURCE + errors << "GitHub token must be sourced from a token broker" + return errors + end + + broker = broker_for(source, replacement_broker) + unless broker&.grant == GITHUB_APP_INSTALLATION_GRANT + errors << "GitHub token must be backed by a GitHub App installation credential" + return errors + end + + secret_scope, secret_scope_error = repository_scope( + secret.labels.to_h[SECRET_REPOSITORIES_LABEL], + "GitHub token repository declaration" + ) + errors << secret_scope_error if secret_scope_error + if secret_scope && secret_scope != expected_scope + errors << "GitHub token repository declaration differs from the reviewed role scope" + end + + broker_scope, broker_scope_error = repository_scope( + broker.github_repositories, + "GitHub App credential repository scope" + ) + errors << broker_scope_error if broker_scope_error + if broker_scope && broker_scope != expected_scope + errors << "GitHub App credential repository scope differs from the reviewed role scope" + end + errors + end + + def canonical_github_token?(secret) + return false unless secret.kind == CredentialProfiles::GithubToken::KIND + return false unless secret.inject_config.blank? && + secret.replace_config == CredentialProfiles::GithubToken::REPLACE_CONFIG + + rules = secret.rules.to_a + rules.present? && rules.all? do |rule| + CredentialProfiles::GithubToken::ALLOWED_HOSTS.include?(rule.host) && rule.cidr.blank? + end + end + + def source_for(secret, replacement_static_secret, replacement_source) + return replacement_source if replacement_source && same_record?(secret, replacement_static_secret) + + secret.source + end + + def broker_for(source, replacement_broker) + return nil unless source&.source_type == TOKEN_BROKER_SOURCE && source.config.is_a?(Hash) + + reference = source.config["credential_id"].to_s.strip + return nil if reference.empty? + return replacement_broker if replacement_broker && references_broker?(source, replacement_broker) + + if BrokerCredential.decode_oid(reference) + BrokerCredential.find_by_oid(reference) + else + BrokerCredential.find_by(foreign_id: reference) + end + end + + def references_broker?(source, broker) + reference = source.config.to_h["credential_id"].to_s.strip + reference.present? && [ broker.oid, broker.foreign_id ].compact.include?(reference) + end + + def policy_roles_granting(secret) + return [] unless secret.persisted? + + Grant.where(static_secret_id: secret.id).where.not(role_id: nil).includes(:role) + .filter_map(&:role).select { |role| managed_role?(role) }.uniq(&:id) + end + + def policy_roles_referencing(credential) + SecretSource.referencing_broker_credential(credential).includes(static_secret: { grants: :role }) + .filter_map(&:static_secret).flat_map(&:grants).filter_map(&:role) + .select { |role| managed_role?(role) }.uniq(&:id) + end + + def repository_scope(value, label) + entries = case value + when String + value.split(",").map(&:strip) + when Array + value + else + return [ nil, "#{label} must be a non-empty repository list" ] + end + return [ nil, "#{label} must contain between one and #{MAX_REPOSITORIES} repositories" ] if entries.empty? || entries.length > MAX_REPOSITORIES + return [ nil, "#{label} must contain exact owner/repository names" ] unless entries.all? { |entry| entry.is_a?(String) && entry.match?(REPOSITORY_FORMAT) } + + normalized = entries.map(&:downcase) + return [ nil, "#{label} contains duplicate repositories" ] unless normalized.uniq.length == normalized.length + + [ normalized.sort, nil ] + end + + def same_record?(left, right) + return false unless left && right + + left.equal?(right) || (left.persisted? && right.persisted? && left.id == right.id) + end + end +end diff --git a/services/console/test/models/discord_github_role_policy_test.rb b/services/console/test/models/discord_github_role_policy_test.rb new file mode 100644 index 0000000000..30dc4b4ed1 --- /dev/null +++ b/services/console/test/models/discord_github_role_policy_test.rb @@ -0,0 +1,166 @@ +require "test_helper" + +class DiscordGithubRolePolicyTest < ActiveSupport::TestCase + SCOPE = [ "508-dev/centaur" ].freeze + + def build_policy_binding + admin = users(:acme_admin) + credential = BrokerCredential.create!( + foreign_id: "discord-github-#{SecureRandom.hex(4)}", + name: "Discord GitHub App", + grant: "github_app_installation", + client_id: "Iv1.0123456789abcdef", + github_installation_id: "12345678", + github_repositories: SCOPE, + created_by: admin + ) + credential.update!( + access_token: "scoped-token", + expires_at: 1.hour.from_now, + last_refresh: Time.current + ) + + secret = StaticSecret.new( + foreign_id: "discord-github-token-#{SecureRandom.hex(4)}", + name: "Discord GitHub token", + kind: CredentialProfiles::GithubToken::KIND, + labels: { "repositories" => SCOPE.join(",") }, + replace_config: CredentialProfiles::GithubToken::REPLACE_CONFIG.deep_dup, + created_by: admin + ) + secret.build_source( + source_type: "token_broker", + config: { "credential_id" => credential.foreign_id } + ) + CredentialProfiles::GithubToken::RULE_ATTRIBUTES.each do |attributes| + secret.rules.build(attributes) + end + secret.save! + + role = Role.create!( + foreign_id: "discord-policy-#{SecureRandom.hex(4)}", + name: "Discord policy", + labels: { + "centaur_discord_policy_managed" => "true", + "repository_scope" => SCOPE.join(",") + }, + created_by: admin + ) + Grant.create!(role: role, static_secret: secret, created_by: admin) + + principal = Principal.create!( + foreign_id: "discord-user-1336096360772141148-#{SecureRandom.random_number(10**18)}", + kind: "discord_user", + labels: { "centaur_discord_policy_managed" => "true" }, + created_by: admin + ) + PrincipalRole.create!(principal: principal, role: role) + + [ principal.reload, role, secret, credential ] + end + + test "rejects a custom wrapper around a GitHub App credential" do + _principal, role, _secret, credential = build_policy_binding + custom = StaticSecret.new( + foreign_id: "discord-custom-wrapper-#{SecureRandom.hex(4)}", + name: "Unreviewed wrapper", + kind: "custom", + labels: { "repositories" => SCOPE.join(",") }, + inject_config: { "header" => "X-Unreviewed", "formatter" => "{{ .Value }}" }, + created_by: users(:acme_admin) + ) + custom.build_source( + source_type: "token_broker", + config: { "credential_id" => credential.foreign_id } + ) + custom.rules.build(host: "unreviewed.example", position: 0) + custom.save! + + grant = Grant.new(role: role, static_secret: custom, created_by: users(:acme_admin)) + + assert_not grant.valid? + assert grant.errors[:base].any? { |message| message.include?("canonical github_token") } + end + + test "policy-managed Discord actors do not inherit default roles" do + principal = Principal.create!( + foreign_id: "discord-user-1336096360772141148-#{SecureRandom.random_number(10**18)}", + kind: "discord_user", + labels: { "centaur_discord_policy_managed" => "true" }, + created_by: users(:acme_admin) + ) + + assert_empty principal.reload.roles + end + + test "rejects widening a broker used by an assigned Discord policy role" do + _principal, _role, _secret, credential = build_policy_binding + credential.github_repositories = SCOPE + [ "508-dev/508-infra" ] + + assert_not credential.valid? + assert credential.errors[:base].any? { |message| message.include?("scope differs") } + end + + test "rejects adding an unreviewed role to an authorized Discord actor" do + principal, _role, _secret, _credential = build_policy_binding + assignment = PrincipalRole.new(principal: principal, role: roles(:acme_infra)) + + assert_not assignment.valid? + assert assignment.errors[:base].any? { |message| message.include?("only receive reviewed") } + end + + test "rejects direct grants to an authorized Discord actor" do + principal, _role, _secret, _credential = build_policy_binding + grant = Grant.new( + principal: principal, + static_secret: static_secrets(:acme_prod_api_key), + created_by: users(:acme_admin) + ) + + assert_not grant.valid? + assert grant.errors[:base].any? { |message| message.include?("may not receive direct grants") } + end + + test "rejects changing the wrapper declaration or policy role scope after assignment" do + _principal, role, secret, _credential = build_policy_binding + secret.labels = secret.labels.merge("repositories" => "508-dev/508-infra") + + assert_not secret.valid? + assert secret.errors[:base].any? { |message| message.include?("declaration differs") } + + role.labels = role.labels.merge("repository_scope" => "508-dev/508-infra") + + assert_not role.valid? + assert role.errors[:base].any? { |message| message.include?("scope differs") } + end + + test "proxy rendering excludes legacy widened Discord GitHub credentials" do + principal, _role, secret, credential = build_policy_binding + assert secret.source.deliverable? + assert DiscordGithubRolePolicy.static_secret_allowed_for_principal?(principal, secret) + legacy = StaticSecret.new( + foreign_id: "discord-legacy-direct-#{SecureRandom.hex(4)}", + name: "Legacy direct secret", + inject_config: { "header" => "X-Legacy", "formatter" => "{{ .Value }}" }, + created_by: users(:acme_admin) + ) + legacy.build_source(source_type: "control_plane", secret: "legacy-token") + legacy.rules.build(host: "legacy.example", position: 0) + legacy.save! + Grant.new( + principal: principal, + static_secret: legacy, + priority: Grant::DEFAULT_DIRECT_PRIORITY, + created_by: users(:acme_admin) + ).save!(validate: false) + + scoped = PrincipalSyncConfigSnapshot.config_for(principal) + assert_equal [ "scoped-token" ], scoped.fetch("secrets").map { |entry| entry.dig("source", "value") } + + credential.update_columns(github_repositories: SCOPE + [ "508-dev/508-infra" ]) + + config = PrincipalSyncConfigSnapshot.config_for(principal) + + assert_empty config.fetch("secrets") + end +end From 2c4071fd8289462e841d9b0eb0c5396022a06dbe Mon Sep 17 00:00:00 2001 From: Michael Wu Date: Wed, 2 Sep 2026 01:08:48 +0900 Subject: [PATCH 14/37] fix: preserve Discord actor credential policy --- services/console/app/models/principal.rb | 25 ++++++++++- .../services/discord_github_role_policy.rb | 3 +- .../api/v1/principals_controller_test.rb | 18 ++++++++ .../models/discord_github_role_policy_test.rb | 42 ++++++++++++++++--- 4 files changed, 79 insertions(+), 9 deletions(-) diff --git a/services/console/app/models/principal.rb b/services/console/app/models/principal.rb index ae2311aec8..f80972e59f 100644 --- a/services/console/app/models/principal.rb +++ b/services/console/app/models/principal.rb @@ -25,6 +25,7 @@ class Principal < ApplicationRecord after_commit :auto_grant_matching_oauth_credentials, on: %i[create update] after_create_commit :enqueue_slack_channel_catalog_refresh, if: :slack_channel_catalog_refreshable? after_create :assign_default_roles, if: :roles_blank_for_defaulting? + before_validation :preserve_discord_actor_policy_marker before_validation :apply_sandbox_repo_cache_label before_commit :bump_own_sync_config_cache_version, on: :update, if: :sync_config_fields_changed? @@ -32,6 +33,8 @@ class Principal < ApplicationRecord URL_SAFE_MESSAGE = "must contain only URL-safe characters (A-Z, a-z, 0-9, -, ., _, ~)" SANDBOX_REPO_CACHE_LABEL = "centaur.sandbox_repo_cache".freeze SANDBOX_REPO_CACHE_VALUES = %w[none public all].freeze + DISCORD_ACTOR_FOREIGN_ID_PREFIX = "discord-user-".freeze + DISCORD_POLICY_MANAGED_LABEL = "centaur_discord_policy_managed".freeze UNKNOWN_KIND = "unknown".freeze KINDS = %w[ unknown user console_user workflow slack_channel slack_dm discord_channel discord_user linear_issue @@ -54,6 +57,7 @@ class Principal < ApplicationRecord allow_nil: true, if: :will_save_change_to_slack_team_id? validates :slack_email, format: { with: URI::MailTo::EMAIL_REGEXP, message: "is not a valid email address" }, allow_nil: true, if: :will_save_change_to_slack_email? + validate :discord_actor_kind_is_immutable # Stand-in for an inline secret value in redacted config: operator inspection # reports that a control_plane source carries a value without revealing it. @@ -181,6 +185,13 @@ def labels_with_sandbox_capabilities ) end + # Actor IDs are created from immutable Discord guild/user IDs by api-rs. Keep + # this identity predicate independent of mutable labels so an API label update + # cannot turn an admitted actor into an ordinary grantable principal. + def discord_actor_principal? + foreign_id.to_s.start_with?(DISCORD_ACTOR_FOREIGN_ID_PREFIX) + end + def effective_slack_channel_permissions_payload @effective_slack_channel_permissions_payload ||= merged_slack_channel_permissions(effective_slack_channel_permissions) end @@ -284,7 +295,7 @@ def roles_blank_for_defaulting? # Actor-scoped Discord principals are always populated by the reviewed # policy replacement path. They must not transiently inherit a default role # before that replacement validates their exact GitHub App scope. - labels.to_h["centaur_discord_policy_managed"] != "true" && + !discord_actor_principal? && association(:roles).target.empty? && !roles.exists? end @@ -305,6 +316,18 @@ def apply_sandbox_repo_cache_label self[:labels] = labels.to_h.merge(SANDBOX_REPO_CACHE_LABEL => sandbox_repo_cache) end + def discord_actor_kind_is_immutable + return unless discord_actor_principal? + + errors.add(:kind, "must be discord_user for a Discord actor principal") unless kind == "discord_user" + end + + def preserve_discord_actor_policy_marker + return unless discord_actor_principal? + + self[:labels] = labels.to_h.merge(DISCORD_POLICY_MANAGED_LABEL => "true") + end + def supplied_key?(attributes, key) attributes.key?(key) || attributes.key?(key.to_s) end diff --git a/services/console/app/services/discord_github_role_policy.rb b/services/console/app/services/discord_github_role_policy.rb index 379e795401..96bd821e96 100644 --- a/services/console/app/services/discord_github_role_policy.rb +++ b/services/console/app/services/discord_github_role_policy.rb @@ -4,7 +4,6 @@ # mutation cannot widen credentials already assigned to a Discord actor. class DiscordGithubRolePolicy MANAGED_ROLE_LABEL = "centaur_discord_policy_managed".freeze - MANAGED_PRINCIPAL_LABEL = "centaur_discord_policy_managed".freeze REPOSITORY_SCOPE_LABEL = "repository_scope".freeze SECRET_REPOSITORIES_LABEL = "repositories".freeze TOKEN_BROKER_SOURCE = "token_broker".freeze @@ -111,7 +110,7 @@ def managed_role?(role) end def managed_principal?(principal) - principal&.labels.to_h[MANAGED_PRINCIPAL_LABEL] == "true" + principal&.discord_actor_principal? end def policy_errors(role, replacement_static_secret: nil, replacement_source: nil, diff --git a/services/console/test/controllers/api/v1/principals_controller_test.rb b/services/console/test/controllers/api/v1/principals_controller_test.rb index cc7481ebe2..2e8891e120 100644 --- a/services/console/test/controllers/api/v1/principals_controller_test.rb +++ b/services/console/test/controllers/api/v1/principals_controller_test.rb @@ -548,6 +548,24 @@ def json_body assert_not json_body.dig("data", "labels").key?("slack_channel_id") end + test "PUT preserves the Discord actor policy marker when replacing labels" do + principal = Principal.create!( + foreign_id: "discord-user-1336096360772141148-100000000000000001", + kind: "discord_user", + labels: { "centaur_discord_policy_managed" => "true" }, + created_by: users(:acme_admin) + ) + + put api_v1_principal_url(id: principal.oid), + params: { data: { labels: { "operator-note" => "reviewed" } } }.to_json, + headers: auth_headers + + assert_response :ok + assert_equal "reviewed", principal.reload.labels["operator-note"] + assert_equal "true", principal.labels["centaur_discord_policy_managed"] + assert_equal "true", json_body.dig("data", "labels", "centaur_discord_policy_managed") + end + test "PUT preserves a label named namespace" do principal = principals(:acme_channel) diff --git a/services/console/test/models/discord_github_role_policy_test.rb b/services/console/test/models/discord_github_role_policy_test.rb index 30dc4b4ed1..456c11b92a 100644 --- a/services/console/test/models/discord_github_role_policy_test.rb +++ b/services/console/test/models/discord_github_role_policy_test.rb @@ -20,7 +20,7 @@ def build_policy_binding last_refresh: Time.current ) - secret = StaticSecret.new( + github_token_wrapper = StaticSecret.new( foreign_id: "discord-github-token-#{SecureRandom.hex(4)}", name: "Discord GitHub token", kind: CredentialProfiles::GithubToken::KIND, @@ -28,14 +28,14 @@ def build_policy_binding replace_config: CredentialProfiles::GithubToken::REPLACE_CONFIG.deep_dup, created_by: admin ) - secret.build_source( + github_token_wrapper.build_source( source_type: "token_broker", config: { "credential_id" => credential.foreign_id } ) CredentialProfiles::GithubToken::RULE_ATTRIBUTES.each do |attributes| - secret.rules.build(attributes) + github_token_wrapper.rules.build(attributes) end - secret.save! + github_token_wrapper.save! role = Role.create!( foreign_id: "discord-policy-#{SecureRandom.hex(4)}", @@ -46,7 +46,7 @@ def build_policy_binding }, created_by: admin ) - Grant.create!(role: role, static_secret: secret, created_by: admin) + Grant.create!(role: role, static_secret_id: github_token_wrapper.id, created_by: admin) principal = Principal.create!( foreign_id: "discord-user-1336096360772141148-#{SecureRandom.random_number(10**18)}", @@ -56,7 +56,7 @@ def build_policy_binding ) PrincipalRole.create!(principal: principal, role: role) - [ principal.reload, role, secret, credential ] + [ principal.reload, role, github_token_wrapper, credential ] end test "rejects a custom wrapper around a GitHub App credential" do @@ -121,6 +121,36 @@ def build_policy_binding assert grant.errors[:base].any? { |message| message.include?("may not receive direct grants") } end + test "Discord actor identity preserves the managed marker across label replacement" do + principal, _role, _secret, _credential = build_policy_binding + + principal.update!(labels: { "operator-note" => "reviewed" }) + + assert_equal "true", principal.reload.labels["centaur_discord_policy_managed"] + direct_grant = Grant.new( + principal: principal, + static_secret: static_secrets(:acme_prod_api_key), + created_by: users(:acme_admin) + ) + assert_not direct_grant.valid? + assert direct_grant.errors[:base].any? { |message| message.include?("may not receive direct grants") } + end + + test "Discord actor foreign ID remains fail-closed after a legacy marker removal" do + principal, _role, _secret, _credential = build_policy_binding + principal.update_columns(labels: {}, kind: "unknown") + principal.reload + + assert_not DiscordGithubRolePolicy.static_secret_allowed_for_principal?(principal, static_secrets(:acme_prod_api_key)) + direct_grant = Grant.new( + principal: principal, + static_secret: static_secrets(:acme_prod_api_key), + created_by: users(:acme_admin) + ) + assert_not direct_grant.valid? + assert direct_grant.errors[:base].any? { |message| message.include?("may not receive direct grants") } + end + test "rejects changing the wrapper declaration or policy role scope after assignment" do _principal, role, secret, _credential = build_policy_binding secret.labels = secret.labels.merge("repositories" => "508-dev/508-infra") From 8cab2fe8d071b7e10473981ad6752b3280df7c54 Mon Sep 17 00:00:00 2001 From: Michael Wu Date: Wed, 2 Sep 2026 01:26:51 +0900 Subject: [PATCH 15/37] fix: reject noncanonical Discord GitHub secrets --- .../services/discord_github_role_policy.rb | 8 +++++++ .../models/discord_github_role_policy_test.rb | 22 +++++++++++++++++++ 2 files changed, 30 insertions(+) diff --git a/services/console/app/services/discord_github_role_policy.rb b/services/console/app/services/discord_github_role_policy.rb index 96bd821e96..62007908b7 100644 --- a/services/console/app/services/discord_github_role_policy.rb +++ b/services/console/app/services/discord_github_role_policy.rb @@ -163,6 +163,7 @@ def static_secrets_for_role(role, replacement_static_secret:, extra_static_secre def github_related_secret?(secret, replacement_static_secret, replacement_source, replacement_broker) return true if secret.kind == CredentialProfiles::GithubToken::KIND + return true if secret.rules.to_a.any? { |rule| github_host_rule?(rule) } broker = broker_for( source_for(secret, replacement_static_secret, replacement_source), @@ -171,6 +172,13 @@ def github_related_secret?(secret, replacement_static_secret, replacement_source broker&.grant == GITHUB_APP_INSTALLATION_GRANT end + def github_host_rule?(rule) + host = rule.host.to_s + CredentialProfiles::GithubToken::ALLOWED_HOSTS.any? do |github_host| + File.fnmatch?(host, github_host) + end + end + def github_secret_errors(secret, expected_scope, replacement_static_secret, replacement_source, replacement_broker) errors = [] diff --git a/services/console/test/models/discord_github_role_policy_test.rb b/services/console/test/models/discord_github_role_policy_test.rb index 456c11b92a..f11fdd44ee 100644 --- a/services/console/test/models/discord_github_role_policy_test.rb +++ b/services/console/test/models/discord_github_role_policy_test.rb @@ -82,6 +82,28 @@ def build_policy_binding assert grant.errors[:base].any? { |message| message.include?("canonical github_token") } end + test "rejects a custom static secret that targets a GitHub host" do + principal, role, _secret, _credential = build_policy_binding + custom = StaticSecret.new( + foreign_id: "discord-custom-github-pat-#{SecureRandom.hex(4)}", + name: "Unreviewed GitHub PAT", + kind: "custom", + inject_config: { "header" => "Authorization", "formatter" => "Bearer {{ .Value }}" }, + created_by: users(:acme_admin) + ) + custom.build_source(source_type: "control_plane", secret: "unreviewed-token") + custom.rules.build(host: "api.github.com", position: 0) + custom.save! + + grant = Grant.new(role: role, static_secret: custom, created_by: users(:acme_admin)) + + assert_not grant.valid? + assert grant.errors[:base].any? { |message| message.include?("canonical github_token") } + + grant.save!(validate: false) + assert_not DiscordGithubRolePolicy.static_secret_allowed_for_principal?(principal, custom) + end + test "policy-managed Discord actors do not inherit default roles" do principal = Principal.create!( foreign_id: "discord-user-1336096360772141148-#{SecureRandom.random_number(10**18)}", From 420c1674e2fdae5b2e7cbe13e71e5ea9e521eea6 Mon Sep 17 00:00:00 2001 From: Michael Wu Date: Wed, 2 Sep 2026 01:46:12 +0900 Subject: [PATCH 16/37] fix: validate Discord GitHub rule replacements --- .../centaur-api-server/src/tool_discovery.rs | 5 ++- services/console/app/models/static_secret.rb | 6 ++- .../services/discord_github_role_policy.rb | 37 ++++++++++++++----- .../api/v1/static_secrets_controller_test.rb | 35 ++++++++++++++++++ 4 files changed, 70 insertions(+), 13 deletions(-) diff --git a/services/api-rs/crates/centaur-api-server/src/tool_discovery.rs b/services/api-rs/crates/centaur-api-server/src/tool_discovery.rs index 60d6021546..9f76e5f101 100644 --- a/services/api-rs/crates/centaur-api-server/src/tool_discovery.rs +++ b/services/api-rs/crates/centaur-api-server/src/tool_discovery.rs @@ -1502,7 +1502,7 @@ fn http_rules( .map(|host| { let mut rule = BTreeMap::from([("host", yaml_string(&host))]); if !methods.is_empty() { - rule.insert("http_methods", yaml_value(methods)?); + rule.insert("methods", yaml_value(methods)?); } if !paths.is_empty() { rule.insert("paths", yaml_value(paths)?); @@ -1821,7 +1821,8 @@ secrets = [ secrets[0].rules[0]["host"].as_str(), Some("api.overlay.test") ); - assert_eq!(secrets[0].rules[0]["http_methods"][0].as_str(), Some("GET")); + assert_eq!(secrets[0].rules[0]["methods"][0].as_str(), Some("GET")); + assert!(secrets[0].rules[0].get("http_methods").is_none()); assert_eq!(secrets[0].rules[0]["paths"][0].as_str(), Some("/v1/*")); let labels = secrets[0] .extra diff --git a/services/console/app/models/static_secret.rb b/services/console/app/models/static_secret.rb index 7a56296c86..17f699c8d5 100644 --- a/services/console/app/models/static_secret.rb +++ b/services/console/app/models/static_secret.rb @@ -52,7 +52,11 @@ def apply_kind_defaults(rules: self.rules) CredentialProfiles::Registry.apply_defaults(self, rules: rules) end - attr_writer :kind_rules_for_validation + # API replacement validates a transient, complete rule set before it swaps + # the persisted associations. Policy validators need the same candidate view + # as the credential-profile validator so they cannot authorize the old rules + # and then persist a newly widened target. + attr_accessor :kind_rules_for_validation def validate_kind_rules(rules: self.rules) CredentialProfiles::Registry.validate_rules(self, rules: rules) diff --git a/services/console/app/services/discord_github_role_policy.rb b/services/console/app/services/discord_github_role_policy.rb index 62007908b7..577aa747c3 100644 --- a/services/console/app/services/discord_github_role_policy.rb +++ b/services/console/app/services/discord_github_role_policy.rb @@ -31,7 +31,11 @@ def validate_grant(grant) def validate_static_secret(secret) policy_roles_granting(secret).each do |role| - messages = policy_errors(role, replacement_static_secret: secret) + messages = policy_errors( + role, + replacement_static_secret: secret, + replacement_rules: secret.kind_rules_for_validation + ) add_errors(secret, prefix_errors(role, messages)) end end @@ -114,7 +118,7 @@ def managed_principal?(principal) end def policy_errors(role, replacement_static_secret: nil, replacement_source: nil, - replacement_broker: nil, extra_static_secret: nil) + replacement_broker: nil, extra_static_secret: nil, replacement_rules: nil) return [] unless managed_role?(role) expected_scope, scope_error = repository_scope( @@ -129,7 +133,13 @@ def policy_errors(role, replacement_static_secret: nil, replacement_source: nil, extra_static_secret: extra_static_secret ) github_secrets = secrets.select do |secret| - github_related_secret?(secret, replacement_static_secret, replacement_source, replacement_broker) + github_related_secret?( + secret, + replacement_static_secret, + replacement_source, + replacement_broker, + replacement_rules: replacement_rules + ) end return [] if github_secrets.empty? @@ -139,7 +149,8 @@ def policy_errors(role, replacement_static_secret: nil, replacement_source: nil, expected_scope, replacement_static_secret, replacement_source, - replacement_broker + replacement_broker, + replacement_rules: replacement_rules ) end if github_secrets.length != 1 @@ -161,9 +172,10 @@ def static_secrets_for_role(role, replacement_static_secret:, extra_static_secre secrets.uniq { |secret| secret.id || secret.object_id } end - def github_related_secret?(secret, replacement_static_secret, replacement_source, replacement_broker) + def github_related_secret?(secret, replacement_static_secret, replacement_source, replacement_broker, + replacement_rules:) return true if secret.kind == CredentialProfiles::GithubToken::KIND - return true if secret.rules.to_a.any? { |rule| github_host_rule?(rule) } + return true if rules_for(secret, replacement_static_secret, replacement_rules).any? { |rule| github_host_rule?(rule) } broker = broker_for( source_for(secret, replacement_static_secret, replacement_source), @@ -180,9 +192,9 @@ def github_host_rule?(rule) end def github_secret_errors(secret, expected_scope, replacement_static_secret, - replacement_source, replacement_broker) + replacement_source, replacement_broker, replacement_rules:) errors = [] - unless canonical_github_token?(secret) + unless canonical_github_token?(secret, rules: rules_for(secret, replacement_static_secret, replacement_rules)) errors << "GitHub App credential must use the canonical github_token static-secret profile" end @@ -218,17 +230,22 @@ def github_secret_errors(secret, expected_scope, replacement_static_secret, errors end - def canonical_github_token?(secret) + def canonical_github_token?(secret, rules: secret.rules.to_a) return false unless secret.kind == CredentialProfiles::GithubToken::KIND return false unless secret.inject_config.blank? && secret.replace_config == CredentialProfiles::GithubToken::REPLACE_CONFIG - rules = secret.rules.to_a rules.present? && rules.all? do |rule| CredentialProfiles::GithubToken::ALLOWED_HOSTS.include?(rule.host) && rule.cidr.blank? end end + def rules_for(secret, replacement_static_secret, replacement_rules) + return replacement_rules if replacement_rules && same_record?(secret, replacement_static_secret) + + secret.rules.to_a + end + def source_for(secret, replacement_static_secret, replacement_source) return replacement_source if replacement_source && same_record?(secret, replacement_static_secret) diff --git a/services/console/test/controllers/api/v1/static_secrets_controller_test.rb b/services/console/test/controllers/api/v1/static_secrets_controller_test.rb index aa59e232aa..91318873a1 100644 --- a/services/console/test/controllers/api/v1/static_secrets_controller_test.rb +++ b/services/console/test/controllers/api/v1/static_secrets_controller_test.rb @@ -354,6 +354,41 @@ def json_body assert_nil RequestRule.find_by(id: old_rule.id), "old rule should be deleted" end + test "PUT rejects replacing a Discord policy secret's safe rules with a GitHub target" do + ref = StaticSecret.create!( + foreign_id: "discord-policy-custom-#{SecureRandom.hex(4)}", + name: "Discord policy custom secret", + kind: "custom", + inject_config: { "header" => "X-Policy-Token", "formatter" => "{{ .Value }}" }, + created_by: users(:acme_admin), + rules: [ RequestRule.new(host: "safe.example.test", position: 0) ] + ) + role = Role.create!( + foreign_id: "discord-policy-role-#{SecureRandom.hex(4)}", + name: "Discord policy role", + labels: { + "centaur_discord_policy_managed" => "true", + "repository_scope" => "508-dev/centaur" + }, + created_by: users(:acme_admin) + ) + Grant.create!(role: role, static_secret: ref, created_by: users(:acme_admin)) + + body = { + data: { + name: ref.name, + inject_config: ref.inject_config, + rules: [ { host: "api.github.com" } ] + } + } + + put api_v1_static_secret_url(id: ref.oid), params: body.to_json, headers: auth_headers + assert_response :unprocessable_content + assert_includes json_body.dig("error", "details", "base").join(" "), + "GitHub App credential must use the canonical github_token static-secret profile" + assert_equal [ "safe.example.test" ], ref.reload.rules.map(&:host) + end + test "PUT with an unchanged document does not bump the sync config cache version" do ref = static_secrets(:github_token_inject) source = SecretSource.create!(source_type: "control_plane", secret: "same-secret", From 51fedc4fc2baf7c4d9a0fcddace9b6499cbacfd3 Mon Sep 17 00:00:00 2001 From: Michael Wu Date: Wed, 2 Sep 2026 02:04:31 +0900 Subject: [PATCH 17/37] fix: reject CIDR rules in Discord policy roles --- .../services/discord_github_role_policy.rb | 9 ++- .../api/v1/static_secrets_controller_test.rb | 67 ++++++++++--------- 2 files changed, 44 insertions(+), 32 deletions(-) diff --git a/services/console/app/services/discord_github_role_policy.rb b/services/console/app/services/discord_github_role_policy.rb index 577aa747c3..bd88147963 100644 --- a/services/console/app/services/discord_github_role_policy.rb +++ b/services/console/app/services/discord_github_role_policy.rb @@ -175,7 +175,7 @@ def static_secrets_for_role(role, replacement_static_secret:, extra_static_secre def github_related_secret?(secret, replacement_static_secret, replacement_source, replacement_broker, replacement_rules:) return true if secret.kind == CredentialProfiles::GithubToken::KIND - return true if rules_for(secret, replacement_static_secret, replacement_rules).any? { |rule| github_host_rule?(rule) } + return true if rules_for(secret, replacement_static_secret, replacement_rules).any? { |rule| github_targetable_rule?(rule) } broker = broker_for( source_for(secret, replacement_static_secret, replacement_source), @@ -191,6 +191,13 @@ def github_host_rule?(rule) end end + # GitHub's address space is not a stable authorization boundary. A CIDR + # rule can cover an address GitHub serves now or later, so reviewed Discord + # roles may only receive the canonical host-scoped GitHub App credential. + def github_targetable_rule?(rule) + rule.cidr.present? || github_host_rule?(rule) + end + def github_secret_errors(secret, expected_scope, replacement_static_secret, replacement_source, replacement_broker, replacement_rules:) errors = [] diff --git a/services/console/test/controllers/api/v1/static_secrets_controller_test.rb b/services/console/test/controllers/api/v1/static_secrets_controller_test.rb index 91318873a1..1c6161ad42 100644 --- a/services/console/test/controllers/api/v1/static_secrets_controller_test.rb +++ b/services/console/test/controllers/api/v1/static_secrets_controller_test.rb @@ -354,39 +354,44 @@ def json_body assert_nil RequestRule.find_by(id: old_rule.id), "old rule should be deleted" end - test "PUT rejects replacing a Discord policy secret's safe rules with a GitHub target" do - ref = StaticSecret.create!( - foreign_id: "discord-policy-custom-#{SecureRandom.hex(4)}", - name: "Discord policy custom secret", - kind: "custom", - inject_config: { "header" => "X-Policy-Token", "formatter" => "{{ .Value }}" }, - created_by: users(:acme_admin), - rules: [ RequestRule.new(host: "safe.example.test", position: 0) ] - ) - role = Role.create!( - foreign_id: "discord-policy-role-#{SecureRandom.hex(4)}", - name: "Discord policy role", - labels: { - "centaur_discord_policy_managed" => "true", - "repository_scope" => "508-dev/centaur" - }, - created_by: users(:acme_admin) - ) - Grant.create!(role: role, static_secret: ref, created_by: users(:acme_admin)) - - body = { - data: { - name: ref.name, - inject_config: ref.inject_config, - rules: [ { host: "api.github.com" } ] + test "PUT rejects replacing a Discord policy secret's safe rules with GitHub-addressable rules" do + [ + { host: "api.github.com" }, + { cidr: "0.0.0.0/0" } + ].each do |replacement_rule| + ref = StaticSecret.create!( + foreign_id: "discord-policy-custom-#{SecureRandom.hex(4)}", + name: "Discord policy custom secret", + kind: "custom", + inject_config: { "header" => "X-Policy-Token", "formatter" => "{{ .Value }}" }, + created_by: users(:acme_admin), + rules: [ RequestRule.new(host: "safe.example.test", position: 0) ] + ) + role = Role.create!( + foreign_id: "discord-policy-role-#{SecureRandom.hex(4)}", + name: "Discord policy role", + labels: { + "centaur_discord_policy_managed" => "true", + "repository_scope" => "508-dev/centaur" + }, + created_by: users(:acme_admin) + ) + Grant.create!(role: role, static_secret: ref, created_by: users(:acme_admin)) + + body = { + data: { + name: ref.name, + inject_config: ref.inject_config, + rules: [ replacement_rule ] + } } - } - put api_v1_static_secret_url(id: ref.oid), params: body.to_json, headers: auth_headers - assert_response :unprocessable_content - assert_includes json_body.dig("error", "details", "base").join(" "), - "GitHub App credential must use the canonical github_token static-secret profile" - assert_equal [ "safe.example.test" ], ref.reload.rules.map(&:host) + put api_v1_static_secret_url(id: ref.oid), params: body.to_json, headers: auth_headers + assert_response :unprocessable_content + assert_includes json_body.dig("error", "details", "base").join(" "), + "GitHub App credential must use the canonical github_token static-secret profile" + assert_equal [ "safe.example.test" ], ref.reload.rules.map(&:host) + end end test "PUT with an unchanged document does not bump the sync config cache version" do From c55a7fbed64e964ad574198e74e0fa565b3c5251 Mon Sep 17 00:00:00 2001 From: Michael Wu Date: Wed, 2 Sep 2026 02:25:53 +0900 Subject: [PATCH 18/37] fix: enforce Discord GitHub policy for all credentials --- .../models/principal_sync_config_snapshot.rb | 35 ++++--- services/console/app/models/request_rule.rb | 5 + .../services/discord_github_role_policy.rb | 94 ++++++++++++++++--- .../models/discord_github_role_policy_test.rb | 49 ++++++++++ 4 files changed, 155 insertions(+), 28 deletions(-) diff --git a/services/console/app/models/principal_sync_config_snapshot.rb b/services/console/app/models/principal_sync_config_snapshot.rb index f489e7e9c5..9539ef36b8 100644 --- a/services/console/app/models/principal_sync_config_snapshot.rb +++ b/services/console/app/models/principal_sync_config_snapshot.rb @@ -328,21 +328,16 @@ def self.effective_pg_dsn_secrets_for(principal) # non-deliverable winner never suppresses a credential that would otherwise # serve. def self.served_credentials_for(principal, extra_static: []) - static = principal.granted_static_secrets.select do |secret| - next false unless secret.source&.deliverable? - next true if DiscordGithubRolePolicy.static_secret_allowed_for_principal?(principal, secret) - - Rails.logger.warn do - "discord_github_policy_credential_denied principal=#{principal.oid} secret=#{secret.oid}" - end - false - end + static = filter_discord_github_policy_credentials( + principal, + principal.granted_static_secrets.select { |secret| secret.source&.deliverable? } + ) static = merge_static_credentials(static, extra_static) if extra_static.any? - gcp_auth = principal.granted_gcp_auth_secrets.to_a - gcp_id_token = principal.granted_gcp_id_token_secrets.to_a - aws_auth = principal.granted_aws_auth_secrets.to_a - hmac = principal.granted_hmac_secrets.to_a - oauth = principal.granted_oauth_token_secrets.to_a + gcp_auth = filter_discord_github_policy_credentials(principal, principal.granted_gcp_auth_secrets.to_a) + gcp_id_token = filter_discord_github_policy_credentials(principal, principal.granted_gcp_id_token_secrets.to_a) + aws_auth = filter_discord_github_policy_credentials(principal, principal.granted_aws_auth_secrets.to_a) + hmac = filter_discord_github_policy_credentials(principal, principal.granted_hmac_secrets.to_a) + oauth = filter_discord_github_policy_credentials(principal, principal.granted_oauth_token_secrets.to_a) suppressed = suppressed_conflict_credentials(static + gcp_auth + gcp_id_token + aws_auth + hmac + oauth) @@ -357,6 +352,18 @@ def self.served_credentials_for(principal, extra_static: []) end private_class_method :served_credentials_for + def self.filter_discord_github_policy_credentials(principal, credentials) + credentials.select do |credential| + next true if DiscordGithubRolePolicy.credential_allowed_for_principal?(principal, credential) + + Rails.logger.warn do + "discord_github_policy_credential_denied principal=#{principal.oid} credential_type=#{credential.class.name} credential=#{credential.oid}" + end + false + end + end + private_class_method :filter_discord_github_policy_credentials + # A secret reachable from both principals collapses to one row taking the # strongest priority (matching granted_secrets_by_priority's MAX), and the # re-sort restores the ascending-priority order iron-proxy's last-wins diff --git a/services/console/app/models/request_rule.rb b/services/console/app/models/request_rule.rb index 497ebcad97..8abfae4823 100644 --- a/services/console/app/models/request_rule.rb +++ b/services/console/app/models/request_rule.rb @@ -41,6 +41,7 @@ def to_proxy_rule validate :http_methods_are_valid validate :paths_are_valid validate :at_most_one_owner + validate :discord_github_policy_valid private @@ -54,6 +55,10 @@ def at_most_one_owner errors.add(:base, "must belong to at most one of #{OWNER_ASSOCIATIONS.join(", ")}") end + def discord_github_policy_valid + DiscordGithubRolePolicy.validate_request_rule(self) + end + def host_xor_cidr if host.present? && cidr.present? errors.add(:base, "host and cidr are mutually exclusive") diff --git a/services/console/app/services/discord_github_role_policy.rb b/services/console/app/services/discord_github_role_policy.rb index bd88147963..a1b8016bbb 100644 --- a/services/console/app/services/discord_github_role_policy.rb +++ b/services/console/app/services/discord_github_role_policy.rb @@ -23,10 +23,32 @@ def validate_grant(grant) end role = grant.role - return unless managed_role?(role) && grant.static_secret + credential = grant.grantable + return unless managed_role?(role) && credential - extra_secret = grant.new_record? ? grant.static_secret : nil - add_errors(grant, policy_errors(role, extra_static_secret: extra_secret)) + if credential.is_a?(StaticSecret) + extra_secret = grant.new_record? ? credential : nil + add_errors(grant, policy_errors(role, extra_static_secret: extra_secret)) + else + add_errors(grant, nonstatic_credential_errors(role, credential)) + end + end + + # Request rules are mutable after a credential has been granted. Enforce + # the same boundary at that mutation point so a previously harmless + # non-static credential cannot later be pointed at GitHub. Static secrets + # have their own complete-profile validation in validate_static_secret. + def validate_request_rule(rule) + credential = credential_for_rule(rule) + return unless credential && !credential.is_a?(StaticSecret) + return unless github_targetable_rule?(rule) + + policy_roles_granting(credential).each do |role| + rule.errors.add( + :base, + "Discord policy role #{role.foreign_id || role.oid}: may not grant #{credential.class.model_name.human.downcase} credentials that can target GitHub" + ) + end end def validate_static_secret(secret) @@ -79,24 +101,29 @@ def validate_principal_role(assignment) add_errors(assignment, prefix_errors(role, policy_errors(role))) end - # Used immediately before a proxy receives its static credentials. Model - # validations block normal mutations; this is a final fail-closed guard for - # legacy state or an out-of-band write that left a Discord role inconsistent. - def static_secret_allowed_for_principal?(principal, secret) + # Used immediately before a proxy receives any credential. Model validations + # block normal mutations; this is a final fail-closed guard for legacy state + # or an out-of-band write that left a Discord role inconsistent. + def credential_allowed_for_principal?(principal, credential) if managed_principal?(principal) roles = principal.roles.to_a return false unless roles.length == 1 && managed_role?(roles.first) - return false unless roles.first.grants.where(static_secret_id: secret.id).exists? + return false unless role_grants_credential?(roles.first, credential) - return policy_errors(roles.first).empty? + return role_allows_credential?(roles.first, credential) end roles = principal.roles.to_a.select do |role| - managed_role?(role) && role.grants.where(static_secret_id: secret.id).exists? + managed_role?(role) && role_grants_credential?(role, credential) end return true if roles.empty? - roles.all? { |role| policy_errors(role).empty? } + roles.all? { |role| role_allows_credential?(role, credential) } + end + + # Backwards-compatible name for callers that render static secrets. + def static_secret_allowed_for_principal?(principal, secret) + credential_allowed_for_principal?(principal, secret) end private @@ -159,6 +186,17 @@ def policy_errors(role, replacement_static_secret: nil, replacement_source: nil, errors.uniq end + def nonstatic_credential_errors(role, credential) + _scope, scope_error = repository_scope( + role.labels.to_h[REPOSITORY_SCOPE_LABEL], + "reviewed Discord role repository_scope" + ) + return [ scope_error ] if scope_error + return [] unless github_targetable_credential?(credential) + + [ "may not grant #{credential.class.model_name.human.downcase} credentials that can target GitHub" ] + end + def static_secrets_for_role(role, replacement_static_secret:, extra_static_secret:) secrets = role.grants.includes(:static_secret).filter_map(&:static_secret) if replacement_static_secret&.persisted? @@ -278,10 +316,38 @@ def references_broker?(source, broker) reference.present? && [ broker.oid, broker.foreign_id ].compact.include?(reference) end - def policy_roles_granting(secret) - return [] unless secret.persisted? + def role_allows_credential?(role, credential) + if credential.is_a?(StaticSecret) + policy_errors(role).empty? + else + nonstatic_credential_errors(role, credential).empty? + end + end + + def role_grants_credential?(role, credential) + association = grantable_association_for(credential) + association && role.grants.where(association => credential).exists? + end + + def github_targetable_credential?(credential) + credential.respond_to?(:rules) && credential.rules.to_a.any? { |rule| github_targetable_rule?(rule) } + end + + def credential_for_rule(rule) + RequestRule::OWNER_ASSOCIATIONS.filter_map { |association| rule.public_send(association) }.first + end + + def grantable_association_for(credential) + Grant::GRANTABLE_ASSOCIATIONS.find do |association| + Grant.reflect_on_association(association).klass == credential.class + end + end + + def policy_roles_granting(credential) + association = grantable_association_for(credential) + return [] unless credential.persisted? && association - Grant.where(static_secret_id: secret.id).where.not(role_id: nil).includes(:role) + Grant.where(association => credential).where.not(role_id: nil).includes(:role) .filter_map(&:role).select { |role| managed_role?(role) }.uniq(&:id) end diff --git a/services/console/test/models/discord_github_role_policy_test.rb b/services/console/test/models/discord_github_role_policy_test.rb index f11fdd44ee..388fb9871a 100644 --- a/services/console/test/models/discord_github_role_policy_test.rb +++ b/services/console/test/models/discord_github_role_policy_test.rb @@ -59,6 +59,16 @@ def build_policy_binding [ principal.reload, role, github_token_wrapper, credential ] end + def policy_test_nonstatic_credentials + [ + [ :gcp_auth_secret, gcp_auth_secrets(:acme_bigquery) ], + [ :gcp_id_token_secret, gcp_id_token_secrets(:acme_cloud_run) ], + [ :aws_auth_secret, aws_auth_secrets(:acme_cloudwatch_aws) ], + [ :oauth_token_secret, oauth_token_secrets(:acme_gmail_oauth) ], + [ :hmac_secret, hmac_secrets(:acme_webhook_hmac) ] + ] + end + test "rejects a custom wrapper around a GitHub App credential" do _principal, role, _secret, credential = build_policy_binding custom = StaticSecret.new( @@ -215,4 +225,43 @@ def build_policy_binding assert_empty config.fetch("secrets") end + + test "rejects every non-static credential type that targets GitHub" do + _principal, role, _secret, _credential = build_policy_binding + + policy_test_nonstatic_credentials.each do |association, credential| + credential.rules.first.update_columns(host: "api.github.com") + grant = Grant.new(role: role, association => credential, created_by: users(:acme_admin)) + + assert_not grant.valid?, "expected #{credential.class.name} GitHub grant to be rejected" + assert grant.errors[:base].any? { |message| message.include?("may not grant") } + end + end + + test "rejects later GitHub rule widening for every non-static credential type" do + _principal, role, _secret, _credential = build_policy_binding + + policy_test_nonstatic_credentials.each do |association, credential| + Grant.create!(role: role, association => credential, created_by: users(:acme_admin)) + rule = credential.rules.first + rule.host = "api.github.com" + + assert_not rule.valid?, "expected #{credential.class.name} GitHub rule to be rejected" + assert rule.errors[:base].any? { |message| message.include?("may not grant") } + end + end + + test "proxy rendering excludes legacy GitHub-targetable non-static credentials" do + principal, role, _secret, _credential = build_policy_binding + + policy_test_nonstatic_credentials.each do |association, credential| + Grant.create!(role: role, association => credential, created_by: users(:acme_admin)) + credential.rules.first.update_columns(host: "api.github.com") + assert_not DiscordGithubRolePolicy.credential_allowed_for_principal?(principal, credential) + end + + transforms = PrincipalSyncConfigSnapshot.config_for(principal).fetch("transforms") + denied_transform_names = %w[gcp_auth gcp_id_token aws_auth hmac_sign oauth_token] + assert_empty transforms.select { |transform| denied_transform_names.include?(transform.fetch("name")) } + end end From 5fe7b3e4d47acd25bf6dbfd9587e7ccc4160e162 Mon Sep 17 00:00:00 2001 From: Michael Wu Date: Tue, 8 Sep 2026 09:21:24 +0900 Subject: [PATCH 19/37] Keep Discord delivery claims provisional until dispatch --- services/discordbot/src/discord-ingress.ts | 32 +++++++++- services/discordbot/src/index.ts | 6 +- services/discordbot/src/server.ts | 3 + services/discordbot/src/types.ts | 2 + .../discordbot/test/discord-ingress.test.ts | 61 ++++++++++++++++++- services/discordbot/test/session-api.test.ts | 1 + 6 files changed, 100 insertions(+), 5 deletions(-) diff --git a/services/discordbot/src/discord-ingress.ts b/services/discordbot/src/discord-ingress.ts index 0f04ee7379..17dd28aa83 100644 --- a/services/discordbot/src/discord-ingress.ts +++ b/services/discordbot/src/discord-ingress.ts @@ -13,6 +13,7 @@ import { discordMentionRoutingDecision } from "./discord-mention-routing"; import type { DiscordbotOptions } from "./types"; const DEFAULT_DELIVERY_TTL_MS = 7 * 24 * 60 * 60 * 1000; +const DEFAULT_DISPATCH_CLAIM_TTL_MS = 30 * 1000; const DEFAULT_CONTINUATION_TTL_MS = 24 * 60 * 60 * 1000; const DEFAULT_MAX_EVENT_AGE_MS = 5 * 60 * 1000; const MAX_CLOCK_SKEW_MS = 60 * 1000; @@ -68,6 +69,7 @@ export type DiscordAcceptedAdmission = { channelId: string; control?: "approve" | "stop"; decision: "allow"; + dispatchStatus: "completed" | "pending"; guildId: string; messageId: string; policy: DiscordPermissionBundle; @@ -134,7 +136,7 @@ export async function admitDiscordGatewayMessage( claimed = await state.setIfNotExists( deliveryKey(event.messageId), pending, - options.ingressDeliveryTtlMs ?? DEFAULT_DELIVERY_TTL_MS, + options.ingressDispatchClaimTtlMs ?? DEFAULT_DISPATCH_CLAIM_TTL_MS, ); } catch { audit(logger, pending); @@ -157,7 +159,9 @@ export async function admitDiscordGatewayMessage( await state.set( deliveryKey(event.messageId), record, - options.ingressDeliveryTtlMs ?? DEFAULT_DELIVERY_TTL_MS, + record.decision === "allow" + ? options.ingressDispatchClaimTtlMs ?? DEFAULT_DISPATCH_CLAIM_TTL_MS + : options.ingressDeliveryTtlMs ?? DEFAULT_DELIVERY_TTL_MS, ); } catch { const persisted = await recoverPersistedAdmissionOrReleaseClaim( @@ -174,10 +178,16 @@ export async function admitDiscordGatewayMessage( return record.decision === "allow" ? record : null; } -/** Load the immutable accepted admission that the Gateway persisted. */ +/** + * Load the accepted admission and durably finalize its delivery claim. This is + * called only after the adapter has created any required thread and dispatched + * into a Chat handler, so earlier adapter failures leave a short-lived claim + * that a reconnect can safely retry instead of a seven-day false duplicate. + */ export async function acceptedDiscordAdmissionForMessage( message: Message, state: StateAdapter, + deliveryTtlMs = DEFAULT_DELIVERY_TTL_MS, ): Promise { const record = await state.get(deliveryKey(message.id)); if (!isAcceptedAdmission(record)) return null; @@ -190,6 +200,18 @@ export async function acceptedDiscordAdmissionForMessage( ) { return null; } + if (record.dispatchStatus === "pending") { + const completed: DiscordAcceptedAdmission = { + ...record, + dispatchStatus: "completed", + }; + try { + await state.set(deliveryKey(message.id), completed, deliveryTtlMs); + } catch { + return null; + } + return completed; + } return record; } @@ -396,6 +418,7 @@ function accepted( channelId: event.channelId, ...(control ? { control: control.type } : {}), decision: "allow", + dispatchStatus: "pending", guildId: event.guildId, messageId: event.messageId, policy, @@ -505,6 +528,7 @@ function sameAdmissionRecord( const acceptedRecord = record as Partial; return ( acceptedRecord.control === expected.control && + acceptedRecord.dispatchStatus === expected.dispatchStatus && acceptedRecord.proposalFingerprint === expected.proposalFingerprint && acceptedRecord.rootMessageId === expected.rootMessageId && acceptedRecord.policy?.fingerprint === expected.policy.fingerprint @@ -543,6 +567,8 @@ function isAcceptedAdmission(value: unknown): value is DiscordAcceptedAdmission return ( record.version === 1 && record.decision === "allow" && + (record.dispatchStatus === "pending" || + record.dispatchStatus === "completed") && record.reason === "accepted" && typeof record.actorId === "string" && typeof record.channelId === "string" && diff --git a/services/discordbot/src/index.ts b/services/discordbot/src/index.ts index 6872cbfc3c..e2aa57b689 100644 --- a/services/discordbot/src/index.ts +++ b/services/discordbot/src/index.ts @@ -413,7 +413,11 @@ async function discordAdmissionForHandler( state: StateAdapter, logger: Logger, ): Promise { - const accepted = await acceptedDiscordAdmissionForMessage(message, state); + const accepted = await acceptedDiscordAdmissionForMessage( + message, + state, + options.ingressDeliveryTtlMs, + ); if (accepted) return accepted; // Production events must already have been admitted by the authenticated // Discord Gateway callback before the adapter creates a thread. The Chat SDK diff --git a/services/discordbot/src/server.ts b/services/discordbot/src/server.ts index ad84461102..f2d22dab4f 100644 --- a/services/discordbot/src/server.ts +++ b/services/discordbot/src/server.ts @@ -60,6 +60,9 @@ const options: DiscordbotOptions = { discordApiUrl: optionalEnv("DISCORD_API_URL"), guildAllowlist, ingressDeliveryTtlMs: optionalNumberEnv("DISCORDBOT_INGRESS_DELIVERY_TTL_MS"), + ingressDispatchClaimTtlMs: optionalNumberEnv( + "DISCORDBOT_INGRESS_DISPATCH_CLAIM_TTL_MS", + ), ingressMaxEventAgeMs: optionalNumberEnv("DISCORDBOT_INGRESS_MAX_EVENT_AGE_MS"), idleTimeoutMs: optionalNumberEnv("SESSION_IDLE_TIMEOUT_MS"), isGatewayActive: () => gateway.isActive(), diff --git a/services/discordbot/src/types.ts b/services/discordbot/src/types.ts index 6f63fc1f6c..d746d8e520 100644 --- a/services/discordbot/src/types.ts +++ b/services/discordbot/src/types.ts @@ -125,6 +125,8 @@ export type DiscordbotOptions = { ingressMaxEventAgeMs?: number; /** Durable inbound-delivery dedup/audit retention. Default 7 days. */ ingressDeliveryTtlMs?: number; + /** Pre-dispatch claim lifetime. Default 30 seconds. */ + ingressDispatchClaimTtlMs?: number; /** Authorized root-to-follow-up lifetime. Default 24 hours. */ continuationTtlMs?: number; guildAllowlist?: readonly string[]; diff --git a/services/discordbot/test/discord-ingress.test.ts b/services/discordbot/test/discord-ingress.test.ts index 3787ba5ab7..4ca930b109 100644 --- a/services/discordbot/test/discord-ingress.test.ts +++ b/services/discordbot/test/discord-ingress.test.ts @@ -1,7 +1,8 @@ import { describe, expect, it } from "bun:test"; import { createMemoryState } from "@chat-adapter/state-memory"; -import type { Logger, StateAdapter } from "chat"; +import type { Logger, Message, StateAdapter } from "chat"; import { + acceptedDiscordAdmissionForMessage, admitDiscordGatewayMessage, type DiscordGatewayMessageEvent, } from "../src/discord-ingress"; @@ -146,6 +147,64 @@ describe("Discord Gateway admission", () => { ]); }); + it("keeps acceptance provisional until Chat dispatch and then retains it", async () => { + const { logger, state } = await harness(); + const message = event("600000000000000004"); + const configured = options({ + ingressDeliveryTtlMs: 10_000, + ingressDispatchClaimTtlMs: 1, + }); + const admitted = await admitDiscordGatewayMessage( + message, + configured, + state, + logger, + NOW, + ); + expect(admitted?.dispatchStatus).toBe("pending"); + + const dispatched = await acceptedDiscordAdmissionForMessage( + { + attachments: [], + author: { + fullName: "Test User", + isBot: false, + isMe: false, + userId: USER, + userName: "tester", + }, + id: message.messageId, + isMention: true, + raw: {}, + text: message.content, + threadId: `discord:${GUILD}:${CHANNEL}:${message.messageId}`, + metadata: { dateSent: new Date(NOW), edited: false }, + } as unknown as Message, + state, + configured.ingressDeliveryTtlMs, + ); + expect(dispatched?.dispatchStatus).toBe("completed"); + + await new Promise((resolve) => setTimeout(resolve, 5)); + expect( + await admitDiscordGatewayMessage(message, configured, state, logger, NOW), + ).toBeNull(); + }); + + it("allows a reconnect retry after an undispatched provisional claim expires", async () => { + const { logger, state } = await harness(); + const message = event("600000000000000005"); + const configured = options({ ingressDispatchClaimTtlMs: 1 }); + expect( + await admitDiscordGatewayMessage(message, configured, state, logger, NOW), + ).toEqual(expect.objectContaining({ dispatchStatus: "pending" })); + + await new Promise((resolve) => setTimeout(resolve, 5)); + expect( + await admitDiscordGatewayMessage(message, configured, state, logger, NOW), + ).toEqual(expect.objectContaining({ dispatchStatus: "pending" })); + }); + it("releases only its provisional delivery claim after transient state failures", async () => { for (const failure of ["evaluation", "final_write"] as const) { const { audits, logger, state } = await harness(); diff --git a/services/discordbot/test/session-api.test.ts b/services/discordbot/test/session-api.test.ts index 47f3a17a09..3d0e5f7eb4 100644 --- a/services/discordbot/test/session-api.test.ts +++ b/services/discordbot/test/session-api.test.ts @@ -221,6 +221,7 @@ describe("approveActionProposal", () => { channelId: "300000000000000001", control: "approve", decision: "allow", + dispatchStatus: "completed", guildId: "200000000000000001", messageId: "600000000000000001", policy: { From ff5156cfdcc173657259da62008532eb709e6b0a Mon Sep 17 00:00:00 2001 From: Michael Wu Date: Tue, 8 Sep 2026 09:28:53 +0900 Subject: [PATCH 20/37] Recover failed Discord claim finalization --- services/discordbot/src/discord-ingress.ts | 10 +++++ .../discordbot/test/discord-ingress.test.ts | 41 +++++++++++++++++++ 2 files changed, 51 insertions(+) diff --git a/services/discordbot/src/discord-ingress.ts b/services/discordbot/src/discord-ingress.ts index 17dd28aa83..1f2e73e583 100644 --- a/services/discordbot/src/discord-ingress.ts +++ b/services/discordbot/src/discord-ingress.ts @@ -208,6 +208,16 @@ export async function acceptedDiscordAdmissionForMessage( try { await state.set(deliveryKey(message.id), completed, deliveryTtlMs); } catch { + try { + const current = await state.get(deliveryKey(message.id)); + if (sameAdmissionRecord(current, completed)) return completed; + if (sameAdmissionRecord(current, record)) { + await state.delete(deliveryKey(message.id)); + } + } catch { + // The short provisional TTL remains the recovery boundary when the + // final write outcome cannot be read or released safely. + } return null; } return completed; diff --git a/services/discordbot/test/discord-ingress.test.ts b/services/discordbot/test/discord-ingress.test.ts index 4ca930b109..0d7d496c07 100644 --- a/services/discordbot/test/discord-ingress.test.ts +++ b/services/discordbot/test/discord-ingress.test.ts @@ -205,6 +205,47 @@ describe("Discord Gateway admission", () => { ).toEqual(expect.objectContaining({ dispatchStatus: "pending" })); }); + it("releases a provisional claim when dispatch finalization fails before commit", async () => { + const { logger, state } = await harness(); + let rejectCompletedWrite = true; + const flaky = new Proxy(state, { + get(target, property) { + if (property === "set") { + return async (key: string, value: unknown, ttlMs?: number) => { + if ( + rejectCompletedWrite && + key.startsWith("discordbot:ingress:delivery:") && + (value as { dispatchStatus?: string }).dispatchStatus === "completed" + ) { + rejectCompletedWrite = false; + throw new Error("write rejected before commit"); + } + return target.set(key, value, ttlMs); + }; + } + const value = Reflect.get(target, property, target); + return typeof value === "function" ? value.bind(target) : value; + }, + }) as StateAdapter; + const message = event("600000000000000006"); + expect( + await admitDiscordGatewayMessage(message, options(), flaky, logger, NOW), + ).not.toBeNull(); + expect( + await acceptedDiscordAdmissionForMessage( + { + author: { userId: USER }, + id: message.messageId, + threadId: `discord:${GUILD}:${CHANNEL}:${message.messageId}`, + } as unknown as Message, + flaky, + ), + ).toBeNull(); + expect( + await admitDiscordGatewayMessage(message, options(), flaky, logger, NOW), + ).not.toBeNull(); + }); + it("releases only its provisional delivery claim after transient state failures", async () => { for (const failure of ["evaluation", "final_write"] as const) { const { audits, logger, state } = await harness(); From 040dee1cbf2f94410f2e0b0ee3a05ed69da5b32e Mon Sep 17 00:00:00 2001 From: Michael Wu Date: Tue, 8 Sep 2026 09:48:10 +0900 Subject: [PATCH 21/37] Finalize Discord admission after durable handoff --- services/discordbot/src/discord-ingress.ts | 56 ++++++++++------- services/discordbot/src/index.ts | 63 +++++++++++++++---- .../discordbot/test/discord-ingress.test.ts | 62 ++++++++++++------ 3 files changed, 126 insertions(+), 55 deletions(-) diff --git a/services/discordbot/src/discord-ingress.ts b/services/discordbot/src/discord-ingress.ts index 1f2e73e583..23c8771df6 100644 --- a/services/discordbot/src/discord-ingress.ts +++ b/services/discordbot/src/discord-ingress.ts @@ -179,15 +179,13 @@ export async function admitDiscordGatewayMessage( } /** - * Load the accepted admission and durably finalize its delivery claim. This is - * called only after the adapter has created any required thread and dispatched - * into a Chat handler, so earlier adapter failures leave a short-lived claim - * that a reconnect can safely retry instead of a seven-day false duplicate. + * Load the accepted admission after the adapter has dispatched into Chat. The + * claim remains provisional until the handler proves a durable session handoff + * (or an intentionally terminal control/skip) and finalizes it below. */ export async function acceptedDiscordAdmissionForMessage( message: Message, state: StateAdapter, - deliveryTtlMs = DEFAULT_DELIVERY_TTL_MS, ): Promise { const record = await state.get(deliveryKey(message.id)); if (!isAcceptedAdmission(record)) return null; @@ -200,29 +198,39 @@ export async function acceptedDiscordAdmissionForMessage( ) { return null; } - if (record.dispatchStatus === "pending") { - const completed: DiscordAcceptedAdmission = { - ...record, - dispatchStatus: "completed", - }; + return record; +} + +/** Promote this exact provisional claim only after a durable handler outcome. */ +export async function completeDiscordAdmissionForMessage( + message: Message, + admission: DiscordAcceptedAdmission, + state: StateAdapter, + deliveryTtlMs = DEFAULT_DELIVERY_TTL_MS, +): Promise { + const completed: DiscordAcceptedAdmission = { + ...admission, + dispatchStatus: "completed", + }; + try { + const current = await state.get(deliveryKey(message.id)); + if (sameAdmissionRecord(current, completed)) return true; + if (!sameAdmissionRecord(current, admission)) return false; + await state.set(deliveryKey(message.id), completed, deliveryTtlMs); + return true; + } catch { try { - await state.set(deliveryKey(message.id), completed, deliveryTtlMs); - } catch { - try { - const current = await state.get(deliveryKey(message.id)); - if (sameAdmissionRecord(current, completed)) return completed; - if (sameAdmissionRecord(current, record)) { - await state.delete(deliveryKey(message.id)); - } - } catch { - // The short provisional TTL remains the recovery boundary when the - // final write outcome cannot be read or released safely. + const current = await state.get(deliveryKey(message.id)); + if (sameAdmissionRecord(current, completed)) return true; + if (sameAdmissionRecord(current, admission)) { + await state.delete(deliveryKey(message.id)); } - return null; + } catch { + // The short provisional TTL remains the recovery boundary when the + // final write outcome cannot be read or released safely. } - return completed; + return false; } - return record; } /** Build the same authenticated event shape for tests/direct Chat dispatch. */ diff --git a/services/discordbot/src/index.ts b/services/discordbot/src/index.ts index e2aa57b689..a95a922da2 100644 --- a/services/discordbot/src/index.ts +++ b/services/discordbot/src/index.ts @@ -31,6 +31,7 @@ import { import { acceptedDiscordAdmissionForMessage, admitDiscordGatewayMessage, + completeDiscordAdmissionForMessage, discordGatewayEventFromMessage, type DiscordAcceptedAdmission, } from "./discord-ingress"; @@ -322,11 +323,25 @@ export function createDiscordbot(options: DiscordbotOptions): Discordbot { ); if (!admission) return; if (admission.control === "stop") { - await stopDiscordExecution(thread, message, admission, options, logger); + if (await stopDiscordExecution(thread, message, admission, options, logger)) { + await completeDiscordAdmissionForMessage( + message, + admission, + state, + options.ingressDeliveryTtlMs, + ); + } return; } if (admission.control === "approve") { - await approveDiscordProposal(thread, message, admission, options, logger); + if (await approveDiscordProposal(thread, message, admission, options, logger)) { + await completeDiscordAdmissionForMessage( + message, + admission, + state, + options.ingressDeliveryTtlMs, + ); + } return; } await thread.subscribe(); @@ -349,11 +364,25 @@ export function createDiscordbot(options: DiscordbotOptions): Discordbot { ); if (!admission) return; if (admission.control === "stop") { - await stopDiscordExecution(thread, message, admission, options, logger); + if (await stopDiscordExecution(thread, message, admission, options, logger)) { + await completeDiscordAdmissionForMessage( + message, + admission, + state, + options.ingressDeliveryTtlMs, + ); + } return; } if (admission.control === "approve") { - await approveDiscordProposal(thread, message, admission, options, logger); + if (await approveDiscordProposal(thread, message, admission, options, logger)) { + await completeDiscordAdmissionForMessage( + message, + admission, + state, + options.ingressDeliveryTtlMs, + ); + } return; } await syncThreadMessageToSession(thread, message, { @@ -413,11 +442,7 @@ async function discordAdmissionForHandler( state: StateAdapter, logger: Logger, ): Promise { - const accepted = await acceptedDiscordAdmissionForMessage( - message, - state, - options.ingressDeliveryTtlMs, - ); + const accepted = await acceptedDiscordAdmissionForMessage(message, state); if (accepted) return accepted; // Production events must already have been admitted by the authenticated // Discord Gateway callback before the adapter creates a thread. The Chat SDK @@ -441,7 +466,7 @@ async function stopDiscordExecution( admission: DiscordAcceptedAdmission, options: DiscordbotOptions, logger: Logger, -): Promise { +): Promise { try { const outcome = await interruptSessionExecution( options, @@ -458,6 +483,7 @@ async function stopDiscordExecution( { emoji: "⏹️", messageId: message.id, threadKey: thread.id }, logger, ); + return true; } catch (error) { traceLog(options, "discordbot_stop_failed", undefined, { actor_id: admission.actorId, @@ -466,6 +492,7 @@ async function stopDiscordExecution( thread_id: thread.id, }); await thread.post("I couldn't stop that run. Check Console and try again."); + return false; } } @@ -475,7 +502,7 @@ async function approveDiscordProposal( admission: DiscordAcceptedAdmission, options: DiscordbotOptions, logger: Logger, -): Promise { +): Promise { try { const outcome = await approveActionProposal(options, admission); const state = outcome.created ? "Queued" : "Already queued"; @@ -488,6 +515,7 @@ async function approveDiscordProposal( { emoji: "✅", messageId: message.id, threadKey: thread.id }, logger, ); + return true; } catch (error) { traceLog(options, "discordbot_proposal_approval_failed", undefined, { actor_id: admission.actorId, @@ -499,6 +527,7 @@ async function approveDiscordProposal( await thread.post( "I couldn't approve that proposal. It may be expired or changed; run a fresh observation and check Console.", ); + return false; } } @@ -624,7 +653,15 @@ async function syncThreadMessageToSession( startedAtMs: traceStartedAtMs, threadId: thread.id, }; + const completeAdmission = () => + completeDiscordAdmissionForMessage( + message, + input.admission, + input.state, + input.options.ingressDeliveryTtlMs, + ); if (isDuplicateIncrementalMessage) { + await completeAdmission(); traceLog(input.options, "discordbot_forward_duplicate_skipped", trace); return; } @@ -651,6 +688,7 @@ async function syncThreadMessageToSession( { emoji: "❓", messageId: message.id, threadKey: thread.id }, logger, ); + await completeAdmission(); return; } @@ -686,6 +724,7 @@ async function syncThreadMessageToSession( { emoji: "❌", messageId: message.id, threadKey: thread.id }, logger, ); + await completeAdmission(); return; } // Discord delta: a thread created from a message keeps that starter message @@ -758,6 +797,7 @@ async function syncThreadMessageToSession( historyForwarded: latest.historyForwarded || shouldIncludeContext, lastEventId: Math.max(latest.lastEventId ?? 0, lastEventId), }); + await completeAdmission(); traceLog(input.options, "discordbot_forward_messages_committed", trace, { appended_message_count: messagesToAppend.length, forwarded_message_count: Math.min(latestMessageIds.size, 1000), @@ -810,6 +850,7 @@ async function syncThreadMessageToSession( onMessagesAppended: commitMessagesAppended, }); } + if (messagesToAppend.length === 0) await completeAdmission(); traceLog(input.options, "discordbot_forward_complete", trace); return; } diff --git a/services/discordbot/test/discord-ingress.test.ts b/services/discordbot/test/discord-ingress.test.ts index 0d7d496c07..205321b6c4 100644 --- a/services/discordbot/test/discord-ingress.test.ts +++ b/services/discordbot/test/discord-ingress.test.ts @@ -4,6 +4,7 @@ import type { Logger, Message, StateAdapter } from "chat"; import { acceptedDiscordAdmissionForMessage, admitDiscordGatewayMessage, + completeDiscordAdmissionForMessage, type DiscordGatewayMessageEvent, } from "../src/discord-ingress"; import type { @@ -163,27 +164,40 @@ describe("Discord Gateway admission", () => { ); expect(admitted?.dispatchStatus).toBe("pending"); + const chatMessage = { + attachments: [], + author: { + fullName: "Test User", + isBot: false, + isMe: false, + userId: USER, + userName: "tester", + }, + id: message.messageId, + isMention: true, + raw: {}, + text: message.content, + threadId: `discord:${GUILD}:${CHANNEL}:${message.messageId}`, + metadata: { dateSent: new Date(NOW), edited: false }, + } as unknown as Message; const dispatched = await acceptedDiscordAdmissionForMessage( - { - attachments: [], - author: { - fullName: "Test User", - isBot: false, - isMe: false, - userId: USER, - userName: "tester", - }, - id: message.messageId, - isMention: true, - raw: {}, - text: message.content, - threadId: `discord:${GUILD}:${CHANNEL}:${message.messageId}`, - metadata: { dateSent: new Date(NOW), edited: false }, - } as unknown as Message, + chatMessage, + state, + ); + expect(dispatched?.dispatchStatus).toBe("pending"); + expect( + await completeDiscordAdmissionForMessage( + chatMessage, + dispatched!, + state, + configured.ingressDeliveryTtlMs, + ), + ).toBe(true); + const completed = await acceptedDiscordAdmissionForMessage( + chatMessage, state, - configured.ingressDeliveryTtlMs, ); - expect(dispatched?.dispatchStatus).toBe("completed"); + expect(completed?.dispatchStatus).toBe("completed"); await new Promise((resolve) => setTimeout(resolve, 5)); expect( @@ -232,15 +246,23 @@ describe("Discord Gateway admission", () => { await admitDiscordGatewayMessage(message, options(), flaky, logger, NOW), ).not.toBeNull(); expect( - await acceptedDiscordAdmissionForMessage( + await completeDiscordAdmissionForMessage( { author: { userId: USER }, id: message.messageId, threadId: `discord:${GUILD}:${CHANNEL}:${message.messageId}`, } as unknown as Message, + (await acceptedDiscordAdmissionForMessage( + { + author: { userId: USER }, + id: message.messageId, + threadId: `discord:${GUILD}:${CHANNEL}:${message.messageId}`, + } as unknown as Message, + flaky, + ))!, flaky, ), - ).toBeNull(); + ).toBe(false); expect( await admitDiscordGatewayMessage(message, options(), flaky, logger, NOW), ).not.toBeNull(); From 3df0eb33a4c398973e0d38aa8b524002c04aafe5 Mon Sep 17 00:00:00 2001 From: Michael Wu Date: Tue, 8 Sep 2026 09:57:55 +0900 Subject: [PATCH 22/37] Filter hoisted credentials through Discord policy --- .../models/principal_sync_config_snapshot.rb | 9 ++++---- .../principal_sync_config_snapshot_test.rb | 23 +++++++++++++++++++ 2 files changed, 28 insertions(+), 4 deletions(-) diff --git a/services/console/app/models/principal_sync_config_snapshot.rb b/services/console/app/models/principal_sync_config_snapshot.rb index 9539ef36b8..96dc70f0f5 100644 --- a/services/console/app/models/principal_sync_config_snapshot.rb +++ b/services/console/app/models/principal_sync_config_snapshot.rb @@ -328,11 +328,12 @@ def self.effective_pg_dsn_secrets_for(principal) # non-deliverable winner never suppresses a credential that would otherwise # serve. def self.served_credentials_for(principal, extra_static: []) - static = filter_discord_github_policy_credentials( - principal, - principal.granted_static_secrets.select { |secret| secret.source&.deliverable? } - ) + static = principal.granted_static_secrets.select { |secret| secret.source&.deliverable? } static = merge_static_credentials(static, extra_static) if extra_static.any? + # Requester-hoisted wrappers are convenience credentials, not an authority + # boundary. Apply the managed Discord policy to the complete union so the + # requester cannot add a credential outside the verified actor's role. + static = filter_discord_github_policy_credentials(principal, static) gcp_auth = filter_discord_github_policy_credentials(principal, principal.granted_gcp_auth_secrets.to_a) gcp_id_token = filter_discord_github_policy_credentials(principal, principal.granted_gcp_id_token_secrets.to_a) aws_auth = filter_discord_github_policy_credentials(principal, principal.granted_aws_auth_secrets.to_a) diff --git a/services/console/test/models/principal_sync_config_snapshot_test.rb b/services/console/test/models/principal_sync_config_snapshot_test.rb index b4cc733d01..d7a6cae0d5 100644 --- a/services/console/test/models/principal_sync_config_snapshot_test.rb +++ b/services/console/test/models/principal_sync_config_snapshot_test.rb @@ -750,6 +750,29 @@ def build_hoistable_wrapper(granted_to:, host:, header: "Authorization", always_ assert_equal baseline.fetch("postgres"), config.fetch("postgres") end + test "a requester cannot hoist a wrapper outside a managed Discord actor role" do + role = Role.create!( + foreign_id: "discord-policy-#{SecureRandom.hex(4)}", + labels: { + "centaur_discord_policy_managed" => "true", + "repository_scope" => "508-dev/centaur" + }, + created_by: users(:acme_admin) + ) + actor = Principal.create!( + foreign_id: "discord-user-1336096360772141148-#{SecureRandom.random_number(10**18)}", + kind: "discord_user", + labels: { "centaur_discord_policy_managed" => "true" }, + created_by: users(:acme_admin) + ) + PrincipalRole.create!(principal: actor, role: role) + requester = build_requester + build_hoistable_wrapper(granted_to: requester, host: "github.com") + proxy = Proxy.create!(name: "discord-requester-union", principal: actor, requester_principal: requester) + + assert_empty proxy.sync_config_snapshot.fetch(:config).fetch("secrets") + end + test "a wrapper whose oauth app is not always_available does not hoist" do requester = build_requester build_hoistable_wrapper(granted_to: requester, host: "github.com", always_available: false) From 6528a45889b8652e3ef8d986669e86e318ce3e01 Mon Sep 17 00:00:00 2001 From: Michael Wu Date: Tue, 8 Sep 2026 10:11:39 +0900 Subject: [PATCH 23/37] Bind Discord capabilities to reviewed roles --- services/console/app/models/principal.rb | 12 ++++ .../services/discord_github_role_policy.rb | 29 ++++++++- .../api/v1/principal_roles_controller_test.rb | 63 +++++++++++++++++++ 3 files changed, 103 insertions(+), 1 deletion(-) diff --git a/services/console/app/models/principal.rb b/services/console/app/models/principal.rb index f80972e59f..a66c1527f4 100644 --- a/services/console/app/models/principal.rb +++ b/services/console/app/models/principal.rb @@ -157,6 +157,18 @@ def apply_default_sandbox_capabilities!(supplied = {}) # ordered state rather than interleaving into a union of privileged roles. def replace_roles_and_sandbox_policy!(roles:, **capabilities) desired_roles = Array(roles).uniq(&:id) + if discord_actor_principal? + reviewed_policy = desired_roles.one? && + DiscordGithubRolePolicy.sandbox_policy_for_role(desired_roles.first) + unless reviewed_policy && capabilities == reviewed_policy + errors.add( + :base, + "Discord actor sandbox policy must exactly match its sole reviewed role" + ) + raise ActiveRecord::RecordInvalid, self + end + capabilities = reviewed_policy + end with_lock do update!(capabilities) desired_ids = desired_roles.map(&:id) diff --git a/services/console/app/services/discord_github_role_policy.rb b/services/console/app/services/discord_github_role_policy.rb index a1b8016bbb..6830862691 100644 --- a/services/console/app/services/discord_github_role_policy.rb +++ b/services/console/app/services/discord_github_role_policy.rb @@ -3,7 +3,14 @@ # can change and again when a proxy config is rendered, so a later Console # mutation cannot widen credentials already assigned to a Discord actor. class DiscordGithubRolePolicy - MANAGED_ROLE_LABEL = "centaur_discord_policy_managed".freeze + MANAGED_ROLE_LABEL = "centaur_discord_policy_managed".freeze + SANDBOX_POLICY_LABELS = { + sandbox_repo_cache: "centaur.discord.sandbox_repo_cache", + sandbox_observability_enabled: "centaur.discord.sandbox_observability_enabled", + sandbox_sessions_read_enabled: "centaur.discord.sandbox_sessions_read_enabled", + sandbox_workflows_read_enabled: "centaur.discord.sandbox_workflows_read_enabled", + sandbox_workflows_write_enabled: "centaur.discord.sandbox_workflows_write_enabled" + }.freeze REPOSITORY_SCOPE_LABEL = "repository_scope".freeze SECRET_REPOSITORIES_LABEL = "repositories".freeze TOKEN_BROKER_SOURCE = "token_broker".freeze @@ -121,6 +128,26 @@ def credential_allowed_for_principal?(principal, credential) roles.all? { |role| role_allows_credential?(role, credential) } end + # The reviewed role is authoritative for a managed Discord actor's sandbox + # capabilities. Return nil for an incomplete or malformed declaration so + # the atomic replacement boundary can fail closed. + def sandbox_policy_for_role(role) + return nil unless managed_role?(role) + + labels = role.labels.to_h + repo_cache = labels[SANDBOX_POLICY_LABELS.fetch(:sandbox_repo_cache)] + return nil unless Principal::SANDBOX_REPO_CACHE_VALUES.include?(repo_cache) + + policy = { sandbox_repo_cache: repo_cache } + SANDBOX_POLICY_LABELS.except(:sandbox_repo_cache).each do |attribute, label| + value = labels[label] + return nil unless %w[true false].include?(value) + + policy[attribute] = value == "true" + end + policy + end + # Backwards-compatible name for callers that render static secrets. def static_secret_allowed_for_principal?(principal, secret) credential_allowed_for_principal?(principal, secret) diff --git a/services/console/test/controllers/api/v1/principal_roles_controller_test.rb b/services/console/test/controllers/api/v1/principal_roles_controller_test.rb index 172d3c5e61..c8add14907 100644 --- a/services/console/test/controllers/api/v1/principal_roles_controller_test.rb +++ b/services/console/test/controllers/api/v1/principal_roles_controller_test.rb @@ -126,6 +126,69 @@ def json_body assert_not principal.sandbox_workflows_write_enabled end + test "PUT rejects Discord actor capabilities that differ from the reviewed role" do + role = Role.create!( + foreign_id: "discord-observer-#{SecureRandom.hex(4)}", + labels: { + "centaur_discord_policy_managed" => "true", + "repository_scope" => "508-dev/centaur", + "centaur.discord.sandbox_repo_cache" => "public", + "centaur.discord.sandbox_observability_enabled" => "true", + "centaur.discord.sandbox_sessions_read_enabled" => "false", + "centaur.discord.sandbox_workflows_read_enabled" => "true", + "centaur.discord.sandbox_workflows_write_enabled" => "false" + }, + created_by: users(:acme_admin) + ) + principal = Principal.create!( + foreign_id: "discord-user-1336096360772141148-#{SecureRandom.random_number(10**18)}", + kind: "discord_user", + labels: { "centaur_discord_policy_managed" => "true" }, + created_by: users(:acme_admin) + ) + original = principal.attributes.slice( + "sandbox_repo_cache", + "sandbox_observability_enabled", + "sandbox_sessions_read_enabled", + "sandbox_workflows_read_enabled", + "sandbox_workflows_write_enabled" + ) + + put api_v1_principal_roles_url(principal_id: principal.oid), + params: { + data: { + role_ids: [ role.oid ], + sandbox_repo_cache: "public", + sandbox_observability_enabled: true, + sandbox_sessions_read_enabled: false, + sandbox_workflows_read_enabled: true, + sandbox_workflows_write_enabled: true + } + }.to_json, + headers: auth_headers + + assert_response :unprocessable_entity + assert_empty principal.reload.roles + assert_equal original, principal.attributes.slice(*original.keys) + + put api_v1_principal_roles_url(principal_id: principal.oid), + params: { + data: { + role_ids: [ role.oid ], + sandbox_repo_cache: "public", + sandbox_observability_enabled: true, + sandbox_sessions_read_enabled: false, + sandbox_workflows_read_enabled: true, + sandbox_workflows_write_enabled: false + } + }.to_json, + headers: auth_headers + + assert_response :ok + assert_equal [ role.id ], principal.reload.role_ids + assert_not principal.sandbox_workflows_write_enabled + end + test "PUT rejects partial policy without changing roles" do principal = principals(:acme_channel) previous_role_ids = principal.role_ids From b9c5626f722712dd6d942257ecdfea25a0eaa7e0 Mon Sep 17 00:00:00 2001 From: Michael Wu Date: Tue, 8 Sep 2026 10:26:40 +0900 Subject: [PATCH 24/37] Keep Discord policy and execution claims durable --- services/console/app/models/role.rb | 5 ++++ services/console/test/models/role_test.rb | 15 ++++++++++++ services/discordbot/src/index.ts | 24 +++++++++++++++---- .../discordbot/test/chat-sdk-emulate.test.ts | 6 +++++ 4 files changed, 45 insertions(+), 5 deletions(-) diff --git a/services/console/app/models/role.rb b/services/console/app/models/role.rb index 18de9b1a8f..46ac2fdc65 100644 --- a/services/console/app/models/role.rb +++ b/services/console/app/models/role.rb @@ -1,6 +1,7 @@ class Role < ApplicationRecord oid_prefix "role" + include SyncConfigCacheInvalidation include ForeignIdCollisionGuard attr_readonly :foreign_id @@ -48,6 +49,10 @@ def self.replace_default_assignments!(role_ids) private + def sync_config_affected_principals + Principal.where(id: principal_ids) + end + def labels_is_a_hash errors.add(:labels, "must be a hash") unless labels.is_a?(Hash) end diff --git a/services/console/test/models/role_test.rb b/services/console/test/models/role_test.rb index 089a39b5e3..45111b7b3f 100644 --- a/services/console/test/models/role_test.rb +++ b/services/console/test/models/role_test.rb @@ -83,6 +83,21 @@ def valid_attrs(overrides = {}) assert_raises(ActiveRecord::ReadonlyAttributeError) { role.update!(foreign_id: "other") } end + test "policy changes invalidate assigned principals' sync config snapshots" do + role = roles(:acme_infra) + assigned = role.principals.to_a + unaffected = Principal.where.not(id: assigned.map(&:id)).first! + assigned_versions = assigned.to_h { |principal| [ principal.id, principal.sync_config_cache_version ] } + unaffected_version = unaffected.sync_config_cache_version + + role.update!(labels: role.labels.merge("operator-note" => "changed")) + + assigned.each do |principal| + assert_equal assigned_versions.fetch(principal.id) + 1, principal.reload.sync_config_cache_version + end + assert_equal unaffected_version, unaffected.reload.sync_config_cache_version + end + test "destroys its grants when destroyed" do role = roles(:acme_infra) grant_ids = role.grants.pluck(:id) diff --git a/services/discordbot/src/index.ts b/services/discordbot/src/index.ts index a95a922da2..153e5d66cd 100644 --- a/services/discordbot/src/index.ts +++ b/services/discordbot/src/index.ts @@ -607,14 +607,25 @@ async function syncThreadMessageToSession( const state = (await thread.state) ?? {}; const messageIds = new Set(state.forwardedMessageIds ?? []); const executedMessageIds = new Set(state.executedMessageIds ?? []); + // A process may die after durably appending the message but before the + // idempotent execute call returns and stores its render obligation. Once the + // provisional ingress claim expires, let that exact message resume instead + // of treating the pre-execute active flag as another live execution. + const resumesUncommittedExecution = + input.mode === "execute" && + input.admission.dispatchStatus === "pending" && + messageIds.has(message.id) && + !executedMessageIds.has(message.id) && + !state.renderObligation; // Discord delta: `state.activeExecution !== true` upstream — a stale flag // (crash before the render finally cleared it) must not wedge the thread. let shouldStartExecution = input.mode === "execute" && - !hasLiveActiveExecution( - state, - input.options.activeExecutionTtlMs ?? ACTIVE_EXECUTION_TTL_MS, - ) && + (resumesUncommittedExecution || + !hasLiveActiveExecution( + state, + input.options.activeExecutionTtlMs ?? ACTIVE_EXECUTION_TTL_MS, + )) && !executedMessageIds.has(message.id); // Discord delta (no slackbotv2 analog): per-guild in-flight execution cap. // On exceed the message is demoted to append-only context and gets a 🚦. @@ -797,7 +808,9 @@ async function syncThreadMessageToSession( historyForwarded: latest.historyForwarded || shouldIncludeContext, lastEventId: Math.max(latest.lastEventId ?? 0, lastEventId), }); - await completeAdmission(); + // Append-only delivery is now durable. Execute delivery remains + // provisional until its execution and render obligation are both durable. + if (!shouldStartExecution) await completeAdmission(); traceLog(input.options, "discordbot_forward_messages_committed", trace, { appended_message_count: messagesToAppend.length, forwarded_message_count: Math.min(latestMessageIds.size, 1000), @@ -838,6 +851,7 @@ async function syncThreadMessageToSession( threadId: thread.id, trace, }); + await completeAdmission(); traceLog(input.options, "discordbot_forward_execution_committed", trace, { execution_id: execution.execution_id, executed_message_count: Math.min(latestExecutedMessageIds.size, 1000), diff --git a/services/discordbot/test/chat-sdk-emulate.test.ts b/services/discordbot/test/chat-sdk-emulate.test.ts index c21d20c6c4..de1180cbfa 100644 --- a/services/discordbot/test/chat-sdk-emulate.test.ts +++ b/services/discordbot/test/chat-sdk-emulate.test.ts @@ -642,12 +642,18 @@ describe("discordbot", () => { await waitFor(() => codexApi.executes.length === 1); await waitFor(() => hasReaction(threadId, mentionId, "PUT", "👀")); + expect( + await botState.get(`discordbot:ingress:delivery:${mentionId}`), + ).toEqual(expect.objectContaining({ dispatchStatus: "pending" })); // The event stream must not open while execute is still in flight. expect(codexApi.eventRequests).toHaveLength(0); expect(hasReaction(threadId, mentionId, "PUT", "✅")).toBe(false); releaseExecute(); await waitForSettle(threadId, mentionId); + expect( + await botState.get(`discordbot:ingress:delivery:${mentionId}`), + ).toEqual(expect.objectContaining({ dispatchStatus: "completed" })); expect(answerPostsIn(threadId).join("\n")).toContain("Executed request 1."); }); From 8dee05a01c60a529da7ed044455e96177b822a8e Mon Sep 17 00:00:00 2001 From: Michael Wu Date: Tue, 8 Sep 2026 10:36:25 +0900 Subject: [PATCH 25/37] Enforce reviewed Discord capabilities on every update --- services/console/app/models/principal.rb | 41 ++++++++++++++++--- .../console/test/models/principal_test.rb | 32 +++++++++++++++ 2 files changed, 68 insertions(+), 5 deletions(-) diff --git a/services/console/app/models/principal.rb b/services/console/app/models/principal.rb index a66c1527f4..739965a2d5 100644 --- a/services/console/app/models/principal.rb +++ b/services/console/app/models/principal.rb @@ -58,6 +58,7 @@ class Principal < ApplicationRecord validates :slack_email, format: { with: URI::MailTo::EMAIL_REGEXP, message: "is not a valid email address" }, allow_nil: true, if: :will_save_change_to_slack_email? validate :discord_actor_kind_is_immutable + validate :discord_actor_sandbox_policy_matches_reviewed_role, on: :update # Stand-in for an inline secret value in redacted config: operator inspection # reports that a control_plane source carries a value without revealing it. @@ -169,11 +170,17 @@ def replace_roles_and_sandbox_policy!(roles:, **capabilities) end capabilities = reviewed_policy end - with_lock do - update!(capabilities) - desired_ids = desired_roles.map(&:id) - principal_roles.where.not(role_id: desired_ids).destroy_all - desired_roles.each { |role| principal_roles.find_or_create_by!(role:) } + @discord_actor_reviewed_sandbox_policy = reviewed_policy if discord_actor_principal? + begin + with_lock do + update!(capabilities) + desired_ids = desired_roles.map(&:id) + principal_roles.where.not(role_id: desired_ids).destroy_all + desired_roles.each { |role| principal_roles.find_or_create_by!(role:) } + end + ensure + remove_instance_variable(:@discord_actor_reviewed_sandbox_policy) if + instance_variable_defined?(:@discord_actor_reviewed_sandbox_policy) end self.roles.reset desired_roles @@ -334,6 +341,30 @@ def discord_actor_kind_is_immutable errors.add(:kind, "must be discord_user for a Discord actor principal") unless kind == "discord_user" end + def discord_actor_sandbox_policy_matches_reviewed_role + return unless discord_actor_principal? + + reviewed_policy = @discord_actor_reviewed_sandbox_policy + unless reviewed_policy + assigned_roles = roles.to_a + reviewed_policy = assigned_roles.one? && + DiscordGithubRolePolicy.sandbox_policy_for_role(assigned_roles.first) + end + actual_policy = { + sandbox_repo_cache: sandbox_repo_cache, + sandbox_observability_enabled: sandbox_observability_enabled, + sandbox_sessions_read_enabled: sandbox_sessions_read_enabled, + sandbox_workflows_read_enabled: sandbox_workflows_read_enabled, + sandbox_workflows_write_enabled: sandbox_workflows_write_enabled + } + return if reviewed_policy && actual_policy == reviewed_policy + + errors.add( + :base, + "Discord actor sandbox policy must exactly match its sole reviewed role" + ) + end + def preserve_discord_actor_policy_marker return unless discord_actor_principal? diff --git a/services/console/test/models/principal_test.rb b/services/console/test/models/principal_test.rb index 01f87a1959..5cb6051982 100644 --- a/services/console/test/models/principal_test.rb +++ b/services/console/test/models/principal_test.rb @@ -12,6 +12,38 @@ class PrincipalTest < ActiveSupport::TestCase assert principal.valid?, principal.errors.full_messages.join(", ") end + test "managed Discord actors cannot widen sandbox capabilities through ordinary updates" do + role = Role.create!( + foreign_id: "discord-observer-#{SecureRandom.hex(4)}", + name: "Discord observer", + labels: { + "centaur_discord_policy_managed" => "true", + "repository_scope" => "508-dev/centaur", + "centaur.discord.sandbox_repo_cache" => "public", + "centaur.discord.sandbox_observability_enabled" => "true", + "centaur.discord.sandbox_sessions_read_enabled" => "false", + "centaur.discord.sandbox_workflows_read_enabled" => "true", + "centaur.discord.sandbox_workflows_write_enabled" => "false" + }, + created_by: users(:acme_admin) + ) + principal = Principal.create!(default_attrs( + foreign_id: "discord-user-1336096360772141148-#{SecureRandom.random_number(10**18)}", + kind: "discord_user", + labels: { "centaur_discord_policy_managed" => "true" } + )) + principal.replace_roles_and_sandbox_policy!( + roles: [ role ], + **DiscordGithubRolePolicy.sandbox_policy_for_role(role) + ) + + assert_raises(ActiveRecord::RecordInvalid) do + principal.update!(sandbox_workflows_write_enabled: true) + end + assert_not principal.reload.sandbox_workflows_write_enabled + assert_equal [ role.id ], principal.role_ids + end + def default_attrs(overrides = {}) { created_by: users(:acme_admin) }.merge(overrides) end From 1036338d308eb51b25050f33782f1748dcd3027d Mon Sep 17 00:00:00 2001 From: Michael Wu Date: Tue, 8 Sep 2026 10:42:10 +0900 Subject: [PATCH 26/37] Allow metadata updates for unassigned Discord actors --- services/console/app/models/principal.rb | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/services/console/app/models/principal.rb b/services/console/app/models/principal.rb index 739965a2d5..bb57de473c 100644 --- a/services/console/app/models/principal.rb +++ b/services/console/app/models/principal.rb @@ -58,7 +58,8 @@ class Principal < ApplicationRecord validates :slack_email, format: { with: URI::MailTo::EMAIL_REGEXP, message: "is not a valid email address" }, allow_nil: true, if: :will_save_change_to_slack_email? validate :discord_actor_kind_is_immutable - validate :discord_actor_sandbox_policy_matches_reviewed_role, on: :update + validate :discord_actor_sandbox_policy_matches_reviewed_role, + on: :update, if: :sandbox_capabilities_changed? # Stand-in for an inline secret value in redacted config: operator inspection # reports that a control_plane source carries a value without revealing it. @@ -365,6 +366,14 @@ def discord_actor_sandbox_policy_matches_reviewed_role ) end + def sandbox_capabilities_changed? + %w[ + sandbox_repo_cache sandbox_observability_enabled + sandbox_sessions_read_enabled sandbox_workflows_read_enabled + sandbox_workflows_write_enabled + ].any? { |field| will_save_change_to_attribute?(field) } + end + def preserve_discord_actor_policy_marker return unless discord_actor_principal? From b17024910fc44e7821cd23f39e898864ce7cdf25 Mon Sep 17 00:00:00 2001 From: Michael Wu Date: Tue, 8 Sep 2026 10:47:20 +0900 Subject: [PATCH 27/37] Normalize GitHub hosts at the policy boundary --- .../services/discord_github_role_policy.rb | 2 +- .../models/discord_github_role_policy_test.rb | 21 +++++++++++++++++++ 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/services/console/app/services/discord_github_role_policy.rb b/services/console/app/services/discord_github_role_policy.rb index 6830862691..951c836866 100644 --- a/services/console/app/services/discord_github_role_policy.rb +++ b/services/console/app/services/discord_github_role_policy.rb @@ -250,7 +250,7 @@ def github_related_secret?(secret, replacement_static_secret, replacement_source end def github_host_rule?(rule) - host = rule.host.to_s + host = rule.host.to_s.strip.downcase.delete_suffix(".") CredentialProfiles::GithubToken::ALLOWED_HOSTS.any? do |github_host| File.fnmatch?(host, github_host) end diff --git a/services/console/test/models/discord_github_role_policy_test.rb b/services/console/test/models/discord_github_role_policy_test.rb index 388fb9871a..68d798bf8d 100644 --- a/services/console/test/models/discord_github_role_policy_test.rb +++ b/services/console/test/models/discord_github_role_policy_test.rb @@ -114,6 +114,27 @@ def policy_test_nonstatic_credentials assert_not DiscordGithubRolePolicy.static_secret_allowed_for_principal?(principal, custom) end + test "rejects custom secrets targeting equivalent GitHub host spellings" do + _principal, role, _secret, _credential = build_policy_binding + + [ "API.GITHUB.COM", "api.github.com." ].each do |host| + custom = StaticSecret.new( + foreign_id: "discord-custom-host-#{SecureRandom.hex(4)}", + name: "Unreviewed GitHub PAT", + kind: "custom", + inject_config: { "header" => "Authorization", "formatter" => "Bearer {{ .Value }}" }, + created_by: users(:acme_admin) + ) + custom.build_source(source_type: "control_plane", secret: "unreviewed-token") + custom.rules.build(host:, position: 0) + custom.save! + + grant = Grant.new(role:, static_secret: custom, created_by: users(:acme_admin)) + assert_not grant.valid?, host + assert grant.errors[:base].any? { |message| message.include?("canonical github_token") }, host + end + end + test "policy-managed Discord actors do not inherit default roles" do principal = Principal.create!( foreign_id: "discord-user-1336096360772141148-#{SecureRandom.random_number(10**18)}", From a07e419f1ce2eeaa4b49468151028f91786dd417 Mon Sep 17 00:00:00 2001 From: Michael Wu Date: Tue, 8 Sep 2026 11:15:28 +0900 Subject: [PATCH 28/37] Stabilize provisional Discord admission test --- services/discordbot/test/discord-ingress.test.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/services/discordbot/test/discord-ingress.test.ts b/services/discordbot/test/discord-ingress.test.ts index 205321b6c4..f306029a2d 100644 --- a/services/discordbot/test/discord-ingress.test.ts +++ b/services/discordbot/test/discord-ingress.test.ts @@ -153,7 +153,9 @@ describe("Discord Gateway admission", () => { const message = event("600000000000000004"); const configured = options({ ingressDeliveryTtlMs: 10_000, - ingressDispatchClaimTtlMs: 1, + // Leave enough time for the provisional read/promote sequence under a + // loaded CI runner, then prove the completed record outlives that TTL. + ingressDispatchClaimTtlMs: 500, }); const admitted = await admitDiscordGatewayMessage( message, @@ -199,7 +201,7 @@ describe("Discord Gateway admission", () => { ); expect(completed?.dispatchStatus).toBe("completed"); - await new Promise((resolve) => setTimeout(resolve, 5)); + await new Promise((resolve) => setTimeout(resolve, 550)); expect( await admitDiscordGatewayMessage(message, configured, state, logger, NOW), ).toBeNull(); From c036e1b844b280d856d484fdd6bdd87c58d6672e Mon Sep 17 00:00:00 2001 From: Michael Wu Date: Tue, 8 Sep 2026 11:33:39 +0900 Subject: [PATCH 29/37] Reconcile Discord actors after role changes --- services/console/app/models/role.rb | 62 +++++++++++++++++- .../services/discord_github_role_policy.rb | 18 ++++++ .../models/discord_github_role_policy_test.rb | 63 ++++++++++++++++++- 3 files changed, 140 insertions(+), 3 deletions(-) diff --git a/services/console/app/models/role.rb b/services/console/app/models/role.rb index 46ac2fdc65..e98e36694e 100644 --- a/services/console/app/models/role.rb +++ b/services/console/app/models/role.rb @@ -1,6 +1,14 @@ class Role < ApplicationRecord oid_prefix "role" + DISCORD_REVOKED_SANDBOX_POLICY = { + sandbox_repo_cache: "none", + sandbox_observability_enabled: false, + sandbox_sessions_read_enabled: false, + sandbox_workflows_read_enabled: false, + sandbox_workflows_write_enabled: false + }.freeze + include SyncConfigCacheInvalidation include ForeignIdCollisionGuard attr_readonly :foreign_id @@ -20,6 +28,10 @@ class Role < ApplicationRecord format: { with: URL_SAFE_FORMAT, message: URL_SAFE_MESSAGE }, allow_nil: true validate :labels_is_a_hash validate :discord_github_policy_valid + before_update :reconcile_discord_actor_sandbox_policy, if: :will_save_change_to_labels? + before_destroy :revoke_discord_actor_sandbox_policy, prepend: true + after_commit :clear_discord_actor_reconciliation + after_rollback :clear_discord_actor_reconciliation def self.ensure_default_infra!(created_by:) role = find_or_initialize_by(foreign_id: "infra") @@ -50,7 +62,55 @@ def self.replace_default_assignments!(role_ids) private def sync_config_affected_principals - Principal.where(id: principal_ids) + Principal.where(id: @sync_config_affected_principal_ids || principal_ids) + end + + def reconcile_discord_actor_sandbox_policy + ids = capture_discord_actor_reconciliation_ids + return if ids.empty? + + policy = DiscordGithubRolePolicy.sandbox_policy_for_role(self) || + DISCORD_REVOKED_SANDBOX_POLICY + apply_discord_actor_sandbox_policy(ids, policy) + end + + def revoke_discord_actor_sandbox_policy + ids = capture_discord_actor_reconciliation_ids + apply_discord_actor_sandbox_policy(ids, DISCORD_REVOKED_SANDBOX_POLICY) if ids.any? + end + + def capture_discord_actor_reconciliation_ids + return @discord_actor_reconciliation_ids if + instance_variable_defined?(:@discord_actor_reconciliation_ids) + + # Query the join directly: role.principals may have been loaded before a + # newly created assignment and therefore be stale inside this transaction. + @sync_config_affected_principal_ids = PrincipalRole + .where(role_id: id) + .pluck(:principal_id) + @discord_actor_reconciliation_ids = Principal + .where(id: @sync_config_affected_principal_ids) + .where("foreign_id LIKE ?", "#{Principal::DISCORD_ACTOR_FOREIGN_ID_PREFIX}%") + .ids + end + + def apply_discord_actor_sandbox_policy(ids, policy) + Principal.where(id: ids).order(:id).lock.each do |principal| + principal.update_columns( + **policy, + labels: principal.labels.to_h.merge( + Principal::SANDBOX_REPO_CACHE_LABEL => policy.fetch(:sandbox_repo_cache) + ), + updated_at: Time.current + ) + end + end + + def clear_discord_actor_reconciliation + remove_instance_variable(:@discord_actor_reconciliation_ids) if + instance_variable_defined?(:@discord_actor_reconciliation_ids) + remove_instance_variable(:@sync_config_affected_principal_ids) if + instance_variable_defined?(:@sync_config_affected_principal_ids) end def labels_is_a_hash diff --git a/services/console/app/services/discord_github_role_policy.rb b/services/console/app/services/discord_github_role_policy.rb index 951c836866..088de9368f 100644 --- a/services/console/app/services/discord_github_role_policy.rb +++ b/services/console/app/services/discord_github_role_policy.rb @@ -21,6 +21,14 @@ class DiscordGithubRolePolicy class << self def validate_role(role) add_errors(role, policy_errors(role)) + if managed_role?(role) && + assigned_discord_principal?(role) && + sandbox_policy_for_role(role).nil? + role.errors.add( + :base, + "Assigned Discord policy roles require a complete sandbox capability declaration" + ) + end end def validate_grant(grant) @@ -155,6 +163,16 @@ def static_secret_allowed_for_principal?(principal, secret) private + def assigned_discord_principal?(role) + return false unless role.persisted? + + Principal + .joins(:principal_roles) + .where(principal_roles: { role_id: role.id }) + .where("principals.foreign_id LIKE ?", "#{Principal::DISCORD_ACTOR_FOREIGN_ID_PREFIX}%") + .exists? + end + def add_errors(record, messages) messages.uniq.each { |message| record.errors.add(:base, message) } end diff --git a/services/console/test/models/discord_github_role_policy_test.rb b/services/console/test/models/discord_github_role_policy_test.rb index 68d798bf8d..92a37554de 100644 --- a/services/console/test/models/discord_github_role_policy_test.rb +++ b/services/console/test/models/discord_github_role_policy_test.rb @@ -42,7 +42,12 @@ def build_policy_binding name: "Discord policy", labels: { "centaur_discord_policy_managed" => "true", - "repository_scope" => SCOPE.join(",") + "repository_scope" => SCOPE.join(","), + "centaur.discord.sandbox_repo_cache" => "public", + "centaur.discord.sandbox_observability_enabled" => "true", + "centaur.discord.sandbox_sessions_read_enabled" => "false", + "centaur.discord.sandbox_workflows_read_enabled" => "true", + "centaur.discord.sandbox_workflows_write_enabled" => "false" }, created_by: admin ) @@ -54,7 +59,10 @@ def build_policy_binding labels: { "centaur_discord_policy_managed" => "true" }, created_by: admin ) - PrincipalRole.create!(principal: principal, role: role) + principal.replace_roles_and_sandbox_policy!( + roles: [ role ], + **DiscordGithubRolePolicy.sandbox_policy_for_role(role) + ) [ principal.reload, role, github_token_wrapper, credential ] end @@ -217,6 +225,57 @@ def policy_test_nonstatic_credentials assert role.errors[:base].any? { |message| message.include?("scope differs") } end + test "role capability changes reconcile every assigned Discord actor" do + principal, role, _secret, _credential = build_policy_binding + previous_version = principal.sync_config_cache_version + + role.update!( + labels: role.labels.merge( + "centaur.discord.sandbox_repo_cache" => "all", + "centaur.discord.sandbox_observability_enabled" => "false", + "centaur.discord.sandbox_sessions_read_enabled" => "true", + "centaur.discord.sandbox_workflows_read_enabled" => "false", + "centaur.discord.sandbox_workflows_write_enabled" => "true" + ) + ) + + principal.reload + assert_equal "all", principal.sandbox_repo_cache + assert_not principal.sandbox_observability_enabled + assert principal.sandbox_sessions_read_enabled + assert_not principal.sandbox_workflows_read_enabled + assert principal.sandbox_workflows_write_enabled + assert_equal "all", principal.labels[Principal::SANDBOX_REPO_CACHE_LABEL] + assert_operator principal.sync_config_cache_version, :>, previous_version + end + + test "destroying an assigned Discord role revokes persisted actor capabilities" do + principal, role, _secret, _credential = build_policy_binding + previous_version = principal.sync_config_cache_version + + role.destroy! + + principal.reload + assert_empty principal.roles + assert_equal "none", principal.sandbox_repo_cache + assert_not principal.sandbox_observability_enabled + assert_not principal.sandbox_sessions_read_enabled + assert_not principal.sandbox_workflows_read_enabled + assert_not principal.sandbox_workflows_write_enabled + assert_equal "none", principal.labels[Principal::SANDBOX_REPO_CACHE_LABEL] + assert_operator principal.sync_config_cache_version, :>, previous_version + end + + test "rejects an incomplete sandbox declaration on an assigned Discord role" do + _principal, role, _secret, _credential = build_policy_binding + + role.labels = role.labels.except("centaur.discord.sandbox_workflows_write_enabled") + + assert_not role.valid? + assert_includes role.errors[:base], + "Assigned Discord policy roles require a complete sandbox capability declaration" + end + test "proxy rendering excludes legacy widened Discord GitHub credentials" do principal, _role, secret, credential = build_policy_binding assert secret.source.deliverable? From 523e1a23b3e2d8c4d14fded51be012354e97d4b5 Mon Sep 17 00:00:00 2001 From: Michael Wu Date: Tue, 8 Sep 2026 11:56:21 +0900 Subject: [PATCH 30/37] Keep Discord actor policy revocation atomic --- services/console/app/models/principal.rb | 35 +++++++++------ services/console/app/models/principal_role.rb | 17 ++++++++ services/console/app/models/role.rb | 5 +++ .../models/discord_github_role_policy_test.rb | 43 +++++++++++++++++++ 4 files changed, 86 insertions(+), 14 deletions(-) diff --git a/services/console/app/models/principal.rb b/services/console/app/models/principal.rb index bb57de473c..5b206cb76c 100644 --- a/services/console/app/models/principal.rb +++ b/services/console/app/models/principal.rb @@ -159,25 +159,32 @@ def apply_default_sandbox_capabilities!(supplied = {}) # ordered state rather than interleaving into a union of privileged roles. def replace_roles_and_sandbox_policy!(roles:, **capabilities) desired_roles = Array(roles).uniq(&:id) - if discord_actor_principal? - reviewed_policy = desired_roles.one? && - DiscordGithubRolePolicy.sandbox_policy_for_role(desired_roles.first) - unless reviewed_policy && capabilities == reviewed_policy - errors.add( - :base, - "Discord actor sandbox policy must exactly match its sole reviewed role" - ) - raise ActiveRecord::RecordInvalid, self - end - capabilities = reviewed_policy - end - @discord_actor_reviewed_sandbox_policy = reviewed_policy if discord_actor_principal? begin with_lock do - update!(capabilities) + if discord_actor_principal? + # A caller may have loaded this role before an operator changed its + # policy. Re-read it only after taking the same principal lock used + # by role reconciliation, so a stale registration cannot restore an + # older, more privileged capability tuple. + desired_ids = desired_roles.map(&:id) + desired_roles = Role.where(id: desired_ids).order(:id).to_a + reviewed_policy = desired_ids.one? && desired_roles.one? && + DiscordGithubRolePolicy.sandbox_policy_for_role(desired_roles.first) + unless reviewed_policy && capabilities == reviewed_policy + errors.add( + :base, + "Discord actor sandbox policy must exactly match its sole reviewed role" + ) + raise ActiveRecord::RecordInvalid, self + end + capabilities = reviewed_policy + @discord_actor_reviewed_sandbox_policy = reviewed_policy + end + desired_ids = desired_roles.map(&:id) principal_roles.where.not(role_id: desired_ids).destroy_all desired_roles.each { |role| principal_roles.find_or_create_by!(role:) } + update!(capabilities) end ensure remove_instance_variable(:@discord_actor_reviewed_sandbox_policy) if diff --git a/services/console/app/models/principal_role.rb b/services/console/app/models/principal_role.rb index 46013283b0..c499b63482 100644 --- a/services/console/app/models/principal_role.rb +++ b/services/console/app/models/principal_role.rb @@ -8,6 +8,7 @@ class PrincipalRole < ApplicationRecord validates :role_id, uniqueness: { scope: :principal_id, message: "is already assigned to this principal" } validate :discord_github_policy_valid + before_destroy :revoke_discord_actor_sandbox_policy, prepend: true private @@ -15,6 +16,22 @@ def sync_config_affected_principals Principal.where(id: principal_id) end + def revoke_discord_actor_sandbox_policy + actor = principal + return unless actor.discord_actor_principal? + + actor.with_lock do + actor.update_columns( + **Role::DISCORD_REVOKED_SANDBOX_POLICY, + labels: actor.labels.to_h.merge( + Principal::SANDBOX_REPO_CACHE_LABEL => + Role::DISCORD_REVOKED_SANDBOX_POLICY.fetch(:sandbox_repo_cache) + ), + updated_at: Time.current + ) + end + end + def discord_github_policy_valid DiscordGithubRolePolicy.validate_principal_role(self) end diff --git a/services/console/app/models/role.rb b/services/console/app/models/role.rb index e98e36694e..86bbf3336f 100644 --- a/services/console/app/models/role.rb +++ b/services/console/app/models/role.rb @@ -96,6 +96,11 @@ def capture_discord_actor_reconciliation_ids def apply_discord_actor_sandbox_policy(ids, policy) Principal.where(id: ids).order(:id).lock.each do |principal| + # The assignment may have been deleted while this callback waited for + # the principal lock. Never restore this role's policy to an actor that + # no longer holds the role. + next unless PrincipalRole.exists?(principal_id: principal.id, role_id: id) + principal.update_columns( **policy, labels: principal.labels.to_h.merge( diff --git a/services/console/test/models/discord_github_role_policy_test.rb b/services/console/test/models/discord_github_role_policy_test.rb index 92a37554de..3cc810dec9 100644 --- a/services/console/test/models/discord_github_role_policy_test.rb +++ b/services/console/test/models/discord_github_role_policy_test.rb @@ -249,6 +249,49 @@ def policy_test_nonstatic_credentials assert_operator principal.sync_config_cache_version, :>, previous_version end + test "stale registration cannot restore a role policy changed while it waited" do + principal, role, _secret, _credential = build_policy_binding + stale_role = Role.find(role.id) + stale_policy = DiscordGithubRolePolicy.sandbox_policy_for_role(stale_role) + + role.update!( + labels: role.labels.merge( + "centaur.discord.sandbox_repo_cache" => "none", + "centaur.discord.sandbox_observability_enabled" => "false", + "centaur.discord.sandbox_sessions_read_enabled" => "false", + "centaur.discord.sandbox_workflows_read_enabled" => "false", + "centaur.discord.sandbox_workflows_write_enabled" => "false" + ) + ) + + assert_raises ActiveRecord::RecordInvalid do + principal.replace_roles_and_sandbox_policy!(roles: [ stale_role ], **stale_policy) + end + principal.reload + assert_equal "none", principal.sandbox_repo_cache + assert_not principal.sandbox_observability_enabled + assert_not principal.sandbox_sessions_read_enabled + assert_not principal.sandbox_workflows_read_enabled + assert_not principal.sandbox_workflows_write_enabled + end + + test "deleting a Discord actor role assignment revokes persisted capabilities" do + principal, role, _secret, _credential = build_policy_binding + previous_version = principal.sync_config_cache_version + + principal.principal_roles.find_by!(role:).destroy! + + principal.reload + assert_empty principal.roles + assert_equal "none", principal.sandbox_repo_cache + assert_not principal.sandbox_observability_enabled + assert_not principal.sandbox_sessions_read_enabled + assert_not principal.sandbox_workflows_read_enabled + assert_not principal.sandbox_workflows_write_enabled + assert_equal "none", principal.labels[Principal::SANDBOX_REPO_CACHE_LABEL] + assert_operator principal.sync_config_cache_version, :>, previous_version + end + test "destroying an assigned Discord role revokes persisted actor capabilities" do principal, role, _secret, _credential = build_policy_binding previous_version = principal.sync_config_cache_version From 4a5d0a55a8a63783a7db8a320c755127c9b26ff7 Mon Sep 17 00:00:00 2001 From: Michael Wu Date: Tue, 8 Sep 2026 12:08:00 +0900 Subject: [PATCH 31/37] Lock Discord policy through actor assignment --- services/console/app/models/principal.rb | 9 ++++--- .../models/discord_github_role_policy_test.rb | 25 +++++++++++++++++++ 2 files changed, 30 insertions(+), 4 deletions(-) diff --git a/services/console/app/models/principal.rb b/services/console/app/models/principal.rb index 5b206cb76c..f5632ba3d2 100644 --- a/services/console/app/models/principal.rb +++ b/services/console/app/models/principal.rb @@ -163,11 +163,12 @@ def replace_roles_and_sandbox_policy!(roles:, **capabilities) with_lock do if discord_actor_principal? # A caller may have loaded this role before an operator changed its - # policy. Re-read it only after taking the same principal lock used - # by role reconciliation, so a stale registration cannot restore an - # older, more privileged capability tuple. + # policy. Re-read and lock it after taking the same principal lock + # used by role reconciliation. The role lock then spans assignment + # creation and capability persistence, so a first registration + # cannot race a role downgrade that saw no assignment yet. desired_ids = desired_roles.map(&:id) - desired_roles = Role.where(id: desired_ids).order(:id).to_a + desired_roles = Role.where(id: desired_ids).order(:id).lock.to_a reviewed_policy = desired_ids.one? && desired_roles.one? && DiscordGithubRolePolicy.sandbox_policy_for_role(desired_roles.first) unless reviewed_policy && capabilities == reviewed_policy diff --git a/services/console/test/models/discord_github_role_policy_test.rb b/services/console/test/models/discord_github_role_policy_test.rb index 3cc810dec9..b363168715 100644 --- a/services/console/test/models/discord_github_role_policy_test.rb +++ b/services/console/test/models/discord_github_role_policy_test.rb @@ -275,6 +275,31 @@ def policy_test_nonstatic_credentials assert_not principal.sandbox_workflows_write_enabled end + test "first registration locks its reviewed role through policy persistence" do + _assigned, role, _secret, _credential = build_policy_binding + principal = Principal.create!( + foreign_id: "discord-user-1336096360772141148-#{SecureRandom.random_number(10**18)}", + kind: "discord_user", + labels: { "centaur_discord_policy_managed" => "true" }, + created_by: users(:acme_admin) + ) + role_lock_queries = [] + callback = lambda do |_name, _start, _finish, _id, payload| + sql = payload[:sql] + role_lock_queries << sql if sql.match?(/FROM [\"]roles[\"].*FOR UPDATE/) + end + + ActiveSupport::Notifications.subscribed(callback, "sql.active_record") do + principal.replace_roles_and_sandbox_policy!( + roles: [ role ], + **DiscordGithubRolePolicy.sandbox_policy_for_role(role) + ) + end + + assert_equal 1, role_lock_queries.length + assert_equal [ role.id ], principal.reload.role_ids + end + test "deleting a Discord actor role assignment revokes persisted capabilities" do principal, role, _secret, _credential = build_policy_binding previous_version = principal.sync_config_cache_version From 73b1adb00c4aca0b15e1b6acf3c482941fb3e76e Mon Sep 17 00:00:00 2001 From: Michael Wu Date: Tue, 8 Sep 2026 12:30:06 +0900 Subject: [PATCH 32/37] Reserve workflow approval idempotency keys --- .../centaur-workflows/src/action_proposals.rs | 124 +++++++++++++++--- .../crates/centaur-workflows/src/lib.rs | 52 ++++++++ 2 files changed, 158 insertions(+), 18 deletions(-) diff --git a/services/api-rs/crates/centaur-workflows/src/action_proposals.rs b/services/api-rs/crates/centaur-workflows/src/action_proposals.rs index be4ea78b58..18089e8feb 100644 --- a/services/api-rs/crates/centaur-workflows/src/action_proposals.rs +++ b/services/api-rs/crates/centaur-workflows/src/action_proposals.rs @@ -10,7 +10,10 @@ use sha2::{Digest, Sha256}; use sqlx::Row; use time::OffsetDateTime; -use super::{CreateWorkflowRunRequest, WorkflowRuntime, WorkflowRuntimeError}; +use super::{ + CreateWorkflowRunRequest, CreateWorkflowRunResponse, WorkflowRun, WorkflowRuntime, + WorkflowRuntimeError, +}; const MIN_PROPOSAL_TTL_SECONDS: i64 = 5 * 60; const MAX_PROPOSAL_TTL_SECONDS: i64 = 30 * 24 * 60 * 60; @@ -411,30 +414,33 @@ impl WorkflowRuntime { // process fails after this point, a repeated authorized approval // resumes from this claim and the stable spawn idempotency key. tx.commit().await?; + let action_input = json!({ + "approval": { + "actor_id": &approval.actor_id, + "capability_class": &approval.capability_class, + "channel_id": &approval.channel_id, + "guild_id": &approval.guild_id, + "message_id": &approval.message_id, + "policy_fingerprint": &approval.policy_fingerprint, + "principal_role": &approval.principal_role, + "proposal_fingerprint": &fingerprint, + "repository_scope": &approval.repository_scope, + "root_message_id": &approval.root_message_id, + "thread_id": &approval.thread_id, + }, + "proposal": proposal_value, + }); let run = self - .create_run(CreateWorkflowRunRequest { + .create_approved_action_run(CreateWorkflowRunRequest { workflow_name: action_workflow.clone(), - input: json!({ - "approval": { - "actor_id": &approval.actor_id, - "capability_class": &approval.capability_class, - "channel_id": &approval.channel_id, - "guild_id": &approval.guild_id, - "message_id": &approval.message_id, - "policy_fingerprint": &approval.policy_fingerprint, - "principal_role": &approval.principal_role, - "proposal_fingerprint": &fingerprint, - "repository_scope": &approval.repository_scope, - "root_message_id": &approval.root_message_id, - "thread_id": &approval.thread_id, - }, - "proposal": proposal_value, - }), + input: action_input.clone(), idempotency_key: Some(format!("approved-proposal:{fingerprint}")), harness_type: None, max_attempts: Some(3), }) .await?; + let stored_run = self.get_run(&run.run_id).await?; + ensure_approved_run_identity(&run, &stored_run, &action_workflow, &action_input)?; let mut tx = self.inner.client.pool().begin().await?; let updated = sqlx::query( "UPDATE workflow_action_proposals SET consumed_at = NOW(), approved_by_actor_id = $2, \ @@ -494,6 +500,24 @@ impl WorkflowRuntime { } } +fn ensure_approved_run_identity( + spawned: &CreateWorkflowRunResponse, + stored: &WorkflowRun, + action_workflow: &str, + action_input: &Value, +) -> Result<(), WorkflowRuntimeError> { + if stored.task_id != spawned.task_id + || stored.run_id != spawned.run_id + || stored.workflow_name != action_workflow + || stored.input != *action_input + { + return Err(WorkflowRuntimeError::Internal( + "approved proposal idempotency key resolved to a different workflow run".to_owned(), + )); + } + Ok(()) +} + pub async fn transition_notification_state( client: &Client, request: NotificationTransitionRequest, @@ -998,6 +1022,70 @@ mod tests { } } + #[test] + fn approval_reuse_requires_the_exact_stored_workflow_identity() { + let input = json!({"approval": {"proposal_fingerprint": "sha256:test"}}); + let spawned = CreateWorkflowRunResponse { + ok: true, + run_id: "run-1".to_owned(), + task_id: "task-1".to_owned(), + status: "queued".to_owned(), + created: false, + }; + let stored = WorkflowRun { + run_id: spawned.run_id.clone(), + task_id: spawned.task_id.clone(), + workflow_name: "execute_approved_improvement".to_owned(), + status: "queued".to_owned(), + input: input.clone(), + result: None, + failure: None, + attempts: 0, + created_at: OffsetDateTime::now_utc(), + updated_at: OffsetDateTime::now_utc(), + }; + + assert!( + ensure_approved_run_identity( + &spawned, + &stored, + "execute_approved_improvement", + &input, + ) + .is_ok() + ); + for mismatched in [ + WorkflowRun { + workflow_name: "unrelated_privileged_workflow".to_owned(), + ..stored.clone() + }, + WorkflowRun { + input: json!({"attacker": true}), + ..stored.clone() + }, + WorkflowRun { + task_id: "attacker-task".to_owned(), + ..stored.clone() + }, + WorkflowRun { + run_id: "attacker-run".to_owned(), + ..stored.clone() + }, + ] { + assert!( + ensure_approved_run_identity( + &spawned, + &mismatched, + "execute_approved_improvement", + &input, + ) + .unwrap_err() + .to_string() + .contains("different workflow run") + ); + } + } + #[test] fn proposal_fingerprint_is_canonical_and_excludes_ordering_noise() { let first = proposal(); diff --git a/services/api-rs/crates/centaur-workflows/src/lib.rs b/services/api-rs/crates/centaur-workflows/src/lib.rs index c2a93846aa..53194d49df 100644 --- a/services/api-rs/crates/centaur-workflows/src/lib.rs +++ b/services/api-rs/crates/centaur-workflows/src/lib.rs @@ -50,6 +50,7 @@ pub const WORKFLOW_ETL_BACKFILL_QUEUE: &str = "centaur_workflows_etl_backfill"; pub const WORKFLOW_SCHEDULE_QUEUE: &str = "centaur_workflow_schedules"; pub const WORKFLOW_TASK: &str = "centaur.workflow"; pub const WORKFLOW_SCHEDULE_TASK: &str = "centaur.workflow.schedule_tick"; +const APPROVED_PROPOSAL_IDEMPOTENCY_PREFIX: &str = "approved-proposal:"; const PYTHON_HOST_ENV: &str = "PYTHON_WORKFLOW_HOST_PATH"; const PYTHON_HOST_INTERPRETER_ENV: &str = "PYTHON_WORKFLOW_HOST_PYTHON"; const WORKFLOW_TOOL_API_URL_ENV: &str = "WORKFLOW_TOOL_API_URL"; @@ -846,6 +847,30 @@ impl WorkflowRuntime { pub async fn create_run( &self, request: CreateWorkflowRunRequest, + ) -> Result { + ensure_unreserved_workflow_idempotency_key(request.idempotency_key.as_deref())?; + self.spawn_workflow_run(request).await + } + + async fn create_approved_action_run( + &self, + request: CreateWorkflowRunRequest, + ) -> Result { + if !request + .idempotency_key + .as_deref() + .is_some_and(|key| key.starts_with(APPROVED_PROPOSAL_IDEMPOTENCY_PREFIX)) + { + return Err(WorkflowRuntimeError::Internal( + "approved action run is missing its reserved idempotency key".to_owned(), + )); + } + self.spawn_workflow_run(request).await + } + + async fn spawn_workflow_run( + &self, + request: CreateWorkflowRunRequest, ) -> Result { let workflow_name = request.workflow_name.trim(); if workflow_name.is_empty() { @@ -3544,6 +3569,7 @@ async fn start_python_child_workflow( .map(str::trim) .filter(|key| !key.is_empty()) .map(ToOwned::to_owned); + ensure_unreserved_workflow_idempotency_key(idempotency_key.as_deref())?; let target_client = match workflow_queue_class(workflow_name) { WorkflowQueueClass::Standard => &workflow_clients.standard, WorkflowQueueClass::SlackLive => &workflow_clients.slack_live, @@ -4552,6 +4578,17 @@ fn workflow_run_from_row(row: sqlx::postgres::PgRow) -> Result, +) -> Result<(), WorkflowRuntimeError> { + if idempotency_key.is_some_and(|key| key.starts_with(APPROVED_PROPOSAL_IDEMPOTENCY_PREFIX)) { + return Err(WorkflowRuntimeError::BadRequest( + "workflow idempotency key uses a reserved approval namespace".to_owned(), + )); + } + Ok(()) +} + fn absurd_error(error: WorkflowRuntimeError) -> absurd::Error { match error { WorkflowRuntimeError::Suspend => absurd::Error::Suspend, @@ -4606,6 +4643,21 @@ mod tests { use super::*; use chrono::TimeZone; + #[test] + fn generic_workflow_runs_cannot_claim_approval_idempotency_keys() { + assert!(ensure_unreserved_workflow_idempotency_key(None).is_ok()); + assert!(ensure_unreserved_workflow_idempotency_key(Some("ordinary-run:1")).is_ok()); + assert!( + ensure_unreserved_workflow_idempotency_key(Some("approved-proposalish:1")).is_ok() + ); + assert!( + ensure_unreserved_workflow_idempotency_key(Some("approved-proposal:sha256:abc")) + .unwrap_err() + .to_string() + .contains("reserved approval namespace") + ); + } + #[test] fn python_event_names_are_collision_free() { assert_ne!( From 42665841ed7b7a44503d2aa6246a9014b56faf47 Mon Sep 17 00:00:00 2001 From: Michael Wu Date: Tue, 8 Sep 2026 12:34:01 +0900 Subject: [PATCH 33/37] Apply Rust workflow formatting --- services/api-rs/crates/centaur-workflows/src/lib.rs | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/services/api-rs/crates/centaur-workflows/src/lib.rs b/services/api-rs/crates/centaur-workflows/src/lib.rs index 53194d49df..ebcdaf6ab1 100644 --- a/services/api-rs/crates/centaur-workflows/src/lib.rs +++ b/services/api-rs/crates/centaur-workflows/src/lib.rs @@ -4647,9 +4647,7 @@ mod tests { fn generic_workflow_runs_cannot_claim_approval_idempotency_keys() { assert!(ensure_unreserved_workflow_idempotency_key(None).is_ok()); assert!(ensure_unreserved_workflow_idempotency_key(Some("ordinary-run:1")).is_ok()); - assert!( - ensure_unreserved_workflow_idempotency_key(Some("approved-proposalish:1")).is_ok() - ); + assert!(ensure_unreserved_workflow_idempotency_key(Some("approved-proposalish:1")).is_ok()); assert!( ensure_unreserved_workflow_idempotency_key(Some("approved-proposal:sha256:abc")) .unwrap_err() From d8d3903e8c6ed1d8ad04c052131ad28b1e5bad88 Mon Sep 17 00:00:00 2001 From: Michael Wu Date: Tue, 8 Sep 2026 12:37:18 +0900 Subject: [PATCH 34/37] Bump Centaur chart after parallel merges --- contrib/chart/Chart.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/contrib/chart/Chart.yaml b/contrib/chart/Chart.yaml index 41856f64c5..946b71edd3 100644 --- a/contrib/chart/Chart.yaml +++ b/contrib/chart/Chart.yaml @@ -2,7 +2,7 @@ apiVersion: v2 name: centaur description: Helm chart for the trusted Centaur control plane type: application -version: 0.1.136 +version: 0.1.137 appVersion: "0.1.0" dependencies: - name: connect From 413bbd1b6c426ea2926323f7c0276f1d5db05a38 Mon Sep 17 00:00:00 2001 From: Michael Wu Date: Tue, 8 Sep 2026 12:59:14 +0900 Subject: [PATCH 35/37] Block GitHub broker token reuse in Discord roles --- .../services/discord_github_role_policy.rb | 85 +++++++++++++++---- .../models/discord_github_role_policy_test.rb | 63 ++++++++++++++ 2 files changed, 131 insertions(+), 17 deletions(-) diff --git a/services/console/app/services/discord_github_role_policy.rb b/services/console/app/services/discord_github_role_policy.rb index 088de9368f..b97115f8b2 100644 --- a/services/console/app/services/discord_github_role_policy.rb +++ b/services/console/app/services/discord_github_role_policy.rb @@ -78,22 +78,30 @@ def validate_static_secret(secret) end def validate_secret_source(source) - secret = source.static_secret - return unless secret + credential = credential_owner_for_source(source) + return unless credential - policy_roles_granting(secret).each do |role| - messages = policy_errors( - role, - replacement_static_secret: secret, - replacement_source: source - ) + policy_roles_granting(credential).each do |role| + messages = if credential.is_a?(StaticSecret) + policy_errors( + role, + replacement_static_secret: credential, + replacement_source: source + ) + else + nonstatic_credential_errors(role, credential, replacement_source: source) + end add_errors(source, prefix_errors(role, messages)) end end def validate_broker_credential(credential) - policy_roles_referencing(credential).each do |role| - messages = policy_errors(role, replacement_broker: credential) + policy_credentials_referencing(credential).each do |role, owner| + messages = if owner.is_a?(StaticSecret) + policy_errors(role, replacement_broker: credential) + else + nonstatic_credential_errors(role, owner, replacement_broker: credential) + end add_errors(credential, prefix_errors(role, messages)) end end @@ -231,15 +239,20 @@ def policy_errors(role, replacement_static_secret: nil, replacement_source: nil, errors.uniq end - def nonstatic_credential_errors(role, credential) + def nonstatic_credential_errors(role, credential, replacement_source: nil, replacement_broker: nil) _scope, scope_error = repository_scope( role.labels.to_h[REPOSITORY_SCOPE_LABEL], "reviewed Discord role repository_scope" ) return [ scope_error ] if scope_error - return [] unless github_targetable_credential?(credential) + return [] unless github_targetable_credential?(credential) || + github_app_broker_source?( + credential, + replacement_source: replacement_source, + replacement_broker: replacement_broker + ) - [ "may not grant #{credential.class.model_name.human.downcase} credentials that can target GitHub" ] + [ "may not grant #{credential.class.model_name.human.downcase} credentials that can target or source credentials from GitHub" ] end def static_secrets_for_role(role, replacement_static_secret:, extra_static_secret:) @@ -378,6 +391,41 @@ def github_targetable_credential?(credential) credential.respond_to?(:rules) && credential.rules.to_a.any? { |rule| github_targetable_rule?(rule) } end + def github_app_broker_source?(credential, replacement_source:, replacement_broker:) + credential_sources(credential, replacement_source: replacement_source).any? do |source| + broker_for(source, replacement_broker)&.grant == GITHUB_APP_INSTALLATION_GRANT + end + end + + def credential_sources(credential, replacement_source:) + association = source_owner_association_for(credential) + return [] unless association + + sources = credential.persisted? ? SecretSource.where(association => credential).to_a : [] + return sources unless replacement_source && + same_record?(credential_owner_for_source(replacement_source), credential) + + replaced = false + sources.map! do |source| + next source unless same_record?(source, replacement_source) + + replaced = true + replacement_source + end + sources << replacement_source unless replaced + sources + end + + def credential_owner_for_source(source) + SecretSource::OWNER_ASSOCIATIONS.filter_map { |association| source.public_send(association) }.first + end + + def source_owner_association_for(credential) + SecretSource::OWNER_ASSOCIATIONS.find do |association| + SecretSource.reflect_on_association(association).klass == credential.class + end + end + def credential_for_rule(rule) RequestRule::OWNER_ASSOCIATIONS.filter_map { |association| rule.public_send(association) }.first end @@ -396,10 +444,13 @@ def policy_roles_granting(credential) .filter_map(&:role).select { |role| managed_role?(role) }.uniq(&:id) end - def policy_roles_referencing(credential) - SecretSource.referencing_broker_credential(credential).includes(static_secret: { grants: :role }) - .filter_map(&:static_secret).flat_map(&:grants).filter_map(&:role) - .select { |role| managed_role?(role) }.uniq(&:id) + def policy_credentials_referencing(credential) + SecretSource.referencing_broker_credential(credential).flat_map do |source| + owner = credential_owner_for_source(source) + next [] unless owner + + policy_roles_granting(owner).map { |role| [ role, owner ] } + end.uniq { |role, owner| [ role.id, owner.class.name, owner.id ] } end def repository_scope(value, label) diff --git a/services/console/test/models/discord_github_role_policy_test.rb b/services/console/test/models/discord_github_role_policy_test.rb index b363168715..4b3cd5b57d 100644 --- a/services/console/test/models/discord_github_role_policy_test.rb +++ b/services/console/test/models/discord_github_role_policy_test.rb @@ -77,6 +77,26 @@ def policy_test_nonstatic_credentials ] end + def build_generic_broker + BrokerCredential.create!( + foreign_id: "discord-generic-#{SecureRandom.hex(4)}", + name: "Generic OAuth broker", + token_endpoint: "https://idp.example/token", + client_id: "generic-client", + refresh_token: "seed", + created_by: users(:acme_admin) + ) + end + + def point_oauth_refresh_source_at(broker) + source = secret_sources(:oauth_gmail_refresh) + SecretSource.where(id: source.id).update_all( + source_type: "token_broker", + config: { "credential_id" => broker.foreign_id } + ) + source.reload + end + test "rejects a custom wrapper around a GitHub App credential" do _principal, role, _secret, credential = build_policy_binding custom = StaticSecret.new( @@ -386,6 +406,49 @@ def policy_test_nonstatic_credentials end end + test "rejects a non-static credential that sources a GitHub App installation token" do + principal, role, _secret, credential = build_policy_binding + oauth = oauth_token_secrets(:acme_gmail_oauth) + point_oauth_refresh_source_at(credential) + grant = Grant.new(role:, oauth_token_secret: oauth, created_by: users(:acme_admin)) + + assert_not grant.valid? + assert grant.errors[:base].any? { |message| message.include?("source credentials from GitHub") } + + grant.save!(validate: false) + assert_not DiscordGithubRolePolicy.credential_allowed_for_principal?(principal, oauth) + end + + test "rejects repointing a granted non-static credential source at a GitHub App broker" do + _principal, role, _secret, github_credential = build_policy_binding + oauth = oauth_token_secrets(:acme_gmail_oauth) + source = point_oauth_refresh_source_at(build_generic_broker) + Grant.create!(role:, oauth_token_secret: oauth, created_by: users(:acme_admin)) + + source.config = { "credential_id" => github_credential.foreign_id } + + assert_not source.valid? + assert source.errors[:base].any? { |message| message.include?("source credentials from GitHub") } + end + + test "rejects changing a broker referenced by a granted non-static credential into a GitHub App broker" do + _principal, role, _secret, _github_credential = build_policy_binding + oauth = oauth_token_secrets(:acme_gmail_oauth) + broker = build_generic_broker + point_oauth_refresh_source_at(broker) + Grant.create!(role:, oauth_token_secret: oauth, created_by: users(:acme_admin)) + + broker.assign_attributes( + grant: "github_app_installation", + client_id: "Iv1.0123456789abcdef", + github_installation_id: "12345678", + github_repositories: SCOPE + ) + + assert_not broker.valid? + assert broker.errors[:base].any? { |message| message.include?("source credentials from GitHub") } + end + test "rejects later GitHub rule widening for every non-static credential type" do _principal, role, _secret, _credential = build_policy_binding From 0ca08b8ab1196f7b99ad5801cb759d17f4cb94b1 Mon Sep 17 00:00:00 2001 From: Michael Wu Date: Tue, 8 Sep 2026 13:03:06 +0900 Subject: [PATCH 36/37] Clarify OAuth credential test fixtures --- .../test/models/discord_github_role_policy_test.rb | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/services/console/test/models/discord_github_role_policy_test.rb b/services/console/test/models/discord_github_role_policy_test.rb index 4b3cd5b57d..d4087ff7f0 100644 --- a/services/console/test/models/discord_github_role_policy_test.rb +++ b/services/console/test/models/discord_github_role_policy_test.rb @@ -408,22 +408,22 @@ def point_oauth_refresh_source_at(broker) test "rejects a non-static credential that sources a GitHub App installation token" do principal, role, _secret, credential = build_policy_binding - oauth = oauth_token_secrets(:acme_gmail_oauth) + oauth_credential = oauth_token_secrets(:acme_gmail_oauth) point_oauth_refresh_source_at(credential) - grant = Grant.new(role:, oauth_token_secret: oauth, created_by: users(:acme_admin)) + grant = Grant.new(role:, oauth_token_secret: oauth_credential, created_by: users(:acme_admin)) assert_not grant.valid? assert grant.errors[:base].any? { |message| message.include?("source credentials from GitHub") } grant.save!(validate: false) - assert_not DiscordGithubRolePolicy.credential_allowed_for_principal?(principal, oauth) + assert_not DiscordGithubRolePolicy.credential_allowed_for_principal?(principal, oauth_credential) end test "rejects repointing a granted non-static credential source at a GitHub App broker" do _principal, role, _secret, github_credential = build_policy_binding - oauth = oauth_token_secrets(:acme_gmail_oauth) + oauth_credential = oauth_token_secrets(:acme_gmail_oauth) source = point_oauth_refresh_source_at(build_generic_broker) - Grant.create!(role:, oauth_token_secret: oauth, created_by: users(:acme_admin)) + Grant.create!(role:, oauth_token_secret: oauth_credential, created_by: users(:acme_admin)) source.config = { "credential_id" => github_credential.foreign_id } @@ -433,10 +433,10 @@ def point_oauth_refresh_source_at(broker) test "rejects changing a broker referenced by a granted non-static credential into a GitHub App broker" do _principal, role, _secret, _github_credential = build_policy_binding - oauth = oauth_token_secrets(:acme_gmail_oauth) + oauth_credential = oauth_token_secrets(:acme_gmail_oauth) broker = build_generic_broker point_oauth_refresh_source_at(broker) - Grant.create!(role:, oauth_token_secret: oauth, created_by: users(:acme_admin)) + Grant.create!(role:, oauth_token_secret: oauth_credential, created_by: users(:acme_admin)) broker.assign_attributes( grant: "github_app_installation", From d5421767b6f02275cdce9b87b22b5093583edd0f Mon Sep 17 00:00:00 2001 From: Michael Wu Date: Tue, 8 Sep 2026 13:09:23 +0900 Subject: [PATCH 37/37] Avoid persisting credential objects in policy tests --- .../models/discord_github_role_policy_test.rb | 27 ++++++++++++------- 1 file changed, 18 insertions(+), 9 deletions(-) diff --git a/services/console/test/models/discord_github_role_policy_test.rb b/services/console/test/models/discord_github_role_policy_test.rb index d4087ff7f0..a19a6377f7 100644 --- a/services/console/test/models/discord_github_role_policy_test.rb +++ b/services/console/test/models/discord_github_role_policy_test.rb @@ -408,22 +408,28 @@ def point_oauth_refresh_source_at(broker) test "rejects a non-static credential that sources a GitHub App installation token" do principal, role, _secret, credential = build_policy_binding - oauth_credential = oauth_token_secrets(:acme_gmail_oauth) - point_oauth_refresh_source_at(credential) - grant = Grant.new(role:, oauth_token_secret: oauth_credential, created_by: users(:acme_admin)) + source = point_oauth_refresh_source_at(credential) + grant = Grant.new( + role:, + oauth_token_secret_id: source.oauth_token_secret_id, + created_by: users(:acme_admin) + ) assert_not grant.valid? assert grant.errors[:base].any? { |message| message.include?("source credentials from GitHub") } grant.save!(validate: false) - assert_not DiscordGithubRolePolicy.credential_allowed_for_principal?(principal, oauth_credential) + assert_not DiscordGithubRolePolicy.credential_allowed_for_principal?(principal, source.oauth_token_secret) end test "rejects repointing a granted non-static credential source at a GitHub App broker" do _principal, role, _secret, github_credential = build_policy_binding - oauth_credential = oauth_token_secrets(:acme_gmail_oauth) source = point_oauth_refresh_source_at(build_generic_broker) - Grant.create!(role:, oauth_token_secret: oauth_credential, created_by: users(:acme_admin)) + Grant.create!( + role:, + oauth_token_secret_id: source.oauth_token_secret_id, + created_by: users(:acme_admin) + ) source.config = { "credential_id" => github_credential.foreign_id } @@ -433,10 +439,13 @@ def point_oauth_refresh_source_at(broker) test "rejects changing a broker referenced by a granted non-static credential into a GitHub App broker" do _principal, role, _secret, _github_credential = build_policy_binding - oauth_credential = oauth_token_secrets(:acme_gmail_oauth) broker = build_generic_broker - point_oauth_refresh_source_at(broker) - Grant.create!(role:, oauth_token_secret: oauth_credential, created_by: users(:acme_admin)) + source = point_oauth_refresh_source_at(broker) + Grant.create!( + role:, + oauth_token_secret_id: source.oauth_token_secret_id, + created_by: users(:acme_admin) + ) broker.assign_attributes( grant: "github_app_installation",