Repository navigation
fix: channel approval buttons are not authorized to a specific user (known issue #11) - #65
Conversation
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
left a comment
There was a problem hiding this comment.
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:
- 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 typosapproverIdshas no way to diagnose it. An ephemeral "you're not authorized to approve this run" (Slack ephemeral / TelegramanswerCallbackQuery({text})) would fix that without becoming an enumeration oracle. - A denied approval is a security event with no audit entry. Right now it's only
logger.warn.CLAUDE.mdrequiresAuditServicefor authorization events, and "did someone try to approve runs they shouldn't have?" is precisely the question this PR exists to make answerable. approvedBycarries rejections too (execute-run.ts:577,603produce"rejected by U1"). It's now a persisted job payload (claw-gateway-run/schema.ts), so renaming toactorId/decidedByis cheap now and awkward later.- Don't interpolate the actor into
content(execute-run.ts:435). The timeline renderscontentverbatim, and any consumer comparingcontent === 'approved'now breaks. A structured field keeps the string stable. approverIdshas no way in — the connectors PATCH route doesn't accept it and there's no UI, so todayapprove_alwayscan only be enabled by writing the config blob by hand. Worth accepting it in the schema, and worth a release note: tenants currently usingapprove_alwayswithout an allowlist will silently lose it.- One missing test: allowlist configured and
message.userIdabsent (Slack sends''atslack.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.
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_alwaysgrants 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
approverIdsallowlist (connectors/types.ts— not a secret, just platform user ids) and enforces it in the gateway'shandleResumebefore enqueueingapprove/approve_always/reject:approve_alwaysspecifically default-denies with no allowlist configured at all — granting a standing permission on trust alone is too consequential. This matches the stopgap already suggested indocs/superpowers/claw-studio-known-issues.mdfix(infra): resolve compute/index.ts corruption, update Dockerfiles, switch to us-east-1 #11.approve/rejectwith 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_decisionevent 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
gateway-service.test.tscases: allowlisted/non-allowlisted approve,approve_alwayswith and without an allowlist, non-allowlisted reject.approvedByfield.bunx vitest runongateway-service.test.ts(23/23) andexecute-run.test.ts(50/50).bunx tsc --noEmit -p tsconfig.lib.jsononlibs/claw-studio— clean.bunx tsc --noEmitonapps/workers— clean.apps/workersvitest 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)
approverIdstoday requires setting it directly on the connector config.