Skip to content

fix(types): validate unsafe websocket casts#823

Open
ColeMurray wants to merge 1 commit into
mainfrom
automation/unsafe-cast-sweep-20260624
Open

fix(types): validate unsafe websocket casts#823
ColeMurray wants to merge 1 commit into
mainfrom
automation/unsafe-cast-sweep-20260624

Conversation

@ColeMurray

@ColeMurray ColeMurray commented Jun 24, 2026

Copy link
Copy Markdown
Owner

This is an automated nightly unsafe-cast remediation. It replaces selected high-risk TypeScript assertions at trust boundaries with Zod safeParse validation, following the TypeScript Coding Standards unsafe-cast / parse-don't-assert guidance and the Zod boundary-validation pattern established in PR #807.

Finding Risk Cast removed Fix
packages/control-plane/src/session/durable-object.ts:1126 HIGH JSON.parse(message) as SandboxEvent for sandbox WebSocket events, bypassing the existing sandboxEventSchema Parse with shared sandboxEventSchema.safeParse before processSandboxEvent
packages/control-plane/src/session/durable-object.ts:1140 HIGH JSON.parse(message) as ClientMessage for client WebSocket messages Add shared clientMessageSchema / z.infer type source and parse with safeParse before dispatch
packages/web/src/components/slack-notify-event.tsx:58 HIGH obj as unknown as SlackNotifySuccessOutput / obj as unknown as ParsedDenial double-casts for parsed tool output Add shared Slack notify envelope Zod schemas and parse with slackNotifyToolEnvelopeSchema.safeParse

Verification:

Command Result
npm run build -w @open-inspect/shared Passed
npm run build -w @open-inspect/control-plane Passed
NODE_ENV=production npm run build -w @open-inspect/web Passed
npm run typecheck Passed
npm run lint Passed after temporarily moving pre-existing untracked .opencode/ tooling directory out of the workspace so the clean-repo lint target was evaluated
npm run format Passed
npm test -w @open-inspect/shared Passed
npm test -w @open-inspect/control-plane Passed
npm test -w @open-inspect/web Passed

Created with Open-Inspect

Summary by CodeRabbit

  • New Features

    • Added stronger validation for incoming messages and Slack-related payloads across the app.
    • Expanded supported Slack notification data checks, including success and denial responses.
  • Bug Fixes

    • Reduced the risk of malformed WebSocket messages being processed.
    • Improved handling of Slack notification envelopes by rejecting invalid shapes earlier.
  • Tests

    • Added coverage for valid and invalid client message and Slack envelope cases.

@open-inspect open-inspect Bot added the automation:unsafe-cast Automated unsafe TypeScript cast remediation label Jun 24, 2026
@coderabbitai

coderabbitai Bot commented Jun 24, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

This PR replaces compile-time TypeScript types with Zod runtime schemas for Attachment, ClientMessage, and Slack notify envelope types in the shared package. The Durable Object's WebSocket handlers now use safeParse to validate incoming messages, and the Slack notify web component replaces manual type guards with schema-based parsing.

Changes

ClientMessage/Attachment Zod Schemas and Durable Object Validation

Layer / File(s) Summary
attachmentSchema and clientMessageSchema definitions and tests
packages/shared/src/types/index.ts, packages/shared/src/types/boundary-schemas.test.ts
Attachment is replaced by a Zod object schema and ClientMessage by a Zod discriminated union keyed on type; tests cover valid prompt-with-attachments, malformed subscribe, and optional-cursor presence messages.
Durable Object WebSocket safeParse wiring
packages/control-plane/src/session/durable-object.ts
handleSandboxMessage and handleClientMessage replace unchecked JSON.parse + type cast with sandboxEventSchema.safeParse and clientMessageSchema.safeParse, throwing on validation failure before further dispatch.

Slack Notify Zod Schemas and Web Component Validation

Layer / File(s) Summary
Slack Zod schema definitions, re-exports, and tests
packages/shared/src/slack/types.ts, packages/shared/src/slack/index.ts, packages/shared/src/slack/types.test.ts
slackDenialReasonSchema, slackNotifySuccessOutputSchema, and slackNotifyToolEnvelopeSchema are introduced as Zod schemas with inferred types; all three are re-exported from the slack package index; tests verify valid success, malformed success, and denial envelope parsing.
Slack notify web component envelope parsing update
packages/web/src/components/slack-notify-event.tsx
parseEnvelope is rewritten to call slackNotifyToolEnvelopeSchema.safeParse and return result.data or null, removing the ParsedDenial type alias and all prior manual shape/type guards.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Possibly related PRs

  • ColeMurray/background-agents#609: Introduces the /sessions/:id/slack-notify control-plane route that emits tool_call/tool_result sandbox events, which are now validated by sandboxEventSchema in the Durable Object handler updated in this PR.
  • ColeMurray/background-agents#613: Consolidates shared Slack-notify types in packages/shared/src/slack/types.ts and updates slack-notify-event.tsx, directly preceding this PR's conversion of those same types to Zod schemas.
  • ColeMurray/background-agents#618: Refactors SlackNotifyToolEnvelope, SlackWireDenialReason, and slack-notify-event.tsx denial parsing — overlapping with this PR's Zod schema replacement of the same envelope contract.

Suggested reviewers

  • divyeshpcodezee-tech

Poem

🐇 Hoppity-hop through schemas we go,
No more raw as casts in the code's flow!
safeParse leaps over each invalid byte,
Zod keeps the data contracts tight.
The bunny validates — and everything's right! ✨

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately summarizes the main change: replacing unsafe websocket casts with runtime validation at type boundaries.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch automation/unsafe-cast-sweep-20260624

Warning

There were issues while running some tools. Please review the errors and either fix the tool's configuration or disable the tool if it's a critical failure.

🔧 ESLint

If the error stems from missing dependencies, add them to the package.json file. For unrecoverable errors (e.g., due to private dependencies), disable the tool in the CodeRabbit configuration.

ESLint install failed: one or more packages not found in the registry.


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@github-actions

Copy link
Copy Markdown

Terraform Validation Results

Step Status
Format
Init
Validate

Note: Terraform plan was skipped because secrets are not configured. This is expected for external contributors. See docs/GETTING_STARTED.md for setup instructions.

Pushed by: @ColeMurray, Action: pull_request

@open-inspect open-inspect Bot left a comment

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.

Summary

PR #823, fix(types): validate unsafe websocket casts, by @ColeMurray replaces unsafe boundary casts with shared Zod schemas for websocket messages and Slack notify envelopes. Reviewed 7 changed files (+169/-58); the implementation looks consistent with the existing producers/consumers and improves trust-boundary validation without introducing blocking issues.

Critical Issues

None found.

Suggestions

None.

Nitpicks

None.

Positive Feedback

  • The new clientMessageSchema and Slack notify envelope schemas make the runtime contract the source of truth for the corresponding TypeScript types.
  • The Durable Object websocket handlers now fail closed on malformed client and sandbox messages instead of dispatching unchecked data.
  • Focused schema tests cover valid and malformed client/Slack envelope cases.

Questions

None.

Verification

  • npm test -w @open-inspect/shared: 226 tests passed.
  • npm run build -w @open-inspect/shared: passed.

Verdict

Approve.

