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 diff --git a/contrib/chart/templates/apirs.yaml b/contrib/chart/templates/apirs.yaml index d482fd9ba9..053276176f 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" . -}} @@ -239,6 +245,14 @@ spec: 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: 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 2762296983..186672bdb8 100644 --- a/contrib/chart/templates/discordbot.yaml +++ b/contrib/chart/templates/discordbot.yaml @@ -1,5 +1,11 @@ {{- 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 -}} +{{- if eq (len $roleBindings) 0 -}} +{{- fail "discordbot.roleBindings must contain reviewed role policy when discordbot is enabled" -}} +{{- end -}} apiVersion: apps/v1 kind: Deployment metadata: @@ -67,11 +73,21 @@ 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 }} +{{- 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 }} + - 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..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 @@ -514,7 +524,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 +535,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.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 e6c048bd1b..981b24c1d5 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 @@ -737,7 +744,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 +756,28 @@ 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 + # 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/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..5b414ff97b 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,71 @@ 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). + */ @@ -46,9 +70,11 @@ index 0024735d2441f1d2e3c2d5ed659fa4c4265f3922..2dd6143d856269b494c64bb3f092ad70 + * 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 @@ -59,19 +85,22 @@ index 0024735d2441f1d2e3c2d5ed659fa4c4265f3922..2dd6143d856269b494c64bb3f092ad70 } /** * Discord thread ID components. -@@ -363,6 +402,11 @@ 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[]; + 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 +636,23 @@ declare class DiscordAdapter implements Adapter { * Handle a message received via the Gateway WebSocket. */ protected handleGatewayMessage(message: Message$1, isMentioned: boolean): Promise; @@ -96,7 +125,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 +152,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 +214,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 +234,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 +245,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 +256,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 +307,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,38 +368,28 @@ 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,105 @@ 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; -+ guildId: message.guildId ?? "@me", -+ channelId: message.channelId -+ }) === true; -+ if (!forwardBotMessage) { -+ this.logger.debug("Ignoring message from bot", { -+ authorId: message.author.id, -+ authorName: message.author.username, -+ isMe -+ }); -+ return; -+ } - } +- } - const isUserMentioned = message.mentions.has(client.user?.id ?? ""); + const isUserMentioned = message.mentions.has(client.user?.id ?? "", { + ignoreEveryone: true, @@ -342,8 +399,64 @@ index e4f6fe07808ab3c9858fda724c36f7aef5084502..cc6ff27fd07eb954ef333b05a4634d26 (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?.({ ++ applicationId: message.applicationId ?? void 0, ++ authorId: message.author.id, ++ guildId: message.guildId ?? "@me", ++ channelId: parentChannelId, ++ webhookId: message.webhookId ?? void 0 ++ }) === true; ++ if (!forwardBotMessage) { ++ this.logger.debug("Ignoring message from bot", { ++ authorId: message.author.id, ++ authorName: message.author.username, ++ isMe: authorIsSelf ++ }); ++ return; ++ } ++ } + 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 +486,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 +2026,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 +2201,18 @@ var DiscordAdapter = class _DiscordAdapter { })), raw: { id: message.id, @@ -387,10 +523,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 +2225,19 @@ var DiscordAdapter = class _DiscordAdapter { try { await this.chat.handleIncomingMessage(this, threadId, chatMessage); } catch (error) { @@ -410,7 +548,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 +2502,9 @@ var DiscordAdapter = class _DiscordAdapter { `Invalid Discord channel ID: ${channelId}` ); } @@ -421,7 +559,7 @@ index e4f6fe07808ab3c9858fda724c36f7aef5084502..cc6ff27fd07eb954ef333b05a4634d26 const embeds = []; const components = []; const card = extractCard(message); -@@ -2313,6 +2487,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 d9eb6f3bd4..9af587fdd6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -11,7 +11,7 @@ overrides: patchedDependencies: '@chat-adapter/discord@4.31.0': - hash: ae2b002ae710b1c1c443862742ed72d36da8a1bcab37aa8a39e572aecaaf183a + 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=ae2b002ae710b1c1c443862742ed72d36da8a1bcab37aa8a39e572aecaaf183a)(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=ae2b002ae710b1c1c443862742ed72d36da8a1bcab37aa8a39e572aecaaf183a)(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/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..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 { @@ -1421,6 +1431,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..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, @@ -38,8 +38,9 @@ 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, + normalize_exact_repository, }; use futures_util::{Stream, StreamExt}; use hmac::{Hmac, KeyInit, Mac}; @@ -271,6 +272,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 +557,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 +629,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 +755,20 @@ 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_message_metadata( + &caller, + &thread_key, + &message.role, + Some(std::mem::take(&mut message.metadata)), + )? + .unwrap_or_else(|| json!({})); + } let message_ids = state .runtime()? .append_messages(&thread_key, &request.messages) @@ -766,7 +786,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 +808,302 @@ 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_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, -) -> Option { + enforce_actor_user_id: bool, +) -> 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(), enforce_actor_user_id)?; + 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>, + enforce_actor_user_id: bool, +) -> 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 enforce_actor_user_id + && 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 { + normalize_exact_repository(value).map_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()) } async fn interrupt_session_execution( @@ -815,7 +1117,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 +1207,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, - thread_key_matches_platform, + CallerClass, principal_subject_owns_session, sanitize_session_metadata_for, + sanitize_session_metadata_for_message, thread_key_matches_platform, }; + use centaur_session_core::{MessageRole, 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 +1284,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 +1299,105 @@ 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() + ); + } + + #[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)] @@ -2907,6 +3330,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-api-server/src/tool_discovery.rs b/services/api-rs/crates/centaur-api-server/src/tool_discovery.rs index 8dd06c8d28..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 @@ -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("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,13 @@ 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]["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 .get("labels") 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/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/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 89d48ad8b2..5cc324b04c 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,27 @@ 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, +} + +/// 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 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..43df3bed45 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"; @@ -72,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, } } } @@ -113,18 +120,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 +573,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 +595,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..4575fba648 100644 --- a/services/api-rs/crates/centaur-iron-control/src/session.rs +++ b/services/api-rs/crates/centaur-iron-control/src/session.rs @@ -7,11 +7,13 @@ //! in console or ``centaur-perms`` remain sticky. The principal is derived from //! the thread key (see [`crate::derive_principal`]). +use std::collections::{BTreeMap, BTreeSet}; + 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, @@ -20,11 +22,68 @@ 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>, + discord_repository_scope: Option<&'a Value>, slack_team_id: Option<&'a str>, slack_user_email: Option<&'a str>, conversation_name: Option<&'a str>, } +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"; +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 { + repo_cache: String, + observability: bool, + sessions_read: bool, + workflows_read: bool, + workflows_write: bool, +} + +impl DiscordPrincipalCapabilities { + #[cfg(test)] + 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 { @@ -36,6 +95,11 @@ 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"), + 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 @@ -75,9 +139,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, )?; @@ -93,12 +163,26 @@ 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) .await?; } + if is_discord + && (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?; + } Ok(record) } @@ -194,6 +278,357 @@ 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. + /// 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, + 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) + .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)?; + 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 + .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" + ))); + } + self.validate_discord_role_github_scope(&role, &repository_scope) + .await?; + 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, + )?; + + 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 + .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) + } + + /// 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) { + 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 { + 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 { + 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}" + ))), + } +} + +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 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> { @@ -305,6 +740,66 @@ 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 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!( @@ -518,6 +1013,191 @@ 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 { + direct_grant: false, + github_secret_kind: GITHUB_TOKEN_KIND, + github_repositories: r#"["508-dev/centaur"]"#, + 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"); + 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 + .iter() + .enumerate() + .filter_map(|(index, request)| { + (request + == "PUT /api/v1/principals/discord-user-200000000000000001-100000000000000001") + .then_some(index) + }) + .collect::>(); + assert_eq!( + principal_updates.len(), + 1, + "identity upsert remains separate from the atomic policy replacement" + ); + let replace = requests + .iter() + .position(|request| request == "PUT /api/v1/principals/prn_discord/roles") + .expect("roles and capabilities are replaced atomically"); + assert!( + 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(); + } + + #[tokio::test] + 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, + }) + .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 { + direct_grant: false, + github_secret_kind: GITHUB_TOKEN_KIND, + github_repositories: r#"["508-dev/centaur"]"#, + 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" + || request == "PUT /api/v1/principals/prn_discord/roles" + }), + "an unreviewed role blocks before any assignment mutation" + ); + 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_secret_kind: GITHUB_TOKEN_KIND, + 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(); + } + } + + #[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) @@ -832,6 +1512,111 @@ mod tests { ); } + fn discord_policy_metadata() -> Value { + json!({ + "discord_actor_user_id": "100000000000000001", + "discord_policy_role_foreign_ids": ["discord-observer"], + "discord_repository_scope": ["508-dev/centaur"] + }) + } + + #[derive(Clone, Copy)] + struct DiscordPolicyStub { + direct_grant: bool, + github_secret_kind: &'static str, + github_repositories: &'static str, + 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","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 { + "{}" + }; + 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 role_grants = r#"{"data":[{"id":"grant_github","role_id":"role_observer","static_secret_id":"ssr_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 + ); + 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/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), + ("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()) + } + _ => ( + "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-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..5d22ead44b 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,39 @@ 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] +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] diff --git a/services/api-rs/crates/centaur-perms/src/tools.rs b/services/api-rs/crates/centaur-perms/src/tools.rs index 33073d34ff..e2ad52fc29 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,30 @@ 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() + .map(str::trim) + .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-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..6a14b6a5e5 --- /dev/null +++ b/services/api-rs/crates/centaur-session-sqlx/migrations/0054_workflow_action_proposals.sql @@ -0,0 +1,77 @@ +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_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}$'), + 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; +revoke all on workflow_semantic_notification_states from public; 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/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..18089e8feb --- /dev/null +++ b/services/api-rs/crates/centaur-workflows/src/action_proposals.rs @@ -0,0 +1,1326 @@ +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, CreateWorkflowRunResponse, WorkflowRun, 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"; +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)] +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, +} + +#[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)?; + self.action_workflow = bounded_identifier("action_workflow", &self.action_workflow, 128)?; + self.repository = normalize_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 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); + 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(&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")?; + 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() \ + AND NOT EXISTS (SELECT 1 FROM workflow_action_proposal_approval_claims claim \ + WHERE claim.fingerprint = workflow_action_proposals.fingerprint)", + ) + .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 { + let fingerprint = validate_approval_request(fingerprint, &request)?; + request.repository_scope = request + .repository_scope + .iter() + .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, observer_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 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())? + .normalize_and_fingerprint()?; + if computed_fingerprint != fingerprint { + return Err(WorkflowRuntimeError::Internal( + "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(), + )); + } + 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 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 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_approved_action_run(CreateWorkflowRunRequest { + workflow_name: action_workflow.clone(), + 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, \ + 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(&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? + .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, + &action_workflow, + run.task_id, + run.run_id, + run.created, + )) + } +} + +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, + 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?; + // 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 \ + WHERE scope = $1 FOR UPDATE", + ) + .bind(&scope) + .fetch_optional(&mut *tx) + .await?; + 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_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()", + ) + .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, + }) +} + +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, +) -> Result { + sqlx::query( + "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) + .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 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 { + "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 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, +) -> Result { + 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 { + 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()), + ("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 = normalize_exact_repository(repository)?; + if !repositories.insert(repository) { + return Err(WorkflowRuntimeError::BadRequest( + "approval repository_scope must contain unique repositories".to_owned(), + )); + } + } + Ok(fingerprint) +} + +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()) +} + +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(); + let repository = parts.next().unwrap_or_default(); + let valid = !owner.is_empty() + && !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 { + return Err(WorkflowRuntimeError::BadRequest( + "proposal repository must be an exact owner/repository name".to_owned(), + )); + } + 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) + || !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 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(); + 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); + assert_eq!( + first_fingerprint, + "sha256:10dee99e59991ad9b3c2caf55898a1647d627e03469da1ac052b67aaa8360659" + ); + } + + #[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") + ); + + for repository in ["./repo", "508-dev/.", "508-dev/.."] { + assert!(normalize_exact_repository(repository).is_err()); + } + } + + #[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 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)); + 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_eq!( + validate_approval_request_with_roles( + &fingerprint.to_ascii_uppercase(), + &request, + &allowed_roles, + ) + .unwrap(), + fingerprint + ); + + 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()); + } + + #[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 c995b920b1..ebcdaf6ab1 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, normalize_exact_repository, +}; + 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"; @@ -43,9 +50,12 @@ 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"; +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; @@ -321,6 +331,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? } @@ -832,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() { @@ -3420,6 +3459,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 +3519,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 { @@ -3476,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, @@ -4124,6 +4218,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, @@ -4447,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, @@ -4501,6 +4643,19 @@ 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!( 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/api/v1/principal_roles_controller.rb b/services/console/app/controllers/api/v1/principal_roles_controller.rb index 78153c5bb4..89dab5eb89 100644 --- a/services/console/app/controllers/api/v1/principal_roles_controller.rb +++ b/services/console/app/controllers/api/v1/principal_roles_controller.rb @@ -3,8 +3,19 @@ module V1 # Manages role assignments for a principal: # GET /api/v1/principals/:principal_id/roles # POST /api/v1/principals/:principal_id/roles (body: data: { role_id }) + # PUT /api/v1/principals/:principal_id/roles (atomic roles + sandbox policy) # DELETE /api/v1/principals/:principal_id/roles/:id (:id is the role oid) class PrincipalRolesController < Api::BaseController + POLICY_KEYS = %w[ + role_ids + sandbox_repo_cache + sandbox_observability_enabled + sandbox_sessions_read_enabled + sandbox_workflows_read_enabled + sandbox_workflows_write_enabled + ].freeze + BOOLEAN_POLICY_KEYS = (POLICY_KEYS - %w[role_ids sandbox_repo_cache]).freeze + def index principal = Principal.find_by_oid!(params[:principal_id]) roles = principal.roles.includes(:slack_channel_permissions).order(:id) @@ -27,6 +38,16 @@ def create render_validation_error(e.record) end + def replace + principal = Principal.find_by_oid!(params[:principal_id]) + policy = replacement_policy + roles = policy.delete(:role_ids).map { |role_id| Role.find_by_oid!(role_id) } + principal.replace_roles_and_sandbox_policy!(roles:, **policy) + render json: { data: roles.map { |role| role_payload(role) } } + rescue ActiveRecord::RecordInvalid => 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/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..35e2eea6c7 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,8 +98,10 @@ 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 + 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 @@ -268,6 +272,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 @@ -279,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 4b2c56cf65..f5632ba3d2 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,9 +33,11 @@ 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 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/ @@ -54,6 +57,9 @@ 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 + 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. @@ -148,6 +154,47 @@ 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) + begin + with_lock do + if discord_actor_principal? + # A caller may have loaded this role before an operator changed its + # 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).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 + 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 + instance_variable_defined?(:@discord_actor_reviewed_sandbox_policy) + 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 @@ -166,6 +213,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 @@ -266,7 +320,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. + !discord_actor_principal? && + association(:roles).target.empty? && !roles.exists? end def assign_default_roles @@ -286,6 +344,50 @@ 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 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 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? + + 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/models/principal_role.rb b/services/console/app/models/principal_role.rb index 5c3494b986..c499b63482 100644 --- a/services/console/app/models/principal_role.rb +++ b/services/console/app/models/principal_role.rb @@ -7,10 +7,32 @@ 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 + before_destroy :revoke_discord_actor_sandbox_policy, prepend: true private 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 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..96dc70f0f5 100644 --- a/services/console/app/models/principal_sync_config_snapshot.rb +++ b/services/console/app/models/principal_sync_config_snapshot.rb @@ -328,13 +328,17 @@ 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 { |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 + # 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) + 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) @@ -349,6 +353,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/models/role.rb b/services/console/app/models/role.rb index 68e5eb9140..86bbf3336f 100644 --- a/services/console/app/models/role.rb +++ b/services/console/app/models/role.rb @@ -1,6 +1,15 @@ 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 @@ -18,6 +27,11 @@ 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 + 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") @@ -47,7 +61,68 @@ def self.replace_default_assignments!(role_ids) private + def sync_config_affected_principals + 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| + # 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( + 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 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..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) @@ -104,6 +108,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 +145,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/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/app/services/discord_github_role_policy.rb b/services/console/app/services/discord_github_role_policy.rb new file mode 100644 index 0000000000..b97115f8b2 --- /dev/null +++ b/services/console/app/services/discord_github_role_policy.rb @@ -0,0 +1,480 @@ +# 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 + 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 + 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)) + 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) + if managed_principal?(grant.principal) + grant.errors.add(:base, "Discord policy-managed principals may not receive direct grants") + return + end + + role = grant.role + credential = grant.grantable + return unless managed_role?(role) && credential + + 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) + policy_roles_granting(secret).each do |role| + messages = policy_errors( + role, + replacement_static_secret: secret, + replacement_rules: secret.kind_rules_for_validation + ) + add_errors(secret, prefix_errors(role, messages)) + end + end + + def validate_secret_source(source) + credential = credential_owner_for_source(source) + return unless credential + + 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_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 + + 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 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 role_grants_credential?(roles.first, credential) + + return role_allows_credential?(roles.first, credential) + end + + roles = principal.roles.to_a.select do |role| + managed_role?(role) && role_grants_credential?(role, credential) + end + return true if roles.empty? + + 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) + end + + 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 + + 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&.discord_actor_principal? + end + + def policy_errors(role, replacement_static_secret: nil, replacement_source: nil, + replacement_broker: nil, extra_static_secret: nil, replacement_rules: 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, + replacement_rules: replacement_rules + ) + 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, + replacement_rules: replacement_rules + ) + end + if github_secrets.length != 1 + errors << "must grant exactly one scoped GitHub App credential, found #{github_secrets.length}" + end + errors.uniq + end + + 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) || + 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 or source credentials from 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? + 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, + replacement_rules:) + return true if secret.kind == CredentialProfiles::GithubToken::KIND + 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), + replacement_broker + ) + broker&.grant == GITHUB_APP_INSTALLATION_GRANT + end + + def github_host_rule?(rule) + host = rule.host.to_s.strip.downcase.delete_suffix(".") + CredentialProfiles::GithubToken::ALLOWED_HOSTS.any? do |github_host| + File.fnmatch?(host, github_host) + 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 = [] + 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 + + 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, 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.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) + + 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 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 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 + + 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(association => credential).where.not(role_id: nil).includes(:role) + .filter_map(&:role).select { |role| managed_role?(role) }.uniq(&:id) + end + + 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) + 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/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/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/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/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/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/api/v1/principal_roles_controller_test.rb b/services/console/test/controllers/api/v1/principal_roles_controller_test.rb index c53ba436a2..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 @@ -98,6 +98,109 @@ 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 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 + + 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/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/controllers/api/v1/static_secrets_controller_test.rb b/services/console/test/controllers/api/v1/static_secrets_controller_test.rb index aa59e232aa..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,6 +354,46 @@ 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 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) + end + 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", 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/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..a19a6377f7 --- /dev/null +++ b/services/console/test/models/discord_github_role_policy_test.rb @@ -0,0 +1,487 @@ +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 + ) + + github_token_wrapper = 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 + ) + github_token_wrapper.build_source( + source_type: "token_broker", + config: { "credential_id" => credential.foreign_id } + ) + CredentialProfiles::GithubToken::RULE_ATTRIBUTES.each do |attributes| + github_token_wrapper.rules.build(attributes) + end + github_token_wrapper.save! + + role = Role.create!( + foreign_id: "discord-policy-#{SecureRandom.hex(4)}", + name: "Discord policy", + labels: { + "centaur_discord_policy_managed" => "true", + "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 + ) + 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)}", + kind: "discord_user", + labels: { "centaur_discord_policy_managed" => "true" }, + created_by: admin + ) + principal.replace_roles_and_sandbox_policy!( + roles: [ role ], + **DiscordGithubRolePolicy.sandbox_policy_for_role(role) + ) + + [ 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 + + 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( + 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 "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 "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)}", + 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 "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") + + 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 "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 "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 "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 + + 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 + + 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? + 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 + + 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 a non-static credential that sources a GitHub App installation token" do + principal, role, _secret, credential = build_policy_binding + 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, 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 + source = point_oauth_refresh_source_at(build_generic_broker) + 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 } + + 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 + broker = build_generic_broker + 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", + 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 + + 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 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) diff --git a/services/console/test/models/principal_test.rb b/services/console/test/models/principal_test.rb index 96e9c37eae..5cb6051982 100644 --- a/services/console/test/models/principal_test.rb +++ b/services/console/test/models/principal_test.rb @@ -1,6 +1,49 @@ 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 + + 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 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/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", diff --git a/services/discordbot/README.md b/services/discordbot/README.md index eed85f0b85..aaf75ba7a0 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,14 @@ 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. 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. - **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 +31,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 +47,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 +69,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 +108,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 +139,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..22f059117c 100644 --- a/services/discordbot/src/discord-allowlist.ts +++ b/services/discordbot/src/discord-allowlist.ts @@ -1,5 +1,9 @@ import type { Logger, Message } from "chat"; import type { DiscordbotOptions } from "./types"; +import { + configuredDiscordRoleIds, + configuredDiscordTriggerBotIds, +} from "./discord-policy"; export type DiscordIngressContext = { authorIsBot: boolean; @@ -35,8 +39,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( @@ -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, @@ -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())); } /** @@ -204,20 +221,26 @@ 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). */ export function resolveTriggerRoleAllowlist( options: DiscordbotOptions, ): string[] { + const policyRoleIds = configuredDiscordRoleIds(options); + // 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)), @@ -236,7 +259,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-api.ts b/services/discordbot/src/discord-api.ts new file mode 100644 index 0000000000..4ce5c5c143 --- /dev/null +++ b/services/discordbot/src/discord-api.ts @@ -0,0 +1,31 @@ +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"); + } + let end = raw.length; + while (end > 0 && raw[end - 1] === "/") end -= 1; + return raw.slice(0, end); +} diff --git a/services/discordbot/src/discord-delivery.ts b/services/discordbot/src/discord-delivery.ts new file mode 100644 index 0000000000..eb12d2a141 --- /dev/null +++ b/services/discordbot/src/discord-delivery.ts @@ -0,0 +1,239 @@ +import { createHash, randomUUID, timingSafeEqual } from "node:crypto"; +import type { Logger, StateAdapter } from "chat"; +import { resolveDiscordApiBase } from "./discord-api"; +import { resolveChannelAllowlist } from "./discord-allowlist"; +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; + request_fingerprint: string; +}; + +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 requestFingerprint = deliveryRequestFingerprint(input); + 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); + } + const existingResult = existingDeliveryResult(existing, requestFingerprint); + if (existingResult) return existingResult; + + 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); + const claimedResult = existingDeliveryResult(existing, requestFingerprint); + if (claimedResult) return claimedResult; + + 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, + 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, + requestFingerprint: string, + options: DiscordbotOptions, +): Promise { + 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") + .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, + request_fingerprint: requestFingerprint, + }; +} + +function existingDeliveryResult( + value: unknown, + 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; + const valid = + result.ok === true && + 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 { + 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..23c8771df6 --- /dev/null +++ b/services/discordbot/src/discord-ingress.ts @@ -0,0 +1,616 @@ +import type { Logger, Message, StateAdapter } from "chat"; +import { + parseDiscordThreadKey, + resolveChannelAllowlist, + resolveGuildAllowlist, +} from "./discord-allowlist"; +import { + resolveDiscordPermissionBundle, + resolveDiscordTriggerBotPermissionBundle, + 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; +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; +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" + | "directed_to_other_discord_member" + | "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"; + dispatchStatus: "completed" | "pending"; + 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.ingressDispatchClaimTtlMs ?? DEFAULT_DISPATCH_CLAIM_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 { + await releasePendingDeliveryClaim(state, pending); + audit(logger, pending); + return null; + } + try { + await state.set( + deliveryKey(event.messageId), + record, + record.decision === "allow" + ? options.ingressDispatchClaimTtlMs ?? DEFAULT_DISPATCH_CLAIM_TTL_MS + : options.ingressDeliveryTtlMs ?? DEFAULT_DELIVERY_TTL_MS, + ); + } catch { + const persisted = await recoverPersistedAdmissionOrReleaseClaim( + state, + pending, + record, + ); + if (!persisted) { + audit(logger, pending); + return null; + } + } + audit(logger, record); + return record.decision === "allow" ? record : null; +} + +/** + * 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, +): 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; +} + +/** 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 { + const current = await state.get(deliveryKey(message.id)); + if (sameAdmissionRecord(current, completed)) return true; + if (sameAdmissionRecord(current, admission)) { + 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 false; + } +} + +/** 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; + if (typeof raw.content !== "string") 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, + // 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, + 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 (!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 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); + 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"); + } + // 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); + } + + // 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.policy.fingerprint !== policy.fingerprint) { + return deny("policy_changed_requires_root_trigger"); + } + } + + 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", + dispatchStatus: "pending", + 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)) && + (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" && + Number.isFinite(event.createdTimestamp) && + Number.isSafeInteger(event.messageType) + ); +} + +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.dispatchStatus === expected.dispatchStatus && + 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}`; +} + +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.dispatchStatus === "pending" || + record.dispatchStatus === "completed") && + 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-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/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-policy.ts b/services/discordbot/src/discord-policy.ts new file mode 100644 index 0000000000..ee101decc8 --- /dev/null +++ b/services/discordbot/src/discord-policy.ts @@ -0,0 +1,350 @@ +import { createHash } from "node:crypto"; +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; + 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 (!DISCORD_SNOWFLAKE.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, + }; + }); +} + +/** + * 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 + * 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, + }, + }; +} + +/** + * 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, + 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/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 d1458b8249..153e5d66cd 100644 --- a/services/discordbot/src/index.ts +++ b/services/discordbot/src/index.ts @@ -17,14 +17,24 @@ import { } from "chat"; import { Hono } from "hono"; import pg from "pg"; +import { resolveDiscordApiBase } from "./discord-api"; import { discordIngressDenialReason, - isAllowedDiscordGuild, isAllowedDiscordMessage, isDiscordIngressAllowlistEmpty, + isAllowedDiscordGuild, + isAllowedTriggerBotIdentifiers, parseDiscordThreadKey, + resolveChannelAllowlist, resolveTriggerBotAllowlist, } from "./discord-allowlist"; +import { + acceptedDiscordAdmissionForMessage, + admitDiscordGatewayMessage, + completeDiscordAdmissionForMessage, + discordGatewayEventFromMessage, + type DiscordAcceptedAdmission, +} from "./discord-ingress"; import { DiscordNarrator, reactToDiscordMessage } from "./discord-narrator"; import { fetchThreadStarterMessage } from "./discord-starter"; import { @@ -35,21 +45,30 @@ import { } from "./discord-threading"; import { setGatewayConnected } from "./gateway"; import { + approveActionProposal, collectInitialContext, + isAuthorizedContextMessage, 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, @@ -175,21 +194,32 @@ 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", { - 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, + apiUrl: discordApiBase, applicationId: options.applicationId, botToken: options.botToken, publicKey: options.publicKey, 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 +244,22 @@ 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 }) => + // 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: ({ + 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. @@ -249,7 +286,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 +315,38 @@ 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") { + if (await stopDiscordExecution(thread, message, admission, options, logger)) { + await completeDiscordAdmissionForMessage( + message, + admission, + state, + options.ingressDeliveryTtlMs, + ); + } + return; + } + if (admission.control === "approve") { + if (await approveDiscordProposal(thread, message, admission, options, logger)) { + await completeDiscordAdmissionForMessage( + message, + admission, + state, + options.ingressDeliveryTtlMs, + ); + } + return; + } await thread.subscribe(); await syncThreadMessageToSession(thread, message, { + admission, executionLimiter, mode: "execute", options, @@ -290,7 +356,37 @@ 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") { + if (await stopDiscordExecution(thread, message, admission, options, logger)) { + await completeDiscordAdmissionForMessage( + message, + admission, + state, + options.ingressDeliveryTtlMs, + ); + } + return; + } + if (admission.control === "approve") { + if (await approveDiscordProposal(thread, message, admission, options, logger)) { + await completeDiscordAdmissionForMessage( + message, + admission, + state, + options.ingressDeliveryTtlMs, + ); + } + return; + } await syncThreadMessageToSession(thread, message, { + admission, executionLimiter, mode: message.isMention === true ? "execute" : "append", options, @@ -308,6 +404,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 +436,101 @@ 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, + ); + return true; + } 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."); + return false; + } +} + +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, + ); + return true; + } 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.", + ); + return false; + } +} + function createDefaultState( options: DiscordbotOptions, logger: Logger, @@ -380,6 +595,7 @@ async function syncThreadMessageToSession( thread: Thread, message: ChatMessage, input: { + admission: DiscordAcceptedAdmission; executionLimiter: GuildExecutionLimiter; mode: DiscordbotMessageMode; options: DiscordbotOptions; @@ -391,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 🚦. @@ -437,7 +664,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; } @@ -464,6 +699,7 @@ async function syncThreadMessageToSession( { emoji: "❓", messageId: message.id, threadKey: thread.id }, logger, ); + await completeAdmission(); return; } @@ -472,7 +708,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 @@ -495,6 +735,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 @@ -506,13 +747,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, { @@ -541,10 +785,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, }; @@ -563,6 +808,9 @@ async function syncThreadMessageToSession( historyForwarded: latest.historyForwarded || shouldIncludeContext, lastEventId: Math.max(latest.lastEventId ?? 0, lastEventId), }); + // 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), @@ -603,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), @@ -615,6 +864,7 @@ async function syncThreadMessageToSession( onMessagesAppended: commitMessagesAppended, }); } + if (messagesToAppend.length === 0) await completeAdmission(); traceLog(input.options, "discordbot_forward_complete", trace); return; } @@ -694,6 +944,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 +1300,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..f2d22dab4f 100644 --- a/services/discordbot/src/server.ts +++ b/services/discordbot/src/server.ts @@ -1,11 +1,28 @@ import { createGatewayController } from "./gateway"; import { createDiscordbot, type DiscordbotOptions } from "./index"; +import { + parseDiscordRoleBindings, + parseDiscordTriggerBotBindings, +} 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 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), @@ -38,21 +55,27 @@ 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"), + 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(), 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, stateKeyPrefix: optionalEnv("DISCORDBOT_STATE_KEY_PREFIX"), - triggerBotAllowlist: optionalList("DISCORDBOT_TRIGGER_BOT_ALLOWLIST"), - triggerRoleAllowlist: optionalList("DISCORDBOT_TRIGGER_ROLE_ALLOWLIST"), + triggerBotBindings, userName: stringEnv("DISCORDBOT_USER_NAME", "centaur"), logger: consoleLogger, }; @@ -118,6 +141,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..a981ac3f93 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, @@ -70,6 +74,7 @@ type ForwardSessionApiCallbacks = { export async function collectInitialContext( thread: { allMessages: AsyncIterable }, currentMessage: Message, + actorId: string, ): Promise { const messages: Message[] = []; try { @@ -78,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( @@ -92,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}`. @@ -189,13 +209,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 +243,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 +272,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 +422,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 +433,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 +450,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 +471,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 +497,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 +621,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 +668,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 +709,7 @@ function sessionAttachmentPart(attachment: DiscordbotApiAttachment): JsonObject function sessionMetadata( message: DiscordbotApiMessage, + policy: DiscordExecutionPolicy, extra: JsonObject = {}, ): JsonObject { return { @@ -591,10 +721,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 +750,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 +772,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..d746d8e520 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,14 @@ 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; + /** Pre-dispatch claim lifetime. Default 30 seconds. */ + ingressDispatchClaimTtlMs?: 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,11 +144,18 @@ 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; /** - * 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[]; /** @@ -132,6 +166,44 @@ 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; +}; + +/** 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; + 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 +257,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..de1180cbfa 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,11 +43,15 @@ import { type DiscordbotOptions, type DiscordbotSessionMessage, } from "../src/index"; +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 TRIGGER_BOT_WEBHOOK_ID = "400000000000000003"; const GUILD_ID = "200000000000000001"; const CHANNEL_ID = "300000000000000001"; const TRIGGER_ROLE_ID = "500000000000000001"; @@ -55,6 +60,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(); @@ -84,6 +99,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`; @@ -133,6 +157,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.", ); @@ -228,6 +257,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, @@ -566,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."); }); @@ -1296,7 +1378,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,43 +1418,107 @@ 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); + + // 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 deniedBotMentionId = await dispatchMessage({ + const allowedBotMentionId = await dispatchMessage({ authorBot: true, authorId: TRIGGER_BOT_ID, channelId: threadId, content: `<@${APP_ID}> from another bot`, mention: true, + roleIds: [], 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(); - bot = createTestBot({ triggerBotAllowlist: [TRIGGER_BOT_ID] }); - const allowedThreadId = discordApi.nextId(); - discordApi.seedThreadChannel(allowedThreadId, CHANNEL_ID); - const allowedBotMentionId = await dispatchMessage({ + // Application and webhook identities use that same explicit static bundle + // with no human role fixture. + bot = createTestBot({ + triggerBotBindings: [ + { + identityId: TRIGGER_BOT_APPLICATION_ID, + roleId: TRIGGER_ROLE_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: allowedThreadId, - content: `<@${APP_ID}> from an allowed bot`, + channelId: applicationThreadId, + content: `<@${APP_ID}> from an allowlisted application`, mention: true, - thread: { id: allowedThreadId, parentId: CHANNEL_ID }, + 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(allowedThreadId, allowedBotMentionId); + 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 an already-active thread. + // 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: authorizedThreadId, parentId: CHANNEL_ID }, + }); + await waitForSettle(authorizedThreadId, authorizedMentionId); + expect(codexApi.executes).toHaveLength(1); + 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 +1526,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 +1538,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,11 +1564,12 @@ 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: { + applicationId?: string; attachments?: Record[]; authorBot?: boolean; authorId?: string; @@ -1414,9 +1577,16 @@ 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[]; thread?: { id: string; parentId: string }; + webhookId?: 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: { @@ -1426,13 +1596,41 @@ 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, + 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], + webhookId: input.webhookId, + }, + botOptions, + botState, + testLogger, + ); + } const data: Record = { ...raw, 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 } } : {}), @@ -1456,6 +1654,44 @@ async function dispatchMessage(input: { return String(raw.id); } +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:${ + input.guildId ?? GUILD_ID + }:${input.thread.parentId}:${input.thread.id}`; + if (await botState.get(rootKey)) return; + await admitDiscordGatewayMessage( + { + authorId: input.authorId ?? USER_ID, + applicationId: input.applicationId, + 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], + webhookId: input.webhookId, + }, + botOptions, + botState, + testLogger, + ); +} + function recoveryApiMessage( key: string, messageId: string, @@ -1732,6 +1968,7 @@ type RawDiscordAuthor = { }; type RawDiscordMessage = { + application_id?: string; attachments: Record[]; author: RawDiscordAuthor; channel_id: string; @@ -1740,6 +1977,7 @@ type RawDiscordMessage = { id: string; timestamp: string; type: number; + webhook_id?: string; }; type DiscordRestCall = { @@ -1767,9 +2005,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; @@ -1807,6 +2047,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, @@ -1814,6 +2055,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 57852b788b..5efe488017 100644 --- a/services/discordbot/test/discord-allowlist.test.ts +++ b/services/discordbot/test/discord-allowlist.test.ts @@ -4,12 +4,13 @@ import { discordIngressDenialReason, discordRoleIdsFromRaw, isAllowedDiscordMessage, + isAllowedTriggerBotIdentifiers, isAllowedTriggerBotMessage, isDiscordIngressAllowlistEmpty, isGuildAllowlistEmpty, parseDiscordThreadKey, } from "../src/discord-allowlist"; -import type { DiscordbotOptions } from "../src/types"; +import type { DiscordbotOptions, DiscordTriggerBotBinding } from "../src/types"; const silentLogger: Logger = { debug: () => undefined, @@ -55,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({ @@ -177,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); @@ -254,6 +261,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); @@ -288,12 +309,44 @@ 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); }); + + 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 new file mode 100644 index 0000000000..6bee769deb --- /dev/null +++ b/services/discordbot/test/discord-delivery.test.ts @@ -0,0 +1,198 @@ +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 { resolveDiscordApiBase } from "../src/discord-api"; +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 { + 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("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"), + ).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"); + 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 () => { + 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: OTHER_CHANNEL_ID, + 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..f306029a2d --- /dev/null +++ b/services/discordbot/test/discord-ingress.test.ts @@ -0,0 +1,648 @@ +import { describe, expect, it } from "bun:test"; +import { createMemoryState } from "@chat-adapter/state-memory"; +import type { Logger, Message, StateAdapter } from "chat"; +import { + acceptedDiscordAdmissionForMessage, + admitDiscordGatewayMessage, + completeDiscordAdmissionForMessage, + type DiscordGatewayMessageEvent, +} from "../src/discord-ingress"; +import type { + DiscordbotOptions, + DiscordRoleBinding, + DiscordTriggerBotBinding, +} 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 triggerBotBinding( + overrides: Partial = {}, +): DiscordTriggerBotBinding { + return { identityId: USER, 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("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, + // 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, + configured, + state, + logger, + NOW, + ); + 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( + chatMessage, + state, + ); + expect(dispatched?.dispatchStatus).toBe("pending"); + expect( + await completeDiscordAdmissionForMessage( + chatMessage, + dispatched!, + state, + configured.ingressDeliveryTtlMs, + ), + ).toBe(true); + const completed = await acceptedDiscordAdmissionForMessage( + chatMessage, + state, + ); + expect(completed?.dispatchStatus).toBe("completed"); + + await new Promise((resolve) => setTimeout(resolve, 550)); + 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 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 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, + ), + ).toBe(false); + 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(); + 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 }], + ["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("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, roleIds: [] }), + configured, + ), + ).toBe("accepted"); + expect( + await reasonFor( + event("600000000000000046", { + authorIsBot: true, + roleIds: [ROLE], + }), + options({ triggerBotAllowlist: [USER] }), + ), + ).toBe("bot_message"); + expect( + await reasonFor( + event("600000000000000047", { + authorIsBot: true, + roleIds: [], + webhookId: "700000000000000001", + }), + options({ + triggerBotBindings: [ + triggerBotBinding({ identityId: "700000000000000001" }), + ], + }), + ), + ).toBe("accepted"); + }); + + 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("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) { + expect( + await admitDiscordGatewayMessage( + candidate, + configured, + state, + logger, + now, + ), + ).toBeNull(); + expect(audits.at(-1)?.data.reason).toBe(expected); + } + }); + + 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(); + 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-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", + ); + }); +}); diff --git a/services/discordbot/test/discord-policy.test.ts b/services/discordbot/test/discord-policy.test.ts new file mode 100644 index 0000000000..34dc1fc4b2 --- /dev/null +++ b/services/discordbot/test/discord-policy.test.ts @@ -0,0 +1,262 @@ +import { describe, expect, it } from "bun:test"; +import { + parseDiscordRoleBindings, + parseDiscordTriggerBotBindings, + resolveDiscordPermissionBundle, + resolveDiscordTriggerBotPermissionBundle, +} from "../src/discord-policy"; +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 = {}, +): 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", + }); + }); +}); + +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" }); + }); +}); diff --git a/services/discordbot/test/session-api.test.ts b/services/discordbot/test/session-api.test.ts index 20bf6bdb8a..3d0e5f7eb4 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,117 @@ describe("forwardToSessionApi principal naming", () => { }); }); +describe("approveActionProposal", () => { + function admission( + fingerprint: string, + ): DiscordAcceptedAdmission { + return { + actorId: "100000000000000001", + channelId: "300000000000000001", + control: "approve", + decision: "allow", + dispatchStatus: "completed", + 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 +471,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 +496,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 = [] 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