Get this on every PR automatically. Webhook fires, AI reviews, you approve.
Install on stripe/link-cli →
// reviewed by pulllight

PullLight found 5 issues

stripe/link-cli #135 — Add redirect URI callback for spend request approval flow wi… by @sylwang-stripe Jul 7, 2026
1 high 4 medium
🔍
// convert to your repo
PullLight caught 5 1 high, 4 medium on this PR.
Install on stripe/link-cli to get this on every PR automatically — 60 seconds.
Findings
5 issues
medium other
The HTML response body `<html><body><p>${message}</p></body></html>` interpolates `message` which is sourced from `STATUS_MESSAGES[raw]` — a fixed lookup table — so that part is safe. However, `raw` itself (the unsanitized query-param value) is never interpolated into the response, so there is no XSS here. No action needed on that front.
// packages/cli/src/utils/local-callback-server.ts:62
high resource-leak
`resolveCallback` is called at most once (on the first HTTP request), but the server is never closed after that first callback fires. Subsequent requests keep the server alive indefinitely. The caller is expected to call `close()`, but if the caller's cleanup path is skipped (e.g. an exception between `waitForCallback` resolving and the `finally` block in the effect), the server stays open and the Node process will not exit. The server should auto-close itself after the first callback is received.
// packages/cli/src/utils/local-callback-server.ts:80
⚡ Suggested fix
      resolveCallback?.({ status });
      server.close();
medium error-handling
When `pollUntilApproved` resolves with a terminal status, `onTerminal` is called but `setStatus` is never called inside `useApprovalPolling` — the caller's `onTerminal` is responsible for calling `setStatus`. However, if `onTerminal` throws, the `setTimeout(() => onComplete(final), ...)` on the next line still fires, potentially calling `onComplete` with a partially-updated state. Wrap the `setTimeout` in the same try block or move it inside `onTerminal`.
// packages/cli/src/commands/spend-request/use-approval-polling.ts:76
medium logic
`useApprovalPolling` is passed `setStatus: () => setStatus('polling')` — it ignores the argument and always sets `'polling'`. This means if the hook ever calls `setStatus('error')` (which the new signature no longer does, but the contract `setStatus: (s: 'polling') => void` still allows), the error would be silently swallowed. More importantly, the `status` state passed to `useApprovalPolling` is the component's full status (including `'denied'`, `'expired'`, `'error'`), but the hook's `isWaiting` guard only checks `'waiting' | 'polling'`, so once the callback path sets `status` to `'success'`/`'denied'`/`'expired'`, the polling hook correctly stays idle — but if `pollingFallback` is set to `true` after the callback path already resolved, polling will start racing with the already-completed flow. Ensure `pollingFallback` is never set to `true` after a terminal status has been reached.
// packages/cli/src/commands/spend-request/create.tsx:63
medium logic
In the `denied`, `expired`, and error branches of the callback path, `setTimeout(() => onComplete(sanitizedFinal), DISPLAY_DELAY_MS)` is called, but in the `denied`/`expired` UI branches the component renders a terminal view with no further action — `onComplete` is still invoked, which may be unexpected for callers that treat `onComplete` as a success-only signal. Verify that callers of `RequestApproval` handle non-approved results from `onComplete` correctly, or use a separate `onError`/`onDenied` callback.
// packages/cli/src/commands/spend-request/request-approval.tsx:57
model: claude-sonnet-4-5   input: 8691 tokens   output: 1281 tokens