Skip to content

fix(slack-plugin): scope interactive surface credentials per delivering company (BLO-21083) - #1090

Open
allyblockcast[bot] wants to merge 1 commit into
masterfrom
platformsre/blo-21083-slack-interactive-tenant-scope
Open

fix(slack-plugin): scope interactive surface credentials per delivering company (BLO-21083)#1090
allyblockcast[bot] wants to merge 1 commit into
masterfrom
platformsre/blo-21083-slack-interactive-tenant-scope

Conversation

@allyblockcast

@allyblockcast allyblockcast Bot commented Aug 6, 2026

Copy link
Copy Markdown

Thinking Path

  • Paperclip is the open source app people use to manage AI agents for work
  • paperclip-plugin-slack handles Slack's interactive surface: emoji-reaction approvals, /clip slash commands, and Block Kit button clicks / modal submissions
  • The host hands every plugin worker an empty bootstrap config whenever more than one company has configured the plugin (server/src/services/plugin-loader.ts), so the plugin must resolve config/credentials per company at request time rather than trusting the setup() snapshot
  • BLO-20959 / PR fix(slack-plugin): register scheduled jobs before the credential-gated early return (BLO-20959) #974 fixed this for the four scheduled jobs, but deliberately left the interactive surface untouched — it still read a module-level pluginToken and guessed the acting company from ctx.companies.list()[0] instead of the delivery's own companyId
  • On a multi-company install that means every reaction, slash command, and button click either does nothing (pluginToken is undefined) or — worse — resolves to whichever company happens to be first in the list, not the one that actually sent the request
  • This pull request derives the company strictly from the inbound delivery (PluginWebhookInput.companyId, which the host attaches per-request and requires an explicit query param for on multi-company webhook URLs) and resolves that company's own config + token, following the ratified pattern from paperclip-plugin-alertmanager/src/config-scope.ts (BLO-20467)
  • The benefit: reaction-driven approvals, slash commands, and button interactions work on a multi-company install after a cold worker start with no config edit, and one tenant's interaction can never be served with another tenant's Slack bot token

Linked Issues or Issue Description

Fixes the interactive-surface half of BLO-20959, split out as its own ticket in Paperclip: BLO-21083 (canonical tracker is Paperclip, not GitHub — no existing GitHub issue).

What happened

Every Slack-originated interactive request path read a module-level pluginToken that is only ever populated when the setup() bootstrap snapshot happens to carry a resolvable slackTokenRef — which the host never provides once more than one company has the plugin configured. Company resolution used resolveTargetCompanyId(), which fell back to ctx.companies.list()[0] — an arbitrary guess, not the delivery's actual tenant.

Repro (pre-fix)

  1. Configure paperclip-plugin-slack for two companies, each with its own Slack app/token.
  2. Restart the worker (or just observe steady state — the bug is permanent, no restart needed to reproduce).
  3. React ✅ on an approval card, or run /clip approve <id>, in either company.
  4. The action either no-ops (pluginToken undefined) or is evaluated against the wrong company's data, depending on which company ctx.companies.list() happens to return first.

A second instance of the same root cause was found while writing an end-to-end verification test: canProcessMutatingApprovalWebhook's Slack signing secret was also resolved once from the bootstrap snapshot, so on any multi-company install it stayed null forever and every mutating interaction (reactions, buttons, /clip approve) was unconditionally rejected — independent of whether the specific company's own signing secret was configured correctly. Without this second fix, the token fix alone would still leave the interactive surface fully dead on a multi-company install.

