Skip to content

fix(mothership): tenant-check outputTable writes and route them through replaceTableRows#5011

Open
TheodoreSpeaks wants to merge 2 commits into
mainfrom
fix/mothership-output-table-tenant-check
Open

fix(mothership): tenant-check outputTable writes and route them through replaceTableRows#5011
TheodoreSpeaks wants to merge 2 commits into
mainfrom
fix/mothership-output-table-tenant-check

Conversation

@TheodoreSpeaks

Copy link
Copy Markdown
Collaborator

Problem

The Mothership outputTable side-channels (maybeWriteOutputToTable for function_execute results, maybeWriteReadCsvToTable for read CSV/JSON imports) had two serious issues:

  1. No tenant check. getTableById(outputTable) was never compared against the caller's workspace, so a prompt-supplied table id from another workspace would have its rows deleted and replaced. The replacement rows were written under the caller's workspaceId, making them invisible to the victim's tenant-scoped reads — the table was effectively destroyed.
  2. Raw drizzle bypass of the service layer. The handlers ran their own DELETE + chunked INSERT transaction, skipping everything replaceTableRows provides: the row-order lock that serializes concurrent replaces, schema/row-size validation, plan row limits, rowCount maintenance on the definition, scaled statement timeouts — and the stable column-id keying every other writer uses (rows were stored keyed by column name, so on id-keyed tables the data didn't surface in the grid).

Fix

Both handlers now:

  • reject tables outside the caller's workspace with the same opaque "not found" error used elsewhere (no existence leak)
  • remap wire rows from column names to stable ids (buildIdByName + rowDataNameToId), failing fast when no keys match any column
  • delegate the replace to replaceTableRows

The handler-level MAX_OUTPUT_TABLE_ROWS (10k) guard is unchanged.

Tests

New tables.test.ts: workspace-mismatch rejection (both handlers), name→id remapping through the service, no-matching-columns fail-fast, service-validation error surfacing. bun run lint:check and tsc --noEmit clean.

🤖 Generated with Claude Code

…gh replaceTableRows

maybeWriteOutputToTable / maybeWriteReadCsvToTable accepted any table id
without verifying it belongs to the caller's workspace, so a foreign
table's rows could be wiped and replaced cross-tenant. They also wrote
rows with raw drizzle keyed by column *name*, bypassing the service
layer's job-slot lock, validation, plan row limits, rowCount
maintenance, and the stable column-id keying every other writer uses.

Both handlers now reject tables outside the caller's workspace and
delegate to replaceTableRows with name→id remapped rows.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@vercel

vercel Bot commented Jun 12, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
docs Skipped Skipped Jun 13, 2026 12:21am

Request Review

@cursor

cursor Bot commented Jun 12, 2026

Copy link
Copy Markdown

PR Summary

High Risk
Fixes a tenant-isolation bug that could wipe another workspace's table rows; behavior changes for writes now follow service validation and id-keyed storage.

Overview
Closes a cross-workspace outputTable vulnerability and aligns copilot table writes with the shared table service.

maybeWriteOutputToTable and maybeWriteReadCsvToTable now treat tables outside the caller's workspace as not found (no writes). Inline Drizzle delete/insert is removed in favor of replaceTableRowsFromWire, which maps column names to stable column ids, fails when a row has no matching columns, and delegates to replaceTableRows for locking, validation, plan limits, and rowCount maintenance.

Adds tables.test.ts covering workspace rejection, name→id remapping, column-mismatch fail-fast, and service validation errors for both handlers.

Reviewed by Cursor Bugbot for commit 68c3ebb. Bugbot is set up for automated code reviews on this repo. Configure here.

@TheodoreSpeaks

Copy link
Copy Markdown
Collaborator Author

@greptile review

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 49afd5f. Configure here.

if (replaceResult.error) {
span.setAttribute(TraceAttr.CopilotTableOutcome, CopilotTableOutcome.InvalidShape)
return { success: false, error: replaceResult.error }
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Abort ignored during table replace

Medium Severity

Replacing the inline delete/insert loop with a single replaceTableRowsFromWire call drops the per-chunk abortSignal checks that used to throw inside the transaction. If the request aborts while replaceTableRows is running, the replace still commits, yet the executor can mark the tool cancelled afterward—so the table is mutated while the run reports cancellation.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 49afd5f. Configure here.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Intentional, matching the codebase's abort semantics: aborts gate before mutations (same as assertServerToolNotAborted in the user_table server tool), and the mutation itself is atomic. The old per-chunk checks threw inside the transaction, which rolled the whole replace back — so the only behavioral difference is that an abort landing mid-transaction no longer wastes the rollback; it can't leave the table half-written either way, since replaceTableRows commits all-or-nothing. Supporting mid-transaction cancellation would need a signal-aware service API, which isn't worth it for this window.

@greptile-apps

greptile-apps Bot commented Jun 12, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR closes two security/correctness gaps in the Mothership outputTable side-channel handlers. Both maybeWriteOutputToTable and maybeWriteReadCsvToTable now gate table access on a workspace ownership check and delegate writes to replaceTableRows instead of running hand-rolled DELETE + INSERT transactions.

  • Tenant guard: table.workspaceId !== context.workspaceId added to both handlers, returning an opaque "not found" error to prevent existence leaks.
  • Column-id keying: wire rows are now translated from display names to stable column ids via buildIdByName + rowDataNameToId through the new replaceTableRowsFromWire helper, matching how every other writer stores data.
  • Service delegation: the raw drizzle transaction is replaced with a call to replaceTableRows, gaining the row-order lock, schema/size validation, plan row limits, and rowCount maintenance; a per-row empty-object guard fails fast before calling the service when no input keys match the schema.

Confidence Score: 5/5

Safe to merge. The changes are narrowly scoped to replacing two hand-rolled write paths with the existing service layer and adding a workspace ownership guard; the fix is well-tested and the new helper is straightforward.

Both handlers now correctly reject cross-tenant table IDs and use the service layer for all writes, including its row-order lock, validation, and capacity checks. The test suite covers workspace rejection, column-id remapping, per-row empty-key fail-fast, and service-error surfacing for both handlers. No latent issues found.

No files require special attention.

Important Files Changed

Filename Overview
apps/sim/lib/copilot/request/tools/tables.ts Removes raw drizzle write paths, adds workspace ownership guard to both handlers, and introduces replaceTableRowsFromWire helper that translates name-keyed wire rows to stable column ids and delegates to the service layer.
apps/sim/lib/copilot/request/tools/tables.test.ts New test file covering workspace-mismatch rejection, name to id remapping, per-row empty-key fail-fast (including mixed-match batches), and service-error surfacing for both handlers.

Reviews (3): Last reviewed commit: "fix(mothership): fail outputTable writes..." | Re-trigger Greptile

Comment thread apps/sim/lib/copilot/request/tools/tables.test.ts
Comment on lines +33 to +37
if (idKeyedRows.every((row) => Object.keys(row).length === 0)) {
return {
error: `None of the row keys match columns on table "${table.name}" (columns: ${table.schema.columns.map((c) => c.name).join(', ')})`,
}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2 Partial-match rows silently produce empty {} objects

The fail-fast guard fires only when every row maps to an empty object. If a user supplies mixed input where some rows have matching column names and others do not, the mismatched rows become {} objects forwarded to replaceTableRows. Depending on the service's validation, those rows may be inserted as blank records. A more targeted guard — filtering out zero-length rows before calling the service — would prevent silent data degradation.

@greptile-apps

greptile-apps Bot commented Jun 12, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR fixes two bugs in the Mothership outputTable side-channels: a missing workspace ownership check (allowing a prompt-supplied table id from another workspace to have its rows wiped), and a raw drizzle bypass of the shared service layer. Both handlers now verify the table belongs to the caller's workspace and delegate to replaceTableRows, gaining the row-order lock, schema coercion, plan row-limit enforcement, and rowCount maintenance.

  • Tenant check (table.workspaceId !== context.workspaceId) is added to both maybeWriteOutputToTable and maybeWriteReadCsvToTable, using the same opaque "not found" response to avoid existence leaks.
  • Column-key translation via buildIdByName + rowDataNameToId remaps wire rows from display names to stable column ids before handing them to replaceTableRows, matching the convention used by every other write path.
  • Fail-fast guard rejects the call early when none of the row keys match any column; partial matches (some rows produce {}) are forwarded to the service where required-field validation may catch them.

Confidence Score: 3/5

The workspace tenant check is implemented correctly and closes the cross-tenant table destruction path. The column-key translation and service delegation are sound. However, the fail-fast guard lets through batches where only some rows have matching columns, silently producing empty rows for non-matching records, and the service's in-batch uniqueness check looks up row values by column display name while the rows are now id-keyed — meaning duplicate values in unique:true columns are not caught.

The critical security fix is correct, but the partially-matching-row behavior can silently insert blank records, and the uniqueness enforcement that routing through the service was supposed to provide is effectively a no-op for modern (id-keyed) columns — a pre-existing bug in the service that this PR makes newly relevant for this path.

apps/sim/lib/copilot/request/tools/tables.ts (fail-fast guard and reliance on service uniqueness check) and apps/sim/lib/table/service.ts line 2052 (pre-existing col.name vs getColumnId(col) mismatch in the uniqueness loop).

Important Files Changed

Filename Overview
apps/sim/lib/copilot/request/tools/tables.ts Adds workspace tenant check, name→id column remapping, and delegates row replacement to the service layer. Core security fix is correct; the fail-fast guard only covers the all-empty-rows case, and the service's in-batch uniqueness check is bypassed for id-keyed non-legacy columns (pre-existing bug).
apps/sim/lib/copilot/request/tools/tables.test.ts New test file covering workspace-mismatch rejection, name→id remapping, no-matching-columns fail-fast, and service-error surfacing for both handlers. Does not cover the partial-key-match or uniqueness bypass scenarios.

Sequence Diagram

sequenceDiagram
    participant Caller as Copilot Handler
    participant Tables as tables.ts
    participant Service as table/service.ts
    participant DB as Database

    Caller->>Tables: maybeWriteOutputToTable(toolName, params, result, ctx)
    Tables->>Service: getTableById(outputTable)
    Service-->>Tables: table
    Tables->>Tables: "Check table.workspaceId === ctx.workspaceId"
    alt workspace mismatch or table missing
        Tables-->>Caller: "{success:false, error:Table not found}"
    else workspace OK
        Tables->>Tables: buildIdByName(table.schema)
        Tables->>Tables: rowDataNameToId(wireRows) id-keyed rows
        Tables->>Tables: idKeyedRows.every(empty)? fail fast
        Tables->>Service: "replaceTableRows({tableId, rows, workspaceId, userId}, table, requestId)"
        Service->>DB: acquireRowOrderLock(tableId)
        Service->>DB: DELETE WHERE tableId
        Service->>DB: INSERT id-keyed rows in batches
        Service-->>Tables: "{deletedCount, insertedCount}"
        Tables-->>Caller: "{success:true, output:{rowCount}}"
    end
Loading

Reviews (2): Last reviewed commit: "fix(mothership): tenant-check outputTabl..." | Re-trigger Greptile

Comment thread apps/sim/lib/copilot/request/tools/tables.ts
… no columns

Review feedback: the all-rows-empty guard let mixed batches slip
unmatched rows through as empty objects. Reject on the first row that
maps to zero columns, naming the row. Adds the missing CSV-suite parity
tests (no-matching-headers, service-error surfacing).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@TheodoreSpeaks

Copy link
Copy Markdown
Collaborator Author

@greptile review

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.

1 participant