Skip to content

fix(sdk): harden hosted loader, lock names, and base matching - #583

Merged
khaliqgant merged 1 commit into
mainfrom
fix/hosted-loader-lock-compat
Sep 25, 2026
Merged

khaliqgant merged 1 commit into
mainfrom
fix/hosted-loader-lock-compat

Conversation

@AgentRelayBot

@AgentRelayBot AgentRelayBot commented Sep 25, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Three findings were still unresolved when #552 merged (d401cb8c, merge 78cc5556). This is the minimal fix for all three, in shipped code paths the native Babysitter artifact depends on.

  1. Mutable Surface-root loader (Codex P1, hosted-extension-sandbox.ts:254). resolveSurfaceRoot called createRequire(import.meta.url).resolve('@relayflows/surface') at dispatch time. require.resolve goes through Module._resolveFilename, which is writable, so authored flow code loaded earlier in the host process could point the sandbox at a different package. The entry is now resolved once while the module initializes, before any authored module can load. A failure is still reported when a caller needs the root.
  2. Unvalidated hosted lock name (Cursor, hosted-extension-declarations.ts). The hosted lock parser pattern-checked source.owner, source.repo, source.sha and source.path, but accepted any string for name — and pluginStoreDirectory joins that name into the store path as ${name}@sha256:${digest}. A lock naming ../escape resolved outside the plugin store. name must now be one lowercase kebab-case path component.
  3. compat.base last-vs-first match (Cursor, flow-extension-compat.ts). The intrinsic-capture rewrite in feat(sdk): isolate hosted Babysitter capability #552 replaced .find() with a loop that has no exit, so the last duplicate name decided the version range. First-match semantics are restored, matching the documented behaviour. validateFlowExtensionManifest rejects duplicate base names, so this is defence in depth for callers that build a manifest another way.

Evidence (evidence/hosted-hardening/)

  • tests.txt: npm run typecheck, npm run build, npm run typecheck:tests all Exit: 0. New suite 11/11.
  • mutation.txt: each fix reverted, the specific test captured failing, the file restored byte-for-byte (sha256 printed before and after), and the test captured passing again.
    • Reverting fix 1 makes the capture test fail with the resolved path pointing into the hostile temp directory — the poisoned resolver being followed.
    • Reverting fix 2 fails all six traversal names, including ../escape, a/b, . and ...
    • Reverting fix 3 fails the first-match assertion.
  • The wider hosted suites report 34 failures on this macOS host. All are pre-existing: 33 are hosted extension isolation requires Linux (they need Linux + bubblewrap), and the one deep-equal failure reproduces with every change stashed, captured in tests.txt. Linux CI is the authority.

Scope

No behaviour change for the merged Babysitter artifact: extensions/babysitter is untouched and stays byte-identical to d3ee3b55 (digest bdf2187b…, manifest 5631a06b…). #549's in-process refusal is untouched. BABYSITTER_REF and the pinned base-source hash are unchanged.

🤖 Generated with Claude Code


Note

High Risk
Changes affect hosted-extension sandbox trust boundaries (module resolution), path validation for lock-driven store layout, and extension base compatibility semantics—security-sensitive loader and path handling.

Overview
This PR hardens three hosted-extension / compat paths left open after #552: sandbox Surface resolution, lockfile plugin names, and compat.base matching.

Surface loader: @relayflows/surface is resolved once at hosted-extension-sandbox module load (via SURFACE_ENTRY and capturedSurfaceEntry()), so later tampering with Module._resolveFilename cannot redirect the sandbox; resolveSurfaceRoot uses that cached path instead of resolving at dispatch time.

Lock names: Hosted flows.lock.json plugin name must match lowercase kebab-case (LOCK_NAME); traversal, slashes, ./.., and invalid casing are rejected with plugin_lock_invalid before name is used as a plugin-store path segment.

Base compat: assertBaseCompatible stops at the first compat.base entry with a matching name (restoring pre-rewrite .find() behavior), so duplicate names cannot widen or narrow the version range.

A new hosted-hardening.test.ts suite (11 tests) covers these behaviors; evidence/hosted-hardening/ adds mutation logs (revert each fix → targeted failure → restore) and broader test run notes (macOS Linux-only suite failures called out as pre-existing).

Reviewed by Cursor Bugbot for commit b37e4c7. Bugbot is set up for automated code reviews on this repo. Configure here.


Summary by cubic

Fixes three leftover findings from #552 in packages/sdk: the hosted surface entry resolves before authored code can steer the loader, the hosted lock name is validated as a single path component, and compat.base matching follows documented first-match semantics. extensions/babysitter is untouched and stays byte-identical to the previous release.

Bug Fixes

  • Resolves @relayflows/surface once at module init; require.resolve consults the writable Module._resolveFilename, and resolving lazily let authored flow code swap the package the sandbox loads.
  • Requires a hosted lock name to be one lowercase kebab-case path component; it previously accepted any string and joined it into the plugin store path as ${name}@sha256:${digest}, so a name like ../escape resolved outside the store.
  • Restores first-match compat.base selection; the intrinsic-capture rewrite dropped the loop's exit, so the last duplicate name decided the version range.

Written for commit b37e4c7. Summary will update on new commits.

Review in cubic

Three findings left unresolved when #552 merged.

Resolve the host's @relayflows/surface entry once while
hosted-extension-sandbox.ts initializes. require.resolve consults the
writable Module._resolveFilename, so resolving lazily ran after authored
flow code could replace it and hand the sandbox a different package.

Require a hosted lock's plugin name to be one lowercase kebab-case path
component. It reaches pluginStoreDirectory as a path segment, and only
source.owner/repo/sha/path were pattern-checked, so a name containing
../ resolved outside the plugin store.

Restore first-match selection in assertBaseCompatible. The
intrinsic-capture rewrite dropped the loop's exit, so a later duplicate
compat.base name decided the version range instead of the first.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

Session-Id: 7d116c0d-6756-4905-ace7-1235559066ed
@coderabbitai

coderabbitai Bot commented Sep 25, 2026

Copy link
Copy Markdown

Important

  • 🔍 Trigger review

This repository does not receive automatic reviews because it has fewer than 10 stars.

⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Advanced

Run ID: 5dd2e495-609c-416a-9381-5b142c694ec9


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.

@AgentRelayBot

Copy link
Copy Markdown
Contributor Author

@codex review

Independent exact-head security review requested for b37e4c7fbbc453cafb9878df21f77629d87feaf6. Focus: (1) the Surface entry is captured at module initialization and cannot be steered by a later Module._resolveFilename replacement; (2) hosted lock name can no longer reach pluginStoreDirectory as anything but one safe path component; (3) assertBaseCompatible first-match restoration does not change behaviour for manifests that pass validateFlowExtensionManifest. Evidence, including mutation verification for each fix, is in evidence/hosted-hardening/.

@chatgpt-codex-connector

Copy link
Copy Markdown

To use Codex here, create a Codex account and connect to github.

@khaliqgant
khaliqgant merged commit 69ef897 into main Sep 25, 2026
9 checks passed
@khaliqgant
khaliqgant deleted the fix/hosted-loader-lock-compat branch September 25, 2026 06:57
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants