From 0682881f45f630b9d88f90e411e0838bfaf17a7b Mon Sep 17 00:00:00 2001 From: "open-inspect[bot]" <255062780+open-inspect[bot]@users.noreply.github.com> Date: Sun, 26 Jul 2026 23:26:30 -0700 Subject: [PATCH 1/7] fix(types): validate session prompt boundary (#1104) This is an automated nightly unsafe-cast remediation sweep. It replaces a high-risk request-body assertion at the authenticated session prompt boundary with Zod-backed parsing, following the TypeScript Coding Standards guidance for unsafe casts and parse-don't-assert validation. The shared schemas follow the Zod boundary-validation pattern established in PR #807. | Finding | Risk | Cast Removed | Fix | | --- | --- | --- | --- | | `packages/control-plane/src/routes/session-prompt.ts:43` | HIGH | `(await request.json()) as { content: string; source?: string; model?: string; reasoningEffort?: string; attachments?: unknown; callbackContext?: CallbackContext }` | Added shared `sendPromptRequestSchema` and `callbackContextSchema`, then used `safeParse` at the request boundary before forwarding prompt data. | Verification: | Command | Result | | --- | --- | | `npm run build -w @open-inspect/shared` | Passed | | `npm run build -w @open-inspect/control-plane` | Passed | | `npm run typecheck` | Passed | | `npm run lint` | Passed in a clean repo view after temporarily moving untracked local `.opencode` helper files that are not part of this branch or repository checkout. | | `npm run format` | Passed | | `npm test -w @open-inspect/shared -- --run src/types/boundary-schemas.test.ts` | Passed | | `npm test -w @open-inspect/control-plane -- --run src/router.session-prompt.test.ts` | Passed | | `npm test -w @open-inspect/shared` | Passed | | `npm test -w @open-inspect/control-plane` | Passed | --- *Created with [Open-Inspect](https://open-inspect-prod.vercel.app/session/a3ffb0d8f57202df1cc68ec9338de239)* Co-authored-by: waclaude Co-authored-by: Cole Murray --- .../src/routes/session-prompt.ts | 35 ++++++--- .../shared/src/types/boundary-schemas.test.ts | 78 +++++++++++++++++++ packages/shared/src/types/index.ts | 5 ++ packages/shared/src/types/session-api.ts | 59 +++++++++----- 4 files changed, 145 insertions(+), 32 deletions(-) diff --git a/packages/control-plane/src/routes/session-prompt.ts b/packages/control-plane/src/routes/session-prompt.ts index d69a08d77..fcecf6902 100644 --- a/packages/control-plane/src/routes/session-prompt.ts +++ b/packages/control-plane/src/routes/session-prompt.ts @@ -1,5 +1,7 @@ import { MAX_SESSION_ATTACHMENTS_PER_MESSAGE, + callbackContextSchema, + sendPromptRequestSchema, sessionAttachmentReferencesSchema, type CallbackContext, type SessionAttachmentReference, @@ -45,30 +47,39 @@ async function handleSessionPrompt( const sessionId = match.groups?.id; if (!sessionId) return error("Session ID required"); - const body = (await request.json()) as { - content: string; - source?: string; - model?: string; - reasoningEffort?: string; - attachments?: unknown; - callbackContext?: CallbackContext; - }; + let rawBody: unknown; + try { + rawBody = await request.json(); + } catch { + return error("Invalid JSON body", 400); + } + + const enforcement = applyIdentityEnforcement(ctx, "prompt", rawBody); + if (enforcement.rejection) return enforcement.rejection; - if (!body.content) { + const bodyResult = sendPromptRequestSchema.safeParse(rawBody); + if (!bodyResult.success) { return error("content is required"); } - const enforcement = applyIdentityEnforcement(ctx, "prompt", body); - if (enforcement.rejection) return enforcement.rejection; + const body = bodyResult.data; const attachments = validateAttachments(body.attachments); if (attachments instanceof Response) return attachments; + let callbackContext: CallbackContext | undefined; + if (mayAttachCallbackContext(ctx) && body.callbackContext !== undefined) { + const callbackContextResult = callbackContextSchema.safeParse(body.callbackContext); + if (!callbackContextResult.success) { + return error("Invalid callbackContext", 400); + } + callbackContext = callbackContextResult.data; + } + // The author comes from the verified principal (user → canonical id, bot → // asserted actor); an actorless bot prompt is system-initiated and stays // anonymous. callbackContext is a completion notification channel — only // the bots that own callbacks may attach one. const authorId = enforcement.enforced.participantUserId ?? "anonymous"; - const callbackContext = mayAttachCallbackContext(ctx) ? body.callbackContext : undefined; if (callbackContext === undefined && body.callbackContext !== undefined) { logger.warn("Dropped callbackContext from unauthorized principal", { event: "identity.callback_context_dropped", diff --git a/packages/shared/src/types/boundary-schemas.test.ts b/packages/shared/src/types/boundary-schemas.test.ts index 743ae025e..f7e8dc33b 100644 --- a/packages/shared/src/types/boundary-schemas.test.ts +++ b/packages/shared/src/types/boundary-schemas.test.ts @@ -5,10 +5,12 @@ import { clientMessageSchema, createSessionResponseSchema, createSessionRequestSchema, + callbackContextSchema, MAX_AUTOMATION_REPOSITORIES, normalizeOptionalRepositoryPair, RepositoryPairValidationError, sandboxEventSchema, + sendPromptRequestSchema, serverMessageSchema, sendPromptResponseSchema, spawnChildSessionRequestSchema, @@ -127,6 +129,82 @@ describe("boundary schemas", () => { }); }); + describe("sendPromptRequestSchema", () => { + it("parses a valid prompt request with a Slack callback context", () => { + const result = sendPromptRequestSchema.safeParse({ + content: "Investigate the failure", + source: "slack", + model: "anthropic/claude-sonnet-4-6", + reasoningEffort: "high", + attachments: [{ attachmentId: "att-1", name: "screenshot.png" }], + callbackContext: { + source: "slack", + channel: "C123", + threadTs: "1710000000.000100", + repoFullName: "open-inspect/background-agents", + model: "anthropic/claude-sonnet-4-6", + reactionMessageTs: "1710000000.000200", + }, + }); + + expect(result.success).toBe(true); + }); + + it("rejects a malformed prompt request", () => { + expect(sendPromptRequestSchema.safeParse({ content: 123 }).success).toBe(false); + expect(sendPromptRequestSchema.safeParse({ source: "web" }).success).toBe(false); + expect(sendPromptRequestSchema.safeParse({ content: "" }).success).toBe(false); + }); + }); + + describe("callbackContextSchema", () => { + it("parses valid callback contexts", () => { + expect( + callbackContextSchema.safeParse({ + source: "slack", + channel: "C123", + threadTs: "1710000000.000100", + repoFullName: "open-inspect/background-agents", + model: "anthropic/claude-sonnet-4-6", + }).success + ).toBe(true); + expect( + callbackContextSchema.safeParse({ + source: "linear", + issueId: "issue-1", + issueIdentifier: "OI-123", + issueUrl: "https://linear.app/open-inspect/issue/OI-123/test", + repoFullName: "open-inspect/background-agents", + model: "anthropic/claude-sonnet-4-6", + transitionIssueOnStart: false, + }).success + ).toBe(true); + expect( + callbackContextSchema.safeParse({ + source: "automation", + automationId: "automation-1", + runId: "run-1", + automationName: "Nightly sweep", + }).success + ).toBe(true); + }); + + it("rejects malformed or partial callback contexts", () => { + expect(callbackContextSchema.safeParse({ source: "slack", channel: "C123" }).success).toBe( + false + ); + expect( + callbackContextSchema.safeParse({ + source: "automation", + automationId: "automation-1", + runId: null, + automationName: "Nightly sweep", + }).success + ).toBe(false); + expect(callbackContextSchema.safeParse({ source: "github" }).success).toBe(false); + }); + }); + describe("sandboxEventSchema", () => { it("parses a valid tool call event", () => { const result = sandboxEventSchema.safeParse({ diff --git a/packages/shared/src/types/index.ts b/packages/shared/src/types/index.ts index f02559a47..9352919ca 100644 --- a/packages/shared/src/types/index.ts +++ b/packages/shared/src/types/index.ts @@ -151,8 +151,12 @@ export type { } from "./session-diffs"; export { + automationCallbackContextSchema, + callbackContextSchema, linearCallbackContextSchema, linearStartCallbackSchema, + sendPromptRequestSchema, + slackCallbackContextSchema, createSessionRequestSchema, createSessionInputSchema, createMediaArtifactRequestSchema, @@ -169,6 +173,7 @@ export type { LinearStartCallback, AutomationCallbackContext, CallbackContext, + SendPromptRequest, CreateSessionRequest, CreateSessionInput, CreateMediaArtifactRequest, diff --git a/packages/shared/src/types/session-api.ts b/packages/shared/src/types/session-api.ts index d7f3550be..7f8c2beca 100644 --- a/packages/shared/src/types/session-api.ts +++ b/packages/shared/src/types/session-api.ts @@ -13,17 +13,20 @@ export interface UserPreferences { updatedAt: number; } -export interface SlackCallbackContext { - source: "slack"; - channel: string; - threadTs: string; - repoFullName: string; - model: string; - reasoningEffort?: string; - reactionMessageTs?: string; -} - const nonEmptyStringSchema = z.string().trim().min(1); + +export const slackCallbackContextSchema = z.object({ + source: z.literal("slack"), + channel: z.string(), + threadTs: z.string(), + repoFullName: z.string(), + model: z.string(), + reasoningEffort: z.string().optional(), + reactionMessageTs: z.string().optional(), +}); + +export type SlackCallbackContext = z.infer; + const linearCallbackContextBaseSchema = z.strictObject({ source: z.literal("linear"), issueId: nonEmptyStringSchema, @@ -63,17 +66,33 @@ export const linearStartCallbackSchema = z.strictObject({ export type LinearStartCallback = z.infer; -export interface AutomationCallbackContext { - source: "automation"; - automationId: string; - runId: string; - automationName: string; -} +export const automationCallbackContextSchema = z.object({ + source: z.literal("automation"), + automationId: z.string(), + runId: z.string(), + automationName: z.string(), +}); + +export type AutomationCallbackContext = z.infer; + +export const callbackContextSchema = z.union([ + slackCallbackContextSchema, + linearCallbackContextSchema, + automationCallbackContextSchema, +]); + +export type CallbackContext = z.infer; + +export const sendPromptRequestSchema = z.object({ + content: z.string().min(1), + source: z.string().optional(), + model: z.string().optional(), + reasoningEffort: z.string().optional(), + attachments: z.unknown().optional(), + callbackContext: z.unknown().optional(), +}); -export type CallbackContext = - | SlackCallbackContext - | LinearCallbackContext - | AutomationCallbackContext; +export type SendPromptRequest = z.infer; function hasRepositoryIdentifier(value: string | null | undefined): boolean { return typeof value === "string" && value.trim().length > 0; From 2fffe74032e4a1a247402ee0197b585b318dbffc Mon Sep 17 00:00:00 2001 From: "open-inspect[bot]" <255062780+open-inspect[bot]@users.noreply.github.com> Date: Sun, 26 Jul 2026 23:36:16 -0700 Subject: [PATCH 2/7] Improve form accessibility and reduce avoidable web work (#1122) ## Summary Fixes **10 root-cause task units** identified by the nightly React Doctor scan while preserving existing behavior. The changes improve screen-reader form associations, avoid redundant render and parsing work, and simplify one frequently rebuilt helper. ## Tasks Fixed 1. **`react-doctor/async-parallel`** - `packages/web/src/app/api/image-builds/route.ts`: three independent response bodies were parsed sequentially. Parsing them together reduces avoidable endpoint latency. **Human severity: medium.** 2. **`react-doctor/label-has-associated-control`** - `packages/web/src/components/settings/integrations/github-integration-settings.tsx` (code-review instructions): the visible label was not programmatically tied to its textarea, making the field harder to identify with a screen reader. Added matching `htmlFor`/`id`. **Human severity: medium.** 3. **`react-doctor/label-has-associated-control`** - `packages/web/src/components/settings/integrations/github-integration-settings.tsx` (comment-action instructions): the visible label was not programmatically tied to its textarea. Added matching `htmlFor`/`id`. **Human severity: medium.** 4. **`react-doctor/label-has-associated-control`** - `packages/web/src/components/settings/integrations/linear-integration-settings.tsx`: the issue-session instructions label was not tied to its textarea. Added matching `htmlFor`/`id`. **Human severity: medium.** 5. **`react-doctor/prefer-module-scope-pure-function`** - `packages/web/src/components/settings/sandbox-settings.tsx`: `normalizePorts` was recreated on every editor render despite capturing no render state. Hoisted it to module scope. **Human severity: low.** 6. **`react-doctor/js-combine-iterations`** - `packages/web/src/components/settings/sandbox-settings.tsx`: port normalization made repeated filter/map passes and intermediate arrays. Replaced them with one behavior-preserving pass. **Human severity: low.** 7. **`react-doctor/label-has-associated-control`** - `packages/web/src/components/settings/sandbox-settings.tsx`: the visible Web Terminal label was not associated with its switch. Added matching `htmlFor`/`id`. **Human severity: medium.** 8. **`react-doctor/control-has-associated-label`** - `packages/web/src/components/settings/sandbox-settings.tsx`: the Web Terminal switch lacked an accessible name. The same visible label/control association now supplies it. **Human severity: medium.** 9. **`react-doctor/jsx-no-constructed-context-values`** - `packages/web/src/components/sidebar-layout.tsx`: the app-shell actions context received a fresh object each render, needlessly redrawing consumers. Memoized the value over its actual dependencies. **Human severity: low.** 10. **`react-doctor/jsx-no-constructed-context-values`** - `packages/web/src/components/ui/toggle-group.tsx`: the toggle-group context received a fresh object each render. Memoized it over `variant` and `size`. **Human severity: low.** ## Task Counting A diagnostic with a non-null `fixGroupId` counts with every diagnostic in that group as one task unit; an ungrouped diagnostic counts individually. All 10 selected diagnostics were ungrouped. No `fixGroupId` was split or partially fixed. The Web Terminal label and control diagnostics are therefore counted as two task units even though one association resolves both. ## React Doctor Results - Before: **131 total** (1 error, 130 warnings) - After: **121 total** (1 error, 120 warnings) - Raw diagnostics cleared: **10** - New stable diagnostic signatures: **0** - Relevant rule counts: - `async-parallel`: 1 -> 0 - `label-has-associated-control`: 21 -> 17 - `control-has-associated-label`: 2 -> 1 - `prefer-module-scope-pure-function`: 1 -> 0 - `js-combine-iterations`: 8 -> 7 - `jsx-no-constructed-context-values`: 2 -> 0 ## Validation - `npm run build -w @open-inspect/shared` - passed - `npm run typecheck -w @open-inspect/web` - passed - `npm exec prettier -- --check packages/web` - passed - `npm run lint -w @open-inspect/web` - passed - `npm test -w @open-inspect/web` - passed, 109 files and 933 tests - `env -u NODE_ENV npm run build -w @open-inspect/web` - passed - Full React Doctor after-scan - passed, selected tasks absent and no new stable finding signatures - `npx -y react-doctor@latest . --verbose --scope changed --base origin/main --yes --blocking none` - completed; only three pre-existing `prefer-useReducer` warnings in touched files remain - `git diff --check` - passed The initial baseline production build inherited a non-standard `NODE_ENV` and failed while prerendering `/automations/new`; rerunning with `NODE_ENV` unset passed. No pre-existing validation failures remain under the project command's expected environment. ## Deferred The remaining 121 diagnostics are explicitly left for later batches or human judgment. This includes migration-scale component splitting and state consolidation, auth-related Zod migration work, local-storage key migration, iframe sandbox policy, locale/time-zone UX decisions, image optimization requiring source/runtime validation, and effect/state diagnostics whose safe fixes require broader ownership decisions. Canonical validation also identified these current false positives, which were not suppressed or edited: - `effect-needs-cleanup` in `use-session-transport.ts`: teardown already closes the socket and clears timers. - `no-loading-flag-reset-outside-finally` in `use-sidebar-sessions.ts`: the loading flag is already reset inside `finally`. - `no-json-parse-stringify-clone` in `session-target.test.ts`: JSON serialization is intentional to verify that `undefined` is omitted; `structuredClone` would change the test's semantics. ## Visual Verification No browser artifact was required because these changes do not alter rendered layout, styling, or content. Form IDs/associations and context/value allocation are non-visual. Co-authored-by: waclaude --- .../web/src/app/api/image-builds/route.ts | 8 ++-- .../github-integration-settings.tsx | 12 +++++- .../linear-integration-settings.tsx | 6 ++- .../components/settings/sandbox-settings.tsx | 37 +++++++++++++------ .../web/src/components/sidebar-layout.tsx | 10 +++-- .../web/src/components/ui/toggle-group.tsx | 22 ++++++----- 6 files changed, 65 insertions(+), 30 deletions(-) diff --git a/packages/web/src/app/api/image-builds/route.ts b/packages/web/src/app/api/image-builds/route.ts index 65f6cb618..4ab67974e 100644 --- a/packages/web/src/app/api/image-builds/route.ts +++ b/packages/web/src/app/api/image-builds/route.ts @@ -41,9 +41,11 @@ export async function GET() { return NextResponse.json({ error: "Failed to fetch image builds" }, { status: 502 }); } - const enabledData = await enabledResponse.json(); - const enabledReposData = await enabledReposResponse.json(); - const statusData = await statusResponse.json(); + const [enabledData, enabledReposData, statusData] = await Promise.all([ + enabledResponse.json(), + enabledReposResponse.json(), + statusResponse.json(), + ]); // The enabled feed also carries the cron's repository lists — serve the // scope identity plus the current fingerprint the status fold keys on. diff --git a/packages/web/src/components/settings/integrations/github-integration-settings.tsx b/packages/web/src/components/settings/integrations/github-integration-settings.tsx index e1ab37f14..afd81a976 100644 --- a/packages/web/src/components/settings/integrations/github-integration-settings.tsx +++ b/packages/web/src/components/settings/integrations/github-integration-settings.tsx @@ -438,7 +438,10 @@ function GlobalSettingsSection({
-