What Changed

  • Added resolveInteractionScope(ctx, companyId, label) in worker.ts: loads the delivering company's own config via ctx.config.get(companyId), resolves its slackTokenRef via ctx.secrets.resolve(ref, { companyId }), and fails closed (returns null, logs one warn) for a missing companyId, an unreadable/empty config, a missing token ref, or a secret that fails to resolve. Never falls back to the bootstrap snapshot or another company's credential.
  • Added resolveCompanySigningSecret(ctx, companyId): same per-company resolution for the Slack signing secret used by verifySlackSignature and canProcessMutatingApprovalWebhook.
  • onWebhook now resolves the signing secret and (for events/slash/interactivity dispatch) the full company scope from input.companyId — the host-attached, per-delivery tenant — instead of guessing.
  • handleReactionEvent, handleInboundMessageEvent, and handleSlashCommand now take an explicit companyId parameter instead of calling the removed resolveTargetCompanyId().
  • isAuthorizedReactor now takes the resolved per-company config instead of reading the module-level pluginConfig snapshot — fixes a related bug where the approval allowlist was always empty on multi-company installs, which would have silently blocked every approval even after the token fix.
  • Removed the now-fully-dead module-level pluginToken and the single-resolution slackSigningSecret global.
  • The in-process thread-message router (freeform revision replies, !approve thread commands) still reads its setup()-time bootstrap token/config/signing-secret — same pre-existing limitation, explicitly out of scope here (documented inline) and left as a natural follow-up given it has a different, lower-risk blast radius (it only fires for events already scoped to a real company via the emitted event's companyId).

Verification

cd packages/plugins/paperclip-plugin-slack
pnpm exec tsc --noEmit   # clean
pnpm exec vitest run     # 135/135 passing (128 pre-existing + 7 new)
pnpm run build            # esbuild bundle succeeds

New cases in src/__tests__/interaction-tenant-scope.test.ts, following the discipline used in job-registration.test.ts: every case was confirmed to fail against the pre-fix worker (git stash the fix, rerun) before being accepted as covering the production path — 6 of 7 fail pre-fix; the 7th is a fail-closed regression guard that (correctly) also holds pre-fix for an unrelated reason, documented inline as such.

case proves vs pre-fix
reaction-driven approval on company B uses only B's token; A's ref is never touched two-company fixture, no cross-tenant token use fails
delivery with no companyId is dropped with one warn fails closed on ambiguous tenant fails
delivery for an unconfigured company is refused, never falls back to a configured sibling fails closed, no cross-tenant fallback fails
/clip acp works on a multi-company install after cold start slash-command path fixed fails
approval button click commits through the right company's RPC + token block_actions path fixed fails
a company with its own signing secret is served (not permanently blocked) signing-secret fix fails
a company with no signing secret is rejected, never served under a sibling's secret fail-closed regression guard passes on both (documented)

Also mocked ctx.companies.list() to throw across the new suite, so any surviving guess-the-tenant code path fails loudly instead of silently.

Post-deploy (cannot be observed pre-merge, per the ticket's stated verifying signal): after a kubectl delete pod paperclip-0 with no config touch, a ✅ reaction on an approval card in a configured company should commit the decision, and plugin interaction logs should show no undefined-token Slack API failures over 1h.

Risks

  • Low-to-moderate. Interactive handler bodies are unchanged; only where they get their company/config/token from changed.
  • New RPC load per interaction — one ctx.config.get(companyId) plus (for mutating paths) one ctx.secrets.resolve per delivery, plus a separate signing-secret resolution per webhook delivery. This is human-driven, low-frequency traffic (reactions, button clicks, slash commands), not a hot loop — comparable in shape to the per-tick job resolution already landed in BLO-20959.
  • Behavioral shift, intended — a multi-company install's interactive surface now actually works instead of silently no-oping or (worse) guessing a tenant. Single-company installs are unaffected in outcome (still exactly one company to resolve to), just now via an explicit per-request lookup instead of a cached snapshot.
  • Known residual gap, explicitly out of scope — the thread-message router (freeform revision replies, !approve/!reject typed in an approval thread) still uses the setup()-time bootstrap token/config, same as before this PR. Filing as a fast follow.
  • No migrations, no API changes, no UI changes.

Model Used

Claude Sonnet 5 (claude-sonnet-5, 1M context), extended reasoning, with tool use — running as the Paperclip Platform/SRE agent.

Checklist

  • I have included a thinking path that traces from project context to this change
  • I have specified the model used (with version and capability details)
  • I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work
  • I have searched GitHub for duplicate or related PRs and linked them above
  • I have described the issue in-PR (canonical tracker is Paperclip issue BLO-21083, no GitHub issue exists)
  • I have run tests locally and they pass
  • I have added or updated tests where applicable
  • If this change affects the UI, I have included before/after screenshots — N/A, no UI surface
  • I have updated relevant documentation to reflect my changes — inline code comments carry the host-behavior rationale; no external docs describe this path
  • I have considered and documented any risks above
  • All Paperclip CI gates are green — pending on this head
  • Greptile is 5/5 with no open P2s, recommendations, or follow-ups
  • I will address all Greptile and reviewer comments before requesting merge

…ng company (BLO-21083)

Every Slack-originated request path — reaction events, slash commands,
view_submission, and block_actions — read a module-level `pluginToken` and
guessed a companyId from `ctx.companies.list()[0]` instead of the delivery's
own `input.companyId`/`event.companyId`. On a multi-company install the host
always hands the worker an empty bootstrap config (plugin-loader.ts), so
`pluginToken` could never be populated and the guessed companyId could name a
different tenant than the one that actually sent the request.

Adds resolveInteractionScope(), mirroring resolveCompanyJobScope() (BLO-20959)
and paperclip-plugin-alertmanager's config-scope.ts (BLO-20467): derive the
company from the inbound payload, load that company's own config and token,
and fail closed with one explanatory warn when the tenant can't be
established or its credential can't be resolved — never fall back to the
bootstrap snapshot or another tenant's token.

Also fixes a second instance of the same root cause discovered while
verifying the fix end-to-end: `canProcessMutatingApprovalWebhook`'s signing
secret was resolved once from the bootstrap snapshot too, so on any
multi-company install it stayed null forever and every mutating approval
interaction was unconditionally rejected regardless of what a given
company's own config said. Resolved per delivery now via
resolveCompanySigningSecret(), same pattern.

The in-process thread-message router (freeform revision replies, !approve
thread commands) still reads its bootstrap-snapshot token/config — same
limitation as before, tracked as a follow-up rather than folded in here.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@allyblockcast

allyblockcast Bot commented Aug 6, 2026

Copy link
Copy Markdown
Author

🔗 Paperclip issue: BLO-20959
🔗 Paperclip issue: BLO-20467
🔗 Paperclip issue: BLO-21083

1 similar comment
@allyblockcast

allyblockcast Bot commented Aug 6, 2026

Copy link
Copy Markdown
Author

🔗 Paperclip issue: BLO-20959
🔗 Paperclip issue: BLO-20467
🔗 Paperclip issue: BLO-21083

@allyblockcast

allyblockcast Bot commented Aug 6, 2026

Copy link
Copy Markdown
Author

@ally please review this PR. Focus areas:

  1. Tenant-derivation correctnessresolveInteractionScope/resolveCompanySigningSecret in worker.ts: is input.companyId genuinely host-authenticated and safe to trust as the delivery's tenant (per server/src/routes/plugins.ts webhook route), or is there a gap where it could be spoofed/misrouted?
  2. Fail-closed completeness — did I miss any remaining pluginToken/pluginConfig read on an interactive code path? (I removed the module-level pluginToken entirely and grepped for stragglers, but a second pass is worth it given the blast radius.)
  3. The signing-secret fix (resolveCompanySigningSecret) — this is a bug I found while writing end-to-end tests, not something the original ticket called out by name. Want a second opinion on whether resolving it per-delivery instead of at setup() time is the right call, and whether the "skip verification if not configured" fallback semantics I preserved are still correct now that it's per-company.
  4. Whether the thread-message router follow-up (documented inline, not fixed here) is the right scope boundary, or should have been folded in.

Referenced: paperclip issue BLO-21083.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

0 participants