[Fix] Job tokens can no longer reassign their run's acting user - #82
Merged
Conversation
Flagged in PR #80 review. The job-scoped cloudJobs.update mutation accepted `actingUserId`, and a run-scoped job token is held by the sandbox runtime. `resolveActorScopedUserContext` (and the MCP proxy's resolveActingUserId) PREFER the run's live `task_runs.actingUserId` when resolving the effective user for actor-scoped credential routes (userApiKeys.getDecryptedKey, user-scoped mcpConnections). Chain: a compromised/misbehaving sandbox could self-reassign actingUserId to any user id, then read that user's decrypted API keys or user-scoped connections — a confused deputy. Fix: remove `actingUserId` from the job-token-writable `update` input schema (zod strips it). Acting-user reassignment stays with the trusted server-side writers that write task_runs directly: web steer (apps/web sandbox-session) and follow-up delivery (apps/api sendMessageToTask syncActingUserIdAfterDelivery). Worker audit: the only worker path that wrote actingUserId via the job token was the SDK helper syncActingUserId (packages/sdk/src/cloud-jobs.ts), used during actor-scoped turn preparation. It set actingUserId to a queued follow-up sender for per-turn actor switching. Because the sandbox and worker host share one run-scoped token with no principal distinction, this legitimate switch is indistinguishable from the attack at the server, and the Redis message queue is destructive-on-read so no durable participant record exists to validate against. Per the reviewer's stated intent ("the worker should never choose its own acting user"), the helper is now reconcile-only: it observes the server-authoritative value and never writes. Trade-off: in a rare multi-user snapshot-resume batch, a later sender's turn runs under the earlier (server-set) actor rather than switching — a within-run mis-attribution among participants, not a cross-user credential leak. Tests: schema strips actingUserId from job-token update input; job token cannot pivot getDecryptedKey to a victim; SDK syncActingUserId never writes on divergence. Comments updated in resolve-actor-scoped-user, proxy-utils, and user-api-keys.
Contributor
|
2 issues outstanding. See task
|
…re delivery, worker follows the server Addresses both PR #82 review findings on the reconcile-only change. High (mismatch must not run the turn): syncActingUserId previously warned on divergence and returned 'unchanged', so a sender's prompt could run while actor-scoped routes still resolved the previous actor. Now: - Trusted writes happen BEFORE delivery everywhere. sendMessageToTask and steerMessageToTask write task_runs.actingUserId (updateActingUserIdIfNeeded, moved to apps/api/src/handlers/tasks/acting-user-sync.ts) before the sandbox RPC; a failed required switch aborts delivery (500) instead of the old swallowed post-delivery sync. Webhook handlers that feed the worker's polled queues (Slack active-job, Teams x2, Telegram, Linear) perform a trusted pre-queue actor sync from the sender they resolved themselves (syncActingUserForInboundMessage, non-fatal), replacing the worker-side write these paths previously relied on. - SDK syncActingUserId returns { result, actingUserId } with a distinct 'mismatch' result when the server actor differs from the requested sender, and never writes. - The worker enforces the invariant: sandbox RPC surfaces (sendPrompt, steerTask, answerUserInputRequest) use the default 'block' policy - a mismatch there means the trusted pre-delivery write did not happen, so the turn is refused with a retryable error. Queued/polled deliveries (Slack/Teams/Telegram/Linear polls, snapshot-resume replays, queued-prompt boundaries) use 'follow-server': requeueing cannot converge (the server value only moves via trusted writes, so a blocked queue would stall until the Redis TTL drops the messages), so the turn runs AND is attributed as the server actor - integrations refresh and sendPrompt userId both use the server value, never the sender's identity over someone else's credentials. Medium ('updated' unreachable / dead git-author branch): 'updated' now means the server actor matches the sender but differs from the worker's last-prepared actor (tracked per run, seeded from cloudJob.actingUserId), and the revived branch runs syncRuntimeGitAuthor FROM the server value - commits after a trusted actor switch carry the new identity. Follow-server mismatches that change the local actor refresh the git author the same way. Ordering reasoning (pre-delivery write failure mode): if the actor switch lands but delivery then fails, the run attributes to the latest human who attempted to engage; none of their prompts ran, the write is idempotent, and the next successful send from any sender re-syncs it. Tests: sendMessageToTask/steerMessageToTask assert write-before-RPC invocation order and no-delivery-on-write-failure; SDK tests cover all four outcomes and assert no job-token write ever happens; worker tests cover block-on-mismatch, follow-server delivery under the server actor (integrations + git author + attribution from the server value), updated-triggers-git-author-sync, and the polled surfaces attributing turns to the effective user. New unit tests for the acting-user-sync helpers.
mrubens
pushed a commit
that referenced
this pull request
Jul 10, 2026
Remove taskId from the job-scoped cloudJobs.update input schema. The mutation is reachable with a run-scoped job token held by the sandbox runtime, and a run's task binding drives attribution, visibility, and PR linkage; runs bind to a task at enqueue and never re-parent. No worker code path sends the field. Mirrors the actingUserId hardening in #82 (lower severity: taskId does not feed credential resolution).
…til it lands A message whose sender is not the run's server-side acting user is no longer delivered at all: the former follow-server policy relabeled the turn's attribution but still executed the mismatched sender's content under the server actor's credential resolution. The skip policy drops that message's content (never requeued), posts a best-effort resend notice to the task's chat thread (one per sender per run), and keeps draining the rest of the queue. Resending re-enters the webhook's trusted pre-queue actor sync, so the resent message runs as its sender. The block policy on sandbox RPC surfaces is unchanged, and the harness queued-prompt boundary gains a shouldSkipPrompt outcome so a skipped prompt cannot crowd the queue with block-and-retry stalls. Separately, a failed runtime git-author sync no longer advances the worker's last-prepared actor marker, so the next turn reports `updated` again and retries the author sync until it succeeds instead of leaving the previous actor's git identity on all later commits.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Vulnerability (flagged in PR #80 review)
The job-scoped
cloudJobs.updatetRPC mutation (packages/sdk/src/server/routers/cloud-jobs.ts) acceptedactingUserId: z.string().optional(). A run-scoped job token is held by the sandbox runtime, andresolveActorScopedUserContext(packages/sdk/src/server/lib/auth/resolve-actor-scoped-user.ts) — plus the MCP proxy'sresolveActingUserId— prefer the run's livetask_runs.actingUserIdwhen resolving the effective user for actor-scoped credential routes (userApiKeys.getDecryptedKey, user-scopedmcpConnections).Confused-deputy chain: a compromised/misbehaving sandbox →
cloudJobs.update({ id, actingUserId: <victim> })with its own job token → thenuserApiKeys.getDecryptedKey(or a user-scoped MCP connection lookup) resolves to the victim → reads the victim's decrypted API keys / connections.Fix
Removed
actingUserIdfrom the job-token-writableupdateinput schema. zod strips the field, so a job-token caller can never persist it. Acting-user reassignment remains available only to the trusted server-side writers, which writetask_runsdirectly and are unchanged:apps/web/src/trpc/commands/sandbox-session/index.ts(sets it from the authenticated web user)apps/api/src/handlers/tasks/sendMessageToTask.tssyncActingUserIdAfterDeliveryThe read-side model (PR #80) is unchanged:
actingUserIdis still the live-trusted attribution source and is still preferred over the token's mint-time userId.Worker audit
The only worker path that wrote
actingUserIdthrough the job token was the SDK helpersyncActingUserId(packages/sdk/src/cloud-jobs.ts), called during actor-scoped turn preparation to switch the actor to a queued follow-up sender before delivering that sender's turn. No code inapps/worker/srcputsactingUserIdinto anupdatecall directly.This switch is a legitimate relay of a server-provided queued-sender id, but it cannot be safely preserved:
createJobTokenmintsuserId = actingUserId, no worker-vs-sandbox principal distinction), so the legit switch is indistinguishable from the attack at the server;getCommunicationMessagesdeletes the key), so there is no durable record of pending senders to validate a target against.Per the reviewer's stated intent ("the worker should never choose its own acting user"),
syncActingUserIdis now reconcile-only: it observes the server-authoritative value and never writes, logging a warning if the requested actor diverges.Trade-off (reported, not a leak): in a rare multi-user snapshot-resume batch, a later sender's turn now runs under the earlier, server-set actor instead of switching mid-batch. Both are legitimate participants of the same run who already share its actor-scoped surface turn-by-turn, so this is a within-run mis-attribution, not a cross-user credential leak. A future server-authoritative per-turn actor sync could restore precise switching if desired.
Other job-token-writable fields audited
While in the
updateschema I checked the remaining fields for confused-deputy potential:taskId(worth a follow-up): writable by a job token but not sent by any workerupdatecall (alltaskId:references in the worker are reads). Re-pointing a run to another task would corrupt run→task attribution/visibility, buttaskIddoes not feedresolveActorScopedUserContext, so it is a data-integrity risk, strictly lesser than credential theft. Left as-is to keep this PR scoped to the credential vector; recommend removing it from the job-token input in a follow-up.status/result/taskPhase/sleepAt: legitimate worker-written runtime/output fields, no auth/attribution role.done,updateRuntimeState,stampMilestone,touchCloudJobHeartbeat): none write auth/attribution fields.Tests
packages/sdk/src/server/routers/cloud-jobs.test.ts: a job-tokenupdatecarryingactingUserIdis stripped (never reachesupdateCloudJob); legitimatestatus/resultfields still persist.packages/sdk/src/server/routers/user-api-keys.test.ts: end-to-end shape — a job token cannot pivotgetDecryptedKeyto a victim; the lookup is scoped only to the persisted (legitimate) actingUserId.packages/sdk/src/cloud-jobs.test.ts:syncActingUserIdnever writes on divergence and warns.Validation
pnpm format+ fullpnpm check(lint, check-types, test, knip) all pass.