Skip to content

fix: channel approval buttons are not authorized to a specific user (known issue #11) - #65

Merged
kartikmanimuthu merged 1 commit into
offloadmemory:mainfrom
fabicarvano:fix/channel-approval-allowlist
Oct 19, 2026
Merged

kartikmanimuthu merged 1 commit into
offloadmemory:mainfrom
fabicarvano:fix/channel-approval-allowlist

Conversation

@fabicarvano

Copy link
Copy Markdown
Contributor

Problem (known issue #11)

The gateway's resume handler (Approve / Reject / "Always allow for this task" on Slack/Telegram/Discord) validates the channel signature, that the run exists, and that it belongs to the right tenant — but never checks the clicking user's platform id against anything. Any member of the connected workspace who can see the approval message can press it.

approve_always grants a standing permission on the task, not a one-time action, so this lets a single click by any workspace member leave a scheduled task able to send mail or create issues unattended indefinitely.

Fix

Adds an optional, per-channel approverIds allowlist (connectors/types.ts — not a secret, just platform user ids) and enforces it in the gateway's handleResume before enqueueing approve/approve_always/reject:

  • If an allowlist is configured, the clicking user must be on it.
  • approve_always specifically default-denies with no allowlist configured at all — granting a standing permission on trust alone is too consequential. This matches the stopgap already suggested in docs/superpowers/claw-studio-known-issues.md fix(infra): resolve compute/index.ts corruption, update Dockerfiles, switch to us-east-1 #11.
  • Plain approve/reject with no allowlist configured keep their existing (pre-this-change) behavior, so a tenant that hasn't set one up yet isn't suddenly locked out of ordinary approvals.

Also threads the approving user's id through to the run's approval_decision event content, so a decision is attributable to a person rather than just "someone in the workspace" — partially addresses the attribution gap noted in #13 as well.

Testing

  • New gateway-service.test.ts cases: allowlisted/non-allowlisted approve, approve_always with and without an allowlist, non-allowlisted reject.
  • Updated assertions on the existing resume tests for the new approvedBy field.
  • bunx vitest run on gateway-service.test.ts (23/23) and execute-run.test.ts (50/50).
  • bunx tsc --noEmit -p tsconfig.lib.json on libs/claw-studio — clean.
  • bunx tsc --noEmit on apps/workers — clean.
  • Full apps/workers vitest suite: 68/68 passing (two unrelated pre-existing env-var failures in other job handlers' test setup, nothing touching this change).

Not included (left for a follow-up / product decision)

  • No Mission Control UI to manage the allowlist yet — this PR is backend enforcement only. Configuring approverIds today requires setting it directly on the connector config.
  • Per-operator identity inside Mission Control itself (fix: playground execution errors and add KB upload page #13) is a larger, separate decision noted in the known-issues register.

The gateway's resume handler (Approve/Reject/"Always allow for this
task" on Slack/Telegram/Discord) validated the channel signature, that
the run exists, and that it belongs to the right tenant, but never
checked the clicking user's platform id against anything. Any member
of the connected workspace who could see the approval message could
press it — and approve_always grants a STANDING permission on the
task, not a one-time action, so this let a single click by any
workspace member leave a scheduled task able to send mail or create
issues unattended indefinitely.

Adds an optional, per-channel approverIds allowlist (connectors/types.ts
-- not a secret, just platform user ids) and enforces it in the
gateway's handleResume before enqueueing approve/approve_always/reject:

- If an allowlist is configured, the clicking user must be on it.
- approve_always specifically default-denies with NO allowlist
  configured at all, since granting a standing permission on trust
  alone is too consequential -- this matches the stopgap already
  recommended in docs/superpowers/claw-studio-known-issues.md #11.
- Plain approve/reject with no allowlist configured keep their
  existing (pre-this-change) behavior, so a tenant that hasn't set one
  up yet is not suddenly locked out of ordinary approvals.

Also threads the approving user's id through to the run's
approval_decision event content, so a decision is attributable to a
person rather than just "someone in the workspace" -- partially
addresses the attribution gap noted in known-issues #13 as well.

Covered by new gateway-service.test.ts cases (allowlisted/non-
allowlisted approve, approve_always with and without an allowlist,
non-allowlisted reject) plus updated assertions on the existing
resume tests for the new approvedBy field. Verified: bunx vitest run
on gateway-service.test.ts (23/23) and execute-run.test.ts (50/50),
bunx tsc --noEmit -p tsconfig.lib.json on libs/claw-studio, bunx tsc
--noEmit on apps/workers, and the broader apps/workers vitest suite
(68/68, two unrelated pre-existing env-var failures in other job
handlers, nothing touching this change).

@kartikmanimuthu kartikmanimuthu left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Thanks @fabicarvano — this is a good security fix, and the threat model is exactly right: a valid channel signature proves the workspace, not the person, and approve_always grants a standing permission, so the blast radius is "any member of the connected workspace can leave a task able to send mail or open issues unattended, indefinitely". Default-deny on approve_always when no allowlist exists is the right call, and keeping plain approve/reject back-compatible is the right compromise for tenants that haven't configured one yet. I also checked that message.userId is genuinely populated by all three adapters (Slack interactive.user.id, Telegram from.id, Discord actorId) — the allowlist can actually match.

Non-blocking follow-ups, in the order I'd tackle them:

  1. Denial returns sendAck, which is an empty 200 for Slack interactions (slack.ts:381). The clicker gets no feedback at all — the button just seems broken. An operator who typos approverIds has no way to diagnose it. An ephemeral "you're not authorized to approve this run" (Slack ephemeral / Telegram answerCallbackQuery({text})) would fix that without becoming an enumeration oracle.
  2. A denied approval is a security event with no audit entry. Right now it's only logger.warn. CLAUDE.md requires AuditService for authorization events, and "did someone try to approve runs they shouldn't have?" is precisely the question this PR exists to make answerable.
  3. approvedBy carries rejections too (execute-run.ts:577, 603 produce "rejected by U1"). It's now a persisted job payload (claw-gateway-run/schema.ts), so renaming to actorId/decidedBy is cheap now and awkward later.
  4. Don't interpolate the actor into content (execute-run.ts:435). The timeline renders content verbatim, and any consumer comparing content === 'approved' now breaks. A structured field keeps the string stable.
  5. approverIds has no way in — the connectors PATCH route doesn't accept it and there's no UI, so today approve_always can only be enabled by writing the config blob by hand. Worth accepting it in the schema, and worth a release note: tenants currently using approve_always without an allowlist will silently lose it.
  6. One missing test: allowlist configured and message.userId absent (Slack sends '' at slack.ts:302). The deny path currently works by accident via !!message.userId — worth pinning so it can't turn into a lockout.

Approving — thanks for taking on the issue and for writing the tests per branch.

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.

2 participants