private async handleSandboxMessage(ws: WebSocket, message: string): Promise<void> {
try {
const event = JSON.parse(message) as SandboxEvent;
const result = sandboxEventSchema.safeParse(JSON.parse(message));

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.

[deep review] This turns the Zod schema into a yes/no gate and then throws away the useful boundary failure details. Since this PR is establishing the parse-dont-assert pattern for websocket trust boundaries, can we avoid copying this inline pattern into each handler and instead use a small shared parse helper that returns/logs the schema issues with the boundary name? Otherwise future schema drift between the sandbox/client and control plane becomes a generic Invalid sandbox event / Invalid client message with no actionable shape information, and every new websocket boundary is likely to grow the same ad-hoc parse/throw block.

| { type: "typing" }
| { type: "presence"; status: "active" | "idle"; cursor?: { line: number; file: string } }
| { type: "fetch_history"; cursor: { timestamp: number; id: string }; limit?: number };
export const clientMessageSchema = z.discriminatedUnion("type", [

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.

[deep review] This shared index is already 865 lines and is now taking on more runtime boundary-validation logic, not just type exports. Can we decompose the websocket/boundary schemas into a focused module and re-export them from here instead? This keeps the contract reusable without turning the catch-all shared types file into the canonical home for every future Zod schema, which is exactly how this file crosses the 1k-line threshold and becomes harder to scan.

@coderabbitai coderabbitai 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.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@packages/shared/src/slack/types.ts`:
- Around line 68-76: The Slack notification schema currently exposes a duration
field named retryAfter without its unit, so rename the field in
slackNotifyToolEnvelopeSchema to encode that it is in seconds and update all
consumers to match. Keep the discriminated union shape in types.ts consistent,
then adjust downstream mappings such as slack-notify-event.tsx so the new schema
field feeds the existing retryAfterSeconds prop instead of propagating the
ambiguous name.

In `@packages/shared/src/types/index.ts`:
- Around line 338-341: The fetch_history schema in the shared types is stricter
than the handler contract: the current z.object for the fetch_history payload
requires cursor, which prevents handleFetchHistory from reaching its existing
data.cursor? INVALID_CURSOR path. Update the fetch_history type definition to
match the handler’s optional cursor behavior, or make the handler
signature/error handling consistent with the required cursor contract in the
same change, using the fetch_history and handleFetchHistory symbols to keep both
aligned.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 0a6d0169-adb2-41c5-a912-2fcbbdd78134

📥 Commits

Reviewing files that changed from the base of the PR and between c935020 and 76c0e53.

📒 Files selected for processing (7)
  • packages/control-plane/src/session/durable-object.ts
  • packages/shared/src/slack/index.ts
  • packages/shared/src/slack/types.test.ts
  • packages/shared/src/slack/types.ts
  • packages/shared/src/types/boundary-schemas.test.ts
  • packages/shared/src/types/index.ts
  • packages/web/src/components/slack-notify-event.tsx

Comment on lines +68 to +76
export const slackNotifyToolEnvelopeSchema = z.discriminatedUnion("ok", [
slackNotifySuccessOutputSchema,
z.object({
ok: z.literal(false),
reason: slackDenialReasonSchema,
agentMessage: z.string(),
retryAfter: z.number().optional(),
}),
]);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win

Encode the duration unit in the retryAfter field name.

retryAfter is a duration but its name omits the unit. The web component already maps it to a retryAfterSeconds prop (slack-notify-event.tsx Line 131), confirming the value is in seconds. Since this field is being threaded through a new schema path, rename it here (the root) and update consumers.

♻️ Proposed rename
   z.object({
     ok: z.literal(false),
     reason: slackDenialReasonSchema,
     agentMessage: z.string(),
-    retryAfter: z.number().optional(),
+    retryAfterSeconds: z.number().optional(),
   }),

Downstream in slack-notify-event.tsx:

-              retryAfterSeconds={envelopeDenial?.retryAfter}
+              retryAfterSeconds={envelopeDenial?.retryAfterSeconds}

As per coding guidelines: "encode the unit in variable names" and "When threading existing fields through new code paths in TypeScript, evaluate whether the existing design (naming, types, units) is correct and fix bad names or units in the same change rather than propagating problems".

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
export const slackNotifyToolEnvelopeSchema = z.discriminatedUnion("ok", [
slackNotifySuccessOutputSchema,
z.object({
ok: z.literal(false),
reason: slackDenialReasonSchema,
agentMessage: z.string(),
retryAfter: z.number().optional(),
}),
]);
export const slackNotifyToolEnvelopeSchema = z.discriminatedUnion("ok", [
slackNotifySuccessOutputSchema,
z.object({
ok: z.literal(false),
reason: slackDenialReasonSchema,
agentMessage: z.string(),
retryAfterSeconds: z.number().optional(),
}),
]);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/shared/src/slack/types.ts` around lines 68 - 76, The Slack
notification schema currently exposes a duration field named retryAfter without
its unit, so rename the field in slackNotifyToolEnvelopeSchema to encode that it
is in seconds and update all consumers to match. Keep the discriminated union
shape in types.ts consistent, then adjust downstream mappings such as
slack-notify-event.tsx so the new schema field feeds the existing
retryAfterSeconds prop instead of propagating the ambiguous name.

Source: Coding guidelines

Comment on lines +338 to +341
z.object({
type: z.literal("fetch_history"),
cursor: z.object({ timestamp: z.number(), id: z.string() }),
limit: z.number().optional(),

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Keep fetch_history aligned with the handler contract.

Line 340 rejects omitted cursors before handleFetchHistory can return its existing INVALID_CURSOR response for data.cursor?. Either make cursor optional here to preserve that path, or tighten the handler signature/error path in the same change.

Proposed contract-preserving fix
   z.object({
     type: z.literal("fetch_history"),
-    cursor: z.object({ timestamp: z.number(), id: z.string() }),
+    cursor: z.object({ timestamp: z.number(), id: z.string() }).optional(),
     limit: z.number().optional(),
   }),
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
z.object({
type: z.literal("fetch_history"),
cursor: z.object({ timestamp: z.number(), id: z.string() }),
limit: z.number().optional(),
z.object({
type: z.literal("fetch_history"),
cursor: z.object({ timestamp: z.number(), id: z.string() }).optional(),
limit: z.number().optional(),
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/shared/src/types/index.ts` around lines 338 - 341, The fetch_history
schema in the shared types is stricter than the handler contract: the current
z.object for the fetch_history payload requires cursor, which prevents
handleFetchHistory from reaching its existing data.cursor? INVALID_CURSOR path.
Update the fetch_history type definition to match the handler’s optional cursor
behavior, or make the handler signature/error handling consistent with the
required cursor contract in the same change, using the fetch_history and
handleFetchHistory symbols to keep both aligned.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

automation:unsafe-cast Automated unsafe TypeScript cast remediation

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant