Skip to content

feat(gateway): bind outbound emit + Slack reads to the calling session's branch#1799

Merged
kgabryje merged 1 commit into
mainfrom
gateway-outbound-branch-binding
Jul 8, 2026
Merged

feat(gateway): bind outbound emit + Slack reads to the calling session's branch#1799
kgabryje merged 1 commit into
mainfrom
gateway-outbound-branch-binding

Conversation

@kgabryje

@kgabryje kgabryje commented Jul 3, 2026

Copy link
Copy Markdown
Member

The bug (confused deputy)

Gateway channels each have a target_branch_id, and inbound routing is branch-scoped — but outbound was not. agor_gateway_emit_message takes an explicit gatewayChannelId, and its only authorization (ensureCanEmitFromChannel) early-returns for admin role, otherwise checking the user's permission on the channel's branch. Nothing tied the calling session's branch to the channel. Since assistant sessions run as admin users, any session could call agor_gateway_outbound_targets_list, discover another assistant's channel, and emit through it — impersonating that assistant's Slack bot. The same leak applied to reads via agor_gateway_slack_thread_history_get.

The fix

Whenever the caller has session context (ctx.sessionId present — always true for agent sessions), outbound emit and Slack thread reads now require session.branch_id === channel.target_branch_id:

  • GatewayService.emitMessage — when emittedBySessionId is set (server-derived from MCP ctx.sessionId, never tool input; the input zod schema is strict and rejects injected attribution fields), a new ensureSessionBranchBoundToChannel check runs before ensureCanEmitFromChannel, with no admin-role bypass (agents are admins — a bypass would make the fix a no-op; this is deliberate). Fails closed if the session can't be loaded. The denial message names the caller's branch but never echoes the channel's target branch (anti-enumeration).
  • agor_gateway_slack_thread_history_get — both the sessionId-mapping path and the explicit gatewayChannelId+threadId path deny cross-branch reads for session-context callers, with the same anti-enumeration property. Existing admin/'all'-permission checks are unchanged for no-session callers.
  • agor_gateway_outbound_targets_list — results are force-scoped to the calling session's branch (even for admins). A conflicting args.branchId returns channels: [] plus a binding note (no throw); an empty result includes a hint to ask an operator.
  • Calls without session context (personal API keys agor_sk_, i.e. human operators) keep today's user-permission model unchanged. No escape hatch in v1.

Tests cover: same-branch emit success with session attribution on the audit row; cross-branch emit denied while admin with no Slack send and no audit row; fail-closed on missing session; unchanged no-session admin/member paths; strict-schema rejection of injected emittedBySessionId/sessionId; targets-list scoping/conflict/hint/no-session cases; and thread-history cross-branch denial (mapped + explicit) alongside unchanged no-session admin reads.

CI note

check:multitenancy-boundaries is pre-existing-red on main (4 benign hits: 3 in daemon test files that drive tenant scope / flush macrotasks, plus one type-only Database import in widgets/env-vars/index.ts) — all unchanged by this PR. It is a known-unrelated failure the team merges over; this PR deliberately does not modify the shared check script.

Stacking

Stacked on #1745 (#1743#1745 ← this). Base is gateway-token-widget, not main; this will be rebased onto main after #1743/#1745 merge.

Residual risks (known, out of scope)

  • (a) An agent could agor_sessions_create a session on the victim branch and have it emit — that's a separate authorization layer and the emit is then correctly attributed to a session genuinely on that branch; out of scope here.
  • (b) Human personal-API-key callers without a session header keep the user-permission model by design.
  • (c) REST sessions.patch branch_id re-parenting is a human-only surface; a possible one-line follow-up is adding branch_id to ensureSessionImmutability.
  • (d) config.allowed_emitter_branch_ids is the designated future escape-hatch extension point if cross-branch emits ever need to be sanctioned per channel.

🤖 Generated with Claude Code

@kgabryje kgabryje force-pushed the gateway-outbound-branch-binding branch from a02b75e to 527fd04 Compare July 3, 2026 16:06

@mistercrunch mistercrunch left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice, thanks for tackling this.

@kgabryje kgabryje force-pushed the gateway-token-widget branch 2 times, most recently from 0a32405 to 9c08248 Compare July 7, 2026 17:49
Base automatically changed from gateway-token-widget to main July 7, 2026 18:00
@kgabryje kgabryje force-pushed the gateway-outbound-branch-binding branch from 527fd04 to bb0a9ae Compare July 7, 2026 18:13
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@kgabryje kgabryje force-pushed the gateway-outbound-branch-binding branch from bb0a9ae to 1750860 Compare July 7, 2026 18:21
@kgabryje kgabryje added the ai-reviewed-gpt-5-5 Reviewed by GPT-5.5 AI reviewer label Jul 7, 2026
@kgabryje kgabryje merged commit 8130131 into main Jul 8, 2026
3 checks passed
@kgabryje kgabryje deleted the gateway-outbound-branch-binding branch July 8, 2026 11:08
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ai-reviewed-gpt-5-5 Reviewed by GPT-5.5 AI reviewer

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants