Skip to content

Commit 681ee38

Browse files
fix(cli): review round 5 — header sanitization, auth ordering, stale suggestion
## Table headers stayed executable (Greptile, P1 security) Round 1 sanitized cell *values* but not the column *names*, and a table's columns are user-defined — so the same control sequences were still executable one row higher, in the header. Sanitizing is now done inside `renderTable` rather than at each call site, so a future column source cannot reopen it, with the two key-derived column builders covered as well. ## Fresh install was told the wrong first step (Cursor, Low) Generated commands read `profile.workspaceId` directly, bypassing `requireWorkspace()` — which checks the key first precisely so a new user is told to log in rather than to set a workspace they cannot use yet. That ordering was fixed for the hand-written commands earlier and reintroduced by the runtime. `sim tables list` on an empty profile now says "Not logged in" again. ## A stale suggestion shadowed the fallback (Cursor, Medium) The picker took `selected ?? suggestedWorkspaceId ?? lastActiveWorkspaceId`. The suggestion comes from a profile the CLI wrote earlier, so it can name a workspace the user has since left — and merely being truthy, it blocked the last-active fallback and left the card on "no workspace" with a perfectly good one available. It now counts only when it resolves against the loaded list. Two of the three have tests that fail against the previous code; the third is verified end-to-end (`sim tables list` on an empty profile). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EsThUPqZXjwuuyRjBbmVkj
1 parent 678bdc4 commit 681ee38

5 files changed

Lines changed: 53 additions & 12 deletions

File tree

apps/sim/app/cli/auth/cli-auth-view.tsx

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -71,11 +71,21 @@ export function CliAuthView() {
7171
*/
7272
const loadingWorkspaces = isPlatform && workspaces.isPending
7373

74-
// The terminal's suggestion, then the user's last active workspace. Derived at
75-
// render rather than synced into state through an effect, so the first paint
76-
// after the list loads already shows the right row.
77-
const workspaceId =
78-
selected ?? request.suggestedWorkspaceId ?? workspaces.data?.lastActiveWorkspaceId ?? null
74+
/**
75+
* The terminal's suggestion, then the user's last active workspace. Derived at
76+
* render rather than synced into state through an effect, so the first paint
77+
* after the list loads already shows the right row.
78+
*
79+
* The suggestion only counts when it resolves to a workspace the user
80+
* actually has. It comes from a profile the CLI wrote earlier, so it can name
81+
* a workspace they have since left or one that no longer exists — and being
82+
* merely truthy, it used to shadow the last-active fallback and leave the card
83+
* on "no workspace" with a perfectly good one available.
84+
*/
85+
const suggested = workspaces.data?.workspaces.some((w) => w.id === request.suggestedWorkspaceId)
86+
? request.suggestedWorkspaceId
87+
: null
88+
const workspaceId = selected ?? suggested ?? workspaces.data?.lastActiveWorkspaceId ?? null
7989
const chosen = workspaces.data?.workspaces.find((w) => w.id === workspaceId)
8090

8191
// Only an admin can bind a key to a workspace. Anything less still gets a

packages/sim-cli/src/commands/hand-written.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -96,7 +96,9 @@ function rowColumns(rows: Row[]): Column<Row>[] {
9696
return [
9797
{ header: 'id', value: (row) => row.id },
9898
...keys.map((key) => ({
99-
header: key,
99+
// A table's column names are user-defined, so the header is remote
100+
// content just as much as the cell beneath it.
101+
header: sanitize(key),
100102
value: (row: Row) => {
101103
const value = row.data[key]
102104
if (value === null || value === undefined) return text(null)

packages/sim-cli/src/output/render.test.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import {
1414
} from './render.js'
1515

1616
const ESC = String.fromCharCode(27)
17+
const BEL = String.fromCharCode(7)
1718

1819
/** Colour is stripped when not writing to a TTY, so force it on for these assertions. */
1920
const coloured = new Chalk({ level: 1 })
@@ -239,6 +240,15 @@ describe('sanitize', () => {
239240
expect(text(`${ESC}]0;x\u0007safe`)).toBe('safe')
240241
})
241242

243+
it('is applied to a table header, not only its cells', () => {
244+
// A table's column names are user-defined, so the header is remote content
245+
// too — sanitizing cells alone left the sequences executable one row up.
246+
const hostile = `${ESC}]0;pwned${BEL}email`
247+
printList('table', [{ v: 'a@b.co' }], [{ header: hostile, value: () => 'a@b.co' }])
248+
expect(logged[0]).not.toContain(ESC)
249+
expect(logged[0]).toContain('EMAIL')
250+
})
251+
242252
it('is applied to an unparseable timestamp, which is echoed verbatim', () => {
243253
// The invalid-date branch returns the server's own string, so it was a way
244254
// past every other formatter.

packages/sim-cli/src/output/render.ts

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -134,13 +134,17 @@ function pad(value: string, width: number): string {
134134
function renderTable<T>(rows: T[], columns: Column<T>[]): string {
135135
if (rows.length === 0) return chalk.dim('No results.')
136136

137+
// A header can be a user-defined column name (a table's own columns), so it is
138+
// remote content and gets the same treatment as a cell. Doing it here rather
139+
// than only at each call site means a future column source cannot reopen this.
140+
const headers = columns.map((column) => sanitize(column.header))
137141
const cells = rows.map((row) => columns.map((column) => column.value(row)))
138-
const widths = columns.map((column, index) =>
139-
Math.max(visibleWidth(column.header), ...cells.map((line) => visibleWidth(line[index])))
142+
const widths = columns.map((_column, index) =>
143+
Math.max(visibleWidth(headers[index]), ...cells.map((line) => visibleWidth(line[index])))
140144
)
141145

142-
const header = columns
143-
.map((column, index) => chalk.dim(pad(column.header.toUpperCase(), widths[index])))
146+
const header = headers
147+
.map((label, index) => chalk.dim(pad(label.toUpperCase(), widths[index])))
144148
.join(' ')
145149
.trimEnd()
146150

packages/sim-cli/src/runtime/build.ts

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -86,7 +86,10 @@ function inferColumns(rows: unknown[]): Column<unknown>[] {
8686
}
8787

8888
return keys.map((key) => ({
89-
header: key,
89+
// The key itself is remote data when the rows are user-defined, and the
90+
// header is printed just like a cell — sanitizing values but not headers
91+
// left the same control sequences executable one row higher.
92+
header: sanitize(key),
9093
value: (row: unknown) => renderCell(at(row, key), 'auto'),
9194
}))
9295
}
@@ -211,7 +214,19 @@ function buildLeaf(operation: V2OperationName, spec: CommandSpec, leafName: stri
211214
}
212215

213216
const { client, profile } = clientFrom(host)
214-
const request = buildRequest(operation, positional, flags, profile.workspaceId)
217+
// `requireWorkspace` checks the key first on purpose, so a fresh install is
218+
// told to log in rather than to set a workspace it cannot use yet. Reading
219+
// `profile.workspaceId` directly skipped that ordering.
220+
const needsWorkspace = Boolean(
221+
(operationSpec.query && PROFILE_INJECTED_FIELD in operationSpec.query) ||
222+
(operationSpec.body && PROFILE_INJECTED_FIELD in operationSpec.body)
223+
)
224+
const request = buildRequest(
225+
operation,
226+
positional,
227+
flags,
228+
needsWorkspace ? client.requireWorkspace() : profile.workspaceId
229+
)
215230

216231
const paging = cursorSlot(operation)
217232
if (paging) {

0 commit comments

Comments
 (0)