Skip to content

Commit 0ca127c

Browse files
fix(cli): review round 2 — body-cursor paging, timestamp sanitization
## `tables rows query` printed nothing (Cursor, High) `isCursorList` only looked for `cursor` on the query slot, but `queryRows` is a POST whose whole filter — cursor included — is in the body. It therefore took the single-request path, which handed an array of rows to `printRecord` and printed an empty record, and it never auto-paged past the first page. Replaced with `cursorSlot`, which checks both slots and tells the pager where to put the cursor back. Added a defensive branch so an array reaching the single-resource path renders as a list with inferred columns rather than silently printing nothing. ## Invalid timestamps bypassed sanitization (Greptile, P1 security) `timestamp()` echoes an unparseable value verbatim, and that value is still server-supplied — so the branch was a way past every other formatter for the control sequences round 1 closed. Now sanitized on that path too. Audited the remaining formatters: no other path returns a server value unsanitized. Both fixes have tests that fail against the previous code. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EsThUPqZXjwuuyRjBbmVkj
1 parent 4a6ac48 commit 0ca127c

4 files changed

Lines changed: 81 additions & 10 deletions

File tree

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

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import {
99
printRecord,
1010
sanitize,
1111
text,
12+
timestamp,
1213
visibleWidth,
1314
} from './render.js'
1415

@@ -237,4 +238,14 @@ describe('sanitize', () => {
237238
it('is applied to values passing through text()', () => {
238239
expect(text(`${ESC}]0;x\u0007safe`)).toBe('safe')
239240
})
241+
242+
it('is applied to an unparseable timestamp, which is echoed verbatim', () => {
243+
// The invalid-date branch returns the server's own string, so it was a way
244+
// past every other formatter.
245+
expect(timestamp(`${ESC}]0;pwned\u0007not-a-date`)).toBe('not-a-date')
246+
})
247+
248+
it('still formats a valid timestamp normally', () => {
249+
expect(timestamp('2026-07-31T09:14:22.500Z')).toBe('2026-07-31 09:14:22')
250+
})
240251
})

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

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,10 @@ export function text(value: unknown): string {
6363
export function timestamp(value: string | null | undefined): string {
6464
if (!value) return EMPTY
6565
const date = new Date(value)
66-
if (Number.isNaN(date.getTime())) return String(value)
66+
// Sanitized on the way out: an unparseable value is echoed verbatim, and it is
67+
// still server-supplied, so this branch was a way to smuggle control sequences
68+
// past every other formatter.
69+
if (Number.isNaN(date.getTime())) return sanitize(String(value))
6770
return date.toISOString().replace('T', ' ').slice(0, 19)
6871
}
6972

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

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,3 +108,39 @@ describe('commands parsed through commander', () => {
108108
expect(mockRequest).not.toHaveBeenCalled()
109109
})
110110
})
111+
112+
describe('pagination slot', () => {
113+
it('pages a body-cursor operation and renders its rows', async () => {
114+
// `queryRows` is a POST whose cursor is in the body, not the query. Reading
115+
// only the query made it take the single-request path and print nothing.
116+
mockRequest.mockReset()
117+
mockRequest
118+
.mockResolvedValueOnce({ data: [{ id: 'r1' }], nextCursor: 'c1' })
119+
.mockResolvedValueOnce({ data: [{ id: 'r2' }], nextCursor: null })
120+
const lines: string[] = []
121+
vi.spyOn(console, 'log').mockImplementation((line: string) => {
122+
lines.push(line)
123+
})
124+
125+
await program().parseAsync(['node', 'sim', 'tables', 'rows', 'query', 'tbl_1'])
126+
127+
expect(mockRequest).toHaveBeenCalledTimes(2)
128+
// Second call resumes from the cursor — in the body, where the contract puts it.
129+
expect(mockRequest.mock.calls[1][1].body).toMatchObject({ cursor: 'c1' })
130+
expect(mockRequest.mock.calls[1][1].query).not.toHaveProperty('cursor')
131+
// And the rows actually render rather than printing an empty record.
132+
expect(JSON.parse(lines[0])).toEqual([{ id: 'r1' }, { id: 'r2' }])
133+
})
134+
135+
it('keeps a query-cursor operation on the query slot', async () => {
136+
mockRequest.mockReset()
137+
mockRequest
138+
.mockResolvedValueOnce({ data: [{ id: 'a' }], nextCursor: 'c1' })
139+
.mockResolvedValueOnce({ data: [{ id: 'b' }], nextCursor: null })
140+
vi.spyOn(console, 'log').mockImplementation(() => {})
141+
142+
await program().parseAsync(['node', 'sim', 'logs', 'list'])
143+
144+
expect(mockRequest.mock.calls[1][1].query).toMatchObject({ cursor: 'c1' })
145+
})
146+
})

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

Lines changed: 30 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -96,10 +96,24 @@ function summaryFor(operation: V2OperationName): string | undefined {
9696
return (V2_OPERATIONS[operation] as { summary?: string }).summary
9797
}
9898

99-
/** Whether the operation answers with the `{ data, nextCursor }` list envelope. */
100-
function isCursorList(operation: V2OperationName): boolean {
101-
const spec = V2_OPERATIONS[operation] as { query?: Record<string, FieldSpec> }
102-
return Boolean(spec.query && 'cursor' in spec.query)
99+
/**
100+
* Which request slot carries the pagination cursor, or null for a non-list
101+
* operation.
102+
*
103+
* Both slots have to be checked: most lists take `cursor` as a query param, but
104+
* `queryRows` is a POST whose whole filter — cursor included — is in the body.
105+
* Looking only at the query made it fall through to the single-request path,
106+
* which then rendered its array of rows through `printRecord` and printed
107+
* nothing at all, and never auto-paged.
108+
*/
109+
function cursorSlot(operation: V2OperationName): 'query' | 'body' | null {
110+
const spec = V2_OPERATIONS[operation] as {
111+
query?: Record<string, FieldSpec>
112+
body?: Record<string, FieldSpec>
113+
}
114+
if (spec.query && 'cursor' in spec.query) return 'query'
115+
if (spec.body && 'cursor' in spec.body) return 'body'
116+
return null
103117
}
104118

105119
/** Adds the flags a field needs, or nothing when the contract omits it. */
@@ -199,7 +213,8 @@ function buildLeaf(operation: V2OperationName, spec: CommandSpec, leafName: stri
199213
const { client, profile } = clientFrom(host)
200214
const request = buildRequest(operation, positional, flags, profile.workspaceId)
201215

202-
if (isCursorList(operation)) {
216+
const paging = cursorSlot(operation)
217+
if (paging) {
203218
const rawLimit = Number.parseInt(String(flags.limit ?? DEFAULT_LIMIT), 10)
204219
if (Number.isNaN(rawLimit) || rawLimit < 0) {
205220
throw new SimApiError('--limit must be a non-negative number', 0)
@@ -210,10 +225,14 @@ function buildLeaf(operation: V2OperationName, spec: CommandSpec, leafName: stri
210225
const rows: unknown[] = []
211226
let cursor: string | null = null
212227
do {
228+
// The cursor goes back in whichever slot the contract declared it.
213229
const page: V2Page<unknown> = await client.request(request.path, {
214230
method: operationSpec.method as 'GET' | 'POST',
215-
query: { ...request.query, cursor },
216-
body: request.body,
231+
query: paging === 'query' ? { ...request.query, cursor } : request.query,
232+
body:
233+
paging === 'body'
234+
? { ...(request.body ?? {}), ...(cursor ? { cursor } : {}) }
235+
: request.body,
217236
})
218237
rows.push(...page.data)
219238
cursor = page.nextCursor
@@ -231,8 +250,10 @@ function buildLeaf(operation: V2OperationName, spec: CommandSpec, leafName: stri
231250
})
232251
const data = result?.data ?? result
233252

234-
if (spec.columns && Array.isArray(data)) {
235-
printList(profile.output, data, columnsFrom(spec.columns))
253+
if (Array.isArray(data)) {
254+
// Reached when a non-paginated operation answers with a collection.
255+
// `printRecord` would silently print nothing for an array.
256+
printList(profile.output, data, spec.columns ? columnsFrom(spec.columns) : inferColumns(data))
236257
return
237258
}
238259

0 commit comments

Comments
 (